第9章 RabbitMQ 消息队列与索引任务

本章目标

前面几章我们已经完成了一条文档进入系统的前半段链路:

text 复制代码
用户创建知识库
  -> 上传文档
  -> 文件保存到 MinIO
  -> document_info 记录元数据
  -> 解析文件
  -> 文本切片

但在真实系统中,解析、切片、Embedding、向量入库这些步骤不能全部放在上传接口里同步执行。

原因很简单:它们太慢、太容易失败,也太需要追踪。

如果用户上传一个 PDF,接口要一直等待:

text 复制代码
PDF 下载
-> PDF 解析
-> 文本切片
-> 调用 Embedding
-> 写入 MySQL
-> 写入 pgvector

用户可能等几十秒,甚至直接超时。

更糟糕的是,如果中间某一步失败,系统很难告诉用户到底失败在哪里,也很难重新执行。

所以 KnowHub 使用 RabbitMQ 和索引任务表,把文档索引改造成后台异步流程。

本章要讲清楚:

  1. 为什么文档索引必须异步化。
  2. index_task 表如何设计。
  3. 任务状态机如何流转。
  4. RabbitMQ 中 exchange、queue、routing key 是什么。
  5. knowledge-service 如何投递索引消息。
  6. task-service 如何消费消息并执行索引。
  7. Redis 幂等锁如何防止重复消费。
  8. 失败重试和超时任务如何处理。

9.1 为什么文档索引必须异步化

文档上传接口的职责应该很清晰:

text 复制代码
接收文件
校验权限
保存文件
创建文档记录
创建索引任务
返回结果

它不应该负责长时间执行索引。

9.1.1 上传接口不能长时间阻塞

用户上传文件后,希望尽快看到上传成功。

如果上传接口一直等待后台索引完成,用户体验会很差。

尤其是 PDF、长文档或模型接口较慢时,接口可能超时。

更合理的方式是:

text 复制代码
上传接口快速返回:文档已上传,正在索引
后台任务继续执行解析、切片和向量化
前端轮询或查看任务状态

这就是异步化的价值。

9.1.2 索引过程有多个失败点

索引不是一个简单操作。

它可能失败在:

  • MinIO 文件下载失败。
  • PDF 解析失败。
  • 文本为空。
  • Embedding 接口超时。
  • pgvector 写入失败。
  • MySQL 写入失败。
  • 服务中途重启。

如果没有任务表,失败后系统只知道"上传接口失败"。

有了任务表,系统可以记录:

text 复制代码
任务状态
失败原因
重试次数
开始时间
结束时间

这样管理员可以排查,用户也能看到状态。

9.1.3 任务需要重试和补偿

有些失败是临时的。

比如 Embedding 接口超时、pgvector 短暂不可用、网络抖动。

这些场景不应该让用户重新上传文件,而应该允许系统重试。

任务表 + RabbitMQ 可以很好地支持这一点。


9.2 index_task 表设计

索引任务表记录每一次文档索引过程。

简化结构如下:

sql 复制代码
CREATE TABLE index_task (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  document_id BIGINT NOT NULL,
  kb_id BIGINT NOT NULL,
  user_id BIGINT NOT NULL,
  status VARCHAR(32) NOT NULL,
  retry_count INT NOT NULL DEFAULT 0,
  max_retry_count INT NOT NULL DEFAULT 3,
  error_message TEXT,
  worker_id VARCHAR(128),
  start_time DATETIME,
  end_time DATETIME,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  INDEX idx_document_id (document_id),
  INDEX idx_status (status),
  INDEX idx_kb_id (kb_id)
);

上面的 DDL 已经能运行。实际团队开发时,建议把字段注释也补齐,方便后续排查任务时直接看懂字段含义:

sql 复制代码
CREATE TABLE index_task (
  id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '索引任务主键',
  document_id BIGINT NOT NULL COMMENT '关联的文档 ID',
  kb_id BIGINT NOT NULL COMMENT '所属知识库 ID',
  user_id BIGINT NOT NULL COMMENT '文档所属用户 ID',
  status VARCHAR(32) NOT NULL COMMENT '任务状态:WAITING/RUNNING/SUCCESS/FAILED/RETRYING/TIMEOUT',
  retry_count INT NOT NULL DEFAULT 0 COMMENT '已重试次数',
  max_retry_count INT NOT NULL DEFAULT 3 COMMENT '最大允许重试次数',
  error_message TEXT COMMENT '失败原因或超时原因',
  worker_id VARCHAR(128) COMMENT '执行该任务的消费者节点标识',
  start_time DATETIME COMMENT '任务开始执行时间',
  end_time DATETIME COMMENT '任务结束时间',
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  INDEX idx_document_id (document_id),
  INDEX idx_status (status),
  INDEX idx_kb_id (kb_id),
  INDEX idx_user_id (user_id)
) COMMENT='文档索引任务表';

idx_status 用于后台扫描 WAITINGRUNNINGFAILED 任务,idx_user_ididx_kb_id 用于管理端筛选和用户资源隔离。

9.2.1 document_id

表示这个任务处理哪一份文档。

9.2.2 kb_iduser_id

kb_iduser_id 用于权限隔离、管理端筛选和问题排查。

管理员可以按知识库或用户查看任务。

普通用户只能看到自己文档对应的任务。

9.2.3 status

status 是任务当前状态。

常见状态包括:

text 复制代码
WAITING
RUNNING
SUCCESS
FAILED
RETRYING
TIMEOUT

状态机是任务系统的核心。

9.2.4 retry_count 和 max_retry_count

retry_count 表示已经重试了几次。

max_retry_count 表示最多允许重试几次。

例如:

text 复制代码
retry_count = 1
max_retry_count = 3

说明任务已经重试过一次,还可以继续重试。

9.2.5 error_message

任务失败时,必须记录失败原因。

例如:

text 复制代码
PDF 解析失败:文件已损坏
Embedding 调用超时
MinIO 文件不存在
pgvector 写入失败

没有失败原因的任务系统,排查价值很低。

9.2.6 start_time 和 end_time

这两个字段用于计算任务耗时,也用于判断 RUNNING 任务是否超时。


9.3 任务状态机

状态机可以让任务流转变得可控。

KnowHub 的索引任务状态包括:

text 复制代码
WAITING
RUNNING
SUCCESS
FAILED
RETRYING
TIMEOUT

9.3.1 WAITING

任务刚创建,等待消费。

此时 RabbitMQ 中应该有对应消息。

9.3.2 RUNNING

task-service 已经开始执行任务。

进入 RUNNING 时,要记录 start_timeworker_id

9.3.3 Success

任务执行成功。

意味着:

  • 文件已解析。
  • 文本已切片。
  • chunk 已写入 MySQL。
  • 向量已写入 pgvector。
  • document_info 状态已更新为 INDEXED。

9.3.4 FAILED

任务执行失败。

失败后要记录 error_message

是否自动重试,要根据失败类型决定。

比如文件格式不支持,不适合自动重试;Embedding 超时,可以重试。

9.3.5 RETRYING

任务准备重新执行。

管理员点击手动重试,或者系统自动补偿时,可以把任务状态改为 RETRYING,并重新投递 RabbitMQ 消息。

9.3.6 TIMEOUT

任务长时间处于 RUNNING,超过配置阈值,就可以标记为 TIMEOUT。

这通常说明:

  • 服务执行中途挂了。
  • 模型调用卡住。
  • 任务线程异常退出但没有更新状态。

定时任务可以扫描这类任务。

9.3.7 禁止的状态流转

状态机还要限制不合理流转。

比如:

text 复制代码
SUCCESS -> RUNNING
SUCCESS -> FAILED
FAILED -> SUCCESS,除非先经过 RETRYING/RUNNING

这样可以防止重复消息把已经成功的任务又执行一遍。


状态机不能只停留在文档里,代码中也应该有枚举和流转校验。IndexTaskStatus 可以放在 rag-common 中,方便 knowledge-service 和 task-service 共用:

java 复制代码
package com.luo.knowhub.common.enums;

import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;

public enum IndexTaskStatus {

    WAITING("等待执行"),
    RUNNING("执行中"),
    SUCCESS("执行成功"),
    FAILED("执行失败"),
    RETRYING("重试中"),
    TIMEOUT("执行超时");

    private final String description;

    IndexTaskStatus(String description) {
        this.description = description;
    }

    public String getDescription() {
        return description;
    }

    private static final Map<IndexTaskStatus, Set<IndexTaskStatus>> TRANSITIONS =
            new EnumMap<>(IndexTaskStatus.class);

    static {
        TRANSITIONS.put(WAITING, EnumSet.of(RUNNING, FAILED));
        TRANSITIONS.put(RUNNING, EnumSet.of(SUCCESS, FAILED, TIMEOUT));
        TRANSITIONS.put(FAILED, EnumSet.of(RETRYING));
        TRANSITIONS.put(RETRYING, EnumSet.of(RUNNING, FAILED));
        TRANSITIONS.put(TIMEOUT, EnumSet.of(RETRYING));
        TRANSITIONS.put(SUCCESS, EnumSet.noneOf(IndexTaskStatus.class));
    }

    public static boolean canTransition(IndexTaskStatus from, IndexTaskStatus to) {
        if (from == null || to == null) {
            return false;
        }
        return TRANSITIONS.getOrDefault(from, Set.of()).contains(to);
    }

    public static void checkTransition(IndexTaskStatus from, IndexTaskStatus to) {
        if (!canTransition(from, to)) {
            throw new IllegalStateException("索引任务状态不能从 " + from + " 流转到 " + to);
        }
    }
}

状态流转图可以按下面的提示语生成:

text 复制代码
请生成一张 KnowHub 索引任务状态机流程图,中文标注,节点包含 WAITING 等待执行、RUNNING 执行中、SUCCESS 执行成功、FAILED 执行失败、RETRYING 重试中、TIMEOUT 执行超时。箭头包括 WAITING -> RUNNING,RUNNING -> SUCCESS,RUNNING -> FAILED,FAILED -> RETRYING(管理员手动触发),RETRYING -> RUNNING(重新投递消息后),RUNNING -> TIMEOUT(超时扫描),TIMEOUT -> RETRYING(管理员手动触发)。SUCCESS 是终态,不允许直接回到 RUNNING 或 FAILED。

这段状态机代码的价值在于:以后不管是 RabbitMQ 消费、管理员重试,还是超时扫描,都可以先用 canTransition 判断流转是否合法,避免重复消息破坏已经成功的任务状态。


9.4 RabbitMQ 基础概念

RabbitMQ 是消息队列。

你可以把它理解成一个"任务中转站"。

knowledge-service 把索引任务消息发给 RabbitMQ,task-service 从 RabbitMQ 取消息执行。

9.4.1 Producer

Producer 是消息生产者。

在 KnowHub 中,knowledge-service 是索引消息的生产者。

它在文档上传后发送消息:

text 复制代码
有一份文档需要索引

9.4.2 Consumer

Consumer 是消息消费者。

在 KnowHub 中,task-service 是消费者。

它监听队列,收到消息后执行索引任务。

9.4.3 Exchange

Exchange 是交换机。

生产者不是直接把消息发到队列,而是发到 Exchange。

KnowHub 可以定义:

text 复制代码
exchange = rag.index.exchange

9.4.4 Queue

Queue 是队列。

消费者从队列中取消息。

KnowHub 可以定义:

text 复制代码
queue = rag.index.queue

9.4.5 Routing Key

Routing Key 用于把消息从 exchange 路由到 queue。

KnowHub 可以定义:

text 复制代码
routingKey = rag.index.document

9.4.6 Ack

Ack 表示消息确认。

消费者处理完消息后,要告诉 RabbitMQ:这条消息处理完成了。

如果没有 Ack,RabbitMQ 可能认为消息没处理完,然后重新投递。

这也是为什么消费者必须处理幂等性。因为消息可能重复投递。

9.4.7 启动 RabbitMQ(Docker)

本章代码运行前,需要先启动 RabbitMQ。

如果只想快速启动一个本机 RabbitMQ,可以使用下面的 Docker 命令:

powershell 复制代码
docker run -d `
  --name knowhub-rabbitmq `
  -p 5672:5672 `
  -p 15672:15672 `
  -e RABBITMQ_DEFAULT_USER=rag `
  -e RABBITMQ_DEFAULT_PASS=change-me `
  rabbitmq:3.13-management

其中:

text 复制代码
5672  是 AMQP 端口,Java 服务通过它连接 RabbitMQ
15672 是管理控制台端口,浏览器通过它查看 Exchange、Queue 和消息状态

启动后访问:

text 复制代码
http://localhost:15672

用户名和密码就是上面配置的:

text 复制代码
rag / change-me

如果项目使用 Docker Compose,可以写成下面的最小配置:

yaml 复制代码
services:
  rabbitmq:
    image: rabbitmq:3.13-management
    container_name: knowhub-rabbitmq
    restart: unless-stopped
    environment:
      RABBITMQ_DEFAULT_USER: rag
      RABBITMQ_DEFAULT_PASS: change-me
    ports:
      - "5672:5672"
      - "15672:15672"
    volumes:
      - knowhub-rabbitmq-data:/var/lib/rabbitmq

volumes:
  knowhub-rabbitmq-data:

服务启动后,后面的 RabbitConfig 会自动声明 Exchange、Queue 和 Binding。你可以在管理控制台的 Exchanges 和 Queues 页面确认它们是否创建成功。


9.4.8 RabbitMQ 客户端依赖与配置

knowledge-service 和 task-service 都需要引入 AMQP 依赖。

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

两个服务的 application.yml 中都要配置 RabbitMQ 连接信息:

yaml 复制代码
spring:
  rabbitmq:
    host: ${RABBITMQ_HOST:127.0.0.1}
    port: ${RABBITMQ_PORT:5672}
    username: ${RABBITMQ_USERNAME:rag}
    password: ${RABBITMQ_PASSWORD:change-me}
    virtual-host: ${RABBITMQ_VIRTUAL_HOST:/}
    listener:
      simple:
        acknowledge-mode: manual
        concurrency: 1
        max-concurrency: 4

exchange、queue、routing key 建议统一放在常量类中:

java 复制代码
package com.luo.knowhub.common.mq;

public final class RabbitMqConstants {

    private RabbitMqConstants() {
    }

    public static final String INDEX_EXCHANGE = "rag.index.exchange";
    public static final String INDEX_QUEUE = "rag.index.queue";
    public static final String INDEX_ROUTING_KEY = "rag.index.document";
}

knowledge-service 的 RabbitMQ 配置类:

java 复制代码
package com.luo.knowhub.knowledge.config;

import com.luo.knowhub.common.mq.RabbitMqConstants;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitIndexConfig {

    @Bean
    public TopicExchange indexExchange() {
        // durable=true 表示 RabbitMQ 重启后 exchange 仍然存在。
        return new TopicExchange(RabbitMqConstants.INDEX_EXCHANGE, true, false);
    }

    @Bean
    public Queue indexQueue() {
        // durable=true 表示队列持久化。
        return new Queue(RabbitMqConstants.INDEX_QUEUE, true);
    }

    @Bean
    public Binding indexBinding(TopicExchange indexExchange, Queue indexQueue) {
        return BindingBuilder.bind(indexQueue)
                .to(indexExchange)
                .with(RabbitMqConstants.INDEX_ROUTING_KEY);
    }
}

