分布式单飞锁

大家好,我是程序员小策。

你有没有遇到过这种场景------用户手抖连点三次"提交",前端重试机制发了 5 个请求,网关超时又补了 2 个,最后 7 个一模一样的 AI 调用同时打到星辰 AI 平台上,Token 账单直接翻 7 倍?

更扎心的是,这 7 个请求返回的还是一模一样的结果。

问题定义:重复请求不是"多做了几次"这么简单

朴素方案很好想:加个分布式锁呗,同一时刻只让一个请求通过。

但分布式锁解决的是"互斥"问题,不是"合并"问题。锁放行之后,7 个请求还是 7 个------只是从"并行 7 个"变成了"串行 7 个"。AI 调用的成本并没有减少,用户等待的时间反而更长了。

真正需要的是:相同请求只执行一次,其他请求直接复用结果。

这就是单飞锁(Singleflight)要解决的核心矛盾。

核心概念:食堂阿姨打饭模型

单飞锁的本质是:对相同 key 的并发请求,只让第一个(leader)真正执行,其余(follower)等待并复用 leader 的结果。

想象一下食堂打饭的场景:你们宿舍 5 个人都想去打红烧肉。与其 5 个人排 5 次队,不如派一个人去打一大份回来分。打饭的那个人就是 leader,等着分饭的就是 follower。

翻译回技术语言:

食堂场景 技术对应
5 个人都想打红烧肉 5 个相同 key 的并发请求
派一个人去排队 leader 执行真实 AI 调用
打一大份回来分 leader 的结果广播给所有 follower
其他人不用再排队 follower 直接复用结果,无需再次调用

当然,实际实现比食堂打饭复杂得多------leader 可能执行失败、可能超时、可能在另一个节点上。这些边界情况才是分布式单飞锁的真正难点。

在分布式单飞锁中,这两个角色有更正式的名称:

角色 含义 行为
Owner 抢到执行权的人 真正调用 AI,把结果存到 Redis,通知 Follower
Follower 没抢到执行权的人 等 Owner 执行完,直接复用结果

后续代码中出现的 Owner 和 Follower,指的就是这两个角色。

实现:从本地到分布式的三层架构

第一层:本地单飞(ConcurrentHashMap + CompletableFuture)

这是最基础的实现,只在单进程内生效:

java 复制代码
// InterviewAiSingleFlightService.java
@Service
public class InterviewAiSingleFlightService {

    private final ConcurrentMap<String, FlightEntry> flights = new ConcurrentHashMap<>();

    public <T> T execute(String key, Supplier<T> supplier) {
        long now = System.currentTimeMillis();
        long ttlMillis = resolveTtlMillis();
        AtomicBoolean newFlight = new AtomicBoolean(false);

        // compute 保证同 key 下"创建 flight + 复用 flight"原子化
        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;  // 复用已有的 flight
        });

        if (newFlight.get()) {
            // leader:执行真实调用,结果广播给所有等待者
            T value = supplier.get();
            entry.resultFuture.complete(value);
            return value;
        }

        // follower:等待 leader 的结果
        T reused = (T) entry.resultFuture.get(resolveWaitTimeoutMillis(), TimeUnit.MILLISECONDS);
        return reused;
    }

    private record FlightEntry(CompletableFuture<Object> resultFuture, long expireAtMillis) {}
}

关键设计点:

  • ConcurrentHashMap.compute() 保证了同 key 下创建/复用 flight 的原子性,避免瞬时并发出现多个 leader
  • CompletableFuture 作为 leader 和 follower 之间的通信管道:leader 完成后调用 complete(),follower 通过 get() 阻塞等待
  • TTL 过期自动清理,防止内存泄漏

局限: 只在单进程内有效。多节点部署时,每个节点都会有自己的 leader,单飞效果打折扣。

第二层:分布式单飞(Redis Lua + Owner/Follower 协调)

