大家好,我是程序员小策。
你有没有遇到过这种场景------用户手抖连点三次"提交",前端重试机制发了 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 的原子性,避免瞬时并发出现多个 leaderCompletableFuture作为 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 三种模式
- 说出心跳、接管、降级这三个关键容错机制的设计动机