分布式 SingleFlight:从单机请求合并到集群级远程调用去重
一、为什么需要 SingleFlight?
在多 Agent 协作的分布式系统中,不同的业务模块经常需要调用外部大语言模型(LLM)或类似的远程服务。这类调用有两个显著特点:
- 贵:每次调用按 Token 计费,成本高昂。
- 慢:一次完整调用可能耗时数秒到数十秒。
在实际运行中,由于网络重试、流程编排中的并发分支、或用户短时间内的重复操作,经常会出现相同参数的远程调用在极短时间窗口内被触发多次的情况。如果每次都真实发起请求,不仅浪费资源,还可能导致限流甚至雪崩。
SingleFlight 的核心思想很简单:对于相同 key 的并发请求,只让一个请求真正执行(leader),其余请求(follower)等待并复用 leader 的结果。
这本质上是一种请求合并(Request Coalescing) 模式。Go 语言有著名的 golang.org/x/sync/singleflight 包,但它只能解决单机问题。在多节点部署的集群环境中,我们需要一个分布式版本。
二、单机版 SingleFlight:一切从 ConcurrentHashMap 开始
在深入分布式方案之前,先看看本地版是如何实现的。
2.1 核心数据结构
java
private final ConcurrentMap<String, FlightEntry> flights = new ConcurrentHashMap<>();
private record FlightEntry(CompletableFuture<Object> resultFuture, long expireAtMillis) {}
每个 FlightEntry 包含一个 CompletableFuture 和一个过期时间。CompletableFuture 是关键------它天然支持"一个生产者写入结果,多个消费者阻塞等待结果"的语义。
2.2 执行流程
java
public <T> T execute(String key, Supplier<T> supplier) {
long now = System.currentTimeMillis();
long ttlMillis = resolveTtlMillis();
AtomicBoolean newFlight = new AtomicBoolean(false);
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;
});
if (newFlight.get()) {
// leader:执行真实调用
try {
T value = supplier.get();
entry.resultFuture.complete(value);
return value;
} catch (Throwable ex) {
entry.resultFuture.completeExceptionally(ex);
flights.remove(key, entry);
throw ex;
}
}
// follower:阻塞等待 leader 的结果
return (T) entry.resultFuture.get(resolveWaitTimeoutMillis(), TimeUnit.MILLISECONDS);
}
这段代码有三个精妙之处:
第一,ConcurrentHashMap.compute() 的原子性保证。 compute 方法会对同一个 key 加锁,确保"判断是否存在旧 entry"和"创建新 entry"这两步操作是原子化的。即使在高并发下,也只有一个线程能看到 existing == null 并设置 newFlight = true。
第二,CompletableFuture 充当了天然的屏障。 leader 线程调用 complete() 写入结果,所有 follower 线程通过 get() 阻塞等待。当 leader 完成后,所有 follower 几乎同时被唤醒并获得结果,无需额外的同步机制。
第三,TTL 过期机制防止无限等待。 如果 leader 崩溃导致 CompletableFuture 永远无法 complete,follower 会在超时后抛出 TimeoutException,并主动移除过期的 entry,避免后续请求继续等待一个已死的 future。
2.3 单机版的局限
单机版在单节点部署下工作良好,但在集群环境中面临根本性挑战:
- 每个节点各自执行:节点 A 和节点 B 可能同时收到相同 key 的请求,各自独立发起远程调用,造成重复消费。
- 内存不共享 :
ConcurrentHashMap是进程内数据结构,无法跨节点传递结果。
这就是分布式 SingleFlight 要解决的问题。
三、分布式 SingleFlight 的整体架构
分布式 SingleFlight 的核心思路是:用 Redis 替代 ConcurrentHashMap 作为协调介质。
整体架构可以拆解为五个角色:
┌─────────────────────────────────────────────────────────────┐
│ 请求入口 (业务调用层) │
│ 构建 singleFlightKey,调用 distributedExecute │
└──────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ DistributedInterviewAiSingleFlightService │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 查 L1 本地缓存 → 命中则直接返回 │ │
│ │ 2. 调用 acquireOrJoin (Redis Lua) → 判断角色 │ │
│ │ 3. Owner 执行 / Follower 等待 / Replay 回放 │ │
│ └─────────────────────────────────────────────────────┘ │
└──────┬──────────────┬───────────────────┬───────────────────┘
│ │ │
┌──────▼──────┐ ┌─────▼──────┐ ┌─────────▼──────────┐
│ FlightCoord │ │ FlightNoti │ │ FlightHeartbeat │
│ inatorRepo │ │ fication │ │ Manager │
│ (Redis Lua) │ │ Service │ │ (ScheduledExecutor) │
│ │ │ (Redis │ │ │
│ │ │ Stream) │ │ │
└─────────────┘ └────────────┘ └─────────────────────┘
- FlightCoordinatorRepository:Redis 访问层,通过 Lua 脚本原子化操作维护元数据状态机。
- FlightNotificationService:基于 Redis Stream 的发布/订阅,用于 owner 通知 follower 终态结果。
- FlightHeartbeatManager:定时心跳调度器,防止长耗时请求的 owner 身份被误判为"已失活"而遭到接管。
- FlightResultSerializer:结果序列化组件,支持 GZIP 压缩、Base64 编码和 SHA-256 校验。
- FlightReplayLocalCache:本地 LRU 缓存,避免 follower 重复访问 Redis 读取结果。
四、状态机:一个请求的完整生命周期
分布式 SingleFlight 的元数据存储在 Redis Hash 中(key 格式:ai:flight:meta:{requestKey}),其状态流转由 Lua 脚本严格管控:
acquireOrJoin
(不存在) ──────────────► PENDING
│
markRunning│
▼
RUNNING
│ │
finishSuccess │ │ finishFailure
▼ ▼
SUCCEEDED FAILED
│ │
│ │ (retryable=1)
│ ▼
│ PENDING (接管)
▼
(终态,等待 TTL 过期)
4.1 六个状态
| 状态 | 含义 | 是否终态 |
|---|---|---|
PENDING |
已创建但尚未开始执行 | 否 |
RUNNING |
owner 正在执行远程调用 | 否 |
SUCCEEDED |
owner 执行成功,结果已存储 | 是 |
FAILED |
owner 执行失败 | 是 |
CANCELLED |
被主动取消 | 是 |
EXPIRED |
超过 TTL 自动过期 | 是 |
状态机的关键设计:所有状态转换都通过 Lua 脚本在 Redis 中原子执行 ,并且每个脚本都会校验 ownerId 和 ownerToken,确保只有合法的 owner 才能修改状态。
五、核心机制详解
5.1 抢占与加入(Acquire or Join)
这是整个分布式协调的入口,也是逻辑最复杂的一段 Lua 脚本。当一个节点带着 requestKey 来到 Redis 时,脚本需要判断它应该扮演什么角色:
lua
local status = redis.call('HGET', KEYS[1], 'status')
-- 情况1:key 不存在 → 你是第一个,成为 Owner
if not status then
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status', 'PENDING', 'ownerId', ARGV[2], ...)
return 'OWNER_NEW|' .. token
end
-- 情况2:已经成功 → 直接回放结果
if status == 'SUCCEEDED' then
return 'REPLAY_SUCCESS|' .. status
end
-- 情况3:之前失败了
if status == 'FAILED' then
local retryable = redis.call('HGET', KEYS[1], 'retryable')
if retryable == '1' then
-- 允许重试 → 你来接管
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status', 'PENDING', 'ownerId', ARGV[2], ...)
return 'OWNER_TAKEOVER|' .. token
end
-- 不允许重试 → 回放失败
return 'REPLAY_FAILURE|0|' .. errorType .. '|' .. errorCode
end
-- 情况4:正在运行中 → 检查心跳是否新鲜
if heartbeatAt > 0 and (now - heartbeatAt) <= takeoverDetectMillis then
redis.call('HINCRBY', KEYS[1], 'followerCount', 1)
return 'FOLLOWER_WAIT|' .. ownerToken
end
-- 情况5:心跳超时 → 判定 owner 已死,你来接管
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status', 'PENDING', 'ownerId', ARGV[2], ...)
return 'OWNER_TAKEOVER|' .. token
这段 Lua 脚本在单次 Redis 调用中完成了五种情况的分支判定,利用 Redis 单线程执行 Lua 脚本的特性,保证了判定过程的原子性。
ownerToken 的设计值得一提。 每次抢占都会递增一个全局序列号作为 token。后续所有操作(markRunning、heartbeat、storeResult、finishSuccess、finishFailure)都会校验这个 token。这相当于一个乐观锁版本号,防止已被接管的旧 owner 继续写入结果。
5.2 Owner 执行流程
当节点被选为 Owner 后,执行流程如下:
java
private String ownerExecute(String stage, String requestKey, Long ownerToken,
Supplier<String> supplier, StageFlightPolicy policy) {
// 1. 确认身份:PENDING → RUNNING
flightCoordinatorRepository.markRunning(requestKey, nodeId(), ownerToken, runningTtlMillis);
// 2. 启动心跳定时器
String heartbeatTaskKey = flightHeartbeatManager.start(
ownerContext,
() -> flightCoordinatorRepository.heartbeat(requestKey, nodeId(), ownerToken, runningTtlMillis)
);
try {
// 3. 执行真实的远程调用
String result = supplier.get();
// 4. 序列化结果(压缩 + 编码 + 校验和)并写入 Redis
FlightStoredResult storedResult = flightResultSerializer.serialize(result, ownerToken, policy);
flightCoordinatorRepository.storeResult(requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis);
// 5. 标记成功:RUNNING → SUCCEEDED
flightCoordinatorRepository.finishSuccess(requestKey, nodeId(), ownerToken, resultTtlMillis);
// 6. 发布通知到 Redis Stream,唤醒等待的 follower
flightNotificationService.publish(requestKey, "owner_succeeded", FlightStatus.SUCCEEDED, ...);
// 7. 写入本地 L1 缓存
flightReplayLocalCache.put(stage, requestKey, result, policy);
return result;
} catch (Throwable ex) {
// 失败处理:标记 FAILED,区分是否可重试
FlightFailure failure = classifyFailure(ex);
flightCoordinatorRepository.finishFailure(...);
flightNotificationService.publish(requestKey, "owner_failed", FlightStatus.FAILED, ...);
throw rethrow(ex);
} finally {
// 8. 停止心跳
flightHeartbeatManager.stop(heartbeatTaskKey);
}
}
整个流程环环相扣,每一步都有对应的失败处理。
5.3 心跳续租:防止 Owner 被误杀
远程调用可能耗时很长(比如一次复杂的多轮推理可能耗时 30 秒以上),但元数据的 TTL 默认只有 15 秒。如果 owner 不做任何动作,TTL 到期后 Redis key 会被清除,其他节点会认为 owner 已死并发起接管,导致同一次调用被执行两次。
FlightHeartbeatManager 通过 ScheduledExecutorService 定时执行心跳续租:
java
public String start(FlightOwnerContext ownerContext, BooleanSupplier heartbeatAction) {
long intervalMillis = Math.max(500L, ownerContext.getPolicy().getHeartbeatIntervalMillis());
ScheduledFuture<?> future = scheduledExecutorService.scheduleAtFixedRate(
() -> heartbeatAction.getAsBoolean(),
intervalMillis, intervalMillis, TimeUnit.MILLISECONDS
);
futures.put(taskKey, future);
return taskKey;
}
心跳 Lua 脚本在每次执行时都会刷新 heartbeatAt 时间戳和 expireAt 过期时间,相当于对 Redis key 做了一次"续命"。只有当心跳中断超过 takeoverDetectMillis(默认 10 秒)后,其他节点才会判定 owner 失活并接管。
这里有一个关键的权衡:
takeoverDetectMillis过短 → 网络抖动或 GC 暂停就可能导致误接管,一次调用被执行两次。takeoverDetectMillis过长 → owner 真的崩溃时,follower 需要等待更久才能接管,用户体验变差。
项目中默认 10 秒是一个经过实践检验的平衡点。
5.4 Follower 等待策略:Stream 阻塞 + 轮询兜底
Follower 的等待采用了双保险策略:
java
private String followerWait(String stage, String requestKey,
StageFlightPolicy policy, long deadlineMillis) {
while (System.currentTimeMillis() < deadlineMillis) {
// 1. 先尝试直接读取结果(可能 owner 已经完成了)
String replay = tryReadSuccessReplay(stage, requestKey, policy);
if (replay != null) return replay;
// 2. 检查是否是不可重试的失败
FlightMetaSnapshot meta = flightCoordinatorRepository.getMeta(requestKey);
if (meta != null && meta.getStatus() == FlightStatus.FAILED && !meta.getRetryable()) {
throw new IllegalStateException("previous failure is not retryable");
}
// 3. 阻塞等待 Redis Stream 的通知(最多 block 3 秒)
flightNotificationService.waitForTerminalEvent(requestKey, streamBlockTimeoutMillis);
// 4. 定时轮询兜底(每 2 秒检查一次结果是否已就绪)
if (System.currentTimeMillis() >= nextPollAt) {
String polledReplay = tryReadSuccessReplay(stage, requestKey, policy);
if (polledReplay != null) return polledReplay;
nextPollAt = System.currentTimeMillis() + pollIntervalMillis;
}
}
return null;
}
为什么要同时使用两种机制?
- Redis Stream 阻塞读:低延迟,owner 完成后 follower 几乎立即感知,但 Stream 消息可能丢失(比如 follower 启动时 owner 已经发完消息了)。
- 轮询兜底:每隔 2 秒主动检查一次结果,确保即使 Stream 消息丢失,follower 最终也能拿到结果。
这种"事件驱动 + 轮询兜底"的模式在分布式系统中非常常见,是一种实用的可靠性保障手段。
5.5 结果序列化与完整性校验
Owner 执行完成后,结果需要序列化后存入 Redis。FlightResultSerializer 负责这个过程:
java
public FlightStoredResult serialize(String value, Long ownerToken, StageFlightPolicy policy) {
byte[] rawBytes = value.getBytes(StandardCharsets.UTF_8);
boolean shouldCompress = rawBytes.length >= threshold && !"none".equals(codec);
byte[] storedBytes = shouldCompress ? gzip(rawBytes) : rawBytes;
return FlightStoredResult.builder()
.payload(Base64.getEncoder().encodeToString(storedBytes))
.codec(shouldCompress ? codec : "none")
.compressed(shouldCompress)
.rawSize(rawBytes.length)
.storedSize(storedBytes.length)
.checksum(DigestUtil.sha256Hex(rawBytes)) // SHA-256 校验和
.contentType("text/plain")
.finishedAt(System.currentTimeMillis())
.ownerToken(ownerToken)
.build();
}
三个设计要点:
- 按需压缩:只有当结果超过阈值(默认 4KB)时才启用 GZIP 压缩,避免小文本的压缩开销反而得不偿失。
- Base64 编码:确保二进制数据能安全存储在 Redis 的字符串类型中。
- SHA-256 校验和:follower 读取结果时会重新计算校验和并与存储值比对,防止数据在传输或存储过程中损坏。
六、三级缓存:性能与一致性的平衡
分布式 SingleFlight 在结果读取路径上实现了三级缓存:
请求 ──► L1 本地 LRU 缓存 ──► Redis Hash (结果) ──► Owner 真实执行
↑ ↑
最快,无网络开销 跨节点共享,但有网络 RTT
L1 本地缓存(FlightReplayLocalCache)
基于 LinkedHashMap 实现的 LRU 缓存,最大 1000 条记录。命中后零网络开销直接返回。每个 stage 可以独立配置是否开启 L1 缓存以及缓存 TTL:
stage-evaluation(评估类):开启,TTL 30 秒。评估结果在短时间内不会变化,适合缓存。stage-analysis(分析类):关闭。因为每次分析的输入上下文不同,缓存可能导致误复用。
Redis 结果存储
存储在 ai:flight:result:{requestKey} 这个 Hash 中,TTL 按 stage 策略配置(10 分钟到 30 分钟不等)。这是跨节点共享的核心。
Owner 实时执行
当 L1 未命中且 Redis 中也没有结果时(或当前节点就是 owner),才会真正发起远程调用。
七、失败接管机制
分布式环境中,失败是常态。系统需要优雅地处理各种失败场景:
7.1 错误分类
java
private FlightFailure classifyFailure(Throwable throwable) {
// 对上游服务抛出的业务异常进行分类
if (cause instanceof UpstreamServiceException serviceException) {
return switch (serviceException.getErrorCode()) {
case TIMEOUT -> new FlightFailure(TIMEOUT, ..., true); // 可重试
case OVERLOADED -> new FlightFailure(OVERLOAD, ..., true); // 可重试
case UNAVAILABLE -> new FlightFailure(PROVIDER, ..., true); // 可重试
};
}
if (cause instanceof TimeoutException) → TIMEOUT, 可重试
if (cause instanceof RejectedExecutionException) → OVERLOAD, 可重试
if (cause instanceof IllegalArgumentException) → VALIDATION, 不可重试
return UNEXPECTED, 不可重试;
}
错误被分为两大类:
- 可重试(retryable=true) :超时、过载、服务不可用等瞬态错误。后续请求到来时,
acquireOrJoin的 Lua 脚本会检测到retryable=1,允许新节点接管并重新执行。 - 不可重试(retryable=false):参数校验失败、未知异常等确定性错误。后续请求直接回放失败结果,避免反复执行注定会失败的调用。
7.2 接管流程
当 owner 失败且错误可重试时:
- Owner 在
finishFailure中写入FAILED状态和retryable=1。 - 下一个到来的请求执行
acquireOrJoin,Lua 脚本检测到status == 'FAILED'且retryable == '1'。 - 脚本返回
OWNER_TAKEOVER,同时递增ownerToken,将状态重置为PENDING。 - 新节点成为新 owner,开始执行。
整个过程对外部透明,follower 感知不到接管的发生,只需要继续等待最终结果即可。
八、Hybrid 模式:分布式与本地的优雅降级
系统支持三种运行模式:
| 模式 | 说明 |
|---|---|
LOCAL |
仅使用本地 ConcurrentHashMap,无 Redis 依赖 |
DISTRIBUTED |
仅使用分布式方案,Redis 不可用则直接报错 |
HYBRID |
优先使用分布式方案,Redis 异常时自动降级到本地模式 |
Hybrid 模式的降级逻辑非常简洁:
java
public String execute(String stage, String requestKey, Supplier<String> supplier) {
FlightMode mode = FlightMode.from(configuration.normalizedMode());
if (!configuration.getEnable() || mode == FlightMode.LOCAL || !configuration.getDistributedEnabled()) {
return localSingleFlightService.execute(requestKey, supplier);
}
try {
return executeDistributed(stage, requestKey, supplier);
} catch (RuntimeException ex) {
if (mode == FlightMode.HYBRID) {
log.warn("Distributed single-flight fallback to local mode, reason={}", ex.getMessage());
return localSingleFlightService.execute(requestKey, supplier);
}
throw ex;
}
}
这种设计保证了即使 Redis 完全不可用,系统也不会因此崩溃------它会自动退化为单机版 SingleFlight,只是失去了跨节点去重能力。
九、按 Stage 差异化配置
不同业务阶段对 SingleFlight 的需求截然不同。系统通过 StageFlightPolicy 支持细粒度配置:
| Stage 类型 | 结果 TTL | 失败 TTL | L1 缓存 | 设计考量 |
|---|---|---|---|---|
| 评估类 | 600s | 60s | 开启 | 评估结果稳定,适合长缓存 |
| 追问类 | 180s | 30s | 开启 | 结果有时效性,TTL 适中 |
| 提取类 | 1800s | - | 开启 | 调用成本高,结果长时间复用 |
| 分析类 | 900s | - | 关闭 | 输入上下文多变,不宜过度复用 |
每个 stage 还可以独立配置心跳间隔、运行 TTL、接管检测时间、压缩阈值等参数。这种差异化策略体现了"不同业务场景,不同容错策略"的工程思维。
十、Key 设计:决定去重粒度的关键
SingleFlight 的效果高度依赖 key 的设计。实践中常见的 key 格式:
stage|sessionId|bizType|inputHash → 评估/交互类调用
stage|sessionId|businessKey → 材料/文件类调用
Key 设计的核心权衡:
- Key 过粗 (例如只用
sessionId)→ 大量不同请求被错误合并,结果混乱。 - Key 过细(例如加入时间戳)→ 几乎永远无法命中,SingleFlight 形同虚设。
一个好的实践是在 sessionId + stage + bizType 的基础上加入 inputHash(对请求输入内容做摘要)。这意味着同一个会话中对同类型的业务、完全相同的输入时,结果才会被复用。这在大多数场景下是合理的------相同输入在短时间内的处理结果不会变化。
十一、可观测性:知道发生了什么
单机版通过 Micrometer 暴露了两个关键指标:
singleflight_miss_total:未命中次数(真实执行了远程调用)singleflight_hit_total:命中次数(复用了已有结果)
通过这两个指标,可以实时计算命中率:
命中率 = hit_total / (hit_total + miss_total)
如果命中率持续偏低,可能意味着 key 设计过细,或者 TTL 配置过短;如果命中率异常偏高,则需要检查是否存在结果过度复用的问题。
在分布式层面,Redis 中存储的元数据本身就是可观测的数据源------可以通过 HGETALL ai:flight:meta:{key} 查看任意请求的当前状态、owner 身份、心跳时间等运行信息。
十二、总结:设计取舍与工程思考
回顾整个分布式 SingleFlight 的设计,有几个值得总结的工程决策:
1. Redis Lua 脚本 vs 分布式锁
项目选择了 Lua 脚本而非分布式锁来实现状态机。原因是 Lua 脚本在 Redis 中原子执行,无需担心锁超时、死锁等问题,且单次网络往返就能完成复杂的分支判定。
2. 心跳续租 vs 长 TTL
选择心跳续租而非设置很长的 TTL,是因为长 TTL 在 owner 崩溃后会导致长时间的空洞期------所有 follower 都在等待一个永远不会到来的结果。心跳机制允许在 takeoverDetectMillis 后快速检测并接管。
3. Stream + 轮询 vs 纯 Pub/Sub
纯 Pub/Sub 模式存在消息丢失风险(subscriber 不在线时消息不会被缓冲)。Redis Stream 天然支持消费者离线后重新消费,配合轮询兜底,实现了更可靠的等待机制。
4. Hybrid 降级 vs 强依赖 Redis
通过支持 Hybrid 模式,系统在 Redis 故障时不会整体崩溃,而是退化为本地 SingleFlight。这是一种务实的容错策略------有分布式去重是锦上添花,没有也不应影响核心功能。
5. 结果压缩与校验
远程服务(如 LLM)的输出可能很长(数千 Token),直接存入 Redis 不仅浪费内存,还可能触发大 key 告警。按需 GZIP 压缩 + SHA-256 校验,在存储成本和可靠性之间取得了平衡。
分布式 SingleFlight 并不是一个独立的"炫技组件",而是深深嵌入业务场景的防护机制。它的设计处处体现了对实际问题的回应:远程调用贵且慢,所以要合并;集群部署下单机版不够用,所以要分布式;Redis 可能挂,所以要 Hybrid 降级;不同阶段需求不同,所以要按 stage 差异化配置。
好的工程方案,永远是问题驱动的。