QPS这么高,那就来写个多级缓存吧

本文介绍在高QPS环境下,如何设计并实现多级缓存系统,包括使用Guava Cache作为本地缓存,Redis作为分布式缓存,以及MySQL作为持久化存储。文章详细解释了缓存架构,Guava Cache的封装与使用,以及查询、更新和删除操作的流程。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

 

QPS这么高,那就来写个多级缓存吧 - 掘金
https://juejin.im/post/5c224cd3f265da610e801e8a

 

 

 

QPS这么高,那就来写个多级缓存吧

查询mysql数据库时,同样的输入需要不止一次获取值或者一个查询需要做大量运算时,很容易会想到使用redis缓存。但是如果查询并发量特别大的话,请求redis服务也会特别耗时,这种场景下,将redis迁移到本地减少查询耗时是一种常见的解决方法

多级缓存基本架构

 

基本架构.png

 

说明:存储选择了mysqlredisguava cache。 mysql作为持久化,redis作为分布式缓存, guava cache作为本地缓存。二级缓存其实就是在redis上面再架了一层guava cahe 
二级缓存.png

 

 

guava cache简单介绍

guava cacheconcurrent hashmap类似,都是k-v型存储,但是concurrent hashmap只能显示的移除元素,而guava cache当内存不够用时或者存储超时时会自动移除,具有缓存的基本功能

封装guava cache

  • 抽象类:SuperBaseGuavaCache.java
@Slf4j
public abstract class SuperBaseGuavaCache<K, V> {
    /**
     * 缓存对象
     * */
    private LoadingCache<K, V> cache;

    /**
     * 缓存最大容量,默认为10
     * */
    protected Integer maximumSize = 10;

    /**
     * 缓存失效时长
     * */
    protected Long duration = 10L;

    /**
     * 缓存失效单位,默认为5s
     */
    protected TimeUnit timeUnit = TimeUnit.SECONDS;

    /**
     * 返回Loading cache(单例模式的)
     *
     * @return LoadingCache<K, V>
     * */
    private LoadingCache<K, V> getCache() {
        if (cache == null) {
            synchronized (SuperBaseGuavaCache.class) {
                if (cache == null) {
                    CacheBuilder<Object, Object> tempCache = null;

                    if (duration > 0 && timeUnit != null) {
                        tempCache = CacheBuilder.newBuilder()
                            .expireAfterWrite(duration, timeUnit);
                    }

                    //设置最大缓存大小
                    if (maximumSize > 0) {
                        tempCache.maximumSize(maximumSize);
                    }

                    //加载缓存
                    cache = tempCache.build( new CacheLoader<K, V>() {
                        //缓存不存在或过期时调用
                        @Override
                        public V load(K key) throws Exception {
                            //不允许返回null值
                            V target = getLoadData(key) != null ? getLoadData(key) : getLoadDataIfNull(key);
                            return target;
                        }
                    });
                }


            }
        }

        return cache;
    }

    /**
     * 返回加载到内存中的数据,一般从数据库中加载
     *
     * @param key key值
     * @return V
     * */
    abstract V getLoadData(K key);

    /**
     * 调用getLoadData返回null值时自定义加载到内存的值
     *
     * @param key
     * @return V
     * */
    abstract V getLoadDataIfNull(K key);

    /**
     * 清除缓存(可以批量清除,也可以清除全部)
     *
     * @param keys 需要清除缓存的key值
     * */
    public void batchInvalidate(List<K> keys) {
        if (keys != null ) {
            getCache().invalidateAll(keys);
            log.info("批量清除缓存, keys为:{}", keys);
        } else {
            getCache().invalidateAll();
            log.info("清除了所有缓存");
        }
    }

    /**
     * 清除某个key的缓存
     * */
    public void invalidateOne(K key) {
        getCache().invalidate(key);
        log.info("清除了guava cache中的缓存, key为:{}", key);
    }

    /**
     * 写入缓存
     *
     * @param key 键
     * @param value 键对应的值
     * */
    public void putIntoCache(K key, V value) {
        getCache().put(key, value);
    }

    /**
     * 获取某个key对应的缓存
     *
     * @param key
     * @return V
     * */
    public V getCacheValue(K key) {
        V cacheValue = null;
        try {
            cacheValue = getCache().get(key);
        } catch (ExecutionException e) {
            log.error("获取guava cache中的缓存值出错, {}");
        }

        return cacheValue;
    }
}
复制代码

