万字详解Single-flight(单飞模式)

前言

这篇文章讲解single-flight相关知识,并通过牛肉哥项目的AI-meeting来加深对这个知识点的印象。

Single-flight(单飞模式)是一种并发请求合并技术 ,它的核心思想是:当多个 goroutine / 线程同时请求同一个资源时,确保只有一个 goroutine / 线程真正执行实际操作,其他所有请求都等待并共享这个结果。

典型应用场景除了最核心的防缓存击穿,它还广泛适用于以下场景:

1.防止昂贵的重复计算:对于 CPU 密集型任务,当大量并发请求需要计算同一复杂规则时,可借此避免重复消耗算力。

2.外部 API 调用保护与限流:当大量并发请求触发访问同一个收费或有严格频率限制的第三方外部接口时,将其合并为一个请求发出,有效节省调用配额并防止被远端限流。

缓存击穿

回顾情景:一个访问量很高的热点 key 失效后,大量请求同时查数据库

之前我在文章讲到一个比较通用的解决方案:

https://blog.csdn.net/2502_94242477/article/details/166690551?fromshare=blogdetail\&sharetype=blogdetail\&sharerId=166690551\&sharerefer=PC\&sharesource=2502_94242477\&sharefrom=from_link

互斥锁or逻辑过期缓存:

如果请求需要尽量新的数据,我会用互斥锁,让一个请求负责重建,其他请求等待后重试。如果业务允许短时间旧数据,我会预热逻辑过期缓存:过期后先返回旧值,只让一个后台任务刷新。两种方案的取舍点是等待时间与数据时效。

single-flight实现:

只让一个请求(key)去实现(owner),其他相同的key(Follower)等待,实现获得的结果同时返回给全部相同的请求(广播).

但有以下缺陷:
只在一个 JVM 里有效

如果服务部署了 20 个副本,每个副本各自合并一次,击穿时的数据库压力不是 1 次,是 20 次。更隐蔽的是过期时间。20 个副本的本地 TTL 互不相同,一个热点 key 一天里会在 20 个不同时刻分别过期,每次都是一场小型击穿。数据库看到的不是一个大尖峰,而是散落在一天里的 20 个小尖峰。

所以单 JVM 的 single-flight 只是"每个副本自己削峰",峰值被削成 1/副本数,并没有被消除。

leader 失败,就变成所有人失败

假设 leader 查库那一刻数据库抖了一下,超时了。结果不是"这一个请求失败",而是 100 个请求同时收到同一个异常。不用 single-flight 的话,这 100 个请求各查各的,可能 90 个成功、10 个失败;用了之后,成功率从 90% 掉到 0%。

更糟的是它们在同一刻被唤醒,所以在同一刻重试。原本分散的 100 个请求,现在变成整齐划一的 100 个,第二波比第一波更集中。

所以单飞必须配三样东西:失败不要原样广播(用 stale 值兜底)、重试加随机抖动、超时后允许别的请求重新竞选 leader。

leader 一卡住,整台机器跟着卡

这一条才是真正把局部问题变成全局事故的地方,也是单 JVM 最要命的一点。

在 Spring MVC、Tomcat 这种同步模型里,follower 的"原地等待"是实打实占着一个工作线程。Tomcat 默认 200 个线程,如果一个热 key 的 leader 查库卡了 5 秒,那 99 个 follower 就把 99 个线程钉在那里 5 秒。这时别的接口进来,发现没有线程可用,它们也开始排队。

于是监控上看到的不是"某个缓存 key 变慢",而是"整台机器所有接口超时"。一个 key 的问题,升级成整个实例不可用。

原因很简单:线程池是全实例共享的,它不区分你是哪个 key。要缓解无非三条------等待必须设超时、把等待改成不占线程的异步、给热 key 单独的线程池。

几个实现上的坑

用ConcurrentHashMap.computeIfAbsent 手搓。 它的加载函数是在桶锁里跑的,一个慢 key 会把哈希到同一个桶的其他 key 一起堵住;加载函数里不能再操作这个 map,否则直接抛异常或死锁;加载过程中如果又查了同一个 key,就是自己等自己,永远出不来。

in-flight 记录忘了删。 异常分支没清掉那条记录,后面所有请求都会拿到那个已经失败的 Future------不是变慢,是永久性故障,而且这个 map 会一直涨。

共享的是同一个对象引用。 100 个 follower 拿到的不是 100 份数据,是同一个 List、同一个实体。谁中途改一下,所有人的数据都变了。

无法应对集群扩展

单 JVM 的单飞机制只在当前进程内有效。如果你的服务未来为了抗流,从单 JVM 扩展到了 10 台机器的集群。在同一时刻,10 台机器上会分别有一个线程去冲击数据库。虽然每台机器做到了单飞,但对于后端 DB 来说,依然承受了 10×110 \times 110×1 的并发压力,因此就要引出分布式的单飞机制。

那到底该怎么用

单 JVM 内不要手写,直接用 Caffeine 的get(key, k -> load()) 或 Guava 的LoadingCache ,单飞是内建的。

严格设置 Future 的 Get 超时时间:

绝对不要使用无限等待的 future.get(),而是使用 future.get(500, TimeUnit.MILLISECONDS)。一旦超时,快速降级(返回熔断数据或旧缓存),防止耗尽 JVM 线程池。

结合异步刷新(Refresh After Write):

让老数据先返回给用户,后台启动一个异步线程去单飞加载新数据。这样用户线程永远不会阻塞,彻底根治线程木僵和超时问题

项目里的single-flight实现

为什么要用这个技术:

1.ai接口资源宝贵,不希望重复调用

2.防止用户连续点击,同一时刻打多个请求

单single-flight

价值:

第一,减少同一节点上的重复模型调用;

第二,减少线程池和外部 AI 服务的瞬时压力;

第三,避免同一份请求在同机并发下返回多份不同 AI 结果,造成解析和流程推进抖动。

它的工作方式很直接:内存里维护一个 ConcurrentMap<key, FlightEntry>,FlightEntry 里放一个 CompletableFuture。第一个请求进来时创建 future 并真正执行 supplier;后面的相同 key 请求不再执行,而是等待这个 future 完成,然后直接复用结果或异常。

详细代码实现:InterviewAiSingleFlightService

细节注意:

用map封装请求: key--请求指纹 value--FlightEntry,里面保存这次请求的 future 和过期时间

java 复制代码
private record FlightEntry(CompletableFuture<Object> resultFuture, long expireAtMillis) {  
}

后面的都是具体的逻辑细节

但项目中单single-flight的实现仅在混合状态下出现本地降级时候实现,进行一个兜底作用

最重要的问题:因为负载均衡重复请求会打到多台机器,而机器之间并不进行互通,所以需要分布式single-flight,也就是我们项目实现的主要方案

他解决的问题就是跨机器进行的问题去重,可以看做专门解决负载均衡的实现

接下来,我会根据singleflight相关的链路图,来讲解代码和知识点

流程图

当一个请求到达,我们先组装key,里面包含着stage

这个key不会很详细:详细会导致相同内容容易区分

也不会很简单:分辨不出来

Plain 复制代码
{stage}|{sessionId}|{questionNumberOrBusinessPart}|{answerHash}

第二步:分析他是否为重任务

去找是否为简历抽题和神态分析这种重型 AI 场景,这种重任务加session锁,来阻止他并发

这个锁本质上是一层基于Redisson 的 sessionId + stage 级分布式互斥锁。

为什么不用single-flight?

第一:Single-Flight 的本质不是"互斥",而是"同语义请求去重 + 结果复用"。我们要的只是重任务不要并发执行,而不是追求结果复用

第二:key不好分

为什么不用乐观锁?

重试成本极高。CAS 失败要重试,重试就得重跑整个 AI 调用:再等 60 秒、再花一次配额。乐观锁在数据库里重试是微秒级,在这里重试是分钟级加真金白银。冲突概率再低,代价也承受不起。

第三步:进入分布式single-flight:

DistributedInterviewAiSingleFlightService

第四步:先查缓存,有没有,没有再往下走

FlightReplayLocalCache

说白了它就是个 Map,key 是stage|requestKey ,value 是结果字符串 + 一个过期时间戳。存进去的东西 不会自己消失 ------得有人来拿的时候发现过期了才顺手删掉;没人来拿就一直躺着占内存,直到塞满 1000 条,再把最久没人拿的那条挤出去。请求来了先问它有没有:有并且没过期,直接把结果给他,连 Redis 都不用碰;没有或者过期了,就让上层去走acquireOrJoin 那一套。等从 Redis 捞到结果,再顺手塞回来一份。

stage开关:

注意这里神态的stage开关是关闭的,因为神态容易变化.其他是开的,比如评估

只管put ,不管get :

