《MMO 服务器开发》15.3 红点聚合与离线同步

红点不是「数一数未读邮件」那么简单,它是整棵 UI 树的聚合;离线玩家上线后也不能整包重拉。本节给 aegis 写一棵可聚合的红点树和一个增量同步日志:计数怎么沿父链传播、重复上报为什么要夹在零、掉线太久为什么必须退化成全量,附真跑的 L1 测试。

15.3 红点聚合与离线同步

红点看起来只是「未读邮件数」,但它其实是一棵树的聚合:邮件分类有红点、邮箱图标有红点、主界面 tab 也有红点,一层层往上加。任何一个叶子(来了一封新邮件、读掉一封)变化,整条祖先链上的数字都要跟着变。如果每个系统各自维护自己的红点、最后在客户端相加,你会立刻遇到「分类之和 ≠ 总数」的漂移。

另一半问题是离线同步。玩家离线期间邮件一直在变:活动发奖、拍卖成交、好友赠礼、GM 补发。上线时如果把整个邮箱重拉一遍,对大号来说是几百封、几十 KB 的无谓流量;而如果只靠客户端自己记「我上次看到哪」,又没法保证和服务端对齐。

这一节写两件东西:一棵红点聚合树,和一个增量同步日志。前者解决「一个数字怎么从叶子传到根」,后者解决「离线回来只拉变化量」。代码是 L1,go test 直接能跑。

红点是一棵树,不是一个数

把红点建成树:叶子是具体系统(mail、quest、guild……),中间节点对子节点求和,根节点的计数就是 tab 上那一个数。每个节点保存两样东西——own(它自己直接产生的计数)和 sum(自己加上所有子孙的聚合值)。Bump(name, delta) 改动某个节点的 own,然后沿父链一路往上把 delta 加到每个祖先的 sum 上,最后返回根节点的新值。

有一个必须写进实现的边界:聚合值夹在零。客户端读掉一封邮件会上报一次「-1」,但网络重传、界面重复触发都可能让同一个「已读」上报两次。如果直接做减法,红点会变成 -1 甚至更小,界面上就出现负数的角标。所以 Bump 在改动前先把 delta 夹一下,保证 own 不会跌破零:

下面这段是 L1,go test 可以直接跑:

// file: internal/mail/reddot.go
// tier: L1
// verify: go test ./internal/mail/

package mail

import "sync"

// RedDot aggregates unread counters along a small node tree. Leaves are the
// concrete systems (mail, quest, guild, ...); an intermediate node sums its
// children; the root's count is the single number the client's tab bar shows.
type RedDot struct {
	mu     sync.Mutex
	parent map[string]string
	own    map[string]int
	sum    map[string]int
	root   string
}

// NewRedDot returns a tree with a single root node.
func NewRedDot(root string) *RedDot {
	return &RedDot{
		parent: map[string]string{root: ""},
		own:    map[string]int{root: 0},
		sum:    map[string]int{root: 0},
		root:   root,
	}
}

// Attach registers name as a child of parent. Re-attaching moves the node and
// re-propagates its current own count, so a hot-swapped subtree stays correct.
func (r *RedDot) Attach(name, parent string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	old, existed := r.parent[name]
	if !existed {
		r.own[name] = 0
		r.sum[name] = 0
	} else if old == parent {
		return
	}
	if existed && old != "" {
		r.propagate(old, -r.sum[name])
	}
	r.parent[name] = parent
	r.propagate(parent, r.sum[name])
}

// Bump changes name's own count by delta and returns the new root total. A node
// that would go negative is clamped at zero, so a duplicated "read" report can
// never push the badge below zero.
func (r *RedDot) Bump(name string, delta int) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ok := r.parent[name]; !ok {
		return r.sum[r.root]
	}
	if r.own[name]+delta < 0 {
		delta = -r.own[name]
	}
	r.own[name] += delta
	r.propagate(name, delta)
	return r.sum[r.root]
}

func (r *RedDot) propagate(name string, delta int) {
	for name != "" {
		r.sum[name] += delta
		name = r.parent[name]
	}
}

// Count returns the aggregated count at name.
func (r *RedDot) Count(name string) int {
	r.mu.Lock()
	defer r.mu.Unlock()
	return r.sum[name]
}

// Unread counts mails the player has not opened yet. It is the leaf value the
// mail system feeds into Bump.
func (mb *Mailbox) Unread() int {
	mb.mu.Lock()
	defer mb.mu.Unlock()
	n := 0
	for _, m := range mb.mails {
		if !m.Read {
			n++
		}
	}
	return n
}

Attach 里那段「先减旧祖先、再加新祖先」也值得说一句:节点是可以在运行时换父的(比如把一个子系统的红点从旧的分组挪到新的分组)。换父时不能只改 parent,还要把它当前的 sum 从旧祖先链上摘掉、再挂到新祖先链上,否则根计数会一直偏着。Unread 则是把邮箱的真实未读数喂给 Bump 的入口——红点树的叶子值永远来自业务系统的真值,而不是客户端上报。

把聚合放在服务端还有一个好处:红点是服务端权威的。客户端断线、切界面、甚至被改包,都不会影响红点的真值——它下次上线拿到的是服务端算好的数字,而不是自己攒出来的。客户端唯一要做的是「显示」和「在上报已读后等待服务端确认」,把红点当成一份只读的推送,而不是本地状态。

离线同步:只拉增量

