分布式 Single-flight 的通用容灾设计:协调层故障时,如何安全退回本地模式
分布式 Single-flight 通常依赖 Redis、Lua 脚本、分布式锁或共享状态机,实现跨节点请求合并。
理想情况下,多个应用实例同时收到相同请求时,只有一个节点真正执行,其他节点等待并复用结果。
但成熟的分布式方案不能只考虑“Redis 正常、网络稳定、状态机永远正确”的理想环境,还必须回答一个更现实的问题:
当分布式协调层不可用时,核心业务是否也要跟着一起失败?
一种常见的容灾方案是:
正常情况下使用分布式 Single-flight;协调层异常时,自动降级为 JVM 本地 Single-flight。
降级后,系统会暂时失去跨节点请求合并能力,但仍然可以保留单节点内的并发去重,避免核心业务完全中断。
一句话总结
分布式 Single-flight 的本地降级,是在 Redis、Lua 脚本或共享状态机异常时,暂时放弃跨节点去重,退回基于本地内存的 Single-flight,以保住核心业务可用性和单节点内的最低限度请求复用能力。
它不是让系统在故障时继续维持完整能力,而是进行一次明确的能力降级:
正常状态:
跨节点请求合并
+ 结果共享
+ 故障接管
+ 全局状态协调
降级状态:
单节点请求合并
+ 本地结果等待
- 跨节点去重
- 全局结果共享
- 跨节点故障接管
一、为什么需要本地降级
一个完整的分布式 Single-flight 通常需要依赖共享协调组件,例如 Redis。
请求
↓
分布式协调层
↓
竞争 Owner
├── Owner:真正执行
└── Follower:等待结果
协调层可能负责:
- 原子竞争执行权;
- 保存当前 Owner;
- 维护租约;
- 更新心跳;
- 保存执行状态;
- 发布完成通知;
- 保存短期结果;
- 支持故障接管;
- 校验 Fencing Token。
这套方案虽然能解决跨节点重复执行,但也引入了新的依赖。
一旦 Redis 或协调逻辑异常,可能出现:
- Redis 连接超时;
- Redis 集群不可达;
- Lua 脚本执行失败;
- Redis 主从切换;
- 状态数据格式异常;
- 协议版本不兼容;
- 返回未知动作;
- 发布订阅不可用;
- 网络发生分区;
- 协调结果无法可靠解析。
如果业务代码完全依赖这套协调层,那么 Redis 一旦异常,核心业务也会直接失败。
这会形成新的问题:
原本只是请求去重能力不可用
↓
最终演变成整个业务不可用
本地降级的目的,就是切断这种故障传播。
二、本地降级真正解决的是什么
本地降级不是为了继续维持“全局只执行一次”。
Redis 已经不可用时,不同服务器之间无法可靠共享状态:
Node A 看不到 Node B 的本地任务
Node B 看不到 Node C 的执行结果
因此,本地降级只能保证:
在同一个 JVM 内,相同 Key 的并发请求只由一个线程真正执行。
例如:
Node A:
请求 A1 ──→ 真正执行
请求 A2 ──→ 等待 A1
请求 A3 ──→ 等待 A1
Node B:
请求 B1 ──→ 真正执行
请求 B2 ──→ 等待 B1
此时集群内最多仍可能有多个真实执行:
Node A 执行一次
Node B 执行一次
Node C 执行一次
但每个节点内部不会因为几十个并发请求而重复执行几十次。
所以,本地降级解决的是:
失去全局协调以后
避免每个节点内部继续发生请求风暴
而不是:
Redis 故障以后
仍然保证整个集群只执行一次
这个能力边界必须明确。
三、通用的两层 Single-flight 架构
比较实用的架构是:
L1:本地 Single-flight
L2:分布式 Single-flight
其中:
L1:JVM 本地层
通常基于:
ConcurrentHashMap
+
CompletableFuture
负责合并同一应用实例内的重复请求。
L2:分布式协调层
通常基于:
Redis
+ Lua
+ 租约
+ 心跳
+ 共享结果
+ Fencing Token
负责合并不同应用实例之间的重复请求。
整体调用链可以设计成:
业务请求
↓
生成语义 Key
↓
进入本地 Single-flight
↓
本地 Leader 尝试分布式协调
├── 协调成功:执行分布式流程
└── 协调失败:根据策略进入本地降级
这种结构的优势是:
- 正常情况下获得跨节点去重;
- 同一节点的大量请求只需要一次分布式协调;
- Redis 异常时仍能保留本地请求合并;
- 避免所有本地等待者同时冲击 Redis;
- 降低协调层的连接和轮询压力。
四、建议定义的运行模式
为了让容灾行为可控,不建议把降级逻辑散落在代码的多个 catch 中。
可以显式定义运行模式。
1. DISTRIBUTED_ONLY
只允许使用分布式 Single-flight。
分布式协调成功
→ 正常执行
分布式协调失败
→ 请求失败
适合:
- 不能容忍跨节点重复执行;
- 下游操作副作用严重;
- 一旦失去全局协调,宁愿暂停服务;
- 支付、发券、扣减、结算等高风险操作。
2. LOCAL_ONLY
明确只使用本地 Single-flight。
不访问 Redis
→ 只在当前 JVM 内合并
适合:
- 本地开发;
- 单实例部署;
- Redis 尚未接入;
- 故障演练;
- 临时关闭分布式能力;
- 某些本来不需要跨节点协调的低风险任务。
3. HYBRID
优先使用分布式 Single-flight,协调层异常时退回本地模式。
先尝试分布式
├── 成功:使用分布式协调
└── 失败:使用本地 Single-flight
这是最典型的高可用模式。
适合:
- AI 推理;
- 报告生成;
- 内容总结;
- 搜索计算;
- 图像处理;
- 允许少量重复执行,但不能接受业务完全中断的场景。
4. BYPASS
完全绕过 Single-flight,直接执行。
请求
→ 直接调用真实业务
适合:
- 紧急排障;
- 功能开关关闭;
- Key 无法安全生成;
- 请求不允许共享结果;
- Single-flight 模块本身出现严重故障。
BYPASS 通常应该谨慎使用,并且必须具备监控告警。
五、什么情况下可以安全降级
并不是所有异常都应该直接回退本地执行。
这是容灾设计中最重要的一点。
需要区分:
协调失败
和
业务执行失败
1. 执行权竞争前失败
例如:
- Redis 无法连接;
- Lua 脚本没有执行;
- 协调请求超时;
- 返回结果为空;
- 返回格式无法解析;
- 返回未知动作;
- Redis 集群整体不可用。
此时系统还没有确认任何节点获得执行权。
这类情况通常可以降级:
分布式协调失败
→ 本地 Single-flight
→ 当前节点自行执行
因为系统还没有明确进入分布式 Owner 状态。
2. 已明确成为 Follower,但等待链路异常
例如:
- 完成通知丢失;
- 发布订阅不可用;
- 轮询 Redis 失败;
- 等待状态查询超时。
这种情况不能简单地立即本地执行。
因为其他节点的 Owner 可能仍在正常调用下游。
如果 Follower 一超时就本地执行,会立即产生重复调用:
Node A:原 Owner 正在执行
Node B:等待超时后本地降级并执行
更合理的处理方式包括:
- 继续查询共享状态;
- 返回“任务处理中”;
- 让客户端稍后查询结果;
- 使用更保守的超时;
- 等待租约过期后重新竞争;
- 根据业务风险决定是否允许重复执行。
3. 已成为 Owner,业务执行失败
假设当前节点已经成功获得分布式执行权,随后真实业务执行抛出异常:
获得 Owner
→ 调用下游
→ 下游返回错误
这不是“分布式协调异常”,而是业务执行失败。
此时一般不应该自动回退本地再执行一遍。
否则会变成:
第一次业务调用失败
→ 被误判为分布式故障
→ 本地模式再次调用
这可能导致:
- 重复请求;
- 隐藏真实异常;
- 重试次数失控;
- 外部服务被连续冲击;
- 不可判断第一次调用是否已产生副作用。
所以:
本地降级应该主要针对协调层失败,而不是无差别捕获所有业务异常。
4. Owner 执行完成,但结果回写失败
这是最危险的情况之一:
下游执行成功
→ Redis 回写结果失败
当前节点知道业务可能已经成功,但其他节点看不到结果。
如果直接本地重试或让 Follower 接管,可能造成重复执行。
这类情况属于“执行结果不确定”,建议采用:
- 持久化业务幂等键;
- 写数据库状态;
- 使用事务消息;
- 记录待补偿事件;
- 返回处理中状态;
- 后台对账与补偿;
- 不立即重新执行高副作用操作。
这种异常不能简单通过本地 Single-flight 解决。
六、推荐的故障分类
可以将异常划分为四类。
| 故障类型 | 例子 | 是否适合本地降级 |
|---|---|---|
| 协调前故障 | Redis 不可用、Lua 未执行 | 通常适合 |
| 协议故障 | 返回空动作、非法状态 | 通常适合,但应告警 |
| 等待故障 | Follower 等待超时、通知丢失 | 谨慎,不能立即重复执行 |
| 执行后故障 | 业务成功但结果回写失败 | 不适合简单降级 |
可以进一步形成处理矩阵:
尚未获得执行权
→ 可以考虑本地降级
确认其他节点正在执行
→ 优先等待或返回处理中
当前节点已经开始真实执行
→ 不应再次自动本地执行
真实执行结果不确定
→ 依赖幂等、状态查询或补偿
七、通用降级流程
一个比较稳妥的流程如下:
请求进入
↓
检查总开关和运行模式
├── LOCAL_ONLY → 本地 Single-flight
├── BYPASS → 直接执行
└── HYBRID / DISTRIBUTED_ONLY
↓
检查分布式熔断器
├── 已打开
│ ├── HYBRID → 本地模式
│ └── DISTRIBUTED_ONLY → 快速失败
↓
尝试分布式协调
├── 成为 Owner → 执行业务
├── 成为 Follower → 等待共享结果
├── 命中结果 → 直接返回
└── 协调失败
├── HYBRID → 本地模式
└── DISTRIBUTED_ONLY → 失败
其中必须注意:
成为 Owner 之后的业务异常
不能简单视为协调异常并重新降级执行
八、通用伪代码
下面是一份更通用的结构示例:
public <T> T execute(
String stage,
String requestKey,
Supplier<T> supplier) {
validate(requestKey, supplier);
FlightMode mode = resolveMode(stage);
if (mode == FlightMode.BYPASS) {
return supplier.get();
}
if (mode == FlightMode.LOCAL_ONLY) {
return localSingleFlight.execute(requestKey, supplier);
}
if (distributedCircuitBreaker.isOpen()) {
return handleCoordinationUnavailable(
mode,
requestKey,
supplier
);
}
AcquireResult acquireResult;
try {
acquireResult = distributedCoordinator.acquire(
stage,
requestKey
);
} catch (CoordinationException ex) {
return handleCoordinationUnavailable(
mode,
requestKey,
supplier
);
}
if (acquireResult == null || acquireResult.action() == null) {
return handleInvalidProtocolResult(
mode,
requestKey,
supplier
);
}
return switch (acquireResult.action()) {
case RESULT_HIT ->
distributedResultStore.read(acquireResult);
case OWNER ->
executeAsOwner(
acquireResult,
supplier
);
case FOLLOWER ->
waitForOwnerResult(acquireResult);
default ->
handleInvalidProtocolResult(
mode,
requestKey,
supplier
);
};
}
协调层不可用时:
private <T> T handleCoordinationUnavailable(
FlightMode mode,
String requestKey,
Supplier<T> supplier) {
if (mode == FlightMode.HYBRID) {
return localSingleFlight.execute(
requestKey,
supplier
);
}
throw new SingleFlightUnavailableException();
}
这个结构的重点不是具体代码,而是明确区分:
协调失败
业务失败
等待失败
结果回写失败
不能用一个宽泛的 catch (RuntimeException) 把所有异常都归类为“可以本地重试”。
九、本地 Single-flight 的通用实现思路
本地模式通常维护一个 JVM 内的正在执行任务表:
Map<RequestKey, FlightEntry>
每个 FlightEntry 至少包含:
共享 Future
过期时间
创建时间
可选的等待者数量
工作过程是:
第一个请求到达
→ 创建 Flight
→ 成为本地 Leader
→ 执行真实业务
后续相同 Key 到达
→ 找到已有 Flight
→ 成为 Waiter
→ 等待同一个 Future
执行成功后:
Leader 完成 Future
→ 所有 Waiter 获得相同结果
执行失败后:
Leader 以异常完成 Future
→ 所有 Waiter 感知相同异常
可以用一句话概括:
使用按 Key 管理的 in-flight Map,将同一 JVM 内的并发重复请求收敛为一个 Leader 执行,多个 Waiter 等待同一个 Future。
十、本地降级不是“无条件继续执行”
如果 Redis 故障后,所有节点都立即进入本地模式,那么集群可能出现:
Node A 执行一次
Node B 执行一次
Node C 执行一次
Node D 执行一次
虽然比每个节点内部重复几十次更好,但仍然可能对下游造成明显冲击。
因此,本地降级还应该配合其他保护机制。
1. 限流
Redis 不可用时,适当降低允许进入真实业务的请求速率。
正常模式:
允许较高并发
降级模式:
限制每个节点或每个 Stage 的并发
2. Bulkhead 隔离
不同业务类型使用独立线程池或信号量:
AI 评分线程池
报告生成线程池
图片处理线程池
避免某一种高成本任务耗尽整个应用的线程资源。
3. 并发上限
即使是不同 Key,也应该限制同时执行的真实任务数量。
例如:
每个节点最多同时执行 20 个 AI 请求
超过上限后:
- 排队;
- 快速失败;
- 返回处理中;
- 写入异步任务队列。
4. 熔断
Redis 已经连续失败时,不要每个请求都继续等待连接超时。
可以使用熔断器:
连续失败达到阈值
→ 打开熔断器
→ 一段时间内不再请求 Redis
→ HYBRID 模式直接进入本地降级
随后通过半开探测恢复:
等待一段时间
→ 允许少量探测请求
→ Redis 恢复则关闭熔断器
5. 超时预算
分布式协调的超时时间不能过长。
例如业务整体超时为 10 秒,如果 Redis 协调就等待 8 秒,再降级本地执行,最终请求很可能仍然超时。
应该为每个阶段分配预算:
总超时:10 秒
协调层:最多 200 毫秒
业务执行:最多 8 秒
结果处理:最多 1 秒
预留:800 毫秒
具体数值应根据业务耗时确定。
十一、降级状态下的 Key 设计
本地和分布式模式应该尽量使用同一套语义 Key 规则。
Key 通常包含:
业务 Stage
+ 租户或权限域
+ 资源标识
+ 输入内容哈希
+ 模型版本
+ Prompt 版本
+ 关键参数版本
例如:
ai:summary:v3:tenant-100:{requestHash}
report:generate:v2:tenant-200:{requestHash}
image:render:v1:tenant-300:{requestHash}
Key 过于宽松会造成错误复用:
不同用户
不同权限
不同模型
却共享了同一结果
Key 过于严格则无法有效合并:
时间戳、随机数等无关字段进入 Key
→ 每个请求的 Key 都不同
十二、本地 Flight 的生命周期
本地模式需要处理 Flight 清理,否则本地 Map 可能持续增长。
常见清理方式包括:
- Owner 完成后立即删除;
- 成功结果保留一个极短 TTL;
- 失败后立即删除;
- 后台定时清理;
- 达到容量阈值时触发清理;
- 使用 Caffeine 等成熟本地缓存库;
- 设置最大 Flight 数量。
需要区分:
执行超时
等待超时
结果复用 TTL
Flight 清理 TTL
它们不是同一个概念。
十三、等待者超时应该怎么处理
本地 Waiter 等待超时,只表示:
当前请求不愿意继续等。
它不代表:
Leader 已经停止执行。
因此,不建议每个 Waiter 超时后都直接删除 Flight。
否则可能出现:
Leader 仍在执行
→ Waiter 超时并删除 Flight
→ 新请求创建新 Flight
→ 同一 JVM 内出现第二个 Leader
更稳妥的策略是:
- Waiter 自己结束等待;
- Flight 仍由 Leader 管理;
- 只有 Leader 完成或明确失活后才清理;
- 后台清理严重超时的 Flight;
- 必要时记录任务执行状态。
十四、降级恢复时如何切回分布式模式
Redis 恢复以后,不能一下子让所有实例同时重新访问协调层。
否则可能形成恢复风暴。
推荐过程是:
Redis 恢复
↓
熔断器进入 HALF_OPEN
↓
允许少量请求探测
↓
连续成功达到阈值
↓
逐步恢复分布式模式
同时建议加入随机抖动:
不同节点在不同时间尝试恢复
避免所有节点在同一毫秒重新连接 Redis。
十五、降级过程中必须监控什么
本地降级不能悄悄发生。
至少应该记录以下指标:
singleflight_distributed_request_total
singleflight_distributed_failure_total
singleflight_local_fallback_total
singleflight_local_owner_total
singleflight_local_waiter_total
singleflight_fallback_execution_total
singleflight_circuit_open_total
singleflight_unknown_action_total
singleflight_wait_timeout_total
singleflight_result_write_failure_total
还需要监控:
- 当前是否处于降级状态;
- 哪些业务 Stage 正在降级;
- 降级持续了多久;
- Redis 错误率;
- 本地真实执行数量;
- 跨节点重复执行的估算量;
- 下游调用量是否突然上升;
- 本地线程池和连接池是否过载;
- 业务响应时间是否恶化。
建议为降级状态设置明确告警:
进入 HYBRID 本地降级
→ 立即告警
持续降级超过一定时间
→ 升级告警等级
十六、不同业务应该采用不同降级策略
不能所有业务都统一“Redis 挂了就本地执行”。
AI 推理、内容生成
通常可以使用:
HYBRID
因为重复执行主要意味着成本增加和结果抖动,通常不会直接造成资金损失。
报告生成、文件转换
可以使用:
HYBRID
+
任务状态查询
+
短期结果缓存
支付、扣款、库存扣减
更适合:
DISTRIBUTED_ONLY
或 Fail Closed
因为重复执行可能产生严重副作用。
即使使用本地降级,也必须先有可靠的业务幂等机制。
普通查询
可能根本不需要复杂的分布式 Single-flight。
缓存、数据库优化或简单本地请求合并可能已经足够。
十七、CAP 视角下的降级含义
当分布式协调正常时,系统倾向于保证:
整个集群只有一个有效 Owner
这是偏向一致性的选择。
当 Redis 不可用后,如果系统切换到本地模式:
每个节点都可能拥有自己的本地 Leader
此时系统主动放弃了全局请求合并的一致性,换取核心业务继续可用。
因此,本地降级的准确含义是:
在协调层故障时,主动牺牲跨节点一致去重能力,以保留业务可用性和节点内部的最低成本控制。
它不是同时获得一致性和可用性,而是一次明确的取舍。
十八、这套容灾方案不能解决什么
本地降级不能解决:
- 跨节点重复执行;
- 外部副作用的 Exactly Once;
- 结果回写不确定性;
- Owner 执行成功但状态丢失;
- 不同节点之间的结果共享;
- 旧 Owner 的脏写问题;
- 数据库级业务幂等;
- 消息重复消费。
这些问题仍然需要:
- 业务幂等键;
- 数据库唯一约束;
- 条件更新;
- Fencing Token;
- Outbox;
- 消息去重;
- 状态查询;
- 补偿与对账。
Single-flight 主要解决的是并发执行合并,不应该被当成万能幂等组件。
十九、推荐的设计原则
原则一:先区分协调异常和业务异常
只有协调层异常才进入本地降级。
业务调用失败不应该自动被当成“Redis 出问题”然后重新执行。
原则二:降级必须有明确模式
使用 LOCAL_ONLY、DISTRIBUTED_ONLY、HYBRID、BYPASS 等模式统一管理,不要把行为隐藏在零散代码中。
原则三:本地降级只是保底能力
降级后必须明确接受:
可能发生跨节点重复执行
原则四:降级必须配合熔断与限流
否则 Redis 故障后,大量节点同时调用高成本下游,可能造成二次故障。
原则五:高副作用操作默认不要 Fail Open
支付、库存、权益等业务,应优先保证幂等和一致性。
原则六:恢复过程也需要保护
使用半开探测、随机抖动和渐进恢复,避免 Redis 恢复后出现重连风暴。
原则七:降级必须可观测
没有指标、日志和告警的降级,等于系统在无声地丢失全局协调能力。
二十、面试中如何表达
可以这样回答:
在分布式 Single-flight 设计中,我不会让核心业务完全绑定在 Redis 协调层上。正常情况下,系统通过 Redis 原子脚本、租约和共享状态完成跨节点请求合并;当协调层发生连接超时、脚本异常、协议结果非法或熔断器打开时,HYBRID 模式会自动退回 JVM 本地 Single-flight,继续通过 ConcurrentHashMap 和 CompletableFuture 合并当前节点内的重复请求。这个降级会暂时牺牲跨节点去重能力,但能保留主业务可用性和单节点内的最低成本控制。同时会配合熔断、限流、线程隔离和监控,防止降级后出现下游请求风暴。对于已经获得执行权后的业务异常或结果回写不确定场景,不会简单再次本地执行,而是通过幂等、状态查询和补偿机制处理。
简历中的一句话
设计分布式 Single-flight 容灾与本地降级机制,在共享协调层异常时由 HYBRID 模式自动切换至 JVM 本地请求合并,并结合熔断、限流、线程隔离及可观测性建设,在牺牲跨节点去重能力的前提下保障核心链路可用性,避免协调组件故障扩散至主业务。
总结
分布式 Single-flight 的本地降级,本质上不是一个复杂的新系统。
它做的事情非常直接:
分布式协调正常
→ 使用全局请求合并
分布式协调异常
→ 退回单节点请求合并
真正困难的不是“调用本地 Single-flight”,而是正确决定:
- 哪些异常可以降级;
- 哪些异常不能重新执行;
- 什么时候应该快速失败;
- 什么时候应该优先保住可用性;
- 如何限制降级后的下游压力;
- 如何从降级状态安全恢复;
- 如何判断是否已经出现跨节点重复执行。
一个成熟的容灾设计,不是保证任何情况下能力都不下降,而是做到:
故障发生时,系统能够明确地失去一部分非核心能力,而不是让整个核心业务一起崩溃。