《游戏服务端架构·规模化、全球化与成本》6.2 积压、顺序与死信

《群岛纪元》赛季结算夜,队列只有 12 万条、没到告警阈值,最老消息却已等了 47 分钟;《裂隙战场》把失败结算丢回队尾重试,后到的结算先执行,同一局被扣了两次分。本节比较条数、字节与最老消息年龄三个积压指标,拆开顺序与并发的取舍,并给出毒消息隔离与幂等重放的可运行夹具。

告警没响,玩家已经等了四十七分钟

《群岛纪元》赛季结算夜,联盟邮件的 fanout 队列在 23:40 有 12 万条待消费,告警阈值是 20 万,没有触发。00:10 开始有玩家反馈“赛季奖励没到”,值班同学才去看队列:最老的一条消息已经等了 47 分钟。

原因不是生产太快,而是一个联盟的附件模板触发了消费端死循环:该分区头部的一条消息反复失败,后面的消息全部堵住。条数只有 12 万,是因为消费端在其他分区仍在推进,总量看起来“还行”;但对卡住的那个分区来说,延迟已经是不可接受的四十七分钟。

同一周,《裂隙战场》出现了另一个方向的问题。结算消费者把失败消息放回队尾重试,下一条针对同一 match_id 的结算先被处理,于是同一局被扣了两次分。团队以为“消息没丢”就等于“顺序没问题”,实际两者是独立的承诺。

这一节讨论积压怎么度量、顺序要付多少并发代价、死信如何收场。

三个积压指标,回答三个不同问题

指标回答的问题典型误用建议用途
条数还有多少条没处理生产与消费同速时永远不高容量规划、趋势面板
字节占了多少存储与网络大消息少、小消息多时失真磁盘水位、保留时长
最老消息年龄玩家实际等了多久空分区拉高平均值主告警依据

条数是速率差,不是延迟。生产 800 条/秒、消费 790 条/秒时,条数一小时才涨 3.6 万;可每条消息的等待时间在持续增加。反过来,活动结束后瞬间灌入 50 万条、消费端两分钟清空,条数峰值很吓人,玩家却没有感知。

字节数适合回答“会不会把磁盘写满”,它不回答“玩家等多久”。一个 2 KB 的附件消息卡在头部,和十万条 100 字节的埋点,字节数可能相同,体验完全不同。

因此告警顺序应当是:

  1. 最老消息年龄超过业务容忍窗口,立即 page。结算类窗口按分钟,社交类按十分钟,埋点类可以按小时。
  2. 字节数超过保留容量的 70%,提前 warn,避免消息被截断。
  3. 条数只放面板,用于判断消费能力是否需要扩容,不单独触发夜间告警。

《群岛纪元》后来把 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 不够,半年后重放时原负载可能已经过期。

重放有三条规则:

  1. 根因修复后才重放,否则只是把毒消息再喂一遍。
  2. 按原分区键分批重放,保持同一键内的相对顺序。
  3. 重放前查询业务侧是否已生效;已生效的跳过,不能依赖“应该没执行过”。

可运行片段:分区、积压与死信

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 对账、补偿与定时任务 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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