目录
一、能编译不等于能上线:错觉是怎么形成的
(一)三道假通行证:语法、编译与全绿的测试
(二)缺陷画像:数量、分布与暴露时机都变了
(三)Java 为什么是重灾区
二、虚构出来的依赖:从幻觉包到幻觉方法
(一)包级幻觉与投毒链条
(二)方法级幻觉:签名合理但不存在
(三)语义级幻觉:存在、能跑、行为不同
(四)存在性验证:把幻觉挡在评审之前
三、标了注解不等于开了事务:AI 最常写错的事务边界
(一)自调用穿透与错层标注
(二)回滚判据:受检异常与被吞掉的异常
(三)事务里的外部副作用:删缓存、发消息与远程调用
四、单线程正确不等于并发正确:三类高频竞态
(一)读—算—写:丢失更新
1、为什么本地与测试都发现不了
2、三种修复与选型判据
(二)单例里的可变状态
(三)复合操作跨过了原子边界
五、一次调用不等于一条SQL:N+1与被延迟的性能账单
(一)N+1 是生成偏好的直接产物
(二)为什么测试环境抓不到
(三)修复路线
1、读路径:把抓取计划写明
1.1、JOIN FETCH 与它的分页陷阱
1.2、EntityGraph 与批量抓取
2、写路径与接口口径
六、没报错不等于没漏洞:安全遗漏的高发位
(一)注入:拼出来的 SQL、命令与路径
(二)缺失的校验与越权
(三)过时的密码学与硬编码凭据
(四)敏感信息的两条泄露通道
(五)为什么模型倾向选不安全的实现
七、把判断工程化:生成之后的五道闸门
(一)第一道闸门:存在性与依赖审计
(二)第二道闸门:架构一致性检查
(三)第三道闸门:重构评审注意力
(四)第四道闸门:测试的独立性与专项化
(五)第五道闸门:SAST、供应链扫描与延迟缺陷度量
八、结语:速度是模型的,正确性是你的
参考资料
干货分享,感谢您的阅读!
一段由 AI 生成的 Java 代码,最危险的时刻往往不是它报错的时候,极有可能是它一切正常的时候:命名规范、注释齐全、编译通过、单测全绿,评审者扫一眼觉得「和我写的差不多」,点下合入按钮——三十天后,生产环境在一个并发高峰里丢了一笔更新,或者在一次促销活动里把数据库连接池拖垮。
多项针对真实代码库的量化研究都指向同一个结论:AI 生成代码的缺陷不是更少了,而是换了形态。它极少犯人类新手那种一眼可见的低级错误,却系统性地在五个位置埋下延迟引爆的地雷:引用根本不存在的依赖与方法、写出不生效的事务边界、生成只在单线程下成立的共享状态操作、把一次查询悄悄放大成一千次,以及在安全敏感的分叉路口稳定地选择不安全的那条路。
这次我们从生成机制出发解释「看起来对」的错觉是如何形成的,逐类拆解五种隐性缺陷的成因、时序与识别特征,最后给出一套把「人的判断」工程化的五道闸门,让评审注意力落在这一代缺陷真正藏身的地方。
一、能编译不等于能上线:错觉是怎么形成的
(一)三道假通行证:语法、编译与全绿的测试
工程师判断一段代码「大概没问题」,依据通常是三件事:读起来通顺、编译器不报错、测试是绿的。这三道关卡对人类代码是有效的统计过滤器——一个连语法都写不对的人,大概率也想不清楚业务逻辑;而一个能把命名、分层、注释都做规范的人,通常已经理解了需求。几十年的评审经验把「表面质量」训练成了「深层质量」的代理指标。
大语言模型恰好击穿了这个代理关系。模型的训练目标是让生成的代码在统计上最像训练语料里的高分样本,而公开语料中的高分样本几乎全部是表面质量优秀的代码。于是模型天然擅长产出规范的命名、完整的 Javadoc、教科书式的分层结构——这些特征与「逻辑是否正确」在模型内部并没有因果绑定。表面质量从人类世界里的可靠信号,变成了 AI 世界里的无效信号,甚至是反向信号:它会诱导评审者放松警惕,把注意力预算花在已经不需要检查的地方。
三道关卡各自放行了什么,值得说清楚。语法通顺只保证「像代码」;编译通过在 Java 里额外保证了类型与符号存在,这比动态语言强得多,但对事务是否生效、锁是否护住了竞态、查询会不会扇出,编译器一无所知;测试全绿的保证则取决于测试是谁写的——当实现与测试出自同一个模型的同一轮生成时,两者共享同一套错误假设,测试验证的只是「实现与自己一致」,而不是「实现与需求一致」。三道关卡全部通过的代码,仍然可能在语义层完全错误,这就是「看起来对、实际上错」的结构性来源。
(二)缺陷画像:数量、分布与暴露时机都变了
这种结构性错位在数据上有清晰的投影。一项针对数十万个开源 Pull Request 的对比分析给出了缺陷密度:AI 深度参与的提交平均每个 PR 带出约 10.8 个问题,人类独立完成的约 6.5 个,密度是 1.7 倍。更关键的是分布的迁移:逻辑类错误比人类基线高出约 75%,性能类问题的出现频率接近 8 倍,安全类缺陷高出 1.5 到 2 倍;而人类常犯的拼写、格式、遗漏空判等浅层问题,AI 反而更少。缺陷没有消失,它们从「浅而多」变成了「深而密」。
安全维度的量化更加直接。一家应用安全厂商在 2025 年发布的生成式 AI 代码安全评测中,让一百多个大模型完成 80 个带有安全分叉的编码任务——每个任务都同时存在安全写法与不安全写法,且不在提示词里给出任何安全要求。结果是 45% 的补全选择了不安全实现,落入 OWASP Top 10 的已知漏洞类别;针对跨站脚本的防护缺失率达 86%,日志注入达 88%。研究还发现两个更冷的事实:模型参数规模越大并不会显著更安全,说明这不是「等下一代模型」能自然解决的规模问题;按发布时间排列,语法正确率在持续上升,安全通过率却几乎是一条水平线——模型在飞快地变得「更像会写代码」,却没有同步变得「更会写安全的代码」。
暴露时机同样变了。浅层缺陷在评审桌上或上线当天就会现形,而这一代缺陷的典型引爆窗口是合入之后的 30 到 90 天:并发缺陷等一个流量高峰,N+1 等数据量增长十倍,幻觉包等一次依赖投毒,事务缺陷等一次异常路径被真正走到。等它爆炸时,提交记录已经翻页,排查者甚至想不起这段代码是生成的。
(三)Java 为什么是重灾区
在上述安全评测覆盖的四种语言里,Java 的成绩显著垫底:安全失败率超过 70%,而 Python、JavaScript、C# 大致在 38% 到 45% 之间。这不是巧合,而是由 Java 语料与框架形态共同决定的。
第一个原因是语料的年代结构。Java 有近三十年的公开代码沉淀,其中大量样本来自安全最佳实践成型之前的年代:拼接 SQL 的 JDBC 教程、使用 MD5 做口令散列的示例、javax时代的 Servlet 代码。模型按统计频率学习,老旧写法在语料中的绝对数量优势,会直接转化为生成时的采样倾向。同类问题在版本断层上同样明显:Spring Boot 2 与 3 之间的javax到jakarta迁移、废弃又更替的安全配置风格(如WebSecurityConfigurerAdapter的移除),都让模型频繁产出「在旧版本里正确、在你的版本里不存在或不生效」的代码。
第二个原因是框架魔法把正确性移出了代码文本。现代 Java 工程的关键语义大量寄生在注解、代理与约定之上:@Transactional是否生效取决于调用是否穿过代理,懒加载集合何时发查询取决于会话边界,Bean 的线程安全性取决于默认单例作用域。这些语义在代码文本上没有任何可见痕迹,而模型恰恰只对文本建模——它能写出注解,却不掌握注解背后的运行时机制。人类新手也会犯这些错误,但人类会在第一次被烫伤后建立机制性理解;模型没有被烫伤的记忆,同一类错误会以稳定的概率在每一次生成中重现。
第三个原因是维护性信号的整体滑坡放大了上述问题。对两亿多行代码变更的纵向研究显示,自 AI 辅助编程普及以来,指示重构活动的「移动代码」占比从 2021 年的约 25% 一路跌到 2026 年上半年的 3.8%,复制粘贴行占比则从 8% 出头攀升到 15.7%,五行以上重复代码块的出现率两年上涨 81%,掩盖错误的构造(如空 catch、宽泛兜底)上涨 47%。重复块意味着同一个隐性缺陷会被复制到多处,修复时漏掉任何一个副本,缺陷就还活着。
二、虚构出来的依赖:从幻觉包到幻觉方法
五类缺陷里最具「AI 特色」的一类,是模型凭空虚构出根本不存在的东西。它有三个层级:包级、方法级、语义级,暴露时机依次推后,危险程度依次上升。
(一)包级幻觉与投毒链条
包级幻觉指模型在依赖声明或导入语句里引用了中央仓库中不存在的构件。USENIX Security 2025 上的一项系统性研究用 16 个主流模型生成了 57.6 万份代码样本,统计其中 223 万次包引用,发现 19.7% 指向不存在的包,去重后得到 205,474 个独一无二的幻觉包名;商业闭源模型的幻觉率约 5.2%,开源模型平均高达 21.7%。
单看「装不上」,这只是构建失败的小麻烦。真正的风险在于幻觉的可复现性:约 45% 的幻觉包名在同一提示词的重复查询中每一次都出现,约六成会在十次查询内至少复现一次。稳定复现意味着可预测,可预测意味着可埋伏——攻击者只需用同样的提示词探出模型的高频幻觉名单,抢先在中央仓库注册这些名字并塞入恶意负载,等待全球开发者把模型给出的依赖坐标复制进构建文件。这类攻击已经有了专名「slopsquatting」,它与传统抢注攻击(typosquatting)的区别在于:受害者不需要手滑打错字,模型会替所有人统一地「打错」同一个字。Java 生态的 Maven 坐标因为带有 groupId 命名空间,攻击面小于 npm 与 PyPI,但通过示例代码扩散的伪造坐标、以及内部仓库代理未做白名单时的穿透拉取,仍然是现实通路。
(二)方法级幻觉:签名合理但不存在
方法级幻觉是 Java 开发者日常遇到最多的形态:类是真的,方法是编的。模型会写出StringUtils.truncateMiddle(...)、LocalDate.plusWorkdays(...)这类「按理说应该有」的调用——签名风格与真实 API 完全一致,语义命名无懈可击,唯独类库作者从来没有实现过它。FSE 2025 上针对 API 幻觉的研究把这类问题归纳为两种形式:调用不存在的 API,以及误用存在的 API;并指出通用的检索增强手段缓解有限,因为它不理解工程内部的依赖结构,也不真正校验生成的调用是否可解析——该研究提出的依赖感知约束解码方案,能把宏观幻觉率相对检索增强基线再降七成以上,侧面说明幻觉的根源是解码时缺少「符号必须存在」的硬约束。
对 Java 工程师而言,方法级幻觉有一个温和的属性:静态类型与编译器是天然的存在性验证器,绝大多数编出来的方法活不过mvn compile。真正需要警惕的是两个变体。其一是版本错位——方法在新版本里真实存在,你的工程锁在旧版本上,模型按新版本作答;或者反过来,方法已在你升级后的版本中移除。其二是幻觉的连锁修复:开发者把编译错误贴回给模型,模型不承认方法不存在,反而虚构一个「需要额外引入的依赖」来提供这个方法——方法级幻觉就此升级成包级幻觉,恰好接上了前一节的投毒链条。
(三)语义级幻觉:存在、能跑、行为不同
最危险的层级是语义级:API 真实存在,调用编译通过,运行不抛异常,但行为与模型(以及被模型说服的你)以为的不同。Java 里的经典样本俯拾皆是:用BigDecimal.equals比较金额,它同时比较数值与标度,0.10与0.100被判不等,而业务语义要的是compareTo;用Optional.get取值而不判存在,把空指针异常换了个名字继续抛;对Map.computeIfAbsent的返回值做二次覆盖写,破坏了它的原子语义;把Files.walk的返回流不加 try-with-resources 直接消费,泄漏文件句柄;用SimpleDateFormat的静态实例跨线程共享——方法都存在,每一个都能跑,每一个都埋着与预期不同的行为。
语义级幻觉之所以难防,是因为它的证伪成本最高:包级幻觉在构建期暴露,方法级在编译期暴露,语义级只在特定输入、特定并发、特定数据形态下暴露。评审者面对一行「存在且常见」的 API 调用,几乎不会去质疑它的边界语义——而这正是模型「误用存在的 API」这一幻觉形式的藏身之处。
(四)存在性验证:把幻觉挡在评审之前
对付虚构,答案不是「更仔细地读代码」,而是把存在性验证自动化,让人眼根本不用参与这一层。可执行的做法有四条。第一,编译与解析前置:任何生成代码在进入评审前必须先通过完整编译,Java 工程师应当把这条视为零成本的天然优势,禁止「先提交再修编译」的工作流。第二,依赖白名单与仓库代理:内部 Maven 仓库只允许拉取经过审计的构件坐标,新依赖的引入走独立审批,从通路上封死幻觉包与投毒包;配合依赖锁定与校验和验证,让「构建文件里多出一个陌生坐标」成为可机器检测的事件。第三,新增依赖的元数据审计:对模型建议的任何新依赖,检查其发布历史、维护者、下载量与源码仓库的真实性——刚注册两周、无源码链接、下载量异常的构件坐标,无论模型说得多么笃定,都按可疑处理。第四,版本对齐的上下文供给:把工程的构建文件、依赖清单与目标版本喂进生成上下文,让模型在你的版本坐标系里作答,从源头压低版本错位型幻觉的发生率。
三、标了注解不等于开了事务:AI 最常写错的事务边界
如果说幻觉类缺陷至少还有编译器兜底,事务类缺陷则完全落在编译器的盲区里:@Transactional写与不写、写在哪里、怎么写,编译产物几乎没有差别,差别全在运行时的代理链路上。这恰好是「只对文本建模」的生成器最系统性的盲区。
(一)自调用穿透与错层标注
Spring 声明式事务的实现基础是动态代理:容器把带注解的 Bean 包一层代理对象,事务的开启与提交发生在代理的环绕逻辑里。这个机制决定了一条铁律——只有穿过代理的调用才有事务。模型生成的典型错误是自调用:
@Service public class OrderService { public void handleBatch(List<OrderRequest> requests) { for (OrderRequest request : requests) { // 同类内直接调用,不经过代理,@Transactional 完全不生效 this.saveOrder(request); } } @Transactional public void saveOrder(OrderRequest request) { // 这里的写库操作实际运行在无事务状态 } }this.saveOrder(...)是对目标对象自身的普通方法调用,代理没有机会介入,事务注解形同虚设。同族错误还包括:把注解标在非 public 方法上(默认代理策略下同样不生效)、标在没有被 Spring 管理的类上、以及在@PostConstruct里调用事务方法。这些写法有一个共同点:代码文本看起来与正确版本几乎一致,差别只在调用拓扑——而调用拓扑正是逐 Token 生成的模型最难保持全局一致的维度。错层标注是另一个高频形态:把@Transactional标到 Controller 上,让事务边界裹住参数解析与序列化;或者标到 Repository 的单条方法上,让本应原子的多表写入被拆成若干个独立小事务,每一个都能提交,合起来却不原子。
(二)回滚判据:受检异常与被吞掉的异常
第二类事务缺陷关于「什么情况下回滚」。Spring 的默认回滚判据是运行时异常与 Error,受检异常默认不回滚——这是写进框架文档的行为约定,但在训练语料的海量示例里极少被强调。模型生成的代码常常在事务方法里抛出或传播受检的业务异常,默认配置下事务照常提交,形成「半程写入」:库存扣了、订单没落、异常也抛了,三件事同时成立。修复只需要一行@Transactional(rollbackFor = Exception.class),但前提是有人知道该问这个问题。
更隐蔽的形态是异常被吞。模型对「健壮性」的统计学理解是 try-catch 包裹加日志记录,于是它频繁产出这样的结构:事务方法内部 catch 住下游抛出的异常、打一行日志、继续往下走。异常没有穿出事务边界,代理层面看到的是方法正常返回,事务顺利提交——被 catch 住的那半截失败操作连同后续的成功操作一起落库。前文提到的维护性研究测得「掩盖错误的构造」两年上涨 47%,与这类生成偏好互为印证。识别特征非常明确:凡是事务方法体内出现「catch 后不重新抛出」的分支,评审时必须逐个确认这是有意的业务决策而不是模型的礼貌性包装。
(三)事务里的外部副作用:删缓存、发消息与远程调用
第三类缺陷最难被测试发现:把不受事务管辖的外部副作用写进事务边界之内。数据库写入可以回滚,但删缓存、发消息、调远程接口都是覆水难收的即时动作,模型把它们与写库语句并排放在同一个事务方法里,就制造了两种时序错位。
第一种是「副作用先于提交生效」。一个真实的线上案例形态是:事务方法内先更新设备状态表、再删除对应的 Redis 缓存,方法返回后事务才提交。在「删缓存」与「提交」之间存在一个毫秒级窗口,任何并发读请求在此窗口内命中缓存缺失、回源数据库读到未提交前的旧值、并把旧值重新写回缓存——缓存从此长期停留在旧状态,且不会自愈,表现为「设备明明在线、查出来却离线」的偶发幻象。第二种是「副作用在回滚后残留」。事务内发出的消息、发起的远程扣款,在事务随后回滚时不会被召回,下游拿着一个「本地从未成立」的事实继续推进各自的流程。
这两种错位的共同修复方向是把副作用移出事务边界并挂到提交时点之后:用事务同步回调(TransactionSynchronizationManager注册 afterCommit 动作)或@TransactionalEventListener(phase = AFTER_COMMIT)让删缓存与发消息发生在提交确认之后;对可靠性要求更高的场景,用事务性发件箱把「要发出的副作用」作为一行数据与业务写入同事务落库,由独立的投递进程保证最终送达。评审时的识别信号同样清晰:事务方法体内出现任何 Redis、MQ、HTTP、RPC 客户端调用,一律标黄追问时序。此外还有一笔容量账——事务内的远程调用会让数据库连接被持有整个远程往返时长,一旦下游变慢,连接池会先于任何业务告警被拖空。
四、单线程正确不等于并发正确:三类高频竞态
在一份为期三十天的 AI 辅助开发实测记录里,效率提升了约 65%,线上缺陷却涨到 11 个,其中 7 个是并发安全问题——全部属于「本地怎么测都是对的、一上并发就出错」的类型。并发是这一代生成缺陷里密度最高的品类,因为模型生成的每一段代码在单线程叙事里都是自洽的,而竞态恰恰只存在于多个执行流的交错之间,不存在于任何单一文本序列之内。
(一)读—算—写:丢失更新
最典型的形态是把一次应当原子的状态变更拆成「读出来、算一下、写回去」三步:
@Transactional public void deductPoints(long userId, int points) { UserAccount account = accountMapper.selectById(userId); // 读:100 int remaining = account.getPoints() - points; // 算:100 - 10 = 90 if (remaining < 0) { throw new InsufficientPointsException(userId); } account.setPoints(remaining); accountMapper.updateById(account); // 写:90 }两个并发请求同时读到 100,各自算出 90,先后写回 90——两次扣减只生效了一次。值得强调的是,方法上的@Transactional在这里毫无帮助:MySQL 默认的可重复读隔离级别下,普通 SELECT 走快照读不加锁,两个事务互不阻塞地各读各的快照,UPDATE 时也不会检查数据是否已被他人修改。事务保证的是「我这几步要么全成、要么全不成」,从不保证「别人不能插在我两步之间」。
1、为什么本地与测试都发现不了
丢失更新对常规验证手段全维度免疫:单元测试是单线程的,集成测试的请求是串行的,本地手工点击更不可能凑出毫秒级的交错。它的触发需要两个请求在「读」与「写」之间的几毫秒窗口内相遇,只有生产环境的真实并发流量能稳定供应这种相遇。这解释了此类缺陷 30 到 90 天的引爆延迟——它在等一次促销、一次批量任务、一次流量高峰。要在上线前抓住它,必须使用专门武器:以并发压测复现竞态窗口,或用CountDownLatch在测试里人为制造同时进入的两个线程,把「相遇」从概率事件变成必然事件。
2、三种修复与选型判据
修复丢失更新有三条标准路径,选型取决于冲突频率。其一是原子化更新,把「读算写」压成一条带条件的 UPDATE(SET points = points - #{points} WHERE id = #{id} AND points >= #{points}),由数据库的行锁保证原子性,再检查受影响行数判定成败——绝大多数计数类场景的首选。其二是乐观锁,表上加版本号列,更新时校验版本并递增,冲突则重试;不阻塞任何人,但冲突率升高时重试会放大延迟,需要配重试率监控。其三是悲观锁,读取时用SELECT ... FOR UPDATE直接锁行,把并发请求串行化;语义最简单,代价是持锁期间的排队与死锁风险。经验判据:不确定就先上乐观锁并监控重试率,重试率持续超过百分之五再评估悲观锁;跨进程、跨资源的互斥才需要升级到分布式锁。
(二)单例里的可变状态
Spring 容器里的 Bean 默认是单例,一个@Service实例同时服务所有请求线程——这条约定意味着 Bean 的实例字段就是共享内存,任何非 final 的可变字段都是竞态候选。模型对这条约定没有稳定的执行力,它会自然地生成「把中间结果存到成员变量里」的代码:本次请求的用户上下文、循环里的累加器、可变的缓存 Map。单线程视角下这些代码干净利落,多线程视角下所有请求在同一块内存上互相覆写。经典变体包括:把非线程安全的SimpleDateFormat提成静态常量共享(并发解析会产出错乱的日期而非异常)、用HashMap做进程内缓存并发读写、在字段上维护count++这类非原子自增。识别方法可以完全规则化:单例 Bean 里出现任何可变实例字段即触发告警,要么改为方法内局部变量,要么换用DateTimeFormatter、ConcurrentHashMap、AtomicLong等线程安全等价物,要么给出明确的并发控制说明。这条规则简单到可以交给静态扫描,没有理由留给人眼。
(三)复合操作跨过了原子边界
第三类竞态更微妙:每一个单步操作都线程安全,组合起来却不是。模型知道「并发场景用 ConcurrentHashMap」,于是写出if (!map.containsKey(key)) { map.put(key, create(key)); }——containsKey 与 put 各自原子,但两步之间的窗口足以让另一个线程完成同样的判断并插入,先检查后执行的复合逻辑在原子边界之外裸奔。正确写法是把复合意图交给容器的原子复合原语:computeIfAbsent、putIfAbsent、merge。同构的错误还有:双重检查锁定漏掉 volatile、check-then-act式的余额判断与扣减分离、以及对并发容器做「先 size 后遍历」的一致性假设。
线程池与异步是这类问题的放大器。模型偏爱Executors.newFixedThreadPool这类工厂方法,其内部使用无界队列,请求堆积时先耗尽堆内存;给方法标@Async却不指定执行器时,旧版本 Spring 的默认行为是每个任务新建一个线程,等价于无界并发;在 Servlet 容器里使用parallelStream,则让所有请求共享同一个公共 ForkJoin 池,一个慢任务能拖住全站的并行流。这些写法与本系列讨论过的线程池标定纪律正面冲突,也是多数团队编码规范里的红线——但模型不读你的规范,除非你把规范喂给它、并用静态检查兜底。
五、一次调用不等于一条SQL:N+1与被延迟的性能账单
前文提到,AI 参与代码的性能类问题出现频率接近人类基线的 8 倍——在 Java 工程里,这个数字的最大单一来源就是数据访问层的查询扇出。
(一)N+1 是生成偏好的直接产物
N+1 查询的形态很简单:先用一条查询取出 N 条主记录,再在遍历中为每条主记录发起一次关联查询,总查询数是 N+1 条。模型高频产出这个模式,有两个机制性原因。其一,逐条处理是语料里的主导叙事:「查列表、循环、对每个元素做点什么」是教学代码的标准骨架,for循环里调用findById在文本上完全自然。其二,ORM 把查询隐藏进了属性访问:JPA 懒加载下,order.getItems()在文本上只是一次 getter 调用,模型不可能从文本形态上察觉这行代码会发一条 SQL——它对「这段代码的数据库往返次数」没有任何建模。于是两种典型形态反复出现:显式的循环内查询(循环体里调用 Repository 方法),以及隐式的懒加载遍历(循环体里访问懒加载关联属性)。更糟的搭配是模型为了「省事」把关联标成FetchType.EAGER,让每一次主实体查询都无条件拖出全部关联,N+1 变成了「永远 +N」。
(二)为什么测试环境抓不到
N+1 是典型的数据量敏感缺陷:测试库里 10 条订单,N+1 意味着 11 条查询,响应几十毫秒,任何功能测试都会放行;生产库里 10 万条订单配上翻页遗漏,同一段代码变成十万零一条查询,配合连接池上限,表现形态不是「这个接口变慢」,而是连接池耗尽后的全站雪崩。功能正确性与数据规模无关,性能却与数据规模成正比——这正是「测试全绿、生产爆炸」在数据访问层的标准剧本。识别手段必须口径化:在集成测试里给数据访问层套上 SQL 统计(datasource-proxy 或 p6spy),对关键接口断言「本次调用的 SQL 条数不随数据量增长」,把 N+1 从肉眼审查问题变成可自动回归的量化指标;同时在测试数据集上刻意放入两个数量级的规模差,让扇出型缺陷在 CI 里就露出增长曲线。
(三)修复路线
1、读路径:把抓取计划写明
读路径的原则是把「取哪些关联」从隐式的属性访问升格为显式的抓取声明,让一次业务调用对应一个可预算的查询计划。
1.1、JOIN FETCH 与它的分页陷阱
JOIN FETCH把主实体与关联在一条 SQL 里取回,是消灭 N+1 的第一选择。但它带着一个模型几乎从不提示的陷阱:对集合关联做 fetch join 时行会被展开,若再叠加分页,Hibernate 无法在 SQL 层正确切页,只能把全量结果拉进内存再分页——日志里那行 HHH000104 警告就是信号。数据量一大,消灭 N+1 的修复本身变成内存事故。集合关联加分页的场景应改为两段式查询:先分页取主实体标识,再按标识集合批量抓取关联。
1.2、EntityGraph 与批量抓取
@EntityGraph允许在不改写 JPQL 的前提下按调用场景声明抓取计划,同一个实体在列表页与详情页可以使用不同的图;@BatchSize或全局的批量抓取配置则把懒加载的逐条查询合并成按标识集合的 IN 查询,将 N+1 压成 N/批大小 + 1。两者都保留懒加载的默认安全性,只在需要的调用点展开——比全局 EAGER 的一刀切健康得多。
2、写路径与接口口径
写路径上的同构问题是循环内逐条save:每条记录一次往返,一千条记录一千次往返。修复是批量化——JDBC 批处理参数配合 ORM 的批量写入配置,或直接使用批量插入语句。接口口径上则要封死无界返回:任何返回列表的端点必须强制分页参数与最大页宽,「先查全表再在内存里过滤」的生成偏好要靠评审清单点名拦截。这些修复都不难,难的是意识到需要修复——所以答案仍然是把 SQL 条数与往返次数变成 CI 里的显式断言,而不是依赖某位评审者恰好今天状态好。
六、没报错不等于没漏洞:安全遗漏的高发位
安全缺陷与前四类的区别在于:它不影响功能,正常用户的正常操作永远不会触发它,唯一的「测试用例」是攻击者。这决定了它在「测试全绿」的流水线上畅通无阻。针对企业真实代码库的实证分析显示,AI 引入的漏洞高度集中于少数场景——输入校验与数据处理不当、不安全的 API 调用位居前列;且严重度分布与人类开发者引入的漏洞基本一致,并不存在「AI 的漏洞都是小问题」的降级效应,反而在 API 接口与网络协议相关代码上更加集中,放大了远程可触达的攻击面。
(一)注入:拼出来的 SQL、命令与路径
模型在字符串拼接与参数绑定之间的选择是概率性的:语料里两种写法都海量存在,于是"WHERE name = '" + userInput + "'"会以稳定的频率出现在生成结果里,无论是 JdbcTemplate 的参数、MyBatis 映射里本应用#{}却写成${}的占位,还是@Query里的拼接。同族问题还有把用户输入直接送进Runtime.exec的命令注入、送进new File(userInput)的路径遍历,以及前面数据里缺失率高达 88% 的日志注入——把未清洗的外部输入原样写进日志,攻击者可以伪造日志行甚至污染下游的日志分析链路。识别规则可以收敛成一句话:凡是外部输入与「解释执行的字符串」(SQL、命令、路径、表达式、日志格式)发生拼接的位置,一律按漏洞处理,无论拼接看起来多么无害。
(二)缺失的校验与越权
第二个高发位是边界校验的系统性缺失。模型默认生成「信任输入」的代码:@RequestBody后面的对象没有@Valid,DTO 上没有任何约束注解,数值参数不检查区间,集合参数不限制大小。比字段校验更危险的是对象级越权的缺失:接口拿到orderId就直接查询返回,从不校验「这条订单是否属于当前登录用户」——水平越权(IDOR)在生成代码里的缺席率极高,因为「校验资源归属」这个动作依赖具体系统的租户与权限模型,而这些上下文根本不在模型的输入里。它不可能生成它不知道的检查。评审时对任何按标识取资源的端点,第一问永远是「归属校验在哪一行」。
(三)过时的密码学与硬编码凭据
语料年代结构在密码学上的投影最为直接:MD5 与 SHA-1 做口令散列、ECB 模式的对称加密、固定的初始化向量、自己拼随机数当令牌——这些在旧教程里铺天盖地的写法,会按训练时的统计权重持续流入生成结果。与之伴生的是硬编码凭据:示例代码为了「能跑」总是把密钥、口令、令牌写成字符串字面量,模型忠实地继承了这个习惯。两条规则可以机器化执行:密码学原语只允许从团队封装的安全工具库引入,生成代码里出现任何直接的底层加密 API 调用即拦截评审;凭据一律走凭据管理服务,源码与配置文件中的任何高熵字符串常量由扫描器标记。
(四)敏感信息的两条泄露通道
第一条通道是日志:模型生成的调试日志倾向于把整个对象打出来,手机号、身份证号、银行卡号随toString明文落盘,进入日志采集链路后扩散到所有能查日志的人。第二条通道是响应:直接把 JPA 实体序列化返回,实体上有什么字段就泄露什么字段——口令散列、内部状态、软删除标记全部出境,且实体后续每加一个字段,接口就自动多泄露一个。修复口径分别是脱敏与投影:日志层面用统一的脱敏工具处理敏感字段,出参层面强制 DTO 或投影、给敏感字段挂脱敏注解,并把「接口返回类型不允许是实体类」写进架构约束由 ArchUnit 强制执行。
(五)为什么模型倾向选不安全的实现
把高发位连起来看,模型的不安全倾向有三个稳定成因。第一是语料偏置:教学示例为了聚焦主题总是省略安全处理,「最小可运行」的样本在语料里远多于「生产级防护」的样本,采样自然偏向前者。第二是目标错位:提示词说「实现一个查询接口」,模型的优化目标就是「实现查询」,安全约束不在目标函数里——前述评测已经证明,不给安全提示时 45% 的补全选择不安全路径,而这恰恰是「氛围编程」的默认工作方式。第三是上下文缺失:越权校验、脱敏规则、凭据管理都依赖组织特定的安全设施,模型没见过你的安全基建,只能按公开世界的平均水平作答。三个成因没有一个能靠「换个更大的模型」解决,它们都指向同一个方向:安全要求必须显式进入生成上下文,并且在生成之后有独立的验证闸门。
七、把判断工程化:生成之后的五道闸门
五类缺陷讲完,可以回答开篇的问题了:AI 生成的 Java 代码之所以「看起来对、实际上错」,是因为它把缺陷从人眼擅长的表面层,系统性地转移到了人眼不擅长的语义层、时序层与规模层。相应的对策也就不是「更仔细地看」,而是重建验证流水线——让机器验证机器擅长骗过人的部分,让人的判断集中在机器给不出答案的部分。落地形态是合入前的五道闸门。
(一)第一道闸门:存在性与依赖审计
拦截目标是第二章的三层幻觉。全量编译作为最低门槛拦下方法级幻觉;依赖白名单、内部仓库代理与锁定校验拦下包级幻觉与投毒链;新依赖引入走独立的元数据审计流程。这道闸门完全自动化,不消耗任何评审注意力,却能把「虚构」这一整类缺陷清零在流水线最前端。
(二)第二道闸门:架构一致性检查
拦截目标是模型「不知道你已经做过的决定」。模型没有工程的架构记忆,它会在已有统一 HTTP 客户端的工程里引入第二个客户端,在已有全局异常处理的工程里写局部的 try-catch 兜底,在约定了 DTO 出参的工程里直接返回实体。这些不一致单看每一处都「能用」,累积起来就是同一件事有三种做法的失控代码库。对策是把架构决策写成可执行规则:ArchUnit 断言分层依赖方向、注解使用位置(例如@Transactional只允许出现在服务层)、禁用 API 清单(例如Executors工厂方法、共享的SimpleDateFormat);配合把团队编码规范作为规则文件喂进生成工具的上下文,让一致性同时作用于生成前与生成后。
(三)第三道闸门:重构评审注意力
拦截目标是逻辑、事务与并发这些必须由人判断的缺陷。评审清单需要按新的缺陷分布重写:不再逐行检查命名与格式(交给格式化器与静态检查),而是对五个高危位定点提问——事务方法内有没有自调用、有没有 catch 后不抛、有没有外部副作用;单例 Bean 里有没有可变字段;先检查后执行的逻辑有没有原子化;循环里有没有查询或远程调用;按标识取资源的端点有没有归属校验。同时增加一个此前不存在的评审维度:来源标注。生成密集的变更需要作者自我声明「哪些部分是生成的、我验证了什么」,让评审者知道该把深度注意力投向哪里——对人类代码有效的「作者可信度」启发式,对生成代码必须替换为「验证记录可信度」。
(四)第四道闸门:测试的独立性与专项化
拦截目标是「实现与测试共享同一套错误假设」。第一原则是测试独立性:验收关键路径的测试不允许与实现出自同一轮生成,至少要有人类基于需求(而不是基于实现)写出的对抗性用例,专门打模型想不到的边界。第二原则是专项化:并发缺陷用并发测试打(多线程同时进入的竞态复现、压测下的不变量断言),性能缺陷用规模测试打(SQL 条数断言、两个数量级的数据集对照),契约缺陷用属性测试与变异测试评估测试本身的强度。功能测试全绿只证明了叙事自洽,专项测试才碰得到时序与规模。
(五)第五道闸门:SAST、供应链扫描与延迟缺陷度量
拦截目标是安全遗漏与「合入之后」的长尾。静态应用安全测试与依赖漏洞扫描进 CI 是底线配置,对生成密集的代码库尤其如此——前述评测同时表明,主流模型对多数注入类缺陷的引入是稳定复现的,恰好是规则型扫描器最擅长的目标。度量层面则要为这一代缺陷建立新指标:按「变更是否 AI 深度参与」分桶统计合入后 30、60、90 天的缺陷回溯率,跟踪重复代码块与错误掩盖构造的趋势线。没有这些数字,团队对「AI 让我们更快了还是更危险了」的判断只能靠感觉——而感觉恰恰是被表面质量欺骗得最彻底的东西。
八、结语:速度是模型的,正确性是你的
回到标题的问题:AI 生成的 Java 代码为什么看起来对、实际上错?因为「看起来对」是模型的优化目标,而「实际上对」从来不是。模型在数十亿行代码上学会了正确代码的一切外观特征,却没有学会外观之下的存在性约束、代理机制、内存模型、查询代价与威胁模型——于是它产出的代码在表面层无可挑剔,在语义层按语料的统计分布掷骰子。虚构的依赖、失效的事务、交错才会现形的竞态、随数据量放大的查询扇出、等待攻击者的安全缺口,五类缺陷共享同一个签名:功能叙事完整,工程语义残缺。
这不构成拒绝生成式工具的理由——效率增益是真实的,缺陷分布的迁移也是真实的,两者同时成立。真正的结论是分工的重构:把表面质量的检查彻底交还给机器,把存在性、一致性、安全性的验证固化成流水线闸门,把人的判断力集中投放到事务边界、并发交错、资源归属这些需要理解「这个系统为什么长这样」的位置上。生成的速度属于模型,合入的签名属于你——签下去之前,值得对照五类缺陷问一遍:这段看起来很对的代码,经受住哪几道闸门的验证了?
参考资料
- 2025 GenAI Code Security Report:我们让 100+ 个模型写代码,多少没通过安全测试(Veracode 官方解读)
- GenAI Code Security Report 2025 年 10 月更新(完整报告 PDF,含 Java 失败率与语言分层数据)
- We Have a Package for You! 代码生成大模型包幻觉的系统性分析(USENIX Security 2025 论文 PDF)
- Package Hallucinations:大模型如何凭空造出漏洞(USENIX ;login: 解读版)
- Towards Mitigating API Hallucination in Code Generated by LLMs with Hierarchical Dependency Aware(FSE 2025,API 幻觉的依赖感知缓解)
- 你的代码审查流程正在针对错误的失败模式进行优化(数十万 PR 的缺陷分布对比分析)
- 当 AI 成为代码贡献者,软件安全正在发生怎样的变化?(腾讯安全应急响应中心实证研究)
- AI 编程 30 天实验复盘:效率提升 65%,线上 bug 涨到 11 个,问题出在哪(一线 Java 工程实测记录)
- AI Copilot Code Quality:2025 年对 2.11 亿行代码变更的质量趋势研究(GitClear)
- The Maintainability Gap:2026 年 AI 代码可维护性研究(GitClear)
- SafeGenBench:面向大模型生成代码的安全漏洞检测基准(arXiv 论文)
- Spring Framework 官方文档:声明式事务与 @Transactional 的代理语义
- Hibernate ORM 官方用户指南:抓取策略与 N+1 问题
- The N+1 query problem 的成因与修复(Vlad Mihalcea)
- OWASP Top 10:Web 应用最关键的安全风险清单