《游戏服务端架构·规模化、全球化与成本》6.1 队列选型与投递语义

《群岛纪元》一次 broker 切换让庆典邮件重投,发奖服务没有幂等,同一份奖励发了两次。本节把 at-most-once、at-least-once 与“业务幂等换 exactly-once”放回北辰三款产品的具体链路,给出延迟、顺序、积压、运维四维选型判据,以及消息键、版本、重试头与死信地址的生产消费契约。

一次重投,两份奖励

凌晨三点十二分,《群岛纪元》的消息集群做了一次主从切换。切换本身只用了四十秒,监控上看不出异常;第二天上午客服收到 87 张工单,都是“庆典邮件领了两次”。活动奖励是 300 钻石,有玩家截图显示到账 600。

复盘结论不复杂:broker 在切换时把一批已投递、未确认的消息重投了一次,这是 at-least-once 的正常行为;发奖消费者把“收到消息”直接当成“执行发奖”,插入发件箱前没有查唯一键。队列没有坏,坏的是契约——没有任何一份文档写清楚“重复投递由谁负责吸收”。

这一节讨论三件事:游戏链路分别能接受哪种投递语义;选队列时应该比较什么;生产者和消费者之间必须约定哪些字段。

三种语义,先看最坏结果

投递语义不是“可靠性等级”,而是“失败时偏向哪一边”。选型时先写下最坏结果,再判断业务能不能承受。

语义承诺最坏结果可以接受的游戏链路不能接受
at-most-once最多一次,不重投消息丢失埋点、观战热度、可重算的日报发奖、支付发货、跨服结算
at-least-once至少一次,可能重投消息重复战斗结算、邮件、订单回调无幂等的任何写操作
exactly-once端到端只生效一次仅在单系统事务内成立同一数据库内的账本记账跨库、跨服务、跨厂商

“exactly-once”在工程上通常不是第三条独立的路,而是 at-least-once 加业务侧幂等。broker 能把“重复”约束在自己的事务边界内,但发奖要写游戏库、支付要调渠道、邮件要落发件箱,这些边界它管不到。因此更准确的说法是:用业务幂等换 exactly-once 的效果。

北辰三款产品的选择并不相同:

产品链路语义幂等键理由
《裂隙战场》战斗结算at-least-oncematch_id重算一局可接受,丢结算不可接受
《裂隙战场》观战热度统计at-most-once无少一次统计不影响体验
《群岛纪元》大地图异步结算at-least-oncesettlement_id离线推进必须最终执行
《群岛纪元》联盟动态流at-most-once无允许缺行,不允许重复刷屏
《泡泡工坊》支付发货at-least-onceorder_id掉单是资损,重复发货也是资损
《泡泡工坊》登录奖励at-least-onceplayer_id + day一天只能领一次

判断口径只有一句:这条链路丢一条和重一条,哪个更贵? 两者都贵,就必须 at-least-once 加幂等,没有第三条捷径。

选型维度:先量化,再比较

队列产品的名字不重要,重要的是它在你工作负载下的五个数。下表权重是《群岛纪元》的示例值,不是通用答案:

维度要问的问题示例权重不达标的后果
延迟入队到消费的 P99 是多少20结算延迟直接变成玩家可感知的卡顿
顺序是否要求同一业务键有序25乱序结算导致重复扣费或状态回退
积压峰值积压能保留多久、能否回放25活动峰值后消息被截断,只能人工补
运维成本团队是否有人能值夜、能否原地扩缩20出故障时无人敢动,恢复时间不可控
已有基础设施northstar-platform 是否已提供10新引入一套系统,监控和权限都要重做

按这张表,三款产品的结论是:

  • 《裂隙战场》要求低延迟、按 match_id 分区、积压很短。适合分区日志型队列,消费端按分区串行,天然保住一局内的顺序。
  • 《群岛纪元》吞吐高、积压可能跨天、需要回放。适合可持久化长积压的分区队列,并额外把冷数据归档到对象存储。
  • 《泡泡工坊》团队小、流量突发、能接受分钟级延迟。适合云托管队列,用运维成本换一点延迟和可控性。

这里不比较具体 broker 的优劣。只要候选方案能在自己的压测负载上满足延迟与积压目标,并且团队能承担它的故障模式,它就是合格选项。不要用别人的基准测试替自己的业务做决定。

生产与消费契约

一份可执行的契约至少包含六个字段,缺一个就会在故障时变成口头约定:

字段归属规则
topic生产方一个主题只表达一种业务事实
semantics双方明确 at-most-once 或 at-least-once
max_attempts消费方达到上限必须进死信,不能无限重试
dead_letter双方死信主题必须有人负责查看和重放
key_field生产方分区键,同一业务实体必须稳定
version_field生产方负载模式版本,消费方不认识的版本进死信

信封上还有三个运行期字段:message_id 用于去重,attempt 由 broker 维护,headers 携带 x-schema-version 与 x-retry-count。生产者不得自己填写 attempt,否则重试预算会被绕过。

幂等责任必须写死:

语义谁负责幂等幂等落点
at-most-once无人负责业务必须能接受丢失
at-least-once消费者唯一键表、状态机 CAS 或幂等令牌

消息生命周期只有五个状态:

produced -> pending -> delivered -> acked
                         |
                         | nack
                         v
                     retrying --(预算耗尽)--> dead-letter

acked 之前崩溃,消息会被重投;acked 之后崩溃,业务已经生效。幂等要覆盖的正是这两者之间的窗口。

失败模式与兜底

失败表现检测兜底
broker 切换一批消息重投消费端重复键计数上升消费端幂等,不靠 broker 保证
消费端崩溃效果已写、Ack 未发同一 message_id 出现两次唯一键或事务内去重表
毒消息单条无限重试拖住分区重试次数与分区滞后同时上升尝试上限加死信隔离
版本升级旧消费者读到新字段反序列化错误率上升不识别版本直接进死信,不做猜测
去重窗口过短超时重投绕过去重重复发生在窗口边缘TTL 覆盖最大重投间隔

最后一条最容易被忽略:很多团队做了去重表,却把 TTL 设成十分钟,而 broker 的最大重投间隔是半小时。窗口一过,重复照旧发生。

可运行片段:把契约写成代码

internal/queuecontract 把上面三张表落成可测试的类型:Contract.Validate 在启动期拒绝不完整契约,Broker.Nack 执行尝试预算,Deduper 提供有界窗口的消费端去重。内存 broker 只是测试替身,生产替换成真实客户端时,Ack/Nack 语义不变。

// file: internal/queuecontract/queuecontract.go
// tier: L1
// verify: go test ./internal/queuecontract/
package queuecontract

import (
	"errors"
	"fmt"
	"sync"
	"time"
)

// Semantics 是主题对消费者做出的投递承诺。
type Semantics string

const (
	AtMostOnce  Semantics = "at-most-once"
	AtLeastOnce Semantics = "at-least-once"
)

// Contract 是单个主题的生产者/消费者协议。
type Contract struct {
	Topic        string
	Semantics    Semantics
	MaxAttempts  int
	DeadLetter   string
	KeyField     string
	VersionField string
}

// Validate 在发布端启动时调用,避免运行到一半才发现契约不完整。
func (c Contract) Validate() error {
	switch {
	case c.Topic == "":
		return errors.New("queuecontract: empty topic")
	case c.Semantics != AtMostOnce && c.Semantics != AtLeastOnce:
		return fmt.Errorf("queuecontract: unknown semantics %q", c.Semantics)
	case c.Semantics == AtLeastOnce && c.MaxAttempts < 1:
		return errors.New("queuecontract: at-least-once needs MaxAttempts >= 1")
	case c.Semantics == AtLeastOnce && c.DeadLetter == "":
		return errors.New("queuecontract: at-least-once needs a dead-letter topic")
	case c.KeyField == "":
		return errors.New("queuecontract: empty key field")
	case c.VersionField == "":
		return errors.New("queuecontract: empty version field")
	}
	return nil
}

// IdempotencyOwner 说明重复投递由谁吸收。
func (c Contract) IdempotencyOwner() string {
	if c.Semantics == AtLeastOnce {
		return "consumer"
	}
	return "none"
}

// Message 是线上信封;Attempt 由 broker 维护,生产者不得自行填写。
type Message struct {
	ID      string
	Key     string
	Version int
	Payload []byte
	Headers map[string]string
	Attempt int
}

// Validate 只检查信封,不解释业务负载。
func (m Message) Validate() error {
	switch {
	case m.ID == "":
		return errors.New("queuecontract: empty message id")
	case m.Key == "":
		return errors.New("queuecontract: empty partition key")
	case m.Version < 1:
		return errors.New("queuecontract: schema version must be >= 1")
	case m.Attempt < 0:
		return errors.New("queuecontract: negative attempt")
	}
	return nil
}

