一个团队升级了 Spring Boot 的 minor 版本,编译通过、单元测试全绿、本地跑通,于是直接上了生产。第二天凌晨有一条定时任务开始重复执行——新版把某个自动配置的默认条件收紧了,两个 bean 的加载顺序变了,而这条路径没有任何测试覆盖。
依赖升级的风险不在"编译不过",那些当场就能发现。风险在行为变了但接口没变:签名一样、返回值类型一样、正常路径的表现一样,只有边界条件、默认值、并发语义这些没有测试的地方变了。
1. 升级的三类变化
| 变化 | 能否被编译发现 |
|---|---|
| 接口签名变更 | 能 |
| 默认行为与默认值变更 | 不能 |
| 并发、顺序、事务语义变更 | 不能 |
第一类是友好的:编译器会指出每一处。第二类与第三类才是真正的风险,它们只在特定路径上表现出来,而这些路径恰好常是测试薄弱的地方。
这个定义把验证从"跑一遍测试"变成了一次排查:列出系统依赖这个库的哪些隐含行为,再逐条问会不会变。列表通常只有五六条,但每一条都不在任何测试文件里。
所以升级的验证深度不取决于"代码改了多少",而取决于这次升级可能改变哪些隐含假设。
2. 验证的四层深度
| 层 | 内容 | 能发现 |
|---|---|---|
| L1 编译与单测 | 本地跑通 | 签名变化 |
| L2 集成测试 | 起依赖、跑主流程 | 配置与默认值变化 |
| L3 灰度发布 | 小比例真实流量 | 行为与性能变化 |
| L4 对比观测 | 新旧版本指标并列 | 细微差异 |
第三层是多数团队的天花板,而第四层是性价比最高的一层:同一时间窗口内把新旧版本的错误率、延迟、日志异常量并列看,比任何测试都敏感——因为真实流量的分布无法被测试复现。
L3 与 L4 之间还有一个成本差异值得说清:灰度发布要改部署流程,而对比观测只要改查询。所以即使不打算灰度,也应当做对比观测——它不需要任何流量切分,只需要把旧版本留在预发跑同一套流量。
L2 容易被做成"假的":用 H2 代替 MySQL、用 mock 代替下游,于是默认值与语义变化正好被这些替身吃掉。
// 四层里每一层的"够/不够"判据
L1 够了:只改了方法签名(如日志库替换)
L2 够了:内部工具、无外部语义依赖
L3 需要:涉及序列化、事务、并发、缓存语义
L4 需要:核心链路(下单、支付、鉴权)
3. 重点检查清单:语义最容易变的地方
| 位置 | 典型变化 |
|---|---|
| 序列化 | 空值处理、字段顺序、日期格式 |
| 事务与传播 | 默认传播行为、只读优化 |
| 连接池 | 默认池大小、超时、空闲回收 |
| 调度 | 默认并发、错过触发的补偿 |
| 缓存 | 默认过期、key 序列化方式 |
第五项的破坏力最大:key 序列化方式变了,缓存会全部 miss,表现为升级后数据库压力骤增,而代码一行没改。
这份清单还可以反向使用:升级前把清单上每一项的当前值打印出来存档,升级后打印一遍,逐项 diff。这比读变更日志可靠,因为日志会漏,而运行时的值不会撒谎。
// 关键默认值的运行时快照:升级前后各存一份
curl -s localhost:8080/actuator/configprops | jq -S . > cfg-before.json
// …升级…
curl -s localhost:8080/actuator/configprops | jq -S . > cfg-after.json
diff cfg-before.json cfg-after.json | head -50
第四项对应开头那个事故:调度框架的默认并发与错过触发补偿是两个最容易变的默认值。
// 升级前把"隐含默认值"显式写出来,让变化无处可藏
spring:
task:
scheduling:
pool.size: 4 # 不写就跟着框架默认走
thread-name-prefix: sched-
datasource:
hikari:
maximum-pool-size: 20 # 默认值随版本变过
connection-timeout: 3000
jackson:
default-property-inclusion: non_null # 空值序列化默认行为曾变
4. 依赖对比:看什么
| 看什么 | 目的 |
|---|---|
| 变更日志的 Breaking 段 | 官方承认的行为变化 |
| 直接依赖的传递依赖树变化 | 间接引入的版本漂移 |
| 默认配置项清单 | 未显式设置的项 |
| 已知 issue 与回归记录 | 别人的踩坑 |
第四项(别人的踩坑)有一个高效的做法:升级前搜 issue 列表里近三个月的 "regression" 与 "breaking"。官方变更日志只写"设计内"的变化,而真实的回归往往先出现在 issue 里,晚几周才进日志。
第二项常被忽略:升级 A 会把 B 一起带上来,而 B 的版本漂移可能才是问题根源。做法是对比升级前后的依赖树差异,而不是只看被升级的那个包。
// 依赖树差异:把"顺手带上来的"找出来
mvn dependency:tree -DoutputFile=before.txt
// …升级…
mvn dependency:tree -DoutputFile=after.txt
diff before.txt after.txt | grep -E '^[<>]' | sort -u
// 重点关注:日志、序列化、HTTP 客户端、连接池这几类传递依赖
5. 回滚的准备
| 准备 | 说明 |
|---|---|
| 版本可回退 | 不删旧依赖,保留可切换 |
| 数据兼容 | 新版本写入的数据旧版本能读 |
| 配置双份 | 新旧配置都能启动 |
| 回滚判据 | 什么现象出现就回滚 |
第二项是硬约束:只要新版本写了旧版本读不懂的数据,回滚就等于数据迁移。这与蓝绿部署的退化机制是同一个问题——升级前必须问"新版本会不会改数据结构或序列化格式"。
还有一个与"回滚判据"有关的要求:判据必须是自动可判的。写成"感觉不对就回滚",凌晨三点没人敢拍;写成"错误率 > 1% 持续 5 分钟",值班的人只需要看一个数。
第三项经常漏:配置项在新旧版本里名字不同,回滚时配置对不上,服务起不来。
// 升级单的四项前置(缺一项就不该上)
1. 依赖版本可回退(旧镜像/旧 lock 文件仍在)
2. 数据格式兼容双向(写测试:旧版读新版数据)
3. 配置在新旧版本都能加载(保留一层兼容映射)
4. 回滚判据写明(如 错误率 > 1% 持续 5 分钟)
6. 排错:五种"升级后才发现"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 定时任务重复执行 | 调度默认并发变了 | 显式设置并发与补偿 |
| 缓存全 miss | key 序列化变化 | 双写或临时换 key 前缀 |
| 序列化字段消失 | 空值策略默认变了 | 显式配置 inclusion |
| 连接被耗尽 | 连接池默认值变了 | 显式设置池大小与超时 |
| 只在生产出现 | 测试用替身吃掉了差异 | 集成测试用真实依赖 |
第五项是根因:测试里的替身越多,升级风险越大。所以"升级前跑一次真依赖的集成测试"应当成为固定动作,而不是可选项。
7. 边界:什么情况不必深验
| 场景 | 为什么 | 做法 |
|---|---|---|
| 纯工具类库(字符串、集合) | 无运行时语义 | L1 即可 |
| 测试依赖、构建插件 | 不影响生产 | L1 即可 |
| patch 版本、仅修 bug | 行为变化面小 | L1 + L2 |
| 已有官方长期支持与兼容承诺 | 风险可控 | L2 + L3 |
还有一条组织层面的边界:升级不该与其他改动同批上线。升级 + 业务改动一起发,出问题时无法区分是哪一边的因果——这类"混批"是升级事故最难排查的形态。
判断口径一句话:这次升级会不会改变系统的"隐含假设"。会(序列化、事务、并发、默认值),就要走到 L3/L4;不会,L1/L2 就够。
8. 自测
- 三类变化中,哪两类编译期发现不了?为什么它们只在测试薄弱处暴露?
- 四层验证里,哪一层的性价比最高?为什么它比测试更敏感?
- 为什么"用 H2/mock 的集成测试"会恰好吃掉升级风险?
- 缓存 key 序列化变化会造成什么现象?为什么它看起来像"代码没改却变慢"?
- 回滚的硬约束是什么?它和蓝绿部署的退化机制是同一个问题,为什么?
- 判断"是否需要深验"的那个问题是什么?