Redis 与 Single-flight 的分工与协作

Redis 与 Single-flight 的分工与协作

在 AI 问答、报告生成和复杂查询中,一次请求往往要等待几秒甚至几十秒。请求执行期间,用户可能连续点击提交,前端可能因为超时发起重试,网关也可能重放请求。

如果这些请求表达的是同一件事,后端却把每一次请求都重新执行,系统就会重复调用模型、重复消耗 Token,还可能让同一份业务状态被多次修改。

Single-flight 用来合并这批同时到达的相同请求。Redis 则让这套合并机制可以跨越多个服务节点。两者的关系可以概括为一句话。

text 复制代码
Single-flight 规定谁执行、谁等待、结果怎样共享。
Redis 保存所有节点共同认可的执行状态和临时结果。

要理解两者怎样合作,需要先知道它们分别解决什么问题。

背景:一次请求为什么会被重复执行

以 AI 面试评分为例,用户提交一段回答后,后端要把题目、回答和评分规则交给模型。假设模型需要十秒才能返回结果,这十秒里可能发生下面的情况。

  • 用户没有看到响应,又点了一次提交。
  • 前端等待超时,自动重试了一次。
  • 第一次调用已经成功,响应却在网络中丢失。
  • 两个相同请求被负载均衡分发到不同的后端实例。

四个请求的业务含义相同。如果后端分别调用四次模型,就会得到四份可能略有差异的评分,同时付出四次调用成本。

缓存只能解决其中一部分问题。请求完成后,缓存可以保存结果;请求仍在执行时,缓存里还没有结果,后续请求仍然可能再次调用模型。Single-flight 关注的正是这段"第一个请求还没有完成"的时间窗口。

Single-flight 是什么

Single-flight 是一种并发请求合并机制。系统先为请求计算一个稳定的 key,例如由业务阶段、会话 ID 和输入摘要组成 requestKey。同一个 key 在同一时间只保留一个执行者,其余请求等待这次执行并共享结果。

一次 flight 中有两种角色。

角色 本地实现中的名称 分布式实现中的名称 职责
执行者 leader owner 调用真正的业务逻辑并产生结果
等待者 waiter follower 不再重复执行,只等待并复用结果

单机版本通常使用 ConcurrentHashMap 保存正在执行的任务,再用 CompletableFuture 向等待者传递结果。项目中的本地实现保留了下面这段核心逻辑。

java 复制代码
FlightEntry entry = flights.compute(key, (ignored, existing) -> {
    if (existing == null || existing.expireAtMillis <= now) {
        newFlight.set(true);
        return new FlightEntry(new CompletableFuture<>(), now + ttlMillis);
    }
    return existing;
});

第一个线程创建 FlightEntry,随后执行真实调用。

java 复制代码
T value = supplier.get();
entry.resultFuture.complete(value);
return value;

后续相同 key 的线程拿到同一个 FlightEntry,等待同一个 future。

java 复制代码
T reused = (T) entry.resultFuture.get(
    resolveWaitTimeoutMillis(), TimeUnit.MILLISECONDS
);
return reused;

这段代码完成了 Single-flight 的基本规则。它能合并一个 JVM 内的请求,但每个 JVM 都有自己的 Map。服务部署三个实例后,三个实例仍可能各自选出一个 leader。

Redis 为什么会进入 Single-flight

多节点环境需要一个所有实例都能访问的位置。节点 A 要知道节点 B 是否已经在执行,节点 B 也要知道结果是否已经由节点 C 产生。Redis 适合承载这些共享信息,因为它能保存带 TTL 的状态,支持原子命令,还可以用 Lua 脚本一次完成多项判断与修改。

Redis 在这里承担四项职责。

Redis 的职责 保存或执行的内容 解决的问题
所有权仲裁 状态、ownerId、ownerToken 集群中由谁执行
运行状态共享 PENDING、RUNNING、SUCCEEDED、FAILED 其他节点应该等待、复用还是接管
临时结果存储 模型结果、压缩信息、校验和、TTL follower 从哪里取得同一份结果
完成通知 Redis Stream 终态事件 owner 完成后怎样及时唤醒 follower

Single-flight 仍然负责合并请求的规则。Redis 负责让这些规则依赖的事实在多个 JVM 之间保持可见。

两者的职责边界

把两者放在一起看,分工会更清楚。

能力 Single-flight Redis
判断请求是否相同 规定使用同一个 requestKey 按 key 保存对应数据
决定执行者和等待者 定义 owner 与 follower 的行为 通过原子状态变更记录仲裁结果
执行业务逻辑 owner 调用模型或其他耗时服务 不执行模型调用
共享执行结果 规定 follower 复用 owner 的结果 临时保存可回放结果
处理 owner 失联 定义超时接管规则 保存心跳并提供原子接管条件
通知等待者 规定 follower 被唤醒后重新读取结果 用 Stream 发送终态通知

这个边界也解释了为什么只加 Redis 锁还不够。一把锁最多告诉其他节点"当前有人执行",却没有自然提供结果回放、失败分类、心跳接管和等待者唤醒。完整的分布式 Single-flight 需要一套小型状态机。

Redis 中保存了什么

项目把不同职责拆成四类 key。

Redis key 类型 主要内容
ai:flight:meta:{requestKey} Hash 状态、owner、token、心跳时间和失败信息
ai:flight:result:{requestKey} Hash 编码后的执行结果、压缩标记和校验和
ai:flight:stream:{requestKey} Stream owner 成功或失败的终态事件
ai:flight:owner-seq String 计数器 单调递增的 owner token

meta 是协调中心。新请求到达时,服务通过 Lua 脚本读取它的状态,并在同一次 Redis 执行中决定当前节点的角色。

项目中的判断逻辑展开后大致如下。

lua 复制代码
local status = redis.call('HGET', KEYS[1], 'status')

if not status then
  local token = redis.call('INCR', KEYS[2])
  redis.call(
    'HSET', KEYS[1],
    'status', 'PENDING',
    'ownerId', ARGV[2],
    'ownerToken', token,
    'heartbeatAt', ARGV[5]
  )
  redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
  return 'OWNER_NEW|' .. token
end

if status == 'SUCCEEDED' then
  return 'REPLAY_SUCCESS|' .. status
end

状态不存在时,当前节点成为新 owner。状态已经成功时,请求直接进入结果回放。任务仍在运行且心跳正常时,当前节点成为 follower。任务允许重试或者旧 owner 的心跳已经过期时,新节点可以接管。

一次完整请求中,两者怎样合作

现在把背景、分工和 Redis 数据放回一条完整链路。

text 复制代码
相同请求到达多个节点
  -> Single-flight 用 requestKey 识别同一批请求
  -> Redis 原子选出一个 owner
  -> owner 调用模型,follower 停止重复调用
  -> owner 用心跳刷新运行状态
  -> owner 把结果写入 Redis,并提交 SUCCEEDED
  -> Redis Stream 通知等待中的 follower
  -> follower 读取同一份结果并返回

第一阶段:识别同一批请求

Single-flight 首先依赖稳定的 requestKey。同一个业务请求必须生成相同 key,才能进入同一次 flight。两个请求即使内容相同,只要 key 不同,系统也会把它们视为两次独立执行。

这一步属于业务侧的责任。Redis 只能按收到的 key 保存数据,无法判断两段业务输入是否表达同一个意图。

第二阶段:Redis 选出 owner

第一个请求发现 meta 不存在,Lua 脚本创建 PENDING 状态,同时从 owner-seq 获取新的 token。该节点成为 owner。

随后到达的相同请求会看到已有状态。心跳仍然有效时,它们得到 FOLLOWER_WAIT,不再调用模型。

Lua 原子执行很关键。如果节点先读取 Redis,再回到 Java 中判断并写入,两个节点可能同时读到空状态。Lua 把读取、判断、生成 token 和写入状态放在一次执行中,Redis 执行脚本期间不会让另一段脚本插入这些步骤。

第三阶段:owner 执行,Redis 记录运行状态

owner 把状态改成 RUNNING,启动定时心跳,然后调用真正的 AI 服务。心跳会更新 heartbeatAt,让 follower 知道执行者仍然存活。

如果心跳长期没有更新,新的请求可以获得更大的 owner token 并接管。所有关键写入都会检查 ownerIdownerToken

lua 复制代码
if redis.call('HGET', KEYS[1], 'ownerId') ~= ARGV[1] then
  return 0
end

if redis.call('HGET', KEYS[1], 'ownerToken') ~= ARGV[2] then
  return 0
end

token 用来区分两次所有权。旧 owner 即使恢复,也不能用旧 token 覆盖新 owner 提交的状态。

第四阶段:owner 保存结果并提交成功

模型返回后,owner 先序列化结果,再写入 result。项目会根据配置选择 gzip 压缩,并保存 Base64 内容和 SHA-256 校验和。