java 复制代码
public synchronized void put(..., policy) {
    if (StrUtil.isBlank(stage) || StrUtil.isBlank(requestKey) || value == null || policy == null|| !Boolean.TRUE.equals(policy.getL1CacheEnabled())) {
        return;    // 开关关着 → 静默丢弃,什么都不写
    }`
环节 开(evaluation / followup / extraction) 关(demeanor)
owner 执行完成 结果写入本机 Map,同节点后续同 key 请求直接命中,零 Redis 访问 结果只写 Redis,本机不留副本,后续请求必须重走 acquireOrJoin
follower 从 Redis 取到结果 顺手 put 到本机,下次命中不再访问 Redis 取到就返回,不写本机
单次请求的 Redis 成本 首次走完整流程,之后 L1 命中直返(0 次) 每次都至少 1 次 Lua,通常 3 次:acquireOrJoin / getMeta / getStoredResult
反序列化成本 命中时没有(存的是最终 String) 每次都要 deserialize 并解压
结果一致性 最长滞后一个 L1 TTL(30s / 15s / 60s) 始终以 Redis 为准
内存占用 每条结果常驻堆内,LRU 上限 1000 条 不占本机堆
维度 开的作用 关的作用
性能 减少 Redis 往返与反序列化,重复请求直接返回 无收益,反而多打 Redis
一致性 牺牲最长一个 TTL 的实时性,可能返回旧结果 每次读权威状态,不会拿到滞后副本
资源 占用本机堆内存 省堆内存,适合结果体积大的场景
适用场景 key 重复率高、结果小、时效要求低 key 分散、结果大、时效要求高

1.通过Redis Lua抢身份

本地 L1 缓存未命中后,当前节点调用 acquireOrJoin。这一步由 Redis Lua 脚本原子完成:读取请求状态、判断当前节点该做什么,并在需要时更新记录。

判定结果有五种:

结果 触发条件 当前节点的动作
OWNER_NEW Redis 中没有该请求记录 创建 PENDING 记录、分配 ownerToken,成为 owner
OWNER_TAKEOVER 失败记录允许重试,或原 owner 心跳超时 更新 owner 和 ownerToken,接管请求
FOLLOWER_WAIT 请求仍在运行,且 owner 心跳正常 不调用模型,等待结果
REPLAY_SUCCESS 请求已经成功 读取并返回已保存的结果
REPLAY_FAILURE 请求失败且不可重试 返回记录的失败信息

只有拿到 OWNER_NEW 或 OWNER_TAKEOVER 的节点会继续执行模型调用。失败重试和心跳超时都会分配新的 ownerToken,用来阻止旧 owner 迟到写入。

!\[Pasted image 20261103110020.png\|644]

Java 侧只负责拼参数( L120-L139 ):

java 复制代码
long now = System.currentTimeMillis();
String raw = stringRedisTemplate.execute(
        ACQUIRE_OR_JOIN_SCRIPT,
        List.of(metaKey(requestKey), OWNER_SEQ_KEY),       // ai:flight:meta:{key}, ai:flight:owner-seq
        stage, ownerId, requestKey, sessionId,
        String.valueOf(now),                               // ARGV[5] 当前时间
        String.valueOf(now + runningTtlMillis),            // ARGV[6] expireAt
        String.valueOf(runningTtlMillis),                  // ARGV[7] TTL 15s
        String.valueOf(policy.getTakeoverDetectMillis())   // ARGV[8] 失活阈值 10s
);
return parseAcquireResult(raw);

返回值解析( L258-L277 ):按| 切分,动作名 + token,REPLAY_FAILURE 额外带 retryable/errorType/errorCode。

复制代码
local status = redis.call('HGET', KEYS[1], 'status')

-- A:从没跑过 → 我当 owner
if not status then
  local token = redis.call('INCR', KEYS[2])
  redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
             'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
             'createdAt',ARGV[5], 'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5],
             'expireAt',ARGV[6], 'retryable','0')
  redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
  return 'OWNER_NEW|' .. token
end

-- B:已成功 → 回放
if status == 'SUCCEEDED' then return 'REPLAY_SUCCESS|' .. status end

-- C:失败过,拆两条路
if status == 'FAILED' then
  local retryable = redis.call('HGET', KEYS[1], 'retryable') or '0'
  if retryable == '1' then
    local token = redis.call('INCR', KEYS[2])
    redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
               'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
               'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5], 'expireAt',ARGV[6],
               'errorType','', 'errorCode','', 'retryable','0')
    redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
    return 'OWNER_TAKEOVER|' .. token
  end
  local errorType = redis.call('HGET', KEYS[1], 'errorType') or ''
  local errorCode = redis.call('HGET', KEYS[1], 'errorCode') or ''
  return 'REPLAY_FAILURE|0|' .. errorType .. '|' .. errorCode
end

-- C3:取消/过期,文档里没写但代码里有
if status == 'CANCELLED' or status == 'EXPIRED' then
  return 'REPLAY_FAILURE|1|' .. status .. '|STATE'
end

-- D:心跳新鲜 → 我只能等
local heartbeatAt = tonumber(redis.call('HGET', KEYS[1], 'heartbeatAt') or '0')
local takeoverDetectMillis = tonumber(ARGV[8])
if heartbeatAt > 0 and (tonumber(ARGV[5]) - heartbeatAt) <= takeoverDetectMillis then
  redis.call('HINCRBY', KEYS[1], 'followerCount', 1)
  return 'FOLLOWER_WAIT|' .. (redis.call('HGET', KEYS[1], 'ownerToken') or '')
end

-- E:心跳超阈值 → 抢过来,换新 token
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
           'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
           'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5], 'expireAt',ARGV[6],
           'errorType','', 'errorCode','', 'retryable','0')
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
return 'OWNER_TAKEOVER|' .. token

2.Redis Lua 脚本原子返回解析

先走FlightCoordinatorRepository.acquireOrJoin :它内部会执行 ACQUIRE_OR_JOIN_SCRIPT_TEXT,根据当前 Redis 里的 meta 状态,返回一段字符串

然后Java 代码再通过 parseAcquireResult 把它解析成 FlightAcquireResult

复制代码
public FlightAcquireResult acquireOrJoin(String stage, String requestKey, String ownerId, String sessionId,  
                                         InterviewAiSingleFlightConfiguration.StageFlightPolicy policy) {  
    long now = System.currentTimeMillis();  
    long runningTtlMillis = policy == null || policy.getRunningTtlMillis() == null || policy.getRunningTtlMillis() <= 0  
            ? 15000L  
            : policy.getRunningTtlMillis();  
    String raw = stringRedisTemplate.execute(  
            ACQUIRE_OR_JOIN_SCRIPT,  
            List.of(metaKey(requestKey), OWNER_SEQ_KEY),  
            StrUtil.blankToDefault(stage, "interview-default"),  
            StrUtil.blankToDefault(ownerId, "unknown-node"),  
            StrUtil.blankToDefault(requestKey, "no-key"),  
            StrUtil.blankToDefault(sessionId, "no-session"),  
            String.valueOf(now),  
            String.valueOf(now + runningTtlMillis),  
            String.valueOf(runningTtlMillis),  
            String.valueOf(policy == null ? 10000L : policy.getTakeoverDetectMillis())  
    );  
    return parseAcquireResult(raw);  
}

3.根据不同状态走不同链路

FlightAcquireResult根据不同的Stage来执行不同的action

这涉及到了stage和action,简单来说:用switch case来判断stage状态对应的具体action行为

复制代码
switch (acquireResult.getAction()) {
    case OWNER_NEW, OWNER_TAKEOVER ->
            return ownerExecute(safeStage, safeRequestKey, acquireResult.getOwnerToken(), supplier, policy);
    case REPLAY_SUCCESS -> {
        String replay = tryReadSuccessReplay(safeStage, safeRequestKey, policy);
        if (replay != null) { return replay; }
        // 读不到 → 不 return,掉到循环底部,下一轮重新 acquire
    }
    case REPLAY_FAILURE -> throw replayFailure(acquireResult);
    case FOLLOWER_WAIT -> {
        String followerReplay = followerWait(safeStage, safeRequestKey, policy, deadline);
        if (followerReplay != null) { return followerReplay; }
        // 等超时 → 同样掉到循环底部
    }
    default -> { return localSingleFlightService.execute(safeRequestKey, supplier); }
}
一、owner 侧
状态一 OWNER_NEW:没人干,你是第一个

1.情况:Redis 里根本没有 ai:flight:meta:{requestKey} 这条记录,说明这个请求从来没人跑过。当前节点是第一个到达的

2.实现:写一条 PENDING 记录,把自己登记成 ownerId,把 ownerToken 和 heartbeatAt 一起写进去,设置 15 秒 TTL,然后把 token 带回 Java 侧进入 ownerExecute执行 supplier。

lua 复制代码
local status = redis.call('HGET', KEYS[1], 'status')
if not status then
  local token = redis.call('INCR', KEYS[2])
  redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
             'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
             'createdAt',ARGV[5], 'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5],
             'expireAt',ARGV[6], 'retryable','0')
  redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
  return 'OWNER_NEW|' .. token
end
状态二 OWNER_TAKEOVER:你不是第一个,但前面有点问题,你去替他干

1.情况:

第一种是 status 为 FAILED 且 retryable 等于 1,上一次失败属于超时、过载、上游不可用这类可恢复错误。

第二种是记录存在但心跳 heartbeatAt 距离当前时间超过 takeoverDetectMillis,默认 10 秒,说明原 owner 已经挂掉、卡死或者网络失联,属于疑似失活。

2.实现:

生成一个新 token 让旧 token 立刻失效,把记录重置回 PENDING,把 ownerId 换成自己,刷新 heartbeatAt 和 expireAt,续 15 秒 TTL,清空上一轮留下的内容。区别只在触发条件,后续执行完全一样,所以 Java 侧 switch 里和 OWNER_NEW 合并成同一个 case。

lua 复制代码
-- 场景1:上一次失败且可重试
if status == 'FAILED' then
  local retryable = redis.call('HGET', KEYS[1], 'retryable') or '0'
  if retryable == '1' then
    local token = redis.call('INCR', KEYS[2])
    redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
               'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
               'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5], 'expireAt',ARGV[6],
               'errorType','', 'errorCode','', 'retryable','0')
    redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
    return 'OWNER_TAKEOVER|' .. token
  end
end
-- 场景2:心跳超过 10 秒没更新,认定 owner 已死
local heartbeatAt = tonumber(redis.call('HGET', KEYS[1], 'heartbeatAt') or '0')
if heartbeatAt > 0 and (tonumber(ARGV[5]) - heartbeatAt) <= tonumber(ARGV[8]) then
  redis.call('HINCRBY', KEYS[1], 'followerCount', 1)
  return 'FOLLOWER_WAIT|' .. (redis.call('HGET', KEYS[1], 'ownerToken') or '')
end
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status','PENDING', 'stage',ARGV[1], 'ownerId',ARGV[2],
           'ownerToken',token, 'requestKey',ARGV[3], 'sessionId',ARGV[4],
           'updatedAt',ARGV[5], 'heartbeatAt',ARGV[5], 'expireAt',ARGV[6],
           'errorType','', 'errorCode','', 'retryable','0')
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[7]))
return 'OWNER_TAKEOVER|' .. token
Fencing token:如果旧的owner突然恢复,那新的怎么办
什么是 Fencing token

可以理解成一个只增不减的版本号,类似银行排队机取的号,窗口永远只叫最新那个号,过号就作废。每次有人抢到 owner 身份,就领一个比上一任更大的号,meta 里只记录当前有效的号。谁手里的号跟 meta 里的对不上,谁就已经过期,后续所有操作一律拒绝。

项目实现

第一步,发号。每次抢占成功都用 INCR ai:flight:owner-seq 生成新号,这个 key 独立于 meta,meta 过期重建也不会让号回退,保证永远单调递增。

lua 复制代码
local token = redis.call('INCR', KEYS[2])
redis.call('HSET', KEYS[1], 'status','PENDING', 'ownerId',ARGV[2], 'ownerToken',token, ...)
return 'OWNER_TAKEOVER|' .. token

第二步,验号。 markRunning、heartbeat、storeResult、finishSuccess、finishFailure 五个写脚本开头都做同一件事,ownerId 和 ownerToken 必须和 meta 里完全相等,否则直接 return 0 不写。

lua 复制代码
if redis.call('HGET', KEYS[1], 'ownerId') ~= ARGV[1] then return 0 end
if redis.call('HGET', KEYS[1], 'ownerToken') ~= ARGV[2] then return 0 end

第三步,处理验号失败。旧 owner 恢复后写结果会拿到 0,storeResult 失败直接抛异常,finishSuccess 失败则先尝试读一次别人的回放结果,读不到才抛。而新 owner 那边完全无感,它的写操作在 Redis 单线程里原子通过,不会被打断也不会被覆盖。

java 复制代码
if (!flightCoordinatorRepository.storeResult(requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis)) {
    throw new IllegalStateException("failed to store distributed flight result");
}
if (!flightCoordinatorRepository.finishSuccess(requestKey, nodeId(), ownerToken, resultTtlMillis)) {
    String replay = tryReadSuccessReplay(stage, requestKey, policy);
    if (replay != null) { return replay; }
    throw new IllegalStateException("failed to finish distributed flight success state");
}

一句话总结:号只增不减,写入必验号,验不过就不写,于是旧 owner 恢复得再突然也只是一次无害的失败。

owner 内部细节
环节一:markRunning 二次校验

需求:

acquireOrJoin 只是抢到了 token,但从 Lua 返回到 Java 真正开工之间还有时间窗口,可能已经被别的节点用更新的 token 接管。所以动手前必须再验一次身份。

实现:

校验 ownerId 相等、ownerToken 相等、status 仍为 PENDING 或 RUNNING,三条全过才把 PENDING 推进成 RUNNING 并续 15 秒 TTL。任何一条不过就返回 0,当前节点当场降级成 follower,绝不调模型。

代码:

java 复制代码
boolean markedRunning = flightCoordinatorRepository.markRunning(
        requestKey, nodeId(), ownerToken, runningTtlMillis);
if (!markedRunning) {
    return followerWait(stage, requestKey, policy,
            System.currentTimeMillis() + resolveFollowerMaxWaitMillis());
}
lua 复制代码
if redis.call('HGET', KEYS[1], 'ownerId') ~= ARGV[1] then return 0 end
if redis.call('HGET', KEYS[1], 'ownerToken') ~= ARGV[2] then return 0 end
local status = redis.call('HGET', KEYS[1], 'status')
if status ~= 'PENDING' and status ~= 'RUNNING' then return 0 end
redis.call('HSET', KEYS[1], 'status','RUNNING', 'updatedAt',ARGV[3],
           'heartbeatAt',ARGV[3], 'expireAt',ARGV[4])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[5]))
return 1
环节二:心跳续租

需求:

模型调用可能耗时挺长时间,但我们又怕他跑着跑着死掉了,所以 meta 的 TTL 只有 15 秒.我们就需要通过heart续约来告诉其他人我还活着

实现:FlightHeartbeatManager

用 ScheduledExecutorService 每 3 秒跑一次 heartbeat,每次执行也是 ownerId、ownerToken、status 三重校验,通过后刷新 heartbeatAt 和 expireAt,并把 TTL 重新续到 15 秒。任务 key 用 requestKey 加 ownerToken 拼接,保证不同轮次的 owner 不会互相覆盖任务句柄,收尾时在 finally 里 stop。

代码:

java 复制代码
String heartbeatTaskKey = flightHeartbeatManager.start(
        ownerContext,
        () -> flightCoordinatorRepository.heartbeat(requestKey, nodeId(), ownerToken, runningTtlMillis)
);
java 复制代码
long intervalMillis = Math.max(500L, policy.getHeartbeatIntervalMillis());
ScheduledFuture<?> future = scheduledExecutorService.scheduleAtFixedRate(
        () -> heartbeatAction.getAsBoolean(), intervalMillis, intervalMillis, TimeUnit.MILLISECONDS);
futures.put(taskKey, future);
lua 复制代码
if redis.call('HGET', KEYS[1], 'ownerId') ~= ARGV[1] then return 0 end
if redis.call('HGET', KEYS[1], 'ownerToken') ~= ARGV[2] then return 0 end
if redis.call('HGET', KEYS[1], 'status') ~= 'RUNNING' then return 0 end
redis.call('HSET', KEYS[1], 'updatedAt',ARGV[3], 'heartbeatAt',ARGV[3],
           'expireAt',ARGV[4], 'lastHeartbeatNode',ARGV[1])
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[5]))
return 1

Bug解决实现:但如果他在业务中卡住了怎么办?我们可以设置一个上次业务推进的工作时间,如果这个时间太长,我们就认为他卡住了,叫新的owner来替换,如果是流式调用,我们只需要检查chunk即可.如果非流式/黑盒,那就只能看时间了.

环节三:本地降级策略

情况:

  • 本来就不走分布式,直接走本地
  • 分布式执行抛异常,在HYBRID模式下回退本地
  • 分布式内部遇到异常结果,直接走本地
    实现:
    在 HYBRID 模式下,如果分布式协调链路抛异常、返回非法动作或无法稳定裁决,就自动回退到本机 single-flight,继续用 ConcurrentHashMap + CompletableFuture 做单节点内的请求合并
    代码:
java 复制代码
    public <T> T execute(String key, Supplier<T> supplier) {
        Objects.requireNonNull(supplier, "supplier cannot be null");
        if (!Boolean.TRUE.equals(configuration.getEnable()) || StrUtil.isBlank(key)) {
            meterRegistry.counter("ai_singleflight_miss_total").increment();
            return supplier.get();
        }

        long now = System.currentTimeMillis();
        long ttlMillis = resolveTtlMillis();
        AtomicBoolean newFlight = new AtomicBoolean(false);
        // compute 保证同 key 下"创建 flight + 复用 flight"原子化,避免瞬时并发下出现多个 leader。
        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()) {
            meterRegistry.counter("ai_singleflight_miss_total").increment();
            try {
                // leader 执行真实调用,并把结果广播给同 key 的等待者。
                T value = supplier.get();
                entry.resultFuture.complete(value);
                return value;
            } catch (Throwable ex) {
                entry.resultFuture.completeExceptionally(ex);
                flights.remove(key, entry);
                throw ex;
            } finally {
                cleanupExpired(now);
            }
        }

        meterRegistry.counter("ai_singleflight_hit_total").increment();
        try {
            @SuppressWarnings("unchecked")
            T reused = (T) entry.resultFuture.get(resolveWaitTimeoutMillis(), TimeUnit.MILLISECONDS);
            return reused;
        } catch (TimeoutException ex) {
            // waiter 超时后主动剔除旧 flight,避免后续请求持续等待一个可能已失活的 future。
            flights.remove(key, entry);
            throw new CompletionException(new RejectedExecutionException("single-flight wait timeout", ex));
        } catch (InterruptedException ex) {
            Thread.currentThread().interrupt();
            throw new CompletionException(ex);
        } catch (ExecutionException ex) {
            throw rethrow(ex.getCause());
        }
    }
环节四:成功收尾

情况:

supplier.get() 正常返回,模型结果拿到了,需要把它变成 cluster 可见的终态。

实现:

严格执行三步且顺序不能换。第一步序列化并 storeResult 把结果写进 ai:flight:result:{requestKey},TTL 600 秒。第二步 finishSuccess 把 meta 改成 SUCCEEDED 并把 TTL 续到 600 秒。第三步 publish 往 Redis Stream 写 owner_succeeded 唤醒所有 follower。最后写本地 L1 并返回。顺序颠倒会出现 follower 看到 SUCCEEDED 却读不到结果的时间窗口。若 finishSuccess 返回 false,说明 token 已被别人换掉,此时不直接报错,先借用回放逻辑读一次,读到了就返回。

代码:

复制代码
String result = supplier.get();
FlightStoredResult storedResult = flightResultSerializer.serialize(result, ownerToken, policy);
long resultTtlMillis = positive(policy.getResultTtlMillis(), 600000L);
if (!flightCoordinatorRepository.storeResult(requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis)) {
    throw new IllegalStateException("failed to store distributed flight result");
}
if (!flightCoordinatorRepository.finishSuccess(requestKey, nodeId(), ownerToken, resultTtlMillis)) {
    String replay = tryReadSuccessReplay(stage, requestKey, policy);
    if (replay != null) { return replay; }
    throw new IllegalStateException("failed to finish distributed flight success state");
}
flightNotificationService.publish(requestKey, "owner_succeeded", FlightStatus.SUCCEEDED, ownerToken, null, false);
flightReplayLocalCache.put(stage, requestKey, result, policy);
return result;`
环节五:失败收尾

情况:

supplier.get() 抛出异常,owner 必须留下失败结论,而且要把能不能重试这个信息写进 Redis,供下一个请求判断走接管还是直接回放失败。

实现:

先 classifyFailure 归类异常拿到 errorType、errorCode、retryable,再 finishFailure 把 meta 写成 FAILED 并记录这三个字段,TTL 60 秒,然后 publish owner_failed 通知 follower,最后原样把异常抛给调用方。无论成败 finally 都要停心跳。

代码

java 复制代码
} catch (Throwable ex) {
    FlightFailure failure = classifyFailure(ex);
    flightCoordinatorRepository.finishFailure(requestKey, nodeId(), ownerToken,
            failure.errorType, failure.errorCode, failure.retryable,
            positive(policy.getFailedResultTtlMillis(), 60000L));
    flightNotificationService.publish(requestKey, "owner_failed", FlightStatus.FAILED, ownerToken, failure.errorType, failure.retryable);
    throw rethrow(ex);
} finally {
    flightHeartbeatManager.stop(heartbeatTaskKey);
}
java 复制代码
if (cause instanceof InterviewAiGuardException guardException) {
    return switch (guardException.getErrorCode()) {
        case AI_TIMEOUT -> new FlightFailure(FlightErrorType.TIMEOUT, guardException.getErrorCode().name(), true);
        case AI_OVERLOADED -> new FlightFailure(FlightErrorType.OVERLOAD, guardException.getErrorCode().name(), true);
        case AI_UNAVAILABLE -> new FlightFailure(FlightErrorType.PROVIDER, guardException.getErrorCode().name(), true);
    };
}
if (cause instanceof TimeoutException) { return new FlightFailure(FlightErrorType.TIMEOUT, "TIMEOUT", true); }
if (cause instanceof RejectedExecutionException) { return new FlightFailure(FlightErrorType.OVERLOAD, "OVERLOADED", true); }
if (cause instanceof IllegalArgumentException) { return new FlightFailure(FlightErrorType.VALIDATION, "VALIDATION", false); }
return new FlightFailure(FlightErrorType.UNEXPECTED, "UNEXPECTED", false);
二、follower 侧
状态三 FOLLOWER_WAIT:当正常fellow

