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 并接管。所有关键写入都会检查 ownerId 与 ownerToken。
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 随后重新读取 meta 和 result。最终结果仍以这两个 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 生命周期。meta 和 result 都设置了 TTL,当前 Stream 发布代码只执行 add(),没有设置过期时间,也没有按 MAXLEN 裁剪。长期运行时,需要给通知 Stream 增加清理策略。
理解这些边界后,Redis 与 Single-flight 的关系就不会被简化成"给请求加一把分布式锁"。Single-flight 管理一次相同计算的角色与流程,Redis 为这套流程提供跨节点可见的状态、临时结果和通知。只要 requestKey 稳定、owner 写入受 token 约束、follower 始终从共享结果回放,多台服务实例就能围绕同一次 flight 协作。