抽象类说明:

  • 1.双重锁检查并发安全的获取LoadingCache的单例对象

  • expireAfterWrite()方法指定guava cache中键值对的过期时间,默认缓存时长为10s

  • maximumSize()方法指定内存中最多可以存储的键值对数量,超过这个数量,guava cache将采用LRU算法淘汰键值对

  • 这里采用CacheLoader的方式加载缓存值,需要实现load()方法。当调用guava cacheget()方法时,如果guava cache中存在将会直接返回值,否则调用load()方法将值加载到guava cache中。在该类中,load方法中是两个抽象方法,需要子类去实现,一个是getLoadData() 方法,这个方法一般是从数据库中查找数据,另外一个是getLoadDataIfNull()方法,当getLoadData()方法返回null值时调用,guava cache通过返回值是否为null判断是否需要进行加载,load()方法中返回null值将会抛出InvalidCacheLoadException异常:

  • invalidateOne()方法主动失效某个key的缓存

  • batchInvalidate()方法批量清除缓存或清空所有缓存,由传入的参数决定

  • putIntoCache()方法显示的将键值对存入缓存

  • getCacheValue()方法返回缓存中的值

  • 抽象类的实现类:StudentGuavaCache.java

@Component
@Slf4j
public class StudentGuavaCache extends SuperBaseGuavaCache<Long, Student> {
    @Resource
    private StudentDAO studentDao;

    @Resource
    private RedisService<Long, Student> redisService;

    /**
     * 返回加载到内存中的数据,从redis中查找
     *
     * @param key key值
     * @return V
     * */
    @Override
    Student getLoadData(Long key) {
        Student student = redisService.get(key);
        if (student != null) {
            log.info("根据key:{} 从redis加载数据到guava cache", key);
        }
        return student;
    }

    /**
     * 调用getLoadData返回null值时自定义加载到内存的值
     *
     * @param key
     * @return
     * */
    @Override
    Student getLoadDataIfNull(Long key) {
        Student student = null;
        if (key != null) {
            Student studentTemp = studentDao.findStudent(key);
            student = studentTemp != null ? studentTemp : new Student();
        }

        log.info("从mysql中加载数据到guava cache中, key:{}", key);

        //此时在缓存一份到redis中
        redisService.set(key, student);
        return student;
    }
}
复制代码

实现父类的getLoadData()getLoadDataIfNull()方法

  • getLoadData()方法返回redis中的值
  • getLoadDataIfNull()方法如果redis缓存中不存在,则从mysql查找,如果在mysql中也查找不到,则返回一个空对象

查询

  • 流程图:查询.png
    • 1.查询本地缓存是否命中
    • 2.本地缓存不命中查询redis缓存
    • 3.redis缓存不命中查询mysql
    • 4.查询到的结果都会被load到本地缓存中在返回
  • 代码实现:
public Student findStudent(Long id) {
        if (id == null) {
            throw new ErrorException("传参为null");
        }

        return studentGuavaCache.getCacheValue(id);
    }
复制代码

删除

  • 流程图:

    删除.png

     

  • 代码实现:

@Transactional(rollbackFor = Exception.class)
    public int removeStudent(Long id) {
        //1.清除guava cache缓存
        studentGuavaCache.invalidateOne(id);
        //2.清除redis缓存
        redisService.delete(id);
        //3.删除mysql中的数据
        return studentDao.removeStudent(id);
    }
复制代码

更新

  • 流程图:

    更新.png

     

  • 代码实现:

 @Transactional(rollbackFor = Exception.class)
    public int updateStudent(Student student) {
        //1.清除guava cache缓存
        studentGuavaCache.invalidateOne(student.getId());
        //2.清除redis缓存
        redisService.delete(student.getId());
        //3.更新mysql中的数据
        return studentDao.updateStudent(student);
    }
复制代码

更新和删除就最后一步对mysql的操作不一样,两层缓存都是删除的 


天太冷了,更新完毕要学罗文姬女士躺床上玩手机了

 

 

 

最后: 附: 完整项目地址

关注下面的标签,发现更多相似文章

 

