6503 字
约 21 分钟
1
分布式 Single-flight 的通用容灾设计

分布式 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_ONLYDISTRIBUTED_ONLYHYBRIDBYPASS 等模式统一管理,不要把行为隐藏在零散代码中。

原则三:本地降级只是保底能力

降级后必须明确接受:

可能发生跨节点重复执行

原则四:降级必须配合熔断与限流

否则 Redis 故障后,大量节点同时调用高成本下游,可能造成二次故障。

原则五:高副作用操作默认不要 Fail Open

支付、库存、权益等业务,应优先保证幂等和一致性。

原则六:恢复过程也需要保护

使用半开探测、随机抖动和渐进恢复,避免 Redis 恢复后出现重连风暴。

原则七:降级必须可观测

没有指标、日志和告警的降级,等于系统在无声地丢失全局协调能力。


二十、面试中如何表达

可以这样回答:

在分布式 Single-flight 设计中,我不会让核心业务完全绑定在 Redis 协调层上。正常情况下,系统通过 Redis 原子脚本、租约和共享状态完成跨节点请求合并;当协调层发生连接超时、脚本异常、协议结果非法或熔断器打开时,HYBRID 模式会自动退回 JVM 本地 Single-flight,继续通过 ConcurrentHashMap 和 CompletableFuture 合并当前节点内的重复请求。这个降级会暂时牺牲跨节点去重能力,但能保留主业务可用性和单节点内的最低成本控制。同时会配合熔断、限流、线程隔离和监控,防止降级后出现下游请求风暴。对于已经获得执行权后的业务异常或结果回写不确定场景,不会简单再次本地执行,而是通过幂等、状态查询和补偿机制处理。


简历中的一句话

设计分布式 Single-flight 容灾与本地降级机制,在共享协调层异常时由 HYBRID 模式自动切换至 JVM 本地请求合并,并结合熔断、限流、线程隔离及可观测性建设,在牺牲跨节点去重能力的前提下保障核心链路可用性,避免协调组件故障扩散至主业务。


总结

分布式 Single-flight 的本地降级,本质上不是一个复杂的新系统。

它做的事情非常直接:

分布式协调正常
→ 使用全局请求合并

分布式协调异常
→ 退回单节点请求合并

真正困难的不是“调用本地 Single-flight”,而是正确决定:

  • 哪些异常可以降级;
  • 哪些异常不能重新执行;
  • 什么时候应该快速失败;
  • 什么时候应该优先保住可用性;
  • 如何限制降级后的下游压力;
  • 如何从降级状态安全恢复;
  • 如何判断是否已经出现跨节点重复执行。

一个成熟的容灾设计,不是保证任何情况下能力都不下降,而是做到:

故障发生时,系统能够明确地失去一部分非核心能力,而不是让整个核心业务一起崩溃。

分布式 Single-flight 的通用容灾设计
http://www.clxhxhhr.top/posts/128/
作者
clxstart
发布于
2026-07-17
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。
文章目录
目录