搜狐技术产品

好家伙,Claude Code 竟然写了这么多 Bug!

大家好,我是 Guide!元旦假期我不是开源了 InterviewGuide 这个 AI 项目嘛,这个项目开发过程中,深度体验了 Claude Code、Cursor 等 AI 编程工具。

不得不说,AI 的产出效率确实惊人,但“效率越高,坑埋得越深”。AI 往往能写出逻辑通顺的代码,却可能在 Spring 框架底层原理、异步时序、资源管理等方面给你“下毒”。

今天,复盘一下在 InterviewGuide 开发中,被 Claude 坑过的 4 个典型 Bug。每一个都是面试高频考点,建议反复阅读,避免在你的项目里留下“定时炸弹”。

1. @Transactional 自调用导致事务增强失效

这是 Spring 事务失效最经典的场景,也是面试高频考点!

场景还原

在知识库上传逻辑中,Claude 帮我写了一个“上传后继续处理”的流程。它为了省事,直接在同一个 Service 类里写了两个带事务的方法,然后在方法内部用 this 调用另一个方法。

说明:这里的核心问题是“事务 AOP 代理被绕过”,与是否异步无关。下面示例为突出事务问题,先不展开异步实现。

问题代码

@Service
publicclassKnowledgeBaseUploadService{

@Transactional
publicvoiduploadAndProcess(File file){
        saveFile(file);

// 自调用:绕过代理对象,导致 processInNewTx() 上的事务配置不生效
this.processInNewTx(file);
    }

@Transactional(propagation = Propagation.REQUIRES_NEW)
publicvoidprocessInNewTx(File file){
// 期望在新事务运行(REQUIRES_NEW),但自调用会导致该注解不生效
// 结果:不会开启“新事务”,往往会在当前调用上下文(通常是外层事务)里执行
    }
}

原理分析

Spring 基于的@Transactional是AOP 动态代理实现的。

  • 正常流程:外部 Bean 调用service.method()→ 进入Proxy 代理对象→ 开启/加入事务 → 执行目标方法
  • 自调用流程:内部通过this调用 → 直接调用目标对象(Target) → 事务拦截器根本没机会执行 →processInNewTx()上的事务传播属性(如REQUIRES_NEW)不会生效

修复方案

核心原则:调用必须“跨过代理”,不要this.xxx()。

最稳定的做法是:把需要新事务的方法拆到另一个 Bean 上,通过注入后调用(确保经过代理)。

@Service
@RequiredArgsConstructor
publicclassKnowledgeBaseUploadService{

privatefinal KnowledgeBaseProcessService processService;

@Transactional
publicvoiduploadAndProcess(File file){
        saveFile(file);
        processService.processInNewTx(file); // 通过代理调用,事务增强生效
    }
}

@Service
publicclassKnowledgeBaseProcessService{

@Transactional(propagation = Propagation.REQUIRES_NEW)
publicvoidprocessInNewTx(File file){
// 现在可以保证以新事务边界执行
    }
}

2. AI 响应解析空指针

现象

面试评估功能在压力测试时,偶尔会报 NullPointerException,导致整个面试流程中断。

原因

LLM 的输出具有随机性。即使在提示里要求“必须返回 JSON”,也可能出现:

  • 字段删除或为null
  • 结构变化(字段名拼错)
  • 类型不一致(数字变字符串)
  • token 截断/中断导致 JSON 不完整

Claude 写的解析代码太“简单”,直接相信了 AI 的输出。

// 问题代码:dto.questionEvaluations() 可能为 null
for (int i = 0; i < dto.questionEvaluations().size(); i++) {
// NPE!
}

修复

在 LLM 应用里,永远不要信任外部输入(包括 AI)。建议做到两层防护:

  1. 解析层:保证结构可解析(必要时做 schema/字段校验)
  2. 业务层:保证流程不崩溃(兜底策略+记录异常)
List<QuestionEvaluationDTO> evaluations = dto.questionEvaluations();

if (evaluations == null || evaluations.isEmpty()) {
    log.error("AI 响应异常:面试评估列表缺失或为空,sessionId={}", sessionId);

// 兜底策略示例:直接降级为“无评估”,保证流程继续
    evaluations = Collections.emptyList();
// 也可以 return + 返回默认评估结果,看你的业务需要
}

// 只处理与问题数量对齐的部分,避免越界
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 的 Error。检查发现,这是由于用户删除了简历,但分析任务还在跑。

原因

这是导致数据不一致的典型问题:

  1. 用户上传简历 → 发送分析任务到 Redis Stream
  2. 分析失败 → 消息进入 pending/等待重试
  3. 用户删除简历 → 数据库记录已删除
  4. 消费者重试处理 → 找不到简历 → 报错

修复

把“生命周期校验”放在异步任务处理的最前面,并区分:

  • 不可恢复错误(实体不存在、参数非法)→ 记录后 ACK/丢弃
  • 可恢复错误(临时网络故障、依赖服务超时)→ 不 ACK,让其重试或进入重试队列

示例(用一次查询代替existsById + findById两次查询):

privatevoidprocessMessage(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 积累了 100+ 条消息,且持续增长。

原因

一个常见的误区:XACK 只是确认消息已被消费,不会删除 Stream 里的消息条目。

  • XADD:写入消息到 Stream
  • XREADGROUP:消费者组读取消息
  • XACK:确认消费(把消息从消费者组的 PEL / Pending Entries List “待处理列表”里移除)
  • XDEL:从 Stream 中删除指定消息条目
  • XTRIM / MAXLEN:裁剪 Stream(限制 Stream 长度,删除较旧的条目)

如果你既没有 XDEL,也没有 XTRIM/MAXLEN,那么 Stream 里的历史消息会持续累积,占用内存/磁盘。生产环境中,最推荐的方式是在写入时直接指定 MAXLEN,实现类似于定长环形队列的效果。

另外还有一种“堆积”是 PEL 堆积:消费者没有 XACK,导致待确认(pending)的消息越来越多。两者要区分排查。

修复

发送消息时添加 MAXLEN 限制,自动裁剪旧消息:

// 修复前
stream.add(StreamAddArgs.entries(message));

// 修复后(自动裁剪超过 1000 条的旧消息)
stream.add(StreamAddArgs.entries(message)
    .trimNonStrict().maxLen(1000));
  • trimNonStrict(): 使用近似裁剪(~),性能更好
  • maxLen(1000): 保留最新 1000 条消息

总结

AI 编程时代的到来,确实降低了代码的编写门槛,但也拔高了代码审计的门槛,对使用者能力的要求还是有门槛的。

Bug 场景考察点关键词
事务失效
Spring AOP 代理原理
this
 调用、自调用
AI 解析 NPE
系统的鲁棒性与健壮性
防御性编程、输入不可信
异步报错
分布式时序与一致性
生命周期、哨兵检查
Stream 堆积
中间件底层存储机制
XACK
 vs XDEL、MAXLEN

AI 写的代码,一定要经过你的“穿透式 Review”。理解每一行代码背后的框架原理和系统边界,才是你作为核心开发者的护城河。

源码地址:

  • Github 地址:https://github.com/Snailclimb/interview-guide
  • Gitee 地址:https://gitee.com/SnailClimb/interview-guide
图片