Redis实战: 利用逻辑过期解决缓存击穿

一、 什么是缓存击穿?

缓存击穿(Cache Breakdown) 是指一个热点 Key (比如某次秒杀活动的商品详情),在某个时间点过期了。恰好在这个时间点,有大量的并发请求访问这个 Key。这些请求发现缓存过期,瞬间全部打到数据库上,就像在防线上凿穿了一个洞,导致数据库压力激增甚至宕机。

核心特征:

  1. 高并发:访问量巨大。
  2. 热点 Key:大家都在查同一个数据。
  3. 瞬间失效:缓存 TTL 到期,数据物理消失。

二、 互斥锁&逻辑过期

面对缓存击穿,通常有两种解法:

1. 互斥锁(Mutex Lock)

  • 思路:谁发现缓存过期了,谁就去抢一把锁。抢到锁的人去查数据库写缓存,其他人排队等待。
  • 优点:数据强一致性(查到的绝对是新的)。
  • 缺点性能较差。所有人都得等那一个线程干完活,如果不巧那个线程挂了或慢了,后面就是灾难性的阻塞。

2. 逻辑过期(Logical Expiration)

  • 思路"永不过期" 。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现"逻辑"过期后,先返回旧数据 ,然后异步开个线程去后台更新。
  • 优点高可用,性能极佳。用户永远不需要等待,拿了数据就走。
  • 缺点:数据存在短暂的不一致(在重建完成前,用户看到的是旧数据)。

三、 逻辑过期的实现原理

我们不使用 Redis 的 setex 来控制生死,而是引入一个包装类 RedisData,人为地记录一个 expireTime

1. 数据结构设计

我们需要一个容器来封装真实的业务数据和逻辑过期时间:

kotlin 复制代码
@Data
public class RedisData {
    private LocalDateTime expireTime; // 逻辑过期时间
    private Object data;              // 真实的业务数据(如 Shop 对象)
}

2. 执行流程图解

  1. 查询缓存:从 Redis 取出数据(逻辑过期前提是数据必须预热,如果 Redis 没数据,直接返回空或降级)。
  2. 判断逻辑时间
    • 如果 expireTime > now():数据新鲜,直接返回。
    • 如果 expireTime <= now()逻辑已过期
  3. 重建缓存
    • 抢锁:尝试获取互斥锁。
    • 抢锁失败 :说明有人在更新了,不要等,直接返回旧数据。
    • 抢锁成功:再次检查缓存是否已更新(Double Check)。如果没更新,则开启独立线程查库写缓存;如果已更新,直接释放锁并返回新数据。
  4. 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的)。

四、 代码实现 (Java)

以下是基于 SpringBoot + StringRedisTemplate 的完整实现,包含了二次检查(Double Check)逻辑

1. 缓存预热

因为 Redis 里没有 TTL,数据不会自己消失。我们需要在活动开始前把数据"预热"进去。

scss 复制代码
/**
 * 预热数据到 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)

typescript 复制代码
// 线程池:用于异步重建缓存
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 以为数据还过期,会再次开启线程去查库。虽然不影响正确性,但浪费了性能。加上二次检查可以避免不必要的重建。

相关推荐
音符犹如代码3 小时前
深入解析 Apollo:微服务时代的配置管理利器
java·分布式·后端·微服务·中间件·架构
爱敲代码的TOM3 小时前
大文件上传下载处理方案-断点续传,秒传,分片,合并
后端·大文件处理
2501_921649493 小时前
外汇与贵金属行情 API 集成指南:WebSocket 与 REST 调用实践
网络·后端·python·websocket·网络协议·金融
VX:Fegn08953 小时前
计算机毕业设计|基于springboot + vue超市管理系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
TVtoPP3 小时前
使用StockTV API获取印度股票数据:完整Python实战指南
开发语言·后端·python·金融·数据分析
IT_陈寒3 小时前
JavaScript 开发者必知的 7 个 ES2023 新特性,第5个能让代码量减少50%
前端·人工智能·后端
Kiri霧3 小时前
Go 字符串格式化
开发语言·后端·golang
JaguarJack4 小时前
PHP 8.6 新增 clamp() 函数
后端·php
桃花岛主704 小时前
go-micro,v5启动微服务的正确方法
开发语言·后端·golang