go-backend-handbook 1.0.0 → 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/00-linux.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  > 目标:能连上远程开发机、看懂机器状态、出问题能自己排查第一层。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 远程 Linux shell | 本机终端,只是换了台机器 | 代码、进程、依赖、环境变量全在远端 |
10
+ > | 进程 | `node app.js` 启动的进程 | 后端进程长期驻留,要管 PID、信号、日志轮转 |
11
+ > | systemd | `pm2` 的自动重启 | 系统级守护,开机自启、崩溃拉起 |
12
+ > | top / htop | Chrome 任务管理器 | 看整机 CPU/内存/磁盘 |
13
+ > | 磁盘满 | `node_modules` 占满硬盘 | 后端会导致日志写失败、DB 异常 |
14
+ > | 没有对应物 | — | nginx / Docker / k8s 是纯后端概念,不用硬套 |
15
+
16
+
5
17
  ## 0.1 为什么后端要有一台"开发机"
6
18
 
7
19
  前端本地跑个 dev server 就够了;后端不行,因为依赖太多:
package/01-go-basics.md CHANGED
@@ -3,6 +3,9 @@
3
3
  > 目标:会 JS 的人,2 小时能读懂 Go 业务代码,1 周能写。
4
4
  > 重点不是穷举语法,而是**JS 有但 Go 没有的东西**,以及**Go 有而 JS 没有的东西**。
5
5
 
6
+ > **JS 类比使用说明**:本章类比最多,但请始终记住——模块/包、对象/struct、数组/slice、异常/error、Promise/goroutine、AbortController/context 都只是**相似的抓手**,不是等价物。Go 有编译期类型检查、固定零值、显式错误返回和真正的多核并发,这些是 JS 没有的。
7
+
8
+
6
9
  ## 1.1 先建立一个心智模型
7
10
 
8
11
  | JS | Go |
@@ -2,6 +2,18 @@
2
2
 
3
3
  > 目标:拿到一个陌生后端项目,能在 20 分钟内从入口追到一个请求的落库路径。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | main 入口 | `index.js` 的 bootstrap | 负责装配依赖,一个进程一个 main |
10
+ > | handler | Express / Koa route handler | 只做协议层,不含业务规则 |
11
+ > | service | 前端 store / service 模块 | 多请求并发执行,共享进程内存 |
12
+ > | repository | 封装 `fetch` 的 api client | 这里封装 SQL / 缓存 |
13
+ > | proto / OpenAPI | TypeScript 类型 + 接口契约 | 同时约束服务端和客户端 |
14
+ > | 依赖注入 | 构造函数传依赖 | 便于测试替换 |
15
+
16
+
5
17
  ## 2.1 分层不是形式主义
6
18
 
7
19
  ```
package/03-concurrency.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  > 目标:理解 Go 的并发模型,能写出"不漏、不重、不崩"的并发代码。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | goroutine | `Promise` / 异步任务 | 由 Go 运行时调度,可真并行 |
10
+ > | `go f()` | 不 `await` 的调用 | 主流程不等待,但协程仍执行 |
11
+ > | channel | `postMessage` / AsyncIterator | 有缓冲、阻塞、关闭语义 |
12
+ > | `select` | `Promise.race` | 同时等多个 channel,可配超时 |
13
+ > | WaitGroup | `Promise.all` | 只等结束,不收返回值 |
14
+ > | context | `AbortController` / `AbortSignal` | 显式沿调用链传递 |
15
+ > | Mutex | 无直接等价物 | JS 主线程默认没有共享内存竞争 |
16
+ > | worker pool | `p-limit` | 固定协程 + channel |
17
+ > | panic | 未捕获异常 | 不 recover 会让整个进程退出 |
18
+
19
+
5
20
  ## 3.1 进程、线程、协程
6
21
 
7
22
  | 概念 | 是什么 | 开销 |
@@ -276,3 +291,5 @@ for i := 0; i < 10; i++ {
276
291
  9. goroutine 永远阻塞无法退出,导致内存/句柄上涨。例:向无人接收的无缓冲 channel 发送;`select` 没有 `ctx.Done()` 分支。
277
292
  10. Value 无类型安全、隐式依赖、难追踪;业务参数应显式作为函数参数。
278
293
  </details>
294
+
295
+ > 生产环境里的并发批量加载、信号量、不可变快照和可取消分批处理,见[第 20 章](20-production-patterns.md)。
package/04-mysql.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  > 目标:能独立完成建表、建索引、写查询、加字段、改类型、数据迁移、刷数据,并知道容量边界在哪。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 表 | 一类对象的数组 | 有 schema、约束、主键 |
10
+ > | 行 | 数组里的对象 | 字段类型固定,有 NULL 语义 |
11
+ > | 索引 | 额外建的查找表(类似 Map) | 占空间、拖慢写入 |
12
+ > | 事务 | IndexedDB transaction 有点像 | SQL 还有隔离级别、锁、死锁 |
13
+ > | JOIN | 按 id 手动 merge 两个数组 | 数据库靠执行计划完成 |
14
+ > | 迁移 | schema 的版本提交 | DDL 可能锁表 |
15
+
16
+
5
17
  ## 4.1 先建三张表
6
18
 
7
19
  ```sql
