《游戏服务端架构·核心系统与契约》10.1 邮件投递与去重

《裂隙战场》的结算邮件、《群岛纪元》的全服补偿、《泡泡工坊》的登录奖励,都要求同一业务事件只产生一封邮件。本节比较直写、消息队列与批量扇出三种投递模型,拆解事件 ID、模板与收件人组成的去重键,说明重试、死信和全服邮件游标如何避免重复与漏发,并给出可运行的幂等投递队列。

邮件是奖励链路的最后一公里

《裂隙战场》一局结束,系统要把胜负奖励、段位变化和举报回执塞进同一个结算流程;《群岛纪元》凌晨维护后,要给全服玩家补发停服补偿;《泡泡工坊》把每日登录奖励做成早上八点到达的邮件。三个场景看起来都是“写一条邮件记录”,但它们的失败代价完全不同:结算邮件少了,玩家会认为系统吞了奖励;全服补偿重复了,经济系统会凭空多出一批资源;登录奖励晚了十分钟,玩家只是错过一次签到。

邮件之所以值得单独设计,是因为它把两个通常分开的问题绑在了一起:可靠送达和恰好一次的业务效果。前者要求至少投递一次,网络超时、进程重启、消费者重投都会让同一条消息出现多次;后者要求同一业务事件最多落一封邮件。只做前者会重复,只做后者会丢件。邮件系统真正的契约是“至少一次投递,幂等落库”。

本节先比较三种投递模型,再把去重键拆开,最后给出一个可运行的幂等队列与全服邮件游标。附件领取、过期和红点不在本节展开,分别放到后续两节。

三种投递模型

投递模型的选择不取决于邮件量,而取决于谁能接受延迟、谁能承担丢失、失败是否要立刻可见。

模型典型延迟(示例值)可靠性失败可见性主要代价适用
业务直写数据库同事务,毫秒级与业务同生共死立即,接口返回错误邮件库故障会拖垮结算单局结算、支付发货
消息队列异步投递百毫秒到数秒至少一次,需消费者幂等延迟可见,需监控积压重复、乱序、死信登录奖励、活动补发
批量扇出分钟到小时游标可恢复,分片成败独立进度可见,单点失败可重跑游标与批次管理全服补偿、赛季结算

《裂隙战场》的结算邮件走业务直写:结算本身已经在一个事务边界内,邮件与奖励同成败,少一封就无法向玩家解释。《泡泡工坊》的登录奖励走消息队列:登录接口不应该因为邮件库抖动而失败,晚几秒到达完全可以接受。《群岛纪元》的全服补偿走批量扇出:几百万收件人不可能塞进一个事务,必须分片、限速、可恢复。

三种模型可以共存,但去重键必须统一。如果直写路径用订单号去重、队列路径用消息 ID 去重,同一个补偿从两条路径进来就会落两封邮件。去重键是业务语义,不是传输层字段。

去重键:业务事件 + 模板 + 收件人

最容易被误用的键是邮件 ID。邮件 ID 通常在落库时才生成,生产者在重试时拿不到上一次的 ID,于是每次都生成新 ID,去重形同虚设。另一个常见错误是只用事件 ID:一个“赛季结束”事件要发给全服,事件 ID 相同,收件人不同,必须允许每个收件人各落一封。

我们固定使用三段键:

dedup_key = event_id + 0x1f + template + 0x1f + recipient

event_id 是业务事实的标识,例如 settle:battle-8821、compensation:maintenance-20261030;template 区分同一事件的不同邮件,例如“排名奖励”和“参与奖励”;recipient 是角色 ID。三段缺一不可:

场景只用事件 ID只用收件人三段键
生产者超时重试正确去重误杀后续同模板邮件正确去重
全服事件扇出只落第一封,其余丢失同一玩家不同事件互相覆盖每人一封,事件隔离
同一事件两种模板第二封被误判重复两封都落,但无法区分来源各自独立
客服手工补发与自动邮件冲突与历史邮件冲突用独立事件 ID,可审计

键的保留时间要覆盖最长重试窗口。示例值:业务重试最长 24 小时,人工补发与对账保留 30 天,则去重集合至少保留 30 天。保留期短于重试窗口,重试就会穿透;保留期无限增长,存储成本会失控。生产上通常把键写入邮件主表的唯一索引,而不是只放在缓存里,缓存失效不能成为重复的理由。

