改了 ≠ 生效了:为什么"改完了"不是完成判据

0 阅读6分钟

改了 ≠ 生效了:为什么"改完了"不是完成判据

主题:变更的生效性验证(部署 · 配置)

先说结论

有一次我修完一个生产缺陷,重建镜像、重启服务、重跑验证 —— 错误又出现了。

代码是对的,本地跑也是对的。问题出在更朴素的地方:我改的那一份,不是在线上真正干活的那一份。

后来我想明白,"改完了"这个判断之所以经常是错的,是因为我用来确认它的证据,几乎全都来自"提交侧":命令跑完了、重启成功了、接口返回 true 了。而这些证据只能证明我提交过,证明不了谁在按它执行。

这篇文章只讲两个入口 —— 变更落到了几个实例、改的是不是被订阅的那一份;以及一个可以复用的完成判据:不看提交侧,只看生效侧。

一、入口一:变更落到了几个实例

现象

一个热点写服务(下单/秒杀链路)有个数据写错的缺陷。我定位到根因、改代码、重新构建镜像、滚动重启,然后重跑之前的验证脚本 —— 错误一模一样地复现了。

第一反应是"我改错了?"于是回去逐行读代码。代码没问题。

真相

这个服务在生产上是双实例(主实例 + 部署在另一台机器上的副本),两个实例都连同一个消息队列消费同一类消息,属于竞争消费:

                     ┌──────────────────────────┐
   消息 ──────────►  │  消息队列(一个队列)      │
                     └───────────┬──────────────┘
                                 │  谁先抢到谁处理
              ┌──────────────────┴──────────────────┐
              ▼                                     ▼
     ┌─────────────────┐                  ┌─────────────────┐
     │ 实例 A(我改了) │                  │ 实例 B(旧版本) │
     │ 新 jar ✅         │                  │ 旧 jar ❌        │
     └─────────────────┘                  └─────────────────┘

我只重建了实例 A,实例 B 还在跑上一个版本的 jar。而这次验证触发的那条消息,恰好被实例 B 消费了 —— 跑的还是旧逻辑。

证据很好找:实例 B 的日志里有一条处理记录,时间戳和我这次验证完全对得上。

关键认知

"部署完成"不是一个布尔值,而是一个概率。

只要这个服务有副本、且副本和主实例竞争消费同一队列,那么"线上跑的是哪个版本"就变成了按消息来分的问题 —— 一半的消息走新逻辑、一半走旧逻辑,而你随手验的那一次,未必落在你想要的那一半上。

处置

  1. 两个实例换成同一份 jar,并把副本的差异收敛成两个环境变量(服务端口 + 链路追踪的服务名),不再单独维护一套镜像 —— 从根上消灭"两个版本并存"的土壤。
  2. 把"部署完成"从一句话变成一条命令:逐实例打印四个事实 —— 容器内 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 一对比,差得明明白白。

关键认知

配置中心返回成功,只说明"它存下了",不说明"应用用了"。

这是"提交侧 / 生效侧"最容易混为一谈的一处:因为配置中心既存配置、又能回读,所以它的成功响应看起来就像一次端到端的确认 —— 但它确认的只是它自己那一侧。

处置

  1. 按应用配置里声明的 dataId 逐字提交(对照应用自己的配置项,不是对照仓库文件名)。
  2. 提交后在消费侧取证:应用侧的通知日志出现变更记录、md5 与预期一致。
  3. 把影子配置当场删掉 —— 否则它迟早会被下一个人(包括未来的自己)改到。

💡 还有一个便宜的探针:改完先跑最小的一档。阈值如果真的生效了,最小的那一档就该变;不变就停下来查生效性,别把整轮阶梯都白跑一遍。

三、四句可以复用的取证方式

两个入口合起来,其实就是一条原则:完成判据只能落在生效侧。 换了别的变更类型,判据也跟着换:

变更类型到哪取证(生效侧)不要看什么(提交侧)
版本 / 部署逐实例的 jar 指纹(容器内 + 宿主文件,必须一致)"重启成功了"、"容器 running"
配置消费侧的变更通知日志 / 行为指标配置中心的返回码、"我 GET 到了"
数据 / 清理前后基线比对(行数 + 关键 SUM)脚本自己打印的"✅ 校验通过"
流程 / 编排对生产最终状态的独立复核脚本自己的输出、退出码

四、可以抄走的 5 条自查

  • 我的服务有几个实例?部署脚本是"逐台核对指纹",还是"重启完就宣布完成"?
  • 副本和主实例是同一个镜像吗?镜像 tag 里有没有写死的日期?
  • 我改的 dataId / key,是从应用配置里抄的,还是从仓库文件名里猜的?
  • 改完配置,我去应用日志里确认它收到了吗?(服务端返回 200 ≠ 生效)
  • 最后一次"变更完成",我是靠脚本的输出确认的,还是靠对生产最终状态的独立取证确认的?

结语

这两个坑的技术含量都不高 —— 没有一个需要读框架源码。它们的可怕之处在于它们是沉默的:

命令跑完了、返回 true 了、容器也起来了 —— 提交侧的一切都在说"成功了"。

所以现在我不再问自己"我改了吗",只问一句:

"生效侧现在是什么?"