第13章 Redis 缓存、幂等锁与任务状态

本章目标

第 12 章我们已经完成了 RAG 问答闭环。

用户可以在知识库中提问,系统会完成:

text 复制代码
鉴权
-> 向量检索
-> Prompt 构造
-> 聊天模型调用
-> 引用返回
-> 问答日志记录

到这里,主功能已经能跑通。

但一个平台如果只追求"能跑",还不够。

真实系统还要考虑:

text 复制代码
高频权限校验会不会反复查数据库?
前端轮询索引任务状态会不会反复打 task-service?
RabbitMQ 重复消费时,同一个任务会不会并发执行?
临时状态应该放在哪里?

这些问题就引出了 Redis。

在 KnowHub 中,Redis 主要承担三个职责:

  1. 知识库 owner 缓存。
  2. 文档索引任务状态缓存。
  3. 索引任务 Redis NX 幂等锁。

本章要讲清楚:

  1. Redis 在 RAG 平台里解决什么问题。
  2. Redis 为什么不是主数据库。
  3. 知识库 owner 缓存如何加速权限校验。
  4. 文档索引任务状态缓存如何减少重复查询。
  5. Redis NX 锁如何防止同一任务并发执行。
  6. Redis key 应该如何设计。
  7. TTL 应该如何设计。
  8. Redis 不可用时系统应该如何处理。
  9. 常见 Redis 问题如何排查。

13.1 为什么引入 Redis

Redis 是一个内存型数据存储组件。

它最常见的用途是缓存。

缓存的意思是:

text 复制代码
把经常读取、变化不那么频繁的数据,临时放在更快的地方。

在 KnowHub 中,有些数据会被频繁读取。

例如每次访问知识库、上传文档、检索文档、问答时,都要校验:

text 复制代码
这个知识库是不是当前用户的?

如果每次都查 MySQL,数据库压力会增加。

再比如,用户上传文档后,前端可能每隔几秒查询一次索引状态:

text 复制代码
WAITING
RUNNING
SUCCESS
FAILED

如果所有轮询都直接请求 task-service,也会造成重复调用。

还有 RabbitMQ 任务消费场景。

消息队列存在重复投递的可能。

如果两个消费者同时处理同一个 taskId,就可能出现重复解析、重复切片、重复向量化。

Redis 正好适合处理这三类问题:

text 复制代码
高频读取:用缓存。
短期状态:用带 TTL 的 key。
并发协调:用 SET NX 锁。

13.2 Redis 不是主数据库

学习 Redis 时,最容易犯的错误是把缓存当成事实来源。

在 KnowHub 中,必须先明确一条原则:

text 复制代码
Redis 只是加速层和协调层,不是主数据来源。

真正的业务主数据仍然在 MySQL 或 task-service 的数据库中。

例如:

text 复制代码
知识库归属:最终以 MySQL knowledge_base 表为准。
索引任务状态:最终以 task-service 的任务表为准。
索引任务是否合法流转:最终以 task-service 状态机为准。

Redis 能做的是:

text 复制代码
减少重复查询
提高响应速度
降低远程调用压力
减少并发重复执行概率

但不能让 Redis 单独决定业务真相。

这一点在本章所有设计中都会反复出现。


13.3 Redis 连接配置

13.3.1 启动 Redis(Docker)

本章所有缓存和锁代码都依赖 Redis 运行。

如果本地还没有 Redis,最小启动命令可以直接用 Docker:

bash 复制代码
docker run -d ^
  --name knowhub-redis ^
  -p 6379:6379 ^
  -v D:/rag/docker/redis-data:/data ^
  redis:7.2 ^
  redis-server --appendonly yes

如果你希望给 Redis 增加密码,可以改成:

bash 复制代码
docker run -d ^
  --name knowhub-redis ^
  -p 6379:6379 ^
  -v D:/rag/docker/redis-data:/data ^
  redis:7.2 ^
  redis-server --appendonly yes --requirepass 123456

这里有两个点要注意:

text 复制代码
-p 6379:6379       把宿主机 6379 端口映射到容器
-v ...:/data       把 Redis 数据目录挂载出来,避免容器重启后缓存和持久化文件全部丢失

等价的 Docker Compose 最小配置如下:

yaml 复制代码
version: "3.8"

services:
  redis:
    image: redis:7.2
    container_name: knowhub-redis
    ports:
      - "6379:6379"
    volumes:
      - ./redis-data:/data
    command: ["redis-server", "--appendonly", "yes"]

启动完成后,可以用下面两种方式确认 Redis 可用:

第一种,命令行:

bash 复制代码
redis-cli -h 127.0.0.1 -p 6379 ping

预期返回:

text 复制代码
PONG

第二种,图形工具:

text 复制代码
RedisInsight

连接 127.0.0.1:6379 后,可以直接查看 key、TTL 和 value。


13.3.2 Maven 依赖

Spring Data Redis 依赖如下:

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

这两个服务都需要引入这个依赖:

text 复制代码
knowledge-service:负责 owner 缓存、任务状态缓存、Redis NX 锁
task-service:如果后续把任务锁或状态缓存逻辑下沉到 task-service,同样需要 Redis 依赖

在 Spring Boot 中,只要引入这个 starter,并配置好:

yaml 复制代码
spring.data.redis

自动配置就会创建:

java 复制代码
StringRedisTemplate

所以当前阶段不需要手动写 RedisTemplate 配置类。对于本章这种"字符串 key + 字符串 value"的场景,StringRedisTemplate 已经足够。


rag-knowledge-service 中,Redis 连接配置位于:

text 复制代码
application.yml

核心配置是:

yaml 复制代码
spring:
  data:
    redis:
      host: ${RAG_REDIS_HOST:127.0.0.1}
      port: ${RAG_REDIS_PORT:6379}
      password: ${RAG_REDIS_PASSWORD:}
      database: ${RAG_REDIS_DATABASE:0}

这里使用环境变量做覆盖。

本地开发时,如果 Redis 跑在本机,默认就是:

text 复制代码
127.0.0.1:6379

如果放到 Linux 虚拟机或 Docker 中,修改环境变量即可。

项目中主要使用:

java 复制代码
StringRedisTemplate

它适合处理字符串 key 和字符串 value。

KnowHub 里 owner 缓存保存的是用户 ID 字符串,任务状态缓存保存的是 JSON 字符串,任务锁保存的是 worker 标识字符串。

所以 StringRedisTemplate 足够满足当前需求。


13.4 知识库 owner 缓存

第 5 章我们讲过用户资源隔离。

用户访问某个知识库时,系统必须判断:

text 复制代码
这个 kbId 是否属于当前 userId?

这个校验非常高频。

文档上传要校验。

文档列表要校验。

向量检索要校验。

RAG 问答也要校验。

所以项目加入了知识库 owner 缓存。

