异步任务不用 Kafka 也行:用 Redis Stream 搭一套轻量级 Producer/Consumer 框架

异步任务不用 Kafka 也行:用 Redis Stream 搭一套轻量级 Producer/Consumer 框架

项目地址:interview-agent

技术栈:Java 21 / Spring Boot 4.0 / Redis 7 (Redisson) / PostgreSQL

问题:LLM 调用太慢,同步扛不住

简历分析、知识库向量化、面试评估------这三个操作有一个共同点:都要调 LLM,都要等好几秒

如果用同步方式处理,用户上传一份简历后要盯着 loading 转 10 秒;上传一份知识库文档后要等 embedding 跑完才能做其他事。这体验太差了。

解决方案很直觉:异步化。先写一条"待处理"记录,告诉用户"已提交",后台慢慢跑。但异步化引出了一串新问题:

  • 任务放哪里?数据库轮询太重,Kafka/RabbitMQ 对这个项目来说太重
  • 失败了怎么办?LLM 调用会超时、会返回格式错误、会限流
  • 用户删了简历,正在跑的分析任务怎么处理?
  • 多实例部署时,怎么保证同一个任务不被重复消费?

这篇文章记录了 Interview Agent 项目怎么用 Redis Stream + 模板方法模式解决这些问题,搭了一套只有两个基类、三条流水线的轻量异步框架。

为什么是 Redis Stream 而不是 Kafka

选型的理由很简单:

Kafka RabbitMQ Redis Stream
部署复杂度 高(ZooKeeper/KRaft) 低(已有 Redis)
消费者组支持
消息持久化 磁盘 磁盘 内存 + AOF
延迟 毫秒级 毫秒级 毫秒级
运维成本 低(复用现有 Redis)

项目已经有 Redis(用于缓存和分布式锁),不需要额外引入消息中间件。Redis Stream 的消费者组(Consumer Group)功能够用------支持多消费者、ACK 机制、pending entry list。对于一个单体应用的异步任务队列,它刚好够。

两个基类搞定三条流水线

整个异步框架只有两个抽象类:AbstractStreamProducer<T>AbstractStreamConsumer<T>。三条流水线(简历分析、知识库向量化、面试评估)各有一对 Producer/Consumer 实现。

Producer:发送就完了

Producer 的核心逻辑只有 30 行:

java 复制代码
public abstract class AbstractStreamProducer<T> {

    private final RedisService redisService;

    protected void sendTask(T payload) {
        try {
            String messageId = redisService.streamAdd(
                streamKey(),
                buildMessage(payload),
                AsyncTaskStreamConstants.STREAM_MAX_LEN  // 1000
            );
            log.info("{}任务已发送到Stream: {}, messageId={}",
                taskDisplayName(), payloadIdentifier(payload), messageId);
        } catch (Exception e) {
            log.error("发送{}任务失败: {}, error={}",
                taskDisplayName(), payloadIdentifier(payload), e.getMessage(), e);
            onSendFailed(payload, "任务入队失败: " + e.getMessage());
        }
    }

    // 子类实现这 5 个方法就够了
    protected abstract String taskDisplayName();
    protected abstract String streamKey();
    protected abstract Map<String, String> buildMessage(T payload);
    protected abstract String payloadIdentifier(T payload);
    protected abstract void onSendFailed(T payload, String error);
}

几个设计要点:

发送失败不抛异常sendTask() catch 了所有异常,转交给 onSendFailed() 让子类更新数据库状态。调用方不需要关心 Redis 是否可用------如果 Redis 挂了,任务状态会变成 FAILED,用户可以在界面上看到并手动重试。

STREAM_MAX_LEN = 1000 自动裁剪 。Redis Stream 会用 XADD ... MAXLEN ~ 1000 自动丢弃最老的消息。这防止了 Stream 无限增长------如果消费者挂了很久没消费,老消息会被自动清理。对于异步任务来说,超过 1000 条未处理的消息意味着系统已经出了大问题,老消息也没有处理价值了。

Payload 用 Map<String, String> 序列化 。不走 JSON 序列化框架,直接用 Map.of("resumeId", id.toString(), "content", text) 构造消息体。简单、无依赖、可读。

Consumer:模板方法的经典应用

Consumer 是整个框架最复杂的部分。它管理了完整的生命周期:初始化、消费循环、消息处理、ACK、重试、优雅关闭。

初始化
java 复制代码
@PostConstruct
public void init() {
    this.consumerName = consumerPrefix() + UUID.randomUUID().toString().substring(0, 8);

    try {
        redisService.createStreamGroup(streamKey(), groupName());
    } catch (Exception e) {
        log.warn("创建消费者组时发生异常(可能已存在): {}", e.getMessage());
    }

    this.executorService = Executors.newSingleThreadExecutor(r -> {
        Thread t = new Thread(r, threadName());
        t.setDaemon(true);
        return t;
    });

    running.set(true);
    executorService.submit(this::consumeLoop);
}

