Table of contents
Open Table of contents
TL;DR
Go 服务本身几乎不是瓶颈(纯 CPU 可达 10 万+ RPS),真正的瓶颈在数据库查询、RPC 调用等 I/O。决策核心是:用 CPU 利用率 × P99 延迟的组合判断该优化代码还是加机器,用连接池指标定位隐藏瓶颈。
Go 服务的核心指标
流量指标
| 指标 | 含义 | 关注点 |
|---|---|---|
| RPS (Requests Per Second) | 每秒处理的请求数 | 服务的吞吐能力上限 |
| P99 Latency | 99% 请求的响应时间 | 用户体验的真实反映,比平均值重要得多 |
| P999 Latency | 99.9% 请求的响应时间 | 尾部延迟,暴露 GC、锁竞争等隐藏问题 |
| Error Rate | 错误率(5xx / 总请求) | 超过 0.1% 就该报警 |
| Inflight Requests | 当前正在处理的请求数 | 反映并发压力,接近上限时延迟会急剧上升 |
资源指标
| 指标 | 含义 | 危险阈值 |
|---|---|---|
| CPU Utilization | CPU 使用率 | > 70% 就该扩容,不是等到 100% |
| Goroutine Count | 活跃 goroutine 数 | 持续增长 = goroutine 泄漏;突增 = 下游阻塞 |
| Heap Memory | 堆内存使用量 | 持续增长 = 内存泄漏 |
| GC Pause (STW) | GC 停顿时间 | Go 1.19+ 通常 < 1ms,> 10ms 说明有问题 |
| GC CPU% | GC 消耗的 CPU 占比 | > 25% 说明内存分配过于频繁 |
| File Descriptors | 打开的文件描述符数 | 接近 ulimit = 无法建立新连接 |
| Thread Count | OS 线程数 | 正常 ≈ GOMAXPROCS + 少量,暴涨 = syscall 阻塞 |
连接指标
| 指标 | 含义 | 关注点 |
|---|---|---|
| Active Connections | 当前活跃连接数 | HTTP/gRPC 连接池是否健康 |
| DB Pool Active/Idle/Wait | 数据库连接池状态 | Wait > 0 = 连接池不够,最常见的瓶颈 |
| Redis Pool Active/Idle | Redis 连接池状态 | 同上 |
| Upstream Latency | 调用下游服务的延迟 | 你的 P99 = 你最慢的下游依赖的 P99 |
单实例能力量级
┌─────────────────────────┬───────────────┬──────────────┐
│ 场景 │ 单实例 RPS │ P99 延迟 │
├─────────────────────────┼───────────────┼──────────────┤
│ 纯计算 JSON 序列化 │ 5万~20万 │ < 1ms │
│ 简单 CRUD(有缓存) │ 1万~5万 │ 2~10ms │
│ 业务逻辑 + DB + Redis │ 3K~15K │ 10~50ms │
│ 复杂业务(多次 DB + RPC) │ 1K~5K │ 50~200ms │
│ 文件上传 / 大 payload │ 500~2K │ 100ms~1s │
└─────────────────────────┴───────────────┴──────────────┘
加上一次 5ms 的数据库查询,RPS 就从 10 万+ 降到万级。瓶颈在 I/O,不在 Go。
决策框架
决策一:需要多少实例?
所需实例数 = (峰值 RPS × 3) ÷ 单实例 RPS
× 3 的原因:
- 突发流量:热点事件可能瞬间 3~5 倍
- 故障冗余:N 台挂一台,剩下 N-1 台要扛住全量
- GC / 部署抖动:滚动更新期间容量减半
决策二:优化代码 vs 加机器?
CPU < 40%,P99 高 → 不是 CPU 问题,优化 I/O(连接池、批量查询、缓存)
CPU > 70%,P99 低 → CPU 密集,加机器或优化算法
CPU < 40%,P99 低 → 余量充足,不用动
CPU > 70%,P99 高 → 两头都到极限,必须加机器 + 优化
决策三:GOMAXPROCS 设置
容器环境:必须用 automaxprocs(uber-go/automaxprocs)
→ 否则 Go 读到宿主机 CPU 核数,goroutine 调度混乱
裸机/VM:默认值(= CPU 核数)通常最优,不用调
决策四:连接池大小
// 数据库连接池 — 关键参数
db.SetMaxOpenConns(N) // 最大连接数
db.SetMaxIdleConns(N) // 最大空闲连接数(设成和 Open 一样)
db.SetConnMaxLifetime(h) // 连接最大存活时间
// N 的计算:
// N = 目标 RPS × 平均查询耗时(秒)
// 例:5000 RPS × 0.005s = 25 个连接 → 留 buffer 设 50
// 铁律:MaxIdleConns = MaxOpenConns
// 否则高峰过后连接被回收,下次请求又要重建,导致延迟尖刺
常见坑:MySQL 默认 max_connections = 151。10 个实例各设 50 连接 = 500,直接超了。连接池大小必须和数据库侧对齐。
决策五:Goroutine 控制
HTTP 服务:不需要 goroutine 池
→ net/http 已经是 per-connection goroutine,调度器能处理百万级
后台任务(消费 MQ、定时任务):需要控制并发
→ 用 semaphore(chan struct{} 或 errgroup.SetLimit)
→ 不用第三方 goroutine 池库,channel 就够了
WebSocket / 长连接:goroutine 数 = 连接数 × 2(读 + 写)
→ 10 万连接 = 20 万 goroutine ≈ 2GB 栈内存(每个 8KB 初始栈)
→ 百万连接需要考虑 epoll 方案(gnet/nbio)
决策六:HTTP vs gRPC
对外 API(给前端/App) → HTTP + JSON(通用性、调试方便)
内部服务间通信 → gRPC + Protobuf
→ 序列化速度快 5~10 倍
→ HTTP/2 多路复用,不用连接池
→ 强类型契约(proto 文件即文档)
→ 流式传输(server streaming 替代轮询)
性能差异:高 QPS 场景(> 1 万 RPS)时 JSON 序列化占 10~30% CPU,
Protobuf 序列化开销几乎可忽略
监控体系
Go 运行时指标 → runtime/metrics(Go 1.16+)或 prometheus/client_golang
→ goroutine_count, gc_pause, heap_inuse, cpu_usage
HTTP 指标 → middleware 埋点
→ request_duration_histogram(按路由、状态码分桶)
→ inflight_requests_gauge
连接池指标 → database/sql 自带 DBStats
→ db.Stats() → OpenConnections, InUse, WaitCount, WaitDuration
→ WaitCount > 0 = 连接池不够,最早的预警信号
链路追踪 → OpenTelemetry → Jaeger/Tempo
→ 定位"P99 高是因为哪个下游慢"
实战例子:DAU 50 万 IM 应用的 Go 服务规划
峰值在线 10 万用户,每用户 1 req/s → 10 万 RPS
流量分布:
- 心跳/在线状态:6 万 RPS → 纯 Redis,Go 处理极轻
- 发消息:2 万 RPS → 写 MQ + 写 DB
- 拉消息/历史:2 万 RPS → 读 Redis 缓存 + 少量 DB
单实例能力(4C8G 容器):
- 心跳类:~3 万 RPS(几乎纯内存操作)
- 消息类:~5000 RPS(有 Redis + MQ I/O)
所需实例:
- 心跳服务:6万 ÷ 3万 × 3 = 6 台
- 消息服务:4万 ÷ 5000 × 3 = 24 台
- 总计约 30 个 Pod
WebSocket 网关:
- 10 万连接 ÷ 单实例 2 万连接 = 5 台
- 每台 4 万 goroutine ≈ 320MB 栈内存,轻松
Pitfalls
- 不要用平均延迟做决策 — P50 = 5ms 的服务可能 P99 = 500ms,10% 的用户体验极差但你看不到
- Goroutine 数不等于并发能力 — 100 万 goroutine 但都阻塞在 DB 查询上 = 没有并发,真正的并发受限于最慢的 I/O
- 连接池是最常被忽略的瓶颈 —
db.Stats().WaitCount如果 > 0,说明请求在排队等连接,加再多机器也没用 - 别在 Go 里做 CPU 密集型计算 — 图片处理、加密、压缩交给专门服务或 C/Rust 库,否则 GC 压力和延迟都不可控
- automaxprocs 在 K8s 环境是必须的 — 不加这个库,4 核容器里 Go 以为自己有 64 核,调度器行为完全错乱