乐于分享
好东西不私藏

AI写代码工程师填坑:我们花一周优化体系,最后发现罪魁祸首是两个空文件

AI写代码工程师填坑:我们花一周优化体系,最后发现罪魁祸首是两个空文件

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 秒

我先不讲技术,先讲一笔账。

58%42%一天 30 次"改一行看效果"——每天的时间分配等编译 + 启动 (35 分钟)真正写/读代码 (25 分钟)

一天 30 次就是 35 分钟纯等待,一年 140 小时——相当于 17 个工作日。

更让人发疯的是后半段:代码改完,要手动 Ctrl+C杀掉服务再重跑。

这本不该是 2026 年应有的体验。在这个年份,AI 工具已经能帮你写出整个模块,但 AI 改完代码以后呢?它不会替你重启服务,不会帮你测试,更不会替你优化构建链路。AI 写得越快,构建越慢的痛苦就越尖锐。

我把目标写在白板上:

像 IDEA 一样,改完即跑、尽量不重编、不手动重启。

然后我花了整整一周。

后来复盘这一周,我先后试了 mvnd、构建缓存、reconciliation、-T 1C并行、根 target 布局。这些办法单独看都很合理,但在这个项目里,有的没收益,有的还制造了新的问题。最后真正把问题解决掉的,反而是两个不起眼的 0 字节空文件

但故事不是这么简单。一切要从一个看似无害的疑问开始:为什么 IDEA 不需要 install?


二、第一波"聪明"优化:让 spring-boot:run 摆脱 install

任何熟悉 Maven 的开发者,第一眼看到上面的脚本都会皱眉:

clean~5s

compile 全量~37s

install~15s

第二次 mvn 启动~3s

spring-boot:run~11s

每一步都有冗余。但要消灭它们,得先回答一个根本问题:

为什么原来需要 install?

mvn spring-boot:run -pl xiaoyu-server用 -pl只把 xiaoyu-server一个模块加载进 reactor。它依赖的 server-base / workflow-engine / app-server只能去 ~/.m2本地仓库翻 jar——所以必须先 install

而 IDEA 完全不需要 install。它是怎么做到的?

✓ IDEA / 一次 reactor

classpath

xiaoyu-server
target/classes
server-base

target/classes

❌ 命令行 mvn -pl

要 jar

先 install

xiaoyu-server

~/.m2 本地仓库

答案是 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>

实测结果很漂亮:

缓存启用前后编译时长 (秒)首次第2次第3次4035302520151050

37s → 0.18s,差不多 200 倍提升。

那一刻我以为,故事结束了。

但接下来的五天里,这个看起来无害的 1.2.0 版本不断暴露边界条件。问题不是"缓存没用",而是它并不适合我当时混用的开发流程。


四、缓存的七宗罪:被假命中欺骗的五天

接下来的几天,我经历了职业生涯里最离奇的一次"假错误—假成功—假修复"循环。

让我先把"缓存的演化路径"画给你看——你可以提前感受一下我那五天的心情曲线:

启用build-cache

命中37s→0.18s

测试编译污染
reconciliation失效
dev/ci物理隔离
clean 删缓存
compile-only解析失败
根 target不兼容

整体移除

第三宗罪:诡异的"找不到符号"

最先冒头的是 ./check.sh(提交前检查)跑 clean install时的一堆假错误:

[ERROR] .../IntEnumMapperTest.java: cannot find symbol[ERROR] .../ConvertUtilsTest.java: cannot find symbol[ERROR] No implementation was created for TestMapper ...

最诡异的是:这些"找不到的符号"(IntEnumConvertUtils),在主代码里都真实存在

我抓住一个细节做对照实验:关掉缓存单独编译测试 —— BUILD SUCCESS。所以不是仓库本身的问题,是缓存。

继续往下挖,发现了一个核心事实:

构建缓存的 key 只基于源码输入,不区分"只编译(compile)"还是"含测试编译(install)"。

让我们看看这是怎么连环触发的:

./runcompile -Dmaven.test.skip=true
缓存写入checksum: dc06748...
条目内容:只编译,无测试
./check.shinstall(含测试)
计算 checksumdc06748...
checksum相同?

复用缓存条目

❌ 测试编译时找不到主类

日志里的"凶器"清清楚楚:

# 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跑过吗?

✓ 可以核对

无从核对

