《游戏服务端编程实践》10.3.3 负载均衡与消息分片

讨论聊天服按频道分片时的分片键选择、一致性哈希的稳定性、实例摘除与迁移顺序、跨分片私聊和热点频道隔离,并给出可测试的 ShardRouter 实现,避免同一频道被多个实例重复处理;文章用路由器代码说明频道归属的不变量,并给出迁移与热点回退的检查清单。

一、为什么按频道分片

单台聊天服能扛住的连接数和消息率都有上限。把聊天拆成多实例后,必须回答一个问题:同一条频道消息由谁处理?如果两个实例都处理,玩家会看到重复消息,序号还会乱序;如果没人处理,消息直接消失。

分片的目标不是“把连接平均分”,而是“让同一频道的处理者唯一且稳定”。连接可以随网关漂移,频道归属不能随机漂移。

二、分片键:频道而不是玩家

分片键稳定性负载均衡主要问题
玩家 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 分片却不处理公会消息的跨分片扇出。
  • 直接删除实例再迁移,迁移窗口内的消息丢失。
  • 把不健康和已摘除混为一谈,健康恢复后频道归属无法复原。
  • 热点频道只靠哈希,单实例被世界频道打满。
  • 跨分片私聊两端都本地处理,玩家收到重复消息。
  • 容量估算只算发布量,忽略网关扇出和客户端下行。
  • 频道映射缓存没有失效机制,实例变化后继续打旧实例。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist