新服开服那天,监控面板上 CPU 直接飙到 98%,Gate 连接数 10 秒内从 2000 涨到 8 万,Logic 队列深度爆红。服务器没扛住,重启了两次。
那次之后我花了两周加了三级背压控制。这篇文章就是那两周的记录。
先看事故现场
开服当天的流量曲线大概是这样的:
1 2 3 4 5 6 7
| 时间 连接数 CPU 队列深度 状态 10:00 200 5% 0 正常 10:01 5000 30% 120 正常 10:02 30000 75% 8000 ⚠️ 队列积压 10:03 80000 98% 队列满 💥 服务崩溃 10:05 重启 — — 恢复 10:06 60000 95% 队列满 💥 又崩了
|
问题很清楚:没有任何流量控制,请求无限制涌入,打满了所有资源。
三级背压:从外到内逐级拦截
这次事故后设计了三级保护,每层拦截不同类型的问题:
1 2 3
| Gate 层 → 限制连接数(防连接风暴) Logic 层 → 限制队列深度(防请求积压) Battle 层 → 限制 CPU 使用率(防计算过载)
|
第一级:Gate 连接限流
最简单粗暴的一层。连接数超了就拒绝,客户端看到 “server_full” 就知道该排队了。
1 2 3 4 5 6 7 8 9 10
| func (s *Server) handleConnection(conn net.Conn) { if s.connCount.Load() >= s.maxConns { conn.Write([]byte("server_full")) conn.Close() return } s.connCount.Add(1) defer s.connCount.Add(-1) s.serve(conn) }
|
maxConns 设置为单机 25 万。这个数字怎么来的?每个 TCP 连接占约 4KB 内存(goroutine 栈 + 读写缓冲),25 万连接约 1GB,加上业务逻辑的内存开销,单机 16GB 内存绰绰有余。
第二级:Logic 队列背压
Gate 转发请求到 Logic,Logic 用 channel 做队列。队列快满时返回 busy,客户端收到后指数退避重试。
1 2 3 4 5 6 7
| func (s *Shard) Handle(req *Request) error { if len(s.requestCh) >= cap(s.requestCh)*90/100 { return ErrServerBusy } s.requestCh <- req return nil }
|
为什么是 90% 不是 100%?留 10% 的余量是因为 channel 满了之后 send 会阻塞,阻塞的 goroutine 不会释放资源,积累多了会更麻烦。提前拒绝比阻塞好。
客户端的重试逻辑:
1 2 3 4
| func _retry_with_backoff(attempt: int) -> void: var delay = min(0.1 * pow(2, attempt), 5.0) await get_tree().create_timer(delay).timeout send_request()
|
第一次 0.1 秒,第二次 0.2 秒,第三次 0.4 秒……最多等 5 秒。大部分情况下 2-3 次重试就能成功。
第三级:Battle CPU 限流
Battle 层是最吃 CPU 的——战斗模拟涉及大量数学计算。监控 CPU 使用率,超过 85% 就随机延迟:
1 2 3 4 5 6
| func (s *Service) Handle(ctx context.Context, req *BattleRequest) (*BattleResult, error) { if s.shouldThrottle() { time.Sleep(time.Duration(rand.Intn(100)) * time.Millisecond) } return s.simulate(ctx, req) }
|
随机延迟的妙处在于:不是所有请求都延迟同样的时间,而是分散到 0~100ms 的窗口里。这比固定延迟更平滑,避免了”所有人同时延迟 50ms 然后同时涌入”的问题。
监控:不看仪表盘就是在盲飞
三级背压加完后,还需要监控来验证效果。每层暴露 Prometheus 指标:
1 2 3 4 5 6 7 8 9 10 11 12
| var ( activeConns = prometheus.NewGauge(prometheus.GaugeOpts{ Name: "gate_active_connections", }) requestQueueDepth = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Name: "logic_queue_depth", }, []string{"shard"}) battleLatency = prometheus.NewHistogram(prometheus.HistogramOpts{ Name: "battle_latency_seconds", Buckets: prometheus.ExponentialBuckets(0.01, 2, 10), }) )
|
Grafana 面板上三个关键指标:
- Gate 连接数:正常范围 5~15 万,超 20 万告警
- Logic 队列深度:正常 < 100,超 500 告警
- Battle P99 延迟:正常 < 200ms,超 500ms 触发自动扩容
Battle 自动扩容:无状态的好处
Battle 层无状态,可以随意扩缩。Kubernetes HPA 基于 P99 延迟自动调整实例数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: battle-hpa spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: battle minReplicas: 5 maxReplicas: 30 metrics: - type: Pods pods: metric: name: battle_latency_seconds_p99 target: type: AverageValue averageValue: "500m"
|
Logic 层有状态,扩容需要数据迁移,通常固定分片数不自动扩。
开服预热:提前 30 分钟
新服开服是最极端的场景。那次事故后,开服流程变成了这样:
1 2 3 4 5 6
| T-30min 扩容 Battle 到 30 实例 T-10min 检查所有监控指标正常 T-5min 开启排队系统,Gate 只接受连接不转发请求 T-0 分批放行:每 10 秒放 1000 人 T+5min 观察队列深度,决定是否加速放行 T+15min 关闭排队系统,恢复正常模式
|
排队系统的实现很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| type Queue struct { waiting []int64 rate int interval time.Duration }
func (q *Queue) process() { ticker := time.NewTicker(q.interval) for range ticker.C { n := min(q.rate, len(q.waiting)) for i := 0; i < n; i++ { playerID := q.waiting[0] q.waiting = q.waiting[1:] q.allow(playerID) } } }
|
效果验证
第二次新服开服,同样的流量规模,这次没崩:
1 2 3 4 5 6 7
| 时间 连接数 CPU 队列深度 状态 10:00 200 5% 0 正常 10:01 5000 25% 50 排队放行中 10:02 15000 45% 200 正常(排队控制) 10:03 30000 60% 300 正常(Battle 自动扩容中) 10:05 50000 70% 400 正常(Battle 扩到 20 实例) 10:10 80000 65% 100 正常(全量放行完成)
|
CPU 峰值从 98% 降到 70%,队列深度从爆满降到 400。代价是前 5 分钟有排队等待,但总比服务器崩溃好。
背压控制的本质不是”限制流量”,是”让系统在过载时优雅降级而不是崩溃”。排队等待 5 秒 vs 服务器重启 2 分钟,选哪个?