AI 编程 30 天实验复盘:效率提升 65%,线上 bug 涨到 11 个,问题出在哪

十年 Java 程序员,从去年开始用 AI 写代码,Claude Code 为主,偶尔 Codex 打杂。用了一段时间之后,我想知道它到底能带来多少收益,也想知道代价是什么。于是做了一次严格对照实验。

5 月底开始,跑满 30 天。手头三个 Spring Boot 微服务------设备数据接入、告警上报、音视频流转发------加一个管理后台。分工模式:我描述需求、审查代码、拍板架构,AI 负责写。对照基线是 4 月份纯手写、规模接近的同类项目。

数据如下:

编码时间降了 65%,交付量涨了一倍多,commit 节奏被打散重排------以前一个接口写一天不提交,现在 20 分钟一个 CRUD 顺手就 commit。这些都符合预期。真正超出预期的是最后一行:同等规模的交付,线上 bug 从 3 个涨到了 11 个。

为了避免"体感数据",我尽量把指标量化到可复现的程度。

日均编码时间:用 Toggl Track 手动计时,只算"实际写业务代码和改 bug"的时间,不算会议、Code Review 别人、写文档。AI 生成代码时,我在旁边审和改,这段时间计入编码时间;纯等待 AI 生成的时间不计。

有效代码行:不是看 git diff 里的加减行数,那会把 AI 生成的格式化代码也算进去。只统计"手敲或大幅修改后保留到最终合并"的代码行,包括我补的校验、锁、异常处理,以及 AI 生成后我又改过的部分。

线上 bug 数:只看生产环境通过 Sentry + 钉钉告警 + 客服工单确认的真实 bug,不包括测试环境或本地发现的问题。这 11 个里,有 3 个是用户反馈后定位的,8 个是监控告警后直接定位的。

这个口径不一定完美,但两个项目用的是同一套统计方法,横向对比是成立的。

复盘下来,7 个是并发安全问题,2 个空指针,1 个缓存一致性问题,1 个异常被吞。全都是本地测试跑不出来、一上线就炸的类型。挑两个最典型的展开讲实现细节。

案例一:积分扣减的并发穿透

AI 生成的代码:

java 复制代码
@Transactional
public void deductPoints(Long userId, Integer amount) {
    User user = userMapper.selectById(userId);
    if (user.getPoints() < amount) {
        throw new BusinessException("积分不足");
    }
    user.setPoints(user.getPoints() - amount);
    userMapper.updateById(user);

    PointsLog log = new PointsLog();
    log.setUserId(userId);
    log.setAmount(-amount);
    pointsLogMapper.insert(log);
}

本地跑三天全绿,上线后两个并发请求同时读到 points = 100,各扣 10 分,都写回 points = 90------少扣了一次。原因很直接:MySQL 默认 RR(Repeatable Read)隔离级别下,普通的 SELECT 不会给数据加锁,两个事务各自读到的都是扣减前的快照,UPDATE 时也不会检测这期间数据是否被别人改过。这是典型的"读---算---写"三步操作,中间没有任何东西挡住第二个请求插队。

这类问题不是靠"更仔细地读一遍代码"能看出来的------代码本身逻辑通顺,问题出在并发路径的推演上。解决方案有两条:

行锁(SELECT FOR UPDATE):查询时直接锁住这一行,其他事务的同名查询会被阻塞,直到当前事务提交或回滚。

java 复制代码
// Mapper 层:加锁查询,必须在 @Transactional 方法内调用,
// 否则锁只在这条 SQL 执行瞬间生效,出了这条语句立刻释放
@Select("SELECT * FROM account WHERE id = #{id} FOR UPDATE")
Account lockAndSelect(@Param("id") Long id);

@Transactional
public void deductPoints(Long userId, Integer amount) {
    Account account = accountMapper.lockAndSelect(userId);
    if (account.getPoints() < amount) {
        throw new BusinessException("积分不足");
    }
    accountMapper.deductPoints(userId, amount);
}

乐观锁(version 字段 CAS):不加锁,更新时把 version 作为条件一起提交,数据库层面保证"如果这期间没人改过,才允许我这次更新生效"。

java 复制代码
// UPDATE 语句自带 CAS 语义:WHERE 条件里带上旧 version,
// 影响行数为 0 说明期间被别人改过,需要重试
@Update("UPDATE account SET points = points - #{amount}, version = version + 1 " +
        "WHERE id = #{id} AND version = #{version}")
int deductWithVersion(@Param("id") Long id,
                      @Param("amount") Integer amount,
                      @Param("version") Integer version);

@Transactional
public void deductPoints(Long userId, Integer amount) {
    for (int i = 0; i < 3; i++) {
        Account account = accountMapper.selectById(userId);
        if (account.getPoints() < amount) {
            throw new BusinessException("积分不足");
        }
        int affected = accountMapper.deductWithVersion(
            userId, amount, account.getVersion());
        if (affected == 1) return;
    }
    throw new BusinessException("扣减冲突,请重试");
}

