《MMO 服务器开发》6.2 跨服聊天与消息路由

跨服消息怎么找到人?频道广播靠「谁订阅了哪个频道」扇出,定向私聊则要先把玩家路由到它当前所在的服。本节用 NATS 真跑三个服:世界频道只到订阅它的服,定向消息精确投进目标服的收件箱,掉线期间的消息先暂存、换服上线后一次性补投,全程用独立 subject 前缀隔离。

跨服战场开打之后,紧接着的需求是跨服聊天: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 也清了,玩家掉线瞬间收到的消息就永远丢了。

本节小结 / 实测结论

三条可以带走的结论:

  1. 频道广播与定向消息是两条路由,别混为一谈。 前者按「订阅关系」扇出、无状态;后者按「玩家在线目录」点投、有状态。把两者塞进同一个 subject 会同时丢掉扇出的简洁和点投的精确。
  2. 在线目录是定向消息的唯一真相。 PlayerID -> ServerID 的映射决定了消息往哪投;玩家换服登录时,这个映射必须跟着更新,否则消息会投到已经没人的旧服。
  3. 离线暂存要在路由中心做,补投目标是「上线后所在的服」。 掉线和上线可能不在同一个服,补投必须查当时的在线目录,而不是记死掉线前的服号。

实测输出(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 资产归属与跨服结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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