手写 SingleFlight 请求合并——比 SETNX 更优雅的缓存击穿方案

原创不易,转载请注明出处。本文基于真实项目实战,完整代码可直接复用。


前言

面试官问:"缓存击穿怎么解决?"

大多数人答:"用 SETNX 互斥锁,只放一个请求去查数据库。"

面试官追问:"还有更好的方案吗?阻塞等待的那些请求,能不能连 Redis 都不用再读一遍?"

答不上来了。

本文介绍一个更优雅的方案------SingleFlight 请求合并 ,并从零手写实现。核心就两个类:ConcurrentHashMap + CompletableFuture,二十行代码搞定。实际用在我自己的 O2O 生活服务平台中,配合 Caffeine + Redis 两级缓存架构,保护了秒杀商品、热门商户等热点数据的访问。


一、缓存击穿是什么

先搞清楚问题出在哪里。

复制代码
正常情况:
  请求 → 查 Redis 缓存 → 命中 → 直接返回(毫秒级,不查 DB)

缓存击穿:
  一个热点 Key(比如双 11 秒杀券的库存详情)过期了
    → 请求 1:缓存没命中 → 准备查 DB
    → 请求 2:缓存没命中 → 准备查 DB
    → 请求 3:缓存没命中 → 准备查 DB
    → ...
    → 请求 100:缓存没命中 → 准备查 DB

  100 个请求同时打到数据库 → 连接池被打满 → 整个系统不可用

关键词:一个热点 Key 过期 + 高并发 = 缓存击穿。

这三个概念经常被一起问,别搞混了:

问题 根因 典型场景
缓存穿透 查的是根本不存在的数据 恶意用不存在的 ID 刷接口
缓存击穿 一个热点 Key 过期 热门商品详情页、秒杀券库存
缓存雪崩 大量 Key 同时过期 批量缓存设了相同的 TTL

二、SETNX 互斥锁方案(能用,但不够好)

最常见的方案:同一时刻只放一个请求去查 DB,其余排队等着。

java 复制代码
public String getData(String key) {
    // 1. 先查缓存
    String data = redis.get(key);
    if (data != null) return data;

    // 2. 拿锁
    RLock lock = redisson.getLock("lock:" + key);
    lock.lock();
    try {
        // 3. Double Check(拿到锁再查一次,可能别人已经重建好了)
        data = redis.get(key);
        if (data != null) return data;

        // 4. 查 DB → 写缓存
        data = db.query(key);
        redis.set(key, data, 30, TimeUnit.MINUTES);
    } finally {
        lock.unlock();
    }
    return data;
}

流程如下:

复制代码
请求 1:拿锁成功 → 查 DB → 写缓存 → 释放锁 → 返回数据
请求 2:等锁... → 拿到锁 → Double Check 读 Redis → 缓存有了 → 返回数据
请求 3:等锁... → 拿到锁 → Double Check 读 Redis → 缓存有了 → 返回数据
...
请求 100:等锁... → 拿到锁 → Double Check 读 Redis → 缓存有了 → 返回数据

DB 只查了 1 次,看起来问题解决了。但有一个容易被忽略的瑕疵:

复制代码
每个等待的请求都要走一遍:
  等锁 → 拿锁 → 读 Redis(Double Check)→ 释放锁

虽然 DB 只查了 1 次,Redis 却被读了 100 次,
而且每次抢锁、释放锁都有 CPU 开销。并发越高,这个开销越明显。

能不能更省?能不能连 Redis 的 Double Check 都不用做了?


三、SingleFlight 请求合并方案

核心思路:第 1 个请求查 DB 拿到结果,直接把结果共享给所有等待的请求。

复制代码
请求 1:查 DB → 拿到结果 → 存到"共享盒子"里 → 返回
请求 2:发现有人在查了 → 直接等"共享盒子"的结果 → 拿到同一份数据 → 返回
请求 3:发现有人在查了 → 直接等"共享盒子"的结果 → 拿到同一份数据 → 返回
...
请求 100:同上

DB 只查了 1 次,Redis 的 Double Check 一次都没读,
所有等待的请求直接共享第 1 个请求的查询结果。

和 SETNX 的本质区别:

复制代码
SETNX(排队执行):
  请求 1 查 DB → 请求 2 拿锁 → 读缓存 → 释放锁
  → 每个等待的请求最后都要"亲手"读一次缓存

SingleFlight(结果共享):
  请求 1 查 DB → 请求 2、3、100 直接拿第 1 个人的结果
  → 只有第 1 个人"动手",其他人"伸手拿"

四、手写实现(完整代码)

核心就两个东西:

  • ConcurrentHashMap:登记本,记录"哪个 Key 正在被查询"
  • CompletableFuture:共享盒子,让等待的请求拿到同一个结果