什么情况:

记录存在,owner正常,当前节点当 follower等结果。

要做什么:

Lua 侧只做一件事,把 followerCount 加 1,然后把当前 ownerToken 一并返回,让 Java 侧知道自己是在等哪一任 owner。Java 侧进入 followerWait,在 20 秒总预算内循环等待,全程绝不调 supplier。循环里有五个动作和三个出口:先 tryReadSuccessReplay 试读,读到就返回;再 getMeta 检查是否 FAILED 且 retryable 为 false,是就直接抛错;再判断剩余时间不足就返回 null 交给外层下一轮;否则 XREAD BLOCK 最多阻塞 3 秒等 Stream 通知;每 2 秒再做一次主动轮询兜住漏唤醒。因为 Stream 用的 ReadOffset.latest 只能收订阅之后的事件,轮询不是优化而是必需补偿。

  1. 代码
lua 复制代码
redis.call('HINCRBY', KEYS[1], 'followerCount', 1)
return 'FOLLOWER_WAIT|' .. (redis.call('HGET', KEYS[1], 'ownerToken') or '')
java 复制代码
while (System.currentTimeMillis() < deadlineMillis) {
    String replay = tryReadSuccessReplay(stage, requestKey, policy);
    if (replay != null) { return replay; }
    FlightMetaSnapshot metaSnapshot = flightCoordinatorRepository.getMeta(requestKey);
    if (metaSnapshot != null && metaSnapshot.getStatus() == FlightStatus.FAILED
            && !Boolean.TRUE.equals(metaSnapshot.getRetryable())) {
        throw new IllegalStateException("distributed single-flight previous failure: " + metaSnapshot.getErrorCode());
    }
    long remainingMillis = deadlineMillis - System.currentTimeMillis();
    if (remainingMillis <= 0) { return null; }
    flightNotificationService.waitForTerminalEvent(requestKey, Math.min(streamBlockTimeoutMillis, remainingMillis));
    if (System.currentTimeMillis() >= nextPollAt) {
        String polledReplay = tryReadSuccessReplay(stage, requestKey, policy);
        if (polledReplay != null) { return polledReplay; }
        nextPollAt = System.currentTimeMillis() + pollIntervalMillis;
    }
}
return null;
状态四 REPLAY_SUCCESS:已有数据

