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 以为数据还过期,会再次开启线程去查库。虽然不影响正确性,但浪费了性能。加上二次检查可以避免不必要的重建。

相关推荐
一 乐36 分钟前
绿色农产品销售|基于springboot + vue绿色农产品销售系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·宠物
3***68841 小时前
Spring Boot中使用Server-Sent Events (SSE) 实现实时数据推送教程
java·spring boot·后端
C***u1761 小时前
Spring Boot问题总结
java·spring boot·后端
上进小菜猪1 小时前
基于 YOLOv8 的人体与行人检测智能识别实战 [目标检测完整源码]
后端
Elieal1 小时前
5 种方式快速创建 SpringBoot 项目
java·spring boot·后端
c***69301 小时前
Spring Boot实时推送技术详解:三个经典案例
spring boot·后端·状态模式
Mr -老鬼2 小时前
Rust适合干什么?为什么需要Rust?
开发语言·后端·rust
12344522 小时前
Agent入门实战-一个题目生成Agent
人工智能·后端
IT_陈寒2 小时前
Java性能调优实战:5个被低估却提升30%效率的JVM参数
前端·人工智能·后端
快手技术2 小时前
AAAI 2026|全面发力!快手斩获 3 篇 Oral,12 篇论文入选!
前端·后端·算法