Redis实战:彻底攻克缓存穿透与缓存击穿
在上一篇文章中,我们探讨了缓存的更新策略及其在分布式场景下的一致性挑战。今天,我们将视角深入到高并发场景下最令人头秃的两个问题:缓存穿透与缓存击穿。
本文将结合 hm-dianping 项目实战,拆解我们是如何通过封装通用的 CacheClient 工具类,利用空值缓存、互斥锁以及逻辑过期这三把利剑,优雅地应对高并发流量冲击的。同时,文末的知识小窗还将带你深入理解 Java 范型与 JUC 并发编程的精髓。
一、 拨开云雾:理解缓存问题的本质
在分布式系统中,缓存就像是数据库的一道防洪堤坝。一旦这道堤坝出现裂缝,滔天洪水(高并发流量)就会瞬间冲垮后方脆弱的数据库。
1. 缓存穿透 (Cache Penetration)
问题描述:请求直接穿透了缓存层,打到了数据库,但数据库里也没这个数据。
典型场景:恶意用户使用脚本,不断请求 id = -1 或不存在的 UUID。
后果:Redis 命中率为 0,数据库压力激增,可能瞬间宕机。
核心矛盾:数据库中不存在的数据,正常情况下不会被写入缓存,导致后续请求依然无法命中。
2. 缓存击穿 (Cache Breakdown)
问题描述:一个热点 Key(比如微博热搜)在缓存过期的瞬间,遭到海量并发访问。
典型场景:秒杀活动的商品详情页、突发热点新闻。
后果:在缓存重建完成前的几百毫秒内,成千上万的请求同时穿透 Redis 直击数据库。
核心矛盾:缓存重建需要时间(查库 + 写缓存),而在重建期间,并发请求没有被挡住。
二、 核心武器:封装 CacheClient 工具类
为了保持业务代码(Service 层)的整洁,避免在每个查询方法里都写一遍 try-catch 和锁逻辑,我们将缓存控制逻辑抽象封装到了 com.hmdp.utils.CacheClient 中。这不仅是代码复用的体现,更是“高内聚低耦合”设计思想的落地。
1. 应对缓存穿透:空值缓存策略
既然问题的根源是“数据库查不到,所以不写缓存”,那我们反其道而行之:查不到数据,我也给你存一个“空值”。
当数据库返回 null 时,我们将一个特定的空值(如空字符串 "")写入 Redis,并赋予一个较短的 TTL(如 2 分钟)。后续请求再来,Redis 会返回这个“空值”,应用层识别后直接拦截,不再查询数据库。
代码实战 (queryWithCachePassThrough):
1 | /** |
2. 应对缓存击穿:互斥锁策略 (Mutex Lock)
针对强一致性需求,我们采用互斥锁方案。简单来说,就是串行化重建。
当发现缓存失效时,并不是所有线程都去查库。而是让大家先去抢一个“锁”(Redis 的 SETNX)。抢到锁的那个“天选之子”负责查库重建,其他线程则休眠重试。
代码实战 (queryWithMutex):
1 | public <R,ID> R queryWithMutex( |
3. 应对缓存击穿:逻辑过期策略 (Logical Expiration)
互斥锁保证了数据一致性,但牺牲了吞吐量。对于秒杀等对响应时间极度敏感、且能容忍短暂数据不一致的场景,我们采用逻辑过期。
核心原理:
- 永不过期:Redis 物理 key 不设置 TTL,保证随时能取到数据(哪怕是旧的)。
- 逻辑时间:在数据对象内部封装一个
expireTime字段。 - 异步重建:查询时判断是否逻辑过期。如果过期,尝试获取锁。获取锁成功的线程开启独立线程去后台更新数据,自己直接返回旧数据;获取锁失败的线程,也直接返回旧数据。
代码实战 (queryWithLogicExpire):
1 | // 线程池:用于异步重建缓存,避免高并发下创建过多线程耗尽资源 |
:::tip 知识小窗 | JavaSE 高阶特性深度解析
在封装 CacheClient 过程中,我们运用了多个 Java 高阶特性,这里详细拆解一下。
1. 泛型方法与函数式编程
源码回顾:public <R, ID> R queryWithCachePassThrough(..., Function<ID, R> dbFallback, ...)
- 泛型声明
<R, ID>:如果不声明,编译器不知道R和ID是什么。这里的声明意味着:“在这个方法作用域内,R代表返回值类型,ID代表入参类型”。 - 函数式接口
Function<ID, R>:- 是什么:Java 8 引入的接口,代表“接收一个参数
ID,产生一个结果R的函数”。 - 怎么用:调用者通过 Lambda 表达式或方法引用传递具体逻辑。
- 为什么用:为了解耦。
CacheClient只管缓存逻辑,不知道怎么查库(查用户?查商铺?)。通过传入这个函数,我们将“查库”的行为参数化了,交还给调用者定义。 - 示例:
id -> getById(id)或this::getById。
- 是什么:Java 8 引入的接口,代表“接收一个参数
2. ExecutorService 与线程池工厂
源码回顾:ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
- Executors:Java JUC 提供的工厂类,用于快速创建线程池。
- ExecutorService:线程池的顶层接口,管理线程生命周期。
newFixedThreadPool(10):- 作用:创建一个定长线程池,固定持有 10 个线程。
- 适用场景:执行长期的、资源消耗较重的任务(如这里的数据库查询 + 写缓存)。
- 为什么:在逻辑过期策略中,如果每次缓存失效都
new Thread(),面对 1000 QPS 的并发,服务器瞬间就会因为创建过多线程而 OOM(内存溢出)。池化技术限制了最大并发数,起到了削峰和资源保护的作用。
3. CountDownLatch 并发测试神器
在测试缓存击穿时,为了模拟“1000个线程同时请求”的瞬间压力,我们使用了 CountDownLatch。
- 定义:一个同步辅助类,允许一个或多个线程等待其他线程完成操作。
- 用法:
1
2
3
4
5CountDownLatch latch = new CountDownLatch(1000); // 设定门闩计数 1000
// ... 在每个线程执行完任务后
latch.countDown(); // 计数 -1
// ... 主线程
latch.await(); // 阻塞,直到计数变为 0 才继续往下走 - 常见用途:
- 并发起跑线:让所有线程度准备好,同时开始执行。
- 任务汇聚:等待多个子任务全部完成后,主线程再汇总结果(类似 MapReduce)。在测试中,我们用它来确保所有并发请求都跑完了,再去统计耗时和成功率。
:::
结语
通过这篇实战笔记,我们不仅掌握了 Redis 缓存击穿与穿透的硬核解决方案,还深入复习了 Java 泛型与并发编程的知识。在分布式高并发的战场上,没有完美的架构,只有最适合业务场景的权衡。
下一篇,我们将继续学习常见项目的一个重点问题——秒杀系统,以及一些常见的Redis 全局唯一 ID 生成器的设计与实现。