AI 写的代码上了生产,我踩了哪些坑?

3 阅读10分钟

上篇写了 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 生成的代码里,forEachstream().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.getWithExpireuserMapper.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 篇文章。不画饼,只算账。

上一篇:别吹了,AI 写 Java 代码到底能省多少时间?一个后端仔的真实记录