DYNAMIC EXPLAINER / ARTICLE FLOW

把章节变成可单步观察的过程

01 / 18
STEP 01 / CODE / LOGIC

先说结论

热更新应先完整下载和验证一套不可变配置快照,再用一次原子切换让新请求看到新版本。不能逐张表边下载边替换,否则同一次业务可能混用两个版本。

当前活动快照:v12(角色表、技能表都为 v12)
后台下载:v13
角色表 v13 成功,技能表 v13 校验失败
结果:继续整体使用 v12,并报告 v13 更新失败

分类:工程设计 / 配置系统

先说结论

热更新应先完整下载和验证一套不可变配置快照,再用一次原子切换让新请求看到新版本。不能逐张表边下载边替换,否则同一次业务可能混用两个版本。

版本 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 或默认成功覆盖
旧版本仍存在时,继续运行与否仍由明确策略决定