直写、队列与扇出的失败边界

直写模型的边界是数据库事务。邮件表、附件表、奖励流水在同一个分片内时,可以一个事务提交;跨分片时不要引入两阶段提交,而是把“邮件已创建”作为本地事实,再用补偿任务推进下游。

队列模型的边界是消费者。生产者只保证“消息已入队”,消费者必须用同一个去重键做幂等落库。Ack 只能发生在落库成功之后;落库成功但 Ack 丢失,消息会重投,此时去重键负责挡住第二封。

批量扇出的边界是游标。全服邮件不是“一条消息广播给所有人”,而是“为每个收件人生成一条投递任务”。一百万玩家就是一百万条任务,区别只在于生成和消费是否分片。游标记录“已经生成到哪个收件人”,重启后从游标继续,而不是从头再扇一次。即使从头再来,统一去重键也能兜底,但会产生大量无效写入和重复告警。

全服邮件扇出
  snapshot recipients -> sort -> cursor
       |
       v
  chunk(1000) -> enqueue jobs -> persist cursor
       |
       v
  workers -> per-recipient dedup key -> mail row
       |
       +--> retry with backoff --> dead letter

快照必须在扇出开始时固定。如果一边扇出一边读取“当前在线玩家”,中途上线或转服的人会收到半封邮件,重跑时收件人集合又变了,无法判断哪些人漏发。正确做法是先落一份收件人快照,记录快照版本,再按快照推进;新增玩家由下一次补偿覆盖。

可运行片段:幂等队列与可恢复扇出

下面的 internal/maildelivery 把投递队列拆成三个文件:types.go 定义任务与策略,queue.go 实现去重、租约、重试和死信,fanout.go 实现全服邮件的分片游标。文章只嵌入后两个文件,类型定义在包内可见。

先看队列。Enqueue 在写入 pending 的同时写入 seen 集合;Lease 只返回到期任务;Nack 按指数退避重排,达到上限后进入死信。生产实现要把 pending、seen 和状态变更放在同一个持久化事务里,内存版本只是把状态机固定下来。

// file: internal/maildelivery/queue.go
// tier: L1
// verify: go test ./internal/maildelivery/
package maildelivery

import (
	"sort"
	"time"
)

// Queue is an in-memory delivery queue. Production keeps the same state
// machine on a durable store, where the dedupe set and the pending job commit
// in one transaction.
type Queue struct {
	opts     Options
	pending  map[string]Job
	inFlight map[string]Job
	seen     map[string]struct{}
	dead     map[string]DeadLetter
	stats    Stats
}

func NewQueue(opts Options) *Queue {
	def := DefaultOptions()
	if opts.MaxAttempts <= 0 {
		opts.MaxAttempts = def.MaxAttempts
	}
	if opts.BaseBackoff <= 0 {
		opts.BaseBackoff = def.BaseBackoff
	}
	if opts.MaxBackoff < opts.BaseBackoff {
		opts.MaxBackoff = opts.BaseBackoff
	}
	return &Queue{
		opts:     opts,
		pending:  make(map[string]Job),
		inFlight: make(map[string]Job),
		seen:     make(map[string]struct{}),
		dead:     make(map[string]DeadLetter),
	}
}

// DedupKey includes the recipient because one event fans out to many
// mailboxes. The separator is ASCII unit separator, not a legal player ID.
func DedupKey(eventID, template, recipient string) string {
	return eventID + "\x1f" + template + "\x1f" + recipient
}

func validate(j Job) error {
	if j.Key == "" {
		return ErrEmptyKey
	}
	if j.Recipient == "" {
		return ErrEmptyRecipient
	}
	if j.Template == "" {
		return ErrEmptyTemplate
	}
	return nil
}

// Enqueue inserts a job unless its key was seen. The seen set covers pending,
// in-flight and delivered keys, so a retry after Ack is also suppressed.
func (q *Queue) Enqueue(j Job) (Result, error) {
	if err := validate(j); err != nil {
		return 0, err
	}
	if _, ok := q.seen[j.Key]; ok {
		q.stats.Duplicates++
		return ResultDuplicate, nil
	}
	q.pending[j.Key] = j
	q.seen[j.Key] = struct{}{}
	q.stats.Enqueued++
	return ResultEnqueued, nil
}