13.4.1 配置类

补充完整配置类代码:

java 复制代码
package com.luo.ragknowledge.kb.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

/**
 * 知识库归属缓存配置。
 *
 * 这个缓存只用于降低高频 owner 校验的数据库压力,
 * 最终归属关系仍然以 MySQL 中的 knowledge_base 表为准。
 */
@ConfigurationProperties(prefix = "rag.kb.owner-cache")
public class KnowledgeBaseOwnerCacheProperties {

    /**
     * 是否启用 owner 缓存。默认启用。
     */
    private boolean enabled = true;

    /**
     * Redis key 前缀,最终 key = keyPrefix + kbId。
     */
    private String keyPrefix = "rag:kb:owner:";

    /**
     * owner 缓存 TTL。知识库归属变化频率低,所以默认缓存 30 分钟。
     */
    private Duration ttl = Duration.ofMinutes(30);

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public Duration getTtl() {
        return ttl;
    }

    public void setTtl(Duration ttl) {
        this.ttl = ttl;
    }
}

配置类是:

text 复制代码
KnowledgeBaseOwnerCacheProperties

配置前缀是:

yaml 复制代码
rag:
  kb:
    owner-cache:
      enabled: true
      key-prefix: rag:kb:owner:
      ttl: 30m

这表示:

text 复制代码
enabled:是否启用 owner 缓存
key-prefix:Redis key 前缀
ttl:缓存 30 分钟

最终 key 形如:

text 复制代码
rag:kb:owner:{kbId}

例如:

text 复制代码
rag:kb:owner:2

value 是 owner userId。

例如:

text 复制代码
10001

13.4.2 读取缓存

KnowledgeBaseServiceImpl.getActiveKnowledgeBase(...) 中,系统先尝试读取缓存:

java 复制代码
Long cachedOwnerId = getCachedOwnerId(knowledgeBaseId);

如果缓存命中,并且 owner 不是当前用户,就可以直接拒绝:

java 复制代码
if (cachedOwnerId != null && !cachedOwnerId.equals(userId)) {
    throw new BusinessException(ErrorCode.NOT_FOUND, "知识库不存在");
}

为什么返回"知识库不存在",而不是"你没有权限"?

这是安全设计。

如果告诉用户"没有权限",等于暴露了这个知识库 ID 确实存在。

返回"不存在"可以减少资源枚举风险。

13.4.3 回源 MySQL

补充 KnowledgeBaseServiceImpl.getActiveKnowledgeBase(...) 中缓存旁路模式的完整代码:

java 复制代码
@Override
public KnowledgeBase getActiveKnowledgeBase(Long userId, Long kbId) {
    // 13.4.2:先查 Redis owner 缓存。
    Long cachedOwnerId = ownerCacheService.getCachedOwnerId(kbId);
    if (cachedOwnerId != null) {
        // 缓存命中且 owner 不是当前用户,直接按"不存在"处理,避免暴露资源存在性。
        if (!cachedOwnerId.equals(userId)) {
            throw new BusinessException(ErrorCode.NOT_FOUND, "知识库不存在");
        }
    }

    // 13.4.3:缓存未命中,回源 MySQL 查询 ACTIVE 知识库。
    KnowledgeBase knowledgeBase = knowledgeBaseMapper.selectOne(
            new LambdaQueryWrapper<KnowledgeBase>()
                    .eq(KnowledgeBase::getId, kbId)
                    .eq(KnowledgeBase::getStatus, KnowledgeBaseStatus.ACTIVE.getCode())
    );

    if (knowledgeBase == null) {
        throw new BusinessException(ErrorCode.NOT_FOUND, "知识库不存在");
    }

    // MySQL 查到后写回缓存,形成典型的 Cache Aside 模式。
    ownerCacheService.cacheOwner(kbId, knowledgeBase.getUserId());

    // 最终权限仍然以 MySQL 查到的 owner 为准。
    if (!userId.equals(knowledgeBase.getUserId())) {
        throw new BusinessException(ErrorCode.NOT_FOUND, "知识库不存在");
    }

    return knowledgeBase;
}

这段代码体现了本章反复强调的一点:

text 复制代码
Redis 负责加速,
MySQL 负责最终真相。

缓存没有命中时,系统会查询 MySQL:

text 复制代码
select knowledge_base by id and ACTIVE status

查到后,会把 owner 写回 Redis:

java 复制代码
cacheOwner(knowledgeBase.getId(), knowledgeBase.getUserId());

这就是典型的缓存旁路模式。

可以理解成:

text 复制代码
先查缓存
缓存没有就查数据库
查到后写缓存

13.4.4 删除缓存

知识库软删除时,系统会执行:

java 复制代码
evictOwnerCache(knowledgeBaseId);

因为知识库状态已经从 ACTIVE 变成 DELETED。

如果不删缓存,旧 owner 缓存可能继续存在。

虽然后续 MySQL 校验还能兜底,但缓存脏数据会增加误判和排查难度。

13.4.5 Redis 异常如何处理

owner 缓存读写都包了异常处理。

读取失败时:

text 复制代码
回退 MySQL

写入失败时:

text 复制代码
记录日志,不影响主链路

这说明 owner 缓存是优化,不是必要条件。

即使 Redis 暂时不可用,知识库权限校验仍然可以依赖 MySQL 完成。


13.4.6 KnowledgeBaseOwnerCacheService 完整实现

为了避免把 Redis 读写逻辑散落在多个业务类中,推荐把 owner 缓存封装成独立 Service:

java 复制代码
package com.luo.ragknowledge.kb.service.cache;

import com.luo.ragknowledge.kb.config.KnowledgeBaseOwnerCacheProperties;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

/**
 * 知识库 owner 缓存服务。
 *
 * Redis 读写失败时一律降级,不阻塞主链路。
 */
@Service
public class KnowledgeBaseOwnerCacheService {
    private static final Logger log = LoggerFactory.getLogger(KnowledgeBaseOwnerCacheService.class);

    private final StringRedisTemplate stringRedisTemplate;
    private final KnowledgeBaseOwnerCacheProperties cacheProperties;

