活动配置同步不是复制文件:LiveOps 需要一张幂等状态表

周末活动开始前十分钟,运营把奖励倍率从 1.5 改成 1.8,又补了一段公告。同步任务扫描配置目录,将文件转换成服务端对象。第一次执行成功,调度器却因响应超时再次触发;与此同时,后台有人修改了同名活动的访问路径。若系统只会“看到文件就创建”,第二次执行可能生成重复活动;若它只按路径更新,又可能把另一个对象覆盖掉;若同步器顺手把草稿发布,配置校验与上线审批便被一个文件扫描动作绕过。

这不是文件复制问题,而是两个状态域之间的对账问题。外部配置源拥有文件身份和内容,LiveOps 内容服务拥有可发布的业务对象,协调层则必须长期记住“这个外部对象对应哪个内部对象、上次看到什么内容、上次是否失败”。只有这三类状态分开,重试、改名、删除和冲突才有确定语义。

本文依据一个真实在线内容同步子系统讨论这条边界。实现会扫描 Markdown 配置,以相对路径作为外部身份;单文件限制为 5 MiB;关键字段与正文共同计算 SHA-256 指纹;SQLite 表用 (source, external_id) 唯一约束保存目标对象 ID、内容指纹、同步时间和最后错误。当天重新运行的集成测试验证了:首次扫描两个文件产生两次新增,第二次原样扫描产生两次跳过,修改一个文件后产生一次更新和一次跳过;状态表测试验证了 upsert、错误筛选、反向查询与删除。把这套机制用于游戏 LiveOps 时,配置载体和解析规则可以替换,但所有权与失败语义不能含糊。

配置身份经过协调层进入游戏服务节点的主视觉

三种身份

同步系统至少面对三种容易被混为一谈的身份。第一种是外部身份,例如 season/summer.md、配置平台的记录 ID 或 Git 仓库中的稳定键。它回答“下一次扫描时怎样认出还是同一个输入”。第二种是内部对象 ID,它由内容服务生成,回答“更新、审核和审计究竟作用在哪个业务对象上”。第三种是访问名,例如活动 slug。它服务于路由和可读性,可以修改,也可能与其他对象冲突。

若把 slug 当成唯一身份,运营改名就会被误判为删除旧活动并创建新活动;旧活动的审批记录、灰度范围与审计链随之断裂。若把文件内容当身份,只改一个标点也会产生新对象。若只保存内部 ID,又无法判断下一次来自同一外部源的输入应该关联谁。协调层因此要保存一条稳定映射:(source, external_id) -> post_id。当前数据库迁移直接对前两个字段建立联合唯一约束,目标 ID 则使用外键;目标被物理删除时,外键会置空而不是连带删除同步记录。

这个设计映射到 LiveOps 后,source 可以区分配置仓库、运营平台和紧急控制台,external_id 是各自命名空间内的稳定键,post_id 则换成活动版本、规则集或公告对象 ID。联合唯一键限制的是“一个源对象只能有一条协调记录”,不是“所有源只能共享一个名字”。两个平台都存在 summer-event 并不冲突;同一平台重复提交它才需要幂等收敛。

状态所有权由此明确:外部源拥有原始输入,不能直接声称内部对象已经发布;内容服务拥有内部对象和生命周期;协调表只拥有身份映射、上次指纹、同步时间与错误,不拥有奖励余额、玩家参与进度或战斗规则执行状态。让同步表兼任业务权威表,看似少一次查询,实际会把导入协议、上线审批和运行时状态绑在一起。

指纹门禁

识别出同一个输入后,系统还要判断它有没有实质变化。当前实现把标题、正文、标签、摘要、状态、置顶标记和 slug 按固定顺序写入 SHA-256,并在字段之间加入零字节分隔。分隔符很重要:没有它,ab + ca + bc 会产生相同拼接串。指纹相同且映射仍指向有效目标时,系统不调用内容更新,只刷新 last_sync_at 并返回 skip

这条不变量可以写成:对给定的 (source, external_id),若规范化输入指纹 H_t 等于已确认指纹 H_s,且目标对象存在,则本轮业务写入次数为零。它不承诺扫描次数为零,也不承诺数据库完全无写入,因为同步时间仍会更新;它承诺昂贵且可能产生版本、审计或发布副作用的内容写入不会重复发生。