// Lease picks one due job. Ties break by key so replays are deterministic.
func (q *Queue) Lease(now time.Time) (Job, bool) {
	var bestKey string
	var best Job
	found := false
	for key, job := range q.pending {
		if job.NextAt.After(now) {
			continue
		}
		if !found || job.NextAt.Before(best.NextAt) || (job.NextAt.Equal(best.NextAt) && key < bestKey) {
			bestKey, best, found = key, job, true
		}
	}
	if !found {
		return Job{}, false
	}
	delete(q.pending, bestKey)
	q.inFlight[bestKey] = best
	return best, true
}

// Ack marks an in-flight job delivered. The key stays in the seen set.
func (q *Queue) Ack(key string) error {
	if _, ok := q.inFlight[key]; !ok {
		return ErrNotInFlight
	}
	delete(q.inFlight, key)
	q.stats.Delivered++
	return nil
}

// Nack returns a job to the queue or moves it to the dead-letter set.
func (q *Queue) Nack(key string, now time.Time, cause string) (bool, error) {
	job, ok := q.inFlight[key]
	if !ok {
		return false, ErrNotInFlight
	}
	delete(q.inFlight, key)
	job.Attempts++
	if job.Attempts >= q.opts.MaxAttempts {
		q.dead[key] = DeadLetter{Job: job, Cause: cause, At: now}
		q.stats.DeadLettered++
		return false, nil
	}
	job.NextAt = now.Add(q.backoff(job.Attempts))
	q.pending[key] = job
	q.stats.Retried++
	return true, nil
}

func (q *Queue) backoff(attempts int) time.Duration {
	if attempts <= 1 {
		return q.opts.BaseBackoff
	}
	if attempts > 20 {
		return q.opts.MaxBackoff
	}
	d := q.opts.BaseBackoff << (attempts - 1)
	if d <= 0 || d > q.opts.MaxBackoff {
		return q.opts.MaxBackoff
	}
	return d
}

// DeadLetters returns a stable copy of the dead-letter set.
func (q *Queue) DeadLetters() []DeadLetter {
	out := make([]DeadLetter, 0, len(q.dead))
	for _, d := range q.dead {
		out = append(out, d)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Job.Key < out[j].Job.Key })
	return out
}

func (q *Queue) Stats() Stats {
	return q.stats
}

几个边界值得单独说明。第一,seen 包含已投递的键,所以 Ack 之后的重复投递也会被挡住;如果业务允许“同一事件再次发送”,必须换一个新的 event_id,而不是删除去重键。第二,Lease 的平局按 key 排序,保证重放和测试可复现;生产上还应加租约超时,避免 worker 崩溃后任务永远停在 in-flight。第三,退避上限不是越大越好:上限过长会让故障恢复后的积压消化变慢,示例值一般取 1 分钟到 5 分钟。

扇出游标只负责“生成任务”,不负责“保证每个任务成功”。生成失败时游标不前移,重跑会重复生成同一批 key,入队侧去重会吸收;消费失败则交给队列的重试和死信。两层各自幂等,合起来才是可恢复的全服邮件。

// file: internal/maildelivery/fanout.go
// tier: L1
// verify: go test ./internal/maildelivery/
package maildelivery

import "sort"

// Fanout is a resumable cursor for global mail. The caller persists the cursor
// after each chunk so a restart resumes instead of resending every recipient.
type Fanout struct {
	Template   string
	Batch      string
	Recipients []string
	cursor     int
}

func NewFanout(template, batch string, recipients []string) (*Fanout, error) {
	if template == "" {
		return nil, ErrEmptyTemplate
	}
	if batch == "" {
		return nil, ErrEmptyKey
	}
	seen := make(map[string]struct{}, len(recipients))
	out := make([]string, 0, len(recipients))
	for _, r := range recipients {
		if r == "" {
			return nil, ErrEmptyRecipient
		}
		if _, ok := seen[r]; ok {
			continue
		}
		seen[r] = struct{}{}
		out = append(out, r)
	}
	sort.Strings(out)
	return &Fanout{Template: template, Batch: batch, Recipients: out}, nil
}