多节点部署下,需要跨进程协调。核心思路是:通过 Redis 作为协调中心,让集群中只有一个节点成为 owner 执行真实调用,其余节点成为 follower 等待结果。

java 复制代码
// DistributedInterviewAiSingleFlightService.java
public String execute(String stage, String requestKey, Supplier<String> supplier) {
    FlightMode mode = FlightMode.from(configuration.normalizedMode());

    // LOCAL 模式直接走本地
    if (!configuration.getDistributedEnabled() || mode == FlightMode.LOCAL) {
        return localSingleFlightService.execute(requestKey, supplier);
    }

    try {
        return executeDistributed(stage, requestKey, supplier);
    } catch (RuntimeException ex) {
        // HYBRID 模式:分布式失败后回退到本地
        if (mode == FlightMode.HYBRID) {
            return localSingleFlightService.execute(requestKey, supplier);
        }
        throw ex;
    }
}

分布式协调的核心是 acquireOrJoin------通过 Redis Lua 脚本实现原子性的"抢占或加入":

lua 复制代码
-- FlightCoordinatorRepository.acquireOrJoin Lua 脚本(简化版)
local status = redis.call('HGET', KEYS[1], 'status')

if not status then
    -- 无记录:成为 OWNER_NEW
    local token = redis.call('INCR', KEYS[2])
    redis.call('HSET', KEYS[1], 'status', 'PENDING',
               'ownerId', ARGV[2], 'ownerToken', token, ...)
    return 'OWNER_NEW|' .. token
end

if status == 'SUCCEEDED' then
    -- 已成功:直接 REPLAY_SUCCESS
    return 'REPLAY_SUCCESS|' .. status
end

if status == 'FAILED' then
    local retryable = redis.call('HGET', KEYS[1], 'retryable')
    if retryable == '1' then
        -- 可重试的失败:成为 OWNER_TAKEOVER
        return 'OWNER_TAKEOVER|' .. token
    end
    -- 不可重试的失败:REPLAY_FAILURE
    return 'REPLAY_FAILURE|0|' .. errorType .. '|' .. errorCode
end

-- owner 心跳正常:成为 FOLLOWER_WAIT
local heartbeatAt = tonumber(redis.call('HGET', KEYS[1], 'heartbeatAt'))
if (now - heartbeatAt) <= takeoverDetectMillis then
    return 'FOLLOWER_WAIT|' .. ownerToken
end

-- owner 心跳超时:接管成为 OWNER_TAKEOVER
return 'OWNER_TAKEOVER|' .. token

为什么用 Lua 脚本? 因为"读取状态 → 判断 → 写入新状态"必须是原子的,否则两个节点可能同时读到 PENDING 状态,都认为自己是 owner。Lua 脚本在 Redis 中是单线程执行的,天然保证了原子性。

第三层:Owner 执行与 Follower 等待

Owner 的执行流程:

java 复制代码
private String ownerExecute(String stage, String requestKey, Long ownerToken,
                            Supplier<String> supplier, StageFlightPolicy policy) {
    // 1. 标记 RUNNING
    flightCoordinatorRepository.markRunning(requestKey, nodeId(), ownerToken, runningTtlMillis);

    // 2. 启动心跳(防止长耗时请求被误接管)
    String heartbeatTaskKey = flightHeartbeatManager.start(ownerContext,
        () -> flightCoordinatorRepository.heartbeat(requestKey, nodeId(), ownerToken, runningTtlMillis));

    try {
        // 3. 执行真实 AI 调用
        String result = supplier.get();

        // 4. 序列化并存储结果
        FlightStoredResult storedResult = flightResultSerializer.serialize(result, ownerToken, policy);
        flightCoordinatorRepository.storeResult(requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis);

        // 5. 标记成功
        flightCoordinatorRepository.finishSuccess(requestKey, nodeId(), ownerToken, resultTtlMillis);

        // 6. 通知 follower
        flightNotificationService.publish(requestKey, "owner_succeeded", FlightStatus.SUCCEEDED, ...);

        // 7. 写入本地 L1 缓存
        flightReplayLocalCache.put(stage, requestKey, result, policy);
        return result;
    } catch (Throwable ex) {
        // 失败处理:标记失败 + 通知 follower
        flightCoordinatorRepository.finishFailure(requestKey, nodeId(), ownerToken, ...);
        flightNotificationService.publish(requestKey, "owner_failed", FlightStatus.FAILED, ...);
        throw rethrow(ex);
    } finally {
        flightHeartbeatManager.stop(heartbeatTaskKey);
    }
}

