1. 4.3 路由策略:本地与远程
在 4.2 的映射之上,本节细化 gateHandler 三条分支与 跨进程候选:Group 路由表视图、zroute.RemoteRouteStrategy,以及 sendNoRouteError/OnNoRoute。锚点 zhenyi/zgate/gate.go、zhenyi/zroute/remote_strategy.go、zhenyi/zactor/group.go。
1.1. 4.3.1 路由的三条路径
Gate 收到客户端消息后,按优先级尝试三条路径:
Gate 收到消息
↓
路径 1:Gate 自己能处理吗?
↓ 不能
路径 2:本进程有其他 Actor 能处理吗?
↓ 没有
路径 3:其他进程有 Actor 能处理吗?
↓ 没有
回复错误:无路由
4.1 节已经讲了这个优先级。本节深入每条路径的实现。
1.2. 4.3.2 路径 1:Gate 自身处理
有些消息不需要到达业务 Actor,Gate 自己处理:
func (s *Server) gateHandler(ctx context.Context, channel IChannel, msg *zmsg.Message) {
// 检查 Gate 自己有没有注册这个 msgId 的 handler
if _, ok := s.GetMsgList()[msg.MsgId]; ok {
s.GetHandleMgr().HandleClientMessage(ctx, msg)
return // 处理完直接返回
}
// 继续尝试其他路径...
}
典型场景:心跳包、认证请求等 Gate 层面的协议。
1.3. 4.3.3 路径 2:本地路由
消息不在 Gate 的处理范围内,尝试转发给同进程的其他 Actor。
1.3.1. 路由表
Group 维护一个路由表:msgId → []IActor。每个 Actor 注册时,声明自己能处理哪些 msgId:
// Actor 注册时声明支持的 msgId
group.RegisterRoutes(actor, []int32{MsgChat, MsgJoinRoom, MsgLeaveRoom})
路由表用 atomic.Value 存储,Copy-On-Write 模式:
- 读路径:无锁原子读(
atomic.Value.Load());默认接口返回副本,热路径可选走只读视图(零分配) - 写路径:复制 map,只对本次修改的 key 生成新 slice,然后原子替换
func (g *Group) LookupActorsByMsgID(msgID int32) []ziface.IActor {
table := g.msgRouteTable.Load() // 无锁读
list := table[msgID]
out := make([]ziface.IActor, len(list))
copy(out, list) // 返回副本,调用方安全修改
return out
}
为什么用 Copy-On-Write 而不是读写锁?因为路由注册是低频操作(进程启动时注册一次),而路由查询是高频操作(每条消息都要查)。Copy-On-Write 让读路径零开销。
1.3.2. DefaultLocalRouter
默认的本地路由实现优先走只读视图快路径(若 Group 实现了 IGroupRouteTableView),否则回退到副本接口:
func (r *DefaultLocalRouter) RouteLocal(group IGroup, msg *zmsg.Message) (IActor, error) {
if fast, ok := any(group).(ziface.IGroupRouteTableView); ok {
actors := fast.LookupActorsByMsgIDView(msg.MsgId) // 只读视图,零分配
if len(actors) == 0 { return nil, ErrNotFound }
return actors[0], nil
}
actors := group.LookupActorsByMsgID(msg.MsgId) // 兼容副本语义
if len(actors) == 0 {
return nil, ErrNotFound
}
return actors[0], nil // 多个候选时返回第一个
}
如果只有一个 Actor 注册了某个 msgId,直接返回它。如果有多个(比如启动了多个 IM Actor 实例做分片),当前默认返回第一个,后续可以扩展为基于消息内容的分片策略。
1.4. 4.3.4 路径 3:远程路由
本进程没有能处理的 Actor,尝试转发给其他进程。
1.4.1. 构建候选列表
Gate 通过 Group.otherActors 获取其他进程的 Actor 配置。这个数据来自服务发现(Etcd),由 watchActor goroutine 维护:
func (g *Group) watchActor(ctx context.Context) {
// 启动时加载存量
items := g.discoverer.FindAllByPrefix("/servers")
for _, item := range items {
g.otherActors.Store(item.ActorConfig.Id, item.ActorConfig)
}
// 监听变化
ch := g.discoverer.Watch()
for cfg := range ch {
if cfg.ActorType <= 0 {
g.otherActors.Delete(cfg.Id) // 下线
} else {
g.otherActors.Store(cfg.Id, cfg) // 上线或更新
}
}
}
默认先尝试使用跨进程快表只读视图(IGroupRemoteRouteTableView),未实现时再回退线性扫描:
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)
break
}
}
}
}
1.4.2. 路由策略(PickOne)
选出一组候选后,由路由策略选出“首选候选索引”(PickOne)。Gate 先尝试首选,失败后再按原顺序 fallback,不再构造 ordered 新切片。zhenyi 提供了三种策略:
FirstCandidate(默认)
// 直接选择第一个候选(签名见 zroute)
func (FirstCandidateStrategy) PickOne(_ *zmsg.Message, candidates []zmodel.ActorConfig) int {
if len(candidates) == 0 { return -1 }
return 0
}
最简单的策略,适合单进程或固定部署。
RoundRobin(轮询)
// 按顺序轮询,无粘性(示意:Add(1) 起算,与首包落点以源码为准)
func (s *RoundRobinStrategy) PickOne(_ *zmsg.Message, candidates []zmodel.ActorConfig) int {
if len(candidates) == 0 { return -1 }
return int(s.seq.Add(1) % uint64(len(candidates)))
}
适合无状态的消息处理。但同一条消息可能被路由到不同的 Actor,不适合有状态的场景。
RendezvousHash(HRW 一致性哈希)
func (s *RendezvousHashStrategy) PickOne(msg *zmsg.Message, candidates []zmodel.ActorConfig) int {
key := DefaultRemoteRouteKey(msg) // 默认用 SessionId
if key == 0 { return 0 }
// 对每个候选计算 HRW 分数,选择分数最高的下标
for i, c := range candidates {
score := hrwScore(key, c.Id, c.Process)
if score > bestScore {
bestIdx = i
}
}
return bestIdx
}
默认 key 由 zroute.DefaultRemoteRouteKey 选择:优先 SessionId,否则 RpcId,均为 0 时退化为“第一个候选”(见 zhenyi/zroute/remote_strategy.go)。在常见客户端协议里 SessionId 非 0,因此表现为会话粘性。
1.4.3. 三种策略的对比
| 策略 | 粘性 | 扩缩容影响 | 适用场景 |
|---|---|---|---|
| FirstCandidate | 无 | 重新部署会变 | 单进程、固定部署 |
| RoundRobin | 无 | 无影响 | 无状态处理 |
| RendezvousHash | 有(按 key) | 最小影响 | 有状态、需要粘性 |
为什么 RendezvousHash 扩缩容影响最小?
普通一致性哈希在节点增减时,大约 1/N 的 key 会重新映射。RendezvousHash(HRW)的效果类似,但实现更简单,不需要虚拟节点。对实时应用来说,扩缩容时只有少量用户的请求需要迁移,大部分用户不受影响。
1.4.4. 跨进程转发
选定目标后,通过 NATS 消息总线转发:
msg.TarActor = target.Id
topic := target.GetTopic()
// 发送侧可复用编码缓冲,示意为直接广播已编码字节
encoded, _ := msg.Marshal()
zbus.DefaultBus.Broadcast(topic, encoded)
目标进程的 Actor 订阅了这个 topic,收到消息后进入自己的 Mailbox 处理。
1.4.5. Fallback
如果首选候选失败了(比如 NATS 发送失败),会尝试下一个候选:
for i := 0; i < len(candidates); i++ {
var target ActorConfig
if i == 0 {
target = candidates[pickIdx]
} else {
idx := i - 1
if idx >= pickIdx {
idx++
}
target = candidates[idx]
}
if i > 0 {
zmetrics.GateRouteRemoteFallback.Inc() // 记录 fallback 次数
}
msg.TarActor = target.Id
topic := target.GetTopic()
encoded, _ := msg.Marshal() // 示例:每轮按当前 TarActor 重新编码
err := zbus.DefaultBus.Broadcast(topic, encoded)
if err != nil {
continue // 失败,尝试下一个
}
return true
}
1.5. 4.3.5 无路由处理
三条路径都失败时,gateHandler 调用 sendNoRouteError(gate.go):内部先尝试 OnNoRoute 注册的 noRouteHandler,若 handled 则按需 sendClient;否则 仅打 Warn 日志,客户端未必收到应用层错误包——需业务在协议层约定超时或注册钩子。
1.6. 4.3.6 本节要点
- 序:自身 handler →
RouteLocal(CmdTypeClient+Retain)→routeToRemoteActor→sendNoRouteError。 - 本地表:
LookupActorsByMsgID/LookupActorsByMsgIDView(无分配快路径视 Group 能力而定)。 - 远程候选:
otherActors+SupportedMsgIDs,快表或线性扫描;PickOne仅选首选下标,失败则 fallback。 - 策略:
FirstCandidate/RoundRobin/RendezvousHash(DefaultRemoteRouteKey:SessionId 优先)。 - 无路由:默认日志;
Server.OnNoRoute可自定义回包。
4.4 节:Push 内 SetReply 与 IToClientFastPath 的顺序与约束。