上周组里一个同事刚上线一个合同审批系统的改动,OA流程里签署方式的下拉框从原来的两个选项改成了三个。代码跟着做了调整,只处理新选项的值。上线当天下午,几个审批单全部处理失败,日志里都是枚举匹配不上的异常。
排查了一下就定位原因了:那些审批单是上线之前提交的,走的还是旧流程,表单里填的还是旧选项值。新代码只认识新值,旧值到了代码里直接走进了异常分支。
问题不在于写错了逻辑,而在于写代码的时候脑子里只有一个假设:用户提交的一定是新值。这个假设在代码上线的那一刻就不成立,因为系统里已经有一批旧数据正在流转中。
新代码和旧数据的时间差
代码部署有一个时间点,姑且叫它T。T之前产生的数据,遵循的是旧规则;T之后产生的数据,遵循新规则。新代码从T开始运行,但它要处理的数据并不全是T之后产生的。

一个OA审批流程,从提交到最终结束,平均周期3到5天。你周三部署新代码,那周一到周三之间提交的审批单,全部携带旧值在流程里流转。这些单据走到你的代码面前时,它们不知道也不关心你的代码刚换过一版。
这个问题的适用范围远不只是审批流程。所有「数据先产生,代码后处理」的场景都有同样的风险:
- 消息队列里积压的消息,格式可能是旧版本的
- 定时任务每小时扫一次数据库,库里有大量历史数据
- 支付回调、物流回调,对方发过来的参数格式可能是上个版本约定的
- 前端有缓存,用户刷新页面之前用的还是旧版界面,提交的参数也是旧格式
一个审批系统的兼容方案
回到那个审批系统的案例。合同签署方式原来只有两个值:「双方签署」和「单签(仅我方签)」。新版拆得更细了:「双签(我方先签)」「双签(对方已签)」「单签(仅我方签)」。
旧的「双方签署」这个值在新版里不再使用,但数据库里存着它,正在流转的审批单带着它。代码里如果把它删了,那些旧单据就没人认识了。
这套系统最终的兼容方案有几个关键设计决策,每一个都有明确的理由。
枚举类保留旧值,打上注释标记。 旧的枚举值不删除,在旁边写清楚「历史值,线上仍有流程使用,保留以兼容」。这行注释是写给三个月后维护这段代码的人看的。没有这行注释,后面接手的人看到一个从没被新流程使用的枚举值,大概率会当做无用代码清理掉,然后线上又出一轮故障。
用语义化判断方法代替精确匹配。 业务上的判断逻辑不是「签署方式等于某个具体字符串」,而是「签署方式属不属于双方签署这个类别」。代码里提供一个 isBothSidesSign(String desc) 方法,内部覆盖所有新旧值的匹配。调用方不需要知道有几个枚举值,只需要调这个方法拿到一个布尔结果。
Java
// 判断是否属于双方签署(兼容新旧值)
public static boolean isBothSidesSign(String desc) {
return BOTH_SIDES.getDesc().equals(desc)
|| BOTH_SIDES_OURS_FIRST.getDesc().equals(desc)
|| BOTH_SIDES_THEIRS_FIRST.getDesc().equals(desc);
}
这个设计的价值在于:下次再新增一个签署方式,只要在这一个方法里加一行,所有调用点的逻辑自动生效。如果是在每个调用点写if-else,新增一个值就要改十几个地方,漏改一个就是一个线上问题。
哪些场景需要兼容,哪些不需要
不是所有代码改动都需要做前后兼容。过度的兼容处理会让代码变得臃肿,每个if分支都在处理历史包袱,读起来费劲,维护成本也高。
判断标准只有一个:这次改动涉及的数据,上线那一刻有没有「在途」的。
「在途」的意思是:数据已经产生了,但还没有被最终处理完。审批单在流转中、消息在队列里等待消费、异步任务还在排队、前端缓存了旧页面的用户还没刷新。这些都是在途数据。
有在途数据的场景,必须兼容。典型的比如:
- 审批系统改了表单选项值,流转中的单据带旧值
- 消息生产者和消费者不能同时发版,消费者需要兼容新旧两种消息格式
- 接口返回值的结构有变化,但客户端有缓存或版本升级不同步
没有在途数据的场景,可以不兼容。比如:
- 一个纯内部的工具方法改了参数类型,所有调用方同一次发版一起改
- 前后端同时发版、没有缓存场景的接口格式变更
- 数据库加了一个新字段,旧数据这个字段为null,新代码对null有默认处理
还有一些灰色地带需要具体判断。数据库里某个字段的含义从A改成了B,理论上需要做数据迁移。但如果你的业务逻辑只查最近7天的数据,而改动之前的数据全在7天之外,那兼容的紧迫性就低得多。
有人可能会问:在途窗口很短的场景(比如消息队列积压通常只有几秒),有必要做兼容吗?我的判断是:窗口短不等于不存在。一次发版重启花5分钟,这5分钟里队列积压的消息全是旧格式。如果处理失败会触发重试、告警甚至影响下游系统,这5分钟的窗口造成的问题可能需要5小时去修复。兼容代码的成本是写的时候多花10分钟,不兼容的成本是出事之后不确定的修复时间。
每次涉及枚举值、消息格式、接口参数、状态机这类改动时,问自己三个问题:
- 上线那一刻,有没有旧数据会走到我的新代码?
- 旧数据走到新代码时,会不会进入异常分支?
- 如果进了异常分支,是直接报错(能发现),还是静默写入错误数据(不容易发现)?
三个问题过一遍,兼容不兼容、兼容到什么程度,基本就清楚了。
发版前自检5问:
- 消息队列里有没有积压的旧格式消息?发版重启期间会不会积压?
- 有没有正在运行的定时任务会扫到旧格式数据?
- 数据库里有没有旧值会被新代码的查询条件命中?
- 外部系统的异步回调(支付、审批、物流)有没有可能带旧格式参数?
- 前端有没有缓存?用户用旧页面提交的请求,新接口能不能正常处理?
比「能处理旧数据」更深一层
做兼容不只是「旧值也处理一下」这么简单。更值得思考的是:代码怎么写,才能让下一次变更也不用到处改。
审批系统的方案选择了统一判断方法而不是if-else散落在各处,原因不只是为了处理当前的旧值。是因为OA表单的选项未来大概率还会变。产品加一个新的签署方式,如果每个调用点都是精确匹配某个字符串值,那每加一个值就要全局搜索一遍所有调用点,改漏一个就是一次线上故障。
有一个经验性的判断:如果你发现自己在代码里对某个外部输入做了精确匹配(equals某个具体字符串),就该警觉了。 精确匹配意味着脆弱,外部值稍有变化,代码就会走到预期之外的分支。
更稳妥的做法是把判断逻辑封装成语义化的方法,调用方表达的是业务意图(是不是双方签署),而不是数据细节(是不是等于某个具体文案)。这样外部值的变化只影响一个方法的内部实现,不扩散到所有业务逻辑。
这个思路在消息队列场景更明显。消息体的格式不可能永远不变,在消费端写死字段名和字段结构,等于把自己绑死在当前版本的消息格式上。一个做法是消息头里带version字段,消费端根据version走不同的反序列化逻辑。代码多写几行,但每次消息格式变更不需要担心队列里还有没有旧消息。
小结
兼容性问题的根源是一个认知偏差。写代码时,开发者的心智模型默认是「新代码处理新数据」,但生产环境里数据不关心你的代码是新是旧。上线的那一刻不是一个干净的分界线,旧数据不会因为你部署了新版本就自动消失或者自动转换格式。
比起报错,更危险的是不兼容导致的静默错误。审批单被归到错误的类别、金额映射到错误的配置、状态流转到不该去的节点。这类问题发现的时候往往已经过了好几天,受影响的数据量也不确定,修复成本远高于事前多写几行兼容代码。
一个值得养成的习惯:每次改动涉及枚举、状态、消息格式、接口参数这些东西时,写完新逻辑之后花30秒问自己「上线那一刻,有没有旧数据会走到我这段代码」。这一个问题能拦住大部分兼容性事故。
最近在知乎出了「应付6000万会员的秒杀系统专栏」和「几亿用户,百万并发的C端商品系统实战」专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:
- 老码头的技术浮生录
它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」
知识星球内后续将推出20+个付费专栏,覆盖电商全链路:
| 选购线 | 用户会员营销线 | 中后台 |
|---|---|---|
| 购物车服务 | 营销系统 | 订单系统 |
| 商品服务 | 用户系统 | 支付系统 |
| 菜单服务 | 结算服务 |
从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。
我的知乎账号:
- SamDeepThinking