// Next returns at most limit jobs and advances the cursor.
func (f *Fanout) Next(limit int) []Job {
	if limit <= 0 || f.cursor >= len(f.Recipients) {
		return nil
	}
	end := f.cursor + limit
	if end > len(f.Recipients) {
		end = len(f.Recipients)
	}
	jobs := make([]Job, 0, end-f.cursor)
	for _, r := range f.Recipients[f.cursor:end] {
		jobs = append(jobs, Job{
			Key:       DedupKey(f.Batch, f.Template, r),
			Recipient: r,
			Template:  f.Template,
			Batch:     f.Batch,
		})
	}
	f.cursor = end
	return jobs
}

func (f *Fanout) Done() bool {
	return f.cursor >= len(f.Recipients)
}

NewFanout 会去重并排序收件人,Next 按固定大小的块推进游标。排序不是为了美观,而是为了让“同一份快照、同一个游标”在重跑时产生相同的任务顺序,便于对账。生产上把游标与快照版本一起持久化,块大小按下游写入能力调整,示例值从 500 到 2000 起步,压测后再定。

失败模式与兜底

失败表现兜底
生产者超时后重试同一事件入队两次统一去重键,唯一索引落库
消费者落库成功但 Ack 丢失消息重投落库幂等,重复消息直接 Ack
去重集合被提前清理老事件重放时重复发信保留期覆盖重试与对账窗口,清理任务告警
全服扇出中途重启部分玩家收到,部分没有持久化游标与快照版本,从游标续跑
游标被重置全量重发固定快照 + 去重键吸收重复,重发只产生告警
死信无人处理奖励长期未送达死信计数告警,按模板聚合,人工重放
退避时间过长故障恢复后积压消化慢上限控制在分钟级,按队列深度动态调整

死信不是垃圾桶。每一条死信都要带模板、收件人、尝试次数和最后一次错误;按模板聚合后,如果同一模板在短时间内大量死信,通常不是收件人的问题,而是模板参数、下游限流或数据格式出了问题。此时继续重试只会放大故障,应该先停掉该模板的消费,再修复重放。

另一个容易忽略的失败是时钟。NextAt 用绝对时间计算退避,如果多台机器时钟偏差较大,任务可能提前或延后到期。生产上应使用统一时间源,或者存“首次失败时间 + 尝试次数”,由消费者按自己的时钟计算下次到期,避免把时钟偏差写进数据。

本机验证

三个包共用同一个 lab 模块。本节只跑投递包,命令与结果如下:

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/maildelivery
$ go vet ./internal/maildelivery/...
$ go test -count=1 ./internal/maildelivery/
ok  	gsarchlab/internal/maildelivery

本机退出码为 0,包内 7 个测试通过,其中 4 个是表驱动测试;gofmt -l 与 go vet 均无输出。测试覆盖重复键、到期租约、退避后死信、Ack 边界、退避上限、扇出确定性与断点续跑。

常见错误

第一,用邮件 ID 做去重。邮件 ID 是落库结果,不是业务事实;生产者在重试时拿不到它。

第二,只对队列消息去重,不对直写路径去重。两条路径最终写同一张邮件表,去重必须在落库边界统一。

第三,全服邮件写成一个事务。收件人越多,事务越大,失败后重试成本越高;分片加游标才是可持续的做法。

第四,死信只计数不聚合。按模板和错误类型聚合,才能区分“个别玩家数据异常”和“整批模板故障”。

第五,去重键保留期拍脑袋。它必须覆盖最长重试窗口和对账窗口,否则老事件重放会穿透。

小结

邮件投递的核心不是“把消息发出去”,而是“同一业务事实只落一封邮件”。直写、队列、扇出三种模型可以共存,但去重键必须统一为事件、模板、收件人的组合;重试与死信负责送达,游标与快照负责全服扇出的可恢复性。下一节进入邮件的另一半:正文之外的附件如何领取、何时过期,以及过期后的资产归谁。

阅读导航:上一节:9.3 榜单的读模型与缓存 · 下一节:10.2 附件、过期与流拍结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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