消费者名用 UUID 后缀 。每个应用实例的消费者都有唯一名称(如 analyze-consumer-a3b2c1d0),但它们在同一个消费者组里。Redis 会自动在组内分配消息,实现水平扩展。

消费者组创建幂等 。Redis 返回 BUSYGROUP 错误时静默忽略。不管是第一次启动还是重启,代码都不需要特殊处理。

守护线程t.setDaemon(true) 保证 Spring 关闭时线程不会阻止 JVM 退出。

消费循环
java 复制代码
private void consumeLoop() {
    while (running.get()) {
        try {
            redisService.streamConsumeMessages(
                streamKey(), groupName(), consumerName,
                AsyncTaskStreamConstants.BATCH_SIZE,       // 10
                AsyncTaskStreamConstants.POLL_INTERVAL_MS,  // 1000ms
                this::processMessage
            );
        } catch (Exception e) {
            if (Thread.currentThread().isInterrupted()) {
                break;
            }
            log.error("消费消息时发生错误: {}", e.getMessage(), e);
        }
    }
}

消费循环用的是 Redis 的 阻塞读XREADGROUP with timeout),不是客户端轮询。服务端会阻塞最多 1000ms 等待新消息,期间不消耗客户端 CPU。每批最多读 10 条消息。

这里有一个 Redisson 的 bug workaround:

java 复制代码
// RedisService.readStreamMessages()
try {
    return stream.readGroup(groupName, consumerName, args);
} catch (ClassCastException e) {
    if (isBlockingReadTimeoutDecodeBug(e, blockTimeoutMs)) {
        return Map.of();  // 超时时 Redisson 返回了错误类型,当空结果处理
    }
    throw e;
}

Redisson 在阻塞读超时时会抛 ClassCastException(把 Collections$EmptyListMap 返回)。这个 bug 在超时时触发,不影响正常消息消费,但需要 catch 掉。

消息处理与重试
java 复制代码
private void processMessage(StreamMessageId messageId, Map<String, String> data) {
    T payload = parsePayload(messageId, data);
    if (payload == null) {
        ackMessage(messageId);  // 格式错误的消息直接 ACK 跳过
        return;
    }

    int retryCount = parseRetryCount(data);
    try {
        markProcessing(payload);
        processBusiness(payload);
        markCompleted(payload);
        ackMessage(messageId);
    } catch (Exception e) {
        if (shouldRetry(e, retryCount)) {
            retryMessage(payload, retryCount + 1);
        } else {
            markFailed(payload, truncateError(...));
        }
        ackMessage(messageId);  // 失败也 ACK
    }
}

这段代码最关键的设计决策是:Always ACK

不管是成功、失败还是重试,原消息都会被 ACK。重试不是靠 Redis 的 PEL(Pending Entry List)机制,而是发一条新消息到 Stream,带上递增的 retryCount

为什么不走 PEL?因为 PEL 的管理很复杂------需要 XCLAIM 转移所有权、需要处理消费者崩溃后的消息恢复、需要监控 pending list 长度。对于这个项目的需求,ACK + 新消息的方案更简单、更可控、更易调试。Stream 里能看到每条重试消息的 retryCount,排查问题时一目了然。

重试决策逻辑:

java 复制代码
private boolean shouldRetry(Exception e, int retryCount) {
    if (retryCount >= AsyncTaskStreamConstants.MAX_RETRY_COUNT) {  // 3
        return false;
    }
    if (e instanceof AiServiceException aiServiceException) {
        return aiServiceException.isRetryable();
    }
    return true;  // 非 AI 异常默认重试
}

最多重试 3 次。AiServiceException 带有 retryable 标记,可以精细控制哪些错误值得重试(比如超时、限流)哪些不值得(比如 API key 无效)。

优雅关闭
java 复制代码
@PreDestroy
public void shutdown() {
    running.set(false);
    if (executorService != null) {
        executorService.shutdown();
    }
}

running.set(false) 让消费循环退出。executorService.shutdown() 等待当前消息处理完成。因为线程是守护线程,即使 shutdown() 没来得及执行,JVM 也会强制退出。

三条流水线的差异化设计

三条流水线共享同一套 Producer/Consumer 基类,但在业务层各有特色。

简历分析:两层重试控制

简历分析消费者有一个独特的设计:结构化输出失败时,抑制 Stream 层重试,但保留用户手动重试的能力

