上篇写了 AI 写 Java 代码的 5 个场景提效数据,发出去之后评论区有个兄弟直接点破了:

字段校验粗糙,内存分页这种问题,你没深说。

说得对。提效是真的,坑也是真的。

这篇专门来聊坑。不是泼冷水,是让你少踩雷。


先说背景

我踩的这些坑,有一个共同特征:测试环境跑得好好的,数据量一上来或者并发一高,生产就出问题了。

AI 生成代码在逻辑层面往往没问题,坑藏在两个地方:

  1. 边界条件:空值、极值、并发竞争
  2. 规模效应:数据量小时感觉不到,量一大就原形毕露

下面 6 个坑,每一个我都实际碰过。

说明:文中代码均为简化的演示版本,只为把问题说清楚。实际生产代码还需要考虑更多细节:日志规范、入参防御、异常链路、事务边界等。请勿直接复制使用。


前置:先聊聊 AI 的编码能力分级

上篇发出来,评论区有人点了内存分页的问题。趁这篇把这个话题说清楚。

内存分页这件事,其实是鉴别 AI 编码能力的分水岭

能力弱的模型,或者没有给上下文约束(数据规模、技术栈),它会这么写:


// 弱模型 / 无上下文约束时的典型输出 public PageResult<UserVO> listUsers(UserQueryReq req) { List<User> allUsers = userMapper.selectByCondition(req); // 全量捞 int fromIndex = (req.getPage() - 1) * req.getPageSize(); int toIndex = Math.min(fromIndex + req.getPageSize(), allUsers.size()); return PageResult.of(UserConverter.toVOList(allUsers.subList(fromIndex, toIndex)), allUsers.size()); }

逻辑上没错,但数据规模一上来就是 OOM。

顺带说一句,subList 本身也有隐患——它返回的不是独立的新集合,而是父集合的一个视图,对父集合的结构性修改会直接影响到它。有经验的同学看到这里应该清楚其中的风险了,这里不展开,以后有机会单独开个专栏讲 Java 代码质量细节。

把技术栈和数据规模告诉 AI,明确要求用 MyBatis-Plus 分页,输出就变成了:


// 给了上下文约束之后的正常输出 public PageResult<UserVO> listUsers(UserQueryReq req) { Page<User> page = new Page<>(req.getPage(), req.getPageSize()); IPage<User> result = userMapper.selectPage(page, buildQueryWrapper(req)); return PageResult.of(UserConverter.toVOList(result.getRecords()), result.getTotal()); }

所以这个问题的根不在你,在工具选型和 Prompt 设计。给 AI 的上下文越清晰,它越不会在基础问题上翻车。

有经验的开发者看一眼就能发现内存分页——这不是坑,是 AI 工具能力的评估指标

真正危险的是下面这些,有经验的人也容易漏。


坑 1:字段校验只有"有没有",没有"合不合法"

高频出现,危害却很隐蔽。

AI 生成的接口参数校验,基本上长这样:


public class AddressAddReq { @NotBlank(message = "收货人姓名不能为空") private String receiverName; @NotBlank(message = "手机号不能为空") private String phone; @NotBlank(message = "详细地址不能为空") private String detailAddress; }

@NotBlank 加了,空值能拦住。但是:

  • receiverName 传个 5000 字的字符串?通过
  • phone 传个 "abcdefghijk"?通过
  • detailAddress 传个 XSS 脚本?通过

正确的写法,每个字段都需要业务语义校验:


public class AddressAddReq { @NotBlank(message = "收货人姓名不能为空") @Size(min = 2, max = 20, message = "收货人姓名长度 2-20 个字符") private String receiverName; @NotBlank(message = "手机号不能为空") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确") private String phone; @NotBlank(message = "详细地址不能为空") @Size(max = 200, message = "详细地址最长 200 个字符") private String detailAddress; }

AI 为什么只加 @NotNull/@NotBlank

它只能从字段名语义推断出最保守的校验。@Pattern 需要知道具体的业务规则——手机号格式、金额范围、枚举值——这些它不知道,也不敢乱猜。

review 时的检查清单

