go-backend-handbook 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/03-concurrency.md CHANGED
@@ -291,3 +291,5 @@ for i := 0; i < 10; i++ {
291
291
  9. goroutine 永远阻塞无法退出,导致内存/句柄上涨。例:向无人接收的无缓冲 channel 发送;`select` 没有 `ctx.Done()` 分支。
292
292
  10. Value 无类型安全、隐式依赖、难追踪;业务参数应显式作为函数参数。
293
293
  </details>
294
+
295
+ > 生产环境里的并发批量加载、信号量、不可变快照和可取消分批处理,见[第 20 章](20-production-patterns.md)。
package/08-api-and-rpc.md CHANGED
@@ -197,3 +197,5 @@ for i := 0; i < 3; i++ {
197
197
  9. 对外/浏览器用 HTTP/JSON;内网高性能强契约用 gRPC;需要流式用 gRPC。
198
198
  10. 鉴权与越权、参数校验、幂等、超时限流、错误码与文档、脱敏、兼容性。
199
199
  </details>
200
+
201
+ > 服务启动、拦截器链和依赖注入的完整骨架,见[第 20 章](20-production-patterns.md)。
@@ -173,3 +173,5 @@ func Trace(next http.Handler) http.Handler {
173
173
  9. 使用本地缓存+默认值继续服务,降级而不是崩溃。
174
174
  10. 指标发现异常接口 → trace 找慢 span → 日志看该 trace 的参数和错误。
175
175
  </details>
176
+
177
+ > 日志、指标、追踪如何一起接入,见[第 20 章](20-production-patterns.md#209-模式七配置日志指标一起设计)。
package/12-testing.md CHANGED
@@ -185,3 +185,5 @@ e2e: { script: [bash scripts/e2e.sh] }
185
185
  9. gofmt、vet、staticcheck、单测(-race)、集成、E2E、构建。
186
186
  10. 能复现该 bug 的回归测试,先红后绿。
187
187
  </details>
188
+
189
+ > 可测试的并发模式与对应测试,见[第 20 章](20-production-patterns.md)。
@@ -0,0 +1,570 @@
1
+ # 第 20 章 从生产代码提取可复用模式(脱敏版)
2
+
3
+ > 目标:把真实服务里反复出现的工程写法,提炼成可以直接搬进自己项目的模式。
4
+ > **本章所有代码都经过脱敏改写**:模块名、包路径、业务名词、接口、表名、域名、账号全部替换为虚构的 `mini-coin` 项目。它们保留的是结构与设计意图,不是任何真实业务实现。
5
+
6
+ ---
7
+
8
+ ## 20.1 为什么不能直接抄生产代码
9
+
10
+ 真实代码里混着四类东西:
11
+
12
+ | 类别 | 例子 | 风险 |
13
+ |---|---|---|
14
+ | 身份标识 | 公司名、团队名、服务名、模块路径 | 泄露组织信息 |
15
+ | 业务语义 | 产品名、业务标签、表名、字段名、状态码 | 泄露业务设计 |
16
+ | 基础设施 | 内网域名、IP、端口、账号、证书 | 被直接利用 |
17
+ | 数据 | 用户 ID、订单号、金额、日志样本 | 隐私和合规问题 |
18
+
19
+ 脱敏不是"把密码换成 `***`",而是**把整段上下文替换成一个自洽的虚构项目**,让代码还能运行、还能讲清楚设计。
20
+
21
+ ---
22
+
23
+ ## 20.2 脱敏方法论
24
+
25
+ 按这个顺序改,改完再扫描一遍:
26
+
27
+ 1. **换模块名**:`module example.com/minicoin`
28
+ 2. **换包路径**:所有 import 改成虚构路径
29
+ 3. **换业务名词**:账户 → `account`,订单 → `recharge_order`,用户 → `uid`
30
+ 4. **换接口**:只保留方法形状,路径和字段全部重写
31
+ 5. **换基础设施**:域名统一 `example.com`,地址统一 `127.0.0.1`
32
+ 6. **换数据**:所有 ID、金额、时间都换成手工构造的样例
33
+ 7. **删掉**:真实日志、监控面板 ID、工单号、告警群、内部文档链接
34
+ 8. **扫描验证**:用关键词 + 正则 + 人工复核
35
+
36
+ ### 扫描命令
37
+
38
+ ```bash
39
+ grep -rInE '公司名|内部域名|内网IP|token|password|secret' .
40
+ grep -rInE '\b(10|172\.(1[6-9]|2[0-9]|3[01])|192\.168)\.[0-9]+\.[0-9]+' .
41
+ grep -rInE '[A-Za-z0-9_-]{32,}' . # 可疑长串,人工确认是不是密钥
42
+ ```
43
+
44
+ 扫描只能发现"你想到的词",所以最后一步永远是**人工通读**。
45
+
46
+ ---
47
+
48
+ ## 20.3 模式一:服务启动与优雅退出
49
+
50
+ 一个生产级 Go 服务启动通常按这个顺序:
51
+
52
+ ```text
53
+ 解析配置 → 初始化日志 → 初始化存储 → 初始化 tracer
54
+ → 初始化下游客户端 → 初始化业务服务
55
+ → 监听端口 → 注册拦截器 → 启动 HTTP 辅助端口
56
+ → 等待信号 → 优雅关闭
57
+ ```
58
+
59
+ 脱敏后的骨架:
60
+
61
+ ```go
62
+ package main
63
+
64
+ import (
65
+ "context"
66
+ "errors"
67
+ "flag"
68
+ "net"
69
+ "net/http"
70
+ "os/signal"
71
+ "syscall"
72
+ "time"
73
+
74
+ "example.com/minicoin/internal/config"
75
+ "example.com/minicoin/internal/logging"
76
+ "example.com/minicoin/internal/service"
77
+ )
78
+
79
+ func main() {
80
+ configPath := flag.String("c", "config/dev.json", "config file path")
81
+ flag.Parse()
82
+
83
+ cfg, err := config.Load(*configPath)
84
+ if err != nil {
85
+ panic(err)
86
+ }
87
+ if err := logging.Init(cfg.Log); err != nil {
88
+ panic(err)
89
+ }
90
+
91
+ svc, err := service.New(cfg)
92
+ if err != nil {
93
+ panic(err)
94
+ }
95
+ defer svc.Close()
96
+
97
+ lis, err := net.Listen("tcp", cfg.GRPCAddr)
98
+ if err != nil {
99
+ panic(err)
100
+ }
101
+
102
+ grpcServer := newGRPCServer(cfg, svc.GRPCAddress())
103
+ httpServer := newAuxHTTPServer(cfg)
104
+
105
+ go func() {
106
+ if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
107
+ logging.Error("aux http server stopped", "err", err)
108
+ }
109
+ }()
110
+
111
+ go func() {
112
+ if err := grpcServer.Serve(lis); err != nil {
113
+ logging.Error("grpc server stopped", "err", err)
114
+ }
115
+ }()
116
+
117
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
118
+ defer stop()
119
+ <-ctx.Done()
120
+
121
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
122
+ defer cancel()
123
+
124
+ // 先停止接收新请求,再关闭辅助 HTTP 服务。
125
+ grpcServer.GracefulStop()
126
+ _ = httpServer.Shutdown(shutdownCtx)
127
+ svc.Close()
128
+ }
129
+ ```
130
+
131
+ 要记住的三件事:
132
+
133
+ 1. **初始化顺序有依赖**:日志要在最前面,业务服务最后;
134
+ 2. **每个资源都要有 `Close`**,用 `defer` 兜底;
135
+ 3. **优雅退出要有超时**:先停止接收新请求,再等在途请求结束,最后强杀。
136
+
137
+ > **JS 类比**:像 `process.on('SIGTERM')` 里先 `server.close()` 再 `process.exit()`。区别是 Go 里连接、协程、后台任务都要自己收尾。
138
+
139
+ ---
140
+
141
+ ## 20.4 模式二:拦截器链
142
+
143
+ gRPC 的拦截器类似 Express 中间件,负责横切关注点:
144
+
145
+ ```go
146
+ server := grpc.NewServer(
147
+ grpc.ChainUnaryInterceptor(
148
+ recoveryInterceptor, // panic 兜底
149
+ metricsInterceptor, // 指标
150
+ tracingInterceptor, // 链路追踪
151
+ loggingInterceptor, // 访问日志
152
+ ),
153
+ )
154
+ ```
155
+
156
+ 顺序很重要:
157
+
158
+ ```text
159
+ 先 recovery(最外层兜底)
160
+ 再 metrics(统计耗时)
161
+ 再 tracing(建立 span)
162
+ 最后 logging(记录业务字段)
163
+ ```
164
+
165
+ **为什么 recovery 放最外层?** 内层中间件 panic 时,外层才能接住。
166
+
167
+ 一个最小 recovery 拦截器:
168
+
169
+ ```go
170
+ func recoveryInterceptor(
171
+ ctx context.Context,
172
+ req any,
173
+ info *grpc.UnaryServerInfo,
174
+ handler grpc.UnaryHandler,
175
+ ) (resp any, err error) {
176
+ defer func() {
177
+ if r := recover(); r != nil {
178
+ logging.Error("panic in handler",
179
+ "method", info.FullMethod,
180
+ "panic", r,
181
+ "stack", string(debug.Stack()),
182
+ )
183
+ err = status.Error(codes.Internal, "internal error")
184
+ }
185
+ }()
186
+ return handler(ctx, req)
187
+ }
188
+ ```
189
+
190
+ **它包不住子协程。** handler 里 `go func(){ ... }()` 的 panic 必须自己 recover,见 20.8。
191
+
192
+ ---
193
+
194
+ ## 20.5 模式三:并发批量加载(保持顺序 + 限制并发)
195
+
196
+ 需求:一次查 30 个账户的汇总,串行太慢,全部并发又怕打爆下游。
197
+
198
+ 错误写法:
199
+
200
+ ```go
201
+ for _, key := range keys {
202
+ go load(key) // 并发无上限
203
+ }
204
+ ```
205
+
206
+ 正确写法在 `code/mini-coin/internal/patterns/batch.go`:
207
+
208
+ ```go
209
+ func LoadBatch[T any](
210
+ ctx context.Context,
211
+ keys []string,
212
+ concurrency int,
213
+ load LoadFunc[T],
214
+ ) ([]T, error) {
215
+ results := make([]T, len(keys))
216
+ errs := make([]error, len(keys))
217
+ sem := make(chan struct{}, concurrency)
218
+
219
+ var wg sync.WaitGroup
220
+ for i, key := range keys {
221
+ i, key := i, key
222
+ wg.Add(1)
223
+ go func() {
224
+ defer wg.Done()
225
+
226
+ select {
227
+ case sem <- struct{}{}:
228
+ defer func() { <-sem }()
229
+ case <-ctx.Done():
230
+ errs[i] = ctx.Err()
231
+ return
232
+ }
233
+
234
+ value, err := load(ctx, key)
235
+ if err != nil {
236
+ errs[i] = err
237
+ return
238
+ }
239
+ results[i] = value
240
+ }()
241
+ }
242
+ wg.Wait()
243
+
244
+ for _, err := range errs {
245
+ if err != nil {
246
+ return nil, err
247
+ }
248
+ }
249
+ return results, nil
250
+ }
251
+ ```
252
+
253
+ 四个关键点:
254
+
255
+ | 设计 | 作用 |
256
+ |---|---|
257
+ | 预分配 `results` | 每个协程写自己的下标,**不需要锁** |
258
+ | 带缓冲 channel 当信号量 | 限制同时访问下游的数量 |
259
+ | `errs` 也预分配 | 多个失败不会互相覆盖 |
260
+ | 结束后按顺序检查 `errs` | 返回"输入顺序下第一个错误",结果可预测 |
261
+
262
+ ### 信号量 vs 固定 worker pool
263
+
264
+ ```text
265
+ 信号量:为每个 key 开一个 goroutine,只有 N 个能往下走
266
+ worker pool:只开 N 个 goroutine,循环从 jobs 取任务
267
+ ```
268
+
269
+ | | 信号量 | worker pool |
270
+ |---|---|---|
271
+ | 协程总数 | 等于任务数 | 固定 N 个 |
272
+ | 内存开销 | 任务多时线性增长 | 恒定 |
273
+ | 实现复杂度 | 简单 | 稍复杂 |
274
+ | 适合 | 几十到几百个任务 | 几万条数据、批量任务 |
275
+
276
+ **任务数量没有上限时,用 worker pool;任务数量有明确上界时,信号量更简单。**
277
+
278
+ ---
279
+
280
+ ## 20.6 模式四:不可变快照替代读写锁
281
+
282
+ 配置、字典、规则列表这类数据:
283
+
284
+ - 读非常频繁;
285
+ - 写很少(定时刷新);
286
+ - 允许短暂最终一致。
287
+
288
+ 如果每次读都加 `RWMutex`,读多时锁竞争仍然存在。更好的方案是**整份快照原子替换**:
289
+
290
+ ```go
291
+ type Snapshot struct {
292
+ Version int64
293
+ Entries map[string]string
294
+ }
295
+
296
+ type SnapshotStore struct {
297
+ value atomic.Value // *Snapshot
298
+ }
299
+
300
+ func (s *SnapshotStore) Load() *Snapshot {
301
+ v, ok := s.value.Load().(*Snapshot)
302
+ if !ok {
303
+ return nil
304
+ }
305
+ return v
306
+ }
307
+
308
+ func (s *SnapshotStore) Store(next *Snapshot) {
309
+ s.value.Store(next)
310
+ }
311
+ ```
312
+
313
+ 刷新流程:
314
+
315
+ ```text
316
+ 查询数据 → 构造完整新快照 → 原子 Store
317
+ ```
318
+
319
+ **铁律:Store 之后绝不能再修改 `Entries`。**
320
+
321
+ 因为读方拿到的就是这份 map,你原地改它,读方会在无锁状态下看到半更新数据,甚至触发并发读写 panic。
322
+
323
+ > **JS 类比**:React/Redux 里"不要原地改 state,构造新对象再替换引用"。思路一样。
324
+
325
+ 失败时怎么办?
326
+
327
+ ```text
328
+ 查询失败 → 不发布半成品,保留旧快照
329
+ 查询为空 → 发布空快照(表示数据确实被清空)
330
+ ```
331
+
332
+ 这两种情况必须区分,否则"下游挂了"会被误判成"数据全没了"。
333
+
334
+ ---
335
+
336
+ ## 20.7 模式五:上下文感知的分批处理
337
+
338
+ 刷数据、迁移、回填,最怕两件事:
339
+
340
+ 1. 一批太大,锁表或主从延迟爆炸;
341
+ 2. 上游取消了,任务还在傻跑。
342
+
343
+ `DrainPages` 把两件事一起解决:
344
+
345
+ ```go
346
+ func DrainPages(
347
+ ctx context.Context,
348
+ delay time.Duration,
349
+ next func(context.Context) (PageResult, error),
350
+ ) error {
351
+ for {
352
+ if err := ctx.Err(); err != nil {
353
+ return err
354
+ }
355
+
356
+ page, err := next(ctx)
357
+ if err != nil {
358
+ return err
359
+ }
360
+ if page.Rows == 0 {
361
+ return nil
362
+ }
363
+
364
+ if delay > 0 {
365
+ timer := time.NewTimer(delay)
366
+ select {
367
+ case <-ctx.Done():
368
+ timer.Stop()
369
+ return ctx.Err()
370
+ case <-timer.C:
371
+ }
372
+ }
373
+ }
374
+ }
375
+ ```
376
+
377
+ 要点:
378
+
379
+ - **每批之前检查 `ctx.Err()`**;
380
+ - **批间等待用 `select` 而不是 `time.Sleep`**,否则取消后还要睡完;
381
+ - **`timer.Stop()`** 防止 timer 泄漏;
382
+ - **`Rows == 0` 是唯一的正常结束条件**。
383
+
384
+ 用的时候:
385
+
386
+ ```go
387
+ err := patterns.DrainPages(ctx, 50*time.Millisecond,
388
+ func(ctx context.Context) (patterns.PageResult, error) {
389
+ return backfillOnePage(ctx, 1000)
390
+ })
391
+ ```
392
+
393
+ ---
394
+
395
+ ## 20.8 模式六:子协程 panic 保护
396
+
397
+ gRPC 的 recovery 拦截器只包住 handler 主调用链:
398
+
399
+ ```go
400
+ func (s *Server) Handler(ctx context.Context, req *pb.Req) (*pb.Resp, error) {
401
+ go func() {
402
+ panic("boom") // ❌ 整个进程退出
403
+ }()
404
+ return &pb.Resp{}, nil
405
+ }
406
+ ```
407
+
408
+ 正确做法:
409
+
410
+ ```go
411
+ func SafeGo(handler PanicHandler, fn func()) {
412
+ go func() {
413
+ defer func() {
414
+ if r := recover(); r != nil {
415
+ handler(r, debug.Stack())
416
+ }
417
+ }()
418
+ fn()
419
+ }()
420
+ }
421
+ ```
422
+
423
+ 使用:
424
+
425
+ ```go
426
+ patterns.SafeGo(func(r any, stack []byte) {
427
+ logging.Error("panic in background goroutine", "panic", r, "stack", string(stack))
428
+ metrics.BackgroundPanicTotal.Inc()
429
+ }, func() {
430
+ doWork()
431
+ })
432
+ ```
433
+
434
+ `recover` 必须写在**发生 panic 的那个 goroutine 的 defer 里**,外层协程 recover 不到内层。
435
+
436
+ ---
437
+
438
+ ## 20.9 模式七:配置、日志、指标一起设计
439
+
440
+ 一个请求出问题时,你需要三样东西:
441
+
442
+ | 工具 | 回答的问题 |
443
+ |---|---|
444
+ | 日志 | 这一次请求发生了什么 |
445
+ | 指标 | 整体是不是变慢了、错多了 |
446
+ | 链路追踪 | 慢在哪一跳、跨了哪些服务 |
447
+
448
+ 最小接入:
449
+
450
+ ```go
451
+ // 日志:结构化,能按字段检索
452
+ logging.Info("recharge created",
453
+ "request_id", requestID,
454
+ "uid", uid,
455
+ "amount", amount,
456
+ "cost_ms", cost.Milliseconds(),
457
+ )
458
+
459
+ // 指标:可聚合、可报警
460
+ rechargeTotal.WithLabelValues("success").Inc()
461
+ rechargeDuration.Observe(cost.Seconds())
462
+
463
+ // 追踪:把 request_id 放进 span,跨服务传递
464
+ span, ctx := opentracing.StartSpanFromContext(ctx, "create_recharge")
465
+ defer span.Finish()
466
+ ```
467
+
468
+ **三者的字段名要对齐**:日志里的 `request_id`、指标标签、trace 的 tag 用同一个名字,排障时才能串起来。
469
+
470
+ ---
471
+
472
+ ## 20.10 模式八:依赖注入,而不是到处 import 单例
473
+
474
+ 不推荐:
475
+
476
+ ```go
477
+ func (s *Service) Recharge(ctx context.Context, req *Req) error {
478
+ // 直接依赖全局变量
479
+ return globalDB.Exec(...)
480
+ }
481
+ ```
482
+
483
+ 推荐:
484
+
485
+ ```go
486
+ type Repository interface {
487
+ CreateOrder(ctx context.Context, order Order) error
488
+ GetAccount(ctx context.Context, uid string) (Account, error)
489
+ }
490
+
491
+ type Service struct {
492
+ repo Repository
493
+ clock Clock
494
+ metrics *Metrics
495
+ }
496
+
497
+ func NewService(repo Repository, clock Clock, metrics *Metrics) *Service {
498
+ return &Service{repo: repo, clock: clock, metrics: metrics}
499
+ }
500
+ ```
501
+
502
+ 好处:
503
+
504
+ - 测试时替换成内存实现,不用连真实数据库;
505
+ - 依赖关系显式,读代码就知道它依赖什么;
506
+ - 不会出现"跑单测还要起一整套中间件"。
507
+
508
+ ---
509
+
510
+ ## 20.11 脱敏后的完整代码在哪
511
+
512
+ 本章的可运行代码在:
513
+
514
+ ```text
515
+ code/mini-coin/internal/patterns/
516
+ ├── batch.go
517
+ ├── snapshot.go
518
+ ├── drain.go
519
+ ├── safego.go
520
+ └── patterns_test.go
521
+ ```
522
+
523
+ 运行:
524
+
525
+ ```bash
526
+ cd code/mini-coin
527
+ go test ./internal/patterns -v
528
+ go test ./... -race # 建议在 CI 或新容器里跑
529
+ ```
530
+
531
+ 这些代码全部是虚构的,但结构直接来自生产实践:信号量限流、预分配槽位、按输入顺序返回错误、不可变快照、可取消的分批处理、子协程 panic 保护。
532
+
533
+ ---
534
+
535
+ ## 20.12 习题
536
+
537
+ ### 🟢 基础题
538
+
539
+ **1.** `LoadBatch` 为什么不需要给 `results` 加锁?
540
+
541
+ **2.** 为什么 `DrainPages` 的批间等待不能直接用 `time.Sleep`?
542
+
543
+ **3.** `Snapshot.Store` 之后为什么不能再修改 `Entries`?
544
+
545
+ ### 🟡 动手题
546
+
547
+ **4.** 给 `LoadBatch` 加一个"并发上限必须 ≥ 1"的校验,并写测试验证 `concurrency = 0` 时的行为。
548
+
549
+ **5.** 把 `DrainPages` 改造成"最多处理 N 页就停"的版本,并补测试。
550
+
551
+ **6.** 用 `httptest` 模拟一个会 panic 的后台任务,验证 `SafeGo` 能捕获 panic 且不影响主进程。
552
+
553
+ ### 🔴 综合题
554
+
555
+ **7.** 用 `LoadBatch` 实现"批量查 100 个账户余额,最多 8 个并发,任一失败返回错误",并写表驱动测试覆盖:全部成功、部分失败、上下文取消三种情况。
556
+
557
+ **8.** 给 `SnapshotStore` 写一个并发测试:一个 goroutine 持续 `Store`,多个 goroutine 持续 `Load`,在 `-race` 下运行应无数据竞争。
558
+
559
+ ---
560
+
561
+ ## 20.13 参考答案要点
562
+
563
+ 1. 每个 goroutine 只写 `results[i]`,下标互不相同,是不同的内存位置,不需要同步。
564
+ 2. `time.Sleep` 无法被取消,上游超时后还要睡完;应该用 `select` 同时监听 `ctx.Done()` 和 timer。
565
+ 3. 读方无锁直接使用 map;原地修改会让读方看到半更新状态,并发读写还会 panic。正确做法是构造新 map 再整体替换。
566
+ 4. 在函数开头把 `concurrency <= 0` 归一到 1,并用测试断言不会死锁、结果正确。
567
+ 5. 增加 `maxPages` 参数,每处理一页递减,到 0 返回 `nil`。
568
+ 6. `SafeGo` 内部 `defer recover`,handler 里 `close(done)`,测试用 `select` 等待。
569
+ 7. 用 `LoadBatch` 包装查询函数;错误顺序按输入下标检查;取消场景用 `context.WithCancel`。
570
+ 8. 写测试时用 `sync.WaitGroup` 起读写协程,在 CI 里跑 `go test -race`。
package/README.md CHANGED
@@ -70,6 +70,7 @@
70
70
  | [17](17-deploy-ops.md) | 部署与运维 | Docker/K8s/Nginx/云/发布回滚 |
71
71
  | [18](18-ai-assisted.md) | AI 辅助后端开发 | 用 AI 提效但不挖坑 |
72
72
  | [19](19-appendix.md) | 附录:容量估算与速查表 | 面试/评审能用的数字 |
73
+ | [20](20-production-patterns.md) | 从生产代码提取可复用模式(脱敏版) | 服务启动、拦截器、批量并发、快照、分批处理、SafeGo |
73
74
 
74
75
  习题与答案:每章末尾有「习题」与「参考答案」。完整代码见 [`code/mini-coin`](code/mini-coin)。
75
76
 
@@ -0,0 +1,79 @@
1
+ // Package patterns 收录生产服务里反复出现的并发、生命周期与发布模式。
2
+ // 本包全部是教学用的虚构代码,不含任何真实业务标识。
3
+ package patterns
4
+
5
+ import (
6
+ "context"
7
+ "fmt"
8
+ "sync"
9
+ )
10
+
11
+ // LoadFunc 定义单个 key 的加载函数。
12
+ type LoadFunc[T any] func(ctx context.Context, key string) (T, error)
13
+
14
+ // LoadBatch 并发加载多个 key,并保持结果与输入顺序一致。
15
+ //
16
+ // - 最多 concurrency 个加载同时执行,用带缓冲 channel 当信号量;
17
+ // - 每个 goroutine 只写自己的下标,不需要加锁;
18
+ // - 所有任务结束后,按输入顺序返回第一个错误。
19
+ //
20
+ // 注意:函数会为每个 key 创建一个 goroutine。key 数量特别大时,
21
+ // 应改用固定 worker pool,而不是只靠信号量限制并发。
22
+ func LoadBatch[T any](
23
+ ctx context.Context,
24
+ keys []string,
25
+ concurrency int,
26
+ load LoadFunc[T],
27
+ ) ([]T, error) {
28
+ results := make([]T, len(keys))
29
+ if len(keys) == 0 {
30
+ return results, nil
31
+ }
32
+ if concurrency <= 0 {
33
+ concurrency = 1
34
+ }
35
+ if concurrency > len(keys) {
36
+ concurrency = len(keys)
37
+ }
38
+
39
+ errs := make([]error, len(keys))
40
+ sem := make(chan struct{}, concurrency)
41
+ var wg sync.WaitGroup
42
+
43
+ for i, key := range keys {
44
+ i, key := i, key
45
+ wg.Add(1)
46
+ go func() {
47
+ defer wg.Done()
48
+
49
+ select {
50
+ case sem <- struct{}{}:
51
+ defer func() { <-sem }()
52
+ case <-ctx.Done():
53
+ errs[i] = ctx.Err()
54
+ return
55
+ }
56
+
57
+ if err := ctx.Err(); err != nil {
58
+ errs[i] = err
59
+ return
60
+ }
61
+
62
+ value, err := load(ctx, key)
63
+ if err != nil {
64
+ errs[i] = fmt.Errorf("load %q: %w", key, err)
65
+ return
66
+ }
67
+ results[i] = value
68
+ }()
69
+ }
70
+
71
+ wg.Wait()
72
+
73
+ for _, err := range errs {
74
+ if err != nil {
75
+ return nil, err
76
+ }
77
+ }
78
+ return results, nil
79
+ }
@@ -0,0 +1,50 @@
1
+ package patterns
2
+
3
+ import (
4
+ "context"
5
+ "time"
6
+ )
7
+
8
+ // PageResult 表示一页的处理结果。
9
+ type PageResult struct {
10
+ Rows int
11
+ }
12
+
13
+ // DrainPages 逐页处理数据,支持上下文取消和批间等待。
14
+ //
15
+ // next 每次读取并处理下一批;返回 Rows == 0 表示处理完成。
16
+ // 每批之间等待 delay,避免持续给数据库和从库复制造成压力。
17
+ func DrainPages(
18
+ ctx context.Context,
19
+ delay time.Duration,
20
+ next func(context.Context) (PageResult, error),
21
+ ) error {
22
+ for {
23
+ if err := ctx.Err(); err != nil {
24
+ return err
25
+ }
26
+
27
+ page, err := next(ctx)
28
+ if err != nil {
29
+ return err
30
+ }
31
+
32
+ if err := ctx.Err(); err != nil {
33
+ return err
34
+ }
35
+ if page.Rows == 0 {
36
+ return nil
37
+ }
38
+ if delay <= 0 {
39
+ continue
40
+ }
41
+
42
+ timer := time.NewTimer(delay)
43
+ select {
44
+ case <-ctx.Done():
45
+ timer.Stop()
46
+ return ctx.Err()
47
+ case <-timer.C:
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,75 @@
1
+ package patterns
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "sync/atomic"
7
+ "testing"
8
+ "time"
9
+ )
10
+
11
+ func TestLoadBatchKeepsOrder(t *testing.T) {
12
+ keys := []string{"c", "a", "b"}
13
+ got, err := LoadBatch(context.Background(), keys, 2, func(_ context.Context, key string) (string, error) {
14
+ return key + "-value", nil
15
+ })
16
+ if err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ want := []string{"c-value", "a-value", "b-value"}
20
+ for i := range want {
21
+ if got[i] != want[i] {
22
+ t.Fatalf("index %d: got %q want %q", i, got[i], want[i])
23
+ }
24
+ }
25
+ }
26
+
27
+ func TestLoadBatchReturnsFirstInputError(t *testing.T) {
28
+ firstErr := errors.New("first")
29
+ secondErr := errors.New("second")
30
+ _, err := LoadBatch(context.Background(), []string{"a", "b"}, 2, func(_ context.Context, key string) (string, error) {
31
+ if key == "a" {
32
+ return "", firstErr
33
+ }
34
+ return "", secondErr
35
+ })
36
+ if !errors.Is(err, firstErr) {
37
+ t.Fatalf("got %v want %v", err, firstErr)
38
+ }
39
+ }
40
+
41
+ func TestDrainPagesCanBeCancelledDuringDelay(t *testing.T) {
42
+ ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
43
+ defer cancel()
44
+ err := DrainPages(ctx, time.Second, func(context.Context) (PageResult, error) {
45
+ return PageResult{Rows: 1}, nil
46
+ })
47
+ if !errors.Is(err, context.DeadlineExceeded) {
48
+ t.Fatalf("got %v want deadline exceeded", err)
49
+ }
50
+ }
51
+
52
+ func TestSnapshotStore(t *testing.T) {
53
+ store := NewSnapshotStore(&Snapshot{Version: 1, Entries: map[string]string{"a": "1"}})
54
+ store.Store(&Snapshot{Version: 2, Entries: map[string]string{"a": "2"}})
55
+ if got := store.Load(); got == nil || got.Version != 2 || got.Entries["a"] != "2" {
56
+ t.Fatalf("unexpected snapshot: %#v", got)
57
+ }
58
+ }
59
+
60
+ func TestSafeGoRecoversPanic(t *testing.T) {
61
+ var called int32
62
+ done := make(chan struct{})
63
+ SafeGo(func(any, []byte) {
64
+ atomic.StoreInt32(&called, 1)
65
+ close(done)
66
+ }, func() { panic("boom") })
67
+ select {
68
+ case <-done:
69
+ case <-time.After(time.Second):
70
+ t.Fatal("panic handler was not called")
71
+ }
72
+ if atomic.LoadInt32(&called) != 1 {
73
+ t.Fatal("handler not called")
74
+ }
75
+ }
@@ -0,0 +1,23 @@
1
+ package patterns
2
+
3
+ import "runtime/debug"
4
+
5
+ // PanicHandler 处理子协程 panic。生产环境应在这里记日志、打点并报警。
6
+ type PanicHandler func(recovered any, stack []byte)
7
+
8
+ // SafeGo 启动一个带 panic 保护的 goroutine。
9
+ //
10
+ // gRPC 的 recovery interceptor 只包住 handler 主调用链,包不住 handler
11
+ // 内部新开的 goroutine,所以子协程必须自己 recover。
12
+ func SafeGo(handler PanicHandler, fn func()) {
13
+ go func() {
14
+ defer func() {
15
+ if r := recover(); r != nil {
16
+ if handler != nil {
17
+ handler(r, debug.Stack())
18
+ }
19
+ }
20
+ }()
21
+ fn()
22
+ }()
23
+ }
@@ -0,0 +1,40 @@
1
+ package patterns
2
+
3
+ import "sync/atomic"
4
+
5
+ // Snapshot 是一份不可变配置快照。
6
+ //
7
+ // 约定:Store 之后绝不能再修改 Entries。刷新方每次构造一份完整的新快照,
8
+ // 然后整体替换指针;读方拿到的永远是一份自洽的数据。
9
+ type Snapshot struct {
10
+ Version int64
11
+ Entries map[string]string
12
+ }
13
+
14
+ // SnapshotStore 用 atomic.Value 发布不可变快照,读路径无锁。
15
+ type SnapshotStore struct {
16
+ value atomic.Value // *Snapshot
17
+ }
18
+
19
+ // NewSnapshotStore 创建快照存储,可以带一份初始快照。
20
+ func NewSnapshotStore(initial *Snapshot) *SnapshotStore {
21
+ s := &SnapshotStore{}
22
+ if initial != nil {
23
+ s.value.Store(initial)
24
+ }
25
+ return s
26
+ }
27
+
28
+ // Load 返回当前快照。没有快照时返回 nil。
29
+ func (s *SnapshotStore) Load() *Snapshot {
30
+ v, ok := s.value.Load().(*Snapshot)
31
+ if !ok {
32
+ return nil
33
+ }
34
+ return v
35
+ }
36
+
37
+ // Store 原子发布一份新快照。调用方必须先构造完整快照,发布后不得再修改。
38
+ func (s *SnapshotStore) Store(next *Snapshot) {
39
+ s.value.Store(next)
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "go-backend-handbook",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Go 后端开发全景讲义:语言、并发、数据库、缓存、消息队列、gRPC、监控告警、测试、性能、安全、部署与完整开发流程",
5
5
  "keywords": [
6
6
  "go",