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

 更新时间:2026年05月24日 15:10:05   作者:山沐与山  
本文详细介绍了双重判定锁在分布式系统的应用,通过两次检查避免了不必要的数据库查询,适用于缓存击穿场景,提升系统效率与性能,感兴趣的可以了解一下

前言

这篇是微服务全家桶系列的学习笔记,这次整理的是分布式场景下的双重判定锁(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 伪代码表示

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

// ✅ 正确:每个短链接一把锁
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.001
cachePenetrationBloomFilter.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() 阻塞等待保证最终一致性

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

到此这篇关于Redis双重判定锁的实现(缓存击穿的终极解决方案)的文章就介绍到这了,更多相关Redis双重判定锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • Deepin UOS编译安装Redis的实现步骤

    Deepin UOS编译安装Redis的实现步骤

    本文主要介绍了Deepin UOS编译安装Redis的实现步骤,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-01-01
  • Redis Value过大问题(键值过大)

    Redis Value过大问题(键值过大)

    这篇文章主要介绍了Redis Value过大问题(键值过大),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2020-12-12
  • Redis持久化深入详解

    Redis持久化深入详解

    这篇文章主要介绍了Redis持久化深入详解,讲解的还是比较详细的,有感兴趣的同学可以学习下
    2021-03-03
  • Redis实现分布式锁的几种方法总结

    Redis实现分布式锁的几种方法总结

    这篇文章主要介绍了Redis实现分布式锁的几种方法总结的相关资料, Redis实现与Zookeeper实现和数据库实现,需要的朋友可以参考下
    2017-07-07
  • Redis之Jedis中zset类型使用方式

    Redis之Jedis中zset类型使用方式

    zadd的score为double类型,添加多个元素需用map;zrange返回List,zrangeWithScore返回List<Tuple>,zcard和zrem返回Long,zscore返回Double包装类需注意null,zrank返回Long包装类
    2025-09-09
  • 浅谈Redis分片集群搭建及其原理

    浅谈Redis分片集群搭建及其原理

    本文主要介绍了Redis分片集群搭建及其原理,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-06-06
  • Redis客户端工具之RedisInsight的下载方式

    Redis客户端工具之RedisInsight的下载方式

    RedisInsight是Redis官方提供的图形化客户端工具,下载步骤包括访问Redis官网、选择RedisInsight、下载链接、注册信息、安装并测试连接
    2025-03-03
  • Redis实现分布式锁的示例代码

    Redis实现分布式锁的示例代码

    分布式锁其实就是,控制分布式系统不同进程共同访问共享资源的一种锁的实现,本文就来介绍一下Redis实现分布式锁的示例代码,具有一定的参考价值,感兴趣的可以了解一下
    2024-08-08
  •  Redis 串行生成顺序编码的方法实现

     Redis 串行生成顺序编码的方法实现

    本文主要介绍了 Redis 串行生成顺序编码的方法实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2022-04-04
  • Redis如何实现计数统计

    Redis如何实现计数统计

    这篇文章主要介绍了Redis如何实现计数统计方式,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教
    2024-04-04

最新评论