AI帮我写的代码上了生产,然后炸了——5个真实翻车案例

147 阅读8分钟

我要先承认一件事:这篇文章里的每个事故,都是我自己造的孽。

过去一年,我从"AI编程真香"的狂热者,变成了"AI代码必须review"的谨慎派。不是因为AI不好用——它确实帮我省了大量时间——而是因为我太信任它了,把它生成的代码没仔细看就提交了。

结果就是:5次线上事故,3次半夜被叫起来修bug,1次差点丢了绩效。

下面是5个真实案例,代码都脱敏了,但问题本身原样保留。


翻车1:N+1查询——AI不知道JPA的懒加载

场景

一个订单列表接口,要返回订单信息和关联的用户信息。我让Copilot生成代码:

// AI生成的代码——看起来没毛病
@GetMapping("/orders")
public List<OrderVO> listOrders() {
    List<Order> orders = orderRepository.findAll();
    return orders.stream()
        .map(order -> {
            OrderVO vo = new OrderVO();
            vo.setOrderId(order.getId());
            vo.setUserName(order.getUser().getName());  // ← 这里
            vo.setAmount(order.getAmount());
            return vo;
        })
        .toList();
}

代码很干净,逻辑也对。本地测试完全没问题——因为本地数据库就3条数据。

上了生产,订单表有40万条记录。这个接口跑起来后,数据库连接池瞬间被耗尽。

为什么炸了

order.getUser() 是JPA的懒加载关联。每遍历一个order,就会触发一次独立的SQL查询去取user。40万条订单 = 40万次SQL查询。

这就是经典的N+1问题。Copilot不知道你用的是懒加载还是急加载,它只是按"最直观的方式"写了代码。

怎么修

// 方案1: 用JOIN FETCH一次性查出
@Query("SELECT o FROM Order o JOIN FETCH o.user")
List<Order> findAllWithUsers();

// 方案2: 批量查询
List<Order> orders = orderRepository.findAll();
Set<Long> userIds = orders.stream().map(Order::getUserId).collect(toSet());
Map<Long, User> userMap = userRepository.findAllById(userIds).stream()
    .collect(toMap(User::getId, identity()));
// 然后在map里用userMap.get(order.getUserId())

教训

AI生成的代码在"单个对象"的场景下几乎没问题。但一旦涉及集合遍历 + 关联查询,N+1几乎是必然的。AI不理解你的ORM配置,它只按语法生成最直接的写法。

JetBrains在2026年3月发了一篇文章专门讲这个问题,标题就叫"AI-Assisted Java Application Development with Agent Skills",里面用这个Spring Data JPA的例子说明:没有显式约束,AI默认生成N+1查询。


翻车2:事务泄漏——AI不知道@Transactional的边界

场景

一个批量导入接口,每100条数据提交一次事务。我让AI写:

// AI生成的代码
@Service
public class ImportService {
    
    @Transactional
    public void importData(List<DataItem> items) {
        for (int i = 0; i < items.size(); i++) {
            repository.save(items.get(i));
            
            if (i % 100 == 0) {
                entityManager.flush();
                entityManager.clear();
            }
        }
    }
}

看起来没问题——每100条flush一次,避免内存堆积。AI还挺聪明,知道加clear防内存泄漏。

但线上跑了三天后,有个导入任务卡住了——数据库行锁没释放,后续所有对这张表的写操作全部阻塞。

为什么炸了

@Transactional标注在整个方法上,意味着整个导入过程在一个事务里。flush()只是把SQL发到数据库,并没有提交事务。如果导入10万条数据,整个10万条都在一个事务里,期间数据库会持有行锁,其他写操作全等着。

AI知道flush()clear(),但它不理解事务边界和锁的关系。

怎么修

@Service
public class ImportService {
    
    private final TransactionTemplate transactionTemplate;
    
    public void importData(List<DataItem> items) {
        // 每100条一个独立事务
        Iterable<List<DataItem>> chunks = Iterables.partition(items, 100);
        for (List<DataItem> chunk : chunks) {
            transactionTemplate.execute(status -> {
                chunk.forEach(repository::save);
                return null;
            });
        }
    }
}

教训

AI对Spring事务的理解停留在"加个注解就行"的层面。 但事务的传播行为、隔离级别、锁的范围这些,AI默认不会考虑。凡是涉及批量操作的代码,一定要人工检查事务边界。


翻车3:缓存击穿——AI的"优化"反而成了炸弹

场景

有个商品详情接口,QPS比较高。我让AI"优化一下性能",AI很贴心地加了缓存:

// AI"优化"后的代码
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
    String key = "product:" + id;
    Product product = (Product) redisTemplate.opsForValue().get(key);
    
    if (product == null) {
        // 缓存没有,查数据库
        product = productRepository.findById(id).orElseThrow();
        redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
    }
    
    return product;
}

看着很标准对吧?先查缓存,没有就查数据库,然后写入缓存。

某天大促,运营配错了缓存过期时间,导致大量商品缓存同时过期。瞬间数万请求全部打到数据库,数据库CPU飙到100%,整个服务雪崩。

为什么炸了

这就是经典的缓存击穿——大量请求同时发现缓存失效,全部穿透到数据库。AI知道加缓存,但不知道缓存失效时的保护机制。

怎么修

