一、 什么是缓存击穿?
缓存击穿(Cache Breakdown) 是指一个热点 Key(比如某次秒杀活动的商品详情),在某个时间点过期了。恰好在这个时间点,有大量的并发请求访问这个 Key。这些请求发现缓存过期,瞬间全部打到数据库上,就像在防线上凿穿了一个洞,导致数据库压力激增甚至宕机。
核心特征:
- 高并发:访问量巨大。
- 热点 Key:大家都在查同一个数据。
- 瞬间失效:缓存 TTL 到期,数据物理消失。
二、 互斥锁&逻辑过期
面对缓存击穿,通常有两种解法:
1. 互斥锁(Mutex Lock)
- 思路:谁发现缓存过期了,谁就去抢一把锁。抢到锁的人去查数据库写缓存,其他人排队等待。
- 优点:数据强一致性(查到的绝对是新的)。
- 缺点:性能较差。所有人都得等那一个线程干完活,如果不巧那个线程挂了或慢了,后面就是灾难性的阻塞。
2. 逻辑过期(Logical Expiration)
- 思路:“永不过期”。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现“逻辑”过期后,先返回旧数据,然后异步开个线程去后台更新。
- 优点:高可用,性能极佳。用户永远不需要等待,拿了数据就走。
- 缺点:数据存在短暂的不一致(在重建完成前,用户看到的是旧数据)。
三、 逻辑过期的实现原理
我们不使用 Redis 的 setex 来控制生死,而是引入一个包装类 RedisData,人为地记录一个 expireTime。
1. 数据结构设计
我们需要一个容器来封装真实的业务数据和逻辑过期时间:
1 2 3 4 5
| @Data public class RedisData { private LocalDateTime expireTime; // 逻辑过期时间 private Object data; // 真实的业务数据(如 Shop 对象) }
|
2. 执行流程图解
- 查询缓存:从 Redis 取出数据(逻辑过期前提是数据必须预热,如果 Redis 没数据,直接返回空或降级)。
- 判断逻辑时间:
- 如果
expireTime > now():数据新鲜,直接返回。
- 如果
expireTime <= now():逻辑已过期。
- 重建缓存:
- 抢锁:尝试获取互斥锁。
- 抢锁失败:说明有人在更新了,不要等,直接返回旧数据。
- 抢锁成功:再次检查缓存是否已更新(Double Check)。如果没更新,则开启独立线程查库写缓存;如果已更新,直接释放锁并返回新数据。
- 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的)。
四、 代码实现 (Java)
以下是基于 SpringBoot + StringRedisTemplate 的完整实现,包含了二次检查(Double Check)逻辑。
1. 缓存预热
因为 Redis 里没有 TTL,数据不会自己消失。我们需要在活动开始前把数据“预热”进去。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| /** * 预热数据到 Redis * @param id 商品ID * @param expireSeconds 逻辑过期时间(秒) */ public void saveShop2redis(Long id, Long expireSeconds) { // 1. 查询数据库 Shop shop = getById(id); // 2. 封装成 RedisData RedisData redisData = new RedisData(); redisData.setData(shop); // 重点:设置逻辑过期时间 = 当前时间 + 指定秒数 (注意单位是 PlusSeconds) redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds)); // 3. 写入 Redis (不设置 TTL) stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData)); }
|
2. 业务逻辑 (queryWithLogicalExpire)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79
| // 线程池:用于异步重建缓存 private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) { String key = RedisConstants.CACHE_SHOP_KEY + id; // 1. 从 Redis 查询 String shopJson = stringRedisTemplate.opsForValue().get(key); // 2. 如果未命中(未预热),直接返回 null if (StrUtil.isBlank(shopJson)) { return null; }
// 3. 反序列化 RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class); JSONObject data = (JSONObject) redisData.getData(); Shop shop = JSONUtil.toBean(data, Shop.class); LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期 if (expireTime.isAfter(LocalDateTime.now())) { // 未过期,直接返回 return shop; }
// ========================================================== // 5. 已过期,需要缓存重建 // ========================================================== String lockKey = RedisConstants.LOCK_SHOP_KEY + id; // 6. 尝试获取互斥锁 boolean isLock = tryLock(lockKey); if (isLock) { // 6.1 获取锁成功 // 【二次检查 (Double Check)】 // 再次查询 Redis,防止在上一个线程释放锁的瞬间,缓存已经被更新了 shopJson = stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { RedisData newRedisData = JSONUtil.toBean(shopJson, RedisData.class); LocalDateTime newExpireTime = newRedisData.getExpireTime(); // 如果发现已经被更新(不过期了) if (newExpireTime.isAfter(LocalDateTime.now())) { // 释放锁,直接返回新数据,不再开启线程重建 unlock(lockKey); return JSONUtil.toBean((JSONObject) newRedisData.getData(), Shop.class); } } // 6.2 确认依然过期,开启独立线程重建 CACHE_REBUILD_EXECUTOR.submit(() -> { try { // 重建缓存(假设逻辑过期时间 20秒) this.saveShop2redis(id, 20L); } catch (Exception e) { e.printStackTrace(); // 建议使用 log.error } finally { // 释放锁 unlock(lockKey); } }); }
// 7. 【核心】无论是否抢到锁,都直接返回旧数据,绝不等待! return shop; }
// 辅助方法:获取锁 private boolean tryLock(String key) { Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); }
// 辅助方法:释放锁 private void unlock(String key) { stringRedisTemplate.delete(key); }
|
五、 总结
1. 为什么选择逻辑过期?
逻辑过期本质上是一种**“妥协的艺术”**。它牺牲了短暂的数据一致性(用户可能在几百毫秒内看到旧数据),换取了系统在极高并发下的稳定性(Redis 永不阻塞,数据库压力极小)。
2. 为什么要做二次检查 (Double Check)?
如果不加二次检查,在高并发下,线程 B 可能会在线程 A 重建完刚刚释放锁的时候抢到锁。此时线程 B 以为数据还过期,会再次开启线程去查库。虽然不影响正确性,但浪费了性能。加上二次检查可以避免不必要的重建。