跨服战场开打之后,紧接着的需求是跨服聊天:A 服的世界频道要能被 B、C 服的玩家看到,公会成员分散在三个服,却要在同一条频道里说话。这和 6.1 的撮合有一个共同的难题——消息的发送方和接收方不在同一台机器上,中间需要一个「路由」。
路由要回答两个不同的问题:频道广播问的是「谁订阅了这个频道」,定向私聊问的是「目标玩家此刻在哪个服」。这两个问题的答案,决定了消息该扇出还是该点投,也决定了断线期间消息该怎么处理。
本节用 NATS 做消息总线,真的连本机 nats://127.0.0.1:34222 起三个服的订阅,把频道扇出、玩家路由、离线补投三条路径都跑通。所有 subject 都带测试前缀 aegis.test.ch06.,绝不污染别的数据。
6.2.1 跨服频道的订阅关系:谁订阅了哪个频道
跨服聊天的第一层抽象是「订阅关系」:一个服订阅了某个频道,就意味着这个服上有玩家关心这个频道。世界频道 world 人人都看,所以每个服都订阅;公会频道 guild42 只有 42 号公会有成员的服才订阅。NATS 的 publish/subscribe 天然就是这个语义——发布方不用知道谁在听,订阅方自己声明关心什么。
主题命名约定两条:
// snippet: NATS subject 约定(测试前缀 aegis.test.ch06.)
aegis.test.ch06.chan.<channel> —— 频道广播:谁订阅谁收,扇出
aegis.test.ch06.srv.<serverID> —— 服收件箱:该服在线玩家的定向消息都投这里
// 频道消息与定向消息共用一个 ChatMessage 载荷,靠 Target 是否非 0 区分。
载荷沿用卷一的字节序,字段顺序写死:From(8) | Target(8) | Seq(4) | len(channel)(2) | channel | body。Channel 非空表示这是一条频道广播,Target 非 0 表示这是一条发给某个玩家的定向消息。命令号用跨服聊天域的 CmdCrossChatSend 0x0E01(发送)与 CmdCrossChatRecv 0x0E02(投递到端),与 6.1 的跨服域 0x0D 分开编址。
两条路径在同一套 subject 上的形状是这样的:
┌──────────────── aegis.test.ch06.* ──────────────────┐
│ │
服 301 ─sub──► │ chan.world ◄─pub─ 任意服的聊天入口(扇出) │
服 303 ─sub──► │ (302 没订阅 → 收不到世界频道) │
│ │
服 301 ─sub──► │ srv.301 ◄─pub─ SendTo(5001) 查目录 → 在 301 │
服 303 ─sub──► │ srv.303 ◄─pub─ SendTo(5002) 查目录 → 在 303 │
│ │
└────────────────────────────────────────────────────┘
同一张图里藏着本节的核心判断:频道消息投给「订阅者集合」,定向消息投给「玩家所在的服」。 前者的集合是配置出来的,后者的落点是运行期查出来的。
6.2.2 按服路由 vs 按玩家路由
两种路由方式对应两条完全不同的路径,用一张表说清楚:
| 维度 | 按服路由(频道广播) | 按玩家路由(定向消息) |
|---|---|---|
| 投递目标 | 订阅了该频道的那些服 | 目标玩家当前所在的服 |
| 是否需要在线目录 | 否 | 是:查 PlayerID -> ServerID |
| 一条消息的接收方 | N 个服(扇出) | 恰好 1 个服 |
| 典型场景 | 世界频道、公会频道、公告 | 私聊、系统提示、撮合结果 |
| 离线怎么办 | 不适用(频道消息不补投) | 暂存,上线补投(见 6.2.3) |
关键区别是要不要维护一张在线目录。频道广播是无状态的:谁订阅谁收,路由中心根本不知道有哪些玩家。定向消息必须有状态:要把「5001 号玩家现在在 301 服」记下来,消息才知道往哪投。下面这段是 L2,连真 NATS,go test 可以直接跑:
// file: internal/crossserver/router.go
// tier: L2
// verify: go test ./internal/crossserver/ -run TestCrossServerChatRouting
package crossserver
import (
"strconv"
"sync"
"github.com/nats-io/nats.go"
)
// Router 用 NATS 做跨服聊天:频道消息按「订阅关系」扇出,定向消息按玩家路由。
//
// <prefix>chan.<channel> —— 频道广播:谁订阅了这个频道谁收
// <prefix>srv.<serverID> —— 服收件箱:该服在线玩家的定向消息都投这里
type Router struct {
nc *nats.Conn
prefix string
mu sync.Mutex
presence map[uint64]uint16 // PlayerID -> 当前所在服
offline map[uint64][]ChatMessage
}
func NewRouter(url, prefix string) (*Router, error) {
nc, err := nats.Connect(url)
if err != nil {
return nil, err
}
return &Router{nc: nc, prefix: prefix, presence: make(map[uint64]uint16), offline: make(map[uint64][]ChatMessage)}, nil
}
func (r *Router) Close() { r.nc.Close() }
func (r *Router) chanSubject(channel string) string { return r.prefix + "chan." + channel }
func (r *Router) srvSubject(serverID uint16) string { return r.prefix + "srv." + strconv.Itoa(int(serverID)) }
// SubscribeChannel 让某个服订阅一个跨服频道。
func (r *Router) SubscribeChannel(channel string, fn func(ChatMessage)) (*nats.Subscription, error) {
return r.nc.Subscribe(r.chanSubject(channel), func(m *nats.Msg) {
if c, err := DecodeChatMessage(m.Data); err == nil {
fn(c)
}
})
}
// SubscribeServer 订阅一个服的收件箱(定向消息的落点)。
func (r *Router) SubscribeServer(serverID uint16, fn func(ChatMessage)) (*nats.Subscription, error) {
return r.nc.Subscribe(r.srvSubject(serverID), func(m *nats.Msg) {
if c, err := DecodeChatMessage(m.Data); err == nil {
fn(c)
}
})
}
// PublishChannel 往频道广播:扇出给所有订阅了该频道的服。
func (r *Router) PublishChannel(channel string, msg ChatMessage) error {
msg.Channel = channel
return r.nc.Publish(r.chanSubject(channel), msg.Encode())
}
// SendTo 把定向消息投给某个玩家:在线则投其所在服的收件箱,离线则暂存。
func (r *Router) SendTo(pid uint64, msg ChatMessage) error {
r.mu.Lock()
sid, online := r.presence[pid]
if !online {
r.offline[pid] = append(r.offline[pid], msg)
}
r.mu.Unlock()
if !online {
return nil
}
msg.Target = pid
return r.nc.Publish(r.srvSubject(sid), msg.Encode())
}
// OnLogin 玩家上线:登记所在服,并把断线期间攒下的消息补投到新服的收件箱。
func (r *Router) OnLogin(pid uint64, serverID uint16) (int, error) {
r.mu.Lock()
r.presence[pid] = serverID
pending := r.offline[pid]
delete(r.offline, pid)
r.mu.Unlock()
for _, m := range pending {
m.Target = pid
if err := r.nc.Publish(r.srvSubject(serverID), m.Encode()); err != nil {
return 0, err
}
}
return len(pending), nil
}
// OnLogout 玩家下线:清掉在线登记,后续定向消息转入离线暂存。
func (r *Router) OnLogout(pid uint64) {
r.mu.Lock()
delete(r.presence, pid)
r.mu.Unlock()
}
// Pending 返回某玩家离线暂存的消息数。
func (r *Router) Pending(pid uint64) int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.offline[pid])
}
// Flush 等 NATS 把已发布的消息处理完。
func (r *Router) Flush() error { return r.nc.Flush() }
测试里 301、303 两个服订阅了 world 频道,302 没订阅;往 world 发一条消息,只有 301 和 303 收到,302 的收件箱始终为空。接着让 5001 上线到 301、5002 上线到 303,各发一条私聊,两条消息精确落进 301 与 303 的收件箱,302 一条都没收到——这就是「按玩家路由」和「按服扇出」的区别。
测试的关键断言就这几行(L2,连真 NATS):
// snippet: internal/crossserver/chat_test.go(断言片段)
// 1) 世界频道扇出:只到订阅了 world 的两个服,且不落进任何收件箱。
if !waitFor(t, time.Second, func() bool { return worldSinks[301].n() == 1 && worldSinks[303].n() == 1 }) {
t.Fatalf("world 扇出失败: 301=%d 303=%d", worldSinks[301].n(), worldSinks[303].n())
}
for id, s := range inbox {
if s.n() != 0 {
t.Fatalf("频道广播误入了服 %d 的收件箱(%d 条)", id, s.n())
}
}
// 2) 定向路由:5001 在 301、5002 在 303,各自只进对应收件箱。
if err := r.SendTo(5001, ChatMessage{From: 900, Seq: 2, Body: "私聊 5001"}); err != nil {
t.Fatalf("SendTo: %v", err)
}
if !waitFor(t, time.Second, func() bool { return inbox[301].n() == 1 && inbox[303].n() == 1 }) {
t.Fatalf("定向路由失败: 301=%d 303=%d", inbox[301].n(), inbox[303].n())
}
if inbox[302].n() != 0 {
t.Fatalf("服 302 不该收到任何定向消息,实收 %d", inbox[302].n())
}
waitFor 轮询而不是 time.Sleep,是因为 NATS 的投递是异步的——发布返回只代表消息进了总线,不代表订阅方已经处理完。用「等条件成立」而不是「睡固定时长」,测试才能既快又不 flaky。
6.2.3 断线期间的消息处理
玩家掉线了,跨服私聊怎么办?两条路:丢掉,或者暂存等它回来。MMO 的做法是后者——离线期间的消息暂存在路由中心,玩家重新上线时一次性补投。注意补投的目标是「上线后所在的服」,不一定是掉线前那个服:玩家完全可能从 301 服掉线、从 303 服上线。
Router 用一个 offline map[uint64][]ChatMessage 承担这个暂存区:SendTo 发现玩家不在 presence 里,就把消息追加进去并直接返回;OnLogin 登记新服号后,把攒下的消息逐条投到新服的收件箱。这里有一个容易忽略的点:离线暂存不能无限增长,真实系统要给它加上限与过期时间,超限就转成离线邮件(对应 /game-server-cross-shard-mail-routing-architecture/ 那篇讲的邮件路由)。
测试验证了完整生命周期:6001 从没上线,两条私聊进入暂存、收件箱为空;OnLogin(6001, 303) 后两条一次性补投到 303 的收件箱,暂存清零;随后 OnLogout 再发一条,又回到暂存——在线目录与离线队列始终一致,消息一条不丢。
把这条时间线摊开看:
SendTo(6001) ─► presence 无 6001 ─► 进 offline[6001] ─► 收件箱 0 条
SendTo(6001) ─► presence 无 6001 ─► 进 offline[6001] ─► 收件箱 0 条,Pending=2
OnLogin(6001,303) ─► presence[6001]=303 ─► 逐条 pub srv.303 ─► 收件箱 2 条,Pending=0
OnLogout(6001) ─► 删 presence[6001]
SendTo(6001) ─► presence 无 6001 ─► 又进 offline[6001] ─► Pending=1
注意最后一步:下线不是「清空暂存」,而是「让后续消息重新开始暂存」。如果 OnLogout 顺手把 offline 也清了,玩家掉线瞬间收到的消息就永远丢了。
本节小结 / 实测结论
三条可以带走的结论:
- 频道广播与定向消息是两条路由,别混为一谈。 前者按「订阅关系」扇出、无状态;后者按「玩家在线目录」点投、有状态。把两者塞进同一个 subject 会同时丢掉扇出的简洁和点投的精确。
- 在线目录是定向消息的唯一真相。
PlayerID -> ServerID的映射决定了消息往哪投;玩家换服登录时,这个映射必须跟着更新,否则消息会投到已经没人的旧服。 - 离线暂存要在路由中心做,补投目标是「上线后所在的服」。 掉线和上线可能不在同一个服,补投必须查当时的在线目录,而不是记死掉线前的服号。
实测输出(go test -race -count=1,连本机 NATS 127.0.0.1:34222,前缀 aegis.test.ch06.):
=== RUN TestCrossServerChatRouting
--- PASS: TestCrossServerChatRouting (0.03s)
=== RUN TestOfflineBufferThenFlush
--- PASS: TestOfflineBufferThenFlush (0.12s)
=== RUN TestChatMessageRoundTrip
--- PASS: TestChatMessageRoundTrip (0.00s)
ok aegis/internal/crossserver 1.692s
一句话收束:跨服聊天不是「把消息发到所有服」,而是「让每条消息找到它该去的那一个或那几个服」——频道靠订阅关系,私聊靠在线目录。 6.3 会把「消息找到了人」推进到「奖励发对了人」,那才是真正要命的资产问题。
阅读导航:上一节:6.1 跨服报名与匹配 · 下一节:6.3 资产归属与跨服结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。