V8: 扩容 —— 「公司扩到 500 人,又慢了」
公司从 20 人扩到 500 人,又收购了一家 300 人的公司。月底报销高峰期 800 人同时使用系统,Grafana 仪表盘上(V7 搭建的)看到一连串红色告警:
- DB 连接池打满:100 个连接全被占用,新请求排队超时
- CPU 100%:单机扛不住并发查询
- 请求延迟 P99 > 10s:月底统计报表拖垮了整个系统
- Redis 缓存命中率暴跌:用户量暴增,热数据变了
财务总监发邮件:“月底最后一天报销系统卡得完全用不了,100 多人的报销没法提交。”
当前状态 (V7):单机部署,一个 Go 进程,一个 PostgreSQL 实例,一个 Redis 实例。有完整的监控体系,所以能精确看到瓶颈在哪里。
问题分析(5 层框架)
Section titled “问题分析(5 层框架)”| 层级 | 问题 | 影响 |
|---|---|---|
| 表象 | 月底报销高峰系统卡死 | 800 人无法正常使用 |
| 直接原因 | DB 连接打满,CPU 100% | 请求排队超时 |
| 系统原因 | 单机架构无法水平扩展 | 资源上限固定 |
| 设计缺失 | 读写未分离,重查询阻塞轻操作 | 月报统计拖垮日常 CRUD |
| 根本原因 | 没有为用户增长做容量规划 | 每次增长都是危机 |
瓶颈分析(基于 Grafana 数据)
Section titled “瓶颈分析(基于 Grafana 数据)”请求链路分析:用户请求 → 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 让它更快 | 异步执行 + 后台 Worker | B | 统计报表天然适合异步,不应阻塞在线请求 |
| 应用扩容 | 优化单机性能 | 多实例 + 负载均衡 | B | 无状态设计后水平扩展最简单 |
| 消息队列 | RabbitMQ / Kafka | Redis Stream | Redis Stream | 已有 Redis,不引入新组件,800 人规模够用 |
| 负载均衡 | 云厂商 LB | Nginx 反向代理 | Nginx | 自建环境,Docker Compose 可编排 |
扩容后架构:
┌─── App 实例 1 ───┐用户 → Nginx LB ──→├─── App 实例 2 ───┤──→ PostgreSQL Primary (写) └─── App 实例 3 ───┘ ↓ 流复制 ↕ PostgreSQL Replica (读) Redis (缓存 + 消息队列) ↕ Background Worker (异步报表生成)- DB 读写分离:GORM 配置两个连接,写走 Primary,读走 Replica
- 连接池调优:Primary MaxOpen=50,Replica MaxOpen=100,MaxIdleTime=10min
- 异步报表:报表请求写入 Redis Stream,Worker 消费后生成结果存表,前端轮询状态
- 无状态应用:Session 已在 JWT(无状态),文件在本地需迁移到共享存储
- Nginx 负载均衡:docker-compose 启动 3 个 app 实例 + 1 个 nginx
- 缓存预热:启动时加载高频数据到 Redis,月底前预热统计报表
给 AI 的 Prompt
Section titled “给 AI 的 Prompt”我有一个 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 机制)
多实例负载均衡
Section titled “多实例负载均衡”-
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%
- 月报生成不影响日常操作延迟
你学到了什么
Section titled “你学到了什么”| 主题 | 对应模块 |
|---|---|
| 水平扩展的前提是无状态设计 | → Module 8 (扩展性) |
| 读写分离解决读多写少场景,但要处理复制延迟 | → Module 3 (复制) |
| 重操作异步化:接受请求 → 后台处理 → 轮询结果 | → Module 5 (消息队列) |
| Redis Stream 是轻量级消息队列的好选择 | → Module 5 |
| 连接池不是越大越好,要匹配 DB 承载能力 | → Module 8 |
| 批处理思维:统计报表应该离线算,不要在线算 | → Module 9 (批处理) |
1. 读写分离后”写完读不到”
Section titled “1. 读写分离后”写完读不到””现象:创建报销单后立即跳转列表页,新记录不在列表中。
原因:写走 Primary,读走 Replica,主从复制有延迟(通常毫秒级,但高峰期可能秒级)。
解决:写操作后的”立即读”强制走 Primary。GORM dbresolver 支持 db.Clauses(dbresolver.Write).Find(&result) 强制走主库。或者前端乐观更新(先本地加上,再异步同步)。
2. 连接池设太大反而更慢
Section titled “2. 连接池设太大反而更慢”现象:MaxOpenConns 设了 500,但性能反而下降。
原因:PostgreSQL 每个连接消耗约 10MB 内存,500 连接 = 5GB。超过 CPU 核数的并发查询会频繁上下文切换。
解决:经验公式 MaxOpenConns = CPU 核数 * 2 + 磁盘数。16 核机器设 50 左右就够了。多出来的并发靠应用层排队。
3. Redis Stream 消息丢失
Section titled “3. Redis Stream 消息丢失”现象:Worker 崩溃后重启,部分任务丢失不执行。 原因:消费后没有 ACK,用了 XREAD 而不是 XREADGROUP,或者 ACK 了但处理失败。 解决:使用 Consumer Group + XREADGROUP + XACK。处理完成后才 ACK。启动时用 XPENDING 检查未 ACK 的消息重新处理。
4. Nginx upstream 健康检查不生效
Section titled “4. Nginx upstream 健康检查不生效”现象:停了一个 app 实例,Nginx 还在往上面转发,返回 502。
原因:Nginx 开源版的健康检查是被动的(请求失败后才标记为 down),不是主动探测。
解决:配置 max_fails=2 fail_timeout=10s,两次失败后 10 秒内不再转发。或使用 nginx-plus / OpenResty 的主动健康检查模块。
5. 缓存预热导致启动变慢
Section titled “5. 缓存预热导致启动变慢”现象:应用启动要 30 秒,因为预热要加载大量数据。 原因:同步预热阻塞了应用启动,Kubernetes 的 readiness 检查超时。 解决:预热放后台 goroutine 异步执行,应用先启动接受请求(冷缓存只是慢一点,不是不能用)。或者使用 startup probe 给足够的启动时间。