Follower 的等待流程:

java 复制代码
private String followerWait(String stage, String requestKey, StageFlightPolicy policy, long deadlineMillis) {
    while (System.currentTimeMillis() < deadlineMillis) {
        // 1. 先查本地 L1 缓存
        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: " + meta.getErrorCode());
        }

        // 3. 阻塞等待 Redis Stream 通知
        flightNotificationService.waitForTerminalEvent(requestKey, streamBlockTimeoutMillis);
    }
    return null;  // 超时
}

完整状态机

OWNER_NEW / OWNER_TAKEOVER
markRunning
finishSuccess
finishFailure
finishFailure
follower replay
retryable takeover
non-retryable
heartbeat timeout\n(other node takeover)
PENDING
RUNNING
SUCCEEDED
FAILED

状态说明:PENDING 是 owner 刚抢占但还没开始执行;RUNNING 是 owner 正在执行 AI 调用;SUCCEEDED 和 FAILED 是终态。关键的是 FAILED 状态区分了"可重试"和"不可重试"------超时、过载属于可重试,参数校验失败属于不可重试。

边界与陷阱

陷阱一:Leader 挂了怎么办?

后果: follower 永远等不到结果,直到超时。

解法: 心跳机制 + 接管检测。owner 在执行期间定时发送心跳续租,如果心跳超时(takeoverDetectMillis,默认 10 秒),其他节点可以通过 OWNER_TAKEOVER 接管执行。

注意: takeoverDetectMillis 不能设太短。AI 调用可能耗时 5-10 秒,如果设 3 秒就会出现"假死"误判,导致两个节点同时执行。

陷阱二:结果太大撑爆 Redis

后果: AI 返回的面试题 JSON 可能很大,直接存 Redis 占用大量内存。

解法: FlightResultSerializer 在结果超过阈值(默认 4KB)时自动 GZIP 压缩,同时计算 SHA-256 校验和防止数据损坏。解压时校验和不匹配直接丢弃,走降级路径。

陷阱三:Follower 等不到通知

后果: Redis Stream 消息可能丢失,follower 一直阻塞。

解法: 双保险策略------Redis Stream 阻塞等待 + 定时轮询。follower 在等待 Stream 通知的同时,每隔 pollFallbackIntervalMillis(默认 2 秒)主动查询一次 Redis 元数据。

陷阱四:分布式全挂了

后果: Redis 不可用时,分布式单飞完全失效。

解法: HYBRID 模式。分布式协调失败时自动降级到本地单飞,保证基本可用:

java 复制代码
try {
    return executeDistributed(stage, requestKey, supplier);
} catch (RuntimeException ex) {
    if (mode == FlightMode.HYBRID) {
        // 降级到本地单飞
        return localSingleFlightService.execute(requestKey, supplier);
    }
    throw ex;
}

兜底方案:四层防线,逐层降级

单飞锁本身只是"请求合并"这一层,但真实系统中,AI 调用可能因为各种原因失败------模型过载、网络超时、Redis 挂了。如果只有单飞锁,一旦 owner 执行失败,所有 follower 一起跟着倒霉。

所以项目中构建了四层防线,从外到内逐层保护:
获取锁失败
分布式失败
熔断/超时
隔离仓满
请求进入
第1层

会话重锁
第2层

分布式单飞
第3层

Guard 保护
第4层

真实 AI 调用
快速拒绝

AI_OVERLOADED
降级本地单飞

