新服开服那天,监控面板上 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" # P99 > 0.5s 时扩容

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 分钟,选哪个?