我用 Claude Code 给 Spring Boot 老项目做了一次代码 Review:查出 4 类隐藏问题

接手 Spring Boot 老项目时,真正麻烦的往往不是编译报错,而是分散在业务代码中的空指针、N+1 查询、异常吞没和并发隐患。本文记录一次使用 Claude Code 辅助代码 Review 的完整流程,并给出提示词、Java 示例、修改方案和人工复核方法。
项目能运行,不代表代码没有问题
前段时间接手一个维护多年的 Spring Boot 项目,编译正常,接口也能访问,但团队里几乎没人敢轻易修改核心模块。
原因并不复杂。这个项目经历过多轮需求迭代,同一个业务规则散落在 Controller、Service、定时任务和消息消费者中。修改一个判断条件,可能影响另一个几个月没人碰过的流程。
更麻烦的是,项目里的大部分问题不会在编译阶段暴露。空指针只有遇到特殊数据才会出现,N+1 查询要等数据量增加后才会变慢,并发问题甚至可能只在线上高峰期偶发。
我决定先用 Claude Code 做一轮辅助代码 Review,但没有让它直接修改项目,而是要求它完成三件事:
- 理清项目结构和核心调用关系;
- 找出有明确代码证据的风险;
- 按严重程度生成待人工确认的问题清单。
这次排查最终找出了四类值得处理的问题。