java 复制代码
@Component
public class SingleFlightCache {

    @Autowired
    private StringRedisTemplate redisTemplate;

    /**
     * 正在执行中的请求
     * key = 缓存 Key
     * value = 该 Key 对应的异步查询任务
     */
    private final ConcurrentHashMap<String, CompletableFuture<String>> flyMap =
            new ConcurrentHashMap<>();

    /**
     * 查询缓存,缓存未命中时走 SingleFlight 逻辑
     *
     * @param key        缓存 Key
     * @param dbFallback 查 DB 的回调函数
     * @param ttl        缓存过期时间
     * @param unit       时间单位
     * @return 缓存数据
     */
    public String get(String key, Function<String, String> dbFallback,
                      long ttl, TimeUnit unit) {

        // ====== 第 1 步:先查缓存 ======
        String cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        // ====== 第 2 步:缓存未命中,尝试加入已有的"飞行中"请求 ======
        CompletableFuture<String> future = flyMap.computeIfAbsent(key, k ->
                CompletableFuture.supplyAsync(() -> {
                    try {
                        // 只有第一个请求能进来这里
                        String dbData = dbFallback.apply(key);
                        redisTemplate.opsForValue().set(key, dbData, ttl, unit);
                        return dbData;
                    } finally {
                        // 无论成功失败,都要清理,防止内存泄漏
                        flyMap.remove(key);
                    }
                })
        );

        // ====== 第 3 步:阻塞等待结果 ======
        try {
            return future.get(3, TimeUnit.SECONDS);  // 3 秒超时
        } catch (TimeoutException e) {
            // 超时了,清理掉这个 Future,防止后续请求一直等一个永远不会完成的结果
            flyMap.remove(key);
            return null;  // 降级返回 null
        } catch (Exception e) {
            flyMap.remove(key);
            return null;
        }
    }
}

使用方式非常简洁:

java 复制代码
@Service
public class ShopService {

    @Autowired
    private SingleFlightCache singleFlightCache;

    public Shop getById(Long id) {
        String key = "cache:shop:" + id;

        // 一行调用,传入查 DB 的回调
        String json = singleFlightCache.get(
            key,
            k -> {                              // 查 DB 的回调
                Shop shop = shopMapper.selectById(id);
                return shop == null ? "" : JSON.toJSONString(shop);
            },
            30, TimeUnit.MINUTES                // 缓存 30 分钟
        );

        return json == null ? null : JSON.parseObject(json, Shop.class);
    }
}

五、逐行拆解------为什么这么写

5.1 为什么用 ConcurrentHashMap

java 复制代码
ConcurrentHashMap<String, CompletableFuture<String>> flyMap

100 个线程同时读写这个 Map。普通 HashMap 在多线程下会丢数据甚至形成死循环(JDK 1.7 头插法扩容时链表反转导致环形引用)。ConcurrentHashMap 内部用 CAS + synchronized(JDK 8)锁住单个桶,不同桶之间互不影响,读写安全且并发度高。

5.2 为什么用 computeIfAbsent

java 复制代码
CompletableFuture<String> future = flyMap.computeIfAbsent(key, k ->
    CompletableFuture.supplyAsync(() -> { ... })
);

computeIfAbsent 是 ConcurrentHashMap 提供的原子方法,将"判断 Key 是否存在"和"不存在则放入"两步合并为一步:

复制代码
key 不存在 → 执行 lambda 创建 Future → 放入 Map → 返回这个 Future
key 已存在 → 不执行 lambda → 直接返回已有的 Future

三步一气呵成,不会被其他线程插队
→ 保证只有第一个请求能创建 Future,后续请求拿到的是同一个实例

如果自己用 if (map.get(key) == null) + map.put(key, value) 分两步写,A 线程和 B 线程可能同时判断 Key 不存在,然后各自创建了一个 Future------合并就失败了。

5.3 为什么用 CompletableFuture

java 复制代码
return future.get(3, TimeUnit.SECONDS);

后续请求调用 future.get()阻塞等待 ------线程挂起,不占用 CPU,直到第一个请求查完 DB 返回结果后才被唤醒。相比于 while (result == null) 忙轮询,这种等待方式不消耗 CPU,而且天然支持超时机制。

5.4 为什么 finally 里要 remove

java 复制代码
.finally {
    flyMap.remove(key);
}

第一个请求查完了,Future 已经有结果。如果不从 Map 里移除,这个 Entry 会一直留在里面。下一次缓存又过期时,computeIfAbsent 发现 Key 还存在,直接返回一个早已完成的旧 Future------里面装着过期的数据,请求合并不但没生效,还拿到了脏数据。