  • 字符串字段:有没有长度上限?
  • 手机/邮箱/身份证等:有没有 @Pattern
  • 数字字段:有没有范围约束(@Min/@Max)?
  • 枚举值字段:有没有 @NotNull + 枚举合法性校验?

坑 2:异常处理一律 catch Exception

AI 生成的 Service 层,异常处理基本上是这样:


public void processOrder(Long orderId) { try { Order order = orderMapper.selectById(orderId); // 业务逻辑... orderMapper.updateStatus(orderId, OrderStatus.COMPLETED); } catch (Exception e) { // ⚠️ 吞掉了所有异常 log.error("处理订单失败", e); } }

这段代码的问题不在于会报错,而在于不会报错

orderMapper.updateStatus 因为数据库连接超时失败了,方法静默返回,订单状态没更新,但调用方以为成功了。线上排查时,日志里有一行 error,没有任何异常抛出,调用链完全断了。

应该区分业务异常和系统异常:


public void processOrder(Long orderId) { Order order = orderMapper.selectById(orderId); if (order == null) { throw new BizException(ErrorCode.ORDER_NOT_FOUND); // 业务异常,让上层处理 } if (!OrderStatus.PENDING.equals(order.getStatus())) { throw new BizException(ErrorCode.ORDER_STATUS_INVALID); } try { orderMapper.updateStatus(orderId, OrderStatus.COMPLETED); } catch (DataAccessException e) { // 系统异常,记录后向上抛,让上层决定是否重试 log.error("更新订单状态失败, orderId={}", orderId, e); throw new SystemException(ErrorCode.DB_ERROR, e); } }

一个原则:业务异常要抛出来让调用方感知,系统异常要记录上下文后向上传递,绝对不能静默吞掉。

架构层面上,我一般会用 AOP 封装统一异常处理,配合统一 Result 对象做业务结果封装,由业务层或调用层统一判断和处理——而不是在每个方法里重复写 try-catch。


坑 3:并发场景没有保护

这个坑要等并发量上来才会暴露,所以最危险。

典型场景——扣减库存:


// AI 生成的代码——单线程下完全没问题 public void deductStock(Long productId, Integer quantity) { Product product = productMapper.selectById(productId); if (product.getStock() < quantity) { throw new BizException(ErrorCode.STOCK_NOT_ENOUGH); } product.setStock(product.getStock() - quantity); // ⚠️ 并发丢失更新问题 productMapper.updateById(product); }

并发 100 个请求同时进来,都读到库存 10,都通过了校验,然后都去扣减——库存直接变负数。

AI 的代码没有并发意识,它只验证了单线程下逻辑正确。

常见解法:


// 方案一:乐观锁(需要表里有 version 字段) @Update("UPDATE product SET stock = stock - #{quantity}, version = version + 1 " + "WHERE id = #{id} AND version = #{version} AND stock >= #{quantity}") int deductStockWithVersion(@Param("id") Long id, @Param("quantity") Integer quantity, @Param("version") Integer version); // 方案二:WHERE 条件兜底(推荐,简单可靠) @Update("UPDATE product SET stock = stock - #{quantity} " + "WHERE id = #{id} AND stock >= #{quantity}") int deductStock(@Param("id") Long id, @Param("quantity") Integer quantity); // 返回值为 0 说明库存不足或被并发抢先,业务层重试或返回失败

一个经验:凡是涉及"查询 → 校验 → 更新"三步的操作,默认考虑并发问题。AI 不会替你想,得自己养成这个意识。


坑 4:N+1 查询——性能慢死不报错

不会直接导致故障,只会让接口越来越慢,直到用户投诉。

AI 生成列表查询,很容易出现 N+1:


// AI 生成的代码 public List<OrderVO> listOrders(Long userId) { List<Order> orders = orderMapper.selectByUserId(userId); return orders.stream().map(order -> { OrderVO vo = OrderConverter.toVO(order); // ⚠️ 每个订单都单独查一次商品信息 List<OrderItem> items = orderItemMapper.selectByOrderId(order.getId()); vo.setItems(OrderConverter.toItemVOList(items)); return vo; }).collect(Collectors.toList()); }

100 个订单 = 1 次主查询 + 100 次子查询 = 101 次 SQL。数据量小感觉不出来,量一上去接口直接龟速。

正确做法:批量查询再组装:


public List<OrderVO> listOrders(Long userId) { List<Order> orders = orderMapper.selectByUserId(userId); if (CollUtil.isEmpty(orders)) { return Collections.emptyList(); } // 批量查询所有订单的商品,一次 SQL 搞定 List<Long> orderIds = orders.stream() .map(Order::getId) .collect(Collectors.toList()); List<OrderItem> allItems = orderItemMapper.selectByOrderIds(orderIds); // 按 orderId 分组,方便后续组装 Map<Long, List<OrderItem>> itemMap = allItems.stream() .collect(Collectors.groupingBy(OrderItem::getOrderId)); return orders.stream().map(order -> { OrderVO vo = OrderConverter.toVO(order); vo.setItems(OrderConverter.toItemVOList( itemMap.getOrDefault(order.getId(), Collections.emptyList()) )); return vo; }).collect(Collectors.toList()); }

2 次 SQL,无论多少订单都是 2 次。

识别方法:AI 生成的代码里,forEach 或 stream().map() 内部有没有调用 Mapper 方法。有就要警惕。


坑 5:能看不能跑——缺包与幻觉方法

前面四个坑上了生产才暴露,这个更直接:代码复制进去,IDE 直接报红。 更棘手的是,即使本地 Agent 扫描了项目代码,有时依然会漏掉。

第一种:import 缺失。

AI 生成的代码经常引用工具类或包装对象,但不带 import:


// AI 生成的代码——CollUtil 和 PageResult 没有 import public PageResult<UserVO> listUsers(UserQueryReq req) { List<User> users = userMapper.selectList(buildWrapper(req)); if (CollUtil.isEmpty(users)) { // ❌ CollUtil 未导入 return PageResult.empty(); // ❌ PageResult 未导入(甚至可能是项目自定义的类) } // ... }

IDE 里报红容易发现,但批量生成多个类、快速粘贴时漏掉几个 import 不稀奇。更危险的是:AI 有时会"编造"一个你项目里根本不存在的工具类,比如 PageUtils.toResult()——这不是 import 问题,而是下面这个。

第二种:幻觉方法——调用了根本不存在的方法。

AI 会根据方法名语义猜测"这个方法应该存在",然后直接调用:


// AI 生成的代码 public UserVO getUserWithCache(Long userId) { // ❌ 你的 RedisUtil 只有 get(key),没有 getWithExpire UserVO cached = redisUtil.getWithExpire(CACHE_KEY + userId, UserVO.class); if (cached != null) return cached; User user = userMapper.selectById(userId); // ❌ UserMapper 里根本没有定义这个方法 userMapper.updateLastAccessTime(userId); return UserConverter.toVO(user); }

redisUtil.getWithExpire 和 userMapper.updateLastAccessTime 都是 AI 凭语义推断"应该有"的方法,实际上不存在。

为什么 AI 会这样? 它参考训练数据里见过的各种 API 风格进行"补全",但你的项目接口定义它并不知道。还有一种情况是 Agent 扫描项目代码时忽略了某些工具 jar 包,导致引用了实际不可用的方法。

识别和防范:

  • 生成完整类之后,先在 IDE 里编译一遍,报红全清掉再 review 逻辑
  • Mapper 层的方法调用,逐一确认接口里真实定义了
  • 工具类方法要跳转进去确认签名,别只看方法名就信了
  • 在 AI 的规范文件(如 CLAUDE.md)里明确要求:所有生成代码必须经过编译检查和逻辑 review,这一步省不了

坑 6:可读性好,性能差——AI 的平衡偏差

AI 写代码有一个明显的倾向:优先写出可读性高的代码,而不是性能最优的代码。

这不是 bug,是它的风格取向。问题在于,它不知道你的数据量和调用频次。

典型例子——对一个列表做多个维度的统计:


// AI 生成的代码——结构清晰,但遍历了 3 次 public OrderSummary buildSummary(List<Order> orders) { long completedCount = orders.stream() .filter(o -> OrderStatus.COMPLETED.equals(o.getStatus())) .count(); // 第 1 次遍历 BigDecimal totalAmount = orders.stream() .map(Order::getAmount) .reduce(BigDecimal.ZERO, BigDecimal::add); // 第 2 次遍历 List<Long> failedIds = orders.stream() .filter(o -> OrderStatus.FAILED.equals(o.getStatus())) .map(Order::getId) .collect(Collectors.toList()); // 第 3 次遍历 return new OrderSummary(completedCount, totalAmount, failedIds); }

数据量小时无所谓。高频接口、列表动辄几千条,三次遍历的开销就不能忽略了。

合并成一次遍历,注释补上逻辑说明:


public OrderSummary buildSummary(List<Order> orders) { long completedCount = 0; BigDecimal totalAmount = BigDecimal.ZERO; List<Long> failedIds = new ArrayList<>(); // 单次遍历同时统计完成数、总金额、失败订单 ID for (Order order : orders) { if (OrderStatus.COMPLETED.equals(order.getStatus())) { completedCount++; } if (OrderStatus.FAILED.equals(order.getStatus())) { failedIds.add(order.getId()); } totalAmount = totalAmount.add(order.getAmount()); } return new OrderSummary(completedCount, totalAmount, failedIds); }

其他常见的可读性换性能问题:

  • 多个维度的统计拆成多条 stream() 链,每条单独遍历
  • 循环里字符串用 + 拼接而不是 StringBuilder(致命)
  • Optional 链式嵌套过深,引入不必要的对象创建开销

我的原则:性能和可扩展性始终优先。AI 给出的可读性版本可以作为理解逻辑的参考,但落地代码要按性能版本写——可读性的问题用注释解决,逻辑复杂的地方写清楚注释,比牺牲性能换可读性要强得多。


总结:AI 代码的质量检查清单

检查项 触发场景 危害等级
字段校验是否完整 所有入参 🟡 中(数据脏、安全漏洞)
异常处理是否合理 Service 层 🔴 高(静默失败)
并发场景是否保护 涉及"查-校-改"的操作 🔴 高(数据不一致)
是否 N+1 查询 列表接口有关联查询 🟡 中(性能劣化)
import 和幻觉方法 所有生成代码 🟡 中(编译失败/运行报错)
可读性 vs 性能平衡 高频接口、大数据量 🟡 中(性能劣化)
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