缓存仍命中残缺条目复用

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

让我用一张图重现这场翻车:

xiaoyu-server(下游)缓存server-base(上游)xiaoyu-server(下游)缓存server-base(上游)跳过构建不产 jarBUILD FAILURE命中,恢复 class1缓存未命中真正编译2需要 server-base:jar3❌ jar 不存在4

原因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调试日志,看到了下面这一幕:

级联

级联

server-base✓ Up to date

workflow-engine✗ 源码已变 (?)

app-server✗ 依赖已变

xiaoyu-server✗ 依赖已变

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.java

0 字节。

$ ls target/.../chatsession/ | grep SessionState# 没有 SessionState.class

也没有。

那一瞬间所有线索串了起来:

target/classesSessionState.java(0 字节)maven-compilertarget/classesSessionState.java(0 字节)maven-compiler判定 stale → 全量重编下次又找不到 → 死循环检查源文件 mtime1存在2查找 SessionState.class3不存在4编译空文件5不产生 .class6

空的 .java文件编译后不产出任何 .classstale 扫描器拿源文件去找对应的 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下的是空孤儿文件,没人引用、没人发现。

我屏住呼吸,把两个文件删了。

效果立竿见影:

无改动重编时长 (秒)删除前删除后20181614121086420

场景
删除前
删除后
无改动重编
17.5s
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 的保存即编译":文件监听器

DevToolstarget/classesmvndinotifywaitsrc/main/**.javaAI AgentDevToolstarget/classesmvndinotifywaitsrc/main/**.javaAI Agent修改源文件1文件系统事件2去抖 1s3触发增量编译4写入新 class5类路径变化6重启 ~3.3s7

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)。

两个小坑

现象
原因
纯注释改动不触发重启
DevTools 比对字节码,注释不改变 .class
杀进程杀不干净
while true
循环会重新拉 inotifywait

第二个坑——pkill -f "bash ./watch",把循环本身一起杀掉。


七、check 的提速:再次破除"并行万能"的迷信

最后一站是提交前检查 ./check.sh。它跑全部测试,原本 70+ 秒。

我做的第一件事是测速:

server-base3.2s

workflow-engine9.5s
app-server9.5s

xiaoyu-server16.8s

合计 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
日常增量
~43s
./check.sh -c
求稳全量
~65s
./check.sh -s
只查编译
~15s

八、最终账本

让我们看看,这一周努力的收益究竟来自哪里

50%30%15%5%优化收益的真实来源删除两个空文件去掉 clean免 install 一次 reactorDevTools+watch 工作流

最终速度对比:

操作
优化前
优化后
改善
./run
无改动重编
~17.5s
~0.86s20×
改完代码生效
手动重启
自动 ~3.3s
./check.sh
(含测试)
~70s+
~43s
~40%
是否需要 install
需要
不需要

注意一个反直觉的事实:最受推崇的"构建缓存"贡献为 0。所谓最佳实践,离开了前提条件,很容易变成误导。


九、写给 2026 年的 Java 工程师

故事讲到这里,我想留下五条经验。每一条都对应着我把 mvnd 重启过几十次的那几个夜晚。

1. 先量后调直觉常错

2. 缓存有边界仅 CI 稳定

3. 最简单的坑最隐蔽0 字节空文件

4. mvnd 改配置后重启守护进程缓存模型

5. DevTools+inotify无 IDE 热重载

第一,先量后调。-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 帮我们写文档。

但我这一周,没有写一行业务代码

2026 年

搭工作流70%

决策/审查20%
业务代码10%
2015 年

写业务代码70%

搭工作流30%

我做的事是:把构建链路调通、把热重启搭起来、把空文件挖出来、把缓存的坑一个一个填上。这些事 AI 帮不了我,因为它需要的不是知识,而是经验现场感——亲眼看到日志、亲手做对照实验、亲耳听到 mvnd 重启的声音。

AI 写代码越快,构建的瓶颈就越尖锐;AI 写代码越多,"为什么慢"的诊断就越值钱。这是 AI 时代留给人类工程师的核心位置:做 AI 看不到的那部分工作,搭那座它需要、但搭不出来的舞台。

故事的开头我以为我在解决一个性能问题。故事的结尾我才明白,我其实在搭建一个 AI 时代的开发者工作台。

而这一切,始于两个 0 字节的空文件