BUILD SUCCESS 骗了我:Java @Async 方法下线 + 残缺仓库部署事故全记录
接手一个运行多年的 Spring Boot 服务,想做一件看起来极其简单的事:把某个头衔授予功能下线。入口是一个带 @Async 注解的方法,被散落在 4 个 ServiceImpl 里调用。逐个改调用方代价高,于是用了一个最小爆炸半径的模式——直接把方法体改成 return,保留签名和历史代码注释。
代码改完,本地 mvn package:BUILD SUCCESS,jar 体积 121 MB,SCP 上去,重启服务,curl 一个鉴权接口回来 401(符合预期),结案。
五分钟后用户反馈:千人千面推荐失效。
现象
生产 curl 任何业务接口均返回 500,日志里满是 ClassNotFoundException、NoClassDefFoundError。翻出生产旧 jar 解包对比,才发现:
- 本地编出的 jar 121 MB,生产旧 jar 133 MB,相差 12 MB
- 本地 jar 里的
.class文件数远少于生产 jar - 生产 jar 里存在的 325 个核心业务类,本地 jar 完全没有
- 3 个内部依赖 jar(
core、game、train)在本地pom.xml里根本没有引用
根因
本地仓库是一份残缺副本。
这个 repo 在最初 import 进版本管理时,只保留了开发者当时认为"能跑起来"的最小子集——IDE 可以 import,mvn package 也能 BUILD SUCCESS,但实际上缺了大量核心模块。生产上跑的 jar 是由更完整的历史工程编出来的,那份工程早已不在版本管理里。
结果就是:我用残缺仓库编了一个"正常"的 jar,把生产里 325 个核心类全部丢掉了。
mvn package BUILD SUCCESS 只能证明你的代码能编译,和生产等价与否毫无关系。
部署后 curl 一个需要鉴权的接口,收到 401,只能证明 Spring Boot 启动了、Shiro 过滤器链加载了。
它无法证明 ThousandPeopleLabelServiceImpl、RecommendController 等业务类在 classpath 里。业务类缺失的错误只会在实际调用这个接口时才暴露出来,而鉴权层比业务层早一个 Filter。
检查"服务是否正常"至少要 curl 2 个业务接口(改动 scope 内 1 个 + scope 外独立模块 1 个),并验证返回的是正常业务数据而非 500/空 body。
正解
@Async 方法下线本身的正确模式
这个模式本身没有问题,在仓库完整的情况下是安全的:
@Async
public void setAutomaticTitleTag(String openid) {
// [ISSUE-075 2026-04-15] 功能已下线,保留方法签名避免破坏调用方
return;
/*
// ========== 原历史代码保留(如需恢复整体 uncomment) ==========
// ... 原评估 + 授予 + addDetails 逻辑 ...
*/
}要点:void 方法直接 return(非 void 方法 return null/默认值),保留 @Async 注解不动(调用方零改动),原逻辑用块注释包裹而非删除。
部署前必做:jar 等价性检查
# 下载生产 jar 解包
scp user@prod:/data/jar/service.jar /tmp/prod.jar
mkdir /tmp/prod-jar && jar -xf /tmp/prod.jar -C /tmp/prod-jar
# 解包本地新编的 jar
mkdir /tmp/local-jar && jar -xf target/service.jar -C /tmp/local-jar
# 对比 class 数(差 > 10% 立即停止)
find /tmp/prod-jar/BOOT-INF/classes -name '*.class' | wc -l
find /tmp/local-jar/BOOT-INF/classes -name '*.class' | wc -l
# 对比 lib 数(差 > 3 个立即停止)
ls /tmp/prod-jar/BOOT-INF/lib/ | wc -l
ls /tmp/local-jar/BOOT-INF/lib/ | wc -ljar 体积警戒线:本地 jar 比生产 jar 小超过 5 MB → 视为残缺信号,停止部署。
残缺仓库的唯一安全操作
一旦确认本地仓库是残缺副本,禁止本地编译部署,改用热补丁方案:
# 1. 备份生产 jar
cp service.jar service.jar.bak-$(date +%Y%m%d_%H%M%S)
# 2. 从生产 jar 里解出目标 .class,仅替换改动的那一个
jar -xf service.jar BOOT-INF/classes/com/example/fans/FansServiceImpl.class
# 3. 用本地编出的单个 .class 替换后重新打包(或直接用 jar -uf 更新)
jar -uf service.jar BOOT-INF/classes/com/example/fans/FansServiceImpl.class残缺仓库 + 必须改代码时,只解包生产 jar,替换你改动的那个 .class,用 jar -uf 更新回去,再重启。影响范围等于你的改动范围,不会引入任何仓库残缺带来的副作用。
同时做好 rollback 预案:部署前备份旧 jar,准备好一键回滚脚本,用户反馈异常时优先 rollback,不要"再编一版"。
一句话外卖
mvn package成功只是"你的改动能编译",不是"你的 jar 等于生产 jar"——接手老服务第一件事是比对本地 jar 和生产 jar 的 class 数量,残缺仓库只能做 .class 级热补丁,不能整包替换。