1. 5.4 跨进程路由表与扩展思路
5.1 喂养 otherActors;若每次远程路由都对 SupportedMsgIDs 线性扫描,成本高。zhenyi/zactor.Group 用 otherActorsVersion + otherRouteSnapshot:版本不变则 O(1) 查表;版本变后首次查询在锁内 全量重建 msgId → []ActorConfig。Gate 侧通过 ziface.IGroupRemoteRouteTableView 走 只读视图(与 4.3 一致)。
1.1. 5.4.1 问题:每次路由都扫描所有远程 Actor?
Gate 收到消息需要远程路由时,要从 otherActors 中找到支持这个 msgId 的候选 Actor。
最简单的做法:每次都遍历所有 otherActors,线性扫描 SupportedMsgIDs。
// 每条消息都这样做:
for _, cfg := range otherActors {
for _, id := range cfg.SupportedMsgIDs {
if id == msgId {
candidates = append(candidates, cfg)
}
}
}
假设有 50 个远程 Actor,每个支持 20 个 msgId,每条消息最多要比较 1000 次。
QPS 10 万,每秒就是 1 亿次比较。虽然每次比较是 O(1),但累积的 CPU 开销不可忽视。
1.2. 5.4.2 解决方案:按需重建快照
Group 维护了一个预建的路由快照:msgId → []ActorConfig。
type otherRouteTableSnapshot struct {
version int64 // 对应 otherActors 的版本号
table map[int32][]zmodel.ActorConfig // msgId → []ActorConfig
}
查询时先检查版本号:
func (g *Group) LookupOtherActorConfigsByMsgID(msgID int32) []zmodel.ActorConfig {
currentVer := g.otherActorsVersion.Load()
snap := g.otherRouteSnapshot.Load()
// 版本匹配 → 直接用快照,O(1) 查 map
if snap != nil && snap.version == currentVer {
return snap.table[msgID]
}
// 版本不匹配 → 重建快照
g.rebuildSnapshot()
return newSnap.table[msgID]
}
1.2.1. 为什么是"按需"而不是"实时"?
otherActors 变化时(节点上下线),版本号递增,但不立即重建快照。
// watchActor 中只递增版本号,不重建
g.otherActors.Store(cfg.Id, cfg)
g.otherActorsVersion.Add(1)
重建延迟到查询时才触发。这避免了节点频繁上下线时反复重建快照的浪费。
1.2.2. 双重检查避免重复构建
多 goroutine 同时检测到版本变化时,只有一个会真正重建:
// 第一次检查(无锁)
snap := g.otherRouteSnapshot.Load()
if snap.version == currentVer {
return snap.table[msgID]
}
// 加锁后再检查一次
g.otherRouteMu.Lock()
defer g.otherRouteMu.Unlock()
snap = g.otherRouteSnapshot.Load()
if snap.version == currentVer {
return snap.table[msgID] // 其他 goroutine 已经重建了
}
// 真正重建
table := make(map[int32][]zmodel.ActorConfig)
g.otherActors.Range(func(_ uint64, cfg zmodel.ActorConfig) bool {
for _, id := range cfg.SupportedMsgIDs {
table[id] = append(table[id], cfg)
}
return true
})
g.otherRouteSnapshot.Store(&otherRouteTableSnapshot{
version: currentVer,
table: table,
})
经典的 Double-Check Locking 模式。
1.3. 5.4.3 性能对比
| 方案 | 每次查询开销 | 更新开销 |
|---|---|---|
| 线性扫描 | O(N × M),N=Actor数,M=平均MsgId数 | 无(不需要维护额外数据结构) |
| 按需重建快照 | O(1)(map 查询) | O(N × M)(重建时),但只在版本变化后首次查询触发 |
快照方案用一次性的重建开销换取后续所有查询的 O(1)。在节点不频繁变化的正常情况下,重建几乎不发生。
1.4. 5.4.4 与本地路由表的对比
本地路由和远程路由用了不同的 Copy-On-Write 策略:
| 本地路由(Group.msgRouteTable) | 远程路由(Group.otherRouteSnapshot) | |
|---|---|---|
| 数据结构 | map[int32][]IActor |
map[int32][]ActorConfig |
| 存储 | atomic.Value |
atomic.Value |
| 更新时机 | Actor 注册时立即更新 | 版本变化时按需重建 |
| 更新方式 | 增量(只改一个 key 的 slice) | 全量重建 |
| 锁 | 无锁(CoW) | 重建时加 otherRouteMu |
为什么本地路由可以增量更新,而远程路由要全量重建?
本地路由的更新是显式的——调用 RegisterRoutes 时明确知道要改哪个 key,可以精确更新。
远程路由的变化来自 Etcd Watch,是增量事件(一个节点上线或下线),但"重建快照"需要扫描所有 otherActors 来构建 msgId 索引。全量重建虽然开销大,但频率低,工程上更简单。
1.5. 5.4.5 Gate 的快表接口
Gate 在远程路由时,优先尝试快表接口:
// Gate 的 routeToRemoteActor 中(接口定义在 ziface)
if fast, ok := any(group).(ziface.IGroupRemoteRouteTableView); ok {
candidates = fast.LookupOtherActorConfigsByMsgIDView(msg.MsgId) // 只读视图,零分配
} else {
// 回退:线性扫描
for _, cfg := range configs {
for _, id := range cfg.SupportedMsgIDs {
if id == msg.MsgId {
candidates = append(candidates, cfg)
}
}
}
}
通过接口检测而不是强制依赖,保持了灵活性。如果未来有更快的路由表实现(比如基于前缀树),只需要实现这个接口。
1.6. 5.4.6 当前架构的边界
目前 zhenyi 的跨进程路由有几个特点:
| 特点 | 说明 |
|---|---|
| 静态分片 | 启动时分配,运行时不变 |
| 手动配置 | 每个进程启动几个什么类型的 Actor,在配置中指定 |
| 水平扩展靠增加进程 | 加一个进程,多一组 Actor |
这适合当前的用户规模。但随着规模增长,可能需要更灵活的分片策略。
1.7. 5.4.7 可能的扩展方向
1.7.1. 动态分片
当前 Actor 的分片数在启动时固定。如果要动态调整分片数(比如在线人数翻倍,需要加一个 IM Actor 分片),需要一个分片分配器:
分片分配器(ShardAllocator):
- 监控每个 Actor 的负载(在线人数、QPS、内存)
- 根据策略决定是否需要新分片
- 分配完成后通知所有进程更新路由表
状态存储可以用 Etcd:
/servers/1/5 → {"actorType":1, "shard":0, "load":500}
/servers/1/6 → {"actorType":1, "shard":1, "load":800}
/servers/1/7 → {"actorType":1, "shard":2, "load":200}
分片分配器读取这些数据,决定把 shard 1 的部分用户迁移到 shard 2。
1.7.2. 分片迁移
迁移用户的难点:用户的状态在内存里,不能直接搬。
可能的方案:
1. 标记迁移:在路由层把用户 A 的请求转发到新分片
2. 状态拉取:新分片向旧分片发 RPC,拉取用户 A 的状态
3. 状态恢复:新分片重建用户 A 的内存状态
4. 确认完成:旧分片删除用户 A 的状态
这和数据库的分片迁移类似,但更复杂,因为内存状态比磁盘数据更难迁移。
1.7.3. 权重路由
不同机器的性能可能不同。一台高配机器应该承担更多流量:
type WeightedStrategy struct {
KeyFunc RemoteRouteKeyFunc
}
func (s *WeightedStrategy) PickOne(msg *zmsg.Message, candidates []zmodel.ActorConfig) int {
// 根据 Actor 的权重选择一个首选候选下标
// 权重可以从 Etcd 的配置中读取
return 0
}
当前 RendezvousHash 对所有节点一视同仁。加入权重后,高配机器被选中的概率更高。
这些扩展都不需要改现有的核心代码——只需要实现新的 RemoteRouteStrategy(PickOne),然后注入到 Gate。
1.8. 5.4.8 本节要点
- 触发:
watchActor只otherActorsVersion.Add(1),不在 watch 路径全量扫描。 - 查询:
LookupOtherActorConfigsByMsgIDView:版本命中则 直接返回snap.table[msgID]切片视图(禁止修改);未命中则在otherRouteMu下重建并二次校验版本。 - 副本:
LookupOtherActorConfigsByMsgIDcopy一份,供调用方安全修改。 - Gate:
any(group).(ziface.IGroupRemoteRouteTableView)成功则 零分配候选视图;否则退回 Gate 内复用remoteCandidatesBuf的扫描(见routeToRemoteActor)。 - 扩展:新
RemoteRouteStrategy、Discoverer或基于 Etcd 的分配器,应尽量 挂在接口注入,避免分叉核心路径。
第六章(大纲)可接全链路可观测性与运维;与本章 zmetrics、NATS/Etcd 监控衔接。