注意 remove 写在 supplyAsync 的 lambda 的 finally 里,不是写在 get() 的外面:不管查询成功还是抛出异常,finally 都会执行清理。

5.5 为什么设 3 秒超时

java 复制代码
future.get(3, TimeUnit.SECONDS);

如果第一个请求查 DB 非常慢------比如慢 SQL 要跑 10 秒------后续 99 个请求不能全卡住干等 10 秒。超时后降级返回 null,配合空值缓存(短 TTL)兜底,至少不会把请求全憋死。


六、两种方案对比

维度 SETNX 互斥锁 SingleFlight 请求合并
DB 查询次数 1 次 1 次
Redis 调用次数 每人 1 次 GET(Double Check) 0 次
等待者做了什么 等锁 → 拿锁 → 自己读 Redis → 释放锁 直接等第 1 个人的 Future 结果
实现复杂度 简单(Redisson 一行 lock() 稍复杂(手写 ConcurrentHashMap + CompletableFuture)
适用场景 有副作用操作(下单扣库存,必须排队) 纯读场景(查缓存,不要副作用)

一句话总结区别:

SETNX 是"排队后各自重建",请求合并是"一人查完全员共享"。

更重要的是:知道什么时候该用什么方案,比知道方案本身更重要。 秒杀下单扣库存这种有副作用的场景,必须用 SETNX 排队保证安全;纯读缓存这种无副作用的场景,用请求合并更省 Redis 调用。


七、进阶:和 Go 语言 SingleFlight 的对比

Go 标准库里有现成的 golang.org/x/sync/singleflight 包:

go 复制代码
// Go 版本:一行调用
g := singleflight.Group{}
v, err, _ := g.Do("key", func() (interface{}, error) {
    return db.Query("SELECT ...")
})

Java 标准库没有这个能力,需要手写。但思路完全一致------都是用一个并发安全的 Map 记录"谁正在查",用共享结果的方式让等待者直接拿到同一个返回值。Go 语言的 singleflight.Group 内部也是 sync.Map + sync.WaitGroup,和我们的 ConcurrentHashMap + CompletableFuture 一一对应。


八、踩坑总结

坑 1:忘记清理 Map

java 复制代码
// ❌ 没有 finally 清理
CompletableFuture<String> future = flyMap.computeIfAbsent(key, k ->
    CompletableFuture.supplyAsync(() -> db.query(key))
);

查询抛异常时 Future 虽然完成了,但 Map 里的 Entry 没被删。下一次缓存过期时,computeIfAbsent 看到 Key 还在,返回的是一个已失败的旧 Future------缓存永远重建不了。

坑 2:没有设置超时

java 复制代码
// ❌ 无限等待,不设超时
return future.get();

第一个请求的 DB 慢 SQL 跑了 10 秒,后续所有请求全卡 10 秒。加上超时机制,至少能降级返回旧数据或空值。

坑 3:异常没有捕获处理

java 复制代码
// ❌ 异常直接抛出去
return future.get(3, TimeUnit.SECONDS);

get() 抛出的异常如果不 catch,会导致外层没有清理 Map 的机会。supplyAsync 的 lambda 里面 finally 只能保证创建 Future 的那个线程最终会 remove,但如果有后续请求在 get 这里抛了 InterruptedException,它自己也要做清理。


九、面试怎么答

面试官:"缓存击穿怎么解决?"

"我项目里用手写 SingleFlight 请求合并,不是用 SETNX 互斥锁。核心是 ConcurrentHashMap 存正在执行的请求,CompletableFuture 让后面的人等同一个结果。第一个请求通过 computeIfAbsent 创建 Future 去查 DB,后续请求拿到同一个 Future 直接 get(3秒) 阻塞等。DB 只查一次,后面的请求连 Redis 的 Double Check 都不用读,直接共享结果。3 秒超时降级防止死等,finally 里 remove 防止内存泄漏。"

面试官:"和 SETNX 互斥锁有什么区别?"

"SETNX 是排队执行------后面的请求等锁释放后,自己再读一次 Redis Double Check。请求合并是结果共享------后面的请求直接拿第一个请求查 DB 的返回值,连 Redis 都不用再读一次。区别在于'排队等的人要不要自己再动手'。另外场景不同:SETNX 适合有副作用的操作(如下单扣库存,必须严格排队),请求合并适合纯读场景(查缓存,无副作用)。"


总结

方案 核心思路 代码量
SETNX 互斥锁 排队,每人拿锁后自己读一次 Redis 5 行(Redisson lock
SingleFlight 请求合并 共享,只有第 1 个人查 DB 20 行(手写)

请求合并不是银弹------纯读场景用它最省 Redis 调用,有副作用操作(如库存扣减)还是用 SETNX 排队更安全。知道什么场景用什么方案,比知道方案本身更重要。