    public KnowledgeBaseOwnerCacheService(StringRedisTemplate stringRedisTemplate,
                                          KnowledgeBaseOwnerCacheProperties cacheProperties) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.cacheProperties = cacheProperties;
    }

    public Long getCachedOwnerId(Long kbId) {
        if (!cacheProperties.isEnabled() || kbId == null) {
            return null;
        }
        try {
            String key = cacheProperties.getKeyPrefix() + kbId;
            String value = stringRedisTemplate.opsForValue().get(key);
            if (!StringUtils.hasText(value)) {
                return null;
            }
            return Long.valueOf(value);
        } catch (Exception ex) {
            // 读缓存失败时直接降级查 MySQL,不能因为 Redis 故障把权限校验主链路卡死。
            log.warn("读取知识库 owner 缓存失败,将回退 MySQL:kbId={}, error={}", kbId, ex.getMessage());
            return null;
        }
    }

    public void cacheOwner(Long kbId, Long userId) {
        if (!cacheProperties.isEnabled() || kbId == null || userId == null) {
            return;
        }
        try {
            String key = cacheProperties.getKeyPrefix() + kbId;
            stringRedisTemplate.opsForValue().set(
                    key,
                    String.valueOf(userId),
                    cacheProperties.getTtl().toSeconds(),
                    TimeUnit.SECONDS
            );
        } catch (Exception ex) {
            // 写缓存失败不影响主链路,只记日志。
            log.warn("写入知识库 owner 缓存失败:kbId={}, userId={}, error={}", kbId, userId, ex.getMessage());
        }
    }

    public void evictOwnerCache(Long kbId) {
        if (!cacheProperties.isEnabled() || kbId == null) {
            return;
        }
        try {
            String key = cacheProperties.getKeyPrefix() + kbId;
            stringRedisTemplate.delete(key);
        } catch (Exception ex) {
            // 删除缓存失败同样不抛异常,最终真相仍由 MySQL 保证。
            log.warn("删除知识库 owner 缓存失败:kbId={}, error={}", kbId, ex.getMessage());
        }
    }
}

13.5 文档索引任务状态缓存

用户上传文档后,前端通常会显示:

text 复制代码
正在索引
索引成功
索引失败

前端可能会轮询接口获取最新状态。

如果每次轮询都调用 task-service,再查任务表,会产生重复压力。

所以 KnowHub 增加了文档索引任务状态缓存。

13.5.1 配置类

配置类是:

text 复制代码
DocumentIndexTaskCacheProperties

配置前缀是:

yaml 复制代码
rag:
  task:
    status-cache:
      enabled: true
      key-prefix: rag:document:index-task:
      ttl: 1m

最终 key 形如:

text 复制代码
rag:document:index-task:{userId}:{kbId}:{documentId}

例如:

text 复制代码
rag:document:index-task:10001:2:88

为什么 key 中包含 userIdkbId

因为任务状态也要遵守用户隔离和知识库隔离。

不能只用 documentId 作为 key。

补充完整配置类代码:

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

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

/**
 * 文档最近一次索引任务状态缓存配置。
 *
 * 它用于降低前端轮询 latest 状态时对 task-service 的重复调用压力。
 */
@ConfigurationProperties(prefix = "rag.task.status-cache")
public class DocumentIndexTaskCacheProperties {

    /**
     * 是否启用任务状态缓存。默认启用。
     */
    private boolean enabled = true;

    /**
     * Redis key 前缀,最终 key = keyPrefix + userId + ":" + kbId + ":" + documentId。
     */
    private String keyPrefix = "rag:document:index-task:";

    /**
     * 状态缓存 TTL 默认只有 1 分钟。
     *
     * 它比 owner 缓存短得多,因为任务状态变化频率远高于知识库 owner,
     * 太长会让前端持续看到过期状态。
     */
    private Duration ttl = Duration.ofMinutes(1);

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public Duration getTtl() {
        return ttl;
    }

    public void setTtl(Duration ttl) {
        this.ttl = ttl;
    }
}

13.5.2 缓存内容

任务状态缓存保存的是:

text 复制代码
IndexTaskResponse 的 JSON 字符串

代码中通过 ObjectMapper 序列化:

java 复制代码
String json = objectMapper.writeValueAsString(response);

读取时再反序列化:

java 复制代码
IndexTaskResponse response = objectMapper.readValue(json, IndexTaskResponse.class);

也就是说,Redis 中不是只保存状态字符串,而是保存任务响应对象。

这样前端查询时可以拿到更完整的信息。

例如:

text 复制代码
taskId
documentId
kbId
userId
status
errorMessage
retryCount

13.5.3 latest 查询如何使用缓存

DocumentIndexTaskExecutionService.latest(...) 中,流程是:

java 复制代码
IndexTaskResponse cached = documentIndexTaskCacheService.getLatest(userId, kbId, documentId);
if (cached != null) {
    return cached;
}
IndexTaskResponse latest = unwrap(indexTaskClient.latest(documentId, userId, kbId));
documentIndexTaskCacheService.cacheLatest(latest);
return latest;

翻译成业务语言就是:

text 复制代码
先查 Redis。
命中就直接返回。
没命中就调用 task-service。
调用后把结果放回 Redis。

这样前端高频轮询时,短时间内可以减少对 task-service 的重复访问。

13.5.4 状态变化时更新缓存

索引任务执行过程中,状态会变化。

例如:

text 复制代码
WAITING -> RUNNING -> SUCCESS
WAITING -> RUNNING -> FAILED

项目会在状态变化后更新缓存。

例如任务开始运行后:

java 复制代码
IndexTaskResponse runningTask = unwrap(indexTaskClient.markRunning(...));
documentIndexTaskCacheService.cacheLatest(runningTask);

成功后:

java 复制代码
IndexTaskResponse successTask = unwrap(indexTaskClient.markSuccess(taskId));
documentIndexTaskCacheService.cacheLatest(successTask);

失败后:

java 复制代码
IndexTaskResponse failedTask = unwrap(indexTaskClient.markFailed(...));
documentIndexTaskCacheService.cacheLatest(failedTask);

这样前端查询时可以更快看到最新状态。

13.5.5 重试时为什么先删除缓存

重试接口中有:

java 复制代码
documentIndexTaskCacheService.evictLatest(userId, kbId, documentId);

然后才查询 latest、调用 retry。

原因是:旧缓存可能还是 FAILED 或 SUCCESS。

如果不删除,重试后前端可能短时间看到旧状态。

所以重试前先清理缓存。


13.5.6 DocumentIndexTaskCacheService 完整实现

任务状态缓存推荐独立成一个 Service,专门处理 JSON 读写和异常降级:

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

import com.fasterxml.jackson.databind.ObjectMapper;
import com.luo.ragknowledge.task.config.DocumentIndexTaskCacheProperties;
import com.luo.ragknowledge.task.dto.IndexTaskResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;

/**
 * 文档索引任务状态缓存服务。
 *
 * Redis 或 JSON 处理失败时,一律回退 task-service,不能中断 latest 查询主链路。
 */
@Service
public class DocumentIndexTaskCacheService {
    private static final Logger log = LoggerFactory.getLogger(DocumentIndexTaskCacheService.class);

    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;
    private final DocumentIndexTaskCacheProperties cacheProperties;

