"三年没升级,现在升不动了"听起来是技术判断,实际是组织判断。依赖不会因为放着不动而变难,变难的是中间的落脚点消失了:当年能顺手跨过的小版本都已 EOL,只剩从三年前直跳今天这一条路,还要走完整的语义迁移。
节奏管理的对象不是版本号,是窗口和回归网:前者是还剩多少可分段台阶,后者是不靠人肉验证能否走完。
1. 滞后不是线性成本
| 阶段 | 表面症状 | 真实约束 | 一次升级的代价 |
|---|---|---|---|
| 正常期(季度内) | 无 | 回归网够不够用 | 半天到两天 |
| 滞后一期(半年到一年) | 某个大版本没跟上 | 阻塞性传递依赖 | 专项排期,可能跨迭代 |
| 滞后多期(跨两个以上大版本) | 一升就崩 | API 语义累积变更 + 平台耦合 | 数周起,常需双版本并行 |
跃变点不在"版本差多少",而在能不能分段。滞后一期还找得到"先升 A 再升 B"的顺序;滞后多期时 A 的新版本已要求 B 的新版本,顺序被压成一次跳跃。
2. 两种升级,两套排期
| 维度 | 安全驱动 | 能力驱动 |
|---|---|---|
| 触发源 | CVE 公告、合规扫描 | 新特性、性能瓶颈、生态弃用 |
| 可延期性 | 不可延期,但有窗口 | 可延期,只损失机会 |
| 排期依据 | 披露日 + 修复 SLA | 产品路线图 |
| 典型幅度 | 多为小版本或 patch | 常跨大版本 |
混在一个排期里是最常见的错:安全升级被产品排期拖走,能力升级被"顺便一起升"打包成大跳。安全升级要能单独发,前提是小版本路径本身通——这恰是长期不升会破坏的东西。
3. 版本窗口:三个窗口同时存在
一个依赖同时有三条时间线:维护窗口(还有常规 release)、安全窗口(还出 CVE 修复)、生态窗口(周边库还发兼容版本)。EOL 不等于立刻坏,只是"下次要升时没有中间版本可落脚"。
| 状态 | 判据 | 风险 | 动作 |
|---|---|---|---|
| 活跃 | 近一年有常规 release | 低 | 跟小版本,季度清一次 |
| 维护 | 只发 patch 或 LTS 分支 | 中 | 排大版本窗口 |
| EOL | 官方声明停止维护 | 高 | 立项,不再等下一版 |
先量现状再排期:
mvn -q versions:display-dependency-updates -DprocessDependencyManagement=false
-DprocessDependencyManagement=false 让输出聚焦直接依赖,不被平台 BOM 干扰。对 Java 平台而言,下一个 LTS 是唯一值得提前规划的落点:选错一次不是多背一年,而是下个窗口前再被迫做一次同样的迁移。
4. 小版本与大版本:代价不在一个量级
| 维度 | 小版本 | 大版本 |
|---|---|---|
| 变更性质 | 修 bug、默认值微调、弃用标记 | 语义变更、包移动、API 删除 |
| 需要的回归网 | 主流程冒烟 | 全量用例 + 契约测试 |
| 是否需并行 | 一般不需要 | 常需双版本并行 |
| 回滚代价 | 换回旧 tag 即可 | 数据/配置可能已按新语义写入 |
| 典型工作量 | 半天到两天 | 数周到数月 |
是否"大版本级别"不能看版本号,要看语义面:序列化默认值、时区处理、空值语义、异常类型、线程与事务默认行为。这些变了,1.4 → 1.5 也得按大版本对待。
5. 你升不上去,往往是别人决定的
第一反应通常是"我的代码不兼容",但多数情况是依赖树里有人钉死了旧版本。
mvn -q dependency:tree -Dincludes=com.fasterxml.jackson.core # 谁带进来的
mvn dependency:tree -Dverbose -Dincludes=org.slf4j # 被调解砍掉的候选
| 阻塞形态 | 机制 | 出路 |
|---|---|---|
| 上游库钉死旧版本 | 以 compile 范围传递出来 | 排除后自己声明,或换库 |
| 平台 BOM 覆盖 | 受管版本压过子模块声明 | 覆盖 BOM 属性或在子模块压过 |
| 同名类双份 | 两个坐标提供同一功能 | 收敛到一套 |
| 依赖了传递依赖 | 没声明却在用,升级时消失 | 显式声明 |
正确提问不是"我为什么升不上去",而是"这个坐标的版本是谁决定的"——答案通常在第三、四层。
6. 升级前必须摸清的三件事
谁在用这个 API:grep 只给候选,编译才是判据。
grep -rn --include=*.java -E '\bcom\.example\.Legacy[A-Za-z]*' src/ | wc -l
japicmp -o old.jar -n new.jar --only-modified --ignore-missing-classes
有没有替代:迁移一个 API 的成本高于换库时,换库更便宜。前提是替代品的寿命比当前依赖更长。
回退点在哪:分三层确认——制品能否换回旧 tag、旧配置 key 是否还在、数据有没有被按新语义写过。第三条决定"能否原地回退"。
7. 自动化工具:收益与噪声同源
| 能力 | 收益 | 噪声来源 | 治理 |
|---|---|---|---|
| 依赖更新 PR | 小步边际成本趋零 | 每日数十个 PR 淹没评审 | 分组 + 并发上限 |
| 分组更新 | 一个 PR 覆盖一组 | 组内失败难定位 | 按生态分组(test / 编译期 / 运行期) |
| 自动合并 patch | 减少人工触碰 | 无回归网时等于自动引入故障 | 只对达标模块开 |
{
"extends": ["config:recommended"],
"packageRules": [
{ "matchUpdateTypes": ["patch"], "groupName": "patch-all" }
],
"prConcurrentLimit": 4,
"schedule": ["every weekend"]
}
判据很简单:一个人每周合并依赖 PR 的时间超过半天,问题在分组与并发上限,不在工具。自动化的价值是把"一次性大升"拆成"每周末四个小 PR",而不是制造永不合并的队列。
8. 持续小步还是集中大升:看协调成本
| 团队形态 | 更适合 | 理由 |
|---|---|---|
| 1–3 人,少数服务 | 集中大升,半年一次 | 小步的固定协调成本占比过高 |
| 5–15 人,十余服务 | 小步 + 季度大版本窗口 | 变更有人 review,回归网可用 |
| 多团队,平台化 | 统一基线 + 平台级升级 | 一次平台动作替代 N 次项目动作 |
统一判据:一次升级需要多少人协调。要跨三人以上才能签发,该集中;一人半天能合完并验证,该小步。
9. 可升级性:没有回归网就不要大升
这是全篇唯一的硬判据。回归网的厚度决定可升级幅度:
| 覆盖类型 | 对升级的保障 | 缺失后的表现 |
|---|---|---|
| 单元测试 | 语义变更的第一道闸 | 编译通过、行为变了、无人发现 |
| 契约测试 | 对外接口不被破坏 | 上游调用方先崩 |
| 集成测试(容器化) | 驱动、SQL、事务行为 | 只在生产暴露 |
| 冒烟脚本 | 主流程可用 | 灰度才现,回退成本已升高 |
破坏性变更要读 diff 而不是 changelog:后者只说"作者认为变了什么",前者说"你的调用点会不会变",中间永远有缺口。
升级还挂在几条耦合链上:
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
| 耦合对 | 联动规则 | 错配的后果 |
|---|---|---|
| Java 版本 × 框架版本 | 按官方支持矩阵核对(具体下限按版本核对) | 编译过、启动期报错 |
| Java 版本 × 字节码目标 | release 必须 ≤ 运行 JDK |
换机器就崩 |
| 依赖 × 容器基础镜像 | 镜像内的 JDK 才是运行期真相 | 本地正常、容器里 NoClassDefFoundError |
| 客户端 × 服务端 | 中间件客户端大版本常抬高服务端下限 | 连得上但功能缺失 |
10. 灰度与回滚:同一制品多版本并行
升级真正的安全阀不是"测过",而是"能退"。
upstream app {
server 127.0.0.1:8080 weight=9; # 旧版本
server 127.0.0.1:8081 weight=1; # 新版本先吃一成
}
灰度前提是两版本能共享状态。共享缓存、会话、分布式锁的 key 必须带版本前缀,否则旧版本读不懂新结构,表现为"偶发 5xx、无法复现"。若新版本已写了只有它能读的数据,那不叫灰度,叫单向迁移。
11. 度量:四个够用
| 指标 | 定义 | 判据(典型量级,按团队校准) | 谁看 |
|---|---|---|---|
| 依赖滞后月数 | 基线距最新稳定版的时间差 | 健康 < 6 个月 | 架构 |
| EOL 依赖数 | 已停止维护的依赖个数 | 期望 0,>0 即立项 | 平台 |
| 升级 PR 存活时长 | 从开到合的时间 | > 1 周说明回归网或人力不足 | 团队 |
| 大版本跨代数 | 同时在生产存在的版本数 | > 2 即有并行成本 | 平台 |
共同点:不需要额外埋点,从依赖清单和 PR 列表就能算。需要专门开发才得到的度量,三个月后没人看。
12. 排错:五种升级后的典型症状
| 现象 | 原因 | 处理 |
|---|---|---|
升级后 NoSuchMethodError |
编译期高、运行期被传递依赖拉低 | dependency:tree -Dincludes= 定位,排除后显式声明 |
| 升级后结果静默不同 | 默认值或序列化语义变更 | 读 diff 找变化点,补断言 |
| 版本改了但行为没变 | BOM 受管版本压过子模块声明 | 查导入优先级 |
| 本地通过、CI 失败 | 仓库不同、版本浮动、插件未锁 | 锁版本,固定仓库 |
| 灰度期偶发异常 | 双版本共享缓存/会话/锁 key | 给共享状态加版本前缀 |
第五条最易被误判为"新版本有 bug"。它是并行期的结构性问题:两版本同时活着时,共享状态就是跨版本的隐式契约,且没有编译期检查。
13. 边界:这些情况本就不该做节奏管理
| 场景 | 做法 | 理由 |
|---|---|---|
| 一次性脚本、临时项目 | 不管理,跑完即弃 | 生命周期短于一个升级周期 |
| 临近交付的冻结期 | 只做安全补丁 | 回归成本无法在窗口内消化 |
| 直接依赖极少的小服务 | 半年集中看一次 | 传播面小,小步的固定成本占比过高 |
| 内部工具、无 SLA 系统 | 降到 EOL 再动 | 失败代价低 |
| 已被平台统一基线的团队 | 跟平台节奏 | 另建一套只会制造分叉 |
失效信号不是"版本旧",而是没人能回答"基线是什么、下次窗口在何时"。这时先补可见性,再谈升级动作。
14. 自测
- 为什么"滞后多期"的成本是跃变而不是线性增长?
- 安全驱动与能力驱动的升级,排期分别由什么时间决定?为什么安全升级要能单独发?
- EOL 之后系统并不会立刻坏,那真正的代价出现在什么时候?
- 升级报
NoSuchMethodError,你的第一句提问应该是什么? - 判断一次升级是否属于"大版本级别",你看版本号还是看什么?
- 灰度期出现无法复现的偶发异常,最可能的共享结构问题是什么?