Skip to content

Go 商业场景训练营

Go 不是“语法少所以简单”。商业项目里真正要掌握的是:接口如何解耦,slice/map/channel 为什么有坑,goroutine 怎么退出,context 为什么必须传,HTTP 服务怎么分层,线上 goroutine 泄漏和慢接口怎么排查。

一句话主线:

Go 的工程能力来自简单语法、显式错误、组合式接口、轻量 goroutine、context 取消传播和标准化工具链。

训练目标

学完本页,你应该能做到:

  1. 写一个可运行的 Go HTTP API。
  2. 解释 handler、service、repository 的职责边界。
  3. 解释 slice、map、interface、goroutine、channel、context 的原理。
  4. 避免 goroutine 泄漏、channel 死锁、map 并发写崩溃。
  5. 写表格驱动测试和超时控制。
  6. 在面试中讲清 Go 的并发模型和工程优势。

商业场景:数据资产查询服务

需求:提供一个资产查询接口,根据资产编码返回资产名称、部门和状态。

mermaid
flowchart TD
    A["HTTP 请求"] --> B["Handler 解析参数"]
    B --> C["Service 校验业务规则"]
    C --> D["Repository 查询存储"]
    D --> E["返回资产对象"]
    E --> F["Handler 输出 JSON"]

最小可运行 Demo:

go
package main

import (
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"time"
)

type Asset struct {
	Code      string `json:"code"`
	Name      string `json:"name"`
	OwnerDept string `json:"ownerDept"`
	Status    string `json:"status"`
}

type AssetRepository interface {
	FindByCode(code string) (Asset, error)
}

type MemoryAssetRepository struct {
	data map[string]Asset
}

func (r *MemoryAssetRepository) FindByCode(code string) (Asset, error) {
	asset, ok := r.data[code]
	if !ok {
		return Asset{}, errors.New("asset not found")
	}
	return asset, nil
}

type AssetService struct {
	repo AssetRepository
}

func (s *AssetService) GetAsset(code string) (Asset, error) {
	if code == "" {
		return Asset{}, errors.New("code required")
	}
	return s.repo.FindByCode(code)
}

func main() {
	repo := &MemoryAssetRepository{data: map[string]Asset{
		"DATASET_001": {
			Code: "DATASET_001", Name: "门诊就诊明细", OwnerDept: "信息科", Status: "PUBLISHED",
		},
	}}
	service := &AssetService{repo: repo}

	mux := http.NewServeMux()
	mux.HandleFunc("/assets", func(w http.ResponseWriter, r *http.Request) {
		code := r.URL.Query().Get("code")
		asset, err := service.GetAsset(code)
		if err != nil {
			http.Error(w, err.Error(), http.StatusBadRequest)
			return
		}
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(asset)
	})

	server := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 3 * time.Second,
	}

	log.Println("server started at :8080")
	log.Fatal(server.ListenAndServe())
}

运行:

bash
go run .
curl "http://127.0.0.1:8080/assets?code=DATASET_001"

为什么这样分层:

职责不这样会怎样
HandlerHTTP 参数、状态码、JSON业务和协议耦合
Service业务规则、校验、编排逻辑散落,难测试
Repository数据访问换数据库时改动大
Interface解耦实现测试无法替换假实现

slice 原理和扩容

slice 不是数组本身,而是对底层数组的一段描述。

mermaid
flowchart TD
    A["slice 变量"] --> B["array 指针"]
    A --> C["len 长度"]
    A --> D["cap 容量"]
    B --> E["底层数组"]

示例:

go
items := []int{1, 2, 3}
items = append(items, 4)

当容量够时,append 直接写入底层数组;容量不够时,会申请更大的底层数组,把旧数据复制过去,再返回新的 slice。所以 append 后必须接收返回值。

错误示例:

go
func add(items []int) {
	items = append(items, 100)
}

func main() {
	items := []int{1, 2}
	add(items)
	// 外部 items 不一定能看到 append 后的新元素
}

正确示例:

go
func add(items []int) []int {
	return append(items, 100)
}

map 并发写为什么会崩

Go 内置 map 不是并发安全的。多个 goroutine 同时写 map,会破坏内部哈希桶结构,运行时可能直接报 fatal error: concurrent map writes

mermaid
flowchart TD
    A["goroutine 1 写 map"] --> C["同一个 map 内部桶"]
    B["goroutine 2 写 map"] --> C
    C --> D["结构被并发修改"]
    D --> E["运行时崩溃或数据错乱"]