@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
    String key = "product:" + id;
    
    // 用双重检查锁 + 互斥锁防止击穿
    Product product = (Product) redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;
    }
    
    // 缓存没有,用分布式锁防止并发重建
    String lockKey = "lock:product:" + id;
    try {
        Boolean locked = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
        
        if (Boolean.TRUE.equals(locked)) {
            // 拿到锁,再查一次缓存(可能其他线程已经重建了)
            product = (Product) redisTemplate.opsForValue().get(key);
            if (product != null) {
                return product;
            }
            
            // 查数据库
            product = productRepository.findById(id).orElseThrow();
            // 随机过期时间,防止同时失效
            int ttl = 25 + ThreadLocalRandom.current().nextInt(10);
            redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.MINUTES);
            return product;
        } else {
            // 没拿到锁,短暂等待后重试
            Thread.sleep(50);
            return getProduct(id);
        }
    } finally {
        redisTemplate.delete(lockKey);
    }
}

教训

AI加缓存是天经地义的"优化",但缓存的所有坑——击穿、穿透、雪崩——AI都不会主动帮你防。 缓存相关的代码,一定、一定要人工review。


翻车4:异常吞掉——AI觉得"catch住就行了"

场景

一个支付回调接口,AI生成的异常处理:

// AI生成的代码
@PostMapping("/payment/callback")
public ResponseEntity<String> handleCallback(@RequestBody String payload) {
    try {
        PaymentResult result = paymentService.processCallback(payload);
        return ResponseEntity.ok("SUCCESS");
    } catch (Exception e) {
        log.error("处理回调失败", e);
        return ResponseEntity.ok("SUCCESS");  // ← 先返回成功,防止重试
    }
}

AI的逻辑是:支付回调失败也别报错,返回SUCCESS让支付平台别重试,自己内部处理。

看起来有点道理?但实际翻车了——支付服务的processCallback里有个空指针bug,在特定条件下抛NPE。这个异常被catch住了,返回了SUCCESS,支付平台以为回调成功就不再重试了。

结果:用户的钱扣了,订单状态没更新。客服那边收到一堆投诉。

为什么炸了

AI不理解业务语义——支付回调返回SUCCESS意味着"我处理好了",但实际上异常被吞了,什么都没处理。正确的做法是返回FAIL让支付平台重试。

怎么修

@PostMapping("/payment/callback")
public ResponseEntity<String> handleCallback(@RequestBody String payload) {
    try {
        paymentService.processCallback(payload);
        return ResponseEntity.ok("SUCCESS");
    } catch (BusinessException e) {
        // 业务异常(如订单已处理过),返回成功
        log.warn("业务异常: {}", e.getMessage());
        return ResponseEntity.ok("SUCCESS");
    } catch (Exception e) {
        // 系统异常,返回失败让支付平台重试
        log.error("系统异常,需要重试", e);
        return ResponseEntity.status(500).body("FAIL");
    }
}

教训

AI的异常处理策略默认是"catch住、记日志、继续跑"。 这在大部分场景没问题,但在金融、支付、订单这些有严格业务语义的场景里,异常处理策略直接影响数据一致性。这类代码必须人工把关。


翻车5:线程安全——AI不懂并发

场景

一个限流器,用AI生成:

// AI生成的代码——看起来是个不错的限流器
public class RateLimiter {
    private int count = 0;
    private long windowStart = System.currentTimeMillis();
    private final int maxRequests;
    private final long windowMillis;
    
    public RateLimiter(int maxRequests, long windowMillis) {
        this.maxRequests = maxRequests;
        this.windowMillis = windowMillis;
    }
    
    public boolean allow() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowMillis) {
            count = 0;
            windowStart = now;
        }
        if (count < maxRequests) {
            count++;
            return true;
        }
        return false;
    }
}

单线程跑完全正确。但这个限流器被用在一个网关过滤器里,多线程并发调用。

线上效果:限流完全失效。配置的是100 QPS,实际跑到了300+ QPS。

为什么炸了

count++不是原子操作。多线程同时执行到if (count < maxRequests)时,可能多个线程同时读到count == 99,然后都执行count++,返回true。限流就形同虚设了。

AI生成的代码里,没有用synchronized、没有用AtomicInteger、没有用ReentrantLock。它根本没考虑线程安全。

怎么修

// 最简单的修法:用AtomicInteger
public class RateLimiter {
    private final AtomicInteger count = new AtomicInteger(0);
    private volatile long windowStart = System.currentTimeMillis();
    
    public boolean allow() {
        long now = System.currentTimeMillis();
        if (now - windowStart > windowMillis) {
            count.set(0);
            windowStart = now;
        }
        return count.incrementAndGet() <= maxRequests;
    }
}

不过说实话,如果你真的需要限流,别自己造轮子。用Guava的RateLimiter或者Sentinel,别人早就把这些坑都填好了。

教训

AI对并发编程的理解非常浅。 它知道synchronizedAtomicInteger的语法,但不会主动判断"这段代码在多线程环境下是否安全"。涉及到共享可变状态的代码,必须人工检查线程安全。


我现在怎么用AI写代码

经历这5次翻车后,我形成了一套自己的AI编程纪律:

放心让AI做的:

  • CRUD模板代码
  • DTO/VO/Converter
  • 单元测试(但会人工补充边界case)
  • 文档和注释
  • 正则表达式

必须人工review的:

  • 任何涉及数据库查询的代码(N+1、事务边界)
  • 任何涉及缓存的代码(击穿、穿透、雪崩)
  • 任何涉及并发的代码(线程安全)
  • 任何涉及钱/支付的代码(异常处理、数据一致性)
  • 任何涉及安全的代码(认证、授权、加密)

不让AI碰的:

  • 核心业务逻辑设计
  • 架构决策
  • 安全相关代码(加密算法、认证流程)

说白了就是一句话:AI是副驾驶,你才是主驾驶。方向盘不能交出去。


说了这么多翻车的事,不是为了劝你别用AI编程。恰恰相反,我每天都在用,省了大量时间。但"用"和"信"是两回事——你可以用AI生成代码,但你不能信任它生成的每一行。

工具越好用,用工具的人越要保持清醒。