《MMO 服务器开发》15.1 邮件投递与去重

邮件是 MMO 的万能投递通道,活动奖励、GM 补偿、拍卖结果、系统公告全走它。本节从零给 aegis 写一个可跑的邮箱内核:容量上界怎么定、全服邮件为什么不能扇出成 N 条存储、以及投递去重为什么不能只在邮箱里查 ID,并把它和卷一的幂等窗口接上,附 12 条真跑通过的 L1 测试。

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 附件、过期与流拍结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标