Go 商业场景训练营
Go 不是“语法少所以简单”。商业项目里真正要掌握的是:接口如何解耦,slice/map/channel 为什么有坑,goroutine 怎么退出,context 为什么必须传,HTTP 服务怎么分层,线上 goroutine 泄漏和慢接口怎么排查。
一句话主线:
Go 的工程能力来自简单语法、显式错误、组合式接口、轻量 goroutine、context 取消传播和标准化工具链。
训练目标
学完本页,你应该能做到:
- 写一个可运行的 Go HTTP API。
- 解释 handler、service、repository 的职责边界。
- 解释 slice、map、interface、goroutine、channel、context 的原理。
- 避免 goroutine 泄漏、channel 死锁、map 并发写崩溃。
- 写表格驱动测试和超时控制。
- 在面试中讲清 Go 的并发模型和工程优势。
商业场景:数据资产查询服务
需求:提供一个资产查询接口,根据资产编码返回资产名称、部门和状态。
flowchart TD
A["HTTP 请求"] --> B["Handler 解析参数"]
B --> C["Service 校验业务规则"]
C --> D["Repository 查询存储"]
D --> E["返回资产对象"]
E --> F["Handler 输出 JSON"]最小可运行 Demo:
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())
}运行:
go run .
curl "http://127.0.0.1:8080/assets?code=DATASET_001"为什么这样分层:
| 层 | 职责 | 不这样会怎样 |
|---|---|---|
| Handler | HTTP 参数、状态码、JSON | 业务和协议耦合 |
| Service | 业务规则、校验、编排 | 逻辑散落,难测试 |
| Repository | 数据访问 | 换数据库时改动大 |
| Interface | 解耦实现 | 测试无法替换假实现 |
slice 原理和扩容
slice 不是数组本身,而是对底层数组的一段描述。
flowchart TD
A["slice 变量"] --> B["array 指针"]
A --> C["len 长度"]
A --> D["cap 容量"]
B --> E["底层数组"]示例:
items := []int{1, 2, 3}
items = append(items, 4)当容量够时,append 直接写入底层数组;容量不够时,会申请更大的底层数组,把旧数据复制过去,再返回新的 slice。所以 append 后必须接收返回值。
错误示例:
func add(items []int) {
items = append(items, 100)
}
func main() {
items := []int{1, 2}
add(items)
// 外部 items 不一定能看到 append 后的新元素
}正确示例:
func add(items []int) []int {
return append(items, 100)
}map 并发写为什么会崩
Go 内置 map 不是并发安全的。多个 goroutine 同时写 map,会破坏内部哈希桶结构,运行时可能直接报 fatal error: concurrent map writes。
flowchart TD
A["goroutine 1 写 map"] --> C["同一个 map 内部桶"]
B["goroutine 2 写 map"] --> C
C --> D["结构被并发修改"]
D --> E["运行时崩溃或数据错乱"]正确做法:
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 很轻量,但不是免费。只启动不退出,最终会泄漏。
flowchart TD
A["启动 goroutine"] --> B{"是否有退出条件"}
B -- "有" --> C["收到 context done 或 channel close"]
C --> D["释放资源并返回"]
B -- "没有" --> E["一直阻塞或循环"]
E --> F["goroutine 泄漏"]带取消的 Worker:
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:
- 请求取消时,下游 goroutine 能及时退出。
- 超时时能停止数据库、HTTP、缓存调用。
- 链路日志可以传递 request id。
- 避免后台任务越积越多。
HTTP 调用必须设置超时
商业服务常见事故:调用外部系统不设超时,外部慢了以后 goroutine 卡住,请求堆积,最终服务不可用。
client := &http.Client{
Timeout: 3 * time.Second,
}
resp, err := client.Get("https://example.com/api")
if err != nil {
return err
}
defer resp.Body.Close()排查流程:
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 常用表格驱动测试覆盖多组输入输出。
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 后不接收返回值 | 数据丢失或看不到新 slice | items = 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 |
| Context | Context |
| Web API | Web API |
| 面试 | Go 面试题 |