task-service 中也声明同样的 exchange、queue、binding:

java 复制代码
package com.luo.knowhub.task.config;

import com.luo.knowhub.common.mq.RabbitMqConstants;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RabbitIndexConfig {

    @Bean
    public TopicExchange indexExchange() {
        return new TopicExchange(RabbitMqConstants.INDEX_EXCHANGE, true, false);
    }

    @Bean
    public Queue indexQueue() {
        return new Queue(RabbitMqConstants.INDEX_QUEUE, true);
    }

    @Bean
    public Binding indexBinding(TopicExchange indexExchange, Queue indexQueue) {
        return BindingBuilder.bind(indexQueue)
                .to(indexExchange)
                .with(RabbitMqConstants.INDEX_ROUTING_KEY);
    }
}

RabbitMQ 的声明是幂等的:两个服务声明同名 exchange 和 queue 没问题,但参数必须一致。比如一个服务声明 durable=true,另一个声明 durable=false,就会启动失败。

最后配置 JSON 序列化:

java 复制代码
package com.luo.knowhub.common.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.amqp.core.AcknowledgeMode;

@Configuration
public class RabbitMessageConfig {

    @Bean
    public MessageConverter jacksonMessageConverter(ObjectMapper objectMapper) {
        // 让 RabbitMQ 消息使用 JSON,而不是 Java 默认序列化。
        return new Jackson2JsonMessageConverter(objectMapper);
    }

    @Bean
    public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory,
                                         MessageConverter messageConverter) {
        RabbitTemplate template = new RabbitTemplate(connectionFactory);
        template.setMessageConverter(messageConverter);
        return template;
    }

    @Bean
    public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
            ConnectionFactory connectionFactory,
            MessageConverter messageConverter) {
        SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
        factory.setConnectionFactory(connectionFactory);
        factory.setMessageConverter(messageConverter);
        // 手动 ack,确保业务代码成功处理后再确认消息。
        factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
        return factory;
    }
}

9.4.9 application.yml 配置汇总

本章相关配置可以统一整理为下面这样:

yaml 复制代码
spring:
  rabbitmq:
    host: ${RABBITMQ_HOST:127.0.0.1}
    port: ${RABBITMQ_PORT:5672}
    username: ${RABBITMQ_USERNAME:rag}
    password: ${RABBITMQ_PASSWORD:change-me}
    virtual-host: ${RABBITMQ_VIRTUAL_HOST:/}
    listener:
      simple:
        acknowledge-mode: manual
        concurrency: 1
        max-concurrency: 4

  data:
    redis:
      host: ${REDIS_HOST:127.0.0.1}
      port: ${REDIS_PORT:6379}
      database: 0

knowhub:
  index-task:
    timeout-minutes: 30
    max-retry-count: 3
    lock-ttl-seconds: 600

minio:
  endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
  access-key: ${MINIO_ACCESS_KEY:ragminio}
  secret-key: ${MINIO_SECRET_KEY:change-me}
  bucket-name: ${MINIO_BUCKET_NAME:knowhub-documents}

其中 RabbitMQ、Redis、MinIO 是 knowledge-service 和 task-service 都可能用到的基础设施配置。timeout-minutesmax-retry-countlock-ttl-seconds 主要属于 task-service,因为真正执行任务和判断超时的是消费者服务。


9.5 索引消息设计

RabbitMQ 消息不要放大文件内容。

正确做法是只传关键 ID。

示例:

json 复制代码
{
  "taskId": 1,
  "documentId": 10,
  "kbId": 12,
  "userId": 3
}

这条消息表达的含义是:

text 复制代码
请处理 taskId=1 的索引任务
文档 ID 是 10
知识库 ID 是 12
用户 ID 是 3

文件内容在哪里?

在 MinIO。

task-service 根据 documentId 查询 document_info,拿到 storage_path,再从 MinIO 下载文件。

这种设计有几个好处:

  • 消息体小。
  • 文件和消息解耦。
  • 失败重试方便。
  • 消费者可以重新查询最新任务状态。

消息体对应的 Java 类如下:

java 复制代码
package com.luo.knowhub.common.mq;

import lombok.Data;

@Data
public class IndexTaskMessage {

    /**
     * index_task 表主键,用于查询和更新任务状态。
     */
    private Long taskId;

    /**
     * document_info 表主键,用于查询文件元数据和 storage_path。
     */
    private Long documentId;

    /**
     * 知识库 ID,用于资源隔离、日志排查和向量写入。
     */
    private Long kbId;

    /**
     * 用户 ID,用于资源隔离和后续向量检索过滤。
     */
    private Long userId;

    public IndexTaskMessage() {
        // Jackson 反序列化 RabbitMQ JSON 消息时需要无参构造方法。
    }

    public IndexTaskMessage(Long taskId, Long documentId, Long kbId, Long userId) {
        this.taskId = taskId;
        this.documentId = documentId;
        this.kbId = kbId;
        this.userId = userId;
    }
}

这个类建议放在 rag-common 模块中。生产者和消费者都依赖它,可以避免两个服务各写一份字段相同但包名不同的消息类。


9.6 knowledge-service 如何投递消息

文档上传完成后,knowledge-service 要做这些事:

text 复制代码
1. 校验知识库 owner
2. 上传文件到 MinIO
3. 写 document_info
4. 调用 task-service 创建 index_task
5. 更新 document_info 为 INDEXING
6. 发送 RabbitMQ 消息
7. 返回上传结果

9.6.1 为什么先创建任务再发消息

如果先发消息,再创建任务,消费者可能很快收到消息,却查不到任务记录。

所以应该先写数据库任务,再发消息。

9.6.2 消息发送失败怎么办

如果 document_info 和 index_task 都写成功了,但 RabbitMQ 发送失败,就会出现任务一直 WAITING。

处理方式有几种。

简单版本:捕获发送异常,将任务标记为 FAILED 或保留 WAITING,让管理员重试。

更可靠版本:使用本地消息表或事务消息思路,定时扫描未发送成功的任务并补投消息。

本书主线先实现可理解、可运行的版本,后续在生产化章节再讨论更强一致性的方案。

9.6.3 document_info 状态更新

发送消息前后,文档状态可以更新为:

text 复制代码
INDEXING

表示文档已经进入后台索引流程。

前端看到这个状态,就知道文档还不能稳定问答。


knowledge-service 中可以新增一个 IndexMessageProducer

java 复制代码
package com.luo.knowhub.knowledge.mq;

