改了 ≠ 生效了:为什么"改完了"不是完成判据
主题:变更的生效性验证(部署 · 配置)
先说结论
有一次我修完一个生产缺陷,重建镜像、重启服务、重跑验证 —— 错误又出现了。
代码是对的,本地跑也是对的。问题出在更朴素的地方:我改的那一份,不是在线上真正干活的那一份。
后来我想明白,"改完了"这个判断之所以经常是错的,是因为我用来确认它的证据,几乎全都来自"提交侧":命令跑完了、重启成功了、接口返回 true 了。而这些证据只能证明我提交过,证明不了谁在按它执行。
这篇文章只讲两个入口 —— 变更落到了几个实例、改的是不是被订阅的那一份;以及一个可以复用的完成判据:不看提交侧,只看生效侧。
一、入口一:变更落到了几个实例
现象
一个热点写服务(下单/秒杀链路)有个数据写错的缺陷。我定位到根因、改代码、重新构建镜像、滚动重启,然后重跑之前的验证脚本 —— 错误一模一样地复现了。
第一反应是"我改错了?"于是回去逐行读代码。代码没问题。
真相
这个服务在生产上是双实例(主实例 + 部署在另一台机器上的副本),两个实例都连同一个消息队列消费同一类消息,属于竞争消费:
┌──────────────────────────┐
消息 ──────────► │ 消息队列(一个队列) │
└───────────┬──────────────┘
│ 谁先抢到谁处理
┌──────────────────┴──────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 实例 A(我改了) │ │ 实例 B(旧版本) │
│ 新 jar ✅ │ │ 旧 jar ❌ │
└─────────────────┘ └─────────────────┘
我只重建了实例 A,实例 B 还在跑上一个版本的 jar。而这次验证触发的那条消息,恰好被实例 B 消费了 —— 跑的还是旧逻辑。
证据很好找:实例 B 的日志里有一条处理记录,时间戳和我这次验证完全对得上。
关键认知
"部署完成"不是一个布尔值,而是一个概率。
只要这个服务有副本、且副本和主实例竞争消费同一队列,那么"线上跑的是哪个版本"就变成了按消息来分的问题 —— 一半的消息走新逻辑、一半走旧逻辑,而你随手验的那一次,未必落在你想要的那一半上。
处置
- 两个实例换成同一份 jar,并把副本的差异收敛成两个环境变量(服务端口 + 链路追踪的服务名),不再单独维护一套镜像 —— 从根上消灭"两个版本并存"的土壤。
- 把"部署完成"从一句话变成一条命令:逐实例打印四个事实 —— 容器内 jar 的指纹、宿主上 jar 文件的指纹、容器状态、健康检查返回码;四者全绿且两个实例指纹相同才算完成。
# 逐实例核对(每个实例都跑;两台指纹必须一致)
for c in <实例A容器> <实例B容器>; do
echo -n "$c 容器内: "; docker exec "$c" md5sum /app/app.jar
echo -n "$c 宿主文件: "; md5sum /data/app/jars/<服务>.jar
docker inspect -f '{{.State.Status}}' "$c"
done
⚠️ 顺带一个坑:副本镜像的 tag 别写死日期。 很多构建命令会覆盖同名 tag,于是"我明明构建了新版本"这句话可能是假的 —— 你会拿到旧镜像层。共用镜像 + 环境变量差异,比维护两套镜像便宜得多。
二、入口二:改的不是被订阅的那一份
现象
做并发压测前,我要把一条限流阈值从 5 临时调大到 200。为了不改代码、不重启,我走配置中心的热更新:
POST提交配置 → 返回true✅- 再
GET读回来 → 确实是 200 ✅
然后压测跑起来:成功吞吐恒定在 5,一点没变。 阈值看起来"改成功了",行为上跟没改一样。
真相
我改的不是应用订阅的那份配置。
应用订阅的 dataId 是 xxx-flow-rules(没有扩展名),而我提交的是 xxx-flow-rules.json —— 因为仓库里那个文件就叫这个名字。于是我在配置中心里新建了一个同名的影子配置,应用侧从头到尾没收到过通知。
最直接的证据在消费侧:应用的变更通知日志里,规则 md5 一直是旧的;把它和配置中心上那份新内容的 md5 一对比,差得明明白白。
关键认知
配置中心返回成功,只说明"它存下了",不说明"应用用了"。
这是"提交侧 / 生效侧"最容易混为一谈的一处:因为配置中心既存配置、又能回读,所以它的成功响应看起来就像一次端到端的确认 —— 但它确认的只是它自己那一侧。
处置
- 按应用配置里声明的 dataId 逐字提交(对照应用自己的配置项,不是对照仓库文件名)。
- 提交后在消费侧取证:应用侧的通知日志出现变更记录、md5 与预期一致。
- 把影子配置当场删掉 —— 否则它迟早会被下一个人(包括未来的自己)改到。
💡 还有一个便宜的探针:改完先跑最小的一档。阈值如果真的生效了,最小的那一档就该变;不变就停下来查生效性,别把整轮阶梯都白跑一遍。
三、四句可以复用的取证方式
两个入口合起来,其实就是一条原则:完成判据只能落在生效侧。 换了别的变更类型,判据也跟着换:
| 变更类型 | 到哪取证(生效侧) | 不要看什么(提交侧) |
|---|---|---|
| 版本 / 部署 | 逐实例的 jar 指纹(容器内 + 宿主文件,必须一致) | "重启成功了"、"容器 running" |
| 配置 | 消费侧的变更通知日志 / 行为指标 | 配置中心的返回码、"我 GET 到了" |
| 数据 / 清理 | 前后基线比对(行数 + 关键 SUM) | 脚本自己打印的"✅ 校验通过" |
| 流程 / 编排 | 对生产最终状态的独立复核 | 脚本自己的输出、退出码 |
四、可以抄走的 5 条自查
- 我的服务有几个实例?部署脚本是"逐台核对指纹",还是"重启完就宣布完成"?
- 副本和主实例是同一个镜像吗?镜像 tag 里有没有写死的日期?
- 我改的 dataId / key,是从应用配置里抄的,还是从仓库文件名里猜的?
- 改完配置,我去应用日志里确认它收到了吗?(服务端返回 200 ≠ 生效)
- 最后一次"变更完成",我是靠脚本的输出确认的,还是靠对生产最终状态的独立取证确认的?
结语
这两个坑的技术含量都不高 —— 没有一个需要读框架源码。它们的可怕之处在于它们是沉默的:
命令跑完了、返回
true了、容器也起来了 —— 提交侧的一切都在说"成功了"。
所以现在我不再问自己"我改了吗",只问一句:
"生效侧现在是什么?"