AI 面试相同请求为什么会重复调用 AI:用 ConcurrentHashMap + CompletableFuture 实现 Single-flight
一、先看我们遇到的问题
在 AI 面试项目中,用户提交答案后,后端需要调用 AI 对答案进行评分。
正常流程应该是:
text
提交答案 → 调用一次 AI → 返回评分结果
但在真实环境中,同一个逻辑请求可能在很短时间内到达多次。例如:
- 用户连续点击提交;
- 前端超时后自动重试;
- 网关重试;
- 两个线程几乎同时处理同一份答案。
如果后端没有并发合并机制,就会变成:
text
请求 A(同一会话、同一题、同一答案)→ 调用 AI
请求 B(同一会话、同一题、同一答案)→ 调用 AI
请求 C(同一会话、同一题、同一答案)→ 调用 AI
结果是同一份答案被评分三次,重复消耗模型 Token、线程和 AI 接口并发额度,而且三次生成结果还可能略有不同。
为了判断两个请求是不是同一次计算,可以使用 业务阶段 + 会话 ID + 题号 + 答案摘要 组成请求 Key。
我们想要的效果是:
text
请求 A → 真正调用 AI ───────→ 返回 result
请求 B → 不再调用 AI → 等待 A → 返回同一个 result
请求 C → 不再调用 AI → 等待 A → 返回同一个 result
这就是 Single-flight:同一个 Key 同时到达时,只让一个请求执行真实任务,其他请求等待并共享它的结果。
二、Single-flight 和 Redis 缓存有什么不同
这里说的 Redis,是指常见的"先读缓存,未命中再计算并写回"的结果缓存用法。
java
String result = redis.get(key);
if (result != null) {
return result;
}
result = callAi();
redis.set(key, result);
return result;
如果 Redis 已经有结果,后来的请求直接读取缓存,确实不会重复调用 AI。
问题发生在多个请求同时缓存未命中:
text
请求 A:查询 Redis → miss → 调用 AI
请求 B:查询 Redis → miss → 调用 AI
A 调用 AI 时,结果还没有写入 Redis。B 只知道"缓存中没有结果",并不知道"A 已经在计算"。因此,普通结果缓存仍可能在首次缓存 miss 时重复调用 AI。
Single-flight 解决的是另一个时间段的问题:
text
请求 A:发现当前没人计算 → 成为 owner → 调用 AI
请求 B:发现 A 正在计算 → 成为 follower → 等待 A
可以这样记:
| 机制 | 回答的问题 | 复用的对象 |
|---|---|---|
| Redis 结果缓存 | 这个结果以前算出来了吗? | 已经完成的结果 |
| Single-flight | 这个结果现在有人在算吗? | 正在进行的任务 |
Redis 和 Single-flight 不是互相替代的关系,它们可以组合使用。但本文只讨论 JVM 本地 Single-flight 的实现原理。
三、Single-flight 是现成方法吗
Single-flight 是一种并发设计模式,不是 Java 标准库里一个名为 SingleFlight 的固定类。
在 Java 中,我们可以使用下面两个并发工具自己实现:
text
ConcurrentHashMap + CompletableFuture
它们分别解决两个问题:
text
ConcurrentHashMap:相同 Key 的请求应该找到哪个任务?
CompletableFuture:任务结束后,所有等待者怎样拿到同一个结果?
核心容器可以这样定义:
java
private final ConcurrentMap<String, FlightEntry> flights =
new ConcurrentHashMap<>();
Map 中保存的不是 AI 最终结果本身,而是"一次正在执行或刚完成的 Flight"。每个 FlightEntry 内部可以包含一个 CompletableFuture 和过期时间。
四、ConcurrentHashMap:保证相同 Key 找到同一个任务
1. 为什么不能使用普通 HashMap
假设使用普通 Map,并写成"先查询,再创建":
java
FlightEntry entry = flights.get(key);
if (entry == null) {
entry = new FlightEntry(new CompletableFuture<>());
flights.put(key, entry);
}
两个线程可能同时执行:
text
线程 A:get(key) → null
线程 B:get(key) → null
线程 A:创建 Future-A
线程 B:创建 Future-B
最终 A 和 B 各自认为自己是执行者,仍然会调用两次 AI。
问题不仅是普通 HashMap 线程不安全,更重要的是"查询 + 判断 + 创建"这三个动作不是一个原子操作。
2. 为什么使用 ConcurrentHashMap.compute
可以通过 compute 原子地创建或复用 Flight:
java
AtomicBoolean newFlight = new AtomicBoolean(false);
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;
});
对于同一个 Key,compute 中的这段更新逻辑会被原子协调。因此:
- 第一个进入的线程发现没有 Flight,创建新 Entry;
- 后续线程发现 Entry 已存在,直接复用;
- 只有创建 Entry 的线程把
newFlight设置为true。
执行结果如下:
text
线程 A:compute(k1) → 创建 Future-1 → newFlight=true → owner
线程 B:compute(k1) → 复用 Future-1 → newFlight=false → follower
线程 C:compute(k1) → 复用 Future-1 → newFlight=false → follower
3. AtomicBoolean 是做什么的
compute 返回的只是 FlightEntry。无论创建还是复用,三个线程最后都拿到同一个 Entry,所以还要知道"这个 Entry 是不是我创建的"。
newFlight 就是当前线程的身份标记:
java
if (newFlight.get()) {
// owner:真正执行 supplier
} else {
// follower:等待 Future
}
这里使用 AtomicBoolean,是因为 Java Lambda 内不能修改普通局部布尔变量。它不是用来在多个请求之间共享 owner 状态;每次调用 execute 都会创建自己的 newFlight。
4. 为什么不在 compute 里面调用 AI
compute 只负责快速地创建或复用 Entry。耗时的 AI 调用放在 compute 外执行:
java
FlightEntry entry = flights.compute(...);
if (newFlight.get()) {
T value = supplier.get();
}
这样 Map 的原子更新部分很短,不会把漫长的网络请求放进 Map 的计算逻辑中。
五、CompletableFuture:让所有请求等待同一个结果
1. 它在这里不是异步线程池
很多人看到 CompletableFuture 会先想到异步执行,但这里最重要的作用不是启动线程,而是充当"将来才会有值的结果容器"。
java
CompletableFuture<Object> future = new CompletableFuture<>();
这行代码不会自动创建线程,也不会自动执行 AI。它只是创建了一个尚未完成的 Future:
text
初始状态:未完成
owner 成功:完成,并保存结果
owner 失败:异常完成,并保存异常
2. owner 如何发布成功结果
只有 newFlight=true 的 owner 执行真实任务:
java
T value = supplier.get();
entry.resultFuture.complete(value);
return value;
supplier.get() 在 AI 面试场景中就是那次昂贵的 AI 调用。
当 owner 得到结果后,调用 complete(value)。同一个 Future 上正在等待的 follower 都会被唤醒,并拿到这个 value。
3. follower 如何等待
follower 不执行自己的 Supplier,而是等待 owner 对应的 Future:
java
T reused = (T) entry.resultFuture.get(
waitTimeoutMillis,
TimeUnit.MILLISECONDS
);
return reused;
所以,即使 follower 传入了另一个 Supplier,它也不会执行。它只关心 owner 最终发布的结果。
这里使用带超时的 get,是为了避免 follower 因 owner 卡死而无限等待。需要注意:follower 等待超时只代表"我不再等了",不会自动终止 owner 正在执行的 AI 调用。
4. owner 失败时怎么办
owner 调用 AI 可能抛出异常。这时不能只让 owner 自己失败,否则 follower 会一直等待一个永远不会完成的 Future。
这时需要让 Future 异常完成:
java
catch (Throwable ex) {
entry.resultFuture.completeExceptionally(ex);
flights.remove(key, entry);
throw ex;
}
completeExceptionally(ex) 会唤醒所有 follower,让它们知道这次共享任务失败了。remove(key, entry) 是条件删除,只有 Map 中仍然是这个 Entry 时才删除,避免误删后来新建的 Flight。
如果等待线程被中断,还应该恢复线程的中断标记,然后向上抛出异常。
六、两个类组合后的完整流程
假设 A、B、C 使用相同 Key 同时请求 AI 评分:
text
1. A 调用 compute(k1)
Map 中没有 k1
A 创建 Future-1,成为 owner
2. B 调用 compute(k1)
Map 中已有 k1 → Future-1
B 成为 follower
3. C 调用 compute(k1)
Map 中已有 k1 → Future-1
C 成为 follower
4. A 执行 supplier.get()
只有这里真正调用 AI
5. B、C 调用 Future-1.get(timeout)
它们等待,不调用 AI
6. A 得到 result,调用 Future-1.complete(result)
7. B、C 被唤醒,也拿到 result
两者的职责可以浓缩成一句话:
ConcurrentHashMap 让相同请求找到同一个任务,CompletableFuture 让这些请求共享同一次执行的结果或异常。
把上面的逻辑组合起来,可以得到下面这段简化代码:
java
public <T> T execute(String key, Supplier<T> supplier) {
AtomicBoolean newFlight = new AtomicBoolean(false);
FlightEntry entry = flights.compute(key, (k, existing) -> {
if (existing == null || existing.expired()) {
newFlight.set(true);
return new FlightEntry(new CompletableFuture<>(), expireAt);
}
return existing;
});
if (newFlight.get()) {
try {
T value = supplier.get();
entry.resultFuture.complete(value);
return value;
} catch (Throwable ex) {
entry.resultFuture.completeExceptionally(ex);
flights.remove(key, entry);
throw ex;
}
}
return (T) entry.resultFuture.get(waitTimeout, MILLISECONDS);
}
七、可选的 TTL 和实现边界
除了 Future,FlightEntry 还可以带有 expireAtMillis。这样,成功完成的 Future 能在一个很短的 TTL 内保留,稍晚到达的相同 Key 可以立即拿到已完成结果。
这种设计除了合并"正在执行"的请求,还提供了一个很短的结果复用窗口。过期 Entry 应当在同 Key 再次进入时被替换,并通过定期清理避免 Map 持续增长。
使用时还要注意三个边界:
- Key 必须准确。 Key 太粗会错误合并不同答案;Key 太细则无法合并相同请求。
- 等待超时不会取消 owner。 follower 超时离开后,旧 owner 可能仍在调用 AI。
- 这个 Map 只存在于当前 JVM。 它只能直接合并进入同一个应用实例的请求。
八、总结
AI 面试中的问题是:相同评分请求同时到达,导致后端重复调用 AI,浪费 Token 和并发资源。
普通 Redis 结果缓存复用的是"已经算完的结果";Single-flight 合并的是"现在正在进行的相同计算"。
Java 中可以使用 ConcurrentHashMap + CompletableFuture 构建本地 Single-flight:
text
ConcurrentHashMap.compute()
→ 原子创建或复用同一个 Flight
→ 选出唯一 owner
CompletableFuture
→ owner 发布结果或异常
→ follower 等待并复用
最终,同一个 Key 即使同时到达多次,也只有 owner 真正调用一次 AI,其他请求只等待并共享这一次调用的结果。