洪峰不是“很多次日常登录”
《群岛纪元》新服开服时,十万预约玩家在五分钟内涌入;《泡泡工坊》买量素材爆量时,登录 QPS 在三十秒内翻了二十倍;《裂隙战场》赛事开始前,所有观战和参赛账号在同一分钟重连。三者都叫“洪峰”,但形状不同:
| 场景 | 总量 | 突增斜率 | 重试放大风险 |
|---|---|---|---|
| 新服开服 | 大,可预估 | 中,集中在开服时刻 | 高,失败后玩家反复点 |
| 买量爆量 | 中到大,不可预估 | 极高 | 高,脚本也会重试 |
| 赛事重连 | 中,集中在少数账号 | 极高 | 中,客户端有重连策略 |
日常登录的 QPS 是平滑的,洪峰的 QPS 是一条几乎垂直的线。架构上不能用“日常峰值乘以二”来估算容量,而要把洪峰当成独立场景:先保护后端不崩,再管理玩家预期。
排队不等于限流
这两个词经常被混用,但目标相反:
| 机制 | 目标 | 失败时的表现 |
|---|---|---|
| 限流 | 保护后端,超过容量就拒绝 | 请求被快速拒绝,客户端退避 |
| 排队 | 管理预期,让玩家知道还要等多久 | 请求被挂起,超时后放弃 |
| 排队 + 限流 | 先排队,再按后端容量放行 | 队列本身也可能成为故障点 |
只有限流,玩家看到的是“登录失败,请重试”,然后疯狂点击;只有排队,后端仍然会被队列的入队和轮询压垮。正确组合是:边缘先做粗粒度限流,挡住明显异常的流量;账号服务前做细粒度排队,按真实处理能力放行。
排队系统的最小语义
一个能用的登录队列,至少要有五个语义:
- 位置:玩家在队列中的序号,或者“前面还有多少人”。
- 预计等待:基于当前放行速率和剩余人数的估算,必须标明是估算值。
- 公平性:不能因为某个渠道重试得多,就让它的玩家一直插队。
- 超时与放弃:等待超过上限必须明确失败,不能让客户端无限挂起。
- 去重:同一个登录请求的重试,不能变成多个排队名额。
前四个是玩家体验,第五个是系统稳定性。没有去重,客户端每重试一次就多一个队列条目,队列会越排越长,预计等待越来越离谱,玩家更焦虑,重试更频繁。
公平性通常按“租户”划分:账号、渠道、地区都可以是租户。实现上不是严格优先级,而是轮转:每个租户各取一个,避免大渠道把队列占满。
限流放在哪一层
| 位置 | 粒度 | 优点 | 代价 |
|---|---|---|---|
| 边缘/CDN | IP、地区、ASN | 最早挡住洪水 | 误伤 NAT 后的正常玩家 |
| 网关 | 连接、账号、设备 | 能识别已认证身份 | 攻击流量仍消耗握手资源 |
| 账号服务 | 账号、渠道、操作类型 | 最贴近真实容量 | 请求已经到达核心,保护太晚 |
《群岛纪元》的组合是:边缘按 IP 做宽限流,网关按账号做中等粒度限流,账号服务按渠道做最终令牌桶。三层阈值不同,边缘最宽,账号服务最严。这样正常玩家几乎感知不到边缘限流,而异常流量在到达账号库之前就被削掉。
被拒绝时返回什么,和限流本身同样重要。返回一个没有信息的 500,客户端只能立即重试;返回明确的“稍后再试 + 建议等待时间”,客户端才有机会退避。
避免“排队、超时、重试、更拥堵”
正反馈是这样形成的:队列变长 → 预计等待变长 → 客户端超时 → 客户端重试 → 队列更长。打断它需要四个动作:
- 服务端给出明确的
Retry-After,并在响应里带上队列位置和估算等待。 - 客户端加随机抖动,避免所有客户端在同一秒重试。
- 排队票据有截止时间,过期即失败,不能把过期请求重新塞回队尾。
- 按请求 ID 去重,重试返回同一个票据,而不是新建一个。
还有一个容易被忽略的点:排队票据只能使用一次。玩家被放行后,如果客户端没收到响应又重试,服务端应该返回“已放行”的同一张票据,而不是再放行一次。否则一次登录会消耗两个后端名额。
代码:令牌桶加公平队列
下面的 pump.go 是队列的放行部分:令牌桶决定这一轮能放多少人,租户轮转决定放谁。位置和等待估算也在同一文件里,估算基于“前面的人数减去当前可用令牌”,再除以放行速率。
// file: internal/loginqueue/pump.go
// tier: L1
// verify: go test ./internal/loginqueue/
// Package loginqueue combines a token bucket with a fair, tenant-aware waiting
// queue. It separates "may I enter now" from "where am I in line".
package loginqueue
import (
"math"
"time"
)
// Pump admits at most max waiting tickets, round-robin across tenants.
func (q *Queue) Pump(max int) []Ticket {
q.mu.Lock()
defer q.mu.Unlock()
q.expireLocked(q.now())
want := len(q.order)
if max > 0 && max < want {
want = max
}
if want == 0 {
return nil
}
granted := q.limiter.Grant(want)
out := make([]Ticket, 0, granted)
for len(out) < granted {
id := q.nextLocked()
if id == "" {
break
}
t := q.byID[id]
t.State = Admitted
q.removeLocked(id)
out = append(out, *t)
}
return out
}
// nextLocked pops the front tenant, takes one ticket, then rotates that
// tenant to the back if it still has waiters. Rotating explicitly (rather
// than indexing a sorted list) keeps fairness stable as tenants come and go.
func (q *Queue) nextLocked() string {
for len(q.ring) > 0 {
name := q.ring[0]
q.ring = q.ring[1:]
ids := q.tenants[name]
if len(ids) == 0 {
delete(q.tenants, name)
continue
}
id := ids[0]
if len(ids) == 1 {
delete(q.tenants, name)
} else {
q.tenants[name] = ids[1:]
q.ring = append(q.ring, name)
}
return id
}
return ""
}
func (q *Queue) dropRingLocked(name string) {
for idx, v := range q.ring {
if v == name {
q.ring = append(q.ring[:idx], q.ring[idx+1:]...)
return
}
}
}
func (q *Queue) removeLocked(id string) {
for idx, v := range q.order {
if v == id {
q.order = append(q.order[:idx], q.order[idx+1:]...)
break
}
}
for name, ids := range q.tenants {
for idx, v := range ids {
if v != id {
continue
}
ids = append(ids[:idx], ids[idx+1:]...)
if len(ids) == 0 {
delete(q.tenants, name)
q.dropRingLocked(name)
} else {
q.tenants[name] = ids
}
break
}
}
}
func (q *Queue) expireLocked(nowT time.Time) {
for _, id := range append([]string(nil), q.order...) {
t := q.byID[id]
if t == nil || nowT.Before(t.Deadline) {
continue
}
t.State = Expired
q.removeLocked(id)
}
}
func (q *Queue) snapshotLocked(t *Ticket) Ticket {
cp := *t
if cp.State != Waiting {
return cp
}
cp.Position = 0
for idx, id := range q.order {
if id == t.ID {
cp.Position = idx + 1
break
}
}
cp.Wait = q.estimateLocked(cp.Position)
return cp
}
func (q *Queue) estimateLocked(position int) time.Duration {
if position <= 0 {
return 0
}
rate := q.limiter.Rate()
if rate <= 0 {
return time.Duration(math.MaxInt64)
}
need := float64(position) - q.limiter.Available()
if need <= 0 {
return 0
}
return time.Duration(need / rate * float64(time.Second))
}
测试验证两件最容易写错的事:过期票据必须释放它占用的名额;过期票据被清理后,轮询应该返回“未知”,而不是让客户端一直等。
// file: internal/loginqueue/expire_test.go
// tier: L1
package loginqueue
import (
"testing"
"time"
)
func TestExpireAndSweep(t *testing.T) {
clk := newClock()
q, err := NewQueue(NewLimiter(1, 1, clk.Now), 5*time.Second, clk.Now)
if err != nil {
t.Fatalf("NewQueue: %v", err)
}
expired := q.Enqueue("A", "r1")
clk.Advance(6 * time.Second)
if poll := q.Poll(expired.ID); poll.State != Expired {
t.Fatalf("state = %v, want Expired", poll.State)
}
if q.Len() != 0 {
t.Fatalf("expired ticket still occupies the queue: %d", q.Len())
}
fresh := q.Enqueue("A", "r2")
admitted := q.Pump(1)
if len(admitted) != 1 || admitted[0].ID != fresh.ID {
t.Fatalf("expiry did not free the grant: %+v", admitted)
}
clk.Advance(10 * time.Second)
if n := q.Sweep(time.Second); n < 1 {
t.Fatalf("swept %d tickets, want at least 1", n)
}
if poll := q.Poll(fresh.ID); poll.State != Unknown {
t.Fatalf("swept ticket state = %v, want Unknown", poll.State)
}
}
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/loginqueue
$ go vet ./internal/loginqueue/...
$ go test -count=1 ./internal/loginqueue/...
ok gsarchlab/internal/loginqueue
gofmt -l 与 go vet 均无输出。go test 在本机通过 17 个用例,覆盖突发容量、令牌补充、租户轮转、请求去重、超时清理和等待估算。测试使用假时钟,耗时数字不代表真实登录性能。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 客户端无抖动重试 | 每秒一波重试洪峰 | 返回 Retry-After,客户端随机退避 |
| 队列无限增长 | 内存上涨,预计等待失真 | 队列长度上限,超出直接拒绝 |
| 过期票据仍占位 | 名额被死请求占用 | 入队和放行前都执行过期清理 |
| 大渠道挤占队列 | 小渠道玩家饿死 | 按租户轮转,不按全局 FIFO |
| 放行后客户端未收到 | 重复登录消耗两个名额 | 请求 ID 去重,返回已放行票据 |
| 令牌桶配置过大 | 后端被瞬间打满 | 以压测容量为准,容量与速率分开配置 |
常见错误与边界
第一,用排队代替限流。队列只改变请求到达的时间,不减少总工作量;后端容量不够,队列只会让失败来得更晚。
第二,预计等待写成承诺。网络、重试、后端抖动都会让估算失准,必须明确标注“预计值”。
第三,排队票据不过期。玩家关掉客户端后,票据仍然占着位置,队列的有效容量越来越小。
第四,超时后把原请求重新入队。这会把已经失败的请求变成新的负载,正反馈就是这么形成的。
第五,只按全局 FIFO 排队。某个渠道的脚本可以把队列头部占满,正常玩家永远排在后面。
小结
洪峰场景下,系统要同时做两件事:用限流保护自己,用排队管理玩家预期。限流回答“现在能不能处理”,排队回答“如果不能,要等多久”。两者都离不开去重、截止时间和明确的退避信号。
登录链路到这里闭环:2.1 决定凭证是否有效,2.2 决定连接落在哪个网关,2.3 决定洪峰时谁先进入。下一章进入匹配与对局,讨论公平性如何被算法和房间结构共同决定。
阅读导航:上一节:2.2 网关路由与连接归属 · 下一节:3.1 匹配算法与公平性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。