增量同步的核心是一个单调递增的版本号加一段有界的变更日志。邮箱每发生一次改动(新增、已读、领取、删除),就往日志里追加一条 Change{Version, Op, MailID};Append 返回这次改动的版本号。客户端每次收到同步后记住「我看到的最高版本」,下次上线时把它带上来。

服务端用 Since(v) 回答:把版本号大于 v 的变更按时间顺序返回。日志用环形缓冲实现,只保留最近 window 条——这正是关键所在,下面这段:

// snippet: internal/mail/sync.go

// Change 是一次邮箱改动,带一个单调递增的版本号。
type Change struct {
	Version uint64
	Op      byte // 'A' 新增, 'R' 已读, 'C' 领取, 'D' 删除
	MailID  MailID
}

// ChangeLog 是最近改动组成的有界环形缓冲。重连的客户端带上它最后看到的版本;
// 该版本仍在环内就回增量,否则客户端必须走全量快照。把环设为有界,正是为了
// 阻止一个长期离线的账号逼服务端重放无限长的历史。
type ChangeLog struct {
	mu      sync.Mutex
	version uint64
	ring    []Change
	head    int
	full    bool
}

// Append 记录一次改动并返回它的版本号。
func (c *ChangeLog) Append(op byte, id MailID) uint64 {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.version++
	c.ring[c.head] = Change{Version: c.version, Op: op, MailID: id}
	c.head = (c.head + 1) % len(c.ring)
	if c.head == 0 {
		c.full = true
	}
	return c.version
}

// Since 返回版本号大于 v 的全部改动,由旧到新。当 v 早于环内现存最旧的一条时
// ok=false:调用方必须退化成全量快照,而不是相信这个(不完整的)增量。
func (c *ChangeLog) Since(v uint64) (out []Change, ok bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	if v >= c.version {
		return nil, true
	}
	if v+1 < c.oldestLocked() {
		return nil, false
	}
	for _, ch := range c.ring {
		if ch.Version > v {
			out = append(out, ch)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Version < out[j].Version })
	return out, true
}

注意 Since 的第二个返回值 ok。它专门用来表达「你的版本太旧了,我这里已经没有你缺的那几条了」。这时候不能假装增量完整,必须让客户端走全量。这是整个增量同步里最容易漏、后果又最严重的一个分支:漏了它,客户端就会永久性地少几封邮件,而服务端还以为自己同步成功了。

增量同步还有一个隐含前提:版本号必须由服务端单点递增。如果多台逻辑服各自维护自己的计数器,客户端拿到的版本就没法跨服比较,「我看到的最高版本」也就失去意义。单服架构下这不成问题;等到卷三做跨服时,这一步要么收敛到全局发号器,要么改成「按服分别记录版本」,不能假装它们是同一个序列。

掉线太久就退化成全量

为什么日志必须有界?因为「一个离线半年的账号」不能逼服务端把它缺席期间的每一条变更都留着重放。环形缓冲的上界就是服务端愿意为增量保留的历史;超过它,就只能退化:

客户端版本服务端日志状态Since 结果动作
等于最新版本—空增量,ok=true无需下发
在窗口内覆盖该版本之后的所有变更返回增量,ok=true只发变化
早于窗口最旧一条中间有缺口ok=false改发全量快照

窗口大小怎么定?取「你愿意接受的最长离线时长」内、单个账号可能产生的最大变更数,再留一点余量。设小了,老玩家频繁触发全量;设大了,服务端内存被日志撑爆。测试钉住了窗口溢出的判定:

还有一个运维上的小细节:日志窗口一旦调小(比如为了省内存),所有版本号早于新窗口最旧的在线客户端都会在下次同步时被判定为「需要全量」。所以调窗口要当成一次需要公告的变更,而不是随手改个数字。

// snippet: internal/mail/reddot_test.go(节选)

func TestChangeLogWindowOverflow(t *testing.T) {
	log := NewChangeLog(3)
	for i := 0; i < 5; i++ {
		log.Append('A', MailID(i+1))
	}
	// 版本 1 已经滑出 3 槽环形缓冲(现存最旧是 3),无法给出完整增量。
	if _, ok := log.Since(1); ok {
		t.Fatalf("Since(1) must report the delta as incomplete")
	}
	// 版本 2 需要的 3、4、5 都还在。
	if out, ok := log.Since(2); !ok || len(out) != 3 {
		t.Fatalf("Since(2) = %v, ok=%v; want 3,4,5", out, ok)
	}
}

接线到命令上:客户端上线先发 CmdMailListReq 0x0A01,请求里带上次的版本号;服务端要么回增量、要么回全量,再顺带把最新的红点根计数一并下发。红点树本身不参与同步协议——它是服务端算好、下发的一个数字,客户端不负责聚合。

本节小结

  • 红点是一棵树,叶子给真值、父链求和;Bump 沿父链传播并返回根计数,Attach 换父时要重挂 sum。
  • 聚合值夹在零:重复的「已读」上报不能让红点变成负数。
  • 增量同步 = 单调版本号 + 有界环形日志;Since 的 ok=false 是「必须退化成全量」的信号,漏掉它会造成永久少信。
  • 日志必须有界,窗口按「可接受的最长离线时长」定;超窗就发全量快照。
  • 本节的 L1 代码在本机 go test ./internal/mail/ 下全绿;红点树与同步日志只依赖标准库。
  • 红点是服务端权威的只读推送:客户端只负责显示与等待确认,聚合与真值都在服务端。

阅读导航:上一节:15.2 附件、过期与流拍结算 · 下一节:16.1 资产冻结与托管 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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