@@ -2,6 +2,18 @@
2
2
 
3
3
  > 目标:知道每类数据库解决什么问题,能在评审会上说出"为什么选它、不选它"。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | MySQL / PostgreSQL | 关系型数据库,像有严格 schema 的对象表 | SQL、事务、约束是核心 |
10
+ > | MongoDB | 文档库,数据形态接近 JS 对象 | 但查询、索引、一致性、部署都不同,不是"存 JSON" |
11
+ > | Redis | 跨进程共享的 `Map` + TTL | 不是 localStorage |
12
+ > | ClickHouse | 列式分析库 | 适合聚合分析,不适合高频单行更新 |
13
+ > | Elasticsearch | 搜索索引 | 不是主存储,最终一致 |
14
+ > | 选型 | 没有银弹 | 按读写模式、一致性、规模、团队成本选 |
15
+
16
+
5
17
  ## 5.1 选型先问五个问题
6
18
 
7
19
  1. **数据模型**:强关系(订单-支付-退款)还是文档/键值/时序/图?
package/06-redis.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:会用 Redis 做缓存、锁、限流、计数,并能处理穿透/击穿/雪崩、大 key、热 key。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | Redis | 跨进程共享的 `Map`,可设 TTL | 不是 localStorage:服务端共享、有淘汰策略 |
10
+ > | 缓存 | React Query / SWR 的缓存 | 所有用户共享,key 设计错会串数据 |
11
+ > | 分布式锁 | 多个标签页共享一把锁 | 跨机器、靠 TTL + 唯一 token |
12
+ > | 限流 | 前端按钮防抖 | 服务端限流保护整个系统 |
13
+ > | 大 key | 一个巨大 JSON 塞进一个 key | 单线程执行命令,大 value 会卡住所有请求 |
14
+
15
+
5
16
  ## 6.1 数据结构与典型用途
6
17
 
7
18
  | 结构 | 命令示例 | 用途 |
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:理解 MQ 的三种价值(解耦、异步、削峰),能设计不丢、不重、可追踪的消费链路。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | MQ | 后端事件总线,类似 `EventTarget` | 持久化、跨进程、可重放 |
10
+ > | producer | `dispatchEvent` | 可能失败、重复、乱序 |
11
+ > | consumer | `addEventListener` | 多实例、offset/ack、失败重投 |
12
+ > | topic | 事件类型频道 | 有分区、顺序、保留策略 |
13
+ > | 幂等 | 前端防重复提交 | 服务端消费端必须自己保证 |
14
+
15
+
5
16
  ## 7.1 为什么需要 MQ
6
17
 
7
18
  支付成功后要做 5 件事:更新订单、发短信、发券、上报、通知风控。
package/08-api-and-rpc.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:设计一个让前端好用、三年后还能兼容、出问题能定位的接口。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | HTTP 接口 | 你平时调的 REST API | 契约、版本、错误码都要稳定 |
10
+ > | gRPC / proto | TypeScript interface + 生成 SDK | HTTP/2 + protobuf 二进制 |
11
+ > | interceptor | Express middleware | 可做鉴权、日志、超时 |
12
+ > | 超时 / 重试 | `fetch` 的 AbortSignal + 重试 | 必须退避、有上限、只重试幂等操作 |
13
+
14
+
5
15
  ## 8.1 HTTP 接口设计要点
