一、为什么按频道分片
单台聊天服能扛住的连接数和消息率都有上限。把聊天拆成多实例后,必须回答一个问题:同一条频道消息由谁处理?如果两个实例都处理,玩家会看到重复消息,序号还会乱序;如果没人处理,消息直接消失。
分片的目标不是“把连接平均分”,而是“让同一频道的处理者唯一且稳定”。连接可以随网关漂移,频道归属不能随机漂移。
二、分片键:频道而不是玩家
| 分片键 | 稳定性 | 负载均衡 | 主要问题 |
|---|---|---|---|
| 玩家 ID | 高 | 好 | 公会消息要额外扇出到所有成员所在分片 |
| 公会 ID | 高 | 依赖公会规模 | 大公会形成热点 |
| 频道名哈希 | 高 | 较好 | 热点频道仍可能集中 |
| 随机选择 | 无 | 看似均匀 | 同一频道落到多个实例 |
按频道名哈希是聊天的自然选择:chat:guild:<id>、chat:world、chat:player:<id> 都有自己的归属实例。代价是热点频道无法靠哈希摊平,需要单独指定实例或做二级分片。
玩家私聊频道要特别小心。A 给 B 发消息,如果 A、B 的私聊频道分别归不同实例,发送方实例不能直接本地处理,而应把消息路由到接收方所属实例,或者统一发布到共享 Broker 由双方网关消费。
三、一致性哈希:稳定与迁移
普通取模在实例数变化时会重排几乎所有键:三台变四台,大量频道瞬间换主,缓存和在线状态都要重建。一致性哈希把实例映射成环上的多个虚拟节点,频道键沿环找到第一个节点,因此增删实例只影响相邻区间。
| 操作 | 受影响的频道 | 不受影响的频道 |
|---|---|---|
| 新增实例 | 原本落在新实例区间的那部分 | 其余全部保持原归属 |
| 摘除实例 | 原本属于该实例的那部分 | 其余全部保持原归属 |
| 标记不健康 | 命中该实例的频道改走下一个健康节点 | 其余不变 |
| 恢复健康 | 对应区间恢复原归属 | 其余不变 |
虚拟节点数量决定分布均匀度。副本太少,环上区间不均;副本太多,内存和查找成本上升。工程上每实例几十到几百个副本都常见,关键是固定哈希函数和副本命名,保证所有实例算出的环一致。示例实现使用 FNV-1a,副本名为 实例ID#序号。
四、实例摘除、健康检查与会话保持
“不健康”和“已摘除”要分开处理。标记不健康只是不再选它,环上的虚拟节点保留,恢复后频道立刻回到原归属;真正摘除则从环上删除节点,并把绑定在它上面的热点频道交还给路由层重新分配。
迁移顺序建议固定为:先标记 draining 停止接收新频道,再等待在途消息处理完,然后迁移热点频道,最后才删除实例。反过来先删实例,再补迁移,迁移期间的消息就会丢。
会话保持是另一层问题。玩家连接固定在某个网关,网关订阅哪些频道由本地在线玩家决定;聊天服实例变化只影响“消息由谁发布和处理”,不应强制玩家重连。若确实需要迁移处理中的频道,应把该频道的短暂空窗记录下来,由客户端按序号发现缺口后拉取历史。
五、跨分片私聊
私聊的频道通常是 chat:player:<接收者ID>。发送方所在实例算出接收者频道归属后,有两种做法:
| 做法 | 路径 | 优点 | 缺点 |
|---|---|---|---|
| 转发到归属实例 | 发送方 → 归属实例 → Broker | 处理者唯一 | 多一跳,需要实例间 RPC |
| 发布到共享 Broker | 发送方 → Broker → 各网关 | 路径短 | 所有网关都要按频道过滤 |
无论哪种,都不能让发送方实例和接收方实例各自本地处理一次,否则同一条私聊会出现两份。序号应由接收者频道所属实例统一分配,发送方只负责携带客户端消息 ID。
六、容量估算:用示例值而非拍脑袋
估算至少包含三个因子:在线人数、人均订阅频道数、人均消息率。下面是一组示例值,仅用于演示算法,不代表任何真实项目:
| 项 | 示例值 | 说明 |
|---|---|---|
| 峰值在线 | 50,000 | 单区服 |
| 人均订阅频道 | 3 | 世界、公会、私聊 |
| 人均消息率 | 0.2 条/秒 | 平均,峰值更高 |
| 单条广播扇出 | 10 个网关 | 取决于网关数 |
| 单实例目标消息率 | 5,000 条/秒 | 留出余量后的保守值 |
按示例值计算,发布侧约 10,000 条/秒,乘扇出后是 100,000 次网关投递/秒。这个量级不能只看 Redis 发布,还要算网关下行和客户端处理。容量规划至少留 50% 余量,并把热点频道从均值模型里拿出来单独评估。
七、热点频道
世界频道、跨服战场、Boss 房间都可能成为热点。哈希分片对热点无能为力,因为同一个频道只能落在一个实例上。可选手段有三种:把热点频道固定到专用实例;对频道做二级分片,例如按地图线号拆成多个子频道;在入口做更严格的限频和聚合。
| 手段 | 隔离效果 | 实现成本 | 副作用 |
|---|---|---|---|
| 专用实例 | 强 | 低 | 资源利用率低 |
| 二级分片 | 强 | 中 | 消息顺序和成员可见性变复杂 |
| 入口限频 | 中 | 低 | 玩家体验受限 |
| 消息聚合 | 中 | 中 | 实时性下降 |
热点频道固定到专用实例后,仍要保留回退路径:专用实例不健康时,路由应能退回普通环,而不是让世界频道整体不可用。
八、失败场景:随机分配导致同频道多实例
某项目为了“均衡”,每条消息随机选一台聊天服处理。同一公会的两条消息可能落到不同实例,序号各自从本地自增,客户端收到 1, 1, 2, 2,去重和排序全部失效;广播时两个实例又各自向网关推送,玩家看到重复内容。
修复方式是把“选择实例”从每条消息改为每个频道:频道到实例的映射缓存起来,实例变化时整批失效。迁移时先停止该频道的旧处理者,再切换映射,最后恢复;切换窗口内到达的消息进入短暂缓冲,按新映射重放。只做映射切换、不做在途保护,仍会丢消息。
九、Go 实现:ShardRouter
ShardRouter 用带虚拟节点的一致性哈希选择实例,热点频道可以固定到指定实例。SetHealthy 只影响选择,RemoveInstance 才真正删除节点并返回失去归属的热点频道。
// file: ch10/hub/shard.go
// tier: L1
// verify: go test ./ch10/hub/
package hub
import (
"errors"
"fmt"
"hash/fnv"
"sort"
"sync"
)
var (
ErrNoInstance = errors.New("shard: no healthy instance")
ErrUnknownInstance = errors.New("shard: unknown instance")
ErrDuplicateInstance = errors.New("shard: duplicate instance")
ErrBadReplicas = errors.New("shard: replicas must be positive")
)
type ringEntry struct {
hash uint32
id string
}
// ShardRouter 用一致性哈希把频道固定到聊天服实例;热点频道可以单独指定。
type ShardRouter struct {
mu sync.RWMutex
replicas int
entries []ringEntry
healthy map[string]bool
hot map[string]string
}
func NewShardRouter(replicas int, ids ...string) (*ShardRouter, error) {
if replicas <= 0 {
return nil, ErrBadReplicas
}
r := &ShardRouter{
replicas: replicas,
healthy: make(map[string]bool),
hot: make(map[string]string),
}
for _, id := range ids {
if err := r.AddInstance(id); err != nil {
return nil, err
}
}
return r, nil
}
// AddInstance 为实例生成 replicas 个虚拟节点;重复 ID 会被拒绝。
func (r *ShardRouter) AddInstance(id string) error {
if id == "" {
return fmt.Errorf("%w: empty id", ErrUnknownInstance)
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.healthy[id]; ok {
return ErrDuplicateInstance
}
r.healthy[id] = true
for i := 0; i < r.replicas; i++ {
r.entries = append(r.entries, ringEntry{
hash: hashKey(fmt.Sprintf("%s#%d", id, i)),
id: id,
})
}
sort.Slice(r.entries, func(i, j int) bool {
if r.entries[i].hash == r.entries[j].hash {
return r.entries[i].id < r.entries[j].id
}
return r.entries[i].hash < r.entries[j].hash
})
return nil
}
// RemoveInstance 摘除实例,并返回因此失去专属实例的热点频道。
func (r *ShardRouter) RemoveInstance(id string) []string {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.healthy[id]; !ok {
return nil
}
delete(r.healthy, id)
kept := r.entries[:0]
for _, entry := range r.entries {
if entry.id != id {
kept = append(kept, entry)
}
}
r.entries = kept
var lost []string
for channel, target := range r.hot {
if target == id {
delete(r.hot, channel)
lost = append(lost, channel)
}
}
sort.Strings(lost)
return lost
}
// SetHealthy 只影响选择,不删除虚拟节点;恢复后频道归属立刻复原。
func (r *ShardRouter) SetHealthy(id string, healthy bool) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.healthy[id]; !ok {
return ErrUnknownInstance
}
r.healthy[id] = healthy
return nil
}
// AssignHot 把热点频道固定到指定实例。
func (r *ShardRouter) AssignHot(channel, instanceID string) error {
if err := ValidateChannel(channel); err != nil {
return err
}
r.mu.Lock()
defer r.mu.Unlock()
if _, ok := r.healthy[instanceID]; !ok {
return ErrUnknownInstance
}
r.hot[channel] = instanceID
return nil
}
// Pick 返回频道归属实例;热点固定优先,其次沿哈希环找到第一个健康实例。
func (r *ShardRouter) Pick(channel string) (string, error) {
if err := ValidateChannel(channel); err != nil {
return "", err
}
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.entries) == 0 {
return "", ErrNoInstance
}
if target, ok := r.hot[channel]; ok && r.healthy[target] {
return target, nil
}
start := sort.Search(len(r.entries), func(i int) bool {
return r.entries[i].hash >= hashKey(channel)
})
for i := 0; i < len(r.entries); i++ {
entry := r.entries[(start+i)%len(r.entries)]
if r.healthy[entry.id] {
return entry.id, nil
}
}
return "", ErrNoInstance
}
// Instances 返回全部实例 ID(含不健康实例),按名称排序。
func (r *ShardRouter) Instances() []string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]string, 0, len(r.healthy))
for id := range r.healthy {
out = append(out, id)
}
sort.Strings(out)
return out
}
// HotChannels 返回热点频道映射的副本。
func (r *ShardRouter) HotChannels() map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]string, len(r.hot))
for channel, id := range r.hot {
out[channel] = id
}
return out
}
func hashKey(s string) uint32 {
h := fnv.New32a()
_, _ = h.Write([]byte(s))
return h.Sum32()
}
Pick 先检查热点映射,再沿环找到第一个健康实例;热点目标不健康时自动回退到普通环,避免单点故障扩大。RemoveInstance 返回受影响的频道列表,调用方可以据此告警或触发迁移,而不是静默改道。
十、测试覆盖
测试文件 ch10/hub/shard_test.go 验证稳定性和迁移边界:
| 测试 | 断言 |
|---|---|
| TestPickIsStableAndDistributed | 同频道多次选择一致,不同频道能分散 |
| TestRemoveInstanceKeepsOtherAssignments | 摘除后,原本不属于它的频道完全不变 |
| TestAddInstanceOnlyStealsToNewInstance | 新增实例只接管新实例自己的区间 |
| TestHotChannelOverridesAndFallback | 热点频道固定、失联回退、摘除后清理 |
| TestRouterErrors | 空路由、全不健康、未知实例、非法频道 |
稳定性测试不是比对某次运行的具体实例名,而是比对“变化前后”的归属关系。这样即使副本数或哈希实现调整,测试仍然表达真正的不变量。
十一、运行验证
cd .local/checks/gs-practice-lab && gofmt -l ch10/hub && go vet ./ch10/hub/ && go test ./ch10/hub/ -count=1
ok gspractice/ch10/hub 0.275s
小结
聊天分片的核心约束是“同一频道只有一个处理者”。按频道名做一致性哈希,可以让增删实例只影响局部频道;健康标记与真正摘除要分开,迁移要先把在途消息处理完。热点频道需要专用实例或二级分片,跨分片私聊要明确由哪一侧分配序号。ShardRouter 把这些规则固化成可测试的选择逻辑。
常见错误与边界
- 每条消息随机选实例,同一频道被多个实例处理。
- 用玩家 ID 分片却不处理公会消息的跨分片扇出。
- 直接删除实例再迁移,迁移窗口内的消息丢失。
- 把不健康和已摘除混为一谈,健康恢复后频道归属无法复原。
- 热点频道只靠哈希,单实例被世界频道打满。
- 跨分片私聊两端都本地处理,玩家收到重复消息。
- 容量估算只算发布量,忽略网关扇出和客户端下行。
- 频道映射缓存没有失效机制,实例变化后继续打旧实例。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。