《游戏服务端架构·核心系统与契约》2.3 登录排队、限流与开服洪峰

开服洪峰与日常登录不是同一个问题:总量、斜率与重试放大都不同。排队管理预期,限流保护后端,两者不能互相替代。本节给出令牌桶加租户公平队列、位置与等待估算、超时与去重,比较限流放在边缘、网关或账号服务的代价,并说明如何避免排队、超时、重试、更拥堵的正反馈。

洪峰不是“很多次日常登录”

《群岛纪元》新服开服时,十万预约玩家在五分钟内涌入;《泡泡工坊》买量素材爆量时,登录 QPS 在三十秒内翻了二十倍;《裂隙战场》赛事开始前,所有观战和参赛账号在同一分钟重连。三者都叫“洪峰”,但形状不同:

场景总量突增斜率重试放大风险
新服开服大,可预估中,集中在开服时刻高,失败后玩家反复点
买量爆量中到大,不可预估极高高,脚本也会重试
赛事重连中,集中在少数账号极高中,客户端有重连策略

日常登录的 QPS 是平滑的,洪峰的 QPS 是一条几乎垂直的线。架构上不能用“日常峰值乘以二”来估算容量,而要把洪峰当成独立场景:先保护后端不崩,再管理玩家预期。

排队不等于限流

这两个词经常被混用,但目标相反:

机制目标失败时的表现
限流保护后端,超过容量就拒绝请求被快速拒绝,客户端退避
排队管理预期,让玩家知道还要等多久请求被挂起,超时后放弃
排队 + 限流先排队,再按后端容量放行队列本身也可能成为故障点

只有限流,玩家看到的是“登录失败,请重试”,然后疯狂点击;只有排队,后端仍然会被队列的入队和轮询压垮。正确组合是:边缘先做粗粒度限流,挡住明显异常的流量;账号服务前做细粒度排队,按真实处理能力放行。

排队系统的最小语义

一个能用的登录队列,至少要有五个语义:

  1. 位置:玩家在队列中的序号,或者“前面还有多少人”。
  2. 预计等待:基于当前放行速率和剩余人数的估算,必须标明是估算值。
  3. 公平性:不能因为某个渠道重试得多,就让它的玩家一直插队。
  4. 超时与放弃:等待超过上限必须明确失败,不能让客户端无限挂起。
  5. 去重:同一个登录请求的重试,不能变成多个排队名额。

前四个是玩家体验,第五个是系统稳定性。没有去重,客户端每重试一次就多一个队列条目,队列会越排越长,预计等待越来越离谱,玩家更焦虑,重试更频繁。

公平性通常按“租户”划分:账号、渠道、地区都可以是租户。实现上不是严格优先级,而是轮转:每个租户各取一个,避免大渠道把队列占满。

限流放在哪一层

位置粒度优点代价
边缘/CDNIP、地区、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 匹配算法与公平性 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查