跳转到内容

V8: 扩容 —— 「公司扩到 500 人,又慢了」

公司从 20 人扩到 500 人,又收购了一家 300 人的公司。月底报销高峰期 800 人同时使用系统,Grafana 仪表盘上(V7 搭建的)看到一连串红色告警:

  • DB 连接池打满:100 个连接全被占用,新请求排队超时
  • CPU 100%:单机扛不住并发查询
  • 请求延迟 P99 > 10s:月底统计报表拖垮了整个系统
  • Redis 缓存命中率暴跌:用户量暴增,热数据变了

财务总监发邮件:“月底最后一天报销系统卡得完全用不了,100 多人的报销没法提交。”

当前状态 (V7):单机部署,一个 Go 进程,一个 PostgreSQL 实例,一个 Redis 实例。有完整的监控体系,所以能精确看到瓶颈在哪里。


层级问题影响
表象月底报销高峰系统卡死800 人无法正常使用
直接原因DB 连接打满,CPU 100%请求排队超时
系统原因单机架构无法水平扩展资源上限固定
设计缺失读写未分离,重查询阻塞轻操作月报统计拖垮日常 CRUD
根本原因没有为用户增长做容量规划每次增长都是危机
请求链路分析:
用户请求 → Go App (单实例) → PostgreSQL (单实例)
↘ Redis (缓存命中率 40%→15%)
瓶颈 1: DB 连接池 100 个,800 并发远远不够
瓶颈 2: 月报统计 SQL 执行 30s+,占用连接不释放
瓶颈 3: 应用单实例,CPU 成瓶颈
瓶颈 4: 缓存 Key 设计不合理,用户量增加后命中率骤降

graph TD
User["用户"] --> LB["负载均衡<br/>(Nginx)"]
LB --> Go1["Go 实例 1"]
LB --> Go2["Go 实例 2"]
LB --> Go3["Go 实例 3"]
Go1 & Go2 & Go3 --> Redis["Redis 集群"]
Go1 & Go2 & Go3 --> Primary["PostgreSQL 主库<br/>(写入)"]
Primary -->|"复制"| Read1["从库 1<br/>(读取)"]
Primary -->|"复制"| Read2["从库 2<br/>(读取)"]
Go1 & Go2 & Go3 --> MQ["消息队列<br/>(异步任务)"]

新增:多实例 + 负载均衡 + 读写分离 + 消息队列 解决:500 人同时使用不卡

决策点选项 A选项 B选择理由
DB 扩容换更大的机器(垂直)读写分离(水平)B垂直扩容有上限,读写分离解决读多写少场景
重查询处理优化 SQL 让它更快异步执行 + 后台 WorkerB统计报表天然适合异步,不应阻塞在线请求
应用扩容优化单机性能多实例 + 负载均衡B无状态设计后水平扩展最简单
消息队列RabbitMQ / KafkaRedis StreamRedis Stream已有 Redis,不引入新组件,800 人规模够用
负载均衡云厂商 LBNginx 反向代理Nginx自建环境,Docker Compose 可编排
扩容后架构:
┌─── App 实例 1 ───┐
用户 → Nginx LB ──→├─── App 实例 2 ───┤──→ PostgreSQL Primary (写)
└─── App 实例 3 ───┘ ↓ 流复制
↕ PostgreSQL Replica (读)
Redis
(缓存 + 消息队列)
Background Worker
(异步报表生成)
  1. DB 读写分离:GORM 配置两个连接,写走 Primary,读走 Replica
  2. 连接池调优:Primary MaxOpen=50,Replica MaxOpen=100,MaxIdleTime=10min
  3. 异步报表:报表请求写入 Redis Stream,Worker 消费后生成结果存表,前端轮询状态
  4. 无状态应用:Session 已在 JWT(无状态),文件在本地需迁移到共享存储
  5. Nginx 负载均衡:docker-compose 启动 3 个 app 实例 + 1 个 nginx
  6. 缓存预热:启动时加载高频数据到 Redis,月底前预热统计报表

