《MMO 服务器开发》5.2 热重载的边界

热更不是「什么都能改」:数值、开关、掉落表可以热更,协议结构、数据模型与需要重建运行态的部分必须重启。本节给出 hot/warm/cold 三类边界判定,用双缓冲加原子指针交换实现无锁热更,并用「旧读引用不影响新读」的并发断言证明切换是原子的。

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 就一定会读到不同的值而报错。

本节小结

  1. 边界由「读侧怎么用」决定。 现读现用的数值/开关是 hot,整表遍历的掉落表是 warm,形状被写进客户端或内存布局的协议/模型是 cold——只有 cold 必须重启。
  2. 热更要整份切换,不能原地改。 双缓冲加 atomic.Pointer 交换,让读侧要么看到全旧、要么看到全新,绝不半新半旧。
  3. deepCopy 不是可选项。 写侧若不复制就改到读侧的引用,双缓冲就失效;这是最容易漏的一环。
  4. 原子性要用并发断言证明。 先取旧引用、切换、再验旧引用不变,加多读者多写者压测,才能把「无撕裂」从口号变成证据。

一句话收束:热更的边界不是技术限制,而是「谁还握着旧数据的引用」——搞清楚了这件事,哪些能热更、哪些必须重启,就是一张可以查表判定的答案。 下一节给热更加上护栏:改之前先看影响面,灰度期盯着指标,一旦不对就一键回滚。

阅读导航:上一节:5.1 配置版本化与差量分发 · 下一节:5.3 线上风控与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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