java 复制代码
private AiServiceException disableAutomaticRetryForStructuredOutputError(AiServiceException e) {
    if (e.getErrorCode() != ErrorCode.AI_RESPONSE_FORMAT_INVALID || !e.isRetryable()) {
        return e;
    }
    // 结构化输出失败已在单次调用内做过重试,这里停止 Stream 自动重入队,
    // 但保留 failureState 中的 retryable=true,允许用户手动重新发起分析。
    return new AiServiceException(e.getErrorCode(), e.getMessage(), false, e);
}

为什么要这样做?因为 LLM 的结构化输出(JSON)有两个层次的重试:

  1. AI 调用层StructuredOutputInvoker 已经做了最多 2 次重试(注入上次错误、自动修复 JSON)
  2. Stream 层:如果 AI 调用层的重试都失败了,Stream 会再重试 3 次

问题是:结构化输出失败通常是 prompt 或模型的问题,不是网络抖动。Stream 层再重试 3 次大概率还是失败,白白浪费 3 次 LLM 调用。

解决方案:在 processBusiness() 的 catch 块里,把 retryabletrue 改成 false。这样 shouldRetry() 会返回 false,直接走 markFailed()。但数据库里存的 analyzeRetryable 仍然是 true,前端可以展示"重新分析"按钮让用户手动触发。

两层重试,语义不同:AI 层重试是自动的、快速的(同一请求内);Stream 层重试是兜底的、有间隔的(新消息);用户手动重试是最终的、有明确意图的。

知识库向量化:生命周期感知

知识库有一个简历没有的状态:正在删除。用户删除知识库时,可能还有向量化任务在队列里排队。如果消费者还在处理这些任务,会产生竞态条件。

解决方案是 生命周期感知------每个生命周期钩子都先检查知识库是否还是 ACTIVE 状态:

java 复制代码
private boolean isActiveKnowledgeBase(Long kbId) {
    return knowledgeBaseRepository.findById(kbId)
        .map(kb -> kb.getLifecycleStatus() == KnowledgeBaseLifecycleStatus.ACTIVE)
        .orElse(false);
}

@Override
protected void markProcessing(VectorizePayload payload) {
    if (isActiveKnowledgeBase(payload.kbId())) {
        updateVectorStatus(payload.kbId(), VectorStatus.PROCESSING, null);
    }
}

@Override
protected void processBusiness(VectorizePayload payload) {
    if (!isActiveKnowledgeBase(payload.kbId())) {
        log.info("知识库已非 ACTIVE,跳过向量化任务: kbId={}", payload.kbId());
        return;
    }
    vectorService.vectorizeAndStore(payload.kbId(), payload.content());
}

如果知识库已经被标记为 DELETING,消费者会静默跳过所有状态更新。这避免了"向量化完成但知识库已经被删了"的脏状态。

注意检查的时机:processBusiness() 在执行前检查,markCompleted()markFailed() 在执行后也检查。因为向量化可能跑好几秒,期间知识库可能被删除。

面试评估:最简单的流水线

面试评估的 Producer 只发一个 sessionId,Consumer 在处理时才从数据库加载所有需要的数据:

java 复制代码
protected void processBusiness(EvaluatePayload payload) {
    InterviewSessionEntity session = sessionRepository
        .findBySessionIdWithResume(sessionId).orElse(null);
    if (session == null) return;

    List<InterviewQuestionDTO> questions = objectMapper.readValue(
        session.getQuestionsJson(), new TypeReference<>() {});

    List<InterviewAnswerEntity> answers = persistenceService
        .findAnswersBySessionId(sessionId);
    for (InterviewAnswerEntity answer : answers) {
        int index = answer.getQuestionIndex();
        if (index >= 0 && index < questions.size()) {
            questions.set(index, questions.get(index).withAnswer(answer.getUserAnswer()));
        }
    }

    InterviewReportDTO report = evaluationService.evaluateInterview(
        sessionId, session.getResume().getResumeText(), questions);
    persistenceService.saveReport(sessionId, report);
}

为什么不像简历分析那样把内容也放进消息?因为面试评估的数据来源更多(问题 + 回答 + 简历),消息体太大不合适。只传 sessionId,Consumer 按需加载,消息体最小化。

前端怎么知道任务完成了

没有 WebSocket。前端用的是 轮询

每个异步任务在数据库实体上有一个状态字段(analyzeStatusvectorStatus),取值为 PENDING / PROCESSING / COMPLETED / FAILED。Producer 入队时写 PENDING,Consumer 开始处理时写 PROCESSING,完成后写 COMPLETEDFAILED

前端每隔几秒查一次这个字段。看到 COMPLETED 就刷新数据,看到 FAILED 就展示错误信息和重试按钮。

这个方案比 WebSocket 简单得多,对于异步任务的延迟容忍度(几秒)来说完全够用。

设计哲学总结

1. 模板方法优于框架封装

