单飞锁:从缓存击穿到分布式锁的演进之路
对于单飞锁,相信大家可能有点陌生。心想:这不就是防缓存击穿的一个小技巧嘛,有什么好说的。那么在这里我就提出以下几个问题,看你是否真的了解单飞锁了。
- 为什么需要单飞锁?它能解决什么问题?
- 单飞锁和普通的互斥锁有什么区别?为什么叫"单飞"?
- 单飞锁在实现上有什么坑?如何避免?
- 在分布式环境下,单飞锁还能用吗?应该用什么锁?
- Redis 分布式锁、Zookeeper 分布式锁、数据库锁,各自适合什么场景?
相信你看到上述问题,肯定会说,这不就是八股吗?这背一下不就好了。那么我想说的是,这上述问题隐藏着架构和权衡的艺术,相信你看完这篇一定会受益匪浅。
一、为什么需要单飞锁?
1.1 先从一个线上故障说起
某个热点数据的缓存过期了,瞬间大量请求同时打到数据库,导致数据库 CPU 飙升到 100%,整个服务雪崩。
事后复盘时,我们发现这个场景有个专门的名词:缓存击穿。
可以想象一个热门餐厅(数据库)门口排着长队(请求)。平时餐厅有个电子菜单(缓存)在外面,顾客看一眼就知道有没有位置,不用进去问。突然有一天电子菜单坏了(缓存过期),所有顾客一窝蜂冲进餐厅问服务员(查数据库),餐厅直接被挤爆。
数据库 缓存 请求3 请求2 请求1 数据库 缓存 请求3 请求2 请求1 par [并发查询数据库] 数据库压力瞬间飙升! 查询数据 失效 查询数据 失效 查询数据 失效 SELECT * FROM table SELECT * FROM table SELECT * FROM table
文字版说明:三个请求同时发现缓存失效,同时去查数据库,导致数据库压力瞬间飙升。实际场景中可能是成百上千个请求。
1.2 缓存击穿的几种解决方案
相信各位开发者都知道,缓存击穿有几种常见的解决方案:
方案1:设置热点数据永不过期
- 优点:简单粗暴,不会击穿
- 缺点:数据更新时需要主动刷新缓存,维护成本高;如果忘记刷新,数据就脏了
方案2:加互斥锁(Mutex Lock)
- 优点:只让一个请求去查数据库,其他请求等待
- 缺点:所有请求都阻塞等待,响应时间变长;如果查数据库的请求挂了,其他请求一直等
方案3:单飞锁(Singleflight)
- 优点:只让一个请求去查数据库,其他请求共享结果(不是等待)
- 缺点:实现稍微复杂一点;需要考虑超时和异常处理
那么问题来了:方案2和方案3有什么区别?为什么单飞锁更好?
二、单飞锁的核心思想
2.1 什么是"单飞"?
单飞锁的核心思想很简单:对于相同的请求,只让一个请求"飞"过去执行,其他请求等待并共享结果。
可以想象一群人(请求)要去食堂打饭(查数据库)。如果每个人都排队打饭,食堂阿姨(数据库)会累死。单飞锁的做法是:选一个代表去打饭,其他人等着,代表打完饭回来后,把饭分给大家。
数据库 单飞锁 请求3 请求2 请求1 数据库 单飞锁 请求3 请求2 请求1 等待请求1的结果,共享返回 等待请求1的结果,共享返回 查询key="user:123" 只有请求1去查数据库 返回数据 查询key="user:123" 查询key="user:123" 返回数据 返回数据(共享) 返回数据(共享)
文字版说明:请求1去查数据库,请求2和请求3等待并共享请求1的结果。数据库只被查询一次。
2.2 单飞锁 vs 互斥锁
相信机智的你一下就能想到,这不就是互斥锁吗?有什么区别?
互斥锁:
- 请求1拿到锁,去查数据库
- 请求2、请求3等待锁
- 请求1查完释放锁,请求2拿到锁,再去查数据库
- 请求2查完释放锁,请求3拿到锁,再去查数据库
- 问题:虽然同一时间只有一个请求查数据库,但每个请求都要查一次,数据库压力还是很大
单飞锁:
- 请求1去查数据库
- 请求2、请求3等待请求1的结果
- 请求1查完,把结果返回给请求1、请求2、请求3
- 优势:数据库只查一次,其他请求共享结果
用一个类比来理解:
- 互斥锁:食堂阿姨一次只给一个人打饭,其他人排队,每个人都要打一次饭
- 单飞锁:选一个代表去打饭,打一大份回来分给大家
2.3 Java 中的 Singleflight 实现
Java 标准库没有直接提供 Singleflight,但我们可以自己实现一个。相信各位开发者都听说过这个概念,但可能没自己写过。
java
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
public class SingleFlightDemo {
public static void main(String[] args) throws InterruptedException {
SingleFlight<String> sf = new SingleFlight<>();
CountDownLatch latch = new CountDownLatch(10);
AtomicInteger queryCount = new AtomicInteger(0);
// 模拟 10 个并发请求查询同一个用户
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
String result = sf.doExecute("user:123", () -> {
// 模拟查数据库
queryCount.incrementAndGet();
System.out.println("查询数据库: user_id=123");
Thread.sleep(100); // 模拟耗时
return "user_123_data";
});
System.out.println("获取到数据: " + result);
} catch (Exception e) {
e.printStackTrace();
} finally {
latch.countDown();
}
}).start();
}
latch.await();
System.out.println("数据库查询次数: " + queryCount.get());
}
}
输出:
查询数据库: user_id=123
获取到数据: user_123_data
获取到数据: user_123_data
获取到数据: user_123_data
...(10次)
数据库查询次数: 1
可以看到,虽然 10 个并发请求查询同一个用户,但数据库只被查询了一次。
三、单飞锁的实现原理
3.1 核心数据结构
单飞锁的核心数据结构很简单:
java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
public class SingleFlight<T> {
private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();
// 内部类:表示一次调用
private static class Call<T> {
private final CountDownLatch latch = new CountDownLatch(1);
private T value;
private Exception exception;
private int duplicateCount = 0;
}
}
说明:
ConcurrentHashMap存储 key 到 Call 的映射,线程安全CountDownLatch用于让等待的线程阻塞,直到结果准备好Call内部类存储结果、异常和重复请求数量
3.2 Do 方法的执行流程
有
没有
调用 Do key, fn
m 中是否有 key?
获取已有的 call
创建新的 call
存入 m key -> call
执行 fn
等待 wg.Wait
设置 val 和 err
调用 wg.Done
从 m 中删除 key
返回 val, err
文字版说明:
- 检查 map 中是否已经有相同 key 的 call
- 如果有,等待该 call 完成(共享结果)
- 如果没有,创建新的 call,执行 fn,设置结果,通知等待者
3.3 完整实现(简化版)
java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.function.Supplier;
public class SingleFlight<T> {
private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();
private static class Call<T> {
private final CountDownLatch latch = new CountDownLatch(1);
private T value;
private Exception exception;
}
/**
* 执行函数,对于相同的 key,只执行一次
* @param key 唯一标识
* @param supplier 要执行的函数
* @return 执行结果
*/
public T doExecute(String key, Supplier<T> supplier) throws Exception {
// 使用 computeIfAbsent 原子性地获取或创建 Call
Call<T> call = calls.computeIfAbsent(key, k -> new Call<>());
// 如果 call 已经有值(latch 已经 countDown),说明是重复请求
// 这里简化处理,实际应该检查 call 是否已经在执行
synchronized (call) {
if (call.value != null || call.exception != null) {
// 已经有结果了,直接返回
if (call.exception != null) {
throw call.exception;
}
return call.value;
}
}
try {
// 执行函数
call.value = supplier.get();
return call.value;
} catch (Exception e) {
call.exception = e;
throw e;
} finally {
// 通知等待者
call.latch.countDown();
// 清理
calls.remove(key);
}
}
/**
* 等待并获取结果
*/
public T waitAndGet(String key) throws Exception {
Call<T> call = calls.get(key);
if (call == null) {
throw new IllegalStateException("No call for key: " + key);
}
call.latch.await(); // 等待结果
if (call.exception != null) {
throw call.exception;
}
return call.value;
}
}
关键点:
- 使用
ConcurrentHashMap保证线程安全 - 使用
CountDownLatch让等待的线程阻塞 - 使用
synchronized块保证对 Call 对象的原子操作 computeIfAbsent原子性地获取或创建 Call 对象
四、单飞锁的坑与最佳实践
4.1 坑1:长时间阻塞
场景:如果 fn 执行时间很长,所有等待的请求都会阻塞。
问题:
- 假设 fn 执行 10 秒,100 个请求都在等
- 这 100 个请求的响应时间都会变成 10 秒
- 用户可能会超时、重试,导致更多请求堆积
解决方案:
java
import java.util.concurrent.*;
import java.util.function.Supplier;
public class SingleFlightWithTimeout<T> {
private final ConcurrentHashMap<String, CompletableFuture<T>> calls = new ConcurrentHashMap<>();
private final ExecutorService executor = Executors.newCachedThreadPool();
/**
* 带超时控制的执行
*/
public T doExecuteWithTimeout(String key, Supplier<T> supplier, long timeout, TimeUnit unit)
throws Exception {
// 获取或创建 Future
CompletableFuture<T> future = calls.computeIfAbsent(key, k ->
CompletableFuture.supplyAsync(supplier::get, executor)
);
try {
// 等待结果,带超时
return future.get(timeout, unit);
} catch (TimeoutException e) {
// 超时后移除 key,让后续请求重试
calls.remove(key);
throw new RuntimeException("操作超时", e);
} finally {
// 完成后清理
calls.remove(key);
}
}
}
说明:
- 使用
CompletableFuture支持异步执行 - 使用
future.get(timeout, unit)支持超时控制 - 超时后移除 key,让后续请求可以重试
4.2 坑2:错误传播
场景:如果 fn 返回错误,所有等待的请求都会收到相同的错误。
问题:
- 假设 fn 因为临时故障(网络抖动)失败
- 所有等待的请求都失败,没有重试机会
- 可能导致大面积服务不可用
解决方案:
java
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.function.Supplier;
public class SingleFlightWithErrorHandling<T> {
private final ConcurrentHashMap<String, Call<T>> calls = new ConcurrentHashMap<>();
private static class Call<T> {
private final CountDownLatch latch = new CountDownLatch(1);
private T value;
private Exception exception;
}
public T doExecute(String key, Supplier<T> supplier) throws Exception {
Call<T> call = calls.computeIfAbsent(key, k -> new Call<>());
synchronized (call) {
if (call.value != null || call.exception != null) {
if (call.exception != null) throw call.exception;
return call.value;
}
}
try {
call.value = supplier.get();
return call.value;
} catch (Exception e) {
call.exception = e;
// 失败时立即删除 key,让后续请求重试
calls.remove(key);
throw e;
} finally {
call.latch.countDown();
}
}
}
4.3 坑3:内存泄漏
场景:如果 key 的数量无限增长,map 会越来越大。
问题:
- 假设每个请求的 key 都不一样(比如用户ID)
- map 会无限增长,导致内存泄漏
解决方案:
- 定期清理长时间未完成的 call
- 使用 LRU 缓存限制 map 大小
- 或者干脆不用单飞锁,改用其他方案
五、分布式环境下应该用什么锁?
5.1 单飞锁的局限性
相信机智的你已经发现了,单飞锁只能在单机环境下使用。如果部署了多个实例,每个实例都有自己的单飞锁,无法跨实例共享。
可以想象多个食堂(服务实例),每个食堂都有自己的代表去打饭。虽然每个食堂内部只有一个人去打饭,但所有食堂加起来,还是会有多个人去打饭。
数据库 实例C的单飞锁 实例B的单飞锁 实例A的单飞锁 请求3->>实例C 请求2->>实例B 请求1->>实例A 数据库 实例C的单飞锁 实例B的单飞锁 实例A的单飞锁 请求3->>实例C 请求2->>实例B 请求1->>实例A 数据库还是被查了3次! 查询key="user:123" 实例A去查数据库 查询key="user:123" 实例B也去查数据库 查询key="user:123" 实例C也去查数据库
文字版说明:每个实例都有自己的单飞锁,无法跨实例共享,数据库还是会被多次查询。
5.2 分布式锁的几种方案
在分布式环境下,需要使用分布式锁。常见的方案有:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Redis 分布式锁 | 性能高、实现简单 | 需要处理锁过期、主从切换问题 | 高并发、对一致性要求不高 |
| Zookeeper 分布式锁 | 强一致性、可靠性高 | 性能较低、实现复杂 | 对一致性要求高 |
| 数据库锁 | 简单、不需要额外组件 | 性能最低、对数据库压力大 | 并发量低、已有数据库 |
5.3 Redis 分布式锁的实现
Redis 分布式锁是最常用的方案,核心是 SET key value NX PX timeout 命令。
java
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class RedisDistributedLock {
private final RedisTemplate<String, String> redisTemplate;
public RedisDistributedLock(RedisTemplate<String, String> redisTemplate) {
this.redisTemplate = redisTemplate;
}
/**
* 尝试获取锁
* @param key 锁的 key
* @param value 锁的 value(用于标识锁的持有者)
* @param timeout 过期时间
* @param unit 时间单位
* @return 是否获取成功
*/
public boolean tryLock(String key, String value, long timeout, TimeUnit unit) {
// NX: key 不存在时才设置
// PX: 设置过期时间(毫秒)
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(key, value, timeout, unit);
return Boolean.TRUE.equals(result);
}
/**
* 释放锁(使用 Lua 脚本保证原子性)
* @param key 锁的 key
* @param value 锁的 value
* @return 是否释放成功
*/
public boolean unlock(String key, String value) {
// Lua 脚本:只有 value 匹配时才删除
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
Long result = redisTemplate.execute(redisScript,
Collections.singletonList(key), value);
return result != null && result == 1L;
}
}
使用示例:
java
import org.springframework.data.redis.core.RedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class UserService {
private final RedisTemplate<String, String> redisTemplate;
private final RedisDistributedLock lock;
private final CacheService cache;
private final DatabaseService db;
public User getUserWithDistributedLock(String userId) throws Exception {
String lockKey = "lock:user:" + userId;
String lockValue = UUID.randomUUID().toString();
// 尝试获取锁
if (!lock.tryLock(lockKey, lockValue, 5, TimeUnit.SECONDS)) {
throw new RuntimeException("获取锁失败");
}
try {
// 查缓存
User data = cache.get(userId);
if (data != null) {
return data;
}
// 查数据库
data = db.query(userId);
cache.set(userId, data, 3600);
return data;
} finally {
// 释放锁
lock.unlock(lockKey, lockValue);
}
}
}
5.4 Redis 分布式锁的坑
坑1:锁过期时间设置不当
- 设置太短:业务还没执行完,锁就过期了,其他请求拿到锁,导致并发问题
- 设置太长:如果服务挂了,锁要等很久才过期,影响可用性
解决方案:
- 使用"看门狗"机制,自动续期
- 或者使用 Redisson 等成熟框架
坑2:主从切换导致锁丢失
- 客户端A在主节点拿到锁
- 主节点挂了,从节点升级为主节点
- 客户端B在新的主节点也拿到锁
- 两个客户端同时持有锁
解决方案:
- 使用 Redlock 算法(多节点投票)
- 或者使用 Zookeeper
5.5 Zookeeper 分布式锁的实现
Zookeeper 通过临时顺序节点实现分布式锁,天然支持锁的有序性和可靠性。
是
否
创建临时顺序节点
获取所有子节点
自己是最小节点?
获得锁
监听前一个节点
前一个节点删除
执行业务
删除自己的节点
文字版说明:
- 每个请求创建一个临时顺序节点
- 判断自己是否是最小节点
- 如果是,获得锁
- 如果不是,监听前一个节点
- 前一个节点删除时,自己成为最小节点,获得锁
优点:
- 强一致性(ZAB 协议)
- 锁释放可靠(临时节点自动删除)
- 支持"公平锁"(按顺序获取锁)
缺点:
- 性能较低(需要创建节点、监听节点)
- 实现复杂
- 依赖 Zookeeper 集群
六、如何选择合适的锁?
6.1 选型决策树
单机
分布式
是
否
高
中
低
需要加锁?
单机还是分布式?
需要共享结果?
对一致性要求?
单飞锁
互斥锁/读写锁
Zookeeper 分布式锁
Redis 分布式锁
数据库锁
性能最优
简单可靠
强一致性
性能与一致性平衡
简单但性能差
6.2 具体场景分析
场景1:防止缓存击穿(单机)
- 选择:单飞锁
- 理由:只让一个请求查数据库,其他请求共享结果,性能最优
场景2:防止缓存击穿(分布式)
- 选择:Redis 分布式锁 + 本地缓存
- 理由:分布式锁保证只有一个实例查数据库,本地缓存减少网络开销
场景3:库存扣减(高并发)
- 选择:Redis 分布式锁 + Lua 脚本
- 理由:性能要求高,用 Lua 脚本保证原子性
场景4:订单创建(强一致性)
- 选择:Zookeeper 分布式锁
- 理由:订单不能重复创建,需要强一致性
场景5:定时任务调度(防止重复执行)
- 选择:数据库锁(乐观锁或悲观锁)
- 理由:并发量低,不需要额外组件
七、面试常见问题
7.1 单飞锁相关
Q1:单飞锁和互斥锁有什么区别?
单飞锁是"共享结果",互斥锁是"排队执行"。单飞锁只让一个请求执行,其他请求等待并共享结果;互斥锁让每个请求都执行一次,只是排队执行。单飞锁适合读多写少的场景,互斥锁适合写场景。
Q2:单飞锁有什么坑?
主要有三个坑:
- 长时间阻塞:如果 fn 执行时间长,所有请求都会阻塞
- 错误传播:如果 fn 失败,所有请求都会失败
- 内存泄漏:如果 key 无限增长,map 会越来越大
Q3:单飞锁在分布式环境下还能用吗?
不能直接用,因为每个实例都有自己的单飞锁,无法跨实例共享。需要配合分布式锁使用,或者使用分布式单飞锁(如 groupcache)。
7.2 分布式锁相关
Q4:Redis 分布式锁如何实现?
核心是 SET key value NX PX timeout 命令,NX 保证只有 key 不存在时才设置,PX 设置过期时间。释放锁时用 Lua 脚本保证原子性。
Q5:Redis 分布式锁有什么问题?
主要问题:
- 锁过期时间设置不当
- 主从切换导致锁丢失
- 没有可重入性
解决方案:使用 Redisson 等成熟框架,或使用 Redlock 算法。
Q6:Redis 分布式锁和 Zookeeper 分布式锁有什么区别?
Redis 优点:性能高、实现简单
Redis 缺点:需要处理锁过期、主从切换问题
Zookeeper 优点:强一致性、可靠性高、支持公平锁
Zookeeper 缺点:性能较低、实现复杂、依赖 Zookeeper 集群
八、总结
相信你看完这篇,对单飞锁和分布式锁有了更深入的理解。最后总结一下核心观点:
1. 单飞锁的核心是"共享结果"
不是让请求排队执行,而是让请求共享结果。这样既减少了数据库压力,又不会让请求等太久。
2. 单飞锁有坑,需要谨慎使用
长时间阻塞、错误传播、内存泄漏,这三个坑都需要处理。使用 DoChan 方法可以解决超时问题。
3. 分布式环境下需要分布式锁
单飞锁只能在单机使用,分布式环境需要 Redis 分布式锁或 Zookeeper 分布式锁。
4. 选型要看场景
没有银弹,只有权衡。单机用单飞锁,分布式用 Redis 或 Zookeeper,具体看一致性和性能要求。
5. 不要过度设计
如果并发量不高,用数据库锁就够了;如果只是防止缓存击穿,单飞锁就够了。不要为了用分布式锁而用分布式锁。