情况:

Lua 读到 status 等于 SUCCEEDED,说明这个 requestKey 之前已经成功跑完,结果在 Redis 里躺着。直接回放即可,绝不重复调模型。

步骤:

先查本地 L1,命中就直接返回,完全不访问 Redis。没命中再 getMeta 确认状态确实是 SUCCEEDED,不是就返回 null,不给半成品。再 getStoredResult 取结果,取到空同样返回 null。取到后反序列化,Base64 解码、按需 gunzip、用 sha256 校验完整性,校验不过直接抛错防止返回脏数据。最后把结果写回 L1 并返回。这个方法被多处复用,包括 follower 循环里的试读和 owner 成功收尾的兜底。

代码:

java 复制代码
String localReplay = flightReplayLocalCache.get(stage, requestKey);
if (localReplay != null) { return localReplay; }
FlightMetaSnapshot metaSnapshot = flightCoordinatorRepository.getMeta(requestKey);
if (metaSnapshot == null || metaSnapshot.getStatus() != FlightStatus.SUCCEEDED) { return null; }
FlightStoredResult storedResult = flightCoordinatorRepository.getStoredResult(requestKey);
if (storedResult == null) { return null; }
String replay = flightResultSerializer.deserialize(storedResult);
flightReplayLocalCache.put(stage, requestKey, replay, policy);
return replay;
状态五 REPLAY_FAILURE:完全失败,不走这条路

什么情况:

两种来源都会返回它。

第一种是 status 等于 FAILED 且 retryable 等于 0,说明上次是参数错误或者未知异常,重试没意义。

第二种是 status 为 CANCELLED 或 EXPIRED,记录已经取消或过期,同样不该继续跑。

要做什么:

Java 侧立刻抛错,不进 ownerExecute 也不进 followerWait。抛什么异常由 retryable 标记决定,可重试的包成 CompletionException 套 RejectedExecutionException,语义上表示可以再试;不可重试的直接 IllegalStateException,一路抛到底。

若外层是 HYBRID 模式,RuntimeException 会被 execute 的 catch 接住并降级到本地 single-flight 处理。

代码:

lua 复制代码
if status == 'FAILED' then
  local retryable = redis.call('HGET', KEYS[1], 'retryable') or '0'
  if retryable == '1' then
    -- 走 OWNER_TAKEOVER,不在这里返回
  end
  local errorType = redis.call('HGET', KEYS[1], 'errorType') or ''
  local errorCode = redis.call('HGET', KEYS[1], 'errorCode') or ''
  return 'REPLAY_FAILURE|0|' .. errorType .. '|' .. errorCode
end
if status == 'CANCELLED' or status == 'EXPIRED' then
  return 'REPLAY_FAILURE|1|' .. status .. '|STATE'
end
java 复制代码
boolean retryable = Boolean.TRUE.equals(acquireResult.getRetryable());
String message = "distributed single-flight replay failure";
if (StrUtil.isNotBlank(acquireResult.getErrorCode())) {
    message = message + ": " + acquireResult.getErrorCode();
}
if (retryable) {
    return new CompletionException(new RejectedExecutionException(message));
}
return new IllegalStateException(message);

降级策略:

实现容灾机制--如果分布式走不了,降级实现兜底

先看是否降级走本地:

1.本来就不走分布式,直接走本地

2.分布式执行抛异常,在 HYBRID混合模式下回退本地

3.分布式内部遇到异常结果,直接走本地

owner heartbeat

4.owner实现:

owner 执行 supplier、进行AI 保护、调用模型、结果写回、业务解析、释放锁。

4.1 执行supplier:

java 复制代码
try {
    String result = supplier.get();  
    .......
4.2 进入 AI 保护
防护 代码位置 实际生效的代码 关键配置来源
熔断 CircuitBreaker newBulkhead CircuitBreaker.decorateCallable 全局配置:失败率、滑动窗口、半开探测、打开等待
舱壁 Bulkhead newBulkhead Bulkhead.decorateCallable stage 级 maxConcurrentCalls,缺省 20
重试 Retry newRetry Retry.decorateCallable stage 级 retryCount、retryWaitMillis
超时 TimeLimiter newTimeLimiter+callWithTimeLimiter TimeLimiter.decorateFutureSupplier stage 级 timeoutMillis,缺省 5000ms

进入入口:

java 复制代码
public <T> T execute(String stage, String requestKey, Callable<T> action) {  
    String safeStage = StrUtil.isNotBlank(stage) ? stage : "interview-default";  
    long startNano = System.nanoTime();  
    try {  
        T result;  
        if (Boolean.TRUE.equals(configuration.getEnable())) {  
            result = guardedExecute(safeStage, action);  
        } else {  
            result = action.call();  
        }  
        recordMetrics(safeStage, "success", System.nanoTime() - startNano);  
        return result;  
    } catch (Throwable ex) {  
        InterviewAiGuardException guardException = classifyException(ex, safeStage);  
        String resultTag = guardException.getErrorCode().name().toLowerCase();  
        recordMetrics(safeStage, resultTag, System.nanoTime() - startNano);  
        if (guardException.getErrorCode() == InterviewAiGuardErrorCode.AI_OVERLOADED  
                && isBulkheadReject(unwrap(ex))) {  
            meterRegistry.counter("ai_bulkhead_rejected_total", "stage", safeStage).increment();  
        }  
        log.warn(  
                "AI guarded call failed, stage={}, requestKey={}, code={}, message={}",  
                safeStage,  
                requestKey,  
                guardException.getErrorCode(),  
                guardException.getMessage()  
        );  
        throw guardException;  
    }  
}
4.3. 调用模型

流程:装饰器链走到最内层,真实执行的是 InterviewAiInvoker 的 doChat(第 127 行)。doChat 建一个 StringBuilder 和匿名 OutputStream,第 134 行调 xingChenAIClient.chat,把流式片段追加进 StringBuilder,最后返回整段字符串。xingChenAIClient.chat 在 XingChenAIClient.java 第 80 行创建连接,真实地址是 https://xingchen-api.xf-yun.com/workflow/v1/chat/completions ,走 HttpsURLConnection,请求头 Content-Type 为 application/json,Accept 为 text/event-stream,Authorization 为 Bearer apiKey:apiSecret,请求体带 flow_id、uid、stream、chat_id、history 和 parameters。invoker 调用时 stream 传的是 false,所以不发 SSE,走非流式分支:readResponseBody 一次性读完整段响应,写给 outputStream 并 flush,回调为空,doChat 拿到的就是完整响应字符串。

java 复制代码
private String doChat(String input, String sessionId, AgentPropertiesDO agentProperties,
                      String fileUrl, Map<String, Object> parameters) throws Exception {
    StringBuilder aiResponse = new StringBuilder();
    xingChenAIClient.chat(
            input,
            StrUtil.isNotBlank(sessionId) ? sessionId : "evaluation_" + System.currentTimeMillis(),
            "{}",
            false,
            new OutputStream() {
                @Override public void write(int b) { }
                @Override public void write(byte[] b, int off, int len) {
                    aiResponse.append(new String(b, off, len, StandardCharsets.UTF_8));
                }
            },
            data -> { },
            agentProperties.getApiKey(),
            agentProperties.getApiSecret(),
            agentProperties.getApiFlowId(),
            fileUrl,
            parameters
    );
    return aiResponse.toString();
}
java 复制代码
URL url = new URL("https://xingchen-api.xf-yun.com/workflow/v1/chat/completions");
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("Accept", "text/event-stream");
conn.setRequestProperty("Authorization", "Bearer " + customApiKey + ":" + customApiSecret);
conn.setDoOutput(true);

JSONObject requestBody = new JSONObject();
requestBody.put("flow_id", customFlowId);
requestBody.put("uid", "123");
requestBody.put("stream", stream);
requestBody.put("chat_id", chatId);
requestBody.put("history", parseHistory(history));
requestBody.put("parameters", buildParameters(input, fileUrl, extraParameters));
java 复制代码
if (stream) {
    handleStreamingResponse(conn, chatId, outputStream, callback);
} else {
    String response = readResponseBody(conn, false);
    callback.accept(response);
    outputStream.write(response.getBytes(StandardCharsets.UTF_8));
    outputStream.flush();
}
conn.disconnect();
4.4. 结果写回

流程:字符串从 doChat 穿回 ownerExecute 的成功分支,single-flight 按字符串原样处理,不感知业务结构。顺序固定三步:先 storeResult 写结果 key,再 finishSuccess 置 SUCCEEDED,最后 publish 通知 follower,随后写本地 L1。序列化按阈值 gzip、Base64 编码、sha256 摘要,反序列化时校验摘要。

存入Redis+Redis Stream广播

java 复制代码
String result = supplier.get();
FlightStoredResult storedResult = flightResultSerializer.serialize(result, ownerToken, policy);
if (!flightCoordinatorRepository.storeResult(requestKey, nodeId(), ownerToken, storedResult, resultTtlMillis)) {
    throw new IllegalStateException("failed to store distributed flight result");
}
if (!flightCoordinatorRepository.finishSuccess(requestKey, nodeId(), ownerToken, resultTtlMillis)) {
    String replay = tryReadSuccessReplay(stage, requestKey, policy);
    if (replay != null) { return replay; }
    throw new IllegalStateException("failed to finish distributed flight success state");
}
flightNotificationService.publish(requestKey, "owner_succeeded", FlightStatus.SUCCEEDED, ownerToken, null, false);
flightReplayLocalCache.put(stage, requestKey, result, policy);
return result;
4.5 业务解析

流程:字符串回到业务 service,锁仍持有。先探工作流错误,再抠结构化 map,再逐个取分数,再判断量表并归一化,最后写缓存、刷新运行时快照,任一步失败按业务错误码抛出。提取链路额外先 persistRawResponse 落原文,解析失败还能回补。

java 复制代码
String workflowErrorMessage = interviewResponseParser.extractWorkflowErrorMessage(aiResponseStr);
if (StrUtil.isNotBlank(workflowErrorMessage)) {
    throw new ClientException(workflowErrorMessage, InterviewErrorCodeEnum.DEMEANOR_AI_RESPONSE_INVALID);
}
Map<String, Object> contentMap = interviewResponseParser.extractStructuredResult(
        aiResponseStr, KEY_PANIC_LEVEL, KEY_SERIOUSNESS_LEVEL, KEY_EMOTICON_HANDLING, KEY_COMPOSITE_SCORE);
Integer compositeScore = interviewResponseParser.parseScoreFromResponse(contentMap, KEY_COMPOSITE_SCORE);
boolean tenScaleDetected = demeanorNormalizationStrategy.isLikelyTenScale(
        panicLevel, seriousnessLevel, emoticonHandling, compositeScore);
int normalizedComposite = demeanorNormalizationStrategy.normalize(compositeScore, tenScaleDetected);
interviewQuestionCacheService.cacheDemeanorScoreDetails(sessionId, normalizedPanic, normalizedSeriousness, normalizedEmoticon, normalizedComposite);
interviewQuestionCacheService.cacheDemeanorScore(sessionId, normalizedComposite);
runtimeSnapshotService.refreshAfterDemeanorEvaluated(sessionId);
4.6 判断是否有锁,有的话释放--最开始神态的session锁

流程:锁在业务入口、single-flight 外层,key 是 stage 加 sessionId,等待 0 秒、租期 90 秒,拿不到直接返回 AI_OVERLOADED。

释放放在 finally,release 先判空再判 isHeldByCurrentThread 才 unlock。

java 复制代码
heavyLock = interviewAiSessionLockService.acquire(reqDTO.getSessionId(), InterviewAiGuardStage.INTERVIEW_DEMEANOR);
if (heavyLock == null) {
    throw new ClientException("AI_OVERLOADED: demeanor evaluation is processing, please retry",
            InterviewErrorCodeEnum.AI_OVERLOADED);
}
try {
    String aiResponseStr = interviewAiInvoker.callAiSyncWithFile(...);
} finally {
    interviewAiSessionLockService.release(heavyLock);
}
java 复制代码
public void release(RLock lock) {
    if (lock != null && lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

分布式系统的CAP理论(重点):

在分布式系统中,一致性、可用性和分区容错性三者不能同时完全满足,系统设计必须在其中做出权衡。

C (Consistency - 一致性): 每次读取要么获得最新写入的数据,要么报错。在分布式系统中,这意味着所有节点在同一时间的数据必须完全一致,就像访问单机单台数据库一样。

A (Availability - 可用性): 每次请求都能获得一个(非错误)的响应,但不保证获取的是最新写入的数据。系统必须一直处于"可提供服务"的状态。

P (Partition Tolerance - 分区容错性): 尽管网络会丢失或延迟节点之间的任意数量的消息(即发生网络分区),系统仍能继续运行。

所以,为了实现在分布式环境下,系统能够实现高可用,基于CAP理论,项目里的分布式Single-flight分布式做了如下的设计

核心设计理念与 CAP 取舍:

1.全局锁与状态机(偏向 CP): 使用 Redis Lua 脚本保证争抢执行权的原子性。全网多个实例同时发起相同请求时,系统强制保证只有一个 Leader 节点突围。此时为了防止"僵尸节点"脑裂,引入 Fencing Token(防护令牌) 机制,一旦原 Leader 假死超时,立即剥夺其回写结果的权利。保证了在并发控制上对强一致性(C)。

2.心跳续期与主动接管(保证 A): 如果 Leader 节点真宕机了,其他处于等待共享状态的 Follower 节点不能被永远阻塞。通过 Owner Heartbeat 机制,一旦 Leader 心跳丢失,Follower 迅速超时并重新发起选举接管请求。这保证了哪怕发生单点故障,整个面试链路的可用性(A)依然不受影响。

3.本地 L1 Cache 终极降级(AP 兜底): 当遭遇极端网络故障(严重的 P),Redis 集群整体不可用时,框架自动退化回本地单机 Single-flight 模式。此时系统主动放弃了全局一致的请求合并(C),但死保住了用户的核心面试流程不中断(A)。

这篇文章我们介绍了什么是single-flight,并跑通了一整条链路:在牛肉哥的项目里是如何使用分布式single-flight来实现需求的

面试问题

主题一:为什么做 single-flight

主问题 1:为什么 AI 面试场景需要 single-flight?

第一人称口播: 我在 AI 面试后端中遇到的核心问题不是普通查询的缓存命中,而是同一个评分或追问请求一旦被重复提交、网络重试或多个入口同时触发,就可能重复消耗模型额度,并且重复结果还会让面试记录出现竞争。我的处理方式是先把一次业务调用定义成稳定的请求指纹,再让同一指纹只产生一个真实执行者,其余请求等待并复用结果。这样解决的是执行中的重复,而不是事后把结果写进缓存。代码中先由统一 AI 调用入口构造 key,再进入本地或分布式 single-flight;当前测试能证明同 key 只执行一次,实际节省量仍需要压测和线上指标确认。

追问 1:它和普通缓存、分布式锁有什么区别?

第一人称口播: 普通缓存主要解决已经完成的结果复用,缓存未命中时仍可能有多个请求同时穿透;分布式锁主要保证临界区同一时刻只有一个持有者,但等待者通常还要自己决定如何取结果。single-flight 把执行过程和结果交付放在一起:第一个请求成为 owner,后续请求成为 follower,follower 等待 owner 的 Future 或 Redis 终态并直接拿到同一结果。我的实现既有 JVM 内的 Future 合并,也有 Redis 的状态、结果和通知,所以它更接近"执行中的请求合并",而不是单纯缓存或互斥锁。

追问 2:哪些请求不能直接复用?

第一人称口播: 我不会把所有 AI 调用都用一个粗粒度 key 合并。评分请求至少要区分业务阶段、会话、题号和答案内容摘要;文件或神态分析还要把文件业务标识纳入 key。若 key 过粗,不同题目或不同材料会错误共享结果;若 key 过细,同一请求的重试又无法命中。当前实现把 key 构造集中在 InterviewAiInvoker,并按 stage 做策略差异,神态分析还关闭了 L1 结果缓存。对于 key 变更,我会用同输入、不同输入和并发重试三组测试验证复用边界。

主问题 2:你如何定义"同一次请求"?

第一人称口播: 我把"同一次请求"定义成业务语义相同且结果可以安全共享的请求,而不是简单使用 HTTP requestId。当前同步 AI 调用会把阶段、sessionId、题号以及答案内容的 SHA-256 摘要拼成 key;带文件的调用则使用阶段、sessionId 和业务文件标识。这个设计让网络层重试只要业务输入不变就能复用,同时把不同题目、不同答案和不同材料隔离开。key 的组成属于业务契约,改动前必须同步检查调用入口、配置策略和测试,否则可能扩大错误复用或让命中率突然归零。

追问 1:为什么只取答案摘要的一部分?

第一人称口播: 这里的目标是让 key 稳定且长度可控,不把完整回答内容直接放进 Redis key。代码先对经过 trim 的答案计算 SHA-256,再截取固定长度作为摘要;在正常输入下它能显著缩短 key,并保持相同内容得到相同指纹。截断摘要存在理论碰撞风险,所以我不会把它描述成密码学身份认证,而是把它看成业务去重指纹。若线上对碰撞风险更敏感,可以增加摘要长度或保存完整摘要校验,并通过构造测试确认不同输入不会意外命中。

追问 2:sessionId 缺失时怎么处理?

第一人称口播: 缺失 sessionId 时,代码会使用固定的 no-session 占位,而不是把空值直接拼接。这个行为能让 key 构造保持确定性,但也意味着调用方必须尽量提供稳定会话标识,否则不同请求可能共享同一缺省空间。因此我会把缺失会话视为风险信号,在入口日志和指标中单独统计,并对不具备安全业务标识的场景选择禁用复用或显式使用业务 key。占位值是防止空指针和格式不一致的工程兜底,不代表业务上可以忽略会话隔离。

主问题 3:为什么要同时实现本地和分布式两层?

第一人称口播: 我保留两层是因为它们解决不同范围的问题。本地层用并发 Map 和 CompletableFuture 合并同一 JVM 内的瞬时并发,路径短、延迟低;分布式层把元数据、结果和通知放到 Redis,覆盖多实例部署。统一服务根据配置选择 local、distributed 或 hybrid:关闭分布式时直接走本地,hybrid 在协调异常时回退本地。这样既减少了单实例的 Redis 访问,也给基础设施异常留下可控的执行路径,但 hybrid 降级期间只保证节点内合并,跨节点去重能力会下降,我会把这点作为故障演练的明确结果。

追问 1:本地 Future 超时后为什么要移除 entry?

第一人称口播: follower 等待 Future 超时,说明当前等待者不能继续占用这次协作关系,否则后续请求可能一直跟着一个已经失活或过慢的 Future。代码在超时后用条件删除移除旧 entry,并抛出可识别的拒绝异常,让上层根据策略决定重试或降级。这里不能简单把 owner 的 Future 强行取消,因为真实模型调用可能仍在执行,取消会带来资源和状态不一致;我的处理是解除等待关系,再由 TTL、异常完成或下一次请求建立新的协作单元。

追问 2:本地层的 TTL 和等待超时分别解决什么?

第一人称口播: TTL 约束一个本地 flight 记录最多保留多久,防止异常情况下 entry 长期占用内存;等待超时约束 follower 愿意等待 owner 结果的时间,防止用户线程无限阻塞。两者不是同一个概念:TTL 偏向生命周期清理,wait timeout 偏向调用体验和故障边界。当前配置分别提供 ttl-millis 和 wait-timeout-millis,测试覆盖了同 key 复用和 waiter 超时。实际值需要结合模型耗时分布、线程池容量和用户可接受等待时间做基线验证。

主题二:Redis 状态机与并发安全

主问题 4:分布式层如何选出唯一 owner?

第一人称口播: 我把选 owner 的竞争放进 Redis Lua 脚本,脚本先读取请求元数据;不存在时递增全局 owner 序列并写入 PENDING、ownerId、ownerToken、租约时间等字段,返回 OWNER_NEW。已有请求处于运行状态时,脚本根据心跳时间返回 FOLLOWER_WAIT;成功则返回 REPLAY_SUCCESS;可重试失败则发放新 token 进入 OWNER_TAKEOVER。由于读取、判断和写入在 Redis 内一次完成,多个实例不会在客户端层面同时看到空状态并各自开始调用。owner token 后续还会参与状态迁移校验,形成从抢占到提交的完整约束。

追问 1:为什么不用 Java 的 Redisson 锁直接包住模型调用?

第一人称口播: 一个大锁可以限制同时执行者,但它没有自然表达结果回放、follower 等待、失败分类和接管语义,而且锁的持有时间会覆盖整个模型调用,长任务更容易遇到续租和故障恢复问题。我需要的是围绕一次业务请求的状态机,而不是只保护一段代码,所以选择 Redis Lua 管理元数据、结果和 token,另外用 Stream 通知等待者。不是说锁完全不能用,项目里其他防重场景仍可使用锁;对 AI single-flight 来说,状态机能把执行、结果和异常边界放在同一个协议里。

追问 2:Lua 脚本为什么比多次 Redis 命令更合适?

第一人称口播: 如果客户端先 HGET 状态,再根据结果 HSET,多个节点之间会在两条命令之间插入竞争,可能同时抢到 owner 或让旧 owner 覆盖新状态。Lua 脚本在 Redis 内串行执行读取、条件判断和写入,减少网络往返,也让状态迁移具备原子性。当前仓库把 acquire、mark running、heartbeat、store result、finish success 和 finish failure 分成明确脚本,每个脚本都校验必要字段。脚本复杂度需要测试和版本审查来控制,不能把所有业务逻辑无边界塞进 Redis。

主问题 5:状态机有哪些关键状态和迁移?

第一人称口播: 关键状态是 PENDING、RUNNING、SUCCEEDED、FAILED,同时保留 CANCELLED 和 EXPIRED 作为终态表达。新请求先建立 PENDING,owner 成功标记 RUNNING 后执行模型调用;结果写入成功并完成状态后进入 SUCCEEDED,异常按错误类型进入 FAILED。可重试失败下一次 acquire 可以发放新 token 重新接管,不可重试失败则直接回放失败信息。所有关键迁移都通过 ownerId、ownerToken 和当前状态校验,避免旧 owner 在已经接管或完成后继续写入。状态枚举还提供终态判断,便于通知和恢复逻辑保持一致。

追问 1:为什么要区分 PENDING 和 RUNNING?

第一人称口播: PENDING 表示请求已经完成 owner 竞争并写入元数据,但真实执行还没有被确认开始;RUNNING 表示 owner 已经通过 mark running 并进入模型调用阶段。这个区分帮助我识别 owner 在抢占后、启动前崩溃的窗口,也让结果写入脚本只接受合理的中间状态。若直接把抢占结果当作 RUNNING,接管判断可能把尚未开始的请求和正常长任务混为一谈。实际是否要对 PENDING 使用更短期限,要结合线程池排队和启动耗时做测量。

追问 2:失败为什么还要带 retryable?

第一人称口播: 失败并不等于都应该重试。模型超时、过载或供应商暂时不可用通常存在接管价值;参数校验失败或不可预期业务错误,重试只会重复失败甚至形成请求风暴。因此我在失败状态中同时记录错误类型、错误码和 retryable,后续 acquire 根据这个标记决定 OWNER_TAKEOVER 还是 REPLAY_FAILURE。这个设计把"是否可以重试"的判断从异常字符串中抽出来,便于观测和策略调整。最终重试次数、退避和用户提示仍要由上层业务契约明确。

主问题 6:owner token / fencing token 解决什么问题?

第一人称口播: owner token 是每次新建或接管执行单元递增生成的逻辑凭证。节点 A 拿到 token 1 后可能因为网络分区失去心跳,节点 B 发现租约失效并拿到 token 2;即使 A 的模型调用随后返回,写结果和完成状态的脚本也会校验 ownerId、ownerToken 以及当前状态,A 的旧 token 无法覆盖 B 的结果。这样防住的不是重复执行本身,而是旧执行者在接管后产生的过期写入。当前实现把 token 校验放进多个 Lua 脚本,形成端到端的 fencing 约束。

追问 1:ownerId 和 ownerToken 为什么都要校验?

第一人称口播: ownerId 表示哪个节点持有执行权,ownerToken 表示该节点在这条请求上的哪一代执行。只校验节点名,节点重启或同节点先后两次接管仍可能混淆;只校验 token,出现实现错误或跨请求误用时缺少节点边界。两个字段一起校验能把"节点身份"和"执行代次"都纳入脚本条件。它不是替代 Redis ACL 或网络安全的身份认证,而是业务状态机内的写入防护。测试需要覆盖同节点新 token、跨节点旧 token 和已完成状态写入三类情况。

追问 2:fencing token 能防止模型调用本身重复吗?

第一人称口播: 不能完全防止。token 只能阻止旧 owner 在结果提交阶段覆盖新 owner,无法撤销已经发给外部模型供应商的请求。如果旧 owner 在网络分区期间仍被供应商执行,理论上仍可能产生重复成本,所以接管阈值不能设置得过于激进,且需要把模型调用超时、心跳周期和供应商幂等能力一起考虑。single-flight 的工程目标是尽量减少重复并保护最终状态一致性,剩余重复执行窗口要通过故障注入和成本指标量化,而不能宣称绝对一次。

主题三:等待、心跳与降级

主问题 7:follower 如何等待并拿到结果?

第一人称口播: follower 首先尝试本地 L1 回放,然后读取 Redis 元数据和结果;如果 owner 仍在执行,就通过 Redis Stream 阻塞等待终态通知,并按轮询间隔再次检查成功结果。通知没有到达时,轮询仍能从状态和结果中恢复;超过 follower 最大等待时间则返回空,让外层最多重试三次后选择本地路径或报告失败。这个设计把低延迟通知和最终状态查询组合起来,避免把 Stream 当成唯一事实来源。结果读取后会进入按 stage 配置的本地回放缓存,后续短时间请求可以减少 Redis 读取。

追问 1:为什么通知和轮询要同时存在?

第一人称口播: 只轮询会持续产生 Redis 读请求,owner 执行时间较长时会放大无效访问;只依赖通知又会受到订阅时序、Stream 生命周期或连接异常影响,通知丢失后 follower 可能一直等不到。当前实现让 Stream 负责正常情况下的快速唤醒,让元数据和结果查询负责兜底确认。轮询间隔、阻塞时间和最大等待时间都可配置,调参时要观察 Redis QPS、follower 等待分布和结果缺失率,而不是只把等待时间调大。

追问 2:成功状态存在但结果不存在怎么办?

第一人称口播: 代码在 replay 路径中不会因为元数据是 SUCCEEDED 就直接返回空字符串,而是继续读取结果对象;如果结果缺失,就返回 null,让外层继续尝试,而不是伪造成功。这个情况可能来自结果写入与状态完成之间的异常窗口、TTL 不一致或 Redis 数据被清理。owner 路径先 storeResult 再 finishSuccess,降低了窗口,但仍需要监控和集成测试验证。生产上可以增加结果缺失指标、短暂重读或把状态标记为异常,具体选择要结合一致性要求。

主问题 8:为什么需要 heartbeat 和 running TTL?

第一人称口播: AI 模型调用是长尾明显的外部操作,不能假设固定几秒内完成。如果只设置一次短 TTL,正常的慢请求会被其他节点误判为失活并接管,造成重复调用和结果竞争。我的实现让 owner 按 stage 策略定时 heartbeat,脚本校验 owner 身份和 RUNNING 状态后更新 heartbeatAt、expireAt 并刷新 Redis TTL;只有超过 takeoverDetectMillis 且没有有效心跳时,后续请求才有机会接管。心跳周期、运行 TTL 和接管检测必须形成比例关系,最终参数要用真实耗时分位数验证。

追问 1:心跳线程本身失败会怎样?

第一人称口播: heartbeat 任务使用独立的 ScheduledExecutorService 定时执行,动作返回 false 时不会凭空延长租约;如果 Redis 不可用或 token 已被接管,续租会失败,之后由状态 TTL 和接管逻辑决定下一步。owner 的主调用仍会继续到完成或异常处理,最终写入脚本会再次校验 token,因此不会因为本地还认为自己是 owner 就覆盖新状态。这里需要补充心跳失败计数、任务异常捕获和告警,否则调度线程异常可能不容易发现。当前代码已经具备停止任务和 token 校验,观测增强属于待补项。

追问 2:如何选择 takeoverDetectMillis?

第一人称口播: 我会先统计模型调用和 Redis 往返的耗时分布,再把 takeoverDetectMillis 设在能够覆盖正常心跳抖动和长尾网络延迟的位置,同时不能大到让故障恢复无法接受。它不能简单等于 running TTL,也不能只看平均耗时。验证时要注入暂停 owner、丢心跳、Redis 短暂抖动和真实慢调用,分别观察误接管率、接管时间和重复模型调用窗口。当前配置按 evaluation、followup、extraction、demeanor 使用不同值,说明策略需要跟业务阶段的耗时和结果风险匹配。

主问题 9:Redis 不可用时 hybrid 模式如何工作?

第一人称口播: 统一服务先根据 enable、mode 和 distributed-enabled 判断是否进入分布式路径。hybrid 模式下,分布式协调过程中抛出运行时异常时会记录阶段和原因,再回退到本地 single-flight;local 模式则从一开始只使用本地层,distributed 模式遇到异常会把问题向上抛出。这样做的结果是 Redis 故障时仍保留单节点内的重复合并能力,但跨节点的唯一 owner 约束暂时失效,可能出现重复模型调用。我会把这项能力定义为明确的降级边界,并通过故障演练量化降级期间的成本和恢复情况。

追问 1:hybrid 会不会掩盖 Redis 故障?

第一人称口播: 会有这个风险,所以回退不能只返回业务结果而没有信号。当前实现写了 warning 日志,说明发生了 distributed single-flight fallback,并带上 stage、key 和异常原因;生产上还应增加计数器、采样结构化日志和告警阈值,让运维知道跨节点去重已经失效。hybrid 的目标是维持部分可用,不是隐藏基础设施问题。我会把 fallback 次数、持续时间、节点数和模型实际调用量放进故障演练报告,超过阈值时再决定是否切换到 fail-fast 或人工处置。

追问 2:为什么不总是 fail-fast?

第一人称口播: AI 面试是用户交互链路,Redis 短暂抖动时全部 fail-fast 可能直接中断正在进行的面试;本地回退可以在单节点范围内继续提供服务,换取一定的重复调用风险。反过来,如果业务对成本或结果唯一性要求高,distributed 模式的失败上抛更容易暴露问题并阻止无控扩散。选择取决于阶段的可重试性、模型费用、用户可接受中断和部署规模。我把它做成配置化模式,而不是在代码里写死单一策略,便于按环境和阶段验证。

主题四:结果存储与失败语义

主问题 10:结果为什么要序列化、压缩和校验?

第一人称口播: AI 返回内容可能较长,直接把原始字符串写入 Redis 会增加存储和网络成本,也难以发现结果损坏。我的结果组件先把字符串转成 UTF-8 字节,达到按 stage 配置的阈值后使用 gzip,再 Base64 编码写入 Redis Hash,同时记录原始大小、存储大小、编码方式、完成时间和 SHA-256 摘要。回放时先解码解压,再重新计算摘要;不一致就抛出校验异常,不返回疑似损坏的内容。这样把存储格式、完整性检查和业务执行逻辑分开,具体压缩收益需要通过真实回答长度分布测量。

追问 1:为什么结果和元数据分开存?

第一人称口播: 元数据需要频繁读取状态、owner、心跳和错误字段,结果内容可能更大、生命周期也不同。分成 meta key 和 result key 后,状态查询不必携带完整 payload,成功结果还可以按更长的业务 TTL 保存;脚本在完成成功时把 resultRef 写入元数据,owner 也会先写结果再提交成功状态。代价是两个 key 的 TTL 和写入顺序需要严格治理,出现成功但结果缺失时必须有明确处理。当前回放路径已经对这种情况返回空并继续兜底,后续可增加一致性监控。

追问 2:L1 本地回放缓存有什么边界?

第一人称口播: L1 只用于减少同一节点短时间内对 Redis 结果的读取,不能替代跨节点的事实存储。代码按 stage 策略决定是否启用,并限制最大条目和 TTL;神态分析这类结果默认关闭 L1,避免过度复用图片分析结果。缓存命中前仍然依赖前面构造出的业务 key,所以 key 语义错误会被 L1 放大。验证时我会检查过期后是否回到 Redis、不同 stage 是否遵守开关,以及 Redis 删除结果后本地缓存是否在 TTL 内产生预期的短暂可见性。

主问题 11:失败分类如何影响接管?

第一人称口播: owner 抛出异常后,我会先解开 CompletionException,再按异常类型映射为 TIMEOUT、OVERLOAD、PROVIDER、VALIDATION 或 UNEXPECTED。超时、过载和供应商不可用标记为可重试,校验失败和未知错误默认不可重试;这些字段由 finishFailure 写入 Redis。下一次请求看到 retryable 失败时可以拿到新的 owner token 接管执行,看到不可重试失败则直接回放错误并避免重复调用。这个分类把技术异常转换成状态机可消费的业务语义,但最终重试次数和用户提示仍需由上层流程定义。

追问 1:为什么未知异常默认不可重试?

第一人称口播: 未知异常的根因不明确,自动重试可能把程序缺陷、数据错误或非幂等副作用重复放大,所以默认快速失败更保守。只有能确认是暂时性故障并且业务调用允许重试时,才应该标记 retryable。这个默认并不意味着永远不能重试,而是把重试授权放到显式的错误分类或后续策略中。上线前我会收集未知异常样本,补充分类规则和告警,避免大量真实的暂时性问题被长期当成不可重试。

追问 2:失败结果也要设置 TTL 吗?

第一人称口播: 需要。失败状态如果永久保留,会让后续经过修复或重试窗口的相同请求一直得到旧失败;如果完全不保留,又会让短时间内的重复请求持续打到模型。当前配置分别提供 failed-result-ttl,且可重试失败在 TTL 内会被下一次 acquire 接管,不可重试失败则在短期内回放相同错误。TTL 的具体值要结合故障恢复速度、用户重试行为和错误是否依赖输入来定,不能直接沿用成功结果 TTL。

主问题 12:如何验证不会出现旧 owner 覆盖新结果?

第一人称口播: 我会从代码约束和故障测试两侧验证。代码侧要求 storeResult、finishSuccess、finishFailure 和 heartbeat 的 Lua 脚本都校验 ownerId、ownerToken 以及允许的当前状态;测试侧先让 owner A 拿到 token 1,再模拟心跳失效让 owner B 拿到 token 2,随后让 A 的 supplier 返回并尝试写结果,预期 Redis 脚本返回失败且 B 的终态保持不变。还要检查通知事件携带的 token,避免观察层误把旧事件当成新状态。当前单元测试覆盖正常 owner 写入和回放,旧 owner 接管场景仍建议补集成测试。

追问 1:结果写入失败时为什么不能直接返回 supplier 结果?

第一人称口播: 如果 owner 已经执行完模型调用,但结果没有成功写入共享存储,直接把结果返回给当前请求会让其他 follower 无法复用,甚至在状态仍运行时重复执行。当前实现把 storeResult 失败视为不可接受状态,抛出异常并进入失败处理;finishSuccess 失败时还会尝试读取已有回放,只有能确认结果存在才返回。这个选择牺牲了单次请求的"看起来成功",换取跨节点状态一致性和不伪造回放的边界。实际是否需要局部返回,要由业务对结果丢失和重复成本的优先级决定。

追问 2:通知发送失败会影响结果正确性吗?

第一人称口播: 正确性主要由 Redis 元数据和结果 key 保证,通知只是帮助 follower 更快醒来,所以通知发送失败不应该让已经成功写入的结果被改成失败。当前 owner 先写结果和成功状态,再发布 Stream 事件;follower 醒来后仍会查询状态和结果,通知缺失可以由轮询兜底。代价是通知异常可能增加等待延迟,因此需要记录 publish 失败和 follower 超时指标。若未来要强化,还可以给 Stream 设置合理保留策略或增加事件重试,但不能把通知当成唯一事实来源。

主题五:测试、指标与改进

主问题 13:现有测试证明了什么?

第一人称口播: 本地 single-flight 测试用两个并发调用验证同 key 只返回第一个 supplier 的结果,也用短等待超时验证 follower 会抛出可识别的拒绝异常;分布式服务测试覆盖关闭分布式后的本地回退、owner 成功执行并写入结果,以及已有成功状态的结果回放且不执行新 supplier;序列化测试覆盖压缩和摘要校验。它们证明了核心分支的单元行为,但没有证明真实 Redis 多实例竞争、网络分区、Stream 丢通知、线程池耗尽和生产模型耗时分布。因此我会把这些缺口明确列成集成测试、故障注入和压力测试计划。

追问 1:如何补充多实例集成测试?

第一人称口播: 我会启动至少两个应用实例和一个真实 Redis,使用固定 stage 和同一 request key 并发发起多个请求,把 supplier 替换为带计数和栅栏的测试实现。断言包括真实 supplier 调用次数、owner/follower 数量、最终状态、结果内容一致性和 follower 等待时间;然后分别注入 owner 进程退出、Redis 延迟、通知缺失和旧 token 回写。测试不能只验证返回值,还要读取 Redis 元数据和结果 key,确认状态迁移与 TTL 符合预期。最后再用不同 key 和不同答案验证业务隔离。

追问 2:如何避免测试把假数据当成结论?

第一人称口播: 测试替身只隔离外部模型供应商,不伪造 Redis 状态迁移或业务 key。supplier 的返回值可以是固定字符串,但调用次数、完成顺序、异常类型和等待时间要由真实并发执行产生;Redis 使用测试容器或项目已有集成环境,不能用一个手写 Map 冒充 Lua 原子性。报告中我会区分"代码行为已验证"和"线上收益待测",不把单测中的调用次数直接换算成生产节省金额。这样既能保证测试可重复,也不会过度外推结论。

主问题 14:你会监控哪些指标?

第一人称口播: 我会把指标分成请求、协调、等待和成本四类。请求侧记录各 stage 的调用总数、single-flight hit/miss、成功与失败分类;协调侧记录 OWNER_NEW、OWNER_TAKEOVER、FOLLOWER_WAIT、接管次数和 token 校验失败;等待侧记录 follower 等待时长、超时、轮询次数、Stream 通知延迟;成本侧将真实模型调用次数与业务请求数按 stage 对比。当前本地服务已经使用 Micrometer 记录 hit/miss,分布式部分已有日志和状态字段,接下来需要补充统一指标命名和 trace 关联,才能评估重复调用和故障窗口。

追问 1:如何判断 key 设计出了问题?

第一人称口播: 我会同时观察命中率和误复用风险,不能只追求更高命中率。命中率突然下降时,先检查 sessionId 是否稳定、答案 trim 或摘要逻辑是否变化、stage 名称和 businessKey 是否一致;命中率异常升高时,要抽样比对同一 key 下的题号、答案摘要和文件标识,确认没有把不同业务输入合并。还可以用离线回放构造相同输入、仅改一个字段的样本,验证 key 是否按预期变化。任何 key 变更都要配套迁移说明、测试和观察窗口。

追问 2:如何衡量 single-flight 带来的成本收益?

第一人称口播: 我不会从 hit 次数直接宣称节省了同等数量的模型费用,因为一次命中可能发生在模型已失败、结果已过期或降级路径中。更可靠的方式是建立业务请求数、真实供应商调用数、命中回放数、失败重试数和各模型计费量的关联,按 stage、节点和时间窗口做基线对比;同时观察端到端延迟和 follower 等待成本。先在压测环境得到受控结果,再在线上小流量观察,才能把架构结果转换成可信的节省金额或延迟变化。

主问题 15:如果继续改进,你会先做什么?

第一人称口播: 我会先补齐真实多实例集成测试和故障注入,再根据数据调整策略,而不是先扩大功能。重点包括旧 owner 接管、Redis 短暂不可用、Stream 通知丢失、结果 key 与元数据 TTL 不一致、心跳调度异常和 follower 大量超时。其次统一分布式指标和 trace,让一次业务请求能串起 key、owner token、模型调用和最终回放;最后再评估是否需要更细的错误退避、结果大小限制或通知清理。这样每个改动都能对应已观察的失败模式,避免把配置调优写成没有证据的稳定性结论。

追问 1:你认为当前方案最大的风险是什么?

第一人称口播: 最大风险是参数和业务 key 的组合错误,而不是某一行 Redis 代码。key 过粗会导致不同请求错误复用,key 过细会让重复调用失去合并;running TTL、心跳周期和 takeoverDetectMillis 不匹配又会造成误接管或故障恢复过慢。另一个风险是 hybrid 在 Redis 故障期间只保证节点内去重,跨节点重复成本可能上升。因此我会把 key 变更评审、按 stage 配置校验、接管故障演练和降级告警作为发布门槛,并用真实数据持续校准。

追问 2:如何向面试官证明这是你负责的工作?

第一人称口播: 我可以从端到端链路说明个人负责边界:我负责后端 AI 调用入口、请求指纹、单机并发合并、Redis Lua 协调、结果存储与回放、心跳续租、失败分类、配置接入以及核心单元测试;前端只作为请求来源,模型供应商是外部依赖。讲解时我会沿着一次请求从 key 构造到 owner 竞争、supplier 执行、结果提交、follower 回放和异常降级走一遍,并能指出对应源码路径和验证用例。对于尚未做过的线上指标,我会明确说待测,不把代码能力说成已经产生的生产收益。

参考资料:

牛牛集团-「码上面试」-笔记

后端仓库:https://github.com/lishuangqiang/AI-Meeting

https://pcnivlpg6pgy.feishu.cn/wiki/W5trwnXpLiyNyikG9sJchDbsnOc

相关推荐
Java后端的Ai之路1 小时前
【AIAgent开发】-Agent工具封装成CLI(纯干货!!!)
java·javascript·人工智能·agent·cli
AntsData1 小时前
用 12K+ 条亚马逊评论信号,定位机械键盘下一版该改什么
java·大数据·计算机外设
code2cat1 小时前
【随笔】连续批处理:长短请求怎样在迭代之间补位
java·人工智能·后端·大语言模型·vllm
最强小杰1 小时前
DeepSeek V4 Flash + Codex CLI 接入教程:原生 Responses API 适配,从配置到跑通收藏这篇就够了(2026)
java·前端·python·ai
Flynn Liang2 小时前
Spring AI 2.0 深度解析:ChatClient、Advisor 链与 MCP——Java 工程师的 AI 落地最优解
java·人工智能·spring·大模型·架构设计·spring ai
灿飞2 小时前
Kotlin与Java虚拟机六:基础与类型系统:一切皆对象,但对象有代价
java·kotlin
计算机毕设定制辅导-无忧学长2 小时前
《基于微信小程序健康习惯激励系统设计与实现》
java·spring boot·mysql·微信小程序·uniapp·健康习惯激励系统
揽星河..2 小时前
【C语言】指针
c语言·开发语言
掘金者阿豪2 小时前
SQL Server数据迁移完成后,凌晨的数据库告警该怎么查?
前端·后端