正确做法:

go
type SafeCounter struct {
	mu sync.Mutex
	m  map[string]int
}

func (c *SafeCounter) Add(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key]++
}

读多写少也可以考虑 sync.RWMutex,特殊场景可以用 sync.Map,但不要把 sync.Map 当作所有 map 的默认替代。

goroutine、channel 和退出

goroutine 很轻量,但不是免费。只启动不退出,最终会泄漏。

mermaid
flowchart TD
    A["启动 goroutine"] --> B{"是否有退出条件"}
    B -- "有" --> C["收到 context done 或 channel close"]
    C --> D["释放资源并返回"]
    B -- "没有" --> E["一直阻塞或循环"]
    E --> F["goroutine 泄漏"]

带取消的 Worker:

go
func worker(ctx context.Context, jobs <-chan string) {
	for {
		select {
		case <-ctx.Done():
			return
		case job, ok := <-jobs:
			if !ok {
				return
			}
			log.Println("handle job", job)
		}
	}
}

为什么要用 context

  1. 请求取消时,下游 goroutine 能及时退出。
  2. 超时时能停止数据库、HTTP、缓存调用。
  3. 链路日志可以传递 request id。
  4. 避免后台任务越积越多。

HTTP 调用必须设置超时

商业服务常见事故:调用外部系统不设超时,外部慢了以后 goroutine 卡住,请求堆积,最终服务不可用。

go
client := &http.Client{
	Timeout: 3 * time.Second,
}

resp, err := client.Get("https://example.com/api")
if err != nil {
	return err
}
defer resp.Body.Close()

排查流程:

mermaid
flowchart TD
    A["Go 服务响应慢"] --> B["看接口耗时和错误率"]
    B --> C{"goroutine 数是否持续增长"}
    C -- "是" --> D["抓 pprof goroutine"]
    D --> E["看阻塞在 HTTP、DB、channel 还是锁"]
    C -- "否" --> F["看 CPU、内存、GC、下游耗时"]
    E --> G["补超时、取消、限流或关闭 channel"]
    F --> H["优化热点函数、SQL 或缓存"]

表格驱动测试

Go 常用表格驱动测试覆盖多组输入输出。

go
func TestAssetService_GetAsset(t *testing.T) {
	repo := &MemoryAssetRepository{data: map[string]Asset{
		"A001": {Code: "A001", Name: "资产1"},
	}}
	service := &AssetService{repo: repo}

	tests := []struct {
		name    string
		code    string
		wantErr bool
	}{
		{name: "found", code: "A001", wantErr: false},
		{name: "empty code", code: "", wantErr: true},
		{name: "not found", code: "A404", wantErr: true},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			_, err := service.GetAsset(tt.code)
			if (err != nil) != tt.wantErr {
				t.Fatalf("wantErr=%v err=%v", tt.wantErr, err)
			}
		})
	}
}

常见坑

后果正确做法
append 后不接收返回值数据丢失或看不到新 sliceitems = append(items, x)
并发写 map程序崩溃加锁、用 channel 串行化或 sync.Map
goroutine 无退出条件泄漏,内存和调度压力增长使用 context 或关闭 channel
channel 只发不收死锁或阻塞明确生产者、消费者和关闭方
HTTP/DB 不设超时请求堆积context timeout 和 client timeout
接口设计过大实现难、耦合高小接口,谁使用谁定义

面试标准回答

goroutine 为什么轻量?

goroutine 由 Go 运行时调度,初始栈较小,可以按需增长,运行时用 GMP 模型把大量 goroutine 调度到少量操作系统线程上执行。它比直接创建大量 OS 线程成本低,但仍然要有退出条件,否则会泄漏。

channel 是什么?

channel 是 goroutine 之间通信和同步的管道。无缓冲 channel 发送和接收必须同时准备好,适合同步交接;有缓冲 channel 可以暂存一定数量元素,适合削峰。channel 要明确关闭方,通常由发送方关闭。

Go interface 为什么常说是隐式实现?

Go 类型不需要显式声明实现某个接口,只要方法集合满足接口,就自动实现。这降低了耦合,适合在调用方定义小接口,用假实现做测试。

关联知识点

知识点继续学习
Go 主线Go 从零到生产级掌握
集合集合
结构体接口结构体与接口
并发goroutine 与 channel
ContextContext
Web APIWeb API
面试Go 面试题