一次重投,两份奖励
凌晨三点十二分,《群岛纪元》的消息集群做了一次主从切换。切换本身只用了四十秒,监控上看不出异常;第二天上午客服收到 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-once | match_id | 重算一局可接受,丢结算不可接受 |
| 《裂隙战场》 | 观战热度统计 | at-most-once | 无 | 少一次统计不影响体验 |
| 《群岛纪元》 | 大地图异步结算 | at-least-once | settlement_id | 离线推进必须最终执行 |
| 《群岛纪元》 | 联盟动态流 | at-most-once | 无 | 允许缺行,不允许重复刷屏 |
| 《泡泡工坊》 | 支付发货 | at-least-once | order_id | 掉单是资损,重复发货也是资损 |
| 《泡泡工坊》 | 登录奖励 | at-least-once | player_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 积压、顺序与死信 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。