《MMO 服务器开发》5.1 配置版本化与差量分发

配置不能只靠重启生效:一个能长期运营的配置中心要有版本号、只推变化的差量、按服与按玩家百分比的灰度,以及节点应用后的 ack 回执。本节用本机 etcd 从零实现版本化存储与差量分发,把「配置写入 → watch 到变更 → 差量应用 → ack」整条链路真跑一遍。

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;再把内容原样发布一次,差量必须为空——这就是「只推变更的键」的硬断言。

本节小结

  1. 版本号只前进,不倒退。 回滚是「用旧内容发新版本」,不是把版本号改回去;线上永远在前进,历史一条不丢。
  2. 差量只推变更的键。 Diff 挑出值变化与删除的键,Publish 用一个事务把它们与版本号、历史快照一起提交,没动的键一个字节都不写。
  3. 灰度要确定性。 按服是名单制,按玩家百分比必须用稳定散列,否则玩家会在放量与收回之间横跳。
  4. 下发要有 ack 闭环。 节点先播种再 rev+1 Watch,应用完回 CmdConfigAck;服务端比对 ack 版本才能说「推齐了」。

一句话收束:配置中心真正难的不是「怎么把值送到线上」,而是「怎么知道每一台都用上了、并且随时能退回上一版」——版本、差量、ack 三件事合起来才是那个「知道」。 下一节把边界钉死:哪些配置能这样热更,哪些碰都不能碰。

阅读导航:上一节:目录 · 下一节:5.2 热重载的边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标