游戏配置不能简单对原始文件字节求哈希。换行符、字段顺序或无意义空白可能让语义相同的输入得到不同指纹;反过来,若规范化遗漏灰度范围、规则版本或生效窗口,语义变化又会被错误跳过。正确做法是先形成一个版本化的规范对象,再对所有会改变业务含义的字段编码。规范化规则本身也属于协议:升级规则时,应记录 hash_schema 或让新旧指纹明确不相等,触发一次受控重写,而不是静默沿用不可比较的摘要。

指纹也不是安全签名。它用于变更检测,不证明配置来自可信操作者。来源认证、提交权限、审批签名和防回滚版本号属于另一条安全边界。把一个 SHA-256 字符串同时称为幂等键、签名和版本号,会让三个不同问题失去各自的验证方式。

正常时序

一次正常同步先验证源目录可访问,再遍历候选文件。读取采用 LimitReader,上限是 5 << 20 字节,并额外读取一个字节来判断超限。设扫描到 N 个文件,第 i 个文件大小为 s_i,单轮输入内存的理论读取量受 min(s_i, 5 MiB + 1) 限制;当前实现逐文件处理,因此不是把 N × 5 MiB 同时驻留内存。5 MiB 是防御性代码上限,不是平均配置大小,也不是吞吐测试结论。

文件解析后被归一化为同步包内部结构,源特有字段不会继续渗透到内容服务。协调层查询映射并计算指纹:无映射则创建;有映射且指纹相同则跳过;有映射且指纹变化则按内部 ID 反查当前访问名,然后更新目标。写入成功后才 upsert 新的指纹和目标 ID。对 LiveOps 而言,这相当于“先确认业务对象提交,再推进同步检查点”。如果先记录新指纹、后写业务对象,后半段失败会让下一轮误以为配置已经应用。

LiveOps 配置同步的所有权、正常时序与失败恢复

当天的 SQLite 集成测试把这条时序执行了三轮。第一轮两个文件得到 Added=2;第二轮输入未变,得到 Skipped=2,没有新增和更新;第三轮只修改一个文件,结果为 Updated=1, Skipped=1,随后按 slug 读取目标,标题和发布状态符合输入。证据证明当前单进程顺序扫描下的幂等行为成立,但没有证明多个同步任务并发执行时不会竞争,也没有证明远程配置源的读取一致性。

失败时序

失败恢复首先要区分“映射存在”和“目标存在”。当前服务若通过 post_id 反查目标得到不存在,会把映射退回创建路径。因为数据库外键在目标删除时把 post_id 置空,下一轮同样会创建新目标。这条语义适合“外部源仍存在就应该恢复内部投影”的内容同步;对游戏活动却未必总是正确。若内部删除代表安全团队强制下架,自动重建会破坏处置决定。LiveOps 需要给删除原因建模:可恢复的投影丢失允许重建,封禁或撤销则必须形成 tombstone,阻止旧源再次复活。

创建时还可能发生访问名冲突。当前实现只重试一次:把 slug 改成带来源特征的备用形式,再创建。它保护的是批处理可继续性,不保证备用名永远唯一。用于活动配置时,更稳妥的做法是让内部 ID 与外部身份决定对象归属,slug 冲突只影响展示名;若展示名也是客户端协议的一部分,则冲突应进入人工仲裁,不能悄悄改名后发布。

单项解析或写入失败时,扫描器增加 Failed、收集带外部身份的错误,并继续处理后续文件。创建失败还会把内容指纹、同步时间和 last_error 写入协调表,但不会伪造目标 ID。下一轮因为没有有效目标,仍会再次尝试创建。这给出了可观察、可重试的失败状态。不过更新目标成功、随后写协调表失败时,业务对象已经变化而检查点仍旧;下一轮会再执行一次更新。也就是说,当前跨内容写入与状态 upsert 没有单一事务,提供的是至少一次更新尝试,不是严格恰好一次。

