ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多实例部署 AI 服务如何避免重复扣费:AI-Meeting 分布式 Single-flight 框架深度解析

多实例部署 AI 服务如何避免重复扣费:AI-Meeting 分布式 Single-flight 框架深度解析

【免费下载链接】AI-Meeting

基于 Spring Boot 3 + Java 17 + Spring AI + MySQL + MongoDB + Redis + SSE/WebSocket,实现 AI 对话、智能体会话、AI 模拟面试、实时语音转写、长文本语音合成等核心功能。架构清晰、文档完整,支持本地运行与 Docker 一键部署,非常适合作为 Spring Boot AI 应用开发、智能体后端设计与简历展示项目。

项目地址:https://gitcode.com/gh_mirrors/ai/AI-Meeting
点击查看免费下载

AI-Meeting 是基于 Spring Boot 3 + Java 17 + Spring AI 的开源项目,实现 AI 对话、智能体会话、AI 模拟面试、实时语音转写与长文本语音合成等核心功能。其中一套容易在面试中被追问、却在很多生产系统里真实存在的难题是:服务扩容到多个实例后,同一个用户的同一次请求会被多台机器各调一遍大模型,Token 费用直接翻倍,用户还会看到两份不一致的回答。AI-Meeting 用一套「本地 + Redis 分布式」双层 Single-flight 框架,把「同一次业务请求的重复 AI 调用」折叠成集群内的一次,本文带你完整拆解它的实现思路。

为什么多实例部署会让 AI 费用翻倍?

先看一个典型故障场景:用户点击「提交回答」,请求经负载均衡落到实例 A,AI 评分需要 10~20 秒;用户等不及刷新页面,请求落到实例 B,于是模型被调用了两次。

单实例时代这个问题可以用「本地缓存 + 锁」解决,因为所有请求都在同一个 JVM 里。但多实例部署后,各实例的本地内存互不可见,去重状态必须放到共享存储(通常是 Redis)中,并且抢占动作必须原子化——否则两个实例会同时认为「我是第一个」,重复调用照样发生。

AI-Meeting 的面试链路(提问、评分、追问、神态分析)都是典型的「单次调用耗时数十秒、单次成本高」的 AI 场景,正是这类框架的主战场。

框架总览:双层 Single-flight 架构

核心入口是 InterviewAiInvoker.java,它负责生成「请求指纹」(single-flight key),格式为:

  • stage|sessionId|questionNumber|answerHash(评分、追问类调用)
  • stage|sessionId|businessKey(文件、神态分析类调用)

key 由「阶段 + 会话 + 题号 + 答案内容的 SHA-256 摘要前 16 位」拼成。这意味着:同一会话、同一题号、内容完全相同的答案,无论落到哪个实例,都会命中同一条 flight 记录,从而天然实现跨实例去重。

在这一层之上,框架分两级工作:

层级实现作用范围核心类
L1 本地ConcurrentHashMap+CompletableFuture单实例内InterviewAiSingleFlightService.java
L2 分布式Redis Lua 脚本 + 状态机 + 心跳全部实例DistributedInterviewAiSingleFlightService.java

第一层:进程内 Single-flight(毫秒级去重)

本地层实现非常精巧:用flights.compute(key, ...)的原子操作保证「创建 flight 与复用 flight」不会同时发生,避免瞬时并发下出现多个 leader:

  • 首个请求(leader):真实调用 AI,结果通过CompletableFuture.complete()广播给所有等待者;
  • 后续请求(waiter):直接future.get()等待同一个结果,超时(默认 5s)后主动剔除旧 flight,防止永久挂起;
  • 失败清理:leader 异常时移除 key,等待者也收到同样的异常,保证语义一致。

这一层拦截的是「同一实例内的重复点击、前端重试」等最高频的浪费,且几乎零开销。

第二层:Redis 分布式协调(跨实例去重)

跨实例协调的核心在 FlightCoordinatorRepository.java。整个抢占过程由一段Redis Lua 脚本原子执行,这是避免「双主」的关键——任何「先 GET 再 SET」的两次网络往返都可能被两个实例穿插执行,而 Lua 脚本在 Redis 中是单线程原子跑完的。

脚本逻辑对应一个清晰的五路分支状态机:

  1. OWNER_NEW:key 不存在 → 自增ownerToken,写入PENDING元数据,当前实例成为 owner;
  2. REPLAY_SUCCESS:状态已是SUCCEEDED→ 直接回放已有结果,一次模型都不调;
  3. REPLAY_FAILURE:上次失败且「不可重试」(如参数校验错误)→ 直接抛出相同错误,避免反复重试确定性失败;
  4. FOLLOWER_WAIT:owner 心跳仍在takeoverDetectMillis(默认 10s)内 → 当前实例降级为 follower,等待结果;
  5. OWNER_TAKEOVER:owner 心跳超时或上次失败可重试 → 新实例生成新 token接管该请求。

owner 拿到抢占权后还要执行markRunning(CAS 校验 ownerId + ownerToken 双身份),确认无误才真正调用模型,进一步杜绝「抢到又丢」的边界问题。

心跳续租与故障接管:长耗时调用的保活机制

AI 调用动辄 20~60 秒,但 Redis 元数据的runningTtlMillis只有 15 秒左右。如果 TTL 一过就被接管,会出现「两个实例同时调用模型」的灾难。

