2026 年,51% 的 GitHub 提交是 AI 生成或大幅辅助的。但当 AI 把代码塞进项目时,谁来回答一个朴素的问题——"为什么我改一行代码,要等 17 秒才能看到效果?"

一、一个让人崩溃的早晨
故事开始于五月底一个周一的早晨。
我正在维护一个名叫"xiaoyu"的多模块 Spring Boot 项目。技术栈是当下最时髦的那种:Spring Boot 4.1.0-M2 + Java 25 (GraalVM) + Maven 多模块。机器配置也豪华——12 核 62G。
但每一次按下 ./run,我都要去倒一杯水。
mvn clean compile install -pl xiaoyu-server -am -DskipTests -qmvn spring-boot:run -pl xiaoyu-server两次 Maven 调用、一次全量 clean、外加 install 走本地仓库——一气呵成,70 秒。
我先不讲技术,先讲一笔账。
一天 30 次就是 35 分钟纯等待,一年 140 小时——相当于 17 个工作日。
更让人发疯的是后半段:代码改完,要手动 Ctrl+C杀掉服务再重跑。
这本不该是 2026 年应有的体验。在这个年份,AI 工具已经能帮你写出整个模块,但 AI 改完代码以后呢?它不会替你重启服务,不会帮你测试,更不会替你优化构建链路。AI 写得越快,构建越慢的痛苦就越尖锐。
我把目标写在白板上:
像 IDEA 一样,改完即跑、尽量不重编、不手动重启。
然后我花了整整一周。
后来复盘这一周,我先后试了 mvnd、构建缓存、reconciliation、-T 1C并行、根 target 布局。这些办法单独看都很合理,但在这个项目里,有的没收益,有的还制造了新的问题。最后真正把问题解决掉的,反而是两个不起眼的 0 字节空文件。
但故事不是这么简单。一切要从一个看似无害的疑问开始:为什么 IDEA 不需要 install?
二、第一波"聪明"优化:让 spring-boot:run 摆脱 install
任何熟悉 Maven 的开发者,第一眼看到上面的脚本都会皱眉:
每一步都有冗余。但要消灭它们,得先回答一个根本问题:
为什么原来需要 install?
mvn spring-boot:run -pl xiaoyu-server用 -pl只把 xiaoyu-server这一个模块加载进 reactor。它依赖的 server-base / workflow-engine / app-server只能去 ~/.m2本地仓库翻 jar——所以必须先 install。
而 IDEA 完全不需要 install。它是怎么做到的?
答案是 IDEA 把每个模块编译到各自 target/classes,直接用这些目录拼 classpath,模块间依赖在项目内部就解析掉了。
模仿 IDEA 的命令行版本
关键:让所有模块在**同一次 Maven 调用(reactor)**里。
mvn -am -pl xiaoyu-server -DskipTests compile spring-boot:run• compile把 reactor 里的模块都编译到target/classes• -am(also-make)把上游模块拉进同一 reactor• spring-boot:run从 reactor 直接拿target/classes,跳过 install
理论上完美。实际上立刻报错。
第一个坑:父 pom 没有 main class
[ERROR] Failed to execute goal org.springframework.boot:spring-boot-maven-plugin:run on project xiaoyu-parent: Unable to find a suitable main class原因:命令行直接调用的插件目标(如 spring-boot:run)会作用于 reactor 中的每一个模块。父 pom是 pom打包、没有 main class,第一个就失败。
解决:用属性默认跳过、应用模块覆盖。
<!-- 父 pom.xml --><spring-boot.run.skip>true</spring-boot.run.skip><!-- xiaoyu-server/pom.xml --><spring-boot.run.skip>false</spring-boot.run.skip>一次 reactor 调用:所有模块都 compile(上游进 target/classes),但 run只在应用模块真正执行。从此再也不需要 install。
到这里感觉良好。一切都还在我的掌握之中。
然后我犯了一个错误:我贪心了。
三、缓存的诱惑:连环踩坑的开始
我顺手装了 mvnd(Maven Daemon),JVM 常驻 + 默认并行,重复构建确实快了一截。但又遇到第二个小坑:
第二个坑:脚本里找不到 mvnd
非交互 shell 里 command -v mvnd找不到——sdkman 装的 mvnd 没进入非交互 shell 的 PATH。脚本静默回退到了 mvn。
command -v mvnd >/dev/null 2>&1 || \ { [ -s "$HOME/.sdkman/bin/sdkman-init.sh" ] && source "$HOME/.sdkman/bin/sdkman-init.sh"; }这个小坑不算什么。真正的灾难,是接下来的构建缓存。
一次很诱人的尝试
我在 Maven 官方文档里发现了 maven-build-cache-extension。承诺很美:缓存任务输出,相同输入直接复用。
<extension> <groupId>org.apache.maven.extensions</groupId> <artifactId>maven-build-cache-extension</artifactId> <version>1.2.0</version></extension>实测结果很漂亮:
37s → 0.18s,差不多 200 倍提升。
那一刻我以为,故事结束了。
但接下来的五天里,这个看起来无害的 1.2.0 版本不断暴露边界条件。问题不是"缓存没用",而是它并不适合我当时混用的开发流程。
四、缓存的七宗罪:被假命中欺骗的五天
接下来的几天,我经历了职业生涯里最离奇的一次"假错误—假成功—假修复"循环。
让我先把"缓存的演化路径"画给你看——你可以提前感受一下我那五天的心情曲线:
第三宗罪:诡异的"找不到符号"
最先冒头的是 ./check.sh(提交前检查)跑 clean install时的一堆假错误:
[ERROR] .../IntEnumMapperTest.java: cannot find symbol[ERROR] .../ConvertUtilsTest.java: cannot find symbol[ERROR] No implementation was created for TestMapper ...最诡异的是:这些"找不到的符号"(IntEnum、ConvertUtils),在主代码里都真实存在。
我抓住一个细节做对照实验:关掉缓存单独编译测试 —— BUILD SUCCESS。所以不是仓库本身的问题,是缓存。
继续往下挖,发现了一个核心事实:
构建缓存的 key 只基于源码输入,不区分"只编译(compile)"还是"含测试编译(install)"。
让我们看看这是怎么连环触发的:
日志里的"凶器"清清楚楚:
# run 写入: Saved Build ... server-base/dc06748e15365105# check 复用: restoring server-base from cache by checksum dc06748e15365105同一个 checksum,两份完全不同的构建意图。
第四宗罪:reconciliation 也救不了
Maven 缓存有个看似为此场景量身定做的特性——reconciliation(参数核对)。
<plugin artifactId="maven-compiler-plugin" goal="testCompile"> <reconciles><reconcile propertyName="skip" skipValue="true"/></reconciles></plugin>结果:没拦住,照样复用残缺条目。
挖到这里我才明白:
reconciliation 只能核对缓存条目里执行过的mojo。
run的"只编译"条目里testCompile根本没跑过——没有记录可核对,缓存照常恢复。
我退而求其次,把 dev 路径和 ci 路径的缓存物理隔离开。这一招短暂地有效了。但只是片刻安宁。
第五宗罪:clean 把缓存连根拔起
我把缓存目录放在 target/build-cache下,结果 check的 clean install一上来就把 target/删了。
补救——给 maven-clean 加排除:
<plugin> <artifactId>maven-clean-plugin</artifactId> <configuration> <excludeDefaultDirectories>true</excludeDefaultDirectories> <filesets> <fileset> <directory>${project.build.directory}</directory> <excludes><exclude>build-cache/**</exclude></excludes> </fileset> </filesets> </configuration></plugin>💭 你有没有注意到一个不祥的信号?为了让缓存活下来,我开始改其他插件的行为了。一个加速手段需要让其他工具"配合"才能工作,往往意味着它和项目的核心流程不在同一频道上。
第六宗罪:缓存恢复了,但 jar 不在
这是压垮 dev 路径缓存的最后一根稻草。
dev 缓存填充后,改动 xiaoyu-server(下游)再 compile:
[INFO] Found cached build, restoring com.yuxiaor.ai:server-base ...[ERROR] Could not find artifact com.yuxiaor.ai:server-base:jar:1.0-SNAPSHOT让我用一张图重现这场翻车:
原因:compile-only 的 reactor 构建里,上游模块被缓存恢复(跳过构建、不产出 jar)。下游 xiaoyu-server一旦缓存未命中需要真正编译,就去解析 server-base:jar——但 jar 从未生成。
这意味着缓存在**最常见的开发场景(改下游、上游不变)**下必崩。
第七宗罪:与"根 target 布局"彻底不兼容
我还想把所有模块的产物归一到根 target/,结果缓存直接报:
[ERROR] Blocked an attempt to restore files outside of a project directory: /projects/walter/xiaoyu/target/server-base/server-base-1.0-SNAPSHOT.jar构建缓存有安全校验——只允许把产物恢复到模块自身目录内。根 target 布局让 server-base 产物落到 根/target/server-base/,恢复被拦截。
到这里,我做出了一个艰难的决定:整体移除构建缓存。
教训写在我的笔记本扉页:
build-cache-extension 适合"始终用同一组 goals"的构建(典型 CI)。一旦你有 compile/install、skipTests/不跳 等多种调用方式混用,缓存 key 不区分它们,极易踩坑。多模块 + 注解处理器(Lombok/MapStruct/Hibernate)会进一步放大问题。
但故事还远没结束。我以为移除缓存就回到了正常状态——结果第二天早上,我又看到了那个熟悉的 17.5 秒。
一行代码没改,凭什么还要重编?
五、侦探登场:删了缓存,问题还在
某天早上,我连两次跑 ./run,第二次仍然要 17.5 秒。
我直接调出 -X调试日志,看到了下面这一幕:
workflow-engine被判定"源码已变" → 全量重编 365 文件 → 它的产物"变了" → 下游全部级联重编。
但我没改它一个字。
我盯着这行日志看了五分钟。然后想起一个细节:maven-compiler-plugin 决定一个模块要不要重编,靠的是 stale 扫描——拿源文件去找对应的 class 文件,看哪个新。
mvnd -pl workflow-engine compile -X 2>&1 | grep -i "Stale source"# [INFO] Stale source detected: .../repository/chatsession/SessionState.java只有这一行。SessionState.java是凶手。
我打开这个文件:
$ wc -c SessionState.java0 SessionState.java0 字节。
$ ls target/.../chatsession/ | grep SessionState# 没有 SessionState.class也没有。
那一瞬间所有线索串了起来:
空的
.java文件编译后不产出任何.class。stale 扫描器拿源文件去找对应的 class,永远找不到。永远判定 stale → 整个模块每次全量重编 → 级联整条依赖链。
这个问题麻烦在于,它不会直接报错,也不会留下显眼痕迹,只是让每次构建都默默多花 17 秒。如果不是去翻 -X日志,它很可能会一直留在项目里。
我立刻在全项目搜了一遍:
find . -path "*/src/main/java/*.java" -empty一共两个:
• workflow-engine/.../chatsession/SessionState.java• xiaoyu-server/.../auth/application/interceptor/UPermissionInterceptor.java
后者更隐蔽——真正在用的 UPermissionInterceptor在 auth.config.interceptor包;这个 auth.application.interceptor下的是空孤儿文件,没人引用、没人发现。
我屏住呼吸,把两个文件删了。
效果立竿见影:
| 0.86s | ||
一个删除两个空文件的操作,根治了"每次全量重编"。比任何缓存都管用。
排查口诀我刻在了脑子里:
遇到"无改动还全量重编",先
mvn -X | grep "Stale source detected"找出元凶文件。十有八九是空文件 / package-info / 类名与文件名不一致 / 产不出同名 class 的源文件。
六、AI 时代的工作流:没有 IDE 也能改完即跑
故事到这里其实可以收尾了。但还有一个 2026 年特有的诉求:
团队没有 IDEA,代码大量由 AI 改。希望像 IDEA 那样"保存即编译、自动热重启"。
这听起来有点反常识——开发者居然不开 IDE?但当你看到 GitHub 上 2026 年初51% 的提交是 AI 生成或大幅辅助的数据,这就一点都不奇怪了。Claude Code、Gemini CLI、Aider、Cline 这一类终端 Agent 正在取代部分 IDE 体验。
但 AI 写完代码以后呢?没人按那个"重启服务器"的按钮。
原理
Spring Boot DevTools 监听 target/classes——只要类文件变了就热重启。所以我们只需要一个东西替代"IDE 的保存即编译":文件监听器。
watch 脚本(核心)
#!/bin/bashsource .env 2>/dev/nullcommand -v mvnd >/dev/null 2>&1 || \ { [ -s "$HOME/.sdkman/bin/sdkman-init.sh" ] && source "$HOME/.sdkman/bin/sdkman-init.sh"; }if command -v mvnd >/dev/null 2>&1; then MVN=mvnd; else MVN=mvn; fimapfile -t WATCH_DIRS < <(find . -type d -path "*/src/main" -not -path "*/target/*")recompile() { $MVN -am -pl xiaoyu-server -T 2C \ -Dmaven.test.skip=true -Dspotless.check.skip=true compile -q \ && echo "✓ 编译完成,DevTools 将自动热重启" \ || echo "✗ 编译失败,保持当前实例"}recompilewhile true; do inotifywait -qq -r -e modify,create,delete,move \ --exclude '/(target|\.git)/' "${WATCH_DIRS[@]}" while inotifywait -qq -r -t 1 -e modify,create,delete,move \ --exclude '/(target|\.git)/' "${WATCH_DIRS[@]}"; do :; done recompiledone用法:一个终端跑 ./run(服务 + DevTools),另一个终端跑 ./watch(自动编译)。
实测:编辑源文件 → watch 增量编译 → DevTools 热重启 ~3.3s(对比冷启 ~11s)。
两个小坑
.class | |
while true |
第二个坑——pkill -f "bash ./watch",把循环本身一起杀掉。
七、check 的提速:再次破除"并行万能"的迷信
最后一站是提交前检查 ./check.sh。它跑全部测试,原本 70+ 秒。
我做的第一件事是测速:
合计 39s,其中 `ChatMemoryDoJpaRoundTripTest` 单测就 12.49s(首个 `@SpringBootTest` 加载上下文)。其余 145 个测试都 <0.5s(复用上下文缓存)。
第九个坑:-T 1C在依赖链上无效
我直觉地加了 -T 1C(每核一个线程),总时间几乎没变。
原因:四个模块是线性依赖链——后一个必须等前一个,无法并行。
这是优化里一个反复出现的陷阱:并行不是越多越好,而是要看依赖关系。这类问题,最好先画依赖链,再决定要不要加线程。
真正的提速点:去掉 clean
clean install : 65s增量 install : 38.5s空文件已修复、增量编译可靠,去掉 clean 是最大且最直接的提速(~40%)。但这有一个权衡:增量构建在删除/重命名类时可能残留旧 .class,理论上有"假通过"风险。
最终方案是默认增量 + -c选项:
CLEAN_GOAL=""; [ "$CLEAN_BUILD" = true ] && CLEAN_GOAL="clean"$MVN $CLEAN_GOAL install -Dspotless.check.skip=true -T 1C -q./check.sh | ||
./check.sh -c | ||
./check.sh -s |
八、最终账本
让我们看看,这一周努力的收益究竟来自哪里:
最终速度对比:
./run | ~0.86s | 20× | |
| 自动 ~3.3s | |||
./check.sh | ~43s | ||
| 不需要 |
注意一个反直觉的事实:最受推崇的"构建缓存"贡献为 0。所谓最佳实践,离开了前提条件,很容易变成误导。
九、写给 2026 年的 Java 工程师
故事讲到这里,我想留下五条经验。每一条都对应着我把 mvnd 重启过几十次的那几个夜晚。
第一,先量后调。-T看似该提速,实测对依赖链无效;缓存的首轮数据很好看,后面却暴露出一串边界问题。每一步都用 time+ 日志验证,别凭直觉。这条经验在 AI 编程时代更重要——AI 给的"优化建议"也是"看似该有效",但你的项目结构是独一无二的。
第二,构建缓存有明确边界。它适合 goals 固定的 CI;本地"compile/install + 跳测试/不跳"混用、多模块 + 注解处理器的场景下,缓存 key 的粒度不够,极易产生"假错误/假通过"。
第三,最隐蔽的坑往往最简单。"每次全量重编"的元凶只是两个 0 字节空文件。当所有花哨方案都失败时,回到最朴素的诊断:mvn -X | grep "Stale source detected"。
第四,改项目模型配置后重启 mvnd。build.directory这类改动后,长命守护进程会用旧模型。
第五,DevTools + 文件监听 = 无 IDE 的热重载。这是 2026 年的"保存即跑"。
尾声:AI 写代码,工程师在做什么?
把这周的故事讲完,我突然意识到一件事。
按理说,2026 年的开发者应该越来越"轻"——AI 帮我们写代码、AI 帮我们写测试、AI 帮我们写文档。
但我这一周,没有写一行业务代码。
我做的事是:把构建链路调通、把热重启搭起来、把空文件挖出来、把缓存的坑一个一个填上。这些事 AI 帮不了我,因为它需要的不是知识,而是经验和现场感——亲眼看到日志、亲手做对照实验、亲耳听到 mvnd 重启的声音。
AI 写代码越快,构建的瓶颈就越尖锐;AI 写代码越多,"为什么慢"的诊断就越值钱。这是 AI 时代留给人类工程师的核心位置:做 AI 看不到的那部分工作,搭那座它需要、但搭不出来的舞台。
故事的开头我以为我在解决一个性能问题。故事的结尾我才明白,我其实在搭建一个 AI 时代的开发者工作台。
而这一切,始于两个 0 字节的空文件。

夜雨聆风