改完 Nginx 的 proxy_read_timeout,nginx -t 通过、reload 返回成功、监控一片绿。三天后才发现超时还是原值:配置写在 server 块里,被更靠内的 location 块覆盖了。整个过程没有一条告警。
这类事故结构相同:验证只证明了「没有新增报错」,变更要证明的是「目标行为已成立」。前者是「异常集合为空」,后者是「期望命题为真」。空集可以被无数原因造成——流量没进来、分支没走到、开关没读到、缓存没失效。
所以验证的判据只有一句话:能举出一条证据,它只在变更生效时才出现。 举不出来,就还停留在「看起来没问题」。
1. 先分清三个命题
| 命题 | 典型证据 | 能证明 | 不能证明 |
|---|---|---|---|
| 没报错 | 错误率、异常日志为空 | 新路径没抛错 | 新路径是否被执行 |
| 生效了 | 生效值快照、行为差异 | 目标行为出现了 | 是否覆盖全部目标范围 |
| 对了 | 业务语义指标、对账 | 结果符合预期 | 是否由本次变更造成 |
三个命题的价码差得很远:第一条几乎免费,第二条要专门埋点,第三条要对照组甚至等一个账期。跳过第二条直接看第一条,就是「三天后才发现」的标准成因。
2. 验证的四个层次
| 层 | 最小证据 | 代价 | 能拦住 | 拦不住 |
|---|---|---|---|---|
| 进程活着 | 进程存在 + 端口 listen + 启动日志最后一行 | 秒级 | 起不来、崩在启动 | 起来了但依赖没连上 |
| 请求通 | 健康检查 + 一条真实业务请求的状态码与耗时 | 分钟级 | 5xx、路由错、鉴权错 | 200 但内容是空数组 |
| 指标对 | 目标指标的方向与幅度 | 十分钟级到窗口 | 阈值写反、方向反 | 指标对但业务不对 |
| 业务语义对 | 一条可核对的事实:某笔单、某个计数、某个字段 | 小时级,需人工或对账 | 口径错、范围错 | 长尾样本 |
层次不是越高越好,而是按影响面选到第几层:改的是「能不能跑」,第 2 层够;改的是「跑得对不对」,必须到第 4 层。只做 1、2 层就声称验证过,是绝大多数隐性失效的来源。
3. 「生效」的三种证据与可信度
| 证据 | 证明什么 | 不证明什么 | 采集成本 |
|---|---|---|---|
| 配置读取日志 | 这个键被读到了 | 是否被使用 | 极低,改一次代码 |
| 运行时快照 | 值在内存里是对的 | 这条路径取的是它 | 低,命令即可 |
| 行为差异 | 结果确实变了 | 变化由本次变更导致 | 中,要固定输入 |
可信度排序是 行为差异 > 运行时快照 > 配置读取日志。倒序排是常见错误:日志只证明「读了」,常读的还是默认值。
// 层 3:取运行时生效值,不看启动脚本里的文本
$ nginx -T 2>/dev/null | grep -B5 -A3 'proxy_read_timeout'
$ jcmd 5123 VM.flags | tr ' ' '\n' | grep -iE 'heap|gc'
$ redis-cli CONFIG GET maxmemory
// 层 4:固定输入比对输出字段,不要只比状态码
$ curl -s "$BASE/api/quote?sku=S1" -o before.json -w '%{http_code}\n'
$ curl -s "$BASE/api/quote?sku=S1" -o after.json -w '%{http_code}\n'
$ diff <(jq -S . before.json) <(jq -S . after.json)
4. 观察窗口由问题的时间尺度决定
窗口的正确写法是:这个变更可能以什么方式错,那种错最早、最晚什么时候可见,取后者乘安全系数。
| 变更类型 | 暴露尺度 | 最小窗口下限 | 原因 |
|---|---|---|---|
| 超时、连接池 | 秒到分 | 10–30 分钟 | 要等一次慢请求才触发 |
| 缓存策略 | 一个 TTL | ≥ 2 × TTL | 第一轮读到的仍是旧值 |
| 定时任务 | 一个调度周期 | ≥ 1 周期 + 余量 | 只在触发时刻暴露 |
| 对账、结算 | 一个账期 | ≥ 1 个账期 | 日终才跑批 |
| 内存增长 | 小时到天 | 按斜率反推 | 看趋势不看瞬时值 |
| 长尾客户 | 天到周 | 分群观察 | 小流量打不到 |
「拍 30 分钟」之所以流行,是因为它刚好覆盖「请求通」这一层。窗口不足的代价不是多等一会儿,而是「这个失效要靠下一个人偶然发现」。
5. 对照组:同口径才有结论
| 可比条件 | 不满足时的假象 |
|---|---|
| 负载量级相当 | 流量跌了 30%,指标自然变好 |
| 时段与业务周期一致 | 白天改、夜里比,曲线必好 |
| 样本口径一致 | 只统计成功请求,错误率当然降 |
| 缓存状态一致 | 一边冷一边热,耗时不可比 |
| 采集口径一致 | 埋点被顺手改过,指标换了定义 |
// 前后对比必须同查询、同粒度、同过滤
$ q() { curl -s "$PROM/api/v1/query" --data-urlencode "query=$1" | jq -r '.data.result[0].value[1]'; }
$ q 'sum(rate(http_server_requests_seconds_count{uri="/api/pay"}[5m]))'
$ q 'sum(rate(http_server_requests_seconds_count{uri="/api/pay",status=~"2.."}[5m]))'
没有对照组的「指标变好了」是最危险的一类结论,因为它在报告里和真的变好长得一模一样。
6. 自动化能判定的与必须人判断的
| 判定项 | 能否自动 | 判据 |
|---|---|---|
| 进程/端口/健康检查 | 能 | 布尔结果 |
| 关键字出现在日志 | 能 | 正则命中 |
| 指标方向与阈值 | 半自动 | 方向可判,「幅度是否够」要人 |
| 业务语义等价 | 半自动 | 对账脚本算差异,人签字 |
| 影响面是否覆盖全部调用方 | 人 | 需要调用图之外的业务知识 |
| 回滚后是否回到原状 | 半自动 | 快照对比 + 人判断残留可接受性 |
自动化的边界在「差异」与「判断」之间:凡是需要判断「差异是否可接受」的地方,脚本只能算出差异,判定在人。硬编码阈值的代价是误报频繁,误报多了整条流水线会被绕过。
7. 「看起来好了」的三种假象
| 假象 | 观测特征 | 判别方法 |
|---|---|---|
| 缓存未失效 | 改完立刻「生效」,旧值仍周期出现 | 用必然未命中的键(随机后缀)再请求 |
| 灰度只覆盖小流量 | 指标几乎不动,日志里却有新版本 | 先查流量分配再看指标,顺序不能反 |
| 指标口径变了 | 指标改善但业务无感 | 变更清单里核对是否动了埋点与过滤条件 |
// 三件事按序核:流量到没到、缓存绕没绕、口径变没变
$ curl -s "$BASE/api/ver" | jq -r '.build, .instance'
$ curl -s -H 'Cache-Control: no-cache' "$BASE/api/quote?sku=S1&probe=$RANDOM" -o /dev/null -w '%{http_code}\n'
$ curl -s "$PROM/api/v1/query" --data-urlencode 'query=count by (version)(up{job="api"})' | jq .
8. 回滚的验证同样是验证
回滚只回到代码原状,不回到系统原状。变更里有「写」的动作,回滚就得逐项验证残留。
| 回滚后仍需确认 | 为什么不会自动恢复 |
|---|---|
| 已写入的数据形态 | 脏数据不会因代码回滚而消失 |
| 缓存条目 | 新格式被旧代码读到,反序列化失败或读出错值 |
| 注册与连接 | 新实例仍在注册中心、长连接仍指向新节点 |
| 外部副作用 | 已发出的消息、已扣的额度、已置的开关 |
| 表结构与索引 | DDL 通常不可逆 |
// 回滚验证的最小集合:版本、缓存、注册中心
$ curl -s "$BASE/api/ver" | jq -r '.build' // 期望回到上一个 build
$ redis-cli --scan --pattern 'quote:v2:*' | head -20 // 新格式键是否残留
$ curl -s "$REGISTRY/api/instances?service=api" | jq -r '.instances[].version' | sort -u
可操作的判据:变更清单每出现一个写动作,回滚清单就加一行验证;行数对不上,说明回滚预案是抄的。
9. 责任归属与度量
分工只有一句话:改的人负责证明生效,看的人负责定义「什么算生效」。这句落不下来,验证就退化成「谁有空谁瞄一眼」。
| 度量 | 定义 | 分母 | 用途 |
|---|---|---|---|
| 变更验证覆盖率 | 有生效证据的变更数 / 全部变更数 | 全部变更 | 看漏测面在哪些类型上 |
| 验证到发现问题时长 | 变更时间点到「发现未生效」 | 未生效变更 | 判断窗口是否普遍偏短 |
| 假阳性率 | 验证通过但实际未生效 / 验证通过数 | 验证通过数 | 判断证据是否只是「没报错」 |
| 单次验证人工成本 | 人工分钟数 / 变更数 | 变更数 | 判断是否过度验证 |
这四个数不需要平台,变更单加两列就能统计。难的不是度量,是承认「验证通过」有时只是「没人看」。
10. 排错:六种「验证了但没验证到」
| 现象 | 原因 | 处理 |
|---|---|---|
配置改了没生效,-t 却通过 |
块级覆盖、include 顺序 | 看 nginx -T 合并后的全文,不看源文件 |
| 日志里找不到生效值 | 只打了入参,没打最终值 | 装配完成后打一次 effective-config |
| 指标变好但业务无感 | 流量下降、分母变小 | 加对照组,固定分母 |
| 重启生效,运行期改不生效 | 值被缓存或只在启动读一次 | 区分启动参数与热配置 |
| 灰度通过,全量回归 | 灰度流量不含问题路径 | 按租户或调用方分群 |
| 回滚后仍报错 | 缓存、schema、消息残留 | 回滚清单包含清理新格式副作用 |
11. 边界:什么时候验证可以压到一行
| 变更类型 | 压到什么程度 | 理由 |
|---|---|---|
| 纯文档 | 一行:链接可访问 + 版本匹配 | 无运行时状态 |
| 纯样式、纯文案 | 一行:目标页面渲染无回归 | 影响面静态可推 |
| 常量替换等可完全静态推导 | 编译 + 一次抽样请求 | 没有运行时分支 |
| 只读查询加索引 | 到第 3 层 | 可回退、无写入 |
| 涉及写入、资金、权限 | 到第 4 层 + 对照组 + 对账 | 错了不会自己暴露 |
| 跨服务契约 | 到第 4 层 + 消费者抽样 | 影响面在别的系统里 |
反过来的边界也要写:影响面「说不清」的变更,不能因为「改动很小」而降级验证。改动大小与影响面是两个维度,小改动打穿全局更多,它绕过了评审。
12. 自测
- 「错误率为零」这条证据能证明哪个命题,不能证明哪个命题?
- 只改了「跑得对不对」类的逻辑,验证该做到第几层?只做到第 2 层会漏掉什么?
- 三种「生效」证据里,哪一种可信度最低,为什么它最容易骗人?
- 一个缓存 TTL 是 10 分钟的变更,观察窗口为什么不能取 10 分钟?
- 指标改善了 20%,你怎么在十分钟内判断这是变更效果还是流量下降?
- 回滚之后,为什么「版本号回去了」不等于验证完成?