1. 5.4 跨进程路由表与扩展思路

5.1 喂养 otherActors;若每次远程路由都对 SupportedMsgIDs 线性扫描,成本高。zhenyi/zactor.GroupotherActorsVersion + 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 对所有节点一视同仁。加入权重后,高配机器被选中的概率更高。

这些扩展都不需要改现有的核心代码——只需要实现新的 RemoteRouteStrategyPickOne),然后注入到 Gate。

1.8. 5.4.8 本节要点

  1. 触发watchActorotherActorsVersion.Add(1)在 watch 路径全量扫描。
  2. 查询LookupOtherActorConfigsByMsgIDView:版本命中则 直接返回 snap.table[msgID] 切片视图禁止修改);未命中则在 otherRouteMu 下重建并二次校验版本。
  3. 副本LookupOtherActorConfigsByMsgID copy 一份,供调用方安全修改。
  4. Gateany(group).(ziface.IGroupRemoteRouteTableView) 成功则 零分配候选视图;否则退回 Gate 内复用 remoteCandidatesBuf 的扫描(见 routeToRemoteActor)。
  5. 扩展:新 RemoteRouteStrategyDiscoverer 或基于 Etcd 的分配器,应尽量 挂在接口注入,避免分叉核心路径。

第六章(大纲)可接全链路可观测性与运维;与本章 zmetrics、NATS/Etcd 监控衔接。

results matching ""

    No results matching ""