<think>好的,我现在需要设计一个多级缓存系统,结合Redis和MySQL,解决并发读缓存一致性、缓存穿透和缓存雪崩的问题。首先,我需要理清每个问题的具体场景和可能的解决方案。 首先,多级缓存通常包括本地缓存(如Caffeine)和分布式缓存(如Redis),再加上数据库(MySQL)。这样的分层结构可以减轻每一层的压力,提系统性能。本地缓存响应速度快,适合频访问的数据,而Redis作为分布式缓存可以共享数据,避免单点问题,MySQL则是持久化存储。 并发读方面,读多少的情况常见。对于读操作,可以采用缓存优先的策略。用户请求先访问本地缓存,如果未命中再查Redis,最后回源到数据库,并将数据缓存。这里需要考虑缓存击穿的问题,当热点数据失效时,大量请求直接打到数据库,可能导致崩溃。解决方法可以是使用互斥锁(如Redis的SETNX)或者逻辑过期时间,让缓存永不过期,但后台异步更新。 操作则需要保证缓存和数据库的一致性。常见的策略是“先更新数据库,再删除缓存”(Cache-Aside Pattern)。这样可以减少不一致的时间窗口,但可能因为删除缓存失败导致脏数据。可以通过重试机制或者消息队列来确保最终一致性。此外,采用延迟双删策略,在更新数据库前后都删除缓存,可能进一步减少不一致的可能。 缓存一致性是一个复杂的问题,尤其是在并发下。除了上述的更新策略,还可以引入版本号或时间戳,在数据变更时让缓存失效,确保后续读取时重新加载最新数据。另外,订阅数据库的binlog(如使用Canal)可以实时感知数据变化,自动更新缓存,减少延迟。 缓存穿透指的是查询不存在的数据,导致请求直接访问数据库。解决方法包括:对不存在的数据缓存空值(设置较短的过期时间),布隆过滤器快速判断数据是否存在,或者在接口层增加参数校验,过滤非法请求。 缓存雪崩是指大量缓存同时失效,导致数据库压力骤增。可以通过设置随机的过期时间,避免同一时间大量缓存过期。对于Redis集群,确保可用(主从+哨兵或集群模式),防止单点故障。本地缓存可以设置较低的过期时间,减少对Redis的依赖,同时使用Hystrix等熔断机制,在数据库压力大时进行降级处理。 在具体实现上,可能需要将本地缓存(Caffeine)和Redis结合。例如,读取时先查本地缓存,未命中则查Redis,再未命中则查数据库,并将结果Redis本地缓存。对于入,先更新数据库,再删除或更新各级缓存。异步处理缓存更新,例如使用消息队列或线程池,避免阻塞主流程。 此外,监控和日志也很重要,需要实时跟踪缓存命中率、系统负载等指标,及时调整策略。对于缓存穿透和雪崩的防护措施,需要在实际压力测试中验证有效性,并根据业务特点调整参数,比如空值的过期时间、布隆过滤器的容量和误判率等。 总结一下,设计多级缓存系统需要考虑分层结构、读策略、一致性机制、防护措施以及监控和容灾。每个环节都需要根据具体业务场景进行权衡和优化,才能达到性能和可用的目标。</think>### Redis + MySQL多级缓存系统设计方案 #### 一、架构设计 采用**Caffeine(本地缓存) + Redis(分布式缓存) + MySQL**三级结构: 1. **本地缓存**:频访问数据驻留内存,响应速度$<1ms$,设置短TTL(如30秒)减少雪崩风险[^1] 2. **Redis缓存**:共享缓存层,设置较长TTL(如5分钟),采用主从集群保障可用[^3] 3. **MySQL**:持久化存储,通过读分离提升查询能力 $$ \begin{cases} \text{读路径} & : \text{Caffeine} \rightarrow \text{Redis} \rightarrow \text{MySQL} \\ \text{路径} & : \text{MySQL} \rightarrow \text{Redis/Caffeine失效} \end{cases} $$ #### 二、并发读处理 1. **读操作优化**: - **缓存加载策略**:本地缓存未命中时,使用Redis分布式锁保证单线程回源 ```java public Object getData(String key) { Object val = caffeine.get(key); if (val == null) { if (redis.setnx(key+":lock", "1")) { val = mysql.query(key); // 回源数据库 redis.setex(key, 300, val); caffeine.put(key, val); redis.del(key+":lock"); } else { Thread.sleep(100); // 自旋等待 return getData(key); } } return val; } ``` - **热点数据**:设置逻辑过期时间,后台异步更新 2. **操作优化**: - 采用**两阶段更新策略**: 1. 先更新MySQL数据库 2. 再通过消息队列删除Redis本地缓存[^2] - 批量入时合并操作,降低IO压力 #### 三、缓存一致性保障 1. **最终一致性方案**: - 更新数据库后,发送延迟双删指令到消息队列: ```python def update_data(key, new_val): mysql.update(key, new_val) # 阶段1:更新数据库 mq.send_delay("delete_cache", key, delay=1000) # 1秒后二次删除 ``` - 订阅MySQL binlog(通过Canal组件),实时同步缓存变更[^4] 2. **版本控制**: - 存储数据时附带版本号,缓存命中时校验版本 $$ \text{CacheValue} = \{ "ver": 3, "data": \{...\} \} $$ #### 四、防护机制设计 1. **缓存穿透防护**: - **布隆过滤器**:前置校验Key是否存在 - **空值缓存**:对查询结果为null的Key,缓存特殊标记(TTL 2-5分钟) ```redis SETEX user:9999 300 "NULL_FLAG" ``` 2. **缓存雪崩防护**: - **差异化TTL**:基础TTL上增加随机值($TTL_{real} = TTL_{base} + random(0, 300s)$) - **熔断降级**:当缓存失效比例超过阈值时,直接返回降级内容 3. **热点Key治理**: - **本地缓存自动加载**:Guava/Caffeine的refreshAfterWrite机制 - **Key分片**:将hotkey拆分为多个子Key(如`product:123:part1`) #### 五、性能指标监控 | 指标 | 预警阈值 | 应对措施 | |---------------------|-------------|---------------------------| | 缓存命中率 | <85% | 检查Key设计/容量规划 | | Redis QPS | >50,000 | 扩展集群节点 | | 数据库连接池使用率 | >80% | 增加连接数/优化慢查询 |
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值