6
16
 
7
17
  ### URL 用名词,动作用方法
@@ -187,3 +197,5 @@ for i := 0; i < 3; i++ {
187
197
  9. 对外/浏览器用 HTTP/JSON;内网高性能强契约用 gRPC;需要流式用 gRPC。
188
198
  10. 鉴权与越权、参数校验、幂等、超时限流、错误码与文档、脱敏、兼容性。
189
199
  </details>
200
+
201
+ > 服务启动、拦截器链和依赖注入的完整骨架,见[第 20 章](20-production-patterns.md)。
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:线上出问题时,能在 5 分钟内回答"哪个请求、哪一步、为什么慢/失败"。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 配置 | `.env` / `import.meta.env` | 多环境、可热更新 |
10
+ > | 结构化日志 | `console.log({ ... })` | 可采集、检索、聚合、报警 |
11
+ > | request id | Network 面板的请求标识 | 要跨服务透传 |
12
+ > | trace | Network waterfall | 跨服务、跨进程 |
13
+ > | metrics | 前端埋点 / Performance API | 聚合指标,不是单条日志 |
14
+
15
+
5
16
  ## 9.1 配置管理
6
17
 
7
18
  ### 分层
@@ -162,3 +173,5 @@ func Trace(next http.Handler) http.Handler {
162
173
  9. 使用本地缓存+默认值继续服务,降级而不是崩溃。
163
174
  10. 指标发现异常接口 → trace 找慢 span → 日志看该 trace 的参数和错误。
164
175
  </details>
176
+
177
+ > 日志、指标、追踪如何一起接入,见[第 20 章](20-production-patterns.md#209-模式七配置日志指标一起设计)。
package/10-resilience.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:流量翻 10 倍、下游挂掉时,服务还能活着,核心功能还能用。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | QPS | 每秒 `fetch` 数量 | 服务端是所有客户端之和 |
10
+ > | 限流 | 按钮节流 | 服务端限流是全局的 |
11
+ > | 熔断 | 失败后暂停重试 | 按下游统计,半开试探 |
12
+ > | 降级 | 兜底数据 / 隐藏入口 | 要有明确业务策略 |
13
+ > | 重试 | `fetch` 重试 | 退避 + 上限 + 幂等 |
14
+
15
+
5
16
  ## 10.1 先厘清几个概念
6
17
 
7
18
  | 概念 | 作用 | 类比 |
package/11-monitoring.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:搭出一套"出问题 1 分钟内知道、5 分钟内定位"的监控。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | Prometheus | 时序数据库 + 查询语言 | 拉模式采集,PromQL 按窗口计算 |
10
+ > | Grafana | 图表看板 | 只负责展示 |
11
+ > | 报警 | 前端错误监控告警 | 阈值 + 持续时间 + 分级 + 静默 |
12
+ > | SLI / SLO | Core Web Vitals 目标 | 按成功率/延迟/饱和度定义 |
13
+
14
+
5
15
  ## 11.1 监控四层
6
16
 
7
17
  | 层 | 指标 |
package/12-testing.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:用最低成本获得最高信心。测试不是"写完补",是设计的一部分。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | `go test` | Vitest / Jest | 编译执行、可并行、表驱动 |
10
+ > | 表驱动 | `test.each` | 用结构体切片描述用例 |
11
+ > | mock | `vi.mock` / `jest.mock` | Go 常用接口注入 |
12
+ > | 集成测试 | msw 但连真依赖 | miniredis / testcontainers |
13
+ > | `-race` | 无直接等价物 | 检测共享内存数据竞争 |
14
+
15
+
5
16
  ## 12.1 测试金字塔
6
17
 
7
18
  ```
@@ -174,3 +185,5 @@ e2e: { script: [bash scripts/e2e.sh] }
174
185
  9. gofmt、vet、staticcheck、单测(-race)、集成、E2E、构建。
175
186
  10. 能复现该 bug 的回归测试,先红后绿。
176
187
  </details>
188
+
189
+ > 可测试的并发模式与对应测试,见[第 20 章](20-production-patterns.md)。
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:能量化性能、找到瓶颈、复现并修掉线上 bug。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | k6 | 就是 JS 脚本 | 不代表被测服务是 JS |
10
+ > | pprof | Chrome Performance | CPU / heap / goroutine |
11
+ > | 火焰图 | flame chart | 按调用栈聚合 |
12
+ > | dlv | DevTools 断点 | 常连远程进程 |
13
+
14
+
5
15
  ## 13.1 压测基本概念
6
16
 
7
17
  | 指标 | 含义 |
package/14-security.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:不写出能被一条 curl 打穿的接口。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 鉴权 | 登录态 / token | 服务端必须重新校验 |
10
+ > | XSS | 渲染转义 | 后端按上下文输出转义 |
11
+ > | CSRF | 自动带 cookie 的风险 | SameSite / CSRF token |
12
+ > | SQL 注入 | 不要拼 SQL | 类比不要 `eval` 用户输入 |
13
+ > | 签名 | 请求签名 | 防重放要时间戳 + nonce |
14
+
15
+
5
16
  ## 14.1 认证 vs 授权
6
17
 
7
18
  - **认证 Authentication**:你是谁(登录、token、证书);
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:知道一个需求从提出到上线,后端要做哪些动作、每个动作产出什么。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 需求评审 | 前端需求评审 + 技术方案 | 后端多容量、兼容、回滚 |
10
+ > | 分支 + CI | PR + GitHub Actions | 还要迁移和部署证据 |
11
+ > | 灰度 | 前端灰度 / CDN 回滚 | 后端要考虑数据兼容 |
12
+ > | 复盘 | 故障复盘 | 时间线、影响面、根因、改进项 |
13
+
14
+
5
15
  ## 15.1 全流程
6
16
 
7
17
  ```
package/16-api-docs.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  > 目标:写出前端不用问人就能对接的接口文档,并且自己能在提测前把接口验证一遍。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | 接口文档 | 你平时看的 YApi / OpenAPI | 必须和实现同步 |
10
+ > | curl | Copy as cURL | 能看到原始 header / 状态码 / 耗时 |
11
+ > | Postman | 图形化 fetch + 断言 | 手工回归和集合运行 |
12
+ > | grpcurl | 命令行版 Postman | 针对 gRPC |
13
+ > | Mock | 前端 mock 数据 | 前后端并行开发 |
14
+
15
+
5
16
  ## 16.1 接口文档由谁写、什么时候写
6
17
 
7
18
  **后端写,和代码一起提交,评审时一起看。**
package/17-deploy-ops.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:能把自己的 Go 服务打包、部署、暴露、扩容、回滚,并知道运维该学到什么程度。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | Docker | 服务 + 依赖的可复制运行环境 | 比 lockfile + Node 版本更彻底 |
10
+ > | nginx | 反向代理 / 静态服务器 | 还要管超时、限流、TLS |
11
+ > | k8s | 没有 JS 对应物 | 容器编排,别硬类比 |
12
+ > | 微服务 | 微前端有点像 | 网络调用和数据一致性完全不同 |
13
+
14
+
5
15
  ## 17.1 一次发布要解决什么
6
16
 
7
17
  ```
package/18-ai-assisted.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  > 目标:把 AI 当"结对程序员 + 代码检索器 + 测试生成器",而不是"代写一切的黑盒"。
4
4
 
5
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
6
+ >
7
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
8
+ > |---|---|---|
9
+ > | AI 写代码 | Copilot / Claude Code | 要给足上下文和约束 |
10
+ > | AI 读代码库 | 把相关文件喂给模型 | 先定位、再改、再验证 |
11
+ > | AI 写测试 | 生成表驱动用例 | 边界条件必须人审 |
12
+ > | AI 排障 | 分析日志和堆栈 | 看不到线上真实状态 |
13
+
14
+
5
15
  ## 18.1 AI 最擅长什么
6
16
 
7
17
  | 场景 | 效果 |
package/19-appendix.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # 第 19 章 附录:容量估算、速查表与知识地图
2
2
 
3
+ > **JS 类比速查**(帮助建立直觉,不表示语义等价;差异列必须看)
4
+ >
5
+ > | 后端概念 | JS / 前端里的近似物 | 关键差异 |
6
+ > |---|---|---|
7
+ > | 容量估算 | 前端性能预算 | 后端按 QPS、延迟、存储、增长估算 |
8
+ > | 排障决策树 | 浏览器 Problems 面板的思路 | 后端要按指标→日志→trace 逐层缩小 |
9
+ > | 知识地图 | 前端技能树 | 后端多了数据、网络、运维、稳定性 |
10
+
11
+
3
12
  ## 19.1 容量估算模板
4
13
 
5
14
  ```
@@ -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
@@ -7,6 +7,31 @@
7
7
 
8
8
  ---
9
9
 
10
+ ## JS 类比怎么用(先读这一节)
11
+
12
+ 你会 JS,这是优势,也是陷阱。本讲义在**两者真有相似概念**时会用 JS 打比方,帮你快速建立直觉:
13
+
14
+ | 后端概念 | 可以类比的 JS / 前端概念 | 必须记住的差异 |
15
+ |---|---|---|
16
+ | 包 / 模块 | npm 包、ES Module | Go 按 import path 引用,首字母大写才导出 |
17
+ | struct | 固定形状的对象 | 有编译期类型和零值,不是任意 JSON |
18
+ | slice | 数组 | 带 len/capacity,可能共享底层数组 |
19
+ | map | `Map` / 对象 | 遍历顺序不保证 |
20
+ | error 返回值 | `throw` / `catch` | Go 把预期错误作为返回值显式处理 |
21
+ | `defer` | `try/finally` | 函数退出时执行,多个按 LIFO |
22
+ | interface | TypeScript interface / 鸭子类型 | Go 是结构化满足,接口通常很小 |
23
+ | goroutine | `Promise` / async 任务 | 真并行,不是单线程事件循环 |
24
+ | context | `AbortController` / `AbortSignal` | 显式沿调用链传递 |
25
+ | 拦截器 | Express / Koa middleware | gRPC 的响应和错误语义不同 |
26
+ | 链路追踪 | Network waterfall | 跨服务、跨进程传播 |
27
+ | `go test` | Vitest / Jest | 编译执行、表驱动、`-race` |
28
+ | k6 压测脚本 | 就是 JS 脚本 | 脚本语言不代表被测服务是 JS |
29
+ | proto / OpenAPI | TypeScript 类型 + 接口契约 | 同时生成服务端接口和客户端 SDK |
30
+
31
+ **JS 没有对应物的,不硬套**:指针、数据竞争、互斥锁、SQL 事务隔离级别、Docker、Kubernetes、集群、容量规划、数据库迁移的锁表风险。这些章节直接讲后端模型,类比只用来降低入门门槛,**不能替代对语义的理解**。
32
+
33
+ ---
34
+
10
35
  ## 这套讲义解决什么问题
11
36
 
12
37
  前端转后端最常见的断层不是语法,而是**系统观**:
@@ -45,6 +70,7 @@
45
70
  | [17](17-deploy-ops.md) | 部署与运维 | Docker/K8s/Nginx/云/发布回滚 |
46
71
  | [18](18-ai-assisted.md) | AI 辅助后端开发 | 用 AI 提效但不挖坑 |
47
72
  | [19](19-appendix.md) | 附录:容量估算与速查表 | 面试/评审能用的数字 |
73
+ | [20](20-production-patterns.md) | 从生产代码提取可复用模式(脱敏版) | 服务启动、拦截器、批量并发、快照、分批处理、SafeGo |
48
74
 
49
75
  习题与答案:每章末尾有「习题」与「参考答案」。完整代码见 [`code/mini-coin`](code/mini-coin)。
50
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,8 +1,19 @@
1
1
  {
2
2
  "name": "go-backend-handbook",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Go 后端开发全景讲义:语言、并发、数据库、缓存、消息队列、gRPC、监控告警、测试、性能、安全、部署与完整开发流程",
5
- "keywords": ["go", "golang", "backend", "handbook", "tutorial", "mysql", "redis", "kafka", "grpc", "kubernetes"],
5
+ "keywords": [
6
+ "go",
7
+ "golang",
8
+ "backend",
9
+ "handbook",
10
+ "tutorial",
11
+ "mysql",
12
+ "redis",
13
+ "kafka",
14
+ "grpc",
15
+ "kubernetes"
16
+ ],
6
17
  "license": "MIT",
7
18
  "files": [
8
19
  "*.md",