双重判定锁详解:缓存击穿的终极解决方案
前言
这篇是微服务全家桶系列的学习笔记,这次整理的是分布式场景下的双重判定锁(Double-Checked Locking,简称 DCL)。
最近在做短链接跳转这块业务,遇到了一个挺有意思的问题:缓存里没数据的时候,一堆请求同时涌进来,全都去查数据库,数据库直接被打趴了 。你想想,一个热点短链接每秒几万次访问,缓存一过期,这几万个请求全部打到 MySQL,这谁顶得住?
后来引入了分布式锁,但又发现一个问题:锁是加上了,可第一个请求把数据写回缓存后,后面排队的请求拿到锁还是傻乎乎地去查数据库。这不是多此一举吗?
这就是双重判定锁要解决的问题------锁外检查一次,锁内再检查一次,既保证了并发安全,又避免了无谓的数据库查询。
🏠个人主页:山沐与山
文章目录
一、缓存的三大经典问题
在聊双重判定锁之前,得先搞清楚为什么需要它。缓存在分布式系统里基本是标配了,但用不好就会出问题。
1.1 缓存穿透
什么情况? 用户老是查一个根本不存在的数据,每次都打到数据库。
比如有人恶意请求 id=-1 的数据,数据库里根本没有,缓存自然也存不了,每次请求都穿透到数据库。
怎么解决?
布隆过滤器:先判断数据可能不可能存在空值缓存:查不到也缓存个占位符,下次直接返回
1.2 缓存击穿
什么情况? 热点 Key 突然过期,大量请求同时打到数据库。
这是本文的重点。假设有个爆款短链接,每秒 10 万次访问,缓存过期的那一瞬间,10 万个请求全部去查数据库。这不是击穿是什么?
怎么解决?
分布式锁:只让一个请求去查库,其他人等着双重判定锁:拿到锁后再检查一次,避免重复查库
1.3 缓存雪崩
什么情况? 大量 Key 同时过期,数据库压力骤增。
怎么解决?
- 随机过期时间:别让大家同时过期
- 永不过期策略:后台异步更新
1.4 三者对比
| 问题 | 触发条件 | 危害 | 解决方案 |
|---|---|---|---|
缓存穿透 |
查询不存在的数据 | 数据库被无效请求打满 | 布隆过滤器 + 空值缓存 |
缓存击穿 |
热点 Key 过期 |
瞬时高并发打到数据库 | 分布式锁 + 双重判定 |
缓存雪崩 |
大量 Key 同时过期 |
数据库持续高压 | 随机过期时间 |
二、什么是双重判定锁
2.1 核心思想
双重判定锁的核心就三步:
- 第一次检查(锁外):先看缓存有没有,有就直接返回,不用加锁
- 获取锁:缓存没有才去抢锁
- 第二次检查(锁内):拿到锁后再看一眼缓存,因为等锁的时候别人可能已经把数据放进去了
为什么要检查两次?举个例子就明白了。
2.2 一个生动的例子
假设食堂打饭,窗口只有一个阿姨(数据库),学生们排队(请求)。
没有双重判定:
学生A看到菜没了 → 叫阿姨去厨房拿
学生B看到菜没了 → 也叫阿姨去厨房拿
学生C看到菜没了 → 也叫阿姨去厨房拿
... (阿姨被叫烦了)
阿姨跑了一趟拿回来菜,结果后面几个学生还在叫她去拿,因为他们不知道已经有人拿回来了。
有双重判定:
学生A看到菜没了 → 举手说"我去找阿姨"
学生B看到菜没了 → 发现有人举手了,等着
学生C看到菜没了 → 发现有人举手了,等着
学生A叫完阿姨,菜回来了
学生B看了一眼,哦有菜了,直接打
学生C看了一眼,哦有菜了,直接打
关键点 :学生 B 和 C 等到可以行动的时候,先看一眼有没有菜 ,而不是直接去叫阿姨。这就是双重判定------拿到行动权后再确认一次。
2.3 伪代码表示
java
public String getData(String key) {
// [第一次检查] 锁外检查缓存
String value = cache.get(key);
if (value != null) {
return value; // 缓存命中,直接返回
}
// 缓存未命中,获取分布式锁
RLock lock = redissonClient.getLock("lock:" + key);
lock.lock();
try {
// [第二次检查] 锁内再检查一次!
value = cache.get(key);
if (value != null) {
return value; // 其他线程已经加载了,直接用
}
// 确实没有,去查数据库
value = db.query(key);
cache.set(key, value);
return value;
} finally {
lock.unlock();
}
}
看到没?lock.lock() 之后的第一件事不是查数据库,而是再检查一次缓存。因为在你等锁的这段时间里,拿到锁的那个线程可能已经把数据放到缓存里了。
三、实战代码解析
来看一段真实项目中的代码,这是短链接跳转服务的核心逻辑。
3.1 Redis Key 设计
java
public class RedisKeyConstant {
// 短链接跳转缓存:fullShortUrl -> originUrl
public static final String GOTO_SHORT_LINK_KEY = "short-link:goto:%s";
// 空值缓存:标记不存在的短链接
public static final String GOTO_IS_NULL_SHORT_LINK_KEY = "short-link:is-null:goto_%s";
// 分布式锁:防止缓存击穿
public static final String LOCK_GOTO_SHORT_LINK_KEY = "short-link:lock:goto:%s";
}
这里设计了三个 Key:
GOTO_SHORT_LINK_KEY:正常的跳转缓存GOTO_IS_NULL_SHORT_LINK_KEY:空值缓存,防止缓存穿透LOCK_GOTO_SHORT_LINK_KEY:分布式锁的Key
3.2 核心跳转逻辑
java
@SneakyThrows
@Override
public void restoreUrl(String shortUri, ServletRequest request, ServletResponse response) {
// 构建完整短链接
String serverName = request.getServerName();
String serverPort = Optional.of(request.getServerPort())
.filter(each -> !Objects.equals(each, 80))
.map(String::valueOf)
.map(each -> ":" + each)
.orElse("");
String fullShortUrl = serverName + serverPort + "/" + shortUri;
// ==================== 第一次判断(锁外)====================
// [检查点1] 查缓存
String originLink = stringRedisTemplate.opsForValue()
.get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(originLink)) {
// 缓存命中,记录统计后直接跳转
shortLinkStats(fullShortUrl, null, buildStatsRecord(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(originLink);
return;
}
// [检查点2] 布隆过滤器判断
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if (!contains) {
// 布隆过滤器说不存在,那就一定不存在
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// [检查点3] 检查空值缓存
String gotoIsNullShortLink = stringRedisTemplate.opsForValue()
.get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
// 已确认不存在的短链接
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// ==================== 获取分布式锁 ====================
RLock lock = redissonClient.getLock(String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));
lock.lock();
try {
// ==================== 第二次判断(锁内)====================
// [双重检查1] 再查一次缓存
originLink = stringRedisTemplate.opsForValue()
.get(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(originLink)) {
// 其他线程已加载缓存,直接使用
shortLinkStats(fullShortUrl, null, buildStatsRecord(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(originLink);
return;
}
// [双重检查2] 再查一次空值缓存
gotoIsNullShortLink = stringRedisTemplate.opsForValue()
.get(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl));
if (StrUtil.isNotBlank(gotoIsNullShortLink)) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// ==================== 查询数据库 ====================
// 先查路由表拿 gid(因为主表是按 gid 分表的)
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper = Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
if (shortLinkGotoDO == null) {
// 路由表没有,设置空值缓存
stringRedisTemplate.opsForValue()
.set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// 查短链接详情
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, shortLinkGotoDO.getGid())
.eq(ShortLinkDO::getFullShortUrl, fullShortUrl)
.eq(ShortLinkDO::getEnableStatus, 0)
.eq(ShortLinkDO::getDelFlag, 0);
ShortLinkDO shortLinkDO = baseMapper.selectOne(queryWrapper);
// 检查是否存在或过期
if (shortLinkDO == null || (shortLinkDO.getValidDate() != null
&& shortLinkDO.getValidDate().before(new Date()))) {
stringRedisTemplate.opsForValue()
.set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
((HttpServletResponse) response).sendRedirect("/page/notfound");
return;
}
// ==================== 写入缓存并跳转 ====================
stringRedisTemplate.opsForValue()
.set(String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
shortLinkDO.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(shortLinkDO.getValidDate()),
TimeUnit.MILLISECONDS);
shortLinkStats(fullShortUrl, shortLinkDO.getGid(), buildStatsRecord(fullShortUrl, request, response));
((HttpServletResponse) response).sendRedirect(shortLinkDO.getOriginUrl());
} finally {
lock.unlock();
}
}
3.3 代码分层解读
这段代码分成四层,层层递进:
| 层级 | 位置 | 检查内容 | 作用 |
|---|---|---|---|
| 第一层 | 锁外 | 缓存 | 命中直接返回,不加锁 |
| 第二层 | 锁外 | 布隆过滤器 | 快速拒绝不存在的请求 |
| 第三层 | 锁外 | 空值缓存 | 拦截已确认不存在的短链接 |
| 第四层 | 锁内 | 双重判定 | 避免等锁期间的重复查库 |
为什么要这么多层?因为越早返回越好。能在锁外解决的事情,就不要进锁;能在缓存解决的事情,就不要查数据库。
四、完整流程图解
4.1 请求处理流程
用户请求
│
▼
┌───────────────────────┐
│ 构建 fullShortUrl │
└───────────────────────┘
│
┌─────────────────────────┴─────────────────────────┐
│ 无锁区域 │
│ ┌─────────────┐ 命中 ┌──────────────┐ │
│ │ 查缓存 │────────────▶│ 直接跳转 │ │
│ └─────────────┘ └──────────────┘ │
│ │ 未命中 │
│ ▼ │
│ ┌─────────────┐ 不存在 ┌──────────────┐ │
│ │ 布隆过滤器 │────────────▶│ 返回 404 │ │
│ └─────────────┘ └──────────────┘ │
│ │ 可能存在 │
│ ▼ │
│ ┌─────────────┐ 存在 ┌──────────────┐ │
│ │ 空值缓存 │────────────▶│ 返回 404 │ │
│ └─────────────┘ └──────────────┘ │
│ │ 不存在 │
└────────┴──────────────────────────────────────────┘
│
▼
┌───────────────────────┐
│ 获取分布式锁 │
│ lock.lock() │
└───────────────────────┘
│
┌────────┴──────────────────────────────────────────┐
│ 有锁区域 │
│ ┌─────────────┐ 命中 ┌──────────────┐ │
│ │ 再查缓存 │────────────▶│ 直接跳转 │ │
│ │ (双重判定) │ │ (别人加载的) │ │
│ └─────────────┘ └──────────────┘ │
│ │ 仍未命中 │
│ ▼ │
│ ┌─────────────┐ 存在 ┌──────────────┐ │
│ │ 再查空值 │────────────▶│ 返回 404 │ │
│ │ (双重判定) │ └──────────────┘ │
│ └─────────────┘ │
│ │ 仍不存在 │
│ ▼ │
│ ┌─────────────────────────────────────────┐ │
│ │ 查询数据库 │ │
│ │ 路由表 → 短链接表 → 写入缓存 → 跳转 │ │
│ └─────────────────────────────────────────┘ │
└───────────────────────────────────────────────────┘
│
▼
┌───────────────────────┐
│ 释放锁 │
│ lock.unlock() │
└───────────────────────┘
4.2 并发场景时序图
假设三个请求几乎同时到来,缓存为空:
时间轴 ──────────────────────────────────────────────────────▶
请求A ─────┬──────────────────────────────────────────────────
│ 查缓存 → 未命中
│ 查布隆 → 可能存在
│ 查空值 → 不存在
│ 获取锁 ✓
│ 双重判定 → 仍未命中
│ 查数据库...
│ 写入缓存 ◀──────────────── 这时候缓存有值了
│ 释放锁
└──▶ 跳转成功
请求B ───────────┬────────────────────────────────────────────
│ 查缓存 → 未命中
│ 查布隆 → 可能存在
│ 查空值 → 不存在
│ 等待锁... ⏳
│ │
│ ▼ (A释放锁后)
│ 获取锁 ✓
│ 双重判定 → 命中!(A已写入)
│ 释放锁
└──▶ 直接跳转,没查库!
请求C ───────────────────────────────────────────────────┬────
│ 查缓存 → 命中!
└──▶ 直接跳转,没加锁!
看到效果了吧?
- 请求
A:第一个到,老老实实查库 - 请求
B:等到锁后发现缓存已有值,直接用,不查库 - 请求
C:来得晚,连锁都不用加,缓存里直接拿
五、最佳实践与踩坑记录
5.1 锁粒度要细
java
// ✅ 正确:每个短链接一把锁
RLock lock = redissonClient.getLock(
String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl)
);
// ❌ 错误:全局一把锁
RLock lock = redissonClient.getLock("short-link:global-lock");
全局锁会导致所有请求串行化,性能急剧下降。正确的做法是按资源粒度加锁,每个短链接有自己的锁,互不影响。
5.2 先检查正常缓存,再检查空值缓存
有人可能会问:为什么拿到锁后先查正常缓存,而不是先查空值缓存?
java
lock.lock();
try {
// 先查正常缓存
originLink = cache.get(GOTO_KEY);
if (StrUtil.isNotBlank(originLink)) {
return originLink;
}
// 再查空值缓存
gotoIsNull = cache.get(IS_NULL_KEY);
if (StrUtil.isNotBlank(gotoIsNull)) {
return 404;
}
// ...
}
原因是:我们假设大部分请求都是正常的 。如果把空值缓存检查放前面,意味着假设系统经常被攻击。而实际情况是,正常请求远多于恶意请求,所以优先检查正常缓存能减少一次无谓的 Redis 查询。
5.3 空值缓存要设过期时间
java
// 设置 30 分钟过期
stringRedisTemplate.opsForValue()
.set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);
为什么?假设短链接被误删后又恢复了,如果空值缓存永不过期,用户就永远访问不了。30 分钟是个平衡点------既能防止短期内的穿透攻击,又不会影响数据恢复后的正常访问。
5.4 用 lock() 而不是 tryLock()
java
// 当前实现:阻塞等待
lock.lock();
// 为什么不用这个?
// if (!lock.tryLock()) {
// throw new ServiceException("系统繁忙,请稍后再试");
// }
因为短链接跳转是用户的核心操作,不应该因为锁竞争就直接失败。用 lock() 让请求排队,最终都能得到正确结果。用 tryLock() 虽然快,但用户体验差------凭什么我点一下就失败了?
5.5 缓存更新时的清理策略
当数据变更时,记得清理相关缓存:
java
// 移入回收站:删除跳转缓存
public void saveRecycleBin(RecycleBinSaveReqDTO requestParam) {
// ... 更新数据库
stringRedisTemplate.delete(String.format(GOTO_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
}
// 从回收站恢复:删除空值缓存
public void recoverRecycleBin(RecycleBinRecoverReqDTO requestParam) {
// ... 更新数据库
stringRedisTemplate.delete(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, requestParam.getFullShortUrl()));
}
这点容易被忽略。短链接禁用时要删跳转缓存,恢复时要删空值缓存,否则会出现缓存和数据库不一致的问题。
六、常见问题
6.1 布隆过滤器说存在就一定存在吗?
不是。布隆过滤器的特性是:
- 说不存在 → 一定不存在(可信)
- 说存在 → 可能存在(有误判率)
所以即使布隆过滤器判断存在,也还需要后续的检查。项目里配置的误判率是 0.001(千分之一),基本上影响不大。
java
// 预估 1000 万条数据,误判率 0.001
cachePenetrationBloomFilter.tryInit(10000000, 0.001);
6.2 为什么不用读写锁?
其实项目里在另一个场景用了读写锁------修改短链接分组 gid 的时候:
java
// 修改 gid 时加写锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(
String.format(LOCK_GID_UPDATE_KEY, fullShortUrl)
);
RLock wLock = readWriteLock.writeLock();
wLock.lock();
// 统计访问时加读锁
RLock rLock = readWriteLock.readLock();
rLock.lock();
但在跳转这个场景不适合用读写锁。因为跳转时大部分时间是"读缓存",不需要加锁;只有缓存未命中时才需要"写缓存",这时候用普通锁就够了。
6.3 双重判定锁是不是万能的?
不是。它主要解决缓存击穿 问题,对于缓存雪崩 (大量 Key 同时过期)效果有限。雪崩问题需要其他手段:
| 问题 | 解决方案 |
|---|---|
缓存击穿 |
分布式锁 + 双重判定 ✓ |
缓存雪崩 |
随机过期时间、热点数据永不过期 |
缓存穿透 |
布隆过滤器 + 空值缓存 |
6.4 锁的粒度多细合适?
一般按业务 Key 来加锁。比如短链接跳转场景,就按 fullShortUrl 加锁:
java
// 锁的粒度 = 单个短链接
String lockKey = String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl);
粒度太粗(全局锁)会导致串行化,粒度太细(比如按用户 IP)没有意义。原则是:不同的业务资源之间不应该互相阻塞。
七、总结
本文介绍了分布式场景下双重判定锁的设计与实现,重点包括:
- 缓存三大问题:穿透、击穿、雪崩的区别与解决方案
- 双重判定锁原理:锁外检查一次,锁内再检查一次
- 实战代码:短链接跳转服务的完整实现
- 最佳实践:锁粒度、检查顺序、缓存过期时间
核心要点总结:
| 设计点 | 推荐做法 | 原因 |
|---|---|---|
| 锁粒度 | 按业务 Key 加锁 |
避免全局串行化 |
| 检查顺序 | 先正常缓存,后空值缓存 | 假设大部分请求是正常的 |
| 空值缓存过期 | 30 分钟 |
平衡防护效果和数据恢复 |
| 锁类型 | lock() 阻塞等待 |
保证最终一致性 |
双重判定锁本质上是一种减少锁竞争的优化模式。第一次检查让大部分请求快速返回,第二次检查避免重复查库。理解了这个核心思想,在其他场景也能灵活运用。
热门专栏推荐
- Agent小册
- 服务器部署
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- 消息队列合集
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