告警没响,玩家已经等了四十七分钟
《群岛纪元》赛季结算夜,联盟邮件的 fanout 队列在 23:40 有 12 万条待消费,告警阈值是 20 万,没有触发。00:10 开始有玩家反馈“赛季奖励没到”,值班同学才去看队列:最老的一条消息已经等了 47 分钟。
原因不是生产太快,而是一个联盟的附件模板触发了消费端死循环:该分区头部的一条消息反复失败,后面的消息全部堵住。条数只有 12 万,是因为消费端在其他分区仍在推进,总量看起来“还行”;但对卡住的那个分区来说,延迟已经是不可接受的四十七分钟。
同一周,《裂隙战场》出现了另一个方向的问题。结算消费者把失败消息放回队尾重试,下一条针对同一 match_id 的结算先被处理,于是同一局被扣了两次分。团队以为“消息没丢”就等于“顺序没问题”,实际两者是独立的承诺。
这一节讨论积压怎么度量、顺序要付多少并发代价、死信如何收场。
三个积压指标,回答三个不同问题
| 指标 | 回答的问题 | 典型误用 | 建议用途 |
|---|---|---|---|
| 条数 | 还有多少条没处理 | 生产与消费同速时永远不高 | 容量规划、趋势面板 |
| 字节 | 占了多少存储与网络 | 大消息少、小消息多时失真 | 磁盘水位、保留时长 |
| 最老消息年龄 | 玩家实际等了多久 | 空分区拉高平均值 | 主告警依据 |
条数是速率差,不是延迟。生产 800 条/秒、消费 790 条/秒时,条数一小时才涨 3.6 万;可每条消息的等待时间在持续增加。反过来,活动结束后瞬间灌入 50 万条、消费端两分钟清空,条数峰值很吓人,玩家却没有感知。
字节数适合回答“会不会把磁盘写满”,它不回答“玩家等多久”。一个 2 KB 的附件消息卡在头部,和十万条 100 字节的埋点,字节数可能相同,体验完全不同。
因此告警顺序应当是:
- 最老消息年龄超过业务容忍窗口,立即 page。结算类窗口按分钟,社交类按十分钟,埋点类可以按小时。
- 字节数超过保留容量的 70%,提前 warn,避免消息被截断。
- 条数只放面板,用于判断消费能力是否需要扩容,不单独触发夜间告警。
《群岛纪元》后来把 page 条件改成“任一分区最老消息年龄大于 5 分钟”,那次事故会在 23:45 之前暴露,而不是等玩家来报。
顺序的代价:并发度换确定性
顺序不是免费属性。要保证顺序,最直接的办法是单分区串行消费:
| 方案 | 顺序范围 | 并发度 | 故障影响 | 适用 |
|---|---|---|---|---|
| 单分区 | 全局 | 1 个消费者 | 一条毒消息阻塞全部 | 极低频、强全局序 |
| 按业务键分区 | 同一键内 | 分区数 | 只阻塞该键 | 结算、背包、订单 |
| 无序并发 | 无 | 消费者数 | 互不影响 | 埋点、通知、统计 |
| 版本号 CAS | 无顺序但可拒绝旧写 | 高 | 冲突重试 | 可并发的状态更新 |
《裂隙战场》的示例预算是:峰值结算 800 条/秒,单消费者处理能力 1,500 条/秒。单分区看似够用,但只剩 47% 余量,一次 GC 抖动就会积压;更麻烦的是任意一条毒消息会阻塞所有对局。改成按 match_id 分 64 个分区后,单分区约 12.5 条/秒,余量充足,且一局内的结算严格有序。以上数字是示例值,实际分区数要按压测结果定。
分区键的选择决定“哪些东西必须排队”:
| 链路 | 需要的有序范围 | 建议键 | 说明 |
|---|---|---|---|
| 战斗结算 | 同一局 | match_id | 不同局互不影响 |
| 背包变更 | 同一玩家 | player_id | 或用版本号 CAS 替代 |
| 支付发货 | 同一订单 | order_id | 订单状态机必须串行 |
| 联盟邮件 fanout | 同一收件人 | player_id | 不要按联盟 ID 聚成热键 |
| 埋点 | 无 | 随机 | 顺序没有业务价值 |
热键是分区方案的常见反例:某大联盟占全服 40% 的邮件量,若按 alliance_id 分区,单个分区会承担近半负载。正确做法是 fanout 阶段展开到收件人,再按 player_id 投递;联盟层面的“统一发送时间”用调度保证,不靠队列顺序保证。
还有一条硬约束:分区数不能在线随意增加。键到分区的映射通常取模,分区数一变,同一个 player_id 会漂到新分区,旧分区里尚未消费的消息与新分区的消息并发执行,顺序承诺当场失效。扩容要按“新建主题加双写迁移”处理,不能改一个配置了事。
死信:隔离毒消息,不是删除失败
重试策略必须先回答“失败消息放回哪里”:
- 放回队首:保持顺序,但毒消息会阻塞整个分区,直到预算耗尽。
- 放回队尾:不阻塞,但同一键的后继消息会超车,顺序承诺失效。
本节的夹具选择“队首有界重试加死信隔离”:失败消息在队首重试,达到上限后移入死信,分区继续前进。这样顺序在预算内保持不变,毒消息也不会永久阻塞。代价是重试期间该分区延迟上升,所以预算不能大:结算类示例值 3 次,社交类 5 次。
pending -> handling -> acked
|
| error, attempts < max
v
retry(head)
|
| attempts = max
v
dead-letter -> 人工重放(必须幂等) -> acked
死信记录至少保留:原主题、分区键、消息 ID、尝试次数、首次与最后一次错误、负载引用。只存一个消息 ID 不够,半年后重放时原负载可能已经过期。
重放有三条规则:
- 根因修复后才重放,否则只是把毒消息再喂一遍。
- 按原分区键分批重放,保持同一键内的相对顺序。
- 重放前查询业务侧是否已生效;已生效的跳过,不能依赖“应该没执行过”。
可运行片段:分区、积压与死信
internal/backlogorder 用内存分区队列模拟上述约束:PartitionOf 固定键归属,Drain 在队首做有界重试并把超限消息移入死信,Backlog 同时给出条数、字节与最老年龄,Replay 要求调用方提供“是否已应用”的判断。
// file: internal/backlogorder/backlogorder.go
// tier: L1
// verify: go test ./internal/backlogorder/
package backlogorder
import (
"errors"
"hash/fnv"
"sync"
"time"
)
// Record 是分区队列中的一条待处理记录。
type Record struct {
Offset int64
Key string
Payload []byte
EnqueuedAt time.Time
Attempts int
}
// PartitionOf 用 FNV-1a 把业务键映射到固定分区,同一键永远落在同一分区。
func PartitionOf(key string, partitions int) int {
if partitions < 1 {
partitions = 1
}
h := fnv.New32a()
_, _ = h.Write([]byte(key))
return int(h.Sum32() % uint32(partitions))
}
// DeadLetter 保存被隔离的毒消息及其失败原因。
type DeadLetter struct {
Record Record
Reason string
FailedAt time.Time
}
// Queue 是内存分区队列,用锁模拟每个分区只有一个消费者。
type Queue struct {
mu sync.Mutex
partitions [][]Record
dead []DeadLetter
}
// New 创建固定数量的分区;分区数一旦确定不可在线增减,否则键的归属会漂移。
func New(partitions int) *Queue {
if partitions < 1 {
partitions = 1
}
return &Queue{partitions: make([][]Record, partitions)}
}
// Publish 返回记录落入的分区号,便于测试与埋点。
func (q *Queue) Publish(r Record) int {
p := PartitionOf(r.Key, len(q.partitions))
q.mu.Lock()
defer q.mu.Unlock()
q.partitions[p] = append(q.partitions[p], r)
return p
}
// Drain 按顺序消费单个分区。失败记录在队首重试;达到上限后进入死信,
// 分区继续前进,毒消息不会永久阻塞后面的记录。
func (q *Queue) Drain(partition, maxAttempts int, handle func(Record) error) (delivered, dead int) {
if maxAttempts < 1 {
maxAttempts = 1
}
q.mu.Lock()
defer q.mu.Unlock()
if partition < 0 || partition >= len(q.partitions) {
return 0, 0
}
pending := q.partitions[partition]
q.partitions[partition] = nil
for len(pending) > 0 {
rec := pending[0]
if err := handle(rec); err != nil {
rec.Attempts++
if rec.Attempts >= maxAttempts {
q.dead = append(q.dead, DeadLetter{
Record: rec,
Reason: err.Error(),
FailedAt: time.Now(),
})
pending = pending[1:]
dead++
continue
}
pending[0] = rec
// 本轮到此为止:保持顺序,等调用方修复原因后再次 Drain。
break
}
pending = pending[1:]
delivered++
}
q.partitions[partition] = pending
return delivered, dead
}
// Backlog 是一次积压快照。条数、字节数、最老消息年龄回答三个不同问题。
type Backlog struct {
Count int
Bytes int
OldestAge time.Duration
}
// Backlog 汇总全部分区的积压。
func (q *Queue) Backlog(now time.Time) Backlog {
q.mu.Lock()
defer q.mu.Unlock()
var b Backlog
for _, pending := range q.partitions {
for _, rec := range pending {
b.Count++
b.Bytes += len(rec.Payload)
age := now.Sub(rec.EnqueuedAt)
if age > b.OldestAge {
b.OldestAge = age
}
}
}
return b
}
// AlertPolicy 以最老消息年龄为主判据,条数只做辅助告警。
type AlertPolicy struct {
WarnAge time.Duration
PageAge time.Duration
MaxCount int
}
// Evaluate 返回 ok、warn 或 page。
func (p AlertPolicy) Evaluate(b Backlog) string {
switch {
case p.PageAge > 0 && b.OldestAge >= p.PageAge:
return "page"
case p.WarnAge > 0 && b.OldestAge >= p.WarnAge:
return "warn"
case p.MaxCount > 0 && b.Count >= p.MaxCount:
return "warn"
default:
return "ok"
}
}
// DeadLetters 返回死信副本。
func (q *Queue) DeadLetters() []DeadLetter {
q.mu.Lock()
defer q.mu.Unlock()
out := make([]DeadLetter, len(q.dead))
copy(out, q.dead)
return out
}
var errEmptyDeadLetter = errors.New("backlogorder: empty dead-letter id")
// Replay 重放死信。applied 返回 true 表示业务侧已处理过,必须跳过;
// 成功重放的记录从死信列表移除,失败的保留供人工介入。
func (q *Queue) Replay(applied func(Record) bool, handle func(Record) error) (replayed, skipped int, err error) {
q.mu.Lock()
defer q.mu.Unlock()
var remain []DeadLetter
for _, dl := range q.dead {
if dl.Record.Key == "" {
err = errEmptyDeadLetter
remain = append(remain, dl)
continue
}
if applied != nil && applied(dl.Record) {
skipped++
continue
}
if handle != nil {
if herr := handle(dl.Record); herr != nil {
dl.Reason = herr.Error()
remain = append(remain, dl)
continue
}
}
replayed++
}
q.dead = remain
return replayed, skipped, err
}
AlertPolicy.Evaluate 的顺序值得注意:先看最老年龄是否到 page,再看 warn 年龄,最后才看条数。条数永远不能单独把值班同学叫醒,这是对上面事故最直接的修正。
本机验证命令与结果:
cd .local/checks/gsarch-lab
go test -count=1 ./internal/backlogorder/
ok gsarchlab/internal/backlogorder
该文件另有 5 个表驱动测试,覆盖分区稳定性、分区内顺序、毒消息隔离、积压告警与幂等重放;退出码为 0。
小结
积压要按“玩家等多久”度量,最老消息年龄是主告警,条数和字节各有各的用途。顺序要用并发度换:按业务键分区能保住单键顺序,代价是热键和不可随意扩容。死信是隔离区,不是垃圾桶;重试预算要小,重放必须幂等且按原键分批。
常见错误有五个:用条数当唯一告警;把失败消息丢回队尾还声称有序;死信只存消息 ID;重放前不查业务是否已生效;在线增加分区数导致键漂移。前两个会在事故当晚出现,后三个通常在一个季度后的某次重放中集中爆发。
下一节把视角从“单条消息”抬到“一批结果”:发奖、支付发货、跨服结算这些异步链路,如何用对账发现差异,用补偿任务收尾,以及定时任务怎样选主、断点续跑。
阅读导航:上一节:6.1 队列选型与投递语义 · 下一节:6.3 对账、补偿与定时任务 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。