在近期开发一个开源AI面试平台项目时,我深度体验了Claude Code等AI编程工具。其产出效率确实惊人,但同时也伴随着一些隐蔽的陷阱。AI往往能写出语法正确的代码,却可能在框架底层原理、异步时序、资源管理等层面埋下隐患。
本文将复盘项目中遇到的4个由AI生成的典型Bug,每一个都对应着面试中的高频考点。理解这些案例背后的原理,能帮助你在使用AI辅助编程时更好地进行代码审计。
1. @Transactional 自调用导致事务增强失效
这是Spring事务失效最经典的场景之一。
场景还原
在知识库上传逻辑中,AI工具为了省事,直接在同一个Service类里编写了两个带事务注解的方法,并在方法内部使用 this 关键字调用另一个方法。
说明:这里的核心问题是“事务AOP代理被绕过”。为突出事务问题,下面的示例暂不涉及异步实现。
问题代码
@Service
public class KnowledgeBaseUploadService {
@Transactional
public void uploadAndProcess(File file) {
saveFile(file);
// 自调用:绕过代理对象,导致 processInNewTx() 上的事务配置不生效
this.processInNewTx(file);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processInNewTx(File file) {
// 期望在新事务运行(REQUIRES_NEW),但自调用会导致该注解不生效
// 结果:不会开启“新事务”,而是在外层事务上下文中执行
}
}
原理分析
Spring 基于 @Transactional 的事务管理是通过 AOP 动态代理 实现的。理解其AOP代理原理对于排查此类问题至关重要。
- 正常流程:外部通过Bean调用
service.method() → 进入 Proxy 代理对象 → 事务拦截器执行,开启/加入事务 → 执行目标方法。
- 自调用流程:在类内部通过
this 调用 → 直接调用 目标对象(Target) → 事务拦截器根本没有机会执行 → processInNewTx() 上配置的事务传播属性(如 REQUIRES_NEW)完全失效。
修复方案
核心原则是确保方法调用必须 “经过代理” ,避免使用 this.xxx() 进行内部调用。
最稳妥的做法是将需要独立事务的方法拆分到另一个Bean中,通过注入的方式进行调用,这样可以保证AOP增强生效。
@Service
@RequiredArgsConstructor
public class KnowledgeBaseUploadService {
private final KnowledgeBaseProcessService processService;
@Transactional
public void uploadAndProcess(File file) {
saveFile(file);
processService.processInNewTx(file); // 通过代理调用,事务增强生效
}
}
@Service
public class KnowledgeBaseProcessService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processInNewTx(File file) {
// 现在可以保证在新的事务边界内执行
}
}
2. AI 响应解析空指针异常
现象
面试评估功能在进行压力测试时,偶尔会抛出 NullPointerException,导致整个面试流程中断。
原因
大语言模型(LLM)的输出具有不确定性。即使在提示词中严格要求“返回JSON格式”,仍可能出现以下情况:
- 某些字段被省略或值为
null。
- 返回结构发生变化(如字段名拼写错误)。
- 数据类型不一致(如数字被返回为字符串)。
- Token被截断导致JSON不完整。
AI生成的解析代码往往过于乐观,直接信任了AI的输出,缺乏必要的防御性检查。
// 问题代码:dto.questionEvaluations() 可能为 null
for (int i = 0; i < dto.questionEvaluations().size(); i++) { // 此处可能引发NPE!
// ...
}
修复
在LLM应用开发中,永远不要信任外部输入,这包括AI模型的输出。建议构建两层防护机制:
- 解析层:确保响应体结构可被解析,必要时进行Schema或字段校验。
- 业务层:保证核心业务流程不会因解析问题而崩溃,需设计兜底策略并记录异常。
List<QuestionEvaluationDTO> evaluations = dto.questionEvaluations();
if (evaluations == null || evaluations.isEmpty()) {
log.error(“AI 响应异常:面试评估列表缺失或为空,sessionId={}“, sessionId);
// 兜底策略:降级为“无评估”,保证流程继续
evaluations = Collections.emptyList();
// 也可根据业务需要,返回默认评估结果
}
// 只处理与问题数量对齐的部分,避免数组越界
int n = Math.min(evaluations.size(), questions.size());
for (int i = 0; i < n; i++) {
QuestionEvaluationDTO ev = evaluations.get(i);
// 对 ev 内部的字段(如 ev.getScore())同样需要进行非空校验和默认值处理
}
3. 删除实体后异步任务报错
现象
后台日志频繁出现 简历不存在: ID=35 的错误。经排查,原因是用户删除了简历实体,但针对该简历的分析异步任务仍在执行队列中。
原因
这是一个典型的由操作时序引发的数据一致性问题:
- 用户上传简历 → 系统发送分析任务到消息队列(如Redis Stream)。
- 分析任务因故失败 → 消息进入pending状态等待重试。
- 用户删除简历 → 数据库中的简历记录被物理删除。
- 消费者重试处理该消息 → 无法根据ID找到简历实体 → 报错。
修复
解决方案是在异步任务处理的最前端进行“生命周期哨兵检查”,并严格区分错误类型:
- 不可恢复错误(如实体不存在、参数非法):记录日志后确认消息(ACK)并丢弃,避免无意义重试。
- 可恢复错误(如临时网络故障、依赖服务超时):不确认消息,让其进入重试流程或死信队列。
以下示例通过一次查询代替 existsById + findById 的两次查询,更为高效:
private void processMessage(StreamMessageId messageId, Map<String, String> data) {
Long resumeId = Long.parseLong(data.get(“resumeId“));
var resumeOpt = resumeRepository.findById(resumeId);
if (resumeOpt.isEmpty()) {
// 不可恢复错误:实体已被用户删除
log.warn(“检测到实体已被删除,跳过异步任务: resumeId={}“, resumeId);
ackMessage(messageId); // 必须ACK,防止消息反复重试造成噪音堆积
return;
}
try {
Resume resume = resumeOpt.get();
// 继续执行业务逻辑...
ackMessage(messageId);
} catch (TransientDependencyException e) {
// 可恢复错误:不ACK,让其进入重试机制
log.warn(“依赖服务异常,等待重试: resumeId={}, msgId={}“, resumeId, messageId, e);
throw e;
} catch (Exception e) {
// 根据业务策略决定是否ACK、重试或转入死信
log.error(“处理失败: resumeId={}, msgId={}“, resumeId, messageId, e);
throw e;
}
}
4. Redis Stream 消息无限堆积
现象
监控发现Redis中某个Stream积压了上百条消息,并且数量持续增长。
原因
一个常见的认知误区是:XACK 命令仅用于确认消息已被消费,它会将消息从消费者组的 PEL(Pending Entries List,待处理列表) 中移除,但不会删除Stream中的消息条目本身。
XADD:向Stream添加消息。
XREADGROUP:消费者组读取消息。
XACK:确认消费(从PEL移除)。
XDEL:从Stream中物理删除指定消息条目。
XTRIM / MAXLEN:裁剪Stream,限制其长度,删除旧条目。
如果既没有在消费后调用 XDEL,也没有在生产和消费环节使用 XTRIM 或设置 MAXLEN,那么Stream中的历史消息就会不断累积,占用大量内存。在生产环境中,最推荐的方式是在写入时直接指定 MAXLEN,实现类似定长环形队列的效果。
另外需注意区分另一种“PEL堆积”:消费者崩溃或未调用 XACK,导致待确认的消息在PEL中越来越多。两者需要不同的排查思路。
修复
在发送消息时添加 MAXLEN 限制,让Redis自动裁剪超出长度的旧消息:
// 修复前 - 可能无限堆积
stream.add(StreamAddArgs.entries(message));
// 修复后 - 自动裁剪,只保留最新的1000条消息
stream.add(StreamAddArgs.entries(message)
.trimNonStrict().maxLen(1000));
trimNonStrict(): 使用近似裁剪(~),性能更好。
maxLen(1000): 限制Stream最大长度为1000条。
总结
AI编程工具极大地提升了代码产出效率,但同时也对开发者的代码审计能力提出了更高要求。我们不能只做代码的“搬运工”,而必须理解每一行代码背后的框架原理和系统边界。
| Bug场景 |
核心考察点 |
关键词 |
| 事务失效 |
Spring AOP 代理原理 |
this 调用、自调用 |
| AI 解析 NPE |
系统的鲁棒性与防御性编程 |
输入校验、不可信输入 |
| 异步报错 |
分布式时序与数据一致性 |
生命周期管理、哨兵检查 |
| Stream 堆积 |
中间件的存储与清理机制 |
XACK vs XDEL、MAXLEN |
对AI生成的代码进行“穿透式Review”至关重要。你需要追问:这段代码在JVM中如何执行?在分布式环境下会有什么并发问题?它所依赖的中间件是如何工作的?这份深入原理的理解,正是高级开发者不可或缺的护城河。
希望这些来自实战的案例解析能对你有所帮助。如果你在开发中也遇到过类似的“AI陷阱”,欢迎在云栈社区交流讨论。