    public DocumentIndexTaskCacheService(StringRedisTemplate stringRedisTemplate,
                                         ObjectMapper objectMapper,
                                         DocumentIndexTaskCacheProperties cacheProperties) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.objectMapper = objectMapper;
        this.cacheProperties = cacheProperties;
    }

    public IndexTaskResponse getLatest(Long userId, Long kbId, Long documentId) {
        if (!cacheProperties.isEnabled()) {
            return null;
        }
        try {
            String key = cacheProperties.getKeyPrefix() + userId + ":" + kbId + ":" + documentId;
            String json = stringRedisTemplate.opsForValue().get(key);
            if (!StringUtils.hasText(json)) {
                return null;
            }
            return objectMapper.readValue(json, IndexTaskResponse.class);
        } catch (Exception ex) {
            // 读缓存失败或 JSON 反序列化失败,都降级远程查 task-service。
            log.warn("读取任务状态缓存失败,将回退 task-service:userId={}, kbId={}, documentId={}, error={}",
                    userId, kbId, documentId, ex.getMessage());
            return null;
        }
    }

    public void cacheLatest(IndexTaskResponse response) {
        if (!cacheProperties.isEnabled() || response == null) {
            return;
        }
        try {
            String key = cacheProperties.getKeyPrefix()
                    + response.getUserId() + ":" + response.getKbId() + ":" + response.getDocumentId();
            String json = objectMapper.writeValueAsString(response);
            stringRedisTemplate.opsForValue().set(
                    key,
                    json,
                    cacheProperties.getTtl().toSeconds(),
                    TimeUnit.SECONDS
            );
        } catch (Exception ex) {
            // JSON 序列化失败或 Redis 写入失败都不抛异常,否则 latest 主链路会被缓存拖死。
            log.warn("写入任务状态缓存失败:taskId={}, error={}",
                    response.getId(), ex.getMessage());
        }
    }

    public void evictLatest(Long userId, Long kbId, Long documentId) {
        if (!cacheProperties.isEnabled()) {
            return;
        }
        try {
            String key = cacheProperties.getKeyPrefix() + userId + ":" + kbId + ":" + documentId;
            stringRedisTemplate.delete(key);
        } catch (Exception ex) {
            // 删除失败只记日志,不影响重试和状态回源。
            log.warn("删除任务状态缓存失败:userId={}, kbId={}, documentId={}, error={}",
                    userId, kbId, documentId, ex.getMessage());
        }
    }
}

13.6 为什么任务状态缓存 TTL 只有 1 分钟

owner 缓存 TTL 是 30 分钟。

任务状态缓存 TTL 是 1 分钟。

为什么差别这么大?

因为两类数据变化频率不同。

知识库 owner 很少变化。

一个知识库创建后,通常不会频繁换 owner。

所以缓存 30 分钟比较合理。

但索引任务状态变化很快。

一个任务可能几秒钟内从 WAITING 变成 RUNNING,再变成 SUCCESS。

如果任务状态缓存太久,就容易让前端看到旧状态。

所以任务状态缓存使用短 TTL:

text 复制代码
1 分钟

TTL 不是随便设的。

它要结合业务变化频率。

可以先记住:

text 复制代码
变化慢的数据,TTL 可以长一点。
变化快的数据,TTL 应该短一点。

13.7 Redis NX 幂等锁

第 9 章讲 RabbitMQ 时,我们已经提到重复消费问题。

消息队列不能保证业务只执行一次。

即使同一条消息只投递一次,也可能出现:

text 复制代码
消费者执行超时
消费者重启
消息重新投递
多个 worker 同时处理同一 taskId

为了降低同一个任务并发执行的风险,项目提供了 Redis NX 锁。

13.7.1 配置类

配置类是:

text 复制代码
IndexTaskLockProperties

配置前缀是:

yaml 复制代码
rag:
  task:
    lock:
      enabled: false
      key-prefix: rag:index-task:lock:
      ttl: 30m

注意,当前默认是:

text 复制代码
enabled=false

这样本地主链路即使没有 Redis,也能先运行。

在验证 RabbitMQ 消费幂等和多 worker 场景时,再开启它。

最终 key 形如:

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

例如:

text 复制代码
rag:index-task:lock:10086

补充完整配置类代码:

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

import org.springframework.boot.context.properties.ConfigurationProperties;

import java.time.Duration;

/**
 * 索引任务 Redis NX 锁配置。
 *
 * Redis 锁只负责防止同一 taskId 被多个 worker 并发执行,
 * 真正的任务状态仍由 task-service 状态机维护。
 */
@ConfigurationProperties(prefix = "rag.task.lock")
public class IndexTaskLockProperties {

    /**
     * 是否启用 Redis 锁。默认 false,
     * 原因是本地学习阶段可以先不依赖 Redis 锁,让主链路先跑通。
     */
    private boolean enabled = false;

    /**
     * Redis 锁 key 前缀,最终 key = keyPrefix + taskId。
     */
    private String keyPrefix = "rag:index-task:lock:";

    /**
     * 锁 TTL。必须大于正常索引任务耗时,避免任务执行中锁过早过期。
     */
    private Duration ttl = Duration.ofMinutes(30);

    public boolean isEnabled() {
        return enabled;
    }

    public void setEnabled(boolean enabled) {
        this.enabled = enabled;
    }

    public String getKeyPrefix() {
        return keyPrefix;
    }

    public void setKeyPrefix(String keyPrefix) {
        this.keyPrefix = keyPrefix;
    }

    public Duration getTtl() {
        return ttl;
    }

    public void setTtl(Duration ttl) {
        this.ttl = ttl;
    }
}

13.7.2 SET NX 是什么

Redis 的 NX 可以理解成:

text 复制代码
只有 key 不存在时才设置成功。

项目中对应代码是:

java 复制代码
Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent(key, owner, lockProperties.getTtl());

如果返回 true,说明抢锁成功。

如果返回 false,说明已经有其他 worker 持有锁。

此时当前 worker 会跳过这个任务:

java 复制代码
if (lockToken.isRejected()) {
    return;
}

这样可以防止同一个 taskId 同时被多个 worker 执行。

13.7.3 owner value 为什么要带 UUID

锁的 value 不是简单写 workerId。

项目中会生成:

text 复制代码
workerId:UUID

例如:

text 复制代码
12345@host:document-index-1:550e8400-e29b-41d4-a716-446655440000

这样每次抢锁都有唯一 owner。

为什么要唯一?

因为锁可能过期后被另一个 worker 重新抢到。

如果旧 worker 执行很慢,后面释放锁时,不能误删新 worker 的锁。

所以释放锁时必须校验 value。

13.7.4 用 Lua 脚本释放锁

项目中释放锁使用 Lua 脚本:

lua 复制代码
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end

意思是:

text 复制代码
只有当前 Redis key 的 value 仍然等于我的 owner,
我才删除这个 key。
否则不删除。

这可以避免误删别人的锁。

如果只简单执行:

text 复制代码
DEL lockKey

