一聚教程网:一个值得你收藏的教程网站

热门教程

Redis双重判定锁的实现:缓存击穿的终极解决方案

时间:2026-07-05 10:48:22 编辑:袖梨 来源:一聚教程网

前言

这篇是微服务全家桶系列的学习笔记,这次整理的是分布式场景下的双重判定锁(Double-Checked Locking,简称 DCL)。

Redis双重判定锁的实现(缓存击穿的终极解决方案)

最近在做短链接跳转这块业务,遇到了一个挺有意思的问题:缓存里没数据的时候,一堆请求同时涌进来,全都去查数据库,数据库直接被打趴了。你想想,一个热点短链接每秒几万次访问,缓存一过期,这几万个请求全部打到 MySQL,这谁顶得住?

后来引入了分布式锁,但又发现一个问题:锁是加上了,可第一个请求把数据写回缓存后,后面排队的请求拿到锁还是傻乎乎地去查数据库。这不是多此一举吗?

这就是双重判定锁要解决的问题——锁外检查一次,锁内再检查一次,既保证了并发安全,又避免了无谓的数据库查询。

一、缓存的三大经典问题

在聊双重判定锁之前,得先搞清楚为什么需要它。缓存在分布式系统里基本是标配了,但用不好就会出问题。

1.1 缓存穿透

什么情况? 用户老是查一个根本不存在的数据,每次都打到数据库。

比如有人恶意请求 id=-1 的数据,数据库里根本没有,缓存自然也存不了,每次请求都穿透到数据库。

怎么解决?

  • 布隆过滤器:先判断数据可能不可能存在
  • 空值缓存:查不到也缓存个占位符,下次直接返回

1.2 缓存击穿

什么情况? 热点 Key 突然过期,大量请求同时打到数据库。

这是本文的重点。假设有个爆款短链接,每秒 10 万次访问,缓存过期的那一瞬间,10 万个请求全部去查数据库。这不是击穿是什么?

怎么解决?

  • 分布式锁:只让一个请求去查库,其他人等着
  • 双重判定锁:拿到锁后再检查一次,避免重复查库

1.3 缓存雪崩

什么情况? 大量 Key 同时过期,数据库压力骤增。

怎么解决?

  • 随机过期时间:别让大家同时过期
  • 永不过期策略:后台异步更新

1.4 三者对比

问题触发条件危害解决方案
缓存穿透查询不存在的数据数据库被无效请求打满布隆过滤器 + 空值缓存
缓存击穿热点 Key 过期瞬时高并发打到数据库分布式锁 + 双重判定
缓存雪崩大量 Key 同时过期数据库持续高压随机过期时间

二、什么是双重判定锁

2.1 核心思想

双重判定锁的核心就三步:

  1. 第一次检查(锁外):先看缓存有没有,有就直接返回,不用加锁
  2. 获取锁:缓存没有才去抢锁
  3. 第二次检查(锁内):拿到锁后再看一眼缓存,因为等锁的时候别人可能已经把数据放进去了

为什么要检查两次?举个例子就明白了。

2.2 一个生动的例子

假设食堂打饭,窗口只有一个阿姨(数据库),学生们排队(请求)。

没有双重判定

学生A看到菜没了 → 叫阿姨去厨房拿

学生B看到菜没了 → 也叫阿姨去厨房拿

学生C看到菜没了 → 也叫阿姨去厨房拿

... (阿姨被叫烦了)

阿姨跑了一趟拿回来菜,结果后面几个学生还在叫她去拿,因为他们不知道已经有人拿回来了。

有双重判定

学生A看到菜没了 → 举手说"我去找阿姨"

学生B看到菜没了 → 发现有人举手了,等着

学生C看到菜没了 → 发现有人举手了,等着

学生A叫完阿姨,菜回来了

学生B看了一眼,哦有菜了,直接打

学生C看了一眼,哦有菜了,直接打

关键点:学生 BC 等到可以行动的时候,先看一眼有没有菜,而不是直接去叫阿姨。这就是双重判定——拿到行动权后再确认一次

2.3 伪代码表示

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 设计

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 核心跳转逻辑

@SneakyThrows@Overridepublic 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 锁粒度要细

// ✅ 正确:每个短链接一把锁RLock lock = redissonClient.getLock(    String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl));// ❌ 错误:全局一把锁RLock lock = redissonClient.getLock("short-link:global-lock");

全局锁会导致所有请求串行化,性能急剧下降。正确的做法是按资源粒度加锁,每个短链接有自己的锁,互不影响。

5.2 先检查正常缓存,再检查空值缓存

有人可能会问:为什么拿到锁后先查正常缓存,而不是先查空值缓存?

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 空值缓存要设过期时间

// 设置 30 分钟过期stringRedisTemplate.opsForValue()    .set(String.format(GOTO_IS_NULL_SHORT_LINK_KEY, fullShortUrl), "-", 30, TimeUnit.MINUTES);

为什么?假设短链接被误删后又恢复了,如果空值缓存永不过期,用户就永远访问不了。30 分钟是个平衡点——既能防止短期内的穿透攻击,又不会影响数据恢复后的正常访问。

5.4 用 lock() 而不是 tryLock()

// 当前实现:阻塞等待lock.lock();// 为什么不用这个?// if (!lock.tryLock()) {//     throw new ServiceException("系统繁忙,请稍后再试");// }

因为短链接跳转是用户的核心操作,不应该因为锁竞争就直接失败。用 lock() 让请求排队,最终都能得到正确结果。用 tryLock() 虽然快,但用户体验差——凭什么我点一下就失败了?

5.5 缓存更新时的清理策略

当数据变更时,记得清理相关缓存:

// 移入回收站:删除跳转缓存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(千分之一),基本上影响不大。

// 预估 1000 万条数据,误判率 0.001cachePenetrationBloomFilter.tryInit(10000000, 0.001);

6.2 为什么不用读写锁?

其实项目里在另一个场景用了读写锁——修改短链接分组 gid 的时候:

// 修改 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 加锁:

// 锁的粒度 = 单个短链接String lockKey = String.format(LOCK_GOTO_SHORT_LINK_KEY, fullShortUrl);

粒度太粗(全局锁)会导致串行化,粒度太细(比如按用户 IP)没有意义。原则是:不同的业务资源之间不应该互相阻塞

七、总结

本文介绍了分布式场景下双重判定锁的设计与实现,重点包括:

  1. 缓存三大问题:穿透、击穿、雪崩的区别与解决方案
  2. 双重判定锁原理:锁外检查一次,锁内再检查一次
  3. 实战代码:短链接跳转服务的完整实现
  4. 最佳实践:锁粒度、检查顺序、缓存过期时间

核心要点总结

设计点推荐做法原因
锁粒度按业务 Key 加锁避免全局串行化
检查顺序先正常缓存,后空值缓存假设大部分请求是正常的
空值缓存过期30 分钟平衡防护效果和数据恢复
锁类型lock() 阻塞等待保证最终一致性

双重判定锁本质上是一种减少锁竞争的优化模式。第一次检查让大部分请求快速返回,第二次检查避免重复查库。理解了这个核心思想,在其他场景也能灵活运用。

热门栏目