本文是 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,部署从来不是“新代码启动了”这么简单。仍在执行的请求、旧消息和长时间等待的任务都会成为旧契约的使用者。并行变更的价值,就是让这些使用者有一条明确的迁移路径,让发布过程本身也成为可以推理的工程流程。

上一篇 · 系列目录 · 下一篇