就可能出现严重问题。

比如:

text 复制代码
worker A 抢锁成功
worker A 执行太久,锁过期
worker B 抢到同一个 key
worker A 结束后执行 DEL
worker B 的锁被误删

Lua 校验 owner 就是为了解决这个问题。


13.7.5 IndexTaskLockService 完整实现

下面给出 Redis NX 锁的完整 Service 代码:

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

import com.luo.ragknowledge.task.config.IndexTaskLockProperties;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.net.InetAddress;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * 索引任务 Redis NX 锁服务。
 */
@Service
public class IndexTaskLockService {
    private static final Logger log = LoggerFactory.getLogger(IndexTaskLockService.class);

    private static final String RELEASE_LOCK_LUA = """
            if redis.call('get', KEYS[1]) == ARGV[1] then
              return redis.call('del', KEYS[1])
            else
              return 0
            end
            """;

    private final StringRedisTemplate stringRedisTemplate;
    private final IndexTaskLockProperties lockProperties;

    /**
     * 当前 worker 实例标识。
     * 形如:hostname:document-index-随机后缀
     */
    private String workerId;

    public IndexTaskLockService(StringRedisTemplate stringRedisTemplate,
                                IndexTaskLockProperties lockProperties) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockProperties = lockProperties;
    }

    @PostConstruct
    public void initWorkerId() {
        try {
            String hostname = InetAddress.getLocalHost().getHostName();
            this.workerId = hostname + ":document-index-" + UUID.randomUUID().toString().substring(0, 8);
        } catch (Exception ex) {
            this.workerId = "unknown-host:document-index-" + UUID.randomUUID().toString().substring(0, 8);
        }
    }

    public LockToken tryLock(Long taskId) {
        if (!lockProperties.isEnabled()) {
            // 本地学习阶段没开 Redis 锁时,直接放行,不阻塞主链路。
            return LockToken.accepted();
        }

        String key = lockProperties.getKeyPrefix() + taskId;
        String owner = workerId + ":" + UUID.randomUUID();

        try {
            Boolean locked = stringRedisTemplate.opsForValue().setIfAbsent(
                    key,
                    owner,
                    lockProperties.getTtl().toSeconds(),
                    TimeUnit.SECONDS
            );

            if (Boolean.TRUE.equals(locked)) {
                return LockToken.accepted(owner, key);
            }
            log.warn("索引任务 Redis 锁被拒绝:taskId={}, key={}", taskId, key);
            return LockToken.rejected();
        } catch (Exception ex) {
            // Redis 故障时直接降级放行,避免整个索引任务因为锁组件故障而完全不可用。
            log.warn("尝试获取索引任务 Redis 锁失败,已降级放行:taskId={}, error={}", taskId, ex.getMessage());
            return LockToken.accepted();
        }
    }

    public void releaseLock(LockToken token) {
        if (token == null || !lockProperties.isEnabled() || token.isRejected()) {
            return;
        }
        if (token.getKey() == null || token.getOwner() == null) {
            return;
        }

        try {
            DefaultRedisScript<Long> script = new DefaultRedisScript<>();
            script.setScriptText(RELEASE_LOCK_LUA);
            script.setResultType(Long.class);

            Long released = stringRedisTemplate.execute(
                    script,
                    Collections.singletonList(token.getKey()),
                    token.getOwner()
            );
            log.info("索引任务 Redis 锁释放完成:key={}, owner={}, released={}",
                    token.getKey(), token.getOwner(), released);
        } catch (Exception ex) {
            log.warn("释放索引任务 Redis 锁失败:key={}, owner={}, error={}",
                    token.getKey(), token.getOwner(), ex.getMessage());
        }
    }

    public static class LockToken {
        private final boolean locked;
        private final String key;
        private final String owner;

        private LockToken(boolean locked, String key, String owner) {
            this.locked = locked;
            this.key = key;
            this.owner = owner;
        }

        public static LockToken accepted() {
            return new LockToken(true, null, null);
        }

        public static LockToken accepted(String owner, String key) {
            return new LockToken(true, key, owner);
        }

        public static LockToken rejected() {
            return new LockToken(false, null, null);
        }

        public boolean isRejected() {
            return !locked;
        }

        public boolean isLocked() {
            return locked;
        }

        public String getKey() {
            return key;
        }

        public String getOwner() {
            return owner;
        }
    }
}

注意这里的降级策略:

text 复制代码
抢锁失败(被别人持有) -> 拒绝执行
Redis 异常                 -> 降级放行
释放锁时 owner 不匹配      -> 不删除,避免误删别人的锁

13.8 Redis 锁和数据库状态机的关系

Redis 锁不是任务状态机。

它只解决一个问题:

text 复制代码
同一个 taskId 不要被多个 worker 同时执行。

任务是否合法流转,仍然由 task-service 的状态机判断。

代码里也有注释:

text 复制代码
Redis 锁只防止同一 taskId 并发执行,合法状态流转仍由 task-service 的状态机判断。

这句话非常重要。

为什么不能只靠 Redis 锁?

因为锁是临时的。

它会过期。

Redis 也可能重启。

而任务状态是业务事实。

任务是不是 WAITING、RUNNING、SUCCESS、FAILED,必须由 task-service 数据库保存。

所以正确关系是:

text 复制代码
Redis 锁:防并发。
task-service 状态机:判定任务能不能运行、成功、失败、重试。

两者互补,不互相替代。


13.9 Redis key 设计原则

Redis key 不是随便起名。

好的 key 应该满足:

text 复制代码
可读
不冲突
能表达业务含义
包含必要隔离维度
方便排查

KnowHub 中有三类 key。

13.9.1 知识库 owner key

text 复制代码
rag:kb:owner:{kbId}

示例:

text 复制代码
rag:kb:owner:2

它表达的是:

text 复制代码
知识库 2 的 owner userId 是谁。

13.9.2 文档索引任务状态 key

text 复制代码
rag:document:index-task:{userId}:{kbId}:{documentId}

示例:

text 复制代码
rag:document:index-task:10001:2:88

它表达的是:

text 复制代码
用户 10001 在知识库 2 中,文档 88 的最近一次索引任务状态。

这里包含 userId 和 kbId,是为了隔离和可读性。

13.9.3 索引任务锁 key

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

示例:

text 复制代码
rag:index-task:lock:10086

它表达的是:

text 复制代码
索引任务 10086 当前是否被某个 worker 执行。

13.9.4 为什么要使用冒号分隔

Redis key 常用冒号分隔层级。

例如:

text 复制代码
rag:document:index-task:10001:2:88

比下面这种更容易读:

text 复制代码
rag_document_index_task_10001_2_88

在 Redis CLI 中查看 key 时,也更容易按前缀过滤。

例如:

text 复制代码
keys rag:document:index-task:*

