一、引言:从一次“慢查询”说起
假设你打开一个电商App的首页,看到的是商家列表。如果每次加载都要等数据库查询完再渲染,你会发现页面一直在转圈圈。这就是典型的**“数据库成了瓶颈”**。
解决思路其实很朴素:把数据提前放在一个更快的地方,下次再来时直接取走。
这就是缓存。
本文将基于《黑马点评》项目的“商户查询缓存”实战,带你从零到一理解并实现一套生产级缓存方案,涵盖:
- 缓存的核心概念与更新策略
- 缓存穿透、击穿、雪崩的解决方案
- 互斥锁与逻辑过期的代码实现
- 生产环境的最佳实践
二、缓存是什么?为什么它能提升性能?
2.1 一句话定义
缓存就是数据的“临时副本”,放在读写速度极快的存储介质(如内存)中,用于加速数据访问。
2.2 收益与成本
| 收益 | 成本 |
|---|---|
| ✅ 降低数据库负载 | ❌ 数据一致性问题(缓存与DB数据不同步) |
| ✅ 降低响应时间(毫秒级返回) | ❌ 代码维护成本增加(缓存击穿/穿透/雪崩) |
| ✅ 提升系统吞吐量 | ❌ 运维成本(Redis集群、内存监控) |
关键思想:TRADEOFF。并非所有业务都适合加缓存,需要对收益和成本做权衡。对于“商户类型”这种读多写少的数据,缓存收益远大于成本,是典型的适用场景。
三、缓存更新策略:怎么保证缓存与DB一致?
3.1 三种基本策略
| 策略 | 核心思想 | 适用场景 |
|---|---|---|
| 内存淘汰 | Redis 内存满时自动淘汰(LRU/LFU) | 作为兜底,不单独依赖 |
| 超时剔除 | 给 Key 设置 TTL(过期时间) | 可容忍短暂不一致 |
| 主动更新 | 数据变更时主动操作缓存 | 对一致性要求较高 |
3.2 生产级方案:Cache Aside + TTL 兜底
一句话概括:查询以缓存优先,写入先更新数据库再淘汰缓存,最后设置过期时间保底。
读流程:
- 先查 Redis → 命中则直接返回
- 未命中则查 MySQL → 写入 Redis → 设置 TTL → 返回
写流程:
- 先更新 MySQL
- 再删除 Redis 缓存(不更新,只删除!)
- 下一次查询时,缓存未命中,自动从 DB 重建
⚠️为什么是“先更新DB,再删缓存”?
如果先删缓存再更新DB,在高并发下,可能刚删完缓存,另一个线程就把旧数据查进缓存了,导致数据脏读。
3.3 更新缓存的“铁律”
- 只删除,不更新:更新缓存容易产生并发脏写问题,删除后让下一次查询重建更安全。
- 设置TTL兜底:即使删除缓存失败,TTL到期后也会自动淘汰,最终保证一致性。
四、缓存三大杀手:穿透、击穿、雪崩
4.1 缓存穿透
现象:查询一个不存在的数据,缓存和数据库都没有,导致每次请求都打到DB。
解决方案:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 缓存空对象 | 对不存在的Key,缓存null或"[]",设置短TTL | 简单,但浪费内存(可接受) |
| 布隆过滤器 | 先将所有存在的Key存到BloomFilter,查询前先过滤 | 内存占用小,但存在误判率 |
4.2 缓存击穿
现象:某个热点Key(如首页商铺列表)突然过期,大量请求同时打到DB。
解决方案:互斥锁(只允许一个线程查DB重建缓存)。
具体实现见第五章代码。
4.3 缓存雪崩
现象:大量Key在同一时间同时过期,或Redis宕机,导致所有请求直冲DB。
解决方案:
- 过期时间加随机偏移(比如30分钟 ± 5分钟)
- 搭建Redis集群(主从+哨兵)
- 多级缓存(本地缓存 + Redis)
五、互斥锁解决缓存击穿(完整实现)
5.1 核心思想
当缓存未命中时,只允许一个线程去查数据库并重建缓存,其他线程等待重试,直到缓存被重建完成。
5.2 Redis实现互斥锁的关键命令
# 原子性加锁+设置过期时间(防死锁)SET lock_key unique_value NX EX10NX:Key不存在时才设置(类似SETNX)EX 10:10秒自动释放(防止持有锁的线程崩溃导致的死锁)unique_value:用UUID生成,释放时校验,防止误删他人的锁
5.3 完整代码(可直接复制)
@GetMapping("list")publicResultqueryTypeList(){// 1. 尝试从缓存获取StringcacheKey="shop:type:list";StringcachedJson=stringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存命中,直接返回if(StrUtil.isNotBlank(cachedJson)){List<ShopType>list=JSONUtil.toList(cachedJson,ShopType.class);returnResult.ok(list);}// 3. 缓存未命中 → 尝试获取互斥锁StringlockKey="lock:shop:type";StringlockValue=UUID.randomUUID().toString();// 唯一值,释放锁时校验try{// 3.1 尝试加锁(原子操作)BooleanisLocked=stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,10,TimeUnit.SECONDS);if(Boolean.TRUE.equals(isLocked)){// 3.2 获取锁成功 → 双重检查,防止在等待锁期间缓存已被其他线程重建StringdoubleCheck=stringRedisTemplate.opsForValue().get(cacheKey);if(StrUtil.isNotBlank(doubleCheck)){List<ShopType>list=JSONUtil.toList(doubleCheck,ShopType.class);returnResult.ok(list);}// 查询数据库List<ShopType>typeList=typeService.query().orderByAsc("sort").list();if(CollUtil.isNotEmpty(typeList)){// 写入缓存,设置30分钟过期stringRedisTemplate.opsForValue().set(cacheKey,JSONUtil.toJsonStr(typeList),30,TimeUnit.MINUTES);}else{// 防止缓存穿透:缓存空对象(短TTL)stringRedisTemplate.opsForValue().set(cacheKey,"[]",5,TimeUnit.MINUTES);}returnResult.ok(typeList);}else{// 3.3 获取锁失败 → 等待并重试(递归)Thread.sleep(50);returnqueryTypeList();// 递归重试}}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 降级策略:直接查DBList<ShopType>typeList=typeService.query().orderByAsc("sort").list();returnResult.ok(typeList);}finally{// 3.4 释放锁:只释放自己加的锁StringcurrentLockValue=stringRedisTemplate.opsForValue().get(lockKey);if(lockValue.equals(currentLockValue)){stringRedisTemplate.delete(lockKey);}}}5.4 代码关键点拆解
| 细节点 | 为什么这么做? |
|---|---|
setIfAbsent(lockKey, lockValue, 10, SECONDS) | 原子性加锁+设置超时,防止死锁 |
| UUID作为锁值 | 释放锁时校验,防止误删其他线程的锁 |
| 双重检查(Double Check) | 获取锁后二次查缓存,避免重复查库 |
Thread.sleep(50)+ 递归重试 | 获取锁失败的线程等待,避免CPU空转 |
finally中释放锁 | 确保无论业务是否异常,锁最终都会被释放 |
六、扩展方案:逻辑过期(极致性能)
6.1 什么是逻辑过期?
物理过期(你平时用的EXPIRE)是 Redis 在 TTL 到期后自动删除 Key。
逻辑过期是:Key永远不过期(不设TTL),但在 Value 里存一个时间戳,由业务代码判断是否“逻辑过期”。
6.2 存储结构
{"data":"店铺类型的JSON数据","expireTime":1736668800000// 逻辑过期时间戳(毫秒)}6.3 读写流程
读流程:
- 获取缓存,反序列化为
RedisData对象 - 判断
expireTime > System.currentTimeMillis()?- 未过期 → 直接返回数据
- 已过期 →返回旧数据(不阻塞!) + 异步线程去更新缓存
写流程(异步):
- 获取互斥锁(只允许一个线程去查库)
- 查数据库,更新
data和expireTime - 释放锁
6.4 互斥锁 vs 逻辑过期 对比
| 维度 | 互斥锁 | 逻辑过期 |
|---|---|---|
| 一致性 | 强(缓存与DB同步更新) | 弱(用户可能看到旧数据) |
| 响应速度 | 较差(等待锁的线程需阻塞) | 极快(永不阻塞) |
| 代码复杂度 | 较低 | 较高(需维护时间戳+异步线程池) |
| 适用场景 | 库存、价格等强一致性场景 | 店铺类型、文章详情等可容忍短暂不一致的场景 |
七、封装Redis工具类:让代码优雅复用
7.1 痛点
每个业务都要写一遍:
Stringjson=stringRedisTemplate.opsForValue().get(key);if(StrUtil.isNotBlank(json)){...}// 加锁、查库、写缓存、释放锁...重复代码散落在各个 Controller,难以维护。
7.2 解决方案
将“缓存操作”的脏活累活全部抽取到独立的RedisCacheClient工具类中。
核心封装方法:
queryWithPassThrough():缓存未命中 → 互斥锁查库 → 写缓存queryWithLogicalExpire():逻辑过期 → 异步重建缓存tryLock()/unlock():统一封装setIfAbsent和delete操作
封装后业务代码极度清爽:
@GetMapping("list")publicResultqueryTypeList(){List<ShopType>list=redisCacheClient.queryWithLogicalExpire(CACHE_KEY,LOCK_KEY,()->typeService.query().orderByAsc("sort").list(),30L,TimeUnit.MINUTES);returnResult.ok(list);}八、总结:这三点最重要
1. 缓存的本质是“用空间换时间”
用内存的高读写速度换取数据库的IO压力降低,代价是需要处理数据一致性问题。
2. 选择缓存策略的核心是“TRADEOFF”
- 对一致性要求高 → 互斥锁(牺牲性能,保证一致)
- 对性能要求高 → 逻辑过期(牺牲一致,换取极致性能)
3. 三个必须记住的命令/模式
SET key value NX EX seconds:原子性加锁+设置过期时间- 先更新DB,再删缓存(而不是更新缓存!)
- 永远设置TTL作为最终一致性的兜底
4. 生产环境的三条铁律
- 宁可暂时读到旧数据,也不能让系统崩掉→ 优先采用逻辑过期方案
- 所有缓存都必须设置 TTL(物理或逻辑)
- 永远不要用
Executors创建线程池→ 改用自定义ThreadPoolExecutor
(因为它底层用了无界队列或无限线程数,高并发下会导致 OOM(内存溢出),生产环境必崩。)