我有一个 Go(Gin) + GORM + PostgreSQL + Redis 的团队记账工具,当前单机部署。
公司扩到 800 人,月底高峰 DB 连接打满、CPU 100%。需要做水平扩展。
请帮我实现以下改造:
1. **PostgreSQL 读写分离**
- docker-compose 新增 PostgreSQL Replica 容器
- 使用 PostgreSQL 流复制(streaming replication)
- GORM 配置读写分离:
```go
import "gorm.io/plugin/dbresolver"
db.Use(dbresolver.Register(dbresolver.Config{
Replicas: []gorm.Dialector{postgres.Open(replicaDSN)},
Policy: dbresolver.RandomPolicy{},
}))
```
- 写操作自动走 Primary,读操作自动走 Replica
- 新建 server/database/resolver.go 封装配置
2. **连接池调优**
- Primary: MaxOpenConns=50, MaxIdleConns=25, ConnMaxLifetime=30m, ConnMaxIdleTime=10m
- Replica: MaxOpenConns=100, MaxIdleConns=50, 其余同上
- 在 Prometheus 指标中区分 primary/replica 连接池状态
- 从环境变量读取连接池参数(支持运行时调整后重启生效)
3. **异步报表生成(Redis Stream)**
- 新建 server/worker/report_worker.go
- API 端:
POST /api/reports 接收报表请求,参数:type(monthly/department), period(2024-01)
生成任务 ID,写入 Redis Stream "report_tasks"
返回 202 Accepted + {"task_id": "xxx", "status": "pending"}
- GET /api/reports/:task_id 查询任务状态
返回 {"task_id": "xxx", "status": "pending|processing|done|failed", "result_url": "..."}
- Worker 端:
消费 Redis Stream,用 Consumer Group
执行统计 SQL(走 Replica,不影响主库)
结果存入 reports 表(task_id, status, result JSON, created_at)
处理完更新状态为 done
- Worker 作为独立进程启动:`go run cmd/worker/main.go`
4. **无状态应用设计**
- 确认 JWT 无状态(不依赖服务器内存 session)
- 本地文件存储改为共享目录(docker volume 挂载同一路径)
- 应用启动时不依赖本地状态
- 支持优雅关闭(graceful shutdown):
收到 SIGTERM → 停止接收新请求 → 等待处理中请求完成(超时 30s)→ 关闭 DB/Redis 连接
5. **Nginx 负载均衡 + 多实例**
- docker-compose.yml 修改:
app 服务使用 `deploy.replicas: 3`(或定义 app1/app2/app3)
新增 nginx 服务作为入口
- nginx.conf:
upstream backend: 3 个 app 实例,使用 least_conn 策略
proxy_pass http://backend
设置 proxy_set_header(Host, X-Real-IP, X-Forwarded-For)
健康检查:每 10s 检查 /health
- 前端 Vite 代理改为指向 nginx
6. **缓存预热**
- 新建 server/cache/warmup.go
- 应用启动时预加载:
- 分类列表(category list)→ 几乎不变,缓存 1 小时
- 当月报销统计概览 → 月底高频访问
- 定时任务:每天凌晨 2 点刷新热数据
- 使用 singleflight 防止缓存击穿(多个请求同时回源)
请给出完整代码,包括 docker-compose.yml 变更、nginx.conf、新增的 Go 文件。

  • 正常写入报销单后,从 Replica 能查到(允许毫秒级延迟)
  • 停止 Replica,读请求降级到 Primary(不报错)
  • Prometheus 指标可以分别看到 primary 和 replica 的连接池状态
  • 写操作日志显示走 Primary,读操作日志显示走 Replica
  • 压测 200 并发,DB 连接数不超过 MaxOpenConns 设定值
  • 空闲连接 10 分钟后自动回收(观察 idle 指标下降)
  • 连接泄漏检测:长时间运行后连接数稳定,不持续增长
  • POST /api/reports 立即返回 202,不阻塞
  • GET /api/reports/:task_id 返回 pending → processing → done 状态变化
  • Worker 进程独立启动,消费任务正常执行
  • 统计 SQL 走 Replica(通过日志或监控确认)
  • Worker 崩溃后重启,未完成任务重新消费(Consumer Group ACK 机制)
  • docker-compose up 启动 3 个 app 实例 + nginx
  • 连续请求,响应头或日志显示请求分散到不同实例
  • 停掉 1 个实例,请求自动转发到其他实例(无报错)
  • Nginx /health 检查能踢掉不健康实例
  • 应用启动日志显示 “cache warmup completed”
  • 启动后第一次访问分类列表,Redis HIT(不回源 DB)
  • singleflight 测试:并发 100 请求同一缓存 Key,DB 只查 1 次
  • 800 并发用户模拟(可用 k6/vegeta)
  • P99 延迟 < 1s(日常 CRUD)
  • 错误率 < 0.1%
  • 月报生成不影响日常操作延迟

主题对应模块
水平扩展的前提是无状态设计→ Module 8 (扩展性)
读写分离解决读多写少场景,但要处理复制延迟→ Module 3 (复制)
重操作异步化:接受请求 → 后台处理 → 轮询结果→ Module 5 (消息队列)
Redis Stream 是轻量级消息队列的好选择→ Module 5
连接池不是越大越好,要匹配 DB 承载能力→ Module 8
批处理思维:统计报表应该离线算,不要在线算→ Module 9 (批处理)

现象:创建报销单后立即跳转列表页,新记录不在列表中。 原因:写走 Primary,读走 Replica,主从复制有延迟(通常毫秒级,但高峰期可能秒级)。 解决:写操作后的”立即读”强制走 Primary。GORM dbresolver 支持 db.Clauses(dbresolver.Write).Find(&result) 强制走主库。或者前端乐观更新(先本地加上,再异步同步)。

现象:MaxOpenConns 设了 500,但性能反而下降。 原因:PostgreSQL 每个连接消耗约 10MB 内存,500 连接 = 5GB。超过 CPU 核数的并发查询会频繁上下文切换。 解决:经验公式 MaxOpenConns = CPU 核数 * 2 + 磁盘数。16 核机器设 50 左右就够了。多出来的并发靠应用层排队。

现象:Worker 崩溃后重启,部分任务丢失不执行。 原因:消费后没有 ACK,用了 XREAD 而不是 XREADGROUP,或者 ACK 了但处理失败。 解决:使用 Consumer Group + XREADGROUP + XACK。处理完成后才 ACK。启动时用 XPENDING 检查未 ACK 的消息重新处理。

现象:停了一个 app 实例,Nginx 还在往上面转发,返回 502。 原因:Nginx 开源版的健康检查是被动的(请求失败后才标记为 down),不是主动探测。 解决:配置 max_fails=2 fail_timeout=10s,两次失败后 10 秒内不再转发。或使用 nginx-plus / OpenResty 的主动健康检查模块。

现象:应用启动要 30 秒,因为预热要加载大量数据。 原因:同步预热阻塞了应用启动,Kubernetes 的 readiness 检查超时。 解决:预热放后台 goroutine 异步执行,应用先启动接受请求(冷缓存只是慢一点,不是不能用)。或者使用 startup probe 给足够的启动时间。