没有用 Spring 的 @StreamListener 或 Spring Integration。原因是:这些框架封装得太深,消费循环、重试逻辑、生命周期管理都藏在框架内部,出了问题很难调试。模板方法模式把控制权留在自己手里------基类定义骨架,子类填充业务,每一层都可读、可调试、可覆盖。

2. Always ACK,重试靠新消息

Redis Stream 的 PEL 机制看起来很方便,但实际用起来很麻烦:消费者崩溃后消息卡在 PEL 里、XCLAIM 的所有权转移语义不直观、pending list 需要额外监控。ACK + 新消息的方案把重试状态变成了 Stream 里的一条新消息,可见、可追踪、可调试。

3. 状态机在数据库里,不在 Redis 里

任务状态(PENDING → PROCESSING → COMPLETED/FAILED)存在 PostgreSQL 的实体字段里,不存在 Redis 里。原因:Redis 是易失的,数据库是持久的。如果 Redis 重启了,Stream 消息可能丢失,但数据库里的状态记录不会丢。前端轮询的也是数据库状态,不是 Stream 状态。

4. 防御性检查贯穿始终

简历分析在处理前检查 existsById,处理后再检查一次(因为 AI 调用耗时长,期间简历可能被删除)。知识库向量化在每个生命周期钩子里都检查 isActiveKnowledgeBase。这些检查看起来多余,但在并发场景下是必要的。

5. 两条重试语义分开

AI 层的重试(结构化输出修复)和 Stream 层的重试(网络/服务异常兜底)是两个不同的概念。AI 层重试在同一请求内快速完成,Stream 层重试跨消息有间隔。把它们混在一起会导致不必要的 LLM 调用浪费。

局限性

  • 单线程消费者。每个 Consumer 只有一个线程处理消息。如果单条消息处理很慢(比如面试评估要调好几次 LLM),队列会积压。解决方案是水平扩展------多开几个应用实例,利用消费者组自动分配。
  • 没有死信队列。重试 3 次后仍然失败的消息直接标记 FAILED,没有转移到死信队列。对于这个项目来说够用,但如果需要离线分析失败原因,死信队列会更方便。
  • Stream 消息不持久化到磁盘 。Redis 的 AOF 持久化可以保证消息不丢,但如果 AOF 策略是 everysec,极端情况下可能丢 1 秒的消息。对于异步任务来说可以接受,但对金融级消息不适用。
  • 没有消息去重 。如果 Producer 在 streamAdd 之后、markProcessing 之前崩溃,用户重试会发一条新消息,导致同一任务被处理两次。目前靠数据库状态幂等(COMPLETED 状态不重复处理)来兜底,但不是严格幂等。

结语

异步任务队列不一定要上 Kafka。对于单体应用、几条流水线、可容忍几秒延迟的场景,Redis Stream + 模板方法模式是一个务实的选择:部署零成本(复用现有 Redis)、代码量小(两个基类 ~200 行)、可调试(Stream 里能看到每条消息和重试记录)。

如果你的项目已经有 Redis,需要异步化几个 LLM 调用,不妨先试试 Redis Stream。等到真的需要持久化保证、事务消息、或者跨服务通信时,再上 Kafka 也不迟。


本文代码来自 Interview Agent 项目 common/async/modules/*/listener/ 目录,关键文件:AbstractStreamProducer.javaAbstractStreamConsumer.javaAnalyzeStreamConsumer.javaVectorizeStreamConsumer.java

相关推荐
进阶的猿猴1 小时前
Rsa简单实现接口到期限制(springBoot)
java·spring boot·后端
城事漫游Molly1 小时前
定量研究设计清单:问卷、实验与变量操作化怎么做?
大数据·人工智能·算法·ai写作·论文笔记
涤生大数据1 小时前
大数据凉了?速看4月的就业数据新鲜出炉!AI时代岗位不会原地消失,而是岗位的标准会被逐步抬高
大数据·人工智能
七夜zippoe1 小时前
基于 JiuwenClaw AgentTeam 集群模式的年会策划实战:从源码部署到多智能体协作落地
人工智能·agent·openjiuwen·jiuwenclaw·agentteam
Soari1 小时前
科研绘图新纪元:深度拆解 3DCellForge,AI 驱动的交互式 3D 细胞建模神器
人工智能·3d·科研绘图·3dcellforg
Java编程爱好者1 小时前
MySQL / PostgreSQL DDL 审核自动化:从人工 review 到 CI 拦截
后端
new【一个】对象1 小时前
Python 包管理器uv
人工智能·windows·python
m0_591364731 小时前
Python如何进行数据平滑处理_使用Pandas滚动中位数计算
jvm·数据库·python
振宇i1 小时前
MySQL数据库修改表结构语句
数据库·mysql