上篇写了 AI 写 Java 代码的 5 个场景提效数据,发出去之后评论区有个兄弟直接点破了:
字段校验粗糙,内存分页这种问题,你没深说。
说得对。提效是真的,坑也是真的。
这篇专门来聊坑。不是泼冷水,是让你少踩雷。
先说背景
我踩的这些坑,有一个共同特征:测试环境跑得好好的,数据量一上来或者并发一高,生产就出问题了。
AI 生成代码在逻辑层面往往没问题,坑藏在两个地方:
- 边界条件:空值、极值、并发竞争
- 规模效应:数据量小时感觉不到,量一大就原形毕露
下面 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 性能平衡 | 高频接口、大数据量 | 🟡 中(性能劣化) |
我现在的工作流
AI 生成代码之后,我不直接 review 代码,而是先过这张表——6 个维度逐一过一遍,每个确认没问题再提 PR。
多花 5 分钟,省掉一次线上故障。值。
AI 帮你快速把代码骨架搭出来,但生产级的代码质量,还是得你自己负责。
它不怕你背锅,你要替自己负责。
这是「栈外」的第 2 篇文章。不画饼,只算账。