并行变更:让新旧版本有一段共同工作的时间
本文是 Agent 工程里的经典架构 系列的一篇。文中的业务场景和代码为讲解设计,产品能力以所链接的官方资料为准。
一个配置字段以前用秒,现在希望支持毫秒。如果把字段名直接改掉,新代码可以正常工作,旧 Worker 却可能还在处理任务;如果只改数值单位而不改字段,问题更隐蔽,旧代码可能把五百毫秒当成五百秒。部署期间的混合版本,往往比单一版本本身更难处理。
Parallel Change把不兼容变更拆成扩展、迁移、收缩三个阶段。它利用一段新旧形式共存的时间,降低一次性替换的风险。这里的并行指契约共存与渐进迁移,不代表必须同时运行两个完全独立的业务系统。
以 Agent 的工具超时配置为例,旧字段是 timeout_seconds,新字段是 timeout_ms。第一阶段先让所有读取者理解两种格式,新写入保持旧读取者能够理解。等旧读取者退出后,才允许新格式表达更细的精度。字段迁移和功能放开不一定发生在同一时刻。
下面的代码仅演示扩展阶段。新读取者优先读取毫秒,缺失时回退到秒;写入同时更新两个字段,并暂时拒绝旧格式无法精确表达的值。迁移只补缺失的新字段,避免把已经存在的新值覆盖掉。内存字典模拟单记录原子修改,不提供多进程一致性。
def read_ms(row):
if "timeout_ms" in row:
return row["timeout_ms"]
return row["timeout_seconds"] * 1000
def write_compatible(row, milliseconds):
if type(milliseconds) is not int or milliseconds <= 0:
raise ValueError("超时必须是正整数")
if milliseconds % 1000:
raise ValueError("旧读取者退出前不开放毫秒精度")
row.update(timeout_ms=milliseconds,
timeout_seconds=milliseconds // 1000)
def backfill(row):
if "timeout_ms" not in row:
row["timeout_ms"] = row["timeout_seconds"] * 1000
row = {"timeout_seconds": 2}
assert read_ms(row) == 2000
backfill(row)
backfill(row) # 回填可重复运行
write_compatible(row, 3000)
assert read_ms(row) == row["timeout_seconds"] * 1000 == 3000
try:
write_compatible(row, 1500)
except ValueError:
pass
else:
raise AssertionError("仍需兼容旧读取者")
print("新旧读取者在兼容阶段得到相同超时")
完整示例可下载,使用 Python 3.9 或以上版本运行:python3 example.py。只依赖标准库,不调用真实业务服务。
为什么拒绝一千五百毫秒?因为还活着的旧代码只能理解整数秒,强行四舍五入会产生行为差异。等确认旧读取者都退出,才可以只写毫秒并放开新精度。兼容阶段限制部分新能力,是一种有意识的发布策略,不是实现不够完整。
真正的线上迁移还要考虑旧写入者。如果某个旧实例只修改秒字段,毫秒字段就会变成过期值,而新读取者恰好优先使用它。必须先让所有写入路径遵守兼容协议,或者使用数据库触发器、版本号与统一入口等机制保持一致。只升级读取代码,再后台补一遍数据,通常不够。
回填期间也存在竞争。扫描读到旧值,准备写新字段时,用户可能已经更新配置。生产实现需要带版本的条件更新,或在事务中判断新字段仍然不存在。失败后重新读取即可,不能拿扫描时的旧值强行覆盖。迁移脚本应该允许重复运行,并有进度与失败记录。
对于长期工作流,更安全的选择有时是让旧任务继续使用旧实现,新任务采用新实现。等旧任务自然结束,再移除旧版本。但如果一个客户实体会运行数年,就不能无限等待,需要明确的状态迁移或切换机制。任务生命周期决定了迁移窗口的长度。
收缩阶段往往最容易被忘记。新字段已经用了半年,旧字段还在,双写逻辑越来越复杂。需要在计划里写出退出条件,例如旧版本流量归零、历史任务全部迁移、回退窗口结束。条件满足后删除兼容分支,才能真正获得简化,而不是永久维护两套系统。
我会把发布拆成几次可验证的小变化,每次都有观察指标和回退办法。回退也要考虑数据:一旦产生旧代码无法理解的新格式,回退二进制并不等于恢复服务。把这种限制提前写出来,能够避免发布现场临时做数据转换。
从高并发服务到 Agent,部署从来不是“新代码启动了”这么简单。仍在执行的请求、旧消息和长时间等待的任务都会成为旧契约的使用者。并行变更的价值,就是让这些使用者有一条明确的迁移路径,让发布过程本身也成为可以推理的工程流程。