// Broker 是测试用的内存替身;生产替换成真实客户端,Ack/Nack 契约不变。
type Broker struct {
	contract Contract
	mu       sync.Mutex
	pending  []Message
	dead     []Message
	acked    int
}

// NewBroker 拒绝不完整的契约,让配置错误在启动期暴露。
func NewBroker(c Contract) (*Broker, error) {
	if err := c.Validate(); err != nil {
		return nil, err
	}
	return &Broker{contract: c}, nil
}

// Contract 返回 broker 持有的契约副本。
func (b *Broker) Contract() Contract { return b.contract }

// Publish 入队一条消息。
func (b *Broker) Publish(m Message) error {
	if err := m.Validate(); err != nil {
		return err
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.pending = append(b.pending, m)
	return nil
}

// Next 取出最老的消息,调用方必须随后 Ack 或 Nack。
func (b *Broker) Next() (Message, bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if len(b.pending) == 0 {
		return Message{}, false
	}
	m := b.pending[0]
	b.pending = b.pending[1:]
	return m, true
}

// Ack 确认消息已处理。
func (b *Broker) Ack(Message) {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.acked++
}

// Nack 退回消息;达到尝试上限后进入死信。at-most-once 直接丢弃,不重试。
func (b *Broker) Nack(m Message) (deadLettered bool) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if b.contract.Semantics == AtMostOnce {
		return false
	}
	m.Attempt++
	if m.Attempt >= b.contract.MaxAttempts {
		b.dead = append(b.dead, m)
		return true
	}
	b.pending = append(b.pending, m)
	return false
}

// DeadLetters 返回死信副本,调用方不能修改 broker 内部状态。
func (b *Broker) DeadLetters() []Message {
	b.mu.Lock()
	defer b.mu.Unlock()
	out := make([]Message, len(b.dead))
	copy(out, b.dead)
	return out
}

// Acked 返回成功确认的条数。
func (b *Broker) Acked() int {
	b.mu.Lock()
	defer b.mu.Unlock()
	return b.acked
}

// Deduper 在有界窗口内抑制重复消息,键是业务消息 ID 而非负载。
type Deduper struct {
	mu   sync.Mutex
	seen map[string]time.Time
	ttl  time.Duration
	now  func() time.Time
}

// NewDeduper 创建窗口去重器;ttl 必须覆盖最大重投间隔。
func NewDeduper(ttl time.Duration) *Deduper {
	return &Deduper{seen: make(map[string]time.Time), ttl: ttl, now: time.Now}
}

// First 判断 ID 是否首次出现并记录。过期项惰性清理,窗口不会无限增长。
func (d *Deduper) First(id string) bool {
	d.mu.Lock()
	defer d.mu.Unlock()
	now := d.now()
	for k, at := range d.seen {
		if now.Sub(at) >= d.ttl {
			delete(d.seen, k)
		}
	}
	if _, ok := d.seen[id]; ok {
		return false
	}
	d.seen[id] = now
	return true
}

片段的关键不是队列实现,而是三条约束:at-most-once 的 Nack 直接丢弃、不重试;at-least-once 必须有死信主题;去重键是业务消息 ID,不是负载哈希。用负载哈希做键,同一业务事实的两次不同序列化会被当成两条消息。

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/queuecontract/
ok  gsarchlab/internal/queuecontract

该文件另有 5 个表驱动测试,覆盖契约校验、信封校验、尝试预算、去重窗口与幂等责任归属;退出码为 0。

小结

队列选型不是选一个产品,而是选一组最坏结果。先把链路按“丢失贵还是重复贵”分类,再决定语义;再用延迟、顺序、积压、运维、存量设施五个维度约束候选;最后把键、版本、重试预算和死信地址写进契约。exactly-once 只在单系统事务内成立,跨系统一律回到“at-least-once 加消费端幂等”。

常见错误有四个:把 at-most-once 用在发奖;用负载哈希当幂等键;去重窗口短于最大重投间隔;死信主题建了却没人看。前三个会在故障当晚暴露,最后一个会安静地积累几个月。

下一节讨论积压本身:条数、字节和最老消息年龄,哪个才应该触发告警;以及为了顺序,分区到底要付出多少并发代价。

阅读导航:上一节:5.3 读写分离的可见性兜底 · 下一节:6.2 积压、顺序与死信 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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