生产环境不建议在大库中随便使用 keys,但本地排查时它很直观。


13.10 TTL 设计原则

TTL 是 key 的过期时间。

Redis 缓存如果没有 TTL,可能一直留在内存中。

这会带来两个问题。

第一,占用内存。

第二,数据可能长期变旧。

KnowHub 中三个 TTL 分别是:

text 复制代码
owner 缓存:30 分钟
任务状态缓存:1 分钟
任务锁:30 分钟

13.10.1 owner 缓存 TTL

owner 变化频率低。

所以可以缓存 30 分钟。

同时,知识库删除时会主动删除缓存。

这属于:

text 复制代码
较长 TTL + 主动删除

13.10.2 任务状态缓存 TTL

任务状态变化快。

所以只缓存 1 分钟。

这属于:

text 复制代码
短 TTL + 状态变化时主动更新

13.10.3 任务锁 TTL

任务锁 TTL 必须比正常索引耗时更长。

如果 TTL 太短,任务还没执行完锁就过期,另一个 worker 可能再次执行同一个任务。

如果 TTL 太长,worker 异常退出后,锁会占用太久。

当前项目默认 30 分钟,是为了覆盖较大的文档解析和向量化耗时。

真实生产中需要根据文档大小、模型耗时和任务超时策略调整。


13.11 Redis 不可用时怎么办

Redis 不可用时,不同功能的处理方式不同。

13.11.1 owner 缓存不可用

owner 缓存失败时,代码会回退 MySQL。

这意味着:

text 复制代码
功能可用,性能下降。

这是比较稳妥的设计。

13.11.2 任务状态缓存不可用

任务状态缓存失败时,代码会回退 task-service。

这意味着:

text 复制代码
功能可用,远程调用增加。

只要 task-service 正常,前端仍然能查到任务状态。

13.11.3 任务锁不可用

任务锁和缓存不同。

如果开启了 Redis 锁,而 Redis 不可用,抢锁逻辑可能失败。

所以当前项目默认:

text 复制代码
rag.task.lock.enabled=false

本地学习时,可以先不依赖 Redis 锁。

当你要验证 RabbitMQ 重复消费、多 worker 并发和真实幂等时,再开启它,并确保 Redis 可用。

这体现了一个工程原则:

text 复制代码
缓存可以降级,分布式锁要谨慎启用。

13.12 缓存常见问题

13.12.1 缓存穿透

缓存穿透指的是:

text 复制代码
请求的数据缓存中没有,数据库中也没有。

例如有人不断请求不存在的 kbId。

当前 owner 缓存没有专门缓存"不存在"的结果。

所以恶意请求不存在 ID 时,仍然可能频繁查 MySQL。

后续可以考虑增加短 TTL 的空值缓存。

例如:

text 复制代码
rag:kb:owner:999999 = NONE,TTL 30 秒

但空值缓存要谨慎。

如果后续这个 ID 又变成有效数据,空值缓存可能造成短时间误判。

13.12.2 缓存击穿

缓存击穿指的是:

text 复制代码
某个热点 key 过期瞬间,大量请求同时打到数据库。

例如某个热门知识库 owner 缓存刚过期,大量问答请求同时进来。

解决思路包括:

text 复制代码
延长 TTL
加随机过期时间
加互斥重建锁
提前刷新缓存

当前项目还没有做复杂的缓存击穿防护,因为学习项目规模较小。

但读者要知道这个风险。

13.12.3 缓存脏数据

缓存脏数据指的是:

text 复制代码
Redis 中的数据和数据库真实数据不一致。

例如知识库已经删除,但 owner 缓存还存在。

当前项目在删除知识库时会调用:

java 复制代码
evictOwnerCache(knowledgeBaseId);

这就是为了减少脏数据。

任务状态缓存也通过短 TTL 和状态变化时更新来降低脏数据影响。

13.12.4 序列化失败

任务状态缓存使用 JSON 保存 IndexTaskResponse

如果对象字段不兼容,可能出现序列化或反序列化失败。

项目中捕获异常后会:

text 复制代码
记录日志
回退 task-service
不中断主链路

13.13 与前面章节的关系

第 13 章不是孤立章节。

它和前面多章都有关系。

13.13.1 与第 5 章用户隔离的关系

第 5 章讲过资源隔离。

owner 缓存就是在加速资源隔离中的高频 owner 校验。

但它不能替代 MySQL 校验。

13.13.2 与第 9 章索引任务的关系

第 9 章讲过 RabbitMQ 和索引任务。

Redis NX 锁用于降低重复消费导致的并发执行风险。

任务状态缓存用于减少前端轮询对 task-service 的压力。

13.13.3 与第 12 章问答闭环的关系

第 12 章问答接口中,每次问答都需要校验知识库归属。

owner 缓存能减少问答高频场景下的数据库访问。

如果后续问答量变大,Redis 的价值会更明显。


13.14 常见问题排查

13.14.1 Redis 连接失败

现象可能是:

text 复制代码
RedisConnectionFailureException
Connection refused

排查顺序:

  1. Redis 是否启动。
  2. host 是否正确。
  3. port 是否正确。
  4. password 是否正确。
  5. database 编号是否正确。
  6. Docker 或虚拟机端口是否映射。

13.14.2 owner 缓存一直不命中

排查顺序:

  1. rag.kb.owner-cache.enabled 是否为 true。
  2. Redis 是否可用。
  3. key 是否是 rag:kb:owner:{kbId}
  4. 写缓存时是否报错。
  5. TTL 是否过短。
  6. 是否每次都在删除缓存。

13.14.3 删除知识库后仍然能访问

优先检查 MySQL 状态。

排查顺序:

  1. knowledge_base.status 是否已经变为 DELETED。
  2. getActiveKnowledgeBase 是否过滤 ACTIVE。
  3. evictOwnerCache 是否执行。
  4. Redis 中是否还存在旧 owner key。

注意,权限判断最终应该以 MySQL 为准。

13.14.4 前端看到的任务状态不刷新

排查顺序:

  1. Redis 中任务状态 key 是否存在。
  2. TTL 是否为 1 分钟。
  3. 状态变化后是否调用 cacheLatest
  4. 重试前是否调用 evictLatest
  5. task-service 返回的状态是否已经变化。

13.14.5 Redis 锁一直释放不了

排查顺序:

  1. 锁 TTL 是否过长。
  2. worker 是否异常退出。
  3. Lua 脚本释放时 owner 是否匹配。
  4. 是否手动改过 Redis value。
  5. 是否同一个 taskId 被多个 worker 竞争。

如果确认是测试环境残留锁,可以手动删除对应 key。

生产环境手动删锁要非常谨慎。

13.14.6 同一个任务仍然重复执行

