5.1 配置版本化与差量分发
《晨曦大陆》上线之后,改一个数值是最频繁的运营动作:调一次掉落权重、关一个活动开关、修一条怪物属性。如果每次都要重启进程,代价是排空连接、重连风暴与一段可观的不可用窗口。配置中心的全部意义,就是把这些改动不重启地送到线上——但「送过去」三个字里藏着三个必须解决的问题。
第一,怎么保证线上每一台机器拿到的是同一份、可追溯的配置?第二,改一个键就要把整张表重推一遍吗?第三,推下去之后,怎么知道每一台真的用上了?这一节从零实现一个挂在 etcd 上的配置中心,把版本号、差量、灰度与 ack 四件事做出来,并真连本机 etcd 跑通。先交代本节与站内既有文章的分工:
| 既有文章 | 负责「架构方案与取舍」 | 本书本节负责「从零实现 + 真跑」 |
|---|---|---|
/game-server-config-center-implementation/ | 配置中心的整体分层、存储选型与读写路径 | 落地一个可编译的 Store,把版本、差量、发布跑出来 |
/game-server-runtime-config-diff-distribution-architecture/ | 差量分发的设计取舍与收敛策略 | 用 Diff 与 etcd 事务实现「只推变更的键」 |
/game-server-live-configuration-risk-control/ | 线上配置变更的风险控制 | 给出灰度推送与 ack 回执的最小实现 |
/boundaries-of-hot-update-in-game-servers/ | 哪些能热更、哪些不能的边界 | 下一节把边界固化成可判定的分类表 |
5.1.1 版本号:只前进,不倒退
配置中心的第一性设计是给每一次发布一个单调递增的版本号。有了它,三件事才成立:节点可以上报「我用的是第几版」,运维可以问「线上还差哪几台没更新」,回滚可以精确指认「回到第 3 版」。
关键约定是:版本号只前进,不倒退。回滚不是把版本号改回 3,而是「以第 3 版的内容,发布一个第 N+1 版」。这样线上永远在前进,历史版本一条不丢,审计也查得到「谁在什么时候把哪一版的内容重新发了出去」。如果允许版本号倒退,节点缓存、监控曲线与灰度观察窗口都会失去可比性。
5.1.2 差量:只推变更的键
整表重推在配置规模小时看着无害,但《晨曦大陆》的掉落表、等级表加起来是几千个键。每次改一个数值就重推几千个键,浪费的是带宽、etcd 写入与每个节点的应用开销。差量的做法很简单:对比新旧两份快照,只把值变化的键和删除的键挑出来。
下面这段是 L2,真连本机主控起的 etcd(http://127.0.0.1:32379),go test 可以直接跑。它把版本、差量与事务化发布做出来,测试数据统一写在独立前缀 aegis:test:ch05: 下:
// file: internal/config/store.go
// tier: L2
// verify: go test ./internal/config/ -run TestEtcd -v
package config
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
clientv3 "go.etcd.io/etcd/client/v3"
)
// Version 是配置快照的单调版本号。每次发布自增;回滚也是「发一个新版本,
// 内容等于旧版本」,版本号只前进不倒退,历史始终可查。
type Version uint64
// Item 是一个配置项:键、值,以及它被写入时的版本号。
type Item struct {
Key string `json:"key"`
Value string `json:"value"`
Ver Version `json:"ver"`
}
// Snapshot 是某个版本下的全量配置视图。
type Snapshot struct {
Version Version `json:"version"`
Items map[string]Item `json:"items"`
}
// Delta 是从一个版本到另一个版本之间「真正变化的部分」。
// 只包含值变化的键与被删除的键——没动的键不进 Delta,这是差量分发的核心。
type Delta struct {
From Version `json:"from"`
To Version `json:"to"`
Changed []Item `json:"changed"`
Deleted []string `json:"deleted"`
}
// Empty 表示这次发布没有任何键变化(幂等发布)。
func (d Delta) Empty() bool { return len(d.Changed) == 0 && len(d.Deleted) == 0 }
// Size 是差量涉及的键数,用于校验「只推变更的键」。
func (d Delta) Size() int { return len(d.Changed) + len(d.Deleted) }
// Diff 计算 from → to 的差量:只收集值变化或被删除的键。
func Diff(from, to Snapshot) Delta {
d := Delta{From: from.Version, To: to.Version}
for k, nv := range to.Items {
if ov, ok := from.Items[k]; !ok || ov.Value != nv.Value {
d.Changed = append(d.Changed, nv)
}
}
for k := range from.Items {
if _, ok := to.Items[k]; !ok {
d.Deleted = append(d.Deleted, k)
}
}
sort.Slice(d.Changed, func(i, j int) bool { return d.Changed[i].Key < d.Changed[j].Key })
sort.Strings(d.Deleted)
return d
}
// Store 是挂在 etcd 上的配置中心。键布局:
//
// <prefix>kv/<key> → 当前值(JSON 编码的 Item)
// <prefix>version → 当前版本号(十进制)
// <prefix>history/<ver> → 该版本的完整快照,供回滚与审计
type Store struct {
cli *clientv3.Client
prefix string
}
// NewStore 创建配置中心;prefix 形如 "aegis:test:ch05:"。
func NewStore(cli *clientv3.Client, prefix string) *Store {
if !strings.HasSuffix(prefix, ":") && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
return &Store{cli: cli, prefix: prefix}
}
func (s *Store) kvPrefix() string { return s.prefix + "kv/" }
func (s *Store) versionKey() string { return s.prefix + "version" }
func (s *Store) histKey(v Version) string {
return s.prefix + "history/" + strconv.FormatUint(uint64(v), 10)
}
// Snapshot 读取当前全量配置(强一致读)。
func (s *Store) Snapshot(ctx context.Context) (Snapshot, error) {
snap, _, err := s.load(ctx)
return snap, err
}
// load 读版本号与全部 kv,并返回本次读到的 etcd revision(供 Watch 播种)。
func (s *Store) load(ctx context.Context) (Snapshot, int64, error) {
snap := Snapshot{Items: map[string]Item{}}
vresp, err := s.cli.Get(ctx, s.versionKey())
if err != nil {
return snap, 0, fmt.Errorf("config: read version: %w", err)
}
if len(vresp.Kvs) > 0 {
if n, perr := strconv.ParseUint(string(vresp.Kvs[0].Value), 10, 64); perr == nil {
snap.Version = Version(n)
}
}
kresp, err := s.cli.Get(ctx, s.kvPrefix(), clientv3.WithPrefix())
if err != nil {
return snap, 0, fmt.Errorf("config: read kv: %w", err)
}
for _, kv := range kresp.Kvs {
var it Item
if err := json.Unmarshal(kv.Value, &it); err != nil {
return snap, 0, fmt.Errorf("config: decode %q: %w", kv.Key, err)
}
snap.Items[it.Key] = it
}
return snap, kresp.Header.Revision, nil
}
// Publish 把 desired 发布为新版本:与当前快照求差量,只写变化的键,
// 并用一个事务原子地写入版本号与历史快照。返回本次真正下发的差量。
func (s *Store) Publish(ctx context.Context, desired map[string]string) (Delta, error) {
cur, _, err := s.load(ctx)
if err != nil {
return Delta{}, err
}
next := Snapshot{Version: cur.Version + 1, Items: make(map[string]Item, len(desired))}
for k, v := range desired {
next.Items[k] = Item{Key: k, Value: v, Ver: next.Version}
}
delta := Diff(cur, next)
ops := make([]clientv3.Op, 0, delta.Size()+2)
for _, it := range delta.Changed {
b, _ := json.Marshal(it)
ops = append(ops, clientv3.OpPut(s.kvPrefix()+it.Key, string(b)))
}
for _, k := range delta.Deleted {
ops = append(ops, clientv3.OpDelete(s.kvPrefix()+k))
}
hb, _ := json.Marshal(next)
ops = append(ops,
clientv3.OpPut(s.histKey(next.Version), string(hb)),
clientv3.OpPut(s.versionKey(), strconv.FormatUint(uint64(next.Version), 10)),
)
resp, err := s.cli.Txn(ctx).Then(ops...).Commit()
if err != nil {
return Delta{}, fmt.Errorf("config: publish txn: %w", err)
}
if !resp.Succeeded {
return Delta{}, fmt.Errorf("config: publish txn not committed")
}
return delta, nil
}
// Cleanup 删除本 prefix 下的全部键,供测试收尾。
func (s *Store) Cleanup(ctx context.Context) error {
_, err := s.cli.Delete(ctx, s.prefix, clientv3.WithPrefix())
return err
}
三个设计点值得停一下。其一,Publish 只把 delta.Changed 与 delta.Deleted 写进 etcd,没变化的键一个字节都不写——这就是「只推变更的键」在存储层的落点。其二,写入用一个 Txn 把「改动的键 + 版本号 + 历史快照」捆成一次提交,节点侧要么看到完整的新版本,要么什么都看不到,不会出现「值更新了但版本号没动」。其三,历史快照是回滚的底气:history/<ver> 存了每一版的完整内容,回滚时把它当新一版的期望状态重新发布即可。
5.1.3 灰度推送:按服与按玩家百分比
差量算出来了,但不是所有节点、所有玩家都应该同时收到。线上配置变更的标准姿势是先灰度:按区服挑几台,或按玩家百分比放量,观察一两个指标窗口再决定继续还是回滚。
按区服是名单制:只把差量推给白名单里的 ServerID。按玩家百分比则必须用确定性散列而不是随机数——同一个玩家在多次判定里必须落到同一侧,否则玩家会在「放量」和「收回」之间反复横跳,体验比不灰度还糟。下面这段是 L2 文件里的片段(// snippet:,完整见 internal/config/gray.go):
// snippet: internal/config/gray.go
// GrayByPlayer 按玩家百分比灰度。用 FNV-1a 对玩家 ID 做确定性散列,
// 而不是随机数:同一玩家在多次判定中结果稳定,否则玩家会在放量与收回之间反复横跳。
func GrayByPlayer(playerID uint64, percent int) bool {
if percent <= 0 {
return false
}
if percent >= 100 {
return true
}
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], playerID)
h := fnv.New64a()
_, _ = h.Write(buf[:])
return int(h.Sum64()%100) < percent
}
// PushMessage 是服务端下发给节点的配置推送,命令号 CmdConfigPush。
type PushMessage struct {
Cmd uint16 `json:"cmd"`
Version Version `json:"version"`
Delta Delta `json:"delta"`
}
// AckMessage 是节点应用差量后的回执,命令号 CmdConfigAck。
type AckMessage struct {
Cmd uint16 `json:"cmd"`
Node string `json:"node"`
Version Version `json:"version"`
Applied int `json:"applied"`
}
5.1.4 下发与 ack:怎么知道推到了
推送不是「发出去就算完」,而是要有回执闭环。节点的做法是:先读一次全量播种、记下 etcd revision,再从 rev+1 起 Watch 前缀。这样既不漏掉订阅瞬间的变更,也不重放历史。每收到一批事件,只把变化的键应用到本地快照,然后回一个 CmdConfigAck。下面这段是 L2 文件里的片段(// snippet:,完整见 internal/config/subscriber.go):
// snippet: internal/config/subscriber.go
// Run 先读一次全量播种、记下 revision,再从 rev+1 起 Watch,
// 既不漏事件也不重放历史。每应用一批变更就回调一次 ack。ctx 取消时返回。
func (s *Subscriber) Run(ctx context.Context, onAck func(AckMessage)) error {
st := NewStore(s.cli, s.prefix)
cur, rev, err := st.load(ctx)
if err != nil {
return err
}
s.mu.Lock()
s.snap = cur
s.mu.Unlock()
ch := s.cli.Watch(ctx, s.prefix+"kv/", clientv3.WithPrefix(), clientv3.WithRev(rev+1))
for resp := range ch {
if err := resp.Err(); err != nil {
return err
}
n := s.applyEvents(resp.Events)
if n == 0 {
continue
}
ver := s.readVersion(ctx)
s.mu.Lock()
s.snap.Version = ver
s.mu.Unlock()
if onAck != nil {
onAck(AckMessage{Cmd: CmdConfigAck, Node: s.node, Version: ver, Applied: n})
}
}
return ctx.Err()
}
有了 ack,服务端就能判断一次下发是否收敛:把所有目标节点的最新 ack 版本和本次发布版本比一比,全都 >= 就算推齐了。真跑的结果如下:
=== RUN TestEtcdConfigDeltaPushAndAck
--- PASS: TestEtcdConfigDeltaPushAndAck (0.07s)
PASS
ok aegis/internal/config 0.727s
这个用例把整条链路串了起来:首次发布 3 个键,差量大小 = 3;只改 battle.damage_scale 一个键后再次发布,差量大小必须是 1,且订阅者收到的 ack 版本为 2、Applied=1;再把内容原样发布一次,差量必须为空——这就是「只推变更的键」的硬断言。
本节小结
- 版本号只前进,不倒退。 回滚是「用旧内容发新版本」,不是把版本号改回去;线上永远在前进,历史一条不丢。
- 差量只推变更的键。
Diff挑出值变化与删除的键,Publish用一个事务把它们与版本号、历史快照一起提交,没动的键一个字节都不写。 - 灰度要确定性。 按服是名单制,按玩家百分比必须用稳定散列,否则玩家会在放量与收回之间横跳。
- 下发要有 ack 闭环。 节点先播种再
rev+1Watch,应用完回CmdConfigAck;服务端比对 ack 版本才能说「推齐了」。
一句话收束:配置中心真正难的不是「怎么把值送到线上」,而是「怎么知道每一台都用上了、并且随时能退回上一版」——版本、差量、ack 三件事合起来才是那个「知道」。 下一节把边界钉死:哪些配置能这样热更,哪些碰都不能碰。
阅读导航:上一节:目录 · 下一节:5.2 热重载的边界 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。