5.2 热重载的边界
上一节把配置送上了线,但「送上去」和「能生效」之间还隔着一堵墙:不是所有配置都能在不重启的情况下生效。一个新手最容易犯的错,是把「配置中心能下发任意键」误当成「任意键都能热更」——于是有人改了登录响应体的字段,热更之后客户端开始大量解析失败;有人热换了玩家数据模型,线上读到一半的存档结构对不上,直接崩。
这一节要回答的就是这个边界问题:哪些能热更、哪些不能,以及能热更的那些怎么切换才安全。核心结论先摆出来:热更的安全性取决于「读侧有没有持有旧数据的引用」。纯数值、开关、掉落表这类「读一次就用完」的数据可以随便换;而协议结构、数据模型、需要重建运行态的部分,一旦读侧有长期引用,热更就是在动别人的地基。
5.2.1 三类边界:hot / warm / cold
把配置按「读侧怎么用它」分成三类,边界就清晰了:
- hot(可直接热更):纯数值、布尔开关。读侧每次用到都现读一次,换掉即生效,没有残留引用。伤害系数、暴击率、功能开关都属于这一类。
- warm(可热更但要双缓冲):整表数据,比如掉落表、等级经验表。读侧可能一次遍历整张表,换表时不能原地改,必须整份切换,否则遍历到一半会看到半新半旧的行。
- cold(不可热更):协议结构、数据模型、需要重建运行态的部分。它们的「形状」被写进了客户端解析器或内存中的对象布局,改形状意味着所有节点、所有在线玩家的状态都要跟着迁移——这只能走版本发布与停机/滚动重启,不是配置中心的活。
下面这张判定表就是本节要固化的东西,用键前缀判定,CanHotReload 直接查它。这段是 L1,go test 可以直接跑:
// file: internal/config/reload.go
// tier: L1
// verify: go test ./internal/config/ -run TestReload -v
package config
import (
"strings"
"sync/atomic"
)
// ItemID 是配置表里的物品 ID(与全书口径一致,uint32)。
type ItemID uint32
// DropEntry 是掉落表的一行:物品、权重与数量。
type DropEntry struct {
ItemID ItemID
Weight int
Count int
}
// HotConfig 是一次热更后对读侧可见的整份配置。它必须是一份「不可变快照」:
// 一旦发布,读侧拿到的这份数据就不再被原地修改,改的是换一份新的。
type HotConfig struct {
Version uint64
Numbers map[string]float64 // 数值:伤害系数、暴击率、经验倍率
Switches map[string]bool // 开关:功能开关、活动开关
Drops map[string][]DropEntry // 掉落表:boss.1 → 掉落项列表
}
// Hot 用双缓冲 + 原子指针交换实现无锁读。读侧永远拿到某个完整快照,
// 写侧构造好新快照后一次 atomic 交换,读侧不会看到半更新的状态。
type Hot struct {
cur atomic.Pointer[HotConfig]
}
// NewHot 用一份初始配置构造双缓冲容器。
func NewHot(initial *HotConfig) *Hot {
h := &Hot{}
h.cur.Store(deepCopy(initial))
return h
}
// Read 返回当前快照。返回的是指针,调用方在本次操作期间应一直用它;
// 中途再次 Load 可能拿到更新的快照——这正是「旧读引用不影响新读」的语义。
func (h *Hot) Read() *HotConfig { return h.cur.Load() }
// Swap 用新值替换整份快照,一次原子交换:读侧要么看到全部旧值、要么看到全部新值。
// 返回被换下的旧快照,便于日志与审计。
func (h *Hot) Swap(next *HotConfig) *HotConfig {
return h.cur.Swap(deepCopy(next))
}
// deepCopy 复制一份快照,切断调用方与原缓冲之间的引用,
// 避免写侧拿到 Read() 的返回值后原地改到读侧正在用的数据。
func deepCopy(in *HotConfig) *HotConfig {
if in == nil {
return &HotConfig{
Numbers: map[string]float64{},
Switches: map[string]bool{},
Drops: map[string][]DropEntry{},
}
}
out := &HotConfig{
Version: in.Version,
Numbers: make(map[string]float64, len(in.Numbers)),
Switches: make(map[string]bool, len(in.Switches)),
Drops: make(map[string][]DropEntry, len(in.Drops)),
}
for k, v := range in.Numbers {
out.Numbers[k] = v
}
for k, v := range in.Switches {
out.Switches[k] = v
}
for k, v := range in.Drops {
out.Drops[k] = append([]DropEntry(nil), v...)
}
return out
}
// Class 是配置项的热更边界。
type Class uint8
const (
ClassHot Class = iota + 1 // 可热更:纯数值、开关、掉落表,改完即生效
ClassWarm // 可热更但要双缓冲:读侧有整表引用,须整份切换
ClassCold // 不可热更:协议结构、数据模型、需重建状态的部分
)
// String 便于日志与断言输出。
func (c Class) String() string {
switch c {
case ClassHot:
return "hot"
case ClassWarm:
return "warm"
case ClassCold:
return "cold"
}
return "unknown"
}
// Classify 按键前缀判定热更边界。这张表就是「哪些能热更、哪些不能」的答案:
// 数值 / 开关 / 掉落表能;协议结构、数据模型、需要重建运行态的一律不能。
func Classify(key string) Class {
switch {
case strings.HasPrefix(key, "proto."),
strings.HasPrefix(key, "schema."),
strings.HasPrefix(key, "model."):
return ClassCold
case strings.HasPrefix(key, "table.drop."),
strings.HasPrefix(key, "table.level."):
return ClassWarm
default:
return ClassHot
}
}
// CanHotReload 报告一个键能否不重启生效。协议与数据模型必须走版本发布。
func CanHotReload(key string) bool { return Classify(key) != ClassCold }
5.2.2 双缓冲与原子切换
能热更的配置,切换动作本身也必须是原子的。做法是双缓冲 + 原子指针交换:读侧通过一个 atomic.Pointer 拿到当前快照,写侧构造好一份全新的 HotConfig,然后一次 Swap 把指针换过去。
这里有两个容易踩的点。第一,Swap 与 NewHot 都做了 deepCopy:如果不复制,写侧拿到 Read() 的返回值后原地改一个字段,就会改到读侧正在用的数据——双缓冲就白做了。第二,读侧拿到指针后必须在整段操作期间一直用它,而不是每个字段都 Read() 一次;后者在两次读之间可能换了快照,反而读到不一致的组合。
5.2.3 旧读引用不影响新读
原子切换到底有没有效,靠断言说话。下面这段是 L1,go test 可以直接跑,它同时验证两件事:Swap 之后新读拿到新值、而切换前拿到的旧引用纹丝不动;以及四个并发读者在两千次切换中,永远不会看到「半新半旧」的撕裂状态:
// file: internal/config/reload_test.go
// tier: L1
// verify: go test ./internal/config/ -run TestReload -v
package config
import (
"sync"
"testing"
)
func TestReloadAtomicSwapAndBoundaries(t *testing.T) {
old := &HotConfig{
Version: 1,
Numbers: map[string]float64{"battle.damage_scale": 1.0},
Switches: map[string]bool{"feature.arena": true},
Drops: map[string][]DropEntry{"boss.1": {{ItemID: 1001, Weight: 60, Count: 1}}},
}
h := NewHot(old)
// 读侧先拿到旧引用,模拟一次正在进行的战斗读到的旧表。
reading := h.Read()
if reading.Numbers["battle.damage_scale"] != 1.0 {
t.Fatalf("initial read = %v", reading.Numbers["battle.damage_scale"])
}
// 写侧热更:整份新快照一次原子交换。
h.Swap(&HotConfig{
Version: 2,
Numbers: map[string]float64{"battle.damage_scale": 1.2},
Switches: map[string]bool{"feature.arena": false},
Drops: map[string][]DropEntry{"boss.1": {{ItemID: 1001, Weight: 40, Count: 1}}},
})
// 新读拿到新值。
if got := h.Read().Numbers["battle.damage_scale"]; got != 1.2 {
t.Fatalf("after swap read = %v, want 1.2", got)
}
// 旧读引用不受影响——它指向的是交换前的不可变快照。
if reading.Numbers["battle.damage_scale"] != 1.0 {
t.Fatalf("old read mutated to %v", reading.Numbers["battle.damage_scale"])
}
if reading.Switches["feature.arena"] != true {
t.Fatalf("old read switch mutated")
}
if h.Read().Version != 2 {
t.Fatalf("version = %d, want 2", h.Read().Version)
}
// 边界判定:数值 / 开关可热更,掉落表要双缓冲,协议与数据模型不可热更。
cases := map[string]Class{
"battle.damage_scale": ClassHot,
"feature.arena": ClassHot,
"table.drop.boss1": ClassWarm,
"proto.login_resp": ClassCold,
"schema.player": ClassCold,
}
for key, want := range cases {
if got := Classify(key); got != want {
t.Errorf("Classify(%q) = %v, want %v", key, got, want)
}
}
if CanHotReload("proto.login_resp") {
t.Errorf("proto must not be hot-reloadable")
}
if !CanHotReload("table.drop.boss1") {
t.Errorf("drop table should be hot-reloadable via double buffer")
}
}
func TestReloadConcurrentReadersSeeWholeSnapshot(t *testing.T) {
h := NewHot(&HotConfig{Version: 1, Numbers: map[string]float64{"a": 1, "b": 1}})
var wg sync.WaitGroup
stop := make(chan struct{})
// 读者:始终看到 a 与 b 相等——两个键在同一份快照里一起换,绝不出现半更新。
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
s := h.Read()
if s.Numbers["a"] != s.Numbers["b"] {
t.Errorf("torn read: a=%v b=%v", s.Numbers["a"], s.Numbers["b"])
return
}
}
}()
}
// 写者:交替整份换 a=b=1 与 a=b=2。
for i := 0; i < 2000; i++ {
v := float64(1)
if i%2 == 0 {
v = 2
}
h.Swap(&HotConfig{Version: uint64(i + 2), Numbers: map[string]float64{"a": v, "b": v}})
}
close(stop)
wg.Wait()
}
真跑的结果:
=== RUN TestReloadAtomicSwapAndBoundaries
--- PASS: TestReloadAtomicSwapAndBoundaries (0.00s)
=== RUN TestReloadConcurrentReadersSeeWholeSnapshot
--- PASS: TestReloadConcurrentReadersSeeWholeSnapshot (0.00s)
PASS
ok aegis/internal/config 0.727s
第一个用例的断言顺序是关键:先 reading := h.Read() 拿到旧引用,再 Swap,然后回头检查 reading 里的值没变。这就是「旧读引用不影响新读」的字面证明。第二个用例用四个并发读者配两千次切换,把「撕裂读」的可能性压到几乎必然触发——只要 Swap 不是整份切换,a 与 b 就一定会读到不同的值而报错。
本节小结
- 边界由「读侧怎么用」决定。 现读现用的数值/开关是 hot,整表遍历的掉落表是 warm,形状被写进客户端或内存布局的协议/模型是 cold——只有 cold 必须重启。
- 热更要整份切换,不能原地改。 双缓冲加
atomic.Pointer交换,让读侧要么看到全旧、要么看到全新,绝不半新半旧。 deepCopy不是可选项。 写侧若不复制就改到读侧的引用,双缓冲就失效;这是最容易漏的一环。- 原子性要用并发断言证明。 先取旧引用、切换、再验旧引用不变,加多读者多写者压测,才能把「无撕裂」从口号变成证据。
一句话收束:热更的边界不是技术限制,而是「谁还握着旧数据的引用」——搞清楚了这件事,哪些能热更、哪些必须重启,就是一张可以查表判定的答案。 下一节给热更加上护栏:改之前先看影响面,灰度期盯着指标,一旦不对就一键回滚。
阅读导航:上一节:5.1 配置版本化与差量分发 · 下一节:5.3 线上风控与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。