《Spring Boot + JPA 实战》第21招 AI写代码也要审SQL
## 01、痛点 凌晨两点,手机报警第三次响起:订单服务 P99 飙到 4.6 秒,数据库 CPU 打满。我打开慢日志,一条看起来普通的订单搜索 SQL 正在对 300 万行主表做全表扫描,还附带 60 多条重复查询。我是老李。这行干了十几年 Java,最近团队把一部分数据访问层交给 AI 生成,效率确实上来了,可这次差点把生产打挂。 问题不是 AI 写得不好,而是我们用审 Java 代码的旧办法,审了一套运行时才会露馅的 JPA 代码。一个复杂订单搜索接口,入参包括订单号、客户昵称、状态、商品 SKU、下单时间范围、金额区间和排序字段。AI 生成的 Entity、Repository、动态查询代码看起来规范,方法命名清晰,映射关系也像那么回事。代码评审时,我们只盯着 Java 类看,没有把生成的 SQL 拉出来认真看一遍,更别提执行计划。 结果就是:测试环境数据量小,一切正常;生产数据量上来后,索引缺失、N+1 查询、事务边界过大这些问题全冒出来。AI 编码越快,这类风险进入生产的速度也越快。过去一个资深工程师一天写三个 Repository,现在三分钟生成十个。代码审查如果还停留在肉眼扫 Java 的阶段,根本拦不住运行时性能事故。 为什么现在要讲这件事?因为数据访问层正在成为 AI 编码的重灾区:生成得快、长得像、跑得慢。我们需要的不是禁用 AI,而是一套专门针对数据访问的评审门禁,把检查范围从代码延伸到 SQL 和执行计划。 ## 02、是什么 我把它总结为六道数据访问门禁:Entity Review、Repository Review、SQL Count、Execution Plan、Index Review、Transaction Boundary。这六道门禁从静态代码一路查到运行时行为,分别回答不同的问题。 第一道,Entity Review。重点看实体映射、关联关系、抓取策略、继承、枚举、ID 生成方式,以及是否存在无意义的大对象映射。常见问题包括把双向关联配成全 EAGER,导致每次查询都拖家带口;或者用数据库默认的字符串映射,把大字段也加载出来。 第二道,Repository Review。重点看仓库方法语义、查询条件拼接、分页与排序、返回值类型、批量操作边界。很多 AI 生成的仓库方法会返回完整实体,而不是投影 DTO;还有一些动态查询在没有条件时直接查全表。 第三道,SQL Count。统计一次业务操作实际触发多少条 SQL。比如一次分页查询,可能先查一次 count,再查一次主列表,然后逐条查明细、客户、商品,形成 N+1。只读代码看不出问题,只有统计 SQL 条数才能暴露。 第四道,Execution Plan。对关键 SQL 看执行计划,确认走没走索引、是否全表扫描、有没有 Using temporary、Using filesort、回表次数是否过高。执行计划会直接展示数据库成本决策。 第五道,Index Review。检查过滤条件、排序字段、关联外键是否被索引覆盖,联合索引是否符合最左前缀原则,是否存在大量冗余索引或缺失的唯一约束。索引问题在 Java 代码里完全无感。 第六道,Transaction Boundary。检查事务边界、传播行为、隔离级别、锁范围与持有时间。一个列表查询如果开启默认事务,可能会无意义地持有快照;一个批量更新如果事务粒度过大,会扩大锁竞争。 这六道门禁有一个共同特点:它们关心的不是代码长什么样,而是代码跑起来什么样。 ## 03、为什么 为什么只做 Java Code Review 已经不够?因为 JPA 这类对象关系映射框架隐藏了 SQL,而 Java 代码只描述映射意图,不描述执行方式。你用眼睛看 findByOrderDateBetweenAndStatus,以为只是一个简单查询;实际上它可能生成一条 select * from orders where order_date between ? and ? and status = ?,但没有索引时就是全表扫描。 更麻烦的是,很多 JPA 性能问题只存在于运行时。N+1 查询、懒加载触发在事务外、分页与多对多关联同时使用、In 条件超过数据库限制、锁扩散、事务过长,这些问题在静态 Java 代码里几乎不可见。代码评审者如果没有 SQL 和执行计划,就像只在图纸上检查建筑,没有去工地看地基和管线。 AI 提升的是代码生成速度,而不是数据访问设计正确性。一个复杂的订单搜索接口,AI 可以飞快生成 Entity、Repository 和查询封装,但它不知道订单表有 300 万行,不知道状态字段选择性低,不知道客户外键上没有索引,也不知道业务高峰期会有多少并发。这些知识需要从数据库统计信息、慢日志和运行监控中获得,而不是从代码生成过程中天然获得。 所以数据访问 Review 必须从代码审查升级为代码 + SQL + 执行计划审查。代码回答想做什么,SQL 回答实际做了什么,执行计划回答数据库会怎么执行。三者合在一起,才能形成完整的数据访问评审证据链。 ## 04、架构 我在团队里落地时,把六道门禁嵌进合并请求评审流程中,形成一条数据访问评审链。大致结构如下: 代码生成层:生成 Entity、Repository、查询封装和服务调用。生成速度很快,但这里不直接合并。 静态检查层:对 Java 代码做 Entity Review 和 Repository Review,检查映射、关联、抓取策略、查询方法、分页排序等。 运行捕获层:在测试或预发环境运行接口,开启 SQL 统计,抓取 SQL Count,并记录每条 SQL 的耗时、参数和执行计划。 数据库分析层:对关键 SQL 做 Execution Plan Review,同时结合表结构、索引和统计信息做 Index Review。 事务分析层:检查事务边界、隔离级别和锁等待,完成 Transaction Boundary Review。 评审决策层:以上证据汇总到 PR 页面或评审报告里。普通数据访问改动需要至少通过静态与运行两层检查;复杂查询 PR 必须附关键 SQL 或执行计划,否则不能合并。 这套架构不复杂,关键是把 SQL 和执行计划变成和 Java 代码同等地位的一等公民。以前我们看 PR 只看 diff,现在还要看 diff 对应的 SQL 和执行计划附件。工具可以自动生成一份数据访问评审摘要,放在 PR 页面上,让评审人一眼看到本次改动新增 4 条 SQL,其中一条全表扫描,建议补索引。 ## 05、实战 拿一个真实场景说明:让 AI 生成一个复杂订单搜索接口。需求是分页查询订单列表,支持订单号、客户昵称、状态、商品 SKU、下单时间范围、金额区间,支持按创建时间和金额排序。AI 很快生成了 OrderEntity、OrderItemEntity、CustomerEntity、ProductEntity,以及对应的 Repository 和动态查询封装。 先做 Entity Review。我发现 OrderEntity 里把 CustomerEntity 和 ProductEntity 都配成了 EAGER,而 OrderItemEntity 虽然配置了 LAZY,但 Service 里在循环中读取了订单明细,最终触发 N+1。另外实体直接暴露了数据库表结构,连内部状态字段也映射出来,导致查询返回列过宽。 再做 Repository Review。动态查询封装在没有传入状态和时间范围时,会生成一条无条件分页查询;排序字段直接拼接到 Sort.by 中,没有校验白名单,有安全风险;分页 count 查询默认使用主查询的关联条件,导致 count 和主查询都重复 join。 然后做 SQL Count。开启 SQL 统计后,一次 20 条订单的分页请求,实际触发了 1 条 count、1 条主列表查询、20 条订单明细查询、20 条客户查询,总共 62 条 SQL。这就是典型的 N+1。 接着看 Execution Plan。主列表查询的 explain 结果显示 type=ALL,key=NULL,Extra 里出现了 Using temporary 和 Using filesort;count 查询扫描了 300 万行。数据库明确告诉我们:这条 SQL 在裸跑。 再检查 Index Review。订单表的 order_date、status 上没有联合索引,客户外键上也没有索引,导致关联查询全表扫描。状态字段单独建索引选择性很低,但和订单日期组合后可以选择性很高。于是我们增加了 idx_orders_date_status 和 idx_orders_customer_id,并把排序字段纳入覆盖索引。 最后看 Transaction Boundary。列表查询开启了默认可重复读事务,在事务里执行了 62 条 SQL 并组装数据。这个事务从第一次查询一直持有到远程日志调用结束,快照保留时间过长,拖累了数据库版本链。批量更新操作还使用了单事务逐条执行,锁范围被无谓扩大。 优化改了三处:把 EAGER 改成 LAZY,列表查询改成 DTO 投影,显式写 countQuery 并删除多余关联;补上联合索引和覆盖索引;列表查询改成只读事务,批量更新拆成小事务。优化后,一次 20 条订单的分页请求,SQL 从 62 条降到 2 条,主查询执行计划从 ALL 变成 ref,耗时从 1.8 秒降到 80 毫秒左右。 这个例子说明,如果只做 Java Code Review,我们大概率会直接通过这段 AI 生成的代码。只有把 SQL Count、Execution Plan、Index Review、Transaction Boundary 全部跑一遍,才能拦住这场生产事故。 ## 06、洞见 这次实战让我更坚定一个判断:AI 提升的是代码生成速度,而很多 JPA 问题存在于运行时。代码是设计意图的静态描述,SQL 是实际执行路径的体现,执行计划是数据库成本决策。只审 Java 代码,等于只检查图纸,没有去看施工和地基。 未来代码生成越普及,人工评审越要往运行时证据倾斜。执行计划、SQL 统计、锁等待、慢日志,这些本来是数据库管理员和运维关心的东西,会越来越多出现在开发评审页面上。一个复杂 PR 如果没有附关键 SQL 或执行计划,就不应该被视为一个完整的数据访问改动。 这不是说要让 Java 开发都变成数据库管理员,而是数据访问评审的最小单元必须从 Java 文件扩展到代码 + SQL + 执行计划。只有把运行时事实拉到评审桌上,才能避免代码看起来都对,生产跑起来全错的尴尬。 ## 07、行动 第一,把六道门禁写进团队开发规范。尤其是复杂查询接口,必须通过 Entity、Repository、SQL Count、Execution Plan、Index、Transaction 六项检查后才能合并。 第二,复杂 PR 必须附关键 SQL 或执行计划。比如订单搜索接口的 PR 里,要贴出主查询的 explain 结果、SQL 条数统计和关键索引说明。没有这些材料,评审可以直接打回。 第三,在测试或预发环境开启 SQL 日志、慢查询阈值和执行计划抓取。让每次数据访问改动都能自动产出运行证据,而不是靠人肉去数据库里翻。 第四,结合表统计信息和数据量做索引收益判断。不要只看字段有没有索引,要看索引选择性、联合索引前缀和覆盖情况。数据量越大,索引决策越不能拍脑袋。 第五,对订单搜索这类核心接口做容量演练。用接近生产的数据量验证执行计划是否稳定,是否存在数据量增长后从 ref 变成 ALL 的风险。 第六,定期回看慢查询日志,把运行时慢 SQL 映射回代码调用链。形成闭环:生产发现慢查询,反向补充六道门禁的检查规则,避免同类问题再次进入代码库。 ## 08、小结 AI 编码确实快,但数据访问层的正确性和性能不会因为生成速度快就自动变好。我们真正需要的,是一套把检查范围从 Java 代码延伸到 SQL 和执行计划的门禁。 复杂订单搜索接口的例子已经说明:60 多条 SQL、全表扫描、缺失索引和过大事务,都可以藏在看似规范的 JPA 代码后面。只看 Java 代码,很容易放行;只有把 SQL Count、Execution Plan、Index Review、Transaction Boundary 这些运行时证据拉出来,才能把风险拦在生产之前。 一句话收尾:代码写得快,不如数据库跑得稳;PR 里多一张执行计划,生产少一次半夜救火。 ## 09、链接与概念说明 JPA / ORM:对象关系映射框架,用 Java 对象映射数据库表,方法调用时可自动生成 SQL。优点是开发快,缺点是 SQL 对开发者半透明,容易出现运行时性能问题。 Entity:领域实体,映射数据库表结构。在 AI 生成的数据访问层中,实体常包含映射注解、关联关系和抓取策略。 Repository:仓库接口,封装增删改查和查询方法。AI 生成的仓库方法可能语义不清、返回实体过重、分页排序不严谨。 SQL Count:统计一次业务操作实际触发的 SQL 条数。用于发现 N+1 查询、重复查询和多余更新。 Execution Plan:数据库执行计划,展示 SQL 的访问路径、扫描方式、索引使用、排序和临时表。判断 SQL 是否高效的核心依据。 Index Review:检查索引是否覆盖过滤、排序、关联字段,是否符合最左前缀原则,是否有冗余或缺失。 Transaction Boundary:事务边界与隔离级别,影响锁范围、持有时间和并发能力。只读查询应避免长事务,批量更新应控制事务粒度。 文中提到的慢查询日志、执行计划、统计信息,可以在数据库控制台和监控平台中查看。建议团队将这类运行时信息纳入常规评审材料,而不是只在事故发生后复盘。