两者的取舍很实际:行锁简单直接,但会把整条链路串行化,高并发下容易堆积等待;乐观锁不阻塞,但冲突时需要重试,重试率一旦升高,用户体感会变差。判断标准是并发量------不确定就先上乐观锁,加个重试率监控,跑三天数据,重试率超过 5% 再考虑换行锁或更激进的方案。

AI 默认给出的是训练数据里最常见的答案:一段没有任何锁保护的 SELECT + UPDATE。它不知道这个接口的真实 QPS,这个判断只能人来做。

案例二:缓存一致性------事务边界和方法调用的时序坑

第二个案例是"设备状态变更后刷新在线状态缓存"。AI 给出的是教科书写法:先更新数据库,再删 Redis。

java 复制代码
@Transactional
public void updateDeviceStatus(DeviceDTO dto) {
    deviceMapper.updateById(dto);                    // 数据库操作,可回滚
    redisTemplate.delete("device:status:" + dto.getId());  // Redis 操作,不可回滚
}

线上跑了两天,偶发设备明明在线、缓存却显示离线。问题出在 @Transactional 的生效边界上:Spring 的声明式事务是靠动态代理实现的,方法体内的代码是"立即执行"的,但事务真正提交(commit)要等整个方法正常返回之后才发生。也就是说,redisTemplate.delete(...) 这一行在事务提交之前就已经执行完毕了。这段时间窗口里,只要有别的请求进来读一次数据库(读到的还是旧值),就会把旧值重新写回缓存------而且这次写回不在任何事务保护之下,不会自动修复。

修复思路是让删缓存的动作严格排在事务提交之后执行。这里有一个很容易踩的二次坑:把删缓存的代码挪到另一个方法里,再在业务方法末尾顺序调用两个方法,这样虽然多了一层方法调用,但如果这两个方法在同一个类里,后一个方法对前一个方法的调用属于同类自调用 ------它是直接的 Java 方法调用,不经过 Spring 生成的代理对象,@Transactional 的所有增强逻辑(包括事务边界)根本不会生效。等于白改。

真正可靠的做法是在事务方法内部注册一个"事务提交后"的回调,由 Spring 的 TransactionSynchronizationManager 保证只有事务真正提交成功才会触发:

java 复制代码
@Transactional
public void updateDeviceStatus(DeviceDTO dto) {
    deviceMapper.updateById(dto);
    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                redisTemplate.delete("device:status:" + dto.getId());
            }
        }
    );
}

原理很简单:Spring 的事务管理器在提交事务时会遍历当前线程绑定的所有 TransactionSynchronization 回调,依次触发 afterCommit();如果事务中途抛异常回滚,这些回调根本不会被调用。这样删缓存这个动作就被严格锁定在"数据库真正落盘之后"执行,不存在中间窗口期,也没有自调用失效的问题。更工程化的做法是把这类"事务后动作"统一落一张本地事件表,由后台任务扫描投递,把数据库提交和缓存/消息处理拆成两个独立生命周期,一个出问题不会拖累另一个。

AI 不知道这个接口的 QPS,不知道你的事务隔离级别,不知道你的缓存过期策略。它只知道"先更新数据库再删缓存"是训练数据里出现最多的标准答案------这个答案在低并发场景下是对的,换成你的高并发场景就不一定成立。

案例三:被吞掉的异常

第 11 个 bug 特别隐蔽,是一个异常被"静默吞掉"导致的状态不一致。

AI 生成的一段设备状态同步代码大致长这样:

java 复制代码
public void syncDeviceStatus(Long deviceId) {
    try {
        DeviceStatus status = remoteService.query(deviceId);
        deviceMapper.updateStatus(deviceId, status);
    } catch (Exception e) {
        log.error("同步设备状态失败, deviceId={}", deviceId, e);
    }
}

看起来规规矩矩:调远程服务,更新本地数据库,异常了记日志。问题出在它把所有异常都 catch 了,包括数据库连接异常、事务回滚异常,甚至包括方法内部可能抛出的运行时异常。

线上某个晚上数据库连接池短暂抖动,有一批设备状态更新失败,但日志只显示"同步设备状态失败",没有触发任何告警,也没有重试。第二天运营发现设备在线状态和实际不符,排查了半天才发现是"伪成功"------方法正常返回了,但数据库没更新。

更合理的做法是分层处理:

  • 远程服务调用失败:按业务规则决定重试还是降级;
  • 数据库更新失败:不要自己吞掉,让事务管理器去处理回滚;
  • 真正需要 catch 的,只有"记录日志"或"通知外部"这类不影响主流程的操作。
