【Redis】双重判定锁详解:缓存击穿的终极解决方案

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

前言

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

最近在做短链接跳转这块业务,遇到了一个挺有意思的问题:缓存里没数据的时候,一堆请求同时涌进来,全都去查数据库,数据库直接被打趴了 。你想想,一个热点短链接每秒几万次访问,缓存一过期,这几万个请求全部打到 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 伪代码表示

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)没有意义。原则是:不同的业务资源之间不应该互相阻塞


七、总结

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

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

核心要点总结

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

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


热门专栏推荐

等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持

文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊

希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏

如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟

相关推荐
开发者小天21 小时前
python中For Loop的用法
java·服务器·python
flushmeteor21 小时前
JDK源码-基础类-String
java·开发语言
毕设源码-钟学长21 小时前
【开题答辩全过程】以 基于ssm的空中停车场管理系统为例,包含答辩的问题和答案
java
不愿是过客1 天前
java实战干货——长方法深递归
java
小北方城市网1 天前
Redis 分布式锁高可用实现:从原理到生产级落地
java·前端·javascript·spring boot·redis·分布式·wpf
六义义1 天前
java基础十二
java·数据结构·算法
毕设源码-钟学长1 天前
【开题答辩全过程】以 基于SpringBoot的智能书城推荐系统的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
笨手笨脚の1 天前
深入理解 Java 虚拟机-03 垃圾收集
java·jvm·垃圾回收·标记清除·标记复制·标记整理
莫问前路漫漫1 天前
WinMerge v2.16.41 中文绿色版深度解析:文件对比与合并的全能工具
java·开发语言·python·jdk·ai编程
九皇叔叔1 天前
【03】SpringBoot3 MybatisPlus BaseMapper 源码分析
java·开发语言·mybatis·mybatis plus