欢迎光临
我们一直在努力

SingleFlight:一个被严重低估的并发控制利器,同时让你的 LLM 调用成本控制更优雅

前言

大家好,我是咪的Coding。

想象一个场景,某条热门活动的缓存 key 在凌晨定时失效,几千个请求同时穿透缓存,直冲数据库,直接拖垮了核心服务。

这就是经典的缓存击穿(Cache Breakdown)。某个热点 key 在失效的瞬间,大量并发请求直接打到数据库,就像在缓存这道屏障上凿开了一个洞。

问题很常见,解决方案大家也耳熟能详:加锁嘛。

于是第一反应是在服务层加了个 synchronized。代码很丑不说,测试时发现:并发上去以后,请求的响应时间分布变得极其不均匀 —— 拿到锁的线程独占资源,其他线程全在排队。后来改成了分布式锁,情况好转了,但 Redis 锁的竞争本身也带来了额外开销。

这时候同事提了一个我几乎没听过的词——SingleFlight。

翻了一圈发现,这东西在 Go 语言社区几乎是"标配",golang.org/x/sync/singleflight 被广泛用于防缓存击穿、接口限流等场景。

但 Java 生态里,关于 SingleFlight 的资料少得可怜,很多开发者甚至没听过。这篇博客就来补上这个空白:简单用 Java 实现 SingleFlight,并说清楚它和传统方案的差异。

一、SingleFlight 是什么?为什么需要它?

SingleFlight 的核心思想一句话就能说清楚:同一时间段内,对于相同 key 的请求,只放行一个去真正执行,其余全部等待并共享结果。

注意,它和"加锁"的出发点不同。加锁的本质是互斥 —— 一次只允许一个线程进入临界区;SingleFlight 的本质是请求合并 —— 它不排斥并发,只是说 “你们要的结果一样,派一个代表去干活就行”。

这个差异在实际效果上非常明显:如果 100 个请求同时查询同一份数据,SingleFlight 只产生 1 次数据库查询,其他 99 次直接拿结果。

也就是,它不解决 “缓存有数据时的性能”,它解决的是 “缓存没数据时的灾难”。

更重要的是,SingleFlight 的应用范围远不止缓存击穿。

数据库聚合查询、第三方 API 调用(按调用次数计费)、ML 推理、页面抓取 …… 任何 “相同输入 → 相同输出 + 高并发 + 高成本操作” 的场景,都可以用它来去重。

二、那么多锁,到底该用谁?

在动手写代码之前,值得花几分钟把几种常见的"防并发重复执行"方案拉出来对比一下。

  • 方案一:全局 synchronized

最简单粗暴的做法。问题也很明显:加锁粒度太粗,所有 key 共用一个锁。A 用户在查询 user:1001 时,B 用户查询 user:2002 也会被阻塞——完全没必要。

  • 方案二:双检锁(Double-Checked Locking)

这是很多 Java 开发者第一时间想到的"缓存初始化"方案。

思路是在 synchronized 前后各检查一次缓存,减少锁竞争。

但在高并发下,当同一个 key 同时失效时,多个线程仍会穿过第一道"无锁检查",然后在 synchronized 排队,锁释放后依次去查 DB。

结果还是多次查询。双重检查锁能解决 “重复初始化” 的问题,但无法彻底解决 “并发穿透” 的问题。

  • 方案三:分布式锁(Redis SETNX / 分布式表锁)

这是工程上比较常见的做法。拿到锁的去查 DB 并回写缓存,拿不到锁的等待或轮询。

缺点是引入了额外的 Redis 调用开销,锁的竞争本身也消耗资源;极端情况下锁未正常释放还可能造成死锁(需要超时兜底)。

  • 方案四:SingleFlight

SingleFlight 的设计思路是 —— 用细粒度的锁(per-key),但锁住的不是"资源",而是"等待关系"。

加锁粒度更细,本质上是通过 map 按 key 管理并发,避免所有 key 共用一把锁。

