邮件是奖励链路的最后一公里
《裂隙战场》一局结束,系统要把胜负奖励、段位变化和举报回执塞进同一个结算流程;《群岛纪元》凌晨维护后,要给全服玩家补发停服补偿;《泡泡工坊》把每日登录奖励做成早上八点到达的邮件。三个场景看起来都是“写一条邮件记录”,但它们的失败代价完全不同:结算邮件少了,玩家会认为系统吞了奖励;全服补偿重复了,经济系统会凭空多出一批资源;登录奖励晚了十分钟,玩家只是错过一次签到。
邮件之所以值得单独设计,是因为它把两个通常分开的问题绑在了一起:可靠送达和恰好一次的业务效果。前者要求至少投递一次,网络超时、进程重启、消费者重投都会让同一条消息出现多次;后者要求同一业务事件最多落一封邮件。只做前者会重复,只做后者会丢件。邮件系统真正的契约是“至少一次投递,幂等落库”。
本节先比较三种投递模型,再把去重键拆开,最后给出一个可运行的幂等队列与全服邮件游标。附件领取、过期和红点不在本节展开,分别放到后续两节。
三种投递模型
投递模型的选择不取决于邮件量,而取决于谁能接受延迟、谁能承担丢失、失败是否要立刻可见。
| 模型 | 典型延迟(示例值) | 可靠性 | 失败可见性 | 主要代价 | 适用 |
|---|---|---|---|---|---|
| 业务直写数据库 | 同事务,毫秒级 | 与业务同生共死 | 立即,接口返回错误 | 邮件库故障会拖垮结算 | 单局结算、支付发货 |
| 消息队列异步投递 | 百毫秒到数秒 | 至少一次,需消费者幂等 | 延迟可见,需监控积压 | 重复、乱序、死信 | 登录奖励、活动补发 |
| 批量扇出 | 分钟到小时 | 游标可恢复,分片成败独立 | 进度可见,单点失败可重跑 | 游标与批次管理 | 全服补偿、赛季结算 |
《裂隙战场》的结算邮件走业务直写:结算本身已经在一个事务边界内,邮件与奖励同成败,少一封就无法向玩家解释。《泡泡工坊》的登录奖励走消息队列:登录接口不应该因为邮件库抖动而失败,晚几秒到达完全可以接受。《群岛纪元》的全服补偿走批量扇出:几百万收件人不可能塞进一个事务,必须分片、限速、可恢复。
三种模型可以共存,但去重键必须统一。如果直写路径用订单号去重、队列路径用消息 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 附件、过期与流拍结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。