HYBRID 模式
快速失败

AI_TIMEOUT
快速拒绝

AI_OVERLOADED

第 1 层:会话重锁(Session Lock)

java 复制代码
// InterviewAiSessionLockService.java --- 基于 Redisson 的会话级互斥锁
public RLock acquire(String sessionId, String stage) throws InterruptedException {
    RLock lock = redissonClient.getLock("interview:ai:heavy:lock:" + stage + ":" + sessionId);
    boolean acquired = lock.tryLock(0, 45, TimeUnit.SECONDS);  // 不等待,拿不到直接返回
    return acquired ? lock : null;
}

作用: 同一会话的"重操作"互斥。比如用户同时点了"上传简历"和"开始面试",两个操作不能并发执行------否则面试出题和简历解析互相打架。

为什么是"重锁"而不是单飞? 因为这两个操作不是"相同请求",它们是"同一会话的不同操作"。单飞锁只能合并相同 key 的请求,不同操作之间的互斥需要用锁。

兜底策略: tryLock 的等待时间设为 0,拿不到锁立即返回 null,上层直接抛 AI_OVERLOADED。不会让用户傻等。

第 2 层:分布式单飞(Singleflight)

这一层就是前面讲的核心------相同请求合并执行。

兜底策略: HYBRID 模式下,分布式协调失败自动降级到本地单飞:

java 复制代码
try {
    return executeDistributed(stage, requestKey, supplier);
} catch (RuntimeException ex) {
    if (mode == FlightMode.HYBRID) {
        // Redis 挂了?网络抖了?降级到本地单飞,保证基本可用
        return localSingleFlightService.execute(requestKey, supplier);
    }
    throw ex;
}

降级后的影响: 每个节点各自走本地单飞,同一节点内的相同请求仍然合并,但不同节点之间不再协调。Token 成本可能增加,但用户不会感受到服务中断。

第 3 层:Guard 保护(Resilience4j)

单飞锁只管"合并请求",不管"执行保护"。owner 真正执行 AI 调用时,还需要熔断、限流、超时、重试:

java 复制代码
// AiCallGuardService.java --- 装饰顺序:熔断 → 隔离仓 → 重试 → 超时
Callable<T> decorated = CircuitBreaker.decorateCallable(
    circuitBreaker,
    Bulkhead.decorateCallable(
        bulkhead,
        Retry.decorateCallable(
            retry,
            () -> callWithTimeLimiter(stage, action)
        )
    )
);

四重保护机制:

机制 作用 兜底效果
熔断器 失败率超过 50% 时打开熔断 快速失败,不再调用 AI,避免雪崩
隔离仓 限制并发调用数(默认 20) 超出并发直接拒绝,保护 AI 平台
重试 超时/IO 异常自动重试 网络抖动时自动恢复,无需人工干预
超时 强制裁剪超时调用(默认 5 秒) 不会无限等待,释放线程资源

装饰顺序为什么是 熔断→隔离仓→重试→超时?

  • 熔断在最外层:熔断打开后直接拒绝,连隔离仓都不用进
  • 隔离仓在第二层:并发满了直接拒绝,不会触发重试
  • 重试在第三层:只有在隔离仓有空位时才重试
  • 超时在最内层:每次重试都受超时约束

异常分类与兜底:

java 复制代码
// 所有异常统一映射为三类语义,前端可以稳定识别
if (cause instanceof TimeoutException) {
    return AI_TIMEOUT;       // 超时:可重试
} else if (isOverloaded(cause)) {
    return AI_OVERLOADED;    // 过载:稍后重试
} else {
    return AI_UNAVAILABLE;   // 不可用:服务异常
}

第 4 层:真实 AI 调用

最内层是真正的星辰 AI 调用。这一层没有兜底------调用要么成功,要么抛异常被上层 Guard 捕获。

完整调用链路