不要直接输入“帮我检查整个项目”
刚开始使用 AI 做代码审查时,很多人会输入一句:
帮我检查一下这个项目有哪些问题。
这样的任务范围太大,输出结果通常会变成一组正确但没有执行价值的建议:
建议完善异常处理
建议增加单元测试
建议优化数据库查询
建议提高代码可读性
这些建议放在任何 Java 项目中都成立,却不能直接告诉开发者应该修改哪个文件、什么情况下会出错。
我后来把任务拆成了两轮。
第一轮:只理解项目,不修改代码
请读取当前 Spring Boot 项目的目录结构、pom.xml 和主要配置文件。
需要输出:
1. 项目使用的主要技术栈;
2. Controller、Service、Mapper、定时任务和消息消费者的分布;
3. 核心业务模块及依赖关系;
4. 可能涉及数据库写入、事务和外部接口调用的模块。
暂时不要修改任何文件,也不要输出通用优化建议。
这一步的目的不是发现 Bug,而是检查 AI 有没有理解项目。
如果它连核心模块、技术栈和调用关系都判断错了,后面的代码审查结果也很难可信。
第二轮:按风险类型逐项扫描
请对当前项目进行第一轮代码风险审查,暂时不要修改代码。
重点检查:
1. 可能出现空指针异常的位置;
2. 循环内执行数据库查询的代码;
3. 捕获异常后没有记录、没有转换、没有继续抛出的代码;
4. 单例对象中共享非线程安全变量的代码;
5. 文件流、数据库连接或线程池未正确关闭的问题。
每个问题必须包含:
- 严重级别
- 文件路径
- 类名和方法名
- 相关代码
- 触发条件
- 可能影响
- 修改建议
无法确认的问题请标记为“需要人工验证”,不要直接下结论。
任务被限制后,AI 输出的内容会更像一份可以执行的代码审查报告,而不是泛泛而谈的项目建议。
问题一:测试数据正常,历史数据却可能触发空指针
项目中有一个订单详情查询方法:
public OrderVO getOrderDetail(Long orderId) {
Order order = orderMapper.selectById(orderId);
OrderVO vo = new OrderVO();
vo.setId(order.getId());
vo.setUserName(order.getUser().getName());
return vo;
}
这段代码在测试环境里一直没有报错,因为测试订单都有对应的用户数据。
但生产环境中存在部分历史订单。用户信息已经被清理,订单记录仍然保留。这时 order.getUser() 可能返回 null。
此外,代码还存在另外两个风险:
- 调用方可能传入空的
orderId; - 数据库中可能不存在对应订单。
可以先把参数和查询结果处理清楚:
public OrderVO getOrderDetail(Long orderId) {
if (orderId == null) {
throw new IllegalArgumentException("orderId must not be null");
}
Order order = orderMapper.selectById(orderId);
if (order == null) {
throw new OrderNotFoundException(
"Order does not exist, orderId=" + orderId
);
}
OrderVO vo = new OrderVO();
vo.setId(order.getId());
User user = order.getUser();
vo.setUserName(user == null ? "未知用户" : user.getName());
return vo;
}
这段修改只能作为结构示例,不能不看业务规则直接复制。
当关联用户不存在时,有些系统允许订单继续展示,只把用户名标记为“未知用户”;有些系统则需要把订单识别为异常数据,并通知运营人员修复。
AI 可以发现空指针风险,却无法只凭一个方法确定最终业务语义。这类问题必须由熟悉订单规则的人确认。
问题二:列表接口里隐藏着 N+1 查询
订单列表接口中还有一段常见写法:
List<Order> orders = orderMapper.selectByCondition(condition);
List<OrderVO> result = new ArrayList<>();
for (Order order : orders) {
User user = userMapper.selectById(order.getUserId());
OrderVO vo = new OrderVO();
vo.setOrderId(order.getId());
vo.setUserName(user == null ? "" : user.getName());
result.add(vo);
}
代码逻辑很直观,却会随着订单数量增加产生大量 SQL。
假设列表查询返回 100 条订单,程序会执行:
1 次订单列表查询
+
100 次用户查询
=
101 次数据库查询
本地测试数据只有几条时,接口响应可能没有明显异常。生产环境数据增加后,数据库连接占用和接口延迟都会上升。
一种处理方式是先收集用户 ID,再批量查询:
List<Order> orders = orderMapper.selectByCondition(condition);
Set<Long> userIds = orders.stream()
.map(Order::getUserId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, User> userMap;
if (userIds.isEmpty()) {
userMap = Collections.emptyMap();
} else {
userMap = userMapper.selectBatchIds(userIds)
.stream()
.collect(Collectors.toMap(
User::getId,
Function.identity(),
(oldValue, newValue) -> oldValue
));
}
List<OrderVO> result = orders.stream()
.map(order -> {
OrderVO vo = new OrderVO();
vo.setOrderId(order.getId());
User user = userMap.get(order.getUserId());
vo.setUserName(user == null ? "" : user.getName());
return vo;
})
.collect(Collectors.toList());
这样可以把查询数量从 1+N 降到两次:
1 次订单列表查询
+
1 次用户批量查询
另一种方案是直接使用关联查询:
SELECT
o.id AS order_id,
o.user_id,
u.name AS user_name
FROM orders o
LEFT JOIN users u
ON u.id = o.user_id
WHERE o.status = #{status}
ORDER BY o.created_at DESC;
不能看到 N+1 就机械地改成 JOIN。
当关联表字段较多、查询条件复杂,或者业务需要分别缓存数据时,批量查询后在内存中组装可能更容易维护。最终方案还要结合分页方式、索引设计、数据规模和数据库执行计划判断。
问题三:异常被捕获了,但调用方完全不知道任务失败
文件导入模块中有一段代码:
public void importFile(Path filePath) {
try {
InputStream inputStream = Files.newInputStream(filePath);
parseFile(inputStream);
} catch (Exception e) {
System.out.println("导入失败");
}
}
这里不只是日志不规范,还同时存在两个问题。
一是 InputStream 没有明确关闭。二是异常被捕获后没有继续抛出,调用方会误以为导入已经成功。
可以改成:
public void importFile(Path filePath, String taskId) {
try (InputStream inputStream = Files.newInputStream(filePath)) {
parseFile(inputStream);
} catch (FileFormatException e) {
log.warn(
"File format is invalid, taskId={}, filePath={}, reason={}",
taskId,
filePath,
e.getMessage()
);
throw e;
} catch (IOException e) {
log.error(
"Failed to read import file, taskId={}, filePath={}",
taskId,
filePath,
e
);
throw new FileImportException("读取导入文件失败", e);
} catch (Exception e) {
log.error(
"Unexpected import error, taskId={}, filePath={}",
taskId,
filePath,
e
);
throw new FileImportException("文件导入失败", e);
}
}
try-with-resources 可以确保当前方法创建的流被正确关闭,异常也会继续传递给上层。
不过,异常处理还要避免另一个极端:每一层都打印一次完整堆栈。
如果底层 Service、任务调度层和全局异常处理器都记录同一个异常,日志中可能出现三条几乎相同的报错。更合理的做法是在拥有业务上下文的位置记录日志,并明确哪一层负责最终异常输出。
问题四:Spring 单例 Service 中共享 HashMap
价格服务中使用了一个成员变量保存计算结果:
@Service
public class PriceService {
private final Map<Long, BigDecimal> priceCache = new HashMap<>();
public BigDecimal calculate(Long productId) {
if (priceCache.containsKey(productId)) {
return priceCache.get(productId);
}
BigDecimal price = queryPrice(productId);
priceCache.put(productId, price);
return price;
}
private BigDecimal queryPrice(Long productId) {
// 查询数据库或调用外部服务
return BigDecimal.ZERO;
}
}
Spring 默认创建的 Service 通常是单例对象,多个请求会同时访问同一个 priceCache。
HashMap 不是线程安全容器,containsKey、get 和 put 也不是一个完整的原子操作。并发量上升后,可能出现重复计算、缓存状态异常或不可预测的读取结果。
基础修改可以使用 ConcurrentHashMap:
@Service
public class PriceService {
private final ConcurrentMap<Long, BigDecimal> priceCache =
new ConcurrentHashMap<>();
public BigDecimal calculate(Long productId) {
if (productId == null) {
throw new IllegalArgumentException(
"productId must not be null"
);
}
return priceCache.computeIfAbsent(
productId,
this::queryPrice
);
}
private BigDecimal queryPrice(Long productId) {
BigDecimal price = loadPriceFromDatabase(productId);
if (price == null) {
throw new PriceNotFoundException(
"Price does not exist, productId=" + productId
);
}
return price;
}
private BigDecimal loadPriceFromDatabase(Long productId) {
// 根据实际项目实现
return BigDecimal.ZERO;
}
}
这只能解决基础线程安全问题,还不算一套完整缓存方案。

继续往下排查时,需要确认:
- 缓存是否需要过期时间;
- 商品价格更新后如何主动失效;
- 缓存数量是否可能无限增长;
- 查询异常时是否允许缓存旧值;
- 多实例部署后是否要求缓存一致;
- 是否应该使用 Caffeine、Redis 等专业缓存方案。
AI 能发现“单例 Service 共享 HashMap”这个风险,但无法自动确定系统需要本地缓存还是分布式缓存。
我最终采用的代码 Review 工作流

为了避免 AI 一次性修改大量代码,我把整个流程拆成六个阶段。
读取项目结构
先分析目录、依赖、配置和模块关系,不急着找 Bug。
这一阶段需要确认 AI 是否识别出项目使用了 Spring Boot、MyBatis、消息队列、定时任务或其他关键组件。
按风险类型分批检查
空指针、SQL 性能、异常处理、并发安全和资源释放最好分开检查。
任务越具体,越容易获得包含文件位置、触发条件和修改建议的结果。
要求提供代码证据
没有文件路径、类名、方法名和相关代码的问题,不应该直接进入修改清单。
一条可执行的审查结果应该接近下面的格式:
严重级别:高
文件:
src/main/java/com/example/service/PriceService.java
方法:
calculate
问题:
Spring 单例 Service 中共享使用 HashMap
触发条件:
多个请求同时查询不同或相同商品价格
可能影响:
重复计算、缓存状态异常、并发读取结果不可预测
建议:
根据缓存生命周期评估 ConcurrentHashMap、Caffeine 或 Redis
人工确认业务语义
人工复核不只是检查 AI 有没有看错代码,还要确认修改是否符合真实业务。
订单、库存、金额、权限、事务和状态流转等模块,尤其不能只根据局部代码直接修改。
每次只修一类问题
不要同时处理空指针、SQL、缓存和异常体系。
一次只改一种问题,Git Diff 更容易检查,出现回归时也更容易定位原因。
使用测试验证结果
至少需要执行:
mvn clean test
如果项目区分单元测试和集成测试,还应根据项目配置运行对应命令。
代码通过编译不等于业务正确。涉及数据库、消息队列和外部接口时,还需要在测试环境验证真实调用链。
Claude Code、ChatGPT 和 Cursor 应该怎么配合
这三类工具在开发流程中的作用并不完全相同。
Claude Code 更适合读取项目目录、分析跨文件调用关系、整理风险清单和处理需要较长上下文的代码任务。
ChatGPT Plus 更适合解释代码逻辑、讨论重构方案、生成测试思路,以及把复杂问题拆成容易执行的排查步骤。
Cursor Pro 更贴近编辑器工作流,适合在具体文件中定位代码、完成局部修改、查看上下文和检查代码差异。
实际使用时,我更倾向于把任务拆开:
Claude Code:
项目级分析和大范围问题扫描
ChatGPT:
问题解释、方案讨论和测试设计
Cursor:
文件级修改和编辑器内操作
开发者:
业务判断、代码复核和最终决策
工具之间不需要强行选出唯一答案。关键在于让每个工具负责它擅长的环节,而不是把整个项目交给单一工具自动处理。
ChatGPT Plus、Claude Pro、Cursor 等会员充值需求怎么处理
当 ChatGPT、Claude、Cursor 等工具逐渐进入日常开发流程后,开发者除了需要比较功能,还会遇到会员订阅和充值问题。
如果你有 ChatGPT Plus充值、Claude Pro订阅、Cursor会员充值、Grok订阅、Gemini Advanced会员或Kiro订阅 等需求,可以了解 gpt108.com。它是第三方 AI会员充值平台,覆盖 ChatGPT Plus、Claude Pro、Grok、Gemini Advanced、Cursor、Kiro 等 AI 会员和工具充值需求。
gpt108.com 解决的是 AI 会员订阅充值流程问题,不替代 ChatGPT、Claude、Cursor 等工具本身,也不是 OpenAI、Anthropic、Google、xAI、Cursor 或 Kiro 的官方网站及授权合作方。使用前建议看清套餐说明、账号要求和售后规则,再结合自己的实际使用频率选择合适的会员方案。
会员只是工具准备的一部分。代码审查是否有效,仍然取决于项目上下文是否完整、审查问题是否具体,以及修改后有没有经过人工复核和测试验证。
AI 代码审查不能代替哪些工作
不能代替业务判断
AI 可以发现 user == null,但不能确定用户不存在时应该显示默认值、阻止订单展示,还是触发数据修复流程。
不能代替性能测试
发现循环查询不等于已经证明它是当前系统的性能瓶颈。优化前后还要结合 SQL 日志、执行计划和接口耗时验证。
不能代替安全审查
权限校验、敏感数据、文件上传、SQL 注入和依赖漏洞需要专门的安全审查流程,不能只依赖通用代码 Review。
不能代替自动化测试
AI 给出的修改可能语法正确,但仍然可能改变事务边界、返回值或异常类型。没有测试覆盖时,修改风险依旧存在。
容易踩的四个坑
让 AI 直接修改整个项目
项目越大,隐藏的业务约束越多。一次修改几十个文件,看起来效率很高,实际很难检查每个变化是否合理。
接受没有证据的结论
“这里可能有性能问题”并不足以创建修复任务。至少需要文件位置、相关代码、触发条件和预期影响。
不检查 Git Diff
修改完成后,要重点检查方法签名、事务注解、异常类型、空值处理和返回结构是否发生变化。
把“测试通过”理解成“完全正确”
测试只能验证已有用例覆盖到的行为。历史数据、并发请求和外部服务异常仍然需要额外验证。
复盘
这次 Claude Code 辅助代码 Review 找出的四类问题都不算罕见:
- 空指针风险;
- N+1 数据库查询;
- 异常吞没和资源未关闭;
- 单例对象中的并发安全问题。
真正节省时间的地方,不是让 AI 自动写了多少代码,而是它先把分散在多个模块中的风险集中整理出来,并给出了文件位置、触发条件和修改方向。
更可靠的使用方式是:
AI 负责扩大检查范围,开发者负责判断业务语义,测试负责验证修改结果。
接手老项目时,可以先选择一个风险较低的模块,按照“分析结构—生成清单—人工确认—局部修改—测试验证”的流程跑一遍。确认结果可靠后,再逐步扩大审查范围。
你在维护 Java 老项目时,遇到过最难定位的问题是什么?是空指针、慢 SQL、事务异常,还是并发场景下的偶发 Bug?
更多推荐



所有评论(0)