而且 SingleFlight 不会循环等待或轮询,第一个请求完成后,所有等待者直接收到回调通知。

方案粒度是否需要额外中间件同 key 并发 DB 次数非重复 key 是否阻塞
全局 synchronized 全局 1
双检锁 全局锁 + 无锁检查 少量
分布式锁 per-key 是(Redis 等) 1
SingleFlight per-key 1

结论:如果你的系统是单进程、不需要跨节点的并发控制,SingleFlight 是一个既不依赖外部中间件,又能达到和分布式锁相同"per-key 去重"效果的轻量方案。甚至在分布式场景下,也可以先用 SingleFlight 在单机内部合并请求,再让胜出的节点去竞争分布式锁,从而大幅降低锁竞争压力。

三、Java 实现:从原理到代码

核心数据结构就两个:

  • ConcurrentHashMap<String, CompletableFuture<T>> :按 key 记录当前正在执行的请求
  • CompletableFuture :承载异步执行结果,天然支持"多等一"的回调模型

当一个请求到来时,用 putIfAbsent 尝试往 map 里放入一个新的 CompletableFuture:

  • 如果 putIfAbsent 返回 null(说明抢到了),由当前线程异步执行真实逻辑,完成后通知 CompletableFuture;
  • 如果返回了一个已存在的 CompletableFuture(说明来晚了),直接等待它的结果即可。

import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;

public class SingleFlight<T> {

private final ConcurrentHashMap<String, CompletableFuture<T>> ongoingRequests = new ConcurrentHashMap<>();
private final Executor executor;

public SingleFlight() {
// 默认使用守护线程池执行异步任务
this.executor = r -> {
Thread t = new Thread(r);
t.setDaemon(true);
t.start();
};
}

public SingleFlight(Executor executor) {
this.executor = executor;
}

public CompletableFuture<T> doRequest(String key, Callable<T> loader) {
CompletableFuture<T> newFuture = new CompletableFuture<>();
CompletableFuture<T> existingFuture = ongoingRequests.putIfAbsent(key, newFuture);

// 已有请求在执行中,直接等待结果
if (existingFuture != null) {
return existingFuture;
}

// 当前线程抢到了执行权
return CompletableFuture.supplyAsync(() -> {
try {
T result = loader.call();
newFuture.complete(result);
return result;
} catch (Exception e) {
newFuture.completeExceptionally(e);
throw new RuntimeException(e);
} finally {
ongoingRequests.remove(key);
}
}, executor);
}
}

putIfAbsent 是关键。它是 ConcurrentHashMap 提供的原子操作 —— 只有当 key 不存在时才写入。这样多个线程同时请求同一个 key 时,只有一个能成功放入新的 CompletableFuture,其他全部拿到同一个引用。后续等待的过程没有任何锁参与,完全依赖 CompletableFuture 内部的回调机制。

四、实战示例:缓存击穿保护

假设有一个 CacheService,缓存未命中时需要查数据库:

public class CacheService {
private final SingleFlight<String> sf = new SingleFlight<>();

public String getData(String key) {
// 1. 先查缓存
String cached = redis.get(key);
if (cached != null) {
return cached;
}

// 2. 缓存未命中,走 SingleFlight 合并请求
try {
String result = sf.doRequest(key, () -> {
// 二次检查:有可能其他线程已经回写了缓存
String recheck = redis.get(key);
if (recheck != null) {
return recheck;
}
// 真正查 DB
return db.query("SELECT data FROM table WHERE key = ?", key);
}).get();

// 3. 回写缓存
redis.set(key, result, 300); // 5 分钟过期
return result;
} catch (Exception e) {
throw new RuntimeException("获取数据失败: " + key, e);
}
}
}

这里有一个值得注意的细节:二次检查。在 loader 内部再次检查缓存,防止数据库查询完成后、回写缓存之前,缓存已被其他方式(比如手动刷新)更新,减少重复写入的开销。

五、你不是只会用它防缓存击穿吧?