java 复制代码
// InterviewAiInvoker.java --- 串联所有保护层的统一入口
private String guardedCall(String stage, String singleFlightKey, Callable<String> callable) throws Exception {
    return distributedInterviewAiSingleFlightService.execute(
        safeStage,
        key,
        () -> aiCallGuardService.execute(safeStage, key, callable)  // 单飞锁内嵌 Guard 保护
    );
}

调用链路:

复制代码
业务层(面试出题/答案评分/神态分析)
  │
  ├─ InterviewAiSessionLockService.acquire()     ← 第1层:会话重锁
  │     └─ 拿不到锁 → AI_OVERLOADED
  │
  └─ InterviewAiInvoker.guardedCall()            ← 第2-4层
        │
        ├─ DistributedInterviewAiSingleFlightService.execute()  ← 第2层:分布式单飞
        │     ├─ [分布式协调失败] → 降级本地单飞
        │     ├─ [OWNER] → 执行 supplier
        │     ├─ [FOLLOWER] → 等待结果
        │     └─ [REPLAY] → 直接复用
        │
        └─ AiCallGuardService.execute()           ← 第3层:Guard 保护
              ├─ [熔断打开] → AI_UNAVAILABLE
              ├─ [隔离仓满] → AI_OVERLOADED
              ├─ [超时] → AI_TIMEOUT + 自动重试
              └─ [全部失败] → 抛出 InterviewAiGuardException
                    │
                    └─ XingChenAIClient.chat()     ← 第4层:真实调用

兜底策略汇总

故障场景 兜底策略 用户感知
同一会话并发重操作 会话重锁互斥 提示"AI 正在处理,请稍后"
相同请求并发 单飞锁合并 无感知,直接复用结果
Redis 不可用 HYBRID 降级到本地单飞 无感知,Token 成本可能增加
AI 平台过载 隔离仓拒绝 + 熔断 提示"AI 服务繁忙,请稍后"
AI 调用超时 超时裁剪 + 自动重试 可能稍慢,但不会无限等待
AI 平台持续故障 熔断器打开 快速失败,不再等待
Owner 节点挂了 心跳超时 + 接管 Follower 等待时间稍长
结果存储失败 finishSuccess 失败 → 尝试 replay 降级到重新执行

一句话总结: 单飞锁解决"合并",Guard 解决"保护",Session Lock 解决"互斥",三层配合才能构成完整的兜底体系。只做单飞不做保护,等于把所有鸡蛋放在一个篮子里------owner 一挂,全盘皆输。

高级考量:三层缓存架构

实际的请求复用不是"要么执行要么等待"这么简单,而是一个三层缓存架构:
命中
未命中
SUCCEEDED
PENDING/RUNNING
无记录
收到通知
执行完成
请求进入
L1 本地缓存
直接返回
Redis 元数据
读取 Redis 结果
Follower 等待
Owner 执行
写入 L1 缓存
写入 Redis 结果
通知 Follower
写入 L1 缓存

层级 存储 命中场景 TTL
L1 本地缓存 FlightReplayLocalCache(LRU LinkedHashMap) 同一节点短时间内重复请求 15-30 秒
L2 Redis 结果 ai:flight:result:*(Hash) 不同节点同时请求 3-10 分钟
L3 真实执行 AI 调用 缓存全部未命中 -

为什么需要 L1? follower 从 Redis 读取结果后写入本地缓存,后续同一节点的请求直接命中 L1,省掉一次 Redis 查询。在面试场景中,用户可能短时间内多次查看同一题的评分结果,L1 缓存能显著降低 Redis 压力。

对比表格

方案 核心思路 优点 缺点 适用场景
分布式锁 互斥,同一时刻只有一个请求执行 实现简单 串行执行,不合并结果,等待时间长 写操作、必须串行的场景
本地单飞 进程内请求合并 零外部依赖,延迟极低 多节点各自执行,合并效果有限 单节点部署、低并发
分布式单飞(本项目) 集群级请求合并 + 结果复用 跨节点合并,Token 成本最优 依赖 Redis,实现复杂 AI 调用等高成本、可复用场景
缓存 先查缓存,命中直接返回 最简单 缓存失效前无法更新 数据变化频率低的场景

