15.1 邮件投递与去重
邮件是 MMO 的万能投递通道。活动奖励、GM 补偿、拍卖成交与流拍、好友赠礼、系统公告,几乎全部落在同一个入口:给某个角色发一封信,信里可能带附件。它值得单独成章,是因为它同时踩中了三个分布式问题——重复投递、扇出、容量。
先说重复。一条「发奖」邮件从产生到落进玩家邮箱,中间可能经过运营后台重试、跨服同步、掉线补发;任何一步重来一次,玩家就可能收到两封、领到两份。再说扇出。全服补偿是「一份模板 × 十万个角色」,如果开服就为每个角色写一条记录,一次补偿就是十万次写。最后说容量。邮箱不能无限长,满了要淘汰谁?淘汰错了就是把没领的奖励删了。
这一节把邮箱内核从零写出来,重点是投递为什么必须幂等、幂等的记忆该放在哪里,以及全服邮件为什么不能真的扇出。代码是 L1,go test 直接能跑。
与站内实战文章的分工
站内 game/server/practice/ 专题里有专门讲邮件后端与收件箱通知架构的文章。它们负责「线上怎么设计、字段怎么定、怎么和运营后台对接」,本节负责「从零写出可运行的那一层,并用测试把边界钉死」:
| 站内既有文章 | 它负责的问题 | 本节负责的部分 |
|---|---|---|
/game-mail-system-backend-details/ | 邮件后端的表结构、附件与有效期的整体设计、与运营后台的对接 | 只把「邮箱 + 去重 + 投递」这一个内核从零写出来 |
/game-server-player-inbox-notification-architecture/ | 收件箱的通知链路、未读与红点的整体方案 | 本节只做邮箱存储与投递去重,红点与离线同步见 15.3 |
一句话:要看架构取舍与线上字段设计去读那两篇,要一份能 go test 通过的邮箱内核读本节。
邮箱模型与容量上界
一封信(Mail)有这些字段:唯一 ID、种类 Kind、发件人、标题、正文、附件列表、创建时间、过期时间,以及 Read / Claimed 两个状态位。附件是「配置表物品 ID + 数量」的列表,注意它是 ItemID(配置表 ID)而不是背包里的实例 ID——发信时还不知道会落进哪个格子。
邮箱用一张 map[MailID]*Mail 加一条 order 顺序表保存,order 记录插入顺序,淘汰时从最旧的开始。容量策略是本节第一个要拍死的决定:邮箱满时,只淘汰「已读且已领取」的邮件。如果最旧的邮件还没读、或还有没领的附件,宁可返回 ErrMailboxFull 拒绝这次投递,也不能删。把「满就删最旧」当成默认实现,会在补发高峰时悄悄吃掉玩家的未领奖励。
下面这段是 L1,go test 可以直接跑:
// file: internal/mail/mail.go
// tier: L1
// verify: go test ./internal/mail/
package mail
import (
"errors"
"sync"
"time"
)
// PlayerID is the whole-server unique role id.
type PlayerID uint64
// MailID identifies one letter. Global-mail templates and personal mails share
// the id space; the high bit of a template id is set so the two never collide.
type MailID uint64
// ItemID is a config-table item id (not an inventory instance id).
type ItemID uint32
// MailKind selects the expiry policy for a mail.
type MailKind uint8
const (
// KindNormal mails hand their attachments to the bag on expiry.
KindNormal MailKind = iota
// KindAuctionReturn is an unsold-auction return: the escrowed item goes
// back to the seller, and if it is never claimed the expiry sweep settles
// it into bound currency instead of letting it rot in a dead mail.
KindAuctionReturn
)
var (
// ErrDuplicate is returned when the same Mail.ID is delivered twice inside
// the dedup window.
ErrDuplicate = errors.New("mail: duplicate delivery")
// ErrMailboxFull is returned when a mailbox is full and holds no evictable
// (read and claimed) mail.
ErrMailboxFull = errors.New("mail: mailbox full")
// ErrNoSuchMail is returned when a mail id is not present in the mailbox.
ErrNoSuchMail = errors.New("mail: no such mail")
)
// Attachment is one stack of items carried by a mail.
type Attachment struct {
ItemID ItemID
Count uint32
}
// Mail is a single letter that lives in exactly one player's mailbox.
type Mail struct {
ID MailID
Kind MailKind
Sender string
Subject string
Body string
Attachments []Attachment
CreatedAt time.Time
ExpireAt time.Time
Read bool
Claimed bool
}
// dedup remembers recently delivered ids so that a re-delivery after the mail
// has been deleted (claimed and removed) is still rejected. It is a plain set
// with lazy pruning: mail volume per player is low, so the O(n) sweep on
// overflow is amortised and beats a per-call scan.
type dedup struct {
ttl time.Duration
ids map[MailID]time.Time
}
func newDedup(ttl time.Duration) *dedup {
return &dedup{ttl: ttl, ids: make(map[MailID]time.Time)}
}
func (d *dedup) seen(id MailID, now time.Time) bool {
t, ok := d.ids[id]
return ok && now.Sub(t) < d.ttl
}
func (d *dedup) record(id MailID, now time.Time) {
d.ids[id] = now
if len(d.ids) > 4096 {
d.prune(now)
}
}
func (d *dedup) prune(now time.Time) {
for id, t := range d.ids {
if now.Sub(t) >= d.ttl {
delete(d.ids, id)
}
}
}
// Mailbox is one player's mail store. All methods are safe for concurrent use.
type Mailbox struct {
mu sync.Mutex
owner PlayerID
capacity int
mails map[MailID]*Mail
order []MailID // oldest first
dedup *dedup
claimed map[MailID][]bool
}
// NewMailbox builds an empty mailbox. capacity is the hard upper bound on live
// mails; dedupTTL is how long a deleted id stays "seen".
func NewMailbox(owner PlayerID, capacity int, dedupTTL time.Duration) *Mailbox {
if capacity < 1 {
capacity = 1
}
return &Mailbox{
owner: owner,
capacity: capacity,
mails: make(map[MailID]*Mail),
dedup: newDedup(dedupTTL),
}
}
// Deliver inserts m and is idempotent: the second delivery of the same id
// inside the dedup window is a no-op that returns ErrDuplicate. When the
// mailbox is full the oldest read-and-claimed mail is evicted; if none is
// evictable the delivery is refused with ErrMailboxFull.
func (mb *Mailbox) Deliver(m *Mail, now time.Time) error {
mb.mu.Lock()
defer mb.mu.Unlock()
if _, ok := mb.mails[m.ID]; ok {
return ErrDuplicate
}
if mb.dedup.seen(m.ID, now) {
return ErrDuplicate
}
if len(mb.mails) >= mb.capacity {
if !mb.evictOne() {
return ErrMailboxFull
}
}
mb.dedup.record(m.ID, now)
cp := *m
cp.Attachments = append([]Attachment(nil), m.Attachments...)
mb.mails[m.ID] = &cp
mb.order = append(mb.order, m.ID)
return nil
}
func (mb *Mailbox) evictOne() bool {
for i, id := range mb.order {
m := mb.mails[id]
if m.Read && m.Claimed {
delete(mb.mails, id)
mb.order = append(mb.order[:i], mb.order[i+1:]...)
return true
}
}
return false
}
// remove deletes a mail; it is used after a mail is fully claimed and the player
// discards it. The id stays in the dedup set so a late re-delivery is rejected.
func (mb *Mailbox) remove(id MailID) {
delete(mb.mails, id)
for i, x := range mb.order {
if x == id {
mb.order = append(mb.order[:i], mb.order[i+1:]...)
break
}
}
}
注意 Deliver 里对 Mail 做了一次浅拷贝,并把 Attachments 单独复制了一份。调用方(奖励服务、运营后台)通常持有原始对象,如果不拷贝,调用方事后改一下附件列表就会穿过指针改到邮箱里的数据。这类「别名 bug」在跨模块投递里非常难查。
投递去重:删掉之后还得记得
只在 mails 里查 ID 是不够的。玩家领完附件、把邮件丢弃之后,这条 ID 就从 mails 里消失了;这时如果来一次重试或补发,同一个 ID 会再次入箱——重复投递照样发生。
所以去重的记忆必须独立于邮件本身:一个单独的 dedup 集合,记住「最近投递过的 ID」,即使对应的邮件已经被删。它的语义和卷一的请求幂等窗口是同一件事,只是键换成了 MailID、TTL 从秒级拉到了天级——邮件重投的时间尺度是补发与同步的窗口,通常以天计。想看那个窗口本身怎么切、内存上界怎么算,去读 11.1 请求幂等窗口
;想把「窗口滑出之后重放为什么仍然安全或不安全」讲清楚,见 11.3 至少一次投递的正确性
。
一个容易踩的坑:去重集合要在投递成功之后才记录。如果把 dedup.record 放在容量判断之前,那么一次因为邮箱满而失败的投递,也会把 ID 记进去;玩家清空邮箱后重试,就会被误判成重复、永久收不到。正确的顺序是:先查 mails、再查 dedup.seen、然后做容量淘汰、最后才 dedup.record。测试里 TestMailboxEvictsReadClaimed 就是专门盯这个顺序的:
// snippet: internal/mail/mail_test.go(节选)
func TestMailboxEvictsReadClaimed(t *testing.T) {
mb := NewMailbox(7, 2, time.Hour)
_ = mb.Deliver(newMail(1, 0), base)
_ = mb.Deliver(newMail(2, 0), base)
// 最旧的邮件未读未领,没有可淘汰项。
if err := mb.Deliver(newMail(3, 0), base); !errors.Is(err, ErrMailboxFull) {
t.Fatalf("deliver into full mailbox = %v, want ErrMailboxFull", err)
}
mb.MarkRead(1)
if _, err := mb.ClaimAll(1, base); err != nil {
t.Fatal(err)
}
// 现在可淘汰了:同一条 id 3 必须还能投进去,而不是被判重复。
if err := mb.Deliver(newMail(3, 0), base); err != nil {
t.Fatalf("deliver after evict = %v", err)
}
}
还要清楚去重挡不住什么:窗口滑出之后,同一个 ID 会被当成新邮件。跨天补发、跨服同步晚了好几天、或者服务重启导致 dedup 清空,都可能让重复漏过去。要真正做到「绝不重复发奖」,dedup 只是第一层,后面必须再压一层持久化的唯一约束((player_id, mail_id) 唯一键),这正是 11.3 讲的那条链。
全服邮件与懒投递
全服补偿如果真扇出,就是「模板 × 角色数」条记录。我们不做扇出,做懒投递:全服邮件只存一份模板,模板 ID 的最高位置 1,和个人邮件 ID 分开,这样两者永不冲突。玩家登录或拉取邮件列表时,服务端才把这个模板展开成一封属于他的 Mail,投进他的邮箱;靠上一节的去重,同一个模板投第二次会被挡掉,所以「重复登录」不会重复入箱。
接线到卷二共享的命令号上,就是下面这样:CmdMailListReq 0x0A01 拉列表并顺手补投未见的全服邮件,CmdMailClaimReq 0x0A02 领附件(下一节的主题):
// snippet: internal/mail/handler.go(命令接线示意,随第 18 章落地)
// onMailList 处理 CmdMailListReq 0x0A01:先补投该玩家尚未见过的全服模板,
// 再返回整个收件箱。Deliver 自带去重,重复登录不会重复入箱。
func (s *MailService) onMailList(p PlayerID, now time.Time) []Mail {
mb := s.mailboxOf(p)
for _, g := range s.pendingGlobals(p, now) {
_ = mb.Deliver(g, now) // 全服模板展开后的个人邮件
}
return mb.List()
}
// onMailClaim 处理 CmdMailClaimReq 0x0A02:邮件 id + 附件下标。
func (s *MailService) onMailClaim(p PlayerID, id MailID, idx int, now time.Time) (Grant, error) {
return s.mailboxOf(p).Claim(id, idx, now)
}
卷二的命令号里,邮件域占 0x0A:CmdMailListReq 0x0A01 与 CmdMailClaimReq 0x0A02,帧格式仍是卷一那套 11 字节定长头(length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1)),这里不再重复。
本节小结
- 容量上界由
capacity钉死,淘汰只挑「已读且已领取」的邮件,宁可拒投也不丢附件。 - 去重的记忆独立于邮件:删掉的 ID 仍要留在
dedup里,且必须在投递成功后才记录,否则失败的投递会污染去重集。 - 全服邮件不扇出,靠模板 + 懒投递 + 去重实现 1→N,重复登录不会重复入箱。
- 本节的 L1 内核在本机
go test ./internal/mail/下 12 条用例全绿;去重只挡窗口内的重放,跨天、跨服、重启造成的重复要落到持久唯一约束(见 11.3)。
阅读导航:上一节:目录 · 下一节:15.2 附件、过期与流拍结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。