import com.luo.knowhub.common.mq.IndexTaskMessage;
import com.luo.knowhub.common.mq.RabbitMqConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.AmqpException;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class IndexMessageProducer {

    private final RabbitTemplate rabbitTemplate;

    public IndexMessageProducer(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void sendIndexTaskMessage(IndexTaskMessage message) {
        try {
            rabbitTemplate.convertAndSend(
                    RabbitMqConstants.INDEX_EXCHANGE,
                    RabbitMqConstants.INDEX_ROUTING_KEY,
                    message
            );
            log.info("索引任务消息发送成功,taskId={}, documentId={}",
                    message.getTaskId(), message.getDocumentId());
        } catch (AmqpException e) {
            // 学习版本先记录错误并保留任务为 WAITING,方便管理员手动重试。
            // 生产环境更推荐本地消息表或事务消息方案,避免 DB 成功但消息丢失。
            log.error("索引任务消息发送失败,taskId={}, documentId={}",
                    message.getTaskId(), message.getDocumentId(), e);
            throw e;
        }
    }
}

在第六章的文档上传链路中,创建 index_task 并更新 document_info.index_status=INDEXING 后投递消息:

java 复制代码
public DocumentUploadResult uploadDocument(Long kbId, MultipartFile file) {
    Long userId = UserContext.getUserId();

    // 1. owner 校验、文件校验、上传 MinIO、写 document_info,详见第六章 6.5 节。
    DocumentInfo document = saveDocumentInfo(kbId, userId, file);

    // 2. 先创建任务表记录,确保消费者收到消息后能查到任务。
    IndexTask task = indexTaskService.create(document.getId(), kbId, userId);

    // 3. 文档状态先改成 INDEXING,让前端知道文档已进入后台处理。
    documentInfoService.markIndexing(document.getId());

    // 4. 第六章上传链路的第 10 步:发送 RabbitMQ 索引消息。
    IndexTaskMessage message = new IndexTaskMessage(
            task.getId(),
            document.getId(),
            kbId,
            userId
    );

    try {
        indexMessageProducer.sendIndexTaskMessage(message);
    } catch (AmqpException e) {
        // 当前学习版保留 WAITING 状态,管理端可以看到任务未被消费并手动重试。
        log.warn("消息发送失败,任务保留 WAITING,taskId={}", task.getId());
    }

    return DocumentUploadResult.indexing(document, task);
}

这里的关键顺序是:先保存文件和任务,再发消息。RabbitMQ 只是触发器,真正的任务状态以 MySQL 中的 index_task 为准。


9.7 task-service 如何消费消息

task-service 是索引任务真正的执行者。

收到消息后,它不能马上无脑执行,而要经过一系列检查。

完整流程如下:

text 复制代码
1. 接收 RabbitMQ 消息
2. 获取 Redis 幂等锁 lock:index-task:{taskId}
3. 查询 index_task
4. 如果任务已 SUCCESS,直接 ack 并跳过
5. 如果状态不是 WAITING/RETRYING,按规则跳过
6. 将任务状态改为 RUNNING
7. 查询 document_info
8. 从 MinIO 下载文件
9. 调用 DocumentParser 解析文本
10. 调用 TextChunker 切片
11. 删除旧 chunk 和旧向量
12. 写入新的 document_chunk
13. 调用 Embedding 生成向量
14. 写入 pgvector
15. 更新 document_info 为 INDEXED
16. 更新 index_task 为 SUCCESS
17. ack 消息
18. 释放 Redis 锁

9.7.1 为什么要先拿 Redis 锁

RabbitMQ 是 at-least-once 语义。

这意味着消息至少会被投递一次,但可能被重复投递。

如果两个消费者同时处理同一个 taskId,就可能重复写 chunk 和向量。

所以执行前先拿锁:

text 复制代码
lock:index-task:{taskId}

只有拿到锁的消费者才能继续执行。

9.7.2 为什么还要检查任务状态

Redis 锁不是唯一保障。

任务状态也要检查。

如果任务已经 SUCCESS,即使又收到重复消息,也不能重新执行。

所以幂等要组合使用:

text 复制代码
Redis 锁
+ 任务状态检查
+ document_id + chunk_index 唯一约束

9.7.3 为什么要删除旧 chunk 和旧向量

重建索引时,如果不删除旧数据,会出现重复片段。

正确做法是:

text 复制代码
删除旧 document_chunk
删除旧 pgvector 向量
重新写入新 chunk 和新向量

这样可以保证同一文档只有一份最新索引结果。


下面是 task-service 中 IndexTaskConsumer 的完整消费方法。它把上面的 18 步流程落到了代码里。

java 复制代码
package com.luo.knowhub.task.mq;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.luo.knowhub.common.enums.DocumentIndexStatus;
import com.luo.knowhub.common.enums.IndexTaskStatus;
import com.luo.knowhub.common.exception.BusinessException;
import com.luo.knowhub.common.mq.IndexTaskMessage;
import com.luo.knowhub.common.mq.RabbitMqConstants;
import com.luo.knowhub.task.chunking.ChunkResult;
import com.luo.knowhub.task.chunking.TextChunker;
import com.luo.knowhub.task.chunking.TextNormalizer;
import com.luo.knowhub.task.entity.DocumentChunk;
import com.luo.knowhub.task.entity.DocumentInfo;
import com.luo.knowhub.task.entity.IndexTask;
import com.luo.knowhub.task.exception.UnrecoverableIndexException;
import com.luo.knowhub.task.mapper.DocumentChunkMapper;
import com.luo.knowhub.task.mapper.DocumentInfoMapper;
import com.luo.knowhub.task.mapper.IndexTaskMapper;
import com.luo.knowhub.task.minio.MinioFileService;
import com.luo.knowhub.task.parser.DocumentParserDispatcher;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.AmqpHeaders;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.handler.annotation.Header;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.net.InetAddress;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@Component
public class IndexTaskConsumer {

    private final StringRedisTemplate redisTemplate;
    private final IndexTaskMapper indexTaskMapper;
    private final DocumentInfoMapper documentInfoMapper;
    private final DocumentChunkMapper documentChunkMapper;
    private final MinioFileService minioFileService;
    private final DocumentParserDispatcher parserDispatcher;
    private final TextChunker textChunker;

    @Value("${knowhub.index-task.lock-ttl-seconds:600}")
    private long lockTtlSeconds;

    public IndexTaskConsumer(StringRedisTemplate redisTemplate,
                             IndexTaskMapper indexTaskMapper,
                             DocumentInfoMapper documentInfoMapper,
                             DocumentChunkMapper documentChunkMapper,
                             MinioFileService minioFileService,
                             DocumentParserDispatcher parserDispatcher,
                             TextChunker textChunker) {
        this.redisTemplate = redisTemplate;
        this.indexTaskMapper = indexTaskMapper;
        this.documentInfoMapper = documentInfoMapper;
        this.documentChunkMapper = documentChunkMapper;
        this.minioFileService = minioFileService;
        this.parserDispatcher = parserDispatcher;
        this.textChunker = textChunker;
    }

    @RabbitListener(queues = RabbitMqConstants.INDEX_QUEUE)
    public void handleIndexTask(IndexTaskMessage message,
                                Channel channel,
                                @Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws IOException {
        Long taskId = message.getTaskId();
        Long documentId = message.getDocumentId();
        String lockKey = "lock:index-task:" + taskId;
        String workerId = buildWorkerId();
        boolean locked = false;

        try {
            // 步骤一:收到消息,先记录 taskId 和 documentId,方便排查。
            log.info("收到索引任务消息,taskId={}, documentId={}", taskId, documentId);

            // 步骤二:获取 Redis 幂等锁,避免多个消费者同时处理同一个 taskId。
            locked = Boolean.TRUE.equals(redisTemplate.opsForValue()
                    .setIfAbsent(lockKey, workerId, lockTtlSeconds, TimeUnit.SECONDS));
            if (!locked) {
                log.warn("索引任务正在被其他节点处理,直接 ack,taskId={}", taskId);
                channel.basicAck(deliveryTag, false);
                return;
            }

            // 步骤三:查询 index_task。
            IndexTask task = indexTaskMapper.selectById(taskId);
            if (task == null) {
                log.warn("索引任务不存在,直接 ack,taskId={}", taskId);
                channel.basicAck(deliveryTag, false);
                return;
            }

            // 步骤四:状态幂等检查。成功任务不允许重复执行。
            if (IndexTaskStatus.SUCCESS.name().equals(task.getStatus())) {
                log.info("索引任务已成功,跳过重复消息,taskId={}", taskId);
                channel.basicAck(deliveryTag, false);
                return;
            }
            if (!IndexTaskStatus.WAITING.name().equals(task.getStatus())
                    && !IndexTaskStatus.RETRYING.name().equals(task.getStatus())) {
                log.warn("索引任务状态不允许执行,taskId={}, status={}", taskId, task.getStatus());
                channel.basicAck(deliveryTag, false);
                return;
            }

            // 步骤五:更新任务为 RUNNING。
            markTaskRunning(task, workerId);

            // 步骤六:根据 documentId 查询 document_info。
            DocumentInfo document = documentInfoMapper.selectById(documentId);
            if (document == null) {
                throw new UnrecoverableIndexException("文档不存在,无法执行索引");
            }

            // 步骤七:从 MinIO 下载文件,完整实现详见第 7 章。
            Path localFile = minioFileService.downloadToTempFile(document.getStoragePath());

            // 步骤八:根据文件类型分发解析器,详见第 8 章。
            String rawText = parserDispatcher.dispatch(document.getFileType(), localFile);

            // 步骤九:文本归一化,详见第 8 章。
            String normalizedText = TextNormalizer.normalize(rawText);

            // 步骤十:空文本属于不可恢复错误,重复重试没有意义。
            if (TextNormalizer.isBlank(normalizedText)) {
                throw new UnrecoverableIndexException("解析后文本为空,无法切片");
            }

            // 步骤十一:调用 TextChunker 切片。
            List<ChunkResult> chunks = textChunker.chunk(normalizedText);

            // 步骤十二:删除旧 chunk,重建索引时必须先清理旧数据。
            documentChunkMapper.delete(new LambdaQueryWrapper<DocumentChunk>()
                    .eq(DocumentChunk::getDocumentId, documentId));
            // vectorMapper.deleteByDocumentId(documentId); // 删除旧向量,详见第 11 章。

            // 步骤十三:批量写入新的 document_chunk。
            for (ChunkResult chunk : chunks) {
                DocumentChunk entity = new DocumentChunk();
                entity.setDocumentId(documentId);
                entity.setKbId(document.getKbId());
                entity.setChunkIndex(chunk.getChunkIndex());
                entity.setContent(chunk.getContent());
                entity.setCreatedAt(LocalDateTime.now());
                documentChunkMapper.insert(entity);
            }

            // 步骤十四:调用 Embedding 服务生成向量,详见第 10 章。
            // List<float[]> vectors = embeddingService.embed(chunks);

            // 步骤十五:写入 pgvector 向量表,详见第 11 章。
            // vectorService.save(document, chunks, vectors);

            // 步骤十六:更新 document_info 为 INDEXED。
            document.setIndexStatus(DocumentIndexStatus.INDEXED.name());
            document.setChunkCount(chunks.size());
            document.setErrorMessage(null);
            document.setUpdatedAt(LocalDateTime.now());
            documentInfoMapper.updateById(document);

            // 步骤十七:更新 index_task 为 SUCCESS。
            task.setStatus(IndexTaskStatus.SUCCESS.name());
            task.setEndTime(LocalDateTime.now());
            task.setErrorMessage(null);
            indexTaskMapper.updateById(task);

            // 步骤十八:业务成功后 ack。
            channel.basicAck(deliveryTag, false);
            log.info("索引任务执行成功,taskId={}, documentId={}, chunkCount={}",
                    taskId, documentId, chunks.size());
        } catch (Exception e) {
            String errorMessage = shortMessage(e);
            log.error("索引任务执行失败,taskId={}, documentId={}, reason={}",
                    taskId, documentId, errorMessage, e);

            // 失败后仍 ack,避免不可恢复错误反复 requeue。
            handleFailure(taskId, documentId, e, errorMessage);
            channel.basicAck(deliveryTag, false);
        } finally {
            if (locked) {
                redisTemplate.delete(lockKey);
            }
        }
    }

    private void markTaskRunning(IndexTask task, String workerId) {
        task.setStatus(IndexTaskStatus.RUNNING.name());
        task.setWorkerId(workerId);
        task.setStartTime(LocalDateTime.now());
        task.setEndTime(null);
        task.setErrorMessage(null);
        indexTaskMapper.updateById(task);
    }

    private void handleFailure(Long taskId, Long documentId, Exception e, String errorMessage) {
        IndexTask task = indexTaskMapper.selectById(taskId);
        if (task != null) {
            int retryCount = task.getRetryCount() == null ? 0 : task.getRetryCount();
            int maxRetryCount = task.getMaxRetryCount() == null ? 3 : task.getMaxRetryCount();

            task.setStatus(IndexTaskStatus.FAILED.name());
            task.setRetryCount(retryCount + 1);
            task.setEndTime(LocalDateTime.now());
            task.setErrorMessage(errorMessage);
            indexTaskMapper.updateById(task);

            if (isRecoverable(e) && retryCount + 1 < maxRetryCount) {
                log.warn("当前错误可恢复,可由管理员或补偿任务重新投递,taskId={}, retryCount={}/{}",
                        taskId, retryCount + 1, maxRetryCount);
            }
        }

        DocumentInfo document = documentInfoMapper.selectById(documentId);
        if (document != null) {
            document.setIndexStatus(DocumentIndexStatus.FAILED.name());
            document.setErrorMessage(errorMessage);
            document.setUpdatedAt(LocalDateTime.now());
            documentInfoMapper.updateById(document);
        }
    }

    private boolean isRecoverable(Exception e) {
        return !(e instanceof UnrecoverableIndexException || e instanceof BusinessException);
    }

    private String shortMessage(Exception e) {
        String message = e.getMessage();
        if (message == null || message.isBlank()) {
            message = e.getClass().getSimpleName();
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }

    private String buildWorkerId() {
        try {
            return InetAddress.getLocalHost().getHostName() + ":" + UUID.randomUUID();
        } catch (Exception e) {
            return "unknown-worker:" + UUID.randomUUID();
        }
    }
}

不可恢复异常可以定义成一个简单类:

java 复制代码
package com.luo.knowhub.task.exception;

public class UnrecoverableIndexException extends RuntimeException {
    public UnrecoverableIndexException(String message) {
        super(message);
    }
}

这段代码故意把失败后 basicAck 写得很明确。原因是:失败原因已经进入 index_task.error_message,后续由任务状态机控制重试,而不是让 RabbitMQ 原地反复投递同一条失败消息。


9.8 失败处理与重试

索引任务失败很正常。

关键不是永远不失败,而是失败后能追踪、能重试、能恢复。

9.8.1 失败时要做什么

如果执行过程中出现异常,task-service 应该:

text 复制代码
捕获异常
记录 error_message
更新 index_task 为 FAILED
更新 document_info 为 FAILED
ack 消息
释放 Redis 锁

为什么失败后还要 ack?

因为如果一直 nack 并 requeue,消息可能立刻反复进入队列,形成死循环。

更稳妥的方式是把失败交给任务状态机和重试机制管理。


9.7 节消费方法中的 catch 块可以抽成下面的失败处理方法:

java 复制代码
private void handleFailure(Long taskId, Long documentId, Exception e) {
    String errorMessage = buildErrorMessage(e);

    IndexTask task = indexTaskMapper.selectById(taskId);
    if (task == null) {
        return;
    }

    int retryCount = task.getRetryCount() == null ? 0 : task.getRetryCount();
    int maxRetryCount = task.getMaxRetryCount() == null ? 3 : task.getMaxRetryCount();

    boolean recoverable = isRecoverable(e);
    boolean canRetry = recoverable && retryCount + 1 < maxRetryCount;

    task.setStatus(IndexTaskStatus.FAILED.name());
    task.setRetryCount(retryCount + 1);
    task.setEndTime(LocalDateTime.now());
    task.setErrorMessage(errorMessage);
    indexTaskMapper.updateById(task);

    DocumentInfo document = documentInfoMapper.selectById(documentId);
    if (document != null) {
        document.setIndexStatus(DocumentIndexStatus.FAILED.name());
        document.setErrorMessage(errorMessage);
        document.setUpdatedAt(LocalDateTime.now());
        documentInfoMapper.updateById(document);
    }

    if (canRetry) {
        log.warn("索引任务失败但可重试,taskId={}, retryCount={}/{}",
                taskId, retryCount + 1, maxRetryCount);
    } else {
        log.warn("索引任务失败且不再自动重试,taskId={}, recoverable={}, retryCount={}/{}",
                taskId, recoverable, retryCount + 1, maxRetryCount);
    }
}

private boolean isRecoverable(Exception e) {
    // 文件类型不支持、PDF 损坏、解析后为空这类错误,统一包装成 UnrecoverableIndexException。
    if (e instanceof UnrecoverableIndexException) {
        return false;
    }
    return true;
}

private String buildErrorMessage(Exception e) {
    String message = e.getMessage();
    if (message == null || message.isBlank()) {
        message = e.getClass().getSimpleName();
    }
    return message.length() > 1000 ? message.substring(0, 1000) : message;
}

如果想写成 MyBatis-Plus 条件更新,也可以这样限制只有执行中的任务才进入失败状态:

java 复制代码
indexTaskMapper.update(null, new LambdaUpdateWrapper<IndexTask>()
        .eq(IndexTask::getId, taskId)
        .in(IndexTask::getStatus,
                IndexTaskStatus.WAITING.name(),
                IndexTaskStatus.RETRYING.name(),
                IndexTaskStatus.RUNNING.name())
        .set(IndexTask::getStatus, IndexTaskStatus.FAILED.name())
        .set(IndexTask::getRetryCount, retryCount + 1)
        .set(IndexTask::getEndTime, LocalDateTime.now())
        .set(IndexTask::getErrorMessage, errorMessage));

重点是:失败处理必须同时更新 index_taskdocument_info。否则前端看到文档还在 INDEXING,管理端却看到任务已经失败,两边状态就会不一致。

9.8.2 哪些错误适合重试

适合重试:

  • Embedding 接口超时。
  • pgvector 临时不可用。
  • 网络抖动。
  • RabbitMQ 临时异常。

不适合重试:

  • 文件类型不支持。
  • MinIO 文件确实不存在。
  • PDF 文件损坏。
  • 文本解析结果为空。

重试不是万能的。不可恢复错误反复重试,只会浪费资源。

9.8.3 手动重试

管理员可以在管理端看到 FAILED 或 TIMEOUT 任务。

点击重试后,系统执行:

text 复制代码
校验任务状态
检查 retry_count 是否超过 max_retry_count
状态改为 RETRYING
retry_count + 1
重新投递 RabbitMQ 消息

之后 task-service 会再次消费并执行。


管理员手动重试接口可以放在 task-service 中。Gateway 已经负责 /admin/** 的 ADMIN 角色校验,所以 Controller 只关注业务入参。

java 复制代码
package com.luo.knowhub.task.controller;

import com.luo.knowhub.common.api.ApiResponse;
import com.luo.knowhub.task.dto.IndexTaskResponse;
import com.luo.knowhub.task.service.IndexTaskRetryService;
import jakarta.validation.constraints.Positive;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Validated
@RestController
@RequestMapping("/admin/index-tasks")
public class AdminIndexTaskController {

    private final IndexTaskRetryService retryService;

    public AdminIndexTaskController(IndexTaskRetryService retryService) {
        this.retryService = retryService;
    }

    @PostMapping("/{taskId}/retry")
    public ApiResponse<IndexTaskResponse> retry(@PathVariable @Positive Long taskId) {
        return ApiResponse.success(retryService.retry(taskId));
    }
}

Service 完整代码如下:

java 复制代码
package com.luo.knowhub.task.service;

import com.luo.knowhub.common.enums.IndexTaskStatus;
import com.luo.knowhub.common.exception.BusinessException;
import com.luo.knowhub.common.mq.IndexTaskMessage;
import com.luo.knowhub.common.mq.RabbitMqConstants;
import com.luo.knowhub.task.dto.IndexTaskResponse;
import com.luo.knowhub.task.entity.IndexTask;
import com.luo.knowhub.task.mapper.IndexTaskMapper;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Set;

@Service
public class IndexTaskRetryService {

    private static final Set<String> RETRYABLE_STATUS = Set.of(
            IndexTaskStatus.FAILED.name(),
            IndexTaskStatus.TIMEOUT.name()
    );

    private final IndexTaskMapper indexTaskMapper;
    private final RabbitTemplate rabbitTemplate;

    public IndexTaskRetryService(IndexTaskMapper indexTaskMapper, RabbitTemplate rabbitTemplate) {
        this.indexTaskMapper = indexTaskMapper;
        this.rabbitTemplate = rabbitTemplate;
    }

    @Transactional(rollbackFor = Exception.class)
    public IndexTaskResponse retry(Long taskId) {
        IndexTask task = indexTaskMapper.selectById(taskId);
        if (task == null) {
            throw new BusinessException("索引任务不存在");
        }

        if (!RETRYABLE_STATUS.contains(task.getStatus())) {
            throw new BusinessException("只有 FAILED 或 TIMEOUT 状态的任务可以重试");
        }

        int retryCount = task.getRetryCount() == null ? 0 : task.getRetryCount();
        int maxRetryCount = task.getMaxRetryCount() == null ? 3 : task.getMaxRetryCount();
        if (retryCount >= maxRetryCount) {
            throw new BusinessException("索引任务已达到最大重试次数");
        }

        // 先改状态为 RETRYING,再重新投递消息。
        task.setStatus(IndexTaskStatus.RETRYING.name());
        task.setRetryCount(retryCount + 1);
        task.setWorkerId(null);
        task.setStartTime(null);
        task.setEndTime(null);
        task.setErrorMessage(null);
        task.setUpdatedAt(LocalDateTime.now());
        indexTaskMapper.updateById(task);

        IndexTaskMessage message = new IndexTaskMessage(
                task.getId(),
                task.getDocumentId(),
                task.getKbId(),
                task.getUserId()
        );

        rabbitTemplate.convertAndSend(
                RabbitMqConstants.INDEX_EXCHANGE,
                RabbitMqConstants.INDEX_ROUTING_KEY,
                message
        );

        return IndexTaskResponse.from(task);
    }
}

这段代码的核心约束是:只能重试 FAILEDTIMEOUT,不能重试 SUCCESS。否则管理员误点一次,就可能把已经成功的索引又重建一遍。

9.8.4 超时扫描

如果任务长时间处于 RUNNING,就需要定时扫描。

条件类似:

text 复制代码
status = RUNNING
and start_time < 当前时间 - timeout

处理方式:

text 复制代码
标记 TIMEOUT
记录错误信息
允许管理员手动重试

也可以根据配置自动重新投递消息。


超时扫描可以先用 Spring 的 @Scheduled 实现:

java 复制代码
package com.luo.knowhub.task.scheduler;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.luo.knowhub.common.enums.IndexTaskStatus;
import com.luo.knowhub.task.entity.IndexTask;
import com.luo.knowhub.task.mapper.IndexTaskMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;
import java.util.List;

@Slf4j
@Component
public class IndexTaskTimeoutScanner {

    private final IndexTaskMapper indexTaskMapper;

    @Value("${knowhub.index-task.timeout-minutes:30}")
    private long timeoutMinutes;

    public IndexTaskTimeoutScanner(IndexTaskMapper indexTaskMapper) {
        this.indexTaskMapper = indexTaskMapper;
    }

    @Scheduled(cron = "0 */5 * * * ?")
    public void scanTimeoutTasks() {
        LocalDateTime cutoff = LocalDateTime.now().minusMinutes(timeoutMinutes);

        List<IndexTask> tasks = indexTaskMapper.selectList(new LambdaQueryWrapper<IndexTask>()
                .eq(IndexTask::getStatus, IndexTaskStatus.RUNNING.name())
                .isNotNull(IndexTask::getStartTime)
                .lt(IndexTask::getStartTime, cutoff));

        for (IndexTask task : tasks) {
            task.setStatus(IndexTaskStatus.TIMEOUT.name());
            task.setEndTime(LocalDateTime.now());
            task.setErrorMessage("任务执行超时");
            task.setUpdatedAt(LocalDateTime.now());
            indexTaskMapper.updateById(task);

            log.warn("索引任务执行超时,已标记 TIMEOUT,taskId={}, documentId={}, startTime={}",
                    task.getId(), task.getDocumentId(), task.getStartTime());
        }
    }
}

学习项目用 @Scheduled 足够。生产环境如果有多个 task-service 实例,需要考虑分布式调度,例如 XXL-Job、Quartz 集群模式,或者给扫描任务也加 Redis 锁,避免多个实例重复扫描。


9.9 重复消费与幂等设计

消息队列系统中,重复消费不是异常情况,而是必须考虑的正常情况。

KnowHub 采用多层幂等策略。

9.9.1 Redis NX 锁

消费前尝试获取锁:

text 复制代码
SET lock:index-task:{taskId} value NX EX 600

拿到锁才能执行。

拿不到锁说明其他消费者正在处理。


Spring Data Redis 中,NX 锁可以这样写:

java 复制代码
package com.luo.knowhub.task.lock;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@Component
public class RedisDistributedLock {

    private final StringRedisTemplate redisTemplate;

    public RedisDistributedLock(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public boolean tryLock(String key, String value, long timeout, TimeUnit unit) {
        // value 建议存 worker_id,例如 hostname + 端口 + UUID,方便排查哪个节点持有锁。
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, value, timeout, unit);
        return Boolean.TRUE.equals(success);
    }

    public void releaseLock(String key) {
        // 学习版本先用简单删除,便于理解。
        // 生产环境建议使用 Lua 脚本:只有 Redis 中的 value 等于当前 worker_id 时才删除,
        // 防止锁过期后被其他节点重新获取,而当前节点误删别人的锁。
        redisTemplate.delete(key);
    }
}

调用示例:

java 复制代码
String key = "lock:index-task:" + taskId;
String value = workerId;
boolean locked = redisDistributedLock.tryLock(key, value, 600, TimeUnit.SECONDS);
if (!locked) {
    channel.basicAck(deliveryTag, false);
    return;
}

Redis 锁解决的是"同一时刻多个消费者并发执行同一个 taskId"的问题,不解决所有幂等问题。所以还要继续检查任务状态和数据库唯一约束。

9.9.2 任务状态检查

即使拿到锁,也要查任务状态。

如果任务已经 SUCCESS,直接跳过。

如果任务不是 WAITING 或 RETRYING,也不要随便执行。

9.9.3 数据库唯一约束

document_chunk 表可以增加唯一约束:

text 复制代码
document_id + chunk_index

即使代码层漏掉重复处理,数据库也能兜底。

9.9.4 重建索引前删除旧数据

如果是重新索引,先删旧 chunk 和旧向量,再写新数据。

这样能避免同一文档出现多套索引。


9.10 常见问题排查

9.10.1 任务一直 WAITING

说明任务创建了,但没有被消费。

排查顺序:

  1. RabbitMQ 是否启动。
  2. 消息是否发送成功。
  3. exchange、queue、routing key 是否一致。
  4. task-service 是否启动。
  5. 消费者监听的队列是否正确。
  6. 队列里是否有消息堆积。

9.10.2 任务一直 RUNNING

说明任务开始执行,但没有正常结束。

排查顺序:

  1. task-service 日志是否有异常。
  2. 是否卡在 MinIO 下载。
  3. 是否卡在 PDF 解析。
  4. 是否卡在 Embedding 调用。
  5. 是否卡在 pgvector 写入。
  6. 超时扫描是否启用。

9.10.3 任务 FAILED 但没有错误原因

说明异常处理不完整。

失败时必须记录 error_message

否则管理员只能看到失败,却不知道为什么失败。

9.10.4 出现重复 chunk

排查顺序:

  1. RabbitMQ 是否重复投递。
  2. Redis 锁是否生效。
  3. SUCCESS 状态是否跳过。
  4. 重建索引前是否删除旧 chunk。
  5. 数据库是否有 document_id + chunk_index 唯一约束。

9.10.5 消息反复重回队列

可能是消费者异常后一直 nack requeue。

如果错误不可恢复,比如文件损坏,反复 requeue 没有意义。

建议把失败写入任务表,由重试机制统一控制。

9.10.6 手动重试后旧消息仍在队列中导致重复执行

现象:管理员点击重试后,新消息被消费成功,但旧消息仍在队列中,导致任务又被消费一次。

排查顺序:

  1. 确认消费者失败时是否调用了 basicAck。如果失败后没有 ack,RabbitMQ 可能会把旧消息重新投递。
  2. 查看 RabbitMQ 控制台中队列是否存在 Unacked 或 Ready 消息。
  3. 确认 Redis 锁是否生效。即使旧消息重复投递,拿不到 lock:index-task:{taskId} 也应该直接跳过。
  4. 确认任务状态检查是否生效。已经 SUCCESS 的任务再次收到消息时,必须 ack 后直接 return。

9.10.7 task-service 消费速度过慢导致消息堆积

现象:RabbitMQ 控制台显示 rag.index.queue 消息数持续增长,很多任务一直处于 WAITING

排查顺序:

  1. 检查 task-service 是否只有一个实例,单实例消费吞吐有限。
  2. 检查单条索引任务耗时,重点看 PDF 解析、Embedding 接口和 pgvector 写入。
  3. 检查 @RabbitListener 是否配置了并发消费者,例如 concurrency = "2-4"
  4. 如果瓶颈在 Embedding,优先考虑限流、批量向量化或增加任务服务实例。

消息堆积不是单纯"RabbitMQ 慢",多数时候是消费者处理速度跟不上生产速度。


本章小结

这一章我们讲了 KnowHub 的 RabbitMQ 消息队列与索引任务设计。

文档索引是典型耗时任务,不适合放在上传接口中同步执行。KnowHub 通过 index_task 表记录任务状态,通过 RabbitMQ 投递索引消息,通过 task-service 在后台执行解析、切片、Embedding 和向量入库。

任务状态机让每个任务都可追踪:WAITING 表示等待执行,RUNNING 表示正在执行,SUCCESS 表示成功,FAILED 表示失败,RETRYING 表示重试中,TIMEOUT 表示执行超时。

RabbitMQ 提供异步解耦,但也带来重复消费问题。因此系统需要 Redis 幂等锁、任务状态检查、数据库唯一约束和重建索引前清理旧数据等多层保障。

下一章,我们会进入 Embedding 与向量化,讲清文档 chunk 如何变成向量,以及为什么向量维度必须和 pgvector 表结构一致。


动手验证:从上传文档到索引任务完成的全链路追踪

步骤一:确认 RabbitMQ 容器已启动。

powershell 复制代码
docker ps

浏览器访问:

text 复制代码
http://localhost:15672

登录后确认 rag.index.exchangerag.index.queue 是否在 knowledge-service 启动后自动创建。对应本章 9.4.7 和 9.4.8。

步骤二:通过 Gateway 上传一个测试 TXT 文件。

http 复制代码
POST /kb/{kbId}/documents/upload
Authorization: Bearer <token>
Content-Type: multipart/form-data
file: test.txt

预期返回上传成功,文档状态为 INDEXING。对应本章 9.6。

步骤三:查看 RabbitMQ 管理控制台。

进入 Queues 页面查看 rag.index.queue。如果 task-service 尚未启动,应该能看到一条 Ready 消息;如果 task-service 已启动并消费,消息数会很快归零。对应本章 9.7。

步骤四:查询 index_task 表。

sql 复制代码
SELECT id, document_id, status, retry_count, error_message, start_time, end_time
FROM index_task
ORDER BY id DESC
LIMIT 5;

预期状态为 SUCCESSretry_count 为 0,error_messageNULLstart_timeend_time 都有值。对应本章 9.2 和 9.3。

步骤五:查询 document_info 表。

sql 复制代码
SELECT id, index_status, chunk_count, error_message
FROM document_info
WHERE id = 文档ID;

预期 index_status=INDEXEDchunk_count > 0。对应本章 9.7 第十六步。

步骤六:查询 document_chunk 表。

sql 复制代码
SELECT document_id, chunk_index, LEFT(content, 80) AS preview
FROM document_chunk
WHERE document_id = 文档ID
ORDER BY chunk_index;

预期切片数据已写入,chunk_index 从 0 开始递增。对应本章 9.7 第十二步和第十三步。

步骤七:查看 task-service 日志。

确认日志中出现:

text 复制代码
收到索引任务消息
文档解析完成
切片完成
索引任务执行成功

对应本章 9.7 的完整消费链路。

步骤八:模拟失败场景。

上传一个不支持的文件类型,例如 exe。预期 index_task.status=FAILEDerror_message 中有明确原因,document_info.index_status=FAILED。对应本章 9.8。

步骤九:执行管理员手动重试。

http 复制代码
POST /admin/index-tasks/{taskId}/retry
Authorization: Bearer <admin-token>

预期任务状态先变为 RETRYING,随后重新进入消费流程。对应本章 9.8.3。

步骤十:查看 Redis 锁。

任务执行期间可以用 RedisInsight 或 redis-cli 查看:

text 复制代码
lock:index-task:{taskId}

任务结束后,这个 key 应该过期或被删除。对应本章 9.9.1。

如果任务一直 WAITING,优先检查 RabbitMQ 是否运行、exchange 和 queue 是否创建成功、routing key 是否匹配、task-service 的 @RabbitListener 是否生效。

思考题

  1. 为什么文档索引不适合在上传接口里同步完成?
  2. index_task 表为什么需要 statusretry_counterror_message
  3. RabbitMQ 中 exchange、queue、routing key 分别是什么?
  4. 为什么索引消息里只传 ID,不传完整文件内容?
  5. 为什么消费者失败后不一定要直接 nack requeue?
  6. Redis 幂等锁和任务状态检查分别解决什么问题?
  7. 如何防止 RabbitMQ 重复消费导致 chunk 重复入库?

思考题参考答案

1. 为什么文档索引不适合在上传接口里同步完成?

因为文档索引涉及 PDF 解析、文本切片、Embedding 调用、向量入库等多个耗时步骤,如果全部放在上传接口中同步执行,用户可能需要等待几十秒甚至更久,接口容易超时。此外,中间任何一步失败都难以追踪和重试。异步化可以让上传接口快速返回,后台任务继续执行,提升用户体验和系统可靠性。

2. index_task 表为什么需要 statusretry_counterror_message

  • status:记录任务当前所处的阶段(WAITING/RUNNING/SUCCESS/FAILED/RETRYING/TIMEOUT),让系统和管理员能追踪任务进度。
  • retry_count:记录已重试次数,结合 max_retry_count 控制重试上限,避免无限重试浪费资源。
  • error_message:记录失败原因,方便管理员排查问题。没有错误信息的失败任务几乎无法定位根因。

3. RabbitMQ 中 exchange、queue、routing key 分别是什么?

  • Exchange(交换机):生产者发送消息的目标,负责根据 routing key 将消息路由到对应的队列。
  • Queue(队列):存储消息的缓冲区,消费者从队列中拉取消息进行处理。
  • Routing Key(路由键):消息携带的标签,exchange 根据 routing key 和绑定规则决定将消息投递到哪个队列。

4. 为什么索引消息里只传 ID,不传完整文件内容?

  • 消息体小,传输和存储开销低。
  • 文件和消息解耦,文件内容已保存在 MinIO,消费者根据 ID 按需下载。
  • 失败重试时,消费者可以重新查询最新的任务状态和文件路径,避免消息中携带过时数据。
  • 避免消息体过大导致 RabbitMQ 性能下降或内存溢出。

5. 为什么消费者失败后不一定要直接 nack requeue?

因为 nack requeue 会让消息立刻重新进入队列头部,可能被同一消费者或其他消费者再次消费,形成死循环。尤其对于不可恢复的错误(如文件损坏、格式不支持),反复 requeue 只会浪费资源。更合理的做法是:失败后 ack 消息,将失败原因写入 index_task.error_message,由任务状态机和重试机制统一控制重试时机和次数。

6. Redis 幂等锁和任务状态检查分别解决什么问题?

  • Redis 幂等锁 :解决多个消费者同时消费同一个 taskId 的并发问题。通过 SET NX EX 确保同一时刻只有一个消费者能处理该任务,防止重复执行。
  • 任务状态检查:解决消息重复投递后,任务已经成功却被再次执行的问题。即使拿到锁,也要检查任务状态是否为 WAITING 或 RETRYING,如果已经是 SUCCESS 则直接跳过,避免破坏已有结果。

7. 如何防止 RabbitMQ 重复消费导致 chunk 重复入库?

采用多层幂等策略:

  1. Redis NX 锁 :消费前获取 lock:index-task:{taskId},防止并发重复执行。
  2. 任务状态检查:消费前确认任务状态为 WAITING 或 RETRYING,已 SUCCESS 的任务直接跳过。
  3. 数据库唯一约束document_chunk 表增加 document_id + chunk_index 唯一索引,即使代码层漏掉,数据库也能拒绝重复插入。
  4. 重建索引前删除旧数据:重新索引时先删除该文档的所有旧 chunk 和旧向量,再写入新数据,确保同一文档只有一份最新索引结果。
相关推荐
naierfengdian3 小时前
分布式风力发电机机抗风扰、稳发电结构设计技术解析
分布式·能源
wWYy.12 小时前
基于Raft分布式Kv存储:sendRequestVote
分布式
笨鸟先飞的橘猫20 小时前
游戏后端分布式学习——消息队列在游戏的用法
分布式·学习·游戏
Wang's Blog20 小时前
Go-Zero项目开发40: 基于Jaeger的分布式链路跟踪实战
开发语言·分布式·golang
wWYy.20 小时前
基于Raft分布式Kv存储:sendAppendEntries
分布式
范什么特西1 天前
关于kafka
分布式·kafka
wWYy.1 天前
基于Raft分布式Kv存储:AppendEntries
分布式
霸道流氓气质1 天前
分布式系统中跨进程上下文传递方案
分布式
wWYy.1 天前
基于Raft的分布式Kv存储项目:raft.h
开发语言·分布式·qt