选型建议: 如果你的场景是"高成本调用 + 短时间窗口内相同请求多",选分布式单飞。如果只是"防止重复提交",分布式锁就够了。

面试追问

追问 1:单飞锁和缓存有什么区别?什么时候用单飞锁,什么时候用缓存?

→ 回答方向:单飞锁解决的是"正在执行中的请求合并",缓存解决的是"已执行完的结果复用"。单飞锁的窗口是请求执行期间(几秒到几十秒),缓存的窗口是 TTL 期间(分钟到小时)。如果结果在短时间内会变化(比如 AI 生成内容每次可能不同),用单飞锁更合适------它保证同一时刻只执行一次,但不缓存历史结果太久。

追问 2:如果 leader 执行了 30 秒还没返回,follower 怎么知道是"还在执行"还是"已经挂了"?

→ 回答方向:心跳机制。leader 在执行期间每 3 秒发送一次心跳续租,follower 通过检查 heartbeatAt 时间戳判断 leader 是否存活。如果 now - heartbeatAt > takeoverDetectMillis(默认 10 秒),follower 可以发起接管。这也是为什么 takeoverDetectMillis 不能设太短------要给 AI 调用留足执行时间。

追问 3:为什么用 Redis Lua 脚本而不是 Redisson 分布式锁?

→ 回答方向:Redisson 锁是互斥语义------同一时刻只有一个线程持有锁。但单飞锁需要的是"第一个执行,其余复用结果"的语义,用锁来实现的话,follower 拿到锁后还是要重新执行,达不到合并请求的目的。Lua 脚本可以在一次原子操作中完成"读状态 → 判断 → 写状态",直接返回 OWNER/FOLLOWER/REPLAY 三种角色,更贴合单飞锁的语义。

追问 4:HYBRID 模式下,分布式失败降级到本地单飞,会不会导致多个节点同时执行?

→ 回答方向:会。HYBRID 模式的降级是"宁可多执行也不阻塞"的策略。降级后每个节点各自走本地单飞,同一节点内的相同请求仍然会合并,但不同节点之间不再协调。这是可用性优于一致性的权衡------Redis 挂了的时候,保证用户还能正常使用比省几个 Token 更重要。

总结

分布式单飞锁的核心思想是:相同请求只执行一次,结果广播给所有等待者。

读完这篇你应该能:

  • 解释单飞锁和分布式锁的本质区别(合并 vs 互斥)
  • 理解 Owner/Follower 协调的完整状态机
  • 在自己的项目中根据场景选择 LOCAL / DISTRIBUTED / HYBRID 三种模式
  • 说出心跳、接管、降级这三个关键容错机制的设计动机
相关推荐
会编程的土豆9 小时前
Kafka 零基础入门(最基本用法)
分布式·kafka
会编程的土豆10 小时前
Kafka 入门笔记(核心语法 + 用法)
笔记·分布式·kafka
song50110 小时前
多模态模型在昇腾上的部署架构
人工智能·分布式·深度学习·架构·transformer·交互
过期动态10 小时前
【RabbitMQ高级篇】生产者可靠性、MQ可靠性、消费者可靠性以及延迟队列的实现
java·数据结构·分布式·算法·rabbitmq·ruby
心中有国也有家21 小时前
hccl 架构拆解:昇腾集合通信库到底在做什么?
人工智能·经验分享·笔记·分布式·算法·架构
188105069631 天前
摸鱼事务所——团队作业——大模型评测作业
大数据·hadoop·分布式
大连赵哥1 天前
分布式文件存储系统:Hadoop HDFS
hadoop·分布式·hdfs
不爱编程的小陈1 天前
从存储引擎到文件系统:用FUSE将分布式KV挂载为本地目录
分布式
song5011 天前
对话:模型推理慢,怎么调
人工智能·分布式·深度学习·transformer·交互