本章目标
前面几章我们已经完成了一条文档进入系统的前半段链路:
text
用户创建知识库
-> 上传文档
-> 文件保存到 MinIO
-> document_info 记录元数据
-> 解析文件
-> 文本切片
但在真实系统中,解析、切片、Embedding、向量入库这些步骤不能全部放在上传接口里同步执行。
原因很简单:它们太慢、太容易失败,也太需要追踪。
如果用户上传一个 PDF,接口要一直等待:
text
PDF 下载
-> PDF 解析
-> 文本切片
-> 调用 Embedding
-> 写入 MySQL
-> 写入 pgvector
用户可能等几十秒,甚至直接超时。
更糟糕的是,如果中间某一步失败,系统很难告诉用户到底失败在哪里,也很难重新执行。
所以 KnowHub 使用 RabbitMQ 和索引任务表,把文档索引改造成后台异步流程。
本章要讲清楚:
- 为什么文档索引必须异步化。
index_task表如何设计。- 任务状态机如何流转。
- RabbitMQ 中 exchange、queue、routing key 是什么。
- knowledge-service 如何投递索引消息。
- task-service 如何消费消息并执行索引。
- Redis 幂等锁如何防止重复消费。
- 失败重试和超时任务如何处理。
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 用于后台扫描 WAITING、RUNNING、FAILED 任务,idx_user_id 和 idx_kb_id 用于管理端筛选和用户资源隔离。
9.2.1 document_id
表示这个任务处理哪一份文档。
9.2.2 kb_id 和 user_id
kb_id 和 user_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_time 和 worker_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-minutes、max-retry-count、lock-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_task 和 document_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);
}
}
这段代码的核心约束是:只能重试 FAILED 或 TIMEOUT,不能重试 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
说明任务创建了,但没有被消费。
排查顺序:
- RabbitMQ 是否启动。
- 消息是否发送成功。
- exchange、queue、routing key 是否一致。
- task-service 是否启动。
- 消费者监听的队列是否正确。
- 队列里是否有消息堆积。
9.10.2 任务一直 RUNNING
说明任务开始执行,但没有正常结束。
排查顺序:
- task-service 日志是否有异常。
- 是否卡在 MinIO 下载。
- 是否卡在 PDF 解析。
- 是否卡在 Embedding 调用。
- 是否卡在 pgvector 写入。
- 超时扫描是否启用。
9.10.3 任务 FAILED 但没有错误原因
说明异常处理不完整。
失败时必须记录 error_message。
否则管理员只能看到失败,却不知道为什么失败。
9.10.4 出现重复 chunk
排查顺序:
- RabbitMQ 是否重复投递。
- Redis 锁是否生效。
- SUCCESS 状态是否跳过。
- 重建索引前是否删除旧 chunk。
- 数据库是否有
document_id + chunk_index唯一约束。
9.10.5 消息反复重回队列
可能是消费者异常后一直 nack requeue。
如果错误不可恢复,比如文件损坏,反复 requeue 没有意义。
建议把失败写入任务表,由重试机制统一控制。
9.10.6 手动重试后旧消息仍在队列中导致重复执行
现象:管理员点击重试后,新消息被消费成功,但旧消息仍在队列中,导致任务又被消费一次。
排查顺序:
- 确认消费者失败时是否调用了
basicAck。如果失败后没有 ack,RabbitMQ 可能会把旧消息重新投递。 - 查看 RabbitMQ 控制台中队列是否存在 Unacked 或 Ready 消息。
- 确认 Redis 锁是否生效。即使旧消息重复投递,拿不到
lock:index-task:{taskId}也应该直接跳过。 - 确认任务状态检查是否生效。已经
SUCCESS的任务再次收到消息时,必须 ack 后直接 return。
9.10.7 task-service 消费速度过慢导致消息堆积
现象:RabbitMQ 控制台显示 rag.index.queue 消息数持续增长,很多任务一直处于 WAITING。
排查顺序:
- 检查 task-service 是否只有一个实例,单实例消费吞吐有限。
- 检查单条索引任务耗时,重点看 PDF 解析、Embedding 接口和 pgvector 写入。
- 检查
@RabbitListener是否配置了并发消费者,例如concurrency = "2-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.exchange 和 rag.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;
预期状态为 SUCCESS,retry_count 为 0,error_message 为 NULL,start_time 和 end_time 都有值。对应本章 9.2 和 9.3。
步骤五:查询 document_info 表。
sql
SELECT id, index_status, chunk_count, error_message
FROM document_info
WHERE id = 文档ID;
预期 index_status=INDEXED,chunk_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=FAILED,error_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 是否生效。
思考题
- 为什么文档索引不适合在上传接口里同步完成?
index_task表为什么需要status、retry_count和error_message?- RabbitMQ 中 exchange、queue、routing key 分别是什么?
- 为什么索引消息里只传 ID,不传完整文件内容?
- 为什么消费者失败后不一定要直接 nack requeue?
- Redis 幂等锁和任务状态检查分别解决什么问题?
- 如何防止 RabbitMQ 重复消费导致 chunk 重复入库?
思考题参考答案
1. 为什么文档索引不适合在上传接口里同步完成?
因为文档索引涉及 PDF 解析、文本切片、Embedding 调用、向量入库等多个耗时步骤,如果全部放在上传接口中同步执行,用户可能需要等待几十秒甚至更久,接口容易超时。此外,中间任何一步失败都难以追踪和重试。异步化可以让上传接口快速返回,后台任务继续执行,提升用户体验和系统可靠性。
2. index_task 表为什么需要 status、retry_count 和 error_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 重复入库?
采用多层幂等策略:
- Redis NX 锁 :消费前获取
lock:index-task:{taskId},防止并发重复执行。 - 任务状态检查:消费前确认任务状态为 WAITING 或 RETRYING,已 SUCCESS 的任务直接跳过。
- 数据库唯一约束 :
document_chunk表增加document_id + chunk_index唯一索引,即使代码层漏掉,数据库也能拒绝重复插入。 - 重建索引前删除旧数据:重新索引时先删除该文档的所有旧 chunk 和旧向量,再写入新数据,确保同一文档只有一份最新索引结果。