Go 服务为什么“像会自动扩协程”,以及真正的风险点
结合 Kratos +net/http+ gRPC-Go +database/sql连接池,说明请求高峰时 goroutine 如何增长、什么在限制它、以及线上真正该盯什么。
一句话结论
不是框架实现了“按 QPS 动态调协程池”,而是 Go 默认模型:一个并发请求(连接/流)对应一个 handler goroutine,请求结束就退出。
所以并发上来时,goroutine 数会跟着涨;流量下去后,这些 handler 退出,数量自然回落。
真正会“卡住上限”的,往往是 DB 连接池、超时、CPU(GOMAXPROCS)、以及有没有进程级并发保护,而不是“协程池满了”。
1. 先分清三个词:QPS、并发、goroutine
很多人把“请求量上来 → 自动扩协程”理解成:
QPS 翻倍 ⇒ 协程池 size 翻倍更准确的公式是:
并发量 ≈ QPS × 平均耗时例子:
| QPS | 平均耗时 | 大约同时在跑的 handler |
|---|---|---|
| 1000 | 20ms | ~20 |
| 1000 | 2s | ~2000 |
所以:
- QPS 高但很快返回 → goroutine 未必很多
- QPS 一般但接口变慢 → goroutine 会暴涨
“自动扩容”扩的是 同时处理中的请求数对应的 goroutine,不是按 QPS 配一张表。
2. 为什么能“自动扩”:HTTP 路径
Kratos HTTP Server 底层包的是标准库 net/http.Server。
核心行为(简化):
Accept 一个连接
→ go c.serve(...) // 每个连接一条服务 goroutine
→ 读请求、调用 Handler
→ 请求结束,该 goroutine 生命周期结束(连接复用时继续读下一个请求)HTTP/1.1 keep-alive 下,常见是 每连接一条 goroutine,串行处理该连接上的请求。
HTTP/2 下,同一连接上的多个 stream 可以并发处理,并发流多了,活跃 handler 也会更多。
对本服务(earn)而言:
internal/server/http.go用的是 Kratostransport/http- 没有在业务侧挂“固定 N 个 worker 的协程池”
- 也没有在 server 层看到进程级
MaxConcurrency/ semaphore
因此:并发请求进来,runtime 就会为处理路径创建/占用更多 goroutine;没有业务自定义的“先排队再进固定 worker”这一层。
3. gRPC 路径也一样(当前配置下)
gRPC-Go 默认也是:
每个 RPC stream → 起一个 handler goroutine可选能力 grpc.NumStreamWorkers(n) 才会改成“固定 worker + channel 派发”。 n == 0(默认)表示关闭 worker 池,仍然是 每 stream 一个 goroutine。
本仓库 internal/server/grpc.go 目前只配了地址、超时、中间件,没有配置:
NumStreamWorkersMaxConcurrentStreams(业务显式限制)
所以 gRPC 侧同样是:并发 RPC 上来 → handler goroutine 跟着涨。
4. automaxprocs 管的不是协程数
项目入口有:
_ "go.uber.org/automaxprocs"它做的是:根据容器 CPU 配额,把 GOMAXPROCS 设成合理值。
这解决的是:
Pod 只分到 2 核,但宿主机 64 核时,
默认 GOMAXPROCS=64 导致调度抖动 / 过度并行它 不 做这些事:
- 不限制 goroutine 数量
- 不按 QPS 扩缩 worker
- 不替代 HPA(水平扩 Pod)
记住:
GOMAXPROCS ≈ 同时能跑 Go 代码的 OS 线程上限(CPU 并行度)
goroutine 数 ≈ 逻辑并发任务数(可以远大于 GOMAXPROCS)goroutine 再多,真正同时跑在 CPU 上的也就大约 GOMAXPROCS 条;其余在等 I/O、锁、channel、调度。
5. 和“DB 连接池自动扩”不要混为一谈
业务读库常见路径:
请求 goroutine
→ r.db(ctx) 拿到 *gorm.DB(连接池句柄)
→ Take / Find 真正执行 SQL
→ database/sql 从池里借连接database/sql 的扩容逻辑是:
- 有空闲连接 → 复用
- 无空闲且
numOpen < MaxOpenConns→ 新建一条物理连接 - 已达
MaxOpenConns→ 等待别人归还,或跟 context 超时一起失败
本项目 PostgreSQL 默认大致是:
MaxOpenConns = 10
MaxIdleConns = 5
ConnMaxLifetime = 30m所以会出现这种错位:
请求侧:几千个 goroutine 都在处理 Subscribe / GetByID
DB 侧:最多 10 条连接在干活
结果:大量 goroutine 堵在等连接,而不是“协程池自动扩到够用”请求并发可以无限涨;DB 并发被池子硬顶死。
这往往是性能悬崖的真正原因。
6. 风险点(比“会不会自动扩”更重要)
风险 1:无进程级并发上限 → 慢请求雪崩
当前 HTTP/gRPC 侧没有看到“同时最多 N 个业务请求”的闸门。
一旦下游变慢(资产中台、PG、Redis):
耗时 ↑ → 并发 ↑ → goroutine ↑ → 内存 ↑ → 更慢 → 更多排队超时(配置里 HTTP/gRPC timeout: 2s)能砍掉一部分拖尾,但:
- 超时前 goroutine 已经占着
- 超时后如果下游仍在跑、或连接未及时释放,压力不会立刻归零
风险 2:DB 池太小,放大排队
10 个 open connection 对 OLTP 常见,但要和:
- Pod 副本数
- 单接口 SQL 次数(N+1)
- 事务持有时间
一起算。
多副本 × 每实例 10 连接,也会打满 PG max_connections。
风险 3:把“goroutine 变多”误当成“吞吐变高”
goroutine 多只说明 在等的活多,不说明 每秒完成的活多。
吞吐真正受限于:
- CPU(
GOMAXPROCS) - DB / Redis / 外部 HTTP
- 锁与串行段(例如按 uid 串行的写路径)
风险 4:业务里裸起的后台 goroutine 不会自动缩
HTTP/gRPC handler 的 goroutine 会随请求结束退出。
但若业务代码写了:
go func() { ... }() // 无退出条件、无 errgroup、无 ctx那是泄漏,跟请求自动模型无关。团队铁律要求:goroutine 必须有退出机制(context / errgroup)。
风险 5:水平扩容(Pod)不是这份代码自带的
仓库内未见 HPA/副本自动伸缩配置时,应明确:
进程内:goroutine 随并发涨(无硬顶或硬顶很松)
集群侧:Pod 数量是否自动扩,取决于 K8s/平台配置,不是 Go runtime不要把“协程涨了”理解成“容量自动够了”。
7. 一张对照表:谁在扩、扩到哪
| 层级 | 会不会随负载变 | 实际行为 | 本项目常见上限 |
|---|---|---|---|
| HTTP/gRPC handler goroutine | 会 | 按并发请求增/减 | 基本无硬顶 |
GOMAXPROCS | 启动时定 | 对齐容器 CPU | CPU 配额 |
| PG 连接池 | 会(按需) | 借不到就新建,到顶等待 | MaxOpen=10 |
| Redis 连接 | 客户端池策略 | 与请求并发解耦 | 配置/默认 |
| Pod 副本 | 取决于平台 | HPA/手动 | 本仓库未见 |
8. 线上建议盯什么指标
优先看“排队”而不是只看 QPS:
- 延迟:P95/P99、是否逼近 2s timeout
- DB:
OpenConnections/InUse/WaitCount/WaitDuration - goroutine 数:持续单调上涨要怀疑泄漏;随流量波浪式涨跌通常正常
- 错误码:超时、连接池等待导致的失败是否上升
- 下游:资产中台、Redis 的耗时与错误率
经验法则:
goroutine 跟着流量波浪 → 多半健康
goroutine 只涨不跌 → 先查泄漏 / 慢调用未取消
WaitCount、WaitDuration 飙升 → 先扩/调 DB 池,或降并发、减慢 SQL9. 如果真要“可控的自动扩”,该加什么
按投入从低到高:
- 超时与取消:请求 ctx 贯穿 DB/HTTP(已有方向,继续守住)
- 进程内限流 / 并发闸门:例如同时最多 N 个写接口,超出快速失败或排队
- 合理 DB 池:按副本数与 PG 容量反推 MaxOpen
- HPA:按 CPU / 自定义指标扩 Pod
- (可选)gRPC worker:固定
NumStreamWorkers换调度开销,一般不是第一优先级
注意:把 handler 改成固定 worker,不会 magically 提高吞吐;它只是把“无限排队进 goroutine”改成“有限排队进队列”,保护进程不被拖死。
10. 收束
回到标题里的两个问题:
为什么能做到“自动扩容”?
因为 Go 的 HTTP/gRPC 默认就是 按并发请求创建 handler goroutine,没有强制你先买一张固定大小的协程票;请求结束,协程退出,看起来就像自动扩缩。
风险点在哪?
自动扩的是“能同时挂起多少个请求”,不是“系统一定扛得住”。
真正的天花板通常是 DB 连接、下游延迟、超时策略、有没有并发闸门、Pod 是否水平扩。
goroutine 变多,往往是压力的 症状;要治的是慢调用和资源上限错配。
附录:和本仓库路径的对应关系(方便对照代码)
| 话题 | 位置 |
|---|---|
| HTTP Server 装配 | internal/server/http.go |
| gRPC Server 装配 | internal/server/grpc.go |
automaxprocs | cmd/main.go |
| PG 连接池创建与参数 | .local/xxx-go-common-lib/storage/db/postgres.go |
| 业务取 DB 句柄 | internal/data/position/tx.go 的 repoBase.db |
| 一次典型查询 | internal/data/position/config_repo.go 的 GetByID |
| HTTP/gRPC 超时配置 | configs/server.yaml(timeout: 2s) |
0 评论