这个窗口不能靠“先写协调表”修复,那会把故障方向变成漏更新。若内容服务的重复更新成本可控,至少一次配合内容层幂等通常更安全;若每次更新都会触发推送、版本发布或大规模节点重载,就需要稳定的变更 ID,由内容服务按该 ID 去重,或把业务变更与 outbox/checkpoint 放进同一个权威事务。协调表只能记录过程,不能单独制造原子性。

发布边界

最危险的捷径是让同步等同于发布。当前通用内容实现会把输入状态传给内容服务,状态为 published 时触发发布。对个人内容工作流这可能是合理契约;对游戏 LiveOps,奖励倍率、掉落表和匹配参数不应因为源文件写了一个状态词就立即影响在线玩家。同步成功只能说明“候选版本已进入内部系统”,不能说明它通过了模式校验、跨字段约束、审批、灰度与生效窗口检查。

更可靠的边界是两阶段:同步器创建不可变候选版本,并更新外部身份到候选版本的映射;发布控制器在独立命令下校验并推进 draft -> validated -> scheduled -> active。运行时节点只消费 active_version,而不是扫描器最后看到的文件。发布命令携带期望前版本,例如只有当前活动仍为版本 41 时才能切到 42;否则拒绝并要求重新评审。这相当于业务层的 compare-and-swap,防止旧审批覆盖后来发生的紧急修订。

还要规定玩家会话跨版本时谁负责一致性。公告文案可以下一次读取即生效;战斗掉落表通常要在房间创建时固定版本;赛季计分规则可能在报名或赛季开始时冻结。同步器不拥有这些决策,它只交付候选配置。各运行时边界必须把版本写入房间、比赛或结算事实,失败重试时继续使用同一版本。否则“配置已经幂等同步”仍可能产生同一局前后规则不同的业务错误。

容量约束

当前实现是顺序全量扫描。若共有 N 个文件,哈希和解析成本近似为 O(Σs_i),状态查询与可能的写入各为每项常数次数据库操作。没有压测数据时,不能宣称它能支撑多少活动或多高频率,但可以给出可复算预算:若配置总字节数为 B,单轮扫描频率为 f,仅内容读取吞吐下限就是 B × f;若所有项未变,仍有 N × f 次状态查询和同步时间更新。十万项每分钟全扫一次,即使内容很小,也会形成每分钟十万次协调写,跳过并不等于免费。

第一个演进触发条件是扫描周期接近调度间隔,或数据库中 last_sync_at 更新成为主要写负载。此时应使用源端变更游标、文件系统事件或清单快照,把候选集合从 N 降为变化量 ΔN,同时保留低频全量对账修复漏事件。第二个触发条件是单项更新会触发昂贵下游工作,此时引入稳定变更 ID 与 outbox,让同步任务只提交变更事实,下游按 ID 去重消费。

第三个触发条件是多实例并发。数据库唯一键只能阻止两条协调记录,不能阻止两个执行者同时看到旧指纹并各自调用业务更新。可以按 source 租约串行化,或在协调记录上增加版本号,用条件更新争夺处理权。不要用全局锁覆盖所有来源:一个远程源阻塞不应让紧急控制台也停止。分区键至少应落到来源;规模更大时可按稳定 external ID 分片,但同一身份必须保持顺序。

架构判断

LiveOps 配置同步的核心不是把文件搬进数据库,而是维护一条可审计的身份关系:外部源用稳定 ID 声明“我是谁”,内容指纹说明“我是否改变”,内部 ID 决定“我要更新谁”,发布状态回答“何时影响在线游戏”。四者合并成一个路径或一个布尔值,重试与故障就会变成重复创建、错误覆盖或越权上线。

当前实现展示了一个可靠起点:联合唯一键锁住外部身份,内容指纹消除无意义更新,目标丢失与 slug 冲突有明确分支,逐项错误不会拖垮整批,真实 SQLite 测试锁定了新增、跳过、更新和状态表行为。它的边界同样明确:业务写入与检查点不是单事务,并发执行尚未被测试,物理删除后的自动重建也不适合所有游戏配置。

因此架构评审时应先问四个问题:源对象的稳定身份是什么,谁拥有候选版本,什么动作才有权发布,失败后下一次执行会重做还是漏做。只要这些答案仍依赖“文件名应该不会变”或“任务大概只跑一次”,系统就还没有幂等同步协议,只有一段碰巧成功的导入脚本。