解决方案是心跳续租:FlightHeartbeatManager.java 按 stage 策略(默认 3s)定时向 Redis 续租,每次续租同样用 Lua 脚本 CAS 校验身份。这样设计带来一个优雅的故障语义:

  • 实例正常 → 心跳持续续租,TTL 永不到期;
  • 实例宕机 → 心跳停发,takeoverDetectMillis后其他实例自动接管,故障恢复时间 ≈ 心跳间隔 × 3,无需人工干预。

结果回放与 L1 本地缓存:命中后零模型调用

owner 成功后做两件事(见 DistributedInterviewAiSingleFlightService.java):

  1. 写入结果:由 FlightResultSerializer.java 完成 gzip 压缩 + Base64 编码 + SHA-256 校验和,控制 Redis 存储成本;
  2. 状态转SUCCEEDED并通过 FlightNotificationService.java 向 Redis Stream 发布终态事件,让阻塞等待的 follower 立即被唤醒,而非傻等轮询。

follower 的等待采用「Stream 阻塞读 + 定时兜底轮询」双通道:阻塞读最长 3s,期间每 2s 轮询一次结果,既低延迟又不怕通知丢失。此外每个实例还有 L1 本地回放缓存(默认 1000 条、30s TTL),同一实例的第二次命中连 Redis 都不用查。

三种运行模式:local / distributed / hybrid

模式由 FlightMode.java 定义,通过xunzhi-agent.ai-singleflight.mode切换:

模式行为适用场景
local仅进程内去重单实例部署、联调
distributed强制走 Redis 协调,失败即报错对一致性要求高、宁可失败不可重复计费
hybrid分布式优先,Redis 异常时自动降级本地模式生产推荐(AI-Meeting 默认)

hybrid模式体现了一个务实的取舍:Redis 短暂抖动时,宁可退回「实例内去重」保住可用性,也不让整个面试功能不可用——去重是「省钱优化」,不应成为功能可用性瓶颈。

关键配置速查表

全部配置集中在 application.yaml,支持按 stage 差异化覆盖:

参数默认值含义
modehybrid运行模式
distributed-enabledtrue是否启用 Redis 协调
follower-max-wait-millis20000follower 最长等待时间
stream-block-timeout-millis3000Stream 阻塞读单次上限
heartbeat-interval-millis3000心跳续租间隔(按 stage)
running-ttl-millis15000运行态 TTL(按 stage)
takeover-detect-millis10000心跳多久未更新判定可接管
result-ttl-millis600000成功结果回放窗口(按 stage)
failed-result-ttl-millis60000失败结果回放窗口
compression-threshold-bytes4096超过该字节数结果才 gzip

例如评分场景(interview-evaluation)结果保留 10 分钟、追问(interview-followup)保留 3 分钟、简历提取(interview-extraction)保留 30 分钟——TTL 就是「多久之内同样的请求允许免费回放」,是防重复扣费最直接的旋钮。更多配置语义可参考项目内置说明 ai-singleflight.md 与 ai-guard.md。

调优实践:三个最容易被忽视的坑

  1. key 设计决定一切:key 过粗(如只含 sessionId)会把不同题的调用误合并,用户拿到错误答案;key 过细(如含时间戳)则完全打不到。记住原则:「什么算同一次业务调用」完全由 key 语义定义,改 key 等于改去重边界。
  2. follower 等待要设上限:等待过久用户会感知为卡顿。框架把follower-max-wait-millis控制在 20s 内,超时后 follower 自行发起新调用(此时大概率已命中结果回放)。
  3. 与限流熔断分层协作:single-flight 只负责「折叠重复」,超时/并发上限/熔断由同目录下的 AI Guard 体系负责(见 AiCallGuardService.java),两者叠加后,同一请求在集群维度上「最多只产生一次真实的模型调用」,重复扣费问题就此闭环。

总结

AI-Meeting 的分布式 Single-flight 框架给出了一套可直接借鉴的防重复扣费方案,其设计精髓可浓缩为五点:

  • Lua 脚本原子抢占:从根上消除「双 owner」;
  • ownerToken 递增 + CAS 身份校验:每次接管都是全新身份,历史状态不会串扰;
  • 心跳续租代替长锁:TTL 短 + 高频续租,宕机自动接管;
  • 终态回放窗口(TTL):成功/失败都可在窗口内零成本回放;
  • hybrid 降级:共享存储故障时优雅退化为实例内去重。

这套机制不只适用于 AI 面试,任何「高延迟、高单价、幂等可回放」的第三方调用(大模型、支付、短信、OCR)都可以套用同一模式。项目同时支持本地运行与 Docker 一键部署(docker-compose.yml),非常适合拿来动手阅读与二次实践。

【免费下载链接】AI-Meeting

基于 Spring Boot 3 + Java 17 + Spring AI + MySQL + MongoDB + Redis + SSE/WebSocket,实现 AI 对话、智能体会话、AI 模拟面试、实时语音转写、长文本语音合成等核心功能。架构清晰、文档完整,支持本地运行与 Docker 一键部署,非常适合作为 Spring Boot AI 应用开发、智能体后端设计与简历展示项目。

项目地址:https://gitcode.com/gh_mirrors/ai/AI-Meeting
点击查看免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进