java 复制代码
@Transactional
public void syncDeviceStatus(Long deviceId) {
    DeviceStatus status = remoteService.query(deviceId);  // 失败抛异常,外层决定重试
    deviceMapper.updateStatus(deviceId, status);          // 失败自动回滚,不吞
}

这个 bug 反映了 AI 的另一个特点:它倾向于写出"看起来健壮"的代码------什么异常都 catch、什么空都判------但不见得知道哪些异常是该让调用方知道的。真正的健壮不是不漏异常,是不该漏的异常坚决不吞。

AI 的盲区到底在哪

把 11 个 bug 归归类,AI 反复踩的是同一个类型的坑:它不知道业务上下文里的"隐式约束"

这些约束包括:

  • 并发模型:接口的 QPS、峰值、是否有秒杀场景;
  • 数据归属:订单属于哪个用户、设备属于哪个租户;
  • 生命周期边界:数据库事务、缓存、MQ、RPC 哪些能回滚、哪些不能;
  • 上游契约:MQ 消息体哪些字段可能缺失、RPC 异常分支返回什么;
  • 安全基线:能不能信任用户传入的 URL、userId 从哪来。

这些东西不会出现在接口方法签名里,也不会出现在训练数据的常见写法里。AI 只能按"最普遍的实现"生成代码,而你的业务往往不在"最普遍"的范围内。

所以 AI 编程真正的分界线不是"你会不会写代码",而是"你能不能把这些隐式约束显式地告诉 AI,或者审出来的时候补上去"。

分工原则:什么留给自己

30 天跑完,我给自己划了一条线:

  • CRUD、单元测试、SQL 优化、配置类、工具方法、枚举转换、日志框架、API 文档------直接丢给 AI。这些活规则明确,不需要业务判断。
  • 架构设计、复杂业务逻辑、跨模块重构、线上排查、安全相关代码------自己写。这些需要把整个系统的上下文装进脑子里,AI 现阶段做不到。
  • 判断标准很简单:这段代码出了问题,你能不能一眼看出锅在哪。能,丢给 AI;看不出来,自己写。

三条钉死的自查清单:

  • 涉及余额、库存、计数类字段的更新,默认走乐观锁或行锁,不写"先查后改"的裸 SELECT + UPDATE
  • 缓存删除动作必须放在事务提交之后执行,不能塞进 @Transactional 方法体内部
  • 任何"读一次、算一次、再写回去"的组合操作,先问自己:并发下这两步中间会不会被别人插队

这三条后来被我系统化成了一份五关审查清单,专门用来审 AI 写的代码------下一篇会展开讲这五关具体怎么审、每一关对应什么代码模式,以及怎么把清单写成 Claude Code Skill。

给正在用 AI 写代码的人三个建议

第一,不要把 AI 当成能成长的 P6。

真人 P6 踩过一次坑会记住,下次类似场景会自己提防。AI 不会。你这次在 prompt 里教它"这里要加锁",下一次换个项目、换个对话,它照样按默认写法来。所以别指望"多审几次它就学会了",要把规则固化成可复用的配置。

第二,你的审查时间不应该缩短,反而应该拉长。

AI 把写代码的时间从 5 小时压到 1 小时,省下来的 4 小时不是让你摸鱼的,是用来更仔细地审代码的。如果审的时间也按比例压缩,bug 数必然会涨。实验已经证明了这个结果。

第三,优先守住"看一眼就会炸"的红线。

不是每个 AI 生成的方法都需要你逐行推演。但凡是涉及余额、库存、计数、缓存、事务、安全、外部输入的代码,必须逐行过。其他 CRUD 可以放宽。把精力放在真正的风险点上。

如果你也在用 AI 写代码,也踩过类似的并发或事务坑,欢迎在评论区聊聊你的案例。觉得这篇有用的话,点个赞、收藏一下,方便回头查代码时能翻到。后续会持续更新这个 AI 编程实验系列,下一篇讲五关审查清单的具体做法。

相关推荐
爱勇宝1 小时前
3位工程师靠“删AI代码”创业,一周收费1万美元:以后最贵的能力,可能不是写代码
前端·后端·架构
不能放弃治疗2 小时前
rule 和 skill
后端
ADIT2 小时前
一、反射的定义与原理
后端
Csvn2 小时前
Python 开发技巧:标准库深度挖掘
后端·python
山东点狮信息科技有限公司2 小时前
SpringBoot+VUE3,一套系统完成OA+HRM+CRM+MES+合同+项目管理 —— 深度解析点狮全业务管理平台
java·spring boot·后端
爱勇宝3 小时前
《道德经》第4章,老子写得很像在描述一个“底层系统”。
前端·后端·程序员
神奇小汤圆3 小时前
Redis 哨兵搭建+ACL权限控制
后端