做这件事的时候是2022年初,我接手一个典型的Java老项目做例行代码审计。这是个2017年启动的会员积分营销系统,跑了好几年,单服务多实例部署,代码量大概40万行,核心链路动不动就有半夜批任务、定时补发、并发扣减。团队平时催得紧,很多代码是赶工出来的,没人愿意回头翻。
闲着也是闲着,我试着把几个核心模块的代码丢给大模型做AI代码审查,看它能不能帮我们摸底。第一次跑完,AI拉了整整20个问题,从SQL注入到并发修改HashMap都有,看起来成果丰硕。但接下来我花了大半个下午逐条复核,最后真正敢签"要修"的只有15个。剩下的5个,不是项目里的真坑,而是AI在缺少业务上下文的情况下把"不符合规范"和"会导致故障"画了等号。
这篇文章就把整个过程拆开讲讲:AI到底怎么审的、20个坑长什么样、为什么老炮只认15个、以及AI又漏掉了哪些真正的坑。
1. 审前准备:老项目喂给AI之前要先做这三件事
1.1 先搞清项目真实的家底
我接手这个项目时的技术栈很典型:Java 8、Spring Boot 1.5.22、MyBatis 3.4、多模块Maven工程,核心业务在三个模块里——交易流水、营销活动、用户账户。服务部署了两台ECS,上面各跑一个Java进程,定时任务用的是Spring注解,没有分布式锁,整库分表没做,单表最大的一张流水表已经6000多万行。
这意味着什么?意味着你不能把整个工程一口气丢给AI,也不能问"帮我看看这个项目有什么问题",那样得到的回答必然是一堆空洞的安全规范复读。要让AI审查有意义,得先给它划定清晰的边界和任务。
1.2 按风险排序选模块,别整库乱扫
我当时的做法是:先自己用经验判断哪些链路最容易出事,再把对应的源码挑出来交给AI。
优先看三个地方——第一,所有涉及金额计算、积分类流水写入的Service类;第二,所有被定时任务调用的方法;第三,所有直接拼接SQL的Mapper实现。这三个范围一划出来,大概30个Java文件、1万多行代码,分6批喂给AI。我用的是一款通用大模型API,没有针对Java代码做过专门微调,但它的代码理解能力已经足够做初筛了。
1.3 提示词里必须写明"输出格式"和"验收标准"
很多人用AI审代码,提问方式就是一句"你看这段代码有没有问题",效果很差。AI不知道你是要它找Bug,还是要它挑代码风格,还是分析性能,给的结果必然发散。
我的提示词模板大概长这样:
你是一位有十年经验的Java后端技术专家,正在审查一个生产环境的会员积分系统代码。 请只关注以下五类问题: 1. 会导致线上故障的正确性Bug 2. 在高并发场景下可能触发的并发安全问题 3. 性能隐患,尤其是循环内的SQL查询和资源使用问题 4. 外部输入未校验导致的安全风险 5. 明显的事务边界错误 对每个问题,按下面格式输出: - 问题描述:一句话说清楚 - 严重级别:严重 / 中等 / 轻微 - 对应代码片段:标明类名和方法名 - 触发条件:什么场景下会出问题 - 修复建议:给出最小改动方案 注意: - 有争议的代码可以先标出来,但不要为了凑数硬凑问题 - 如果你不确定某个写法是否算Bug,请标注"上下文不足"加上这个约束之后,AI的回复质量明显高了一截。它会主动区分"确定性问题"和"疑似问题",而不是把构造函数里少了个final都当成高危漏洞列出来。
2. 20个坑的完整画像:AI扫出来的问题究竟准不准
2.1 问题分类与数量分布
第一轮AI输出20个问题,我按类型做了个统计:
| 问题类型 | 数量 | 典型表现 |
|---|---|---|
| 性能隐患 | 6 | 循环内查库、N+1查询、逐条批量插入 |
| 并发安全 | 3 | 共享SimpleDateFormat、HashMap并发写、线程池未复用 |
| 事务失效 | 2 | @Transactional自调用、catch异常导致事务不回滚 |
| 正确性 | 6 | 包装类型==比较、异常只printStackTrace、时间日期处理错误、equals未重写导致集合判断失效 |
| 安全风险 | 2 | 手工拼接SQL、前端订单金额直传后端未校验 |
| 可维护性 | 1 | 大量魔法值散落各处 |
这个分布本身就很说明问题:AI对"静态可见的缺陷"敏感度很高,凡是能从代码字面推断出来的问题,它几乎都能抓到。我逐条看了一下,20个里有15个确实是真问题,这个命中率不算低。
2.2 几个有代表性的典型案例拆解
先说一个最典型的——N+1查询。AI标记了一个积分任务发放方法,里面是这么写的:
for (Order order : orderList) { Member member = memberMapper.selectById(order.getMemberId()); rewardPoints += member.getLevel() * 2; }一眼看过去就是在循环里查表。但AI能把这个当成"严重"级别输出,是因为它进一步推算了影响:orderList在双11批量补单时能达到几千条,每一次发放任务都会产生几千条SQL,数据库连接池迟早被打满。这不是"理论上慢一点"的问题,而是"峰值时期必挂"的问题。后来的修复也很简单,一次性查出会员ID集合再批量查询,用Map<Long, Member>对接入逻辑处理,SQL从几千条降到几十条。
第二个代表性问题是事务失效,出现在签到方法里:
@Service public class SignService { @Transactional public void sign(String userId) { this.updateSignRecord(userId); rewardService.addPoints(userId, 10); } public void updateSignRecord(String userId) { // 更新签到表 } }AI指出@Transactional标记在sign()上,但this.updateSignRecord()是内部方法调用,绕过了Spring的代理对象,事务切面根本不会生效。这种逻辑如果没人提醒,确实很容易漏过去,因为单看sign()方法本身,注解、事务边界写得都没毛病。问题出在"自调用"这个隐蔽点上。
第三类是SimpleDateFormat的线程安全问题:
private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");这是个老得不能再老的坑了,但在这个项目里依然存在。AI给的解释很到位:"SimpleDateFormat不是线程安全的,该类的内部日历状态在parse/format时会被修改。多线程并发调用同一个实例会导致日期错乱,甚至出现不可识别的字符。"随后建议换成ThreadLocal封装或使用Java 8的DateTimeFormatter。
第四类说出来有点丢人——手工拼SQL。项目里有几个老Mapper方法是这么写的:
String sql = "SELECT * FROM t_member WHERE 1=1"; if (StringUtils.isNotBlank(keyword)) { sql += " AND nickname LIKE '%" + keyword + "%'"; }AI直接将这个问题标记为严重,原因是keyword如果被恶意传入%'; DROP TABLE ...; --之类的字符串,会产生注入风险。虽然这个系统内部使用,没有直接暴露给公网,但作为长期维护的存量代码,这种写法无论如何都不该留。
第五类是包装类型比较,这个也很有意思:
if (member.getId() == user.getId()) { // 判断是否是本人 }两个Long对象用==比较,在值超过127时比较的是对象引用,不是数值本身。AI把问题圈出来,提醒改用longValue()或equals()。这类问题光靠人眼扫代码很容易滑过去,因为只有在数据量大到超过Java包装类缓存阈值时才暴露。
这五个case能看出来:AI做静态审查的核心优势是"覆盖面广、不疲劳、能穷举"。它不靠运气,不靠灵感,只要代码里存在可被识别的模式,它基本都能标出来。
3. 老炮复核后只留15个:被砍掉的5个到底冤不冤
3.1 五个不认可的问题清单
我复核的时候不是凭感觉砍,而是把每个问题放到"当前业务、当前架构、当前团队能力"三个维度去重新审视。最终被砍掉的5个问题如下:
| AI判定 | 老炮复核结论 |
|---|---|
| Service层超长方法(300行)需拆分 | 业务本身是高度耦合的结算流程,拆散后更难追踪,优先级低 |
| 日志打印量过大,建议减少 | 该模块连续三年靠日志定位线上问题,删日志会降低排障能力 |
| 建议用StringBuffer替换StringBuilder | 该线程是单线程私有变量,换成StringBuffer只是加了无谓的锁开销 |
| 建议引入Lombok简化getter/setter | 老项目团队不熟悉Lombok,引入新依赖的收益远低于学习成本 |
| 建议统一用DateTimeFormatter替换SimpleDateFormat | 项目已有单例工具类做了synchronized隔离,风险可控,改动收益弱 |
如果把AI当成"代码规范老师",这5个建议其实都说得通。但放在真实生产项目里,"有问题"和"值得改"是两回事。
3.2 误报的三种典型来源
我复盘下来,AI这5个误报基本可以归为三类:
第一类是"把规范偏好当Bug"。StringBuffer还是StringBuilder,Lombok还是手写getter,这属于团队工程偏好,不是故障隐患。AI并不知道这段代码所在的线程模型、并发环境、团队背景,它只会看到"这里有个StringBuilder,但StringBuffer线程安全,建议替换"。但实际业务中,这个局部变量根本没有被其他线程共享的可能,替换就是负优化。
第二类是"缺少业务上下文导致误判"。比如300行的复杂方法,AI觉得"太长、不好维护",但它不知道这是财务结算逻辑,里面十几个步骤强耦合,拆成十几个小方法之后,后续同事维护时反而要在多个方法之间来回跳。业务复杂度不是代码行数带来的,拆分一个本身就很复杂的方法,解决不了问题。
第三类是"不知道历史债务的存在"。那些看着冗余的日志打印,其实是几年前生产上出过一次严重故障,当时日志不够详细,整个团队排查了整整一通宵。从那以后,这个模块的日志就是故意堆出来的。老炮看到的是"每个日志背后的故事",AI看到的是"打印次数超过阈值"。
3.3 复核时我给AI补充过一轮追问
我做过一个实验,把那5个被砍的问题重新丢给AI,并补了一句背景说明:"这个方法是单线程内使用的私有变量,项目团队目标是Java 8,不引入新依赖,法律责任模块需要保留详细日志。"结果AI很快改口,承认其中4条"在给定约束下可以不改"。
这个实验让我意识到一个关键点:AI判断问题时会默认一个"理想代码环境",但现实项目里存在大量历史原因、团队约束和业务妥协。所以AI审查结果不能直接当工单派发,必须有一个懂业务、懂历史、懂架构的人做最终裁决。
3.4 复核本身也是团队的"问题对齐"过程
这次复核还有一个额外收获。团队里四五个核心开发坐在一起过了一遍问题清单,很多人第一次意识到"哦,原来这种写法在并发下会炸""原来事务自调用一直没生效过"。与其说这次任务是对旧项目的体检,不如说它变相给团队做了一次针对老代码陷阱的集中培训。
4. 五个漏网之鱼:AI没看到、但老炮必须补上的真坑
AI审查看似全面,但它毕竟只能基于喂给它的代码做推断。一旦问题跨出了文件边界、脱离了源码层面,AI就很容易漏掉。这次我复核完20个问题之后,又另外揪出了几个AI完全没有提到的坑。
4.1 定时任务重复执行:AI的视线到不了部署架构
项目里有个积分补发的定时任务,注解是Spring的@Scheduled,固定时间点扫描前一天未发放的积分明细并补发。服务是两个实例部署,这个任务在机器A和机器B上都会启动。
这会导致什么结果?如果某次调度因为数据库慢、任务超时,下一次调度时间到了还没执行完,两个实例就会出现并发重复补发,用户账户里凭空多出一笔积分。AI看到的是一个孤单的@Scheduled方法,它不知道这个方法同时跑在多少台机器上,也不知道项目有没有引入分布式锁。单看代码,这方法没啥问题;放到部署环境里,这就是一个事故隐患。
老炮的做法:不需要引入复杂的分布式调度中心,最简单的方案是在任务执行前尝试获取一个Redis分布式锁,拿到锁才能跑,拿不到就跳过。改动不过十来行代码,但只有了解部署形态的人才会想到这一点。
4.2 接口幂等性缺失:AI看不见调用链上的隐患
积分系统里有个"参与活动领取积分"的接口,前端做了防重复点击,但后端并没有做幂等控制。一旦用户手速快、网络重试、或者有人绕过前端直接调用接口,同一条活动记录可以生成多笔积分流水。
AI审查单看这个Controller方法,会觉得"逻辑没啥问题"。但它不知道这个接口的调用来源、不知道上游系统是否会重试、也不知道用户行为模式。幂等性是个系统级属性,需要从整条调用链去设计,不可能在单个方法里通过代码审查发现。
老炮的补充方案:在进入领积分逻辑之前,先查一下本次请求的幂等键在流水表里是否存在,存在就直接返回成功,用唯一索引兜底。
4.3 依赖冲突引出的运行时异常:AI看不到编译期的classpath
还有一个坑更隐蔽。系统里某个内部工具jar和项目里的fastjson版本对不上,导致在生产环境偶发NoSuchMethodError,但本地开发环境怎么跑都正常。
你把源码喂给AI,AI看到的是JSON.parseObject()这个调用,会觉得再普通不过。它不可能知道Maven依赖树里到底解析到哪个版本的fastjson,也不可能知道这个版本里有没有那个方法。这类问题依赖的是编译期classpath解析结果和运行时环境,纯静态审查根本无解。
4.4 AI漏检的机制原因总结
把这几类漏网之鱼摆在一起看,原因很清晰:AI的审查视野完全由你喂给它的材料决定。它没有部署视角、没有调用链视角、没有真实数据、不知道团队历史,更没法主动问"这个任务有没有跨机器执行"这种问题。它能做的,是在你划定的源码范围内做模式匹配和逻辑推断。
5. AI代码审查的正确姿势:把它当实习生,别当裁判
5.1 推荐的三阶段流程
经过这一轮实战,我现在给团队定的AI辅助代码审查流程是这样的:
第一阶段,AI初筛。把核心代码按模块分批次交给AI,明确输出格式和关注点,让它产出"疑似问题清单"。这个阶段的核心指标是覆盖率,宁可要一些误报,也别让它漏掉明显Bug。
第二阶段,人工复核。安排资深开发逐条过,结合业务上下文、部署架构、历史原因做裁决。每个问题都要回答三个问题:它会不会发生?发生了影响多大?修改成本多高?只有三个答案都指向"该改"才进工单。
第三阶段,修复后回归。改完的代码再丢给AI看一遍,确认修复方案没有引入新问题。这样还能顺便验证AI的修复建议是否真的解决了它自己提出的问题。
5.2 要求AI给出"证据链",不给结论
我这次实践下来,最好的一个用法是要求AI输出"触发条件"和"为什么"。比如它说"这段代码存在性能隐患",紧接着就会看到它标注出循环体的位置、对应的SQL语句、以及达到什么量级会触发风险。这种带证据链的输出,复核起来非常省力,不用自己再去翻代码验证它是不是瞎说。
反过来,如果AI只给结论不给理由,比如"建议采用更优雅的设计模式",这类输出基本可以直接忽略,因为它没有把问题落到具体场景里。
用AI审代码还有一个绕不开的现实问题:信噪比。AI发现20个问题,其中5个是误报,这个比例其实已经算不错了。我见过有些人直接把AI输出粘贴给团队成员,让新人按单子修,结果新人把不该改的也改了,反而引入了回归。这就是没设置"复核人"这个角色的后果。
5.3 一次AI审查最大的产出不是问题清单,是团队标准
复盘这次的整个过程,我觉得最有价值的不是那15个被确认的坑,而是复核过程中形成的一套判断标准。比如Java老项目里哪些写法是红线、哪些是建议、哪些可以不动;什么情况下允许保留手工拼接SQL但必须加白名单校验;定时任务上线前必须确认有没有多实例锁。这些标准以前散落在几个老开发脑子里,现在变成了一张可以被检查、被讨论、被培训的清单。
5.4 给准备尝试AI审查的人几个具体建议
如果你想在自己项目里复刻这个流程,我有几条实操建议:
- 第一批先拿"已经出过事故"的模块试水,这样AI的输出可以对标已知问题,方便评估准确率。
- 提示词里务必写明"不要为了凑数硬凑问题",否则AI会把代码风格和真正的Bug混在一起,增加你的复核负担。
- 不要一次喂超过50个方法,AI的处理质量会随上下文长度明显下降。分批次、按模块喂,效果要比一次性全量丢进去好得多。
- 遇到AI说"上下文不足"的标注,优先重点关注。这往往意味着它看到了风险,但缺业务信息无法确认,这种地方最值得人工细看。
老项目维护这件事,本质上就是在一个不那么完美的代码库里持续做出理性的取舍判断。AI能帮你把水面下的礁石更快、更全地标出来,但最终要承认哪些是真障碍、哪些只是看上去危险,还是得自己动手潜下去看。以后我大概率会继续用AI做初筛,但每一份审查报告,我都会保留"人工复核"这一道工序。