SingleFlight 的适用场景比"缓存击穿"宽泛得多。举几个常见的例子:

  • 1. 第三方 API 调用去重(按调用次数计费)

比如调用某个按次付费的天气 API,多个用户同时查询同一个城市的天气:

public Weather getWeather(String city) {
return sf.doRequest("weather:" + city, () -> apiClient.fetch(city)).get();
}

100 个用户同时查北京天气,只花 1 次 API 调用的钱。

  • 2. 数据库聚合查询保护

运营后台的某个统计页面,大量运营同时刷新:

public Report getDailyReport(String date) {
return sf.doRequest("report:" + date, () -> {
return db.query("SELECT … FROM orders WHERE date = ?", date);
}).get();
}

  • 3. 单机限流降级

虽然本质上不是限流器,但 SingleFlight 在"瞬时流量过来但数据还是一样"的场景下,天然起到了削减峰值的作用 —— 将 N 个请求合并为 1 次执行,间接保护了下游服务。

六、使用 SingleFlight 需要注意的几个点

说完了优点,咱们来聊聊现在的不足:

  • 1. 这是单机方案,不跨节点

SingleFlight 作用于进程内部,不会自动跨 JVM。

如果你的服务部署了 10 个实例,每个实例内部会各自合并请求,但 10 个实例还是会各自去查一次 DB。

分布式场景下可采取的措施是:SingleFlight + 分布式锁,先用 SingleFlight 合并节点内请求,再让胜出的节点去竞争分布式锁。

  • 2. 错误结果会共享给所有等待者

如果代表线程的 loader 执行失败了,这个错误会被传播给所有等待它的请求。

一旦 DB 挂了,所有的请求都会返回同样的错误,没有一个能"绕开"。

建议在 loader 内部做好异常处理和降级策略——比如超时后走兜底逻辑,或者直接触发熔断。

  • 3. Key 设计必须匹配去重维度

这是最容易踩的坑。doRequest 的 key 决定了什么算"相同请求"。

如果你的接口是查用户详情,传入的 key 必须包含用户 ID(如 "user:1001"),而不是简单的 "user",否则不同用户的请求会被错误合并,返回相同的数据。

另外加了 SingleFlight 后并发 QPS 没降,大概率是 key 没设计对。

  • 4. loader 执行时间过长会导致请求堆积

如果 loader 内部有死循环或长时间阻塞(比如数据库查询 30 秒超时),那所有等待的线程都会被"闷死"在 CompletableFuture.get() 上。建议给 get() 加上超时:

String result = sf.doRequest(key, loader).get(5, TimeUnit.SECONDS);

  • 5. 不适合并发量低或操作极快的场景

SingleFlight 本身有开销(ConcurrentHashMap 的原子操作、CompletableFuture 的创建和回调)。

如果并发量很低,或者目标操作本身只需要 1 毫秒,使用 SingleFlight 反而得不偿失。

说到底,只有 “高并发 + 高成本 + 可共享结果” 三者同时满足时,它才真正发挥价值。

总结

写完这篇博客,我自己最大的感触是:工程上很多优雅的解决方案,本质上是思维方式的转变。

面对缓存击穿,直觉告诉我们"加锁"。锁是在说"你们别抢,一个一个来"。SingleFlight 换了个思路 —— 既然你们要的东西一样,何必每个人都跑一趟?派个代表去,结果共享,完事。

当然,SingleFlight 依然不是万能药。它不能替代分布式锁(不跨节点),不能替代限流器(不主动限制请求量),更不能替代缓存(不存储结果)。

下次再需要请求合并时,不妨试试 SingleFlight。也许你能少写一堆 tryLock、SETNX 和轮询重试的代码。

毕竟,有时候最好的优化不是加东西,而是减东西。

感谢你看到这里,如果喜欢咪的Coding的话可以点个关注支持一下吧!也欢迎各位在评论区留言!

赞(0)
未经允许不得转载:171主机测评 » SingleFlight:一个被严重低估的并发控制利器,同时让你的 LLM 调用成本控制更优雅
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址