把章节变成可单步观察的过程
分类:工程设计 / 配置系统
先说结论
热更新应先完整下载和验证一套不可变配置快照,再用一次原子切换让新请求看到新版本。不能逐张表边下载边替换,否则同一次业务可能混用两个版本。
版本 12 到 13 的安全切换
当前活动快照:v12(角色表、技能表都为 v12)
后台下载:v13
角色表 v13 成功,技能表 v13 校验失败
结果:继续整体使用 v12,并报告 v13 更新失败
只有全部 v13 文件、引用和语义校验通过后,活动指针才从 v12 一次切到 v13。失败可见不等于让游戏立刻不可用;旧快照能否继续服务取决于兼容和过期策略。
这篇文章深化什么
配置生成管线关注:
源数据怎样经过校验、生成和打包,变成运行时可读取的数据。
热更新继续追问:
运行中的程序怎样发现新配置?
多个配置文件怎样保证来自同一个版本?
下载、解析或切换失败时,怎样让失败保持可见?
读取方怎样保证一次逻辑只看到一致快照?
这篇文章是在配置管线基础上,深入:
版本清单
不可变配置快照
准备与提交
原子切换
并发读取一致性
失败状态与诊断证据
它不讨论某个具体表格工具,也不假设热更新一定允许程序继续运行。
生活化模型:用换菜单理解版本一致性
想象一家餐厅要更换整套菜单。
新菜单包含:
菜品表
价格表
套餐关系表
过敏原说明表
如果服务员先换了菜品表,但收银台仍使用旧价格表:
菜单上出现新菜
价格系统查不到或读到旧价格
套餐引用到不存在的菜品
正确换菜单不是“哪个文件先下载完就先用哪个”,而是:
把整套新菜单放到准备区。
确认文件完整、版本一致、引用合法。
所有检查都通过后,一次性宣布新菜单生效。
如果检查失败:
新菜单不能标记为已发布。
失败原因必须让维护者和调用方看到。
旧菜单是否还能继续服务,是业务运行策略,不应由热更新模块用“自动回退”偷偷决定。
版本清单是配置集合的身份证
热更新包不应只是一组散落文件。它需要一个可验证清单:
ConfigSetVersion
SchemaVersion
BuildId
Files
Dependencies
ContentHash
示意:
ConfigSetVersion: 2026.07.28.1
SchemaVersion: 14
Files:
- ItemConfig.bin
- PriceConfig.bin
- BundleConfig.bin
Dependencies:
BundleConfig -> ItemConfig
这些值只是格式示意,不是推荐的生产版本号。
清单至少要回答:
这一组文件共同属于哪个配置版本?
每个文件是否完整且未被替换?
读取代码是否理解这个结构版本?
跨表引用需要哪些成员同时存在?
进阶:三种版本不要混为一谈
内容版本
表示配置数据内容发生了变化:
商品数值变化
文本变化
规则开关变化
结构版本
表示读取协议或字段结构变化:
字段新增或删除
类型变化
序列化布局变化
枚举语义变化
构建身份
表示这套产物由哪次确定生成产生:
源数据提交
生成器版本
构建任务
产物哈希集合
是否把它们编码成一个字符串,是实现选择;但结构兼容与内容新旧不能只靠字符串大小比较替代。
不可变配置快照
运行时读取配置时,不应让一个逻辑过程看到“切到一半”的集合。
把当前生效配置表示为不可变快照:
ConfigSnapshot
Version
ItemTable
PriceTable
BundleTable
Metadata
读取方先获取一个快照引用:
snapshot = configProvider.AcquireSnapshot()
随后同一逻辑使用这个引用完成所有查询:
item = snapshot.ItemTable[id]
price = snapshot.PriceTable[item.PriceId]
bundle = snapshot.BundleTable.FindByItem(id)
即使全局当前快照在中途切换,这次逻辑仍读同一版本。
这叫快照一致性:
一次读取事务只观察一个完整版本。
快照是不可变的,切换时替换引用,而不是原地逐表修改。
热更新状态机
一个明确的更新状态机可以包含:
Idle
CheckingManifest
Downloading
VerifyingArtifacts
Parsing
Validating
ReadyToCommit
Committing
Active
Failed
每次更新尝试还应有独立身份:
UpdateAttemptId
TargetVersion
StartedAt
CurrentStage
Failure
失败不能直接跳回 Idle,否则观察者会看不出刚刚发生过更新失败。
是否允许新的更新尝试覆盖旧失败记录,是保留策略选择;至少应有历史或外部日志保存证据。
从发现版本到生效的逐步流程
第一步:读取并验证清单
检查:
清单格式可解析
签名或可信来源符合系统协议
目标内容版本明确
结构版本被当前读取器支持
文件集合完整
如果失败:
状态 -> Failed
Stage -> CheckingManifest
保留原始响应与解析错误摘要
不创建可提交快照
第二步:下载到隔离准备区
新文件写入准备区,不覆盖当前活动快照的文件。
准备区按更新尝试隔离:
Attempt A 的部分文件
不能与 Attempt B 的文件混成一套配置。
下载成功不等于配置可用。
第三步:校验产物完整性
对照清单验证:
文件存在
长度满足产物描述
内容哈希一致
成员版本一致
只要一个成员不一致:
整套目标版本不能进入 ReadyToCommit。
第四步:解析为候选快照
解析发生在隔离对象中:
CandidateSnapshot
不应边解析边修改活动配置。
解析错误应保留:
文件
表
字段或记录位置
结构版本
原始错误
第五步:语义校验
二进制或 JSON 能解析,不代表业务关系合法。
校验例子:
主键唯一
必填引用存在
枚举值在支持范围内
跨表版本相同
有向关系满足约束
校验规则的具体内容必须来自配置协议和领域不变量,不能临时猜测。
第六步:准备提交
到 ReadyToCommit 时,应具备:
完整候选快照
目标版本
验证报告
变更摘要
提交前置条件
提交前置条件可能包括:
当前活动版本仍是开始准备时读取的版本
没有另一个提交正在进行
所有必要消费者支持目标结构版本
第七步:原子切换
提交操作只做:
将 CurrentSnapshot 引用从旧快照替换为候选快照。
切换成功后:
新读取者获得新快照。
已经持有旧快照的读取者继续完成旧版本读取。
这避免同一次逻辑跨版本读取。
最小 C# 快照模型
public sealed class ConfigSnapshot
{
public string Version { get; }
public ItemTable Items { get; }
public PriceTable Prices { get; }
public ConfigSnapshot(
string version,
ItemTable items,
PriceTable prices)
{
Version = version;
Items = items;
Prices = prices;
}
}
public sealed class ConfigProvider
{
private ConfigSnapshot current;
public ConfigProvider(ConfigSnapshot initial)
{
current = initial;
}
public ConfigSnapshot AcquireSnapshot()
{
// 返回不可变快照引用;一次逻辑应持续使用同一个引用。
return Volatile.Read(ref current);
}
public bool TryCommit(
ConfigSnapshot expectedCurrent,
ConfigSnapshot candidate,
out string? error)
{
ConfigSnapshot observed = Interlocked.CompareExchange(
ref current,
candidate,
expectedCurrent);
if (!ReferenceEquals(observed, expectedCurrent))
{
error =
$"配置提交冲突:期望活动版本 {expectedCurrent.Version}," +
$"实际活动版本 {observed.Version}。";
return false;
}
error = null;
return true;
}
}
这段示例确定表达:
快照不可原地修改
读取方持有稳定引用
提交使用比较并交换检测并发版本变化
冲突通过错误返回给调用方
它没有定义冲突后应该重试、放弃还是重新准备。那是上层更新协议,不能在 TryCommit 内静默替换。
更新尝试的失败结果
失败结果不应只是一个布尔值:
public enum ConfigUpdateStage
{
Manifest,
Download,
ArtifactVerification,
Parsing,
SemanticValidation,
Commit
}
public sealed record ConfigUpdateFailure(
string AttemptId,
string TargetVersion,
ConfigUpdateStage Stage,
string ErrorCode,
string Message,
Exception? Cause);
一个失败记录至少要能回答:
哪次尝试失败
目标版本是什么
失败发生在哪个阶段
稳定错误码是什么
原始错误链是什么
当前活动版本是什么
候选快照是否已经被丢弃
正常路径
发现目标版本
下载完整文件集
哈希验证通过
解析成功
语义验证通过
候选快照 ReadyToCommit
原子切换成功
发布 VersionActivated 事件
状态 -> Active
VersionActivated 必须在提交成功后发布。
订阅者收到事件时应能获得:
旧版本
新版本
提交身份
变更摘要或查询入口
边界路径
内容相同但清单重新发布
如果目标内容哈希与当前活动集合完全一致:
是否跳过提交
是否记录为已检查但无变化
属于版本协议选择。无论如何,都不应虚构一次“新版本已激活”。
更新准备期间又出现更新版本
Attempt A 正在下载时发现目标 B。
合法策略可能是:
让 A 完成,再独立处理 B
取消 A 并清晰记录取消原因
并行准备,但提交时比较活动版本
具体策略需要显式确定。不同尝试的文件、状态和错误不得混合。
旧读取者长期持有旧快照
不可变快照允许旧读取者安全完成,但也会延迟旧快照回收。
应观察:
各版本活跃引用
最长持有时间
持有者类别
旧快照内存
不能为了回收内存去原地清空旧快照,因为读取者仍可能使用它。
结构版本变化
目标配置需要新字段,而当前读取代码不支持:
候选版本必须在兼容性检查阶段失败。
返回空字段、默认枚举或忽略未知结构都属于兼容与降级策略,不能未经协议允许自动采用。
失败路径
清单与文件内容不一致
Stage -> ArtifactVerification
状态 -> Failed
报告目标文件、期望哈希、实际哈希
不进入解析与提交
跨表引用断裂
套餐引用不存在的商品
应报告:
来源表和记录
引用字段
目标表与目标键
候选版本
不能删除该套餐、填入默认商品或忽略引用后继续提交。
提交竞争
两个候选快照都基于版本 A 准备:
Attempt B 先把 A 切到 B
Attempt C 再尝试以 A 为前置提交 C
第二次比较并交换失败:
期望版本 A
实际版本 B
候选版本 C
系统应把冲突暴露给协调层,不应绕过前置条件强行覆盖 B。
激活通知失败
快照已经原子提交,但某个订阅者处理激活事件失败:
提交事实不能伪装成未发生。
订阅失败也不能被吞掉。
必须分别记录:
配置版本已经激活
哪个消费者通知失败
原始错误
消费者当前确认版本
如何恢复消费者一致性需要明确协议,不能由事件分发器返回默认成功。
当前旧版本还在,不等于自动回退
如果候选版本在提交前失败,活动引用仍指向旧快照。这是因为:
新版本从未提交。
它不是一次自动回退。
此时系统真实状态应写成:
ActiveVersion = old
LatestUpdateAttempt = Failed
TargetVersion = new
Failure = ...
调用方必须明确决定:
是否允许继续使用当前活动版本
是否阻止某项功能
是否终止启动或进入维护状态
热更新模块不能看到旧快照仍可读,就自行把失败转换成“继续成功运行”。
代价
版本一致性会增加:
候选快照与活动快照并存的内存
下载准备区空间
全量解析与语义校验时间
清单和哈希计算
旧快照引用追踪
失败诊断数据
原子切换本身可以很小,但“准备一个确定正确的候选快照”通常才是主要成本。
全量或增量更新是实现选择。增量更新如果最终仍要保证集合一致性,就必须能证明补丁应用后的完整版本身份。
可观测性
每次更新尝试应记录:
AttemptId
Source
CurrentVersion
TargetVersion
SchemaVersion
Stage
StageDuration
DownloadedBytes
VerifiedFileCount
ValidationErrorCount
CommitResult
FailureCode
OriginalError
运行时还应能查询:
当前活动版本
最近一次成功提交
最近一次失败尝试
当前是否正在准备候选版本
各版本活跃读取引用
各消费者确认版本
通用机制与实现选择
可验证的通用机制
一组互相引用的配置必须以一致集合验证。
候选数据应与活动数据隔离。
不可变快照可以让一次读取固定在同一版本。
准备完成后替换快照引用可避免逐表切换的混合状态。
提交竞争必须验证预期活动版本。
失败后的目标版本不能标记为 Active。
必须由具体系统确定
版本编码规则
清单签名与可信来源
全量包还是增量包
结构兼容协议
更新失败后的运行决策
更新并发策略
旧快照回收条件
消费者激活确认协议
错误的阻断等级
最后总结
配置管线保证:
源数据能生成符合规则的产物。
热更新一致性保证:
运行时只激活一套完整、验证通过的配置快照。
可靠的切换过程应满足:
清单能标识完整集合
结构版本和内容版本分开验证
候选快照不污染活动快照
一次逻辑只读取一个快照
提交检查并发前置版本
失败状态不会被 Idle 或默认成功覆盖
旧版本仍存在时,继续运行与否仍由明确策略决定