结果写入成功后,owner 才把 meta 改成 SUCCEEDED。这个顺序可以避免 follower 看见成功状态时,结果还没有准备好。

java 复制代码
String result = supplier.get();
FlightStoredResult storedResult =
    flightResultSerializer.serialize(result, ownerToken, policy);

flightCoordinatorRepository.storeResult(
    requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis
);
flightCoordinatorRepository.finishSuccess(
    requestKey, nodeId(), ownerToken, resultTtlMillis
);

结果只在 Redis 中保留一段时间。默认策略的成功结果 TTL 为 600 秒,失败结果 TTL 为 60 秒,具体业务阶段还能覆盖这两个时间。这个设计提供短时结果复用,不承担永久业务存储。

第五阶段:follower 被唤醒并复用结果

owner 提交终态后,向 ai:flight:stream:{requestKey} 写入一条成功或失败事件。等待中的 follower 使用阻塞读取等待这条消息。

java 复制代码
stringRedisTemplate.opsForStream().read(
    StreamReadOptions.empty().count(1).block(Duration.ofMillis(timeout)),
    StreamOffset.create(streamKey(requestKey), ReadOffset.latest())
);

Stream 在这里承担通知职责。它告诉 follower "状态可能已经变化",follower 随后重新读取 metaresult。最终结果仍以这两个 Hash 为准。

因此,这里的 Redis Stream 用到了消息队列能力,使用范围限于一次 flight 的完成通知。它没有承担通用业务事件总线的角色,也没有消费者组、ACK、死信队列等完整消息消费设计。

本地缓存处在什么位置

分布式结果回放仍然要访问 Redis。相同结果在一个节点内被频繁读取时,项目还可以启用本地 L1 缓存。

text 复制代码
L1 本地缓存命中
  -> 直接返回

L1 未命中
  -> 查询 Redis meta 和 result
  -> 反序列化结果
  -> 按配置写回 L1

L1 只负责减少重复访问 Redis。它没有集群视角,也不参与 owner 仲裁。关闭 L1 后,分布式 Single-flight 仍然成立;失去 Redis 后,L1 无法让不同节点共享状态。

两者合作时仍有边界

第一条边界来自外部调用。owner 已经把请求发送给模型后,如果节点突然失联,新 owner 接管时可能再次调用模型。owner token 能阻止旧节点覆盖 Redis 状态,却不能撤回已经发给外部服务的请求。不可重复的外部操作仍然需要自己的幂等机制。

第二条边界来自降级策略。当前项目使用 hybrid 模式,Redis 异常时会退回本地 Single-flight。服务可以继续响应,但不同 JVM 之间不再共享状态,跨节点去重会降级为单节点去重。

第三条边界是 Stream 生命周期。metaresult 都设置了 TTL,当前 Stream 发布代码只执行 add(),没有设置过期时间,也没有按 MAXLEN 裁剪。长期运行时,需要给通知 Stream 增加清理策略。

理解这些边界后,Redis 与 Single-flight 的关系就不会被简化成"给请求加一把分布式锁"。Single-flight 管理一次相同计算的角色与流程,Redis 为这套流程提供跨节点可见的状态、临时结果和通知。只要 requestKey 稳定、owner 写入受 token 约束、follower 始终从共享结果回放,多台服务实例就能围绕同一次 flight 协作。

相关推荐
byxdaz3 小时前
jeston平台交叉编译Poppler库与预览pdf文件
数据库
SilicoCode3 小时前
江科大STM32入门:FLASH闪存详解——从结构原理到读写保护
数据库·mongodb
谷哥的小弟4 小时前
腾讯云服务器安装Redis Stack图文教程
服务器·redis·腾讯云·redis stack
一个有温度的技术博主4 小时前
DeepSeek Harness 深度解析:与 LangChain / LangGraph 的本质区别
数据库·oracle·langchain·harness
Wang's Blog4 小时前
PostgreSQL笔记4: 市场地位、生态全景与行业应用实践
数据库·笔记·postgresql
NeilYuen4 小时前
【vemory】高性能KV存储AOF方案设计
linux·c++·redis·缓存
隔窗听雨眠5 小时前
Oracle误Truncate操作恢复:从原理到实战的完整指南
数据库·oracle
Lethehong6 小时前
MySQL迁移如何做到零改造?四层兼容方案详解
数据库·mysql·adb
LuminousCPP6 小时前
数据结构基础篇(二):顺序表与链表全方位对比|从内存布局到 CPU 缓存理解底层差异
c语言·数据结构·经验分享·链表·缓存