排查顺序:

  1. rag.task.lock.enabled 是否为 true。
  2. 两个 worker 是否连接同一个 Redis。
  3. 锁 TTL 是否短于任务执行时间。
  4. taskId 是否一致。
  5. task-service 状态机是否阻止重复 RUNNING。
  6. RabbitMQ 是否重复投递。

记住:Redis 锁只是防并发的一层保障,不是唯一保障。

13.14.7 Redis 内存占用越来越高

排查顺序:

  1. key 是否都有 TTL。
  2. 是否有大量任务状态 key 没过期。
  3. 是否存入了过大的 JSON。
  4. 是否错误缓存了完整文档内容。
  5. Redis maxmemory 策略是否合理。

KnowHub 当前 key 都应该带 TTL。

如果内存不断上涨,需要检查是否有新增代码写入了无过期 key。


13.14.8 Docker 中 Redis 容器重启后所有 key 丢失

现象:

text 复制代码
刚写入的 owner 缓存、任务状态缓存或锁 key,
在 Redis 容器重启或重建后全部消失。

常见原因是:

text 复制代码
Docker 启动 Redis 时没有挂载 /data 目录,
RDB/AOF 持久化文件没有保存到宿主机。

排查顺序:

第一,检查 docker rundocker-compose.yml 中是否配置了:

yaml 复制代码
volumes:
  - ./redis-data:/data

第二,进入 Redis 执行:

bash 复制代码
redis-cli CONFIG GET dir

确认当前持久化目录是否指向容器内 /data

第三,再执行:

bash 复制代码
redis-cli CONFIG GET save

确认 RDB 保存策略是否正常。

解决方式就是把 /data 映射到宿主机目录,例如:

yaml 复制代码
- ./redis-data:/data

13.14.9 反序列化 IndexTaskResponse 时报错导致缓存永久失效

现象:

text 复制代码
Redis 中明明已经有任务状态 JSON,
但每次读取都返回 null,
系统每次都降级去调用 task-service。

这通常说明:

text 复制代码
缓存不是没写进去,
而是反序列化一直失败。

排查顺序:

第一,检查 IndexTaskResponse 是否有无参构造方法。Jackson 反序列化通常需要它。

第二,检查 Java 字段类型是否和 Redis 中 JSON 的值兼容。例如时间字段、枚举字段、数字字段变化后,旧缓存可能无法反序列化。

第三,检查最近是否改过 IndexTaskResponse 结构,例如新增字段、删除字段、修改字段名。

解决方式有两个:

第一,变更 DTO 结构后,主动清理旧缓存 key。

第二,在序列化数据中引入版本号,或者在 key 前缀里带版本,例如:

text 复制代码
rag:document:index-task:v2:

这样新旧缓存可以自然隔离。


本章小结

这一章我们讲了 Redis 缓存、幂等锁与任务状态。

Redis 在 KnowHub 中不是主数据库,而是加速层和协调层。知识库 owner 缓存用于降低高频归属校验的 MySQL 压力;文档索引任务状态缓存用于减少前端轮询对 task-service 的重复调用;Redis NX 锁用于防止同一个索引任务被多个 worker 同时执行。

owner 缓存 key 是 rag:kb:owner:{kbId},默认 TTL 30 分钟。任务状态缓存 key 是 rag:document:index-task:{userId}:{kbId}:{documentId},默认 TTL 1 分钟。任务锁 key 是 rag:index-task:lock:{taskId},默认 TTL 30 分钟。

项目中特别强调:缓存不能替代数据库事实来源。知识库归属最终以 MySQL 为准,任务状态最终以 task-service 数据库为准,Redis 锁只负责降低并发重复执行风险,合法状态流转仍由 task-service 状态机判断。

下一章,我们会进入 Sentinel 限流与 AI 降级,继续讲 KnowHub 如何在高并发请求和外部 AI 服务不稳定时保护系统。

本章涉及的关键类与文件

text 复制代码
knowledge-service/src/main/java/.../
  config/
    cache/
      KnowledgeBaseOwnerCacheProperties.java    (owner 缓存配置)
      DocumentIndexTaskCacheProperties.java     (任务状态缓存配置)
    lock/
      IndexTaskLockProperties.java              (任务锁配置)
  service/
    cache/
      KnowledgeBaseOwnerCacheService.java       (owner 缓存:读、写、删)
      DocumentIndexTaskCacheService.java        (任务状态缓存:读、写、删)
    lock/
      IndexTaskLockService.java                 (Redis NX 锁:抢锁、释放)
      LockToken.java                            (锁结果封装)
    impl/
      KnowledgeBaseServiceImpl.java             (集成 owner 缓存的 getActiveKnowledgeBase)
      DocumentIndexTaskExecutionService.java    (集成任务状态缓存的 latest 查询)

resources/
  application.yml
  (spring.data.redis、rag.kb.owner-cache、rag.task.status-cache、rag.task.lock 配置)

动手验证:观察 Redis 缓存与锁的行为

步骤一,对应 13.3 节:确认 Redis 容器已启动。执行:

bash 复制代码
docker ps
redis-cli -h 127.0.0.1 -p 6379 ping

预期 ping 返回:

text 复制代码
PONG

步骤二,对应 13.3 节:启动 knowledge-service,确认启动日志中没有 Redis 连接失败异常,StringRedisTemplate 自动配置生效。

步骤三,对应 13.4 节:通过 Gateway 登录一个用户,访问一次知识库详情 GET /kb/{kbId}。然后执行:

bash 复制代码
redis-cli KEYS rag:kb:owner:*
redis-cli GET rag:kb:owner:{kbId}
redis-cli TTL rag:kb:owner:{kbId}

预期可以看到 owner 缓存 key、对应的 owner userId,以及小于 30 分钟的剩余 TTL。

步骤四,对应 13.5 节:上传一个文档后,执行:

bash 复制代码
redis-cli KEYS rag:document:index-task:*
redis-cli GET rag:document:index-task:{userId}:{kbId}:{documentId}
redis-cli TTL rag:document:index-task:{userId}:{kbId}:{documentId}

预期 Redis 中出现任务状态缓存 JSON,包含 taskIdstatus 等字段,并且 TTL 会持续减少,1 分钟后自动过期。

步骤五,对应 13.4.4 节:删除一个知识库后,再次执行:

bash 复制代码
redis-cli KEYS rag:kb:owner:{kbId}

预期对应 key 已经消失,说明 evictOwnerCache(...) 生效。

步骤六,对应 13.7 节:开启:

yaml 复制代码
rag:
  task:
    lock:
      enabled: true

触发一次索引任务消费后,执行:

bash 复制代码
redis-cli KEYS rag:index-task:lock:*
redis-cli GET rag:index-task:lock:{taskId}

预期可以看到锁 key,value 形如:

text 复制代码
workerId:UUID

任务完成后再查看,key 应该已经释放。

步骤七,对应 13.7.2 节:模拟锁竞争。先手动写一个锁:

bash 复制代码
redis-cli SET rag:index-task:lock:{taskId} mock-worker:manual-lock NX EX 1800

再触发同一 taskId 的消费,观察日志中是否出现"锁被拒绝"或"任务被跳过"的日志,确认锁逻辑生效。

如果 owner 缓存一直没有生成,优先检查三点:

text 复制代码
1. knowledge-service 日志里是否有 Redis 连接错误
2. owner 缓存配置是否 enabled=true
3. getActiveKnowledgeBase(...) 是否真的走到了写缓存分支

思考题

  1. Redis 在 KnowHub 中承担了哪三类职责?
  2. 为什么说 Redis 不是主数据库?
  3. owner 缓存 key 为什么只需要 kbId,而任务状态缓存 key 要包含 userIdkbIddocumentId
  4. owner 缓存读取失败时,为什么可以回退 MySQL?
  5. 任务状态缓存 TTL 为什么比 owner 缓存短?
  6. Redis SET NX 适合解决什么问题?
  7. 释放 Redis 锁时为什么要校验 owner?
  8. Redis 锁为什么不能替代 task-service 状态机?
  9. 缓存穿透和缓存击穿有什么区别?
  10. Redis 不可用时,缓存和分布式锁的处理策略有什么不同?
  11. 如果前端看到任务状态不刷新,你会按什么顺序排查?
  12. 为什么生产环境不应该随意手动删除 Redis 锁?

思考题参考答案

1. Redis 在 KnowHub 中承担了哪三类职责?

  • 知识库 owner 缓存------加速高频权限校验。
  • 文档索引任务状态缓存------减少前端轮询对 task-service 的重复调用。
  • 索引任务 Redis NX 幂等锁------防止同一个 taskId 被多个 worker 同时执行。

2. 为什么说 Redis 不是主数据库?

Redis 只做加速和协调,不保存业务最终真相。知识库归属最终以 MySQL 中 knowledge_base 表为准,任务状态最终以 task-service 数据库为准,合法状态流转由 task-service 状态机判定。Redis 挂了,系统仍可降级运行。

3. owner 缓存 key 为什么只需要 kbId,而任务状态缓存 key 要包含 userIdkbIddocumentId

owner 缓存是「知识库 → owner」的映射,一个 kbId 只属于一个 owner,与具体用户无关。任务状态缓存则需要同时区分用户、知识库和文档三个维度,单用 documentId 会破坏用户隔离和知识库隔离。

4. owner 缓存读取失败时,为什么可以回退 MySQL?

因为 MySQL 才是 owner 关系的主数据来源,Redis 只是加速层。缓存读取失败时降级查 MySQL,只是性能下降,不影响功能正确性。

5. 任务状态缓存 TTL 为什么比 owner 缓存短?

任务状态变化快(几秒内可能从 WAITING 变成 RUNNING 再变成 SUCCESS),TTL 太长会让前端持续看到过期状态。owner 变化频率很低,可以缓存更久(30 分钟)。TTL 长短要匹配数据变化频率。

6. Redis SET NX 适合解决什么问题?

适合解决分布式环境下的互斥问题------多个 worker 抢夺同一个资源的执行权时,保证只有一个能抢到锁并执行,防止同一 taskId 被并发处理。

7. 释放 Redis 锁时为什么要校验 owner?

防止误删别人的锁。经典场景:worker A 持有锁但执行超时导致锁过期,worker B 重新抢到同一 key,worker A 完成后直接 DEL 会把 worker B 的锁误删。通过 Lua 脚本校验 value 是否匹配当前 owner,只有匹配时才删除。

8. Redis 锁为什么不能替代 task-service 状态机?

Redis 锁是临时的,会过期,Redis 也可能重启。而任务状态(WAITING / RUNNING / SUCCESS / FAILED)是业务事实,必须持久化保存。锁只防并发,状态机判定任务能否合法流转,两者互补。

9. 缓存穿透和缓存击穿有什么区别?

  • 缓存穿透:请求的数据缓存和数据库中都没有(如恶意请求不存在的 ID),每次都穿透到数据库。
  • 缓存击穿:某个热点 key 刚好过期,大量并发请求同时打到数据库。

穿透是「一直没有」,击穿是「刚好过期瞬间涌入」。

10. Redis 不可用时,缓存和分布式锁的处理策略有什么不同?

  • 缓存:降级回源 MySQL / task-service,功能可用但性能下降。
  • 分布式锁:如果开启了 enabled=true 而 Redis 不可用,抢锁逻辑会失败,可能影响任务正确性。所以 KnowHub 默认 enabled=false,缓存可以随时降级,分布式锁要谨慎启用。

11. 如果前端看到任务状态不刷新,你会按什么顺序排查?

  1. Redis 中任务状态 key 是否存在。
  2. TTL 是否为 1 分钟(是否过期太快)。
  3. 状态变化后是否调用了 cacheLatest
  4. 重试前是否调用了 evictLatest 清理旧缓存。
  5. task-service 返回的状态是否真的变了。
  6. 是否有反序列化错误导致缓存读出来是 null。

12. 为什么生产环境不应该随意手动删除 Redis 锁?

手动删锁等于绕过 owner 校验,可能把其他 worker 正在持有的有效锁误删,导致同一个 taskId 被两个 worker 同时执行,造成重复解析、重复切片、重复向量化等问题。生产环境删锁前必须确认该锁确实是残留锁且没有 worker 在执行对应任务。

相关推荐
SelectDB技术团队1 小时前
当 PostgreSQL 面临性能瓶颈:80TB 电商业务迁移至 Apache Doris 的实践思考
数据库·postgresql·apache
热心市民lcj1 小时前
Spring Boot 整合 Caffeine 本地缓存实战
spring boot·后端·缓存
心念枕惊1 小时前
新写了个直播录制工具,可录制抖音快手斗鱼直播
运维·服务器·数据库
逐米时代2 小时前
向量数据库选型——Chroma、Qdrant、Milvus到底怎么选
数据库·milvus
段一凡-华北理工大学2 小时前
AI推动工业智能化转型~系列文章05:特征工程:工业 AI 的第一生产力
数据库·人工智能·分布式·搜索引擎·特征工程·高炉智能化
半桶水专家2 小时前
SQL Server DML 操作语句完全指南
数据库·sqlserver
Irene19912 小时前
Oracle 连接避坑指南:环境+配置+账号
数据库·oracle
不在逃避q2 小时前
一步一步学习使用LiveBindings()TListView进阶使用(),打造天气预报程序
服务器·数据库·学习
2401_873479402 小时前
IPv6支持不足怎么办?用双栈兼容IP离线库实现平滑过渡
数据库·网络协议·tcp/ip·ip