游乐游手机版
首页/数据库/文章详情

Redis逻辑过期机制解决缓存击穿问题

时间:2026-06-12 07:07
一、什么是缓存击穿?Redis热点Key失效引发的问题详解 先来理解一个典型场景:某个秒杀活动的商品详情页,平日里访问量一般,但一到活动开始,流量瞬间暴增。这个商品就是所谓的“热点 Key”。Redis缓存击穿,指的就是这个热点 Key 在某个时间点恰好过期了,而就在过期的那个瞬间,海量并发请求一起

一、什么是缓存击穿?Redis热点Key失效引发的问题详解

先来理解一个典型场景:某个秒杀活动的商品详情页,平日里访问量一般,但一到活动开始,流量瞬间暴增。这个商品就是所谓的“热点 Key”。Redis缓存击穿,指的就是这个热点 Key 在某个时间点恰好过期了,而就在过期的那个瞬间,海量并发请求一起涌来。缓存里没有数据,这些请求就直接穿透到数据库——就像防线上被凿了个洞,数据库瞬间承受不住压力,轻则响应变慢,重则直接宕机。理解缓存击穿机制是掌握高并发缓存优化的第一步。

Redis利用逻辑过期解决缓存击穿问题

缓存击穿核心特征:高并发、热点Key、瞬间失效

  1. 高并发:同一时间访问量巨大,系统承载压力极高。
  2. 热点 Key:大量请求都在查询同一个数据对象,资源竞争集中。
  3. 瞬间失效:缓存 TTL 到期,数据物理消失,导致请求直接穿透至后端数据库。

二、互斥锁与逻辑过期方案对比:如何选择缓存击穿解决方案?

面对缓存击穿这个“漏洞”,业界有两种主流解法:一种是互斥锁方案,另一种是逻辑过期方案。它们各有各的特点,也各有各的适用场景。理解两者的区别,有助于在实际项目中做出合理的技术选型。

1. 互斥锁(Mutex Lock)方案

  • 思路:谁发现缓存过期了,谁就去抢一把分布式锁。抢到锁的线程负责查询数据库并写入缓存,其他线程则排队等待。
  • 优点:数据强一致性——用户查询到的绝对是最新数据,适合对数据一致性要求较高的场景。
  • 缺点性能较差。所有请求都得等那一个线程完成缓存重建,如果不巧那个线程执行缓慢或发生故障,后面就会引发灾难性的阻塞,影响整体系统吞吐量。

2. 逻辑过期(Logical Expiration)方案

  • 思路“永不过期”。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现“逻辑”过期后,先返回旧数据,然后异步开启一个后台线程去更新缓存。
  • 优点高可用,性能极佳。用户永远不需要等待,拿了数据就直接返回,响应速度极快。
  • 缺点:数据存在短暂的不一致(在缓存重建完成前,用户看到的是旧数据),属于用一致性换可用性的典型 trade-off。

三、逻辑过期实现原理与RedisData数据结构设计

逻辑过期方案的关键,是不再依赖 Redis 自带的 setex 来控制数据生命周期,而是自己设计一个包装类,人为记录一个过期时间。这个包装类通常命名为 RedisData,它是整个逻辑过期机制的核心数据结构。

1. 数据结构设计

一个容器,用来封装真实的业务数据和逻辑过期时间,方便在查询时进行过期判断:

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

2. 逻辑过期执行流程图解

  1. 查询缓存:从 Redis 取出数据(注意:逻辑过期方案的前提是数据必须预热,如果 Redis 没数据,直接返回空或执行降级处理)。
  2. 判断逻辑过期时间
    • 如果 expireTime > now():数据仍然新鲜,直接返回给调用方。
    • 如果 expireTime <= now()逻辑已过期,需要触发缓存重建流程。
  3. 缓存重建流程
    • 抢锁:尝试获取互斥锁,确保只有一个线程执行重建操作。
    • 抢锁失败:说明已经有其他线程在更新缓存,不要等待,直接返回旧数据给用户。
    • 抢锁成功:再次检查缓存是否已被其他线程更新(Double Check 机制)。如果没更新,则开启独立线程查询数据库并写入缓存;如果已更新,直接释放锁并返回新数据。
  4. 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的),确保用户请求不被阻塞。

四、逻辑过期代码实现:SpringBoot+StringRedisTemplate实战

下面是一段基于 SpringBoot + StringRedisTemplate 的完整实现代码,包含了二次检查(Double Check)逻辑。代码本身不算复杂,但细节值得留意,尤其是在高并发场景下的锁处理和异步重建机制。

1. 缓存预热

因为 Redis 里没有设置 TTL,数据不会自动消失。我们需要在活动开始前把数据“预热”进去,确保缓存中已经有初始数据可供查询。

/**
 * 预热数据到 Redis
 * @param id 商品ID
 * @param expireSeconds 逻辑过期时间(秒)
 */
public void sa veShop2redis(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)

// 线程池:用于异步重建缓存
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.sa veShop2redis(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 以为数据还过期,会再次开启线程去查询数据库。虽然不影响数据正确性,但浪费了系统性能。加上二次检查可以避免不必要的缓存重建,让锁的利用率更高,整体系统的并发处理能力也更强。

来源:https://www.jb51.net/database/365281kkh.htm
上一篇Redis核心知识点详解及Java项目完整实践案例解析 下一篇Redis有序集合SortedSet实现示例
本站内容用于信息整理与展示,如有侵权或内容问题请及时联系处理。

相关推荐

补充同频道和同主题内容,方便继续浏览更多相关内容。

同类最新

继续查看同栏目最近更新的文章。

更多
Redis 7.0增量AOF重写RDB前导码配置详解
数据库 · 2026-07-02

Redis 7.0增量AOF重写RDB前导码配置详解

先说一个几乎所有人都踩过的典型误区:很多人把 aof-use-rdb-preamble yes 当作开启“增量重写”的开关。实际上,这个配置只干了一件事——让重写后的 AOF 文件头部带上 RDB 快照。它解决的是加载速度问题,跟“增量重写”本身的概念压根不是一回事。真正的增量重写,依赖的是 Red

在Python Tornado异步框架中安全执行SQL命令的方法与最佳实践
数据库 · 2026-07-02

在Python Tornado异步框架中安全执行SQL命令的方法与最佳实践

直接在Tornado里用SQLAlchemy同步执行SQL,结果就是阻塞IOLoop,所谓“异步框架里写同步数据库代码”,等于白搭。安全执行的关键不是“怎么写SQL”,而是“怎么不卡住事件循环”。 为什么不能在RequestHandler里直接调用session execute() 因为sessio

利用SQL触发器实现在INSERT数据时自动同步到审计表
数据库 · 2026-07-02

利用SQL触发器实现在INSERT数据时自动同步到审计表

先说结论:可以用触发器把 INSERT 数据同步到审计表,但必须用 AFTER INSERT,并且审计表的字段顺序、类型、字符集得和源表严格一致。否则,轻则写入错位、数据截断,重则直接报错、丢数据。下面把这些坑一个一个掰开说。 能,但必须用 AFTER INSERT,且审计表字段顺序、类型、字符集要

如何用SQL编写按不同工作日统计员工出勤率
数据库 · 2026-07-02

如何用SQL编写按不同工作日统计员工出勤率

在实际业务中,统计不同工作日的出勤率是HR系统里的高频需求。如果直接按日期函数分组,很容易掉进语言环境、索引失效或分母口径的坑里。下面就来拆解具体的实现要点。 必须用 CASE WHEN 将日期映射为固定 weekday 标签(如 Mon )再分组,避免语言环境导致的分组断裂;需过滤 DOW IN

Spring Boot 3动态拼接SQL为何引发严重安全漏洞
数据库 · 2026-07-02

Spring Boot 3动态拼接SQL为何引发严重安全漏洞

SQL注入漏洞的核心成因,本质上是因为用户输入直接参与了SQL语句的字符串拼接,而未采用参数化绑定机制。在MyBatis中使用${}、QueryWrapper中调用apply()与last()、JPA的@Query注解进行拼接等操作,都会绕过PreparedStatement的安全防护。动态字段必须