1. 5.2 RendezvousHash 路由与粘性策略
5.1 提供远程 ActorConfig 候选;Gate 在 routeToRemoteActor 中用 zroute.RemoteRouteStrategy.PickOne 选首选下标,失败再 fallback。本节说明 HRW (hrwScore) 与 DefaultRemoteRouteKey,实现见 zhenyi/zroute/remote_strategy.go。
1.1. 5.2.1 路由选型:轮询 vs 哈希 vs 一致性哈希 vs HRW
先看几种常见路由策略的区别:
| 策略 | 粘性 | 扩缩容影响 | 实现复杂度 |
|---|---|---|---|
| 轮询(RoundRobin) | 无 | 无(全打散) | 低 |
| 随机(Random) | 无 | 无(全打散) | 极低 |
| 取模哈希(N % nodes) | 有 | 大(全部重映射) | 低 |
| 一致性哈希(Ketama) | 有 | 小(约 1/N) | 中 |
| RendezvousHash(HRW) | 有 | 与环哈希同量级、实现更简单 | 中 |
实时应用需要粘性——同一个用户的请求总是到同一个节点。所以轮询和随机被排除。
取模哈希的问题是扩缩容时全部重映射:3 个节点变 4 个,几乎所有用户的映射都变了。对有状态服务来说,这意味着大量用户的状态需要迁移。
一致性哈希解决了这个问题,但需要虚拟节点来平衡负载。
本框架默认在需要粘性且候选规模有限(常几十个)时采用 HRW:线性扫描即可,不维护环与虚拟节点;是否与 Ketama「谁更优」取决于节点规模、均衡需求与运维习惯,此处只交代 zhenyi 的实现选择。
1.2. 5.2.2 RendezvousHash 是什么?
RendezvousHash 的核心思想:把 key 和每个节点做一次哈希,选分数最高的节点。
用户 A 的请求:
score(A, 节点1) = 0.72
score(A, 节点2) = 0.85 ← 最高,选这个
score(A, 节点3) = 0.31
用户 B 的请求:
score(B, 节点1) = 0.91 ← 最高
score(B, 节点2) = 0.44
score(B, 节点3) = 0.67
同一个用户总是选到同一个节点(哈希函数确定性)。新增或删除节点时,只有分数比较受影响的用户会改变映射,大部分用户不受影响。
1.3. 5.2.3 与典型一致性哈希的对比(工程取舍)
1.3.1. 扩缩容对比
假设 3 个 IM Actor,加一个:
一致性哈希(Ketama):
原来:用户分布在 3 个节点的哈希环上
加了节点 4:
- 节点 4 从相邻节点"抢走"一段哈希范围
- 约 1/4 用户迁移到节点 4
- 需要 100~200 个虚拟节点才能均匀分布
RendezvousHash:
原来:每个用户对所有节点算分数,选最高
加了节点 4:
- 每个用户重新算一遍,节点 4 可能胜出也可能不
- 约 1/4 用户迁移到节点 4
- 不需要虚拟节点,天然均匀
两者在「约 1/N 迁移」量级上常接近;HRW 不引入虚拟节点,以 O(候选数) 扫描换数据结构简单。
1.3.2. 负载均衡
一致性哈希需要虚拟节点来保证均匀分布。如果虚拟节点不够,可能出现某些物理节点承担更多流量。
HRW 通过 对所有候选打分取最大 分散 key;是否比「少虚节点」的环哈希更匀,应靠压测与监控。
1.3.3. 实现复杂度
一致性哈希(Ketama):
- 维护一个排序的哈希环(通常用红黑树或跳表)
- 查找时在环上二分查找
- 虚拟节点管理(每个物理节点 100~200 个虚拟节点)
RendezvousHash:
- 线性扫描所有候选,选最高分
- 不需要维护额外数据结构
- 不需要虚拟节点
对于 zhenyi 的场景(通常几十个 Actor),线性扫描的开销可以忽略。如果有几百个节点,可以做分区优化,但目前的规模不需要。
1.4. 5.2.4 zhenyi 的实现
1.4.1. hash 函数
func hrwScore(key uint64, actorID uint64, process uint64) uint64 {
const (
c1 = 0x9e3779b185ebca87 // 黄金比例相关的乘子
c2 = 0xc2b2ae3d27d4eb4f
)
x := key ^ (actorID + 0x100000001b3*process)
x ^= x >> 33
x *= c1
x ^= x >> 29
x *= c2
x ^= x >> 32
return x
}
实现为 64 位整型混合(乘子与位移见源码注释),输出 uint64 分数 只做大小比较,避免浮点。
三个输入参数:
- key:路由键(
DefaultRemoteRouteKey:优先SessionId,否则RpcId) - actorID:Actor 的唯一 ID
- process:进程 ID
为什么同时用 actorID 和 process?防止不同进程的 Actor ID 碰撞导致 hash 冲突。
1.4.2. 默认 key 的选择
func DefaultRemoteRouteKey(msg *zmsg.Message) uint64 {
if msg.SessionId != 0 {
return msg.SessionId // 优先用 SessionId(会话粘性)
}
if msg.RpcId != 0 {
return msg.RpcId // 其次用 RpcId(请求粘性)
}
return 0 // 无 key,退化为第一个候选
}
大部分消息都有 SessionId(客户端消息),所以默认就是会话粘性。
如果没有 SessionId 但有 RpcId(Actor 间的 RPC 调用),用 RpcId 做 key。
如果都没有,退化为"第一个候选优先",由 fallback 机制兜底。
1.4.3. 选址逻辑(PickOne)
func (s *RendezvousHashStrategy) PickOne(msg *zmsg.Message, candidates []zmodel.ActorConfig) int {
key := s.KeyFunc(msg) // 默认 DefaultRemoteRouteKey
if key == 0 {
return 0 // 无 key,退化为第一个候选
}
bestIdx := -1
var bestScore uint64
for i, c := range candidates {
score := hrwScore(key, uint64(c.Id), uint64(c.Process))
if bestIdx < 0 || score > bestScore { // 平局保留先出现的下标
bestIdx = i
bestScore = score
}
}
return bestIdx
}
策略只返回一个首选下标;Gate 先尝试首选,失败再 fallback(见 4.3)。
1.5. 5.2.5 key 的选择对粘性的影响
key 选择不同,粘性的粒度不同:
| key | 粘性粒度 | 适用场景 |
|---|---|---|
| SessionId | 用户级 | IM、游戏(同一用户状态在同一节点) |
| MsgId | 消息类型级 | 无状态处理(同类消息到同一节点) |
| 自定义 shardKey | 业务级 | 多租户(同租户数据在同一节点) |
zhenyi 默认优先用 SessionId,无则 RpcId,与大多数有状态实时场景一致;若仍无 key,则退化为“第一个候选”。
如果需要自定义,可以创建自己的 RendezvousHashStrategy:
strategy := &RendezvousHashStrategy{
KeyFunc: func(msg *zmsg.Message) uint64 {
return msg.SessionId ^ uint64(msg.MsgId) // 自定义混合 key
},
}
gate.SetRemoteRouteStrategy(strategy)
1.6. 5.2.6 无 key 时的降级
当消息没有 SessionId 也没有 RpcId 时,DefaultRemoteRouteKey 返回 0;RendezvousHashStrategy.PickOne 在 key == 0 时直接返回索引 0(首选候选),与“无粘性时固定打第一个候选”的语义一致,而不是对候选做全量重排。
1.7. 5.2.7 本节要点
- HRW:
hrwScore(key, Id, Process);Process 参与混合以降低跨进程 Id 碰撞风险。 - 默认 key:
SessionId→RpcId→0(0时PickOne返回 0,即首选候选)。 - 自定义:
RendezvousHashStrategy{KeyFunc: ...}+gate.SetRemoteRouteStrategy。 - 复杂度:对候选 线性扫描;候选数很大时需另做分域或分层。
- 与快照:远程候选列表来自 Group 跨进程路由快照(5.4),与本节策略正交。
5.3 节:TopicBus / znats 工程细节。