Redis-缓存-击穿-分布式锁

Redis-缓存-击穿-分布式锁

一、来因宫

1.1、形成原因

一个或多个高频热点key在redis中失效或者被删除,导致大量的请求瞬间穿透缓存,直接查询数据库,造成数据库压力剧增或者宕机
举个栗子🌰:

yaml 复制代码
25年1月12日9点整,发布一条某某国企招考信息
大量的备考人员开始涌进app内查看招考信息,
由于我们的程序设置了缓存机制,发布的文章同步redis中,默认48小时,可以支撑大量用户的访问;
但是招考信息从发布到开始报名期间,假设5天的时间,就是120小时。
就会出现招考信息48小时在redis过期的时间点,存在大量的用户访问请求上来
redis查询不到,大量的请求直接去查询数据库,增大压力、宕机的可能
**这瞬间的访问直接透过Redis到了数据库**

1.2、问题分析

  • 缓存时间太短了
  • key值失效后没能及时同步
  • 即使过期,也不能批量数据查询数据库

1、缓存时间短,加时间,加多少?怎么加?全部加?部分加?

2、过期了,进行同步,怎么同步?周期性同步?同步哪些?

3、不让大批量数据访问,那就加锁🔒,在哪加?怎么加?

xml 复制代码
1、加时间可行,但是需要思考加多长时间,根据实际业务场景,明年招考之时
	总会有考生过来看看去年的公告,对比一下;
	另外一种:
		管理端发布文章同步设置缓存时间,程序自动获取设置时长,
		同时设置下架时间,考试结束后,自动将当前招考简章下架,不对外进行展示
	方法可行,但是不推荐,站在产品角度,用户体验感不好
===================================================================================
2、缓存失效立即同步,可以实现,站在业务角度上,没必要,定时更新,有些key可能是过期后不在使用的,
	浪费内存,程序管理不科学
===================================================================================
3、加锁可行
	大量请求进来,按照顺序第一个进来的拿到锁,去执行逻辑;后面的请求校验锁状态不对,不会往下执行
	**对锁没概念的同学,简单理解成执行前,判断status值为1,当第一个进来,将status设置成0,
	后面的再进来的请求拿到的都是0,只有等第一个释放,status改成1,后面的请求在进行抢夺执行权**		

二、技术方案

2.1、重点分析锁

关于锁的内容也比较重要繁琐,先放个 空链接 内容还未整理,后续再挂上,这里就只简单的说明,增加一下大家的印象

bash 复制代码
上面的图示,展示了利用锁控制只能一个获取到锁的请求进行数据库查询以及重新载入数据,
有效的缓解数据库压力,但是会造成数据拥堵,延长响应时长,性能方面相对较差

这种方式减轻了DB的压力,具体使用的锁类型,可以通过锁相关信息进行了解

bash 复制代码
这种情况说明一下:
	存储缓存的时候,并没有设置失效时间,在参数中添加了时间戳字段
	线程1获取到数据后,去计算时间戳过期了,然后获取锁去新线程更新缓存内容,同时将老数据返回
	线程3进来发现缓存过期,但是锁被线程1拿走了,线程3就直接将老数据返回
	例如:这时候还有新的线程4,此时的线程1已经更新完数据,那么线程4直接将获取到的最新数据返回

这个好处就是节约时间了,提升性能了,但是会导致数据不准确,返回的是老数据,不确定数据是否有更新

2.2、技术实现

第一种:获取锁,其他线程等待更新

java 复制代码
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import redis.clients.jedis.Jedis;

public class RedisCacheWithMutex {
    // TODO 这里只是做演示,具体根据业务封装使用
    private final Jedis jedis = new Jedis("localhost", 6379);
    // 为每个热点key创建独立的锁(避免锁竞争影响其他key)
    // 基于 ReentrantLock 实现的互斥锁实例
    // ReentrantLock 是 Lock 接口的最常用实现
    private final Lock lock = new ReentrantLock();
    
    // 获取数据(缓存+数据库)
    public String getData(String key) {
        // 1. 先查缓存
        String value = jedis.get(key);
        if (value != null) {
            return value; // 缓存命中,直接返回
        }
        
        // 2. 缓存未命中,尝试获取锁重建缓存
        try {
            // 尝试获取锁(可设置超时时间,避免死等)
            if (lock.tryLock()) {
                // 3. 再次查缓存(防止锁释放后其他线程已更新缓存)
                value = jedis.get(key);
                if (value == null) {
                    // 4. 从数据库查询数据
                    value = queryFromDB(key);
                    // 5. 更新缓存(设置合理的过期时间)
                    jedis.setex(key, 3600, value);
                }
                return value;
            } else {
                // 未获取到锁,等待片刻后重试(避免频繁尝试)
                Thread.sleep(50);
                return getData(key); // 递归重试
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            // 释放锁(只有持有锁的线程才需要释放)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    // 模拟从数据库查询数据
    private String queryFromDB(String key) {
        System.out.println("从数据库查询:" + key);
        return "data_" + key; // 实际场景中是数据库查询结果
    }
}

第二种:不设置过期时间,但是参数中添加时间戳

java 复制代码
import com.alibaba.fastjson.JSON;
import redis.clients.jedis.Jedis;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

// 缓存数据包装类,包含实际数据和时间戳
class CacheData<T> {
    private T data;          // 实际数据
    private long timestamp;  // 数据更新时间戳(毫秒)

    public CacheData(T data, long timestamp) {
        this.data = data;
        this.timestamp = timestamp;
    }

    // getter和setter
    public T getData() { return data; }
    public long getTimestamp() { return timestamp; }
}

public class RedisLogicalExpireCache {
    private final Jedis jedis = new Jedis("localhost", 6379);
    private final Lock lock = new ReentrantLock();
    // 数据过期阈值(例如5分钟),超过此时长则需要更新
    private static final long EXPIRE_THRESHOLD = 5 * 60 * 1000;

    /**
     * 获取数据
     * @param key 缓存key
     * @return 数据
     */
    public <T> T getData(String key, Class<T> clazz) {
        // 1. 从Redis获取缓存
        String json = jedis.get(key);
        
        // 2. 缓存不存在,直接查库并初始化缓存
        if (json == null) {
            return loadDataAndInitCache(key, clazz);
        }
        
        // 3. 解析缓存数据
        CacheData<T> cacheData = JSON.parseObject(json, new com.alibaba.fastjson.TypeReference<CacheData<T>>(){});
        
        // 4. 判断是否需要更新(当前时间 - 数据时间戳 > 阈值)
        if (System.currentTimeMillis() - cacheData.getTimestamp() > EXPIRE_THRESHOLD) {
            // 5. 尝试获取锁,只有一个线程去更新数据
            if (lock.tryLock()) {
                // 启动异步线程更新数据,避免阻塞当前请求
                new Thread(() -> {
                    try {
                        updateCache(key, clazz);
                    } finally {
                        lock.unlock(); // 确保锁释放
                    }
                }).start();
            }
            // 无论是否获取到锁,都先返回旧数据
        }
        
        // 6. 返回缓存数据(旧数据或刚初始化的数据)
        return cacheData.getData();
    }

    /**
     * 初始化缓存(首次加载数据时使用)
     */
    private <T> T loadDataAndInitCache(String key, Class<T> clazz) {
        try {
            if (lock.tryLock()) {
                // 双重检查,避免多线程同时初始化
                String json = jedis.get(key);
                if (json != null) {
                    return JSON.parseObject(json, new com.alibaba.fastjson.TypeReference<CacheData<T>>(){}).getData();
                }
                
                // 查库获取数据
                T data = queryFromDB(key, clazz);
                // 存入缓存,不设置过期时间
                jedis.set(key, JSON.toJSONString(new CacheData<>(data, System.currentTimeMillis())));
                return data;
            } else {
                // 未获取到锁,等待片刻后重试
                Thread.sleep(50);
                return getData(key, clazz);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    /**
     * 更新缓存数据
     */
    private <T> void updateCache(String key, Class<T> clazz) {
        // 1. 查库获取最新数据
        T newData = queryFromDB(key, clazz);
        // 2. 更新缓存(使用当前时间戳)
        jedis.set(key, JSON.toJSONString(new CacheData<>(newData, System.currentTimeMillis())));
    }

    /**
     * 模拟从数据库查询数据
     */
    private <T> T queryFromDB(String key, Class<T> clazz) {
        System.out.println("从数据库查询数据: " + key);
        // 实际场景中这里是数据库查询逻辑
        try {
            // 模拟数据库查询耗时
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return JSON.parseObject("{\"id\":\"" + key + "\",\"name\":\"数据" + key + "\"}", clazz);
    }
}
    

三、总结

这是实际业务以及大家分享经验得到的解决方案,有一些感悟和大家说一下

1、实际业务产生问题,先结合现有的资源以及技术栈进行分析问题进行解决

2、网上提供的解决方案同样也是经过验证的,大家也可以放心使用

3、如果遇到的比较偏僻或者不是大众的问题,那么就集思广益,各抒己见,各种尝试,当然项目不会停下来等着我们,紧急刺手问题先解决,能让项目正常的进行运转,利用后续的时间进行优化总结经验

4、学习这些问题以及解决方案,是为了让大家在开发过程中就能想到设计方案,而不是出现问题再来解决,大众化的问题,当做变成的习惯,解决开发时间,养成良好习惯