关于应用缓存

 nadia     2020-07-17     1937     0   

欢迎来到银盒子的世界~

为了减少数据库的压力,炫目中要用缓存了,目前的想法是用多级缓存,即:

一级缓存(本地缓存) -> 二级缓存(分布式缓存,redis等) -> DB

一级缓存,找到两个python的扩展包,项目用的flask框架

  1. Tache

    文档地址:https://zhihu.github.io/tache/

    使用博客参考:https://www.ctolib.com/zhihu-tache.html

  2. flask-ache

    使用博客参考:https://www.cnblogs.com/ExMan/p/10162170.html

    https://www.jianshu.com/p/48de3d70892e

一个关于多级缓存的解释博客 https://blog.csdn.net/liuzewei2015/article/details/99706438

(这个帖子写的很详细,贴一下)

1、多级缓存

本地缓存:性能最好;本地缓存大小一般较小;分布式缓存:有一些数据是不适合存放在本地缓存的,比如登录凭证,这个数据和用户有直接的关联,如一个请求访问A服务器凭证存放在A服务器,下一次请求访问B服务器,B缓存中没有凭证,影响登录状态;比本地缓存性能略低一些,主要是因为有网络开销;

多级缓存:一级缓存(本地缓存) -> 二级缓存(分布式缓存)-> DB :

可以避免缓存雪崩(缓存失效,大量请求直达DB),提高系统的可用性:

假设只有一级缓存和数据库:如果一个请求被分派到了服务器1,应用程序首先访问一级缓存(本地缓存),如果没有找到数据,应用程序就访问数据库DB,得到数据,并将数据更新到本地缓存  ;如果又来了一个和用户相关的请求,被分配到了服务器2,由于访问和用户相关的数据,且应用程序在一级缓存中没有找到数据,这个时候仅有一级缓存就出现了问题;如果一个没有和服务器强关联的请求,比如访问热门帖子,则不会有影响,因为先在一级缓存中没有找到数据的话,再到DB中查找;(由于之前使用redis存储登录凭证和用户信息)。使用二级缓存Redis,应用程序首先访问二级缓存,如果没有找到数据,应用程序就访问数据库DB,得到数据,并将数据更新到Redis,再访问服务器2,先从Redis找数据,可以找到,就直接得到数据了。

一级缓存(本地缓存),如果缓存命中,就可以直接返回;如果一级缓存没有,就会去Redis查找,再不行就走传统业务逻辑。这种缓存比单一的缓存工具比起来有如下特点:

1、适应集群环境

2、比单一Redis缓存性能高

3、设计三级数据层分摊了请求量,降低数据库压力

    与所有缓存框架一样,缓存只适合非关键数据,因为缓存更新多少具有延时。

    缓存中存的是频繁调用但不经常变化的数据:按照热门程度排的帖子存到缓存中;

    不建议用Spring整合本地缓存的工具:Spring整合是用一个缓存管理器cache maneger去管理所有的缓存,程序中可能有很多缓存:A缓存帖子,B缓存其他的数据,每个缓存的大小,淘汰策略应该有所不同,可能需要多个缓存管理器,使用多个缓存管理器反而麻烦了;

缓存的更新:

1、当以及缓存失效时,得益于Caffeine本身提供的功能,可以指定方法去Redis获取并更新到缓存中。

2、当业务数据发生变化,调用delete方法直接清除一/二级缓存

3、当新建缓存时,先Redis存入缓存,再通过Redis的消息订阅机制 让本地每台机器接收最新的cache
(一)集成Caffeine

Spring集成流程:
1、导入包
2、在配置文件中配置缓存中最大的数量和过期时间(存到缓存中过多长时间就自动清理掉)
3、主要优化Service层中的方法:增加init方法(并使用@PostConstruct)查找热门帖子及数量:同步加载,如果缓存中没有,则加载到缓存;重构查找帖子方法(先在缓存中找,没有就到数据库中找)和得到帖子行数的方法;
4、压测:有缓存和没有缓存进行性能对比;

       //  ________________________________________________________
        //++++++++++++++++查询帖子列表及帖子数的方法重构+++++++++++++++++++++
        // Caffeine核心接口: Cache, LoadingCache:同步的, AsyncLoadingCache:异步的
        //都是按照key缓存value
     
        @Value("${caffeine.posts.max-size}")
        private int maxSize;
     
        @Value("${caffeine.posts.expire-seconds}")
        private int expireSeconds;
     
     
        //帖子列表缓存
        private LoadingCache<String,List<DiscussPost>> postListCache;
        //帖子数的缓存
        private LoadingCache<Integer,Integer> postRowsCache;
     
        //这两个缓存:应用程序在调用Service方法的时候调用一次初始化方法就可以了,不需要初始化多次
        @PostConstruct
        public void init(){
            postListCache = Caffeine.newBuilder()
                    .maximumSize(maxSize)//这里基于大小和时间;一共有三种:基于大小、时间、引用的驱逐策略
                    .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
                    .build(new CacheLoader<>() {//尝试从缓存中取数据,没有的话访问数据库查找数据存到缓存中
                        @Nullable
                        @Override
                        public List<DiscussPost> load(@NonNull String key) throws Exception {
                            //先判断传进的参数key
                            if (key == null || key.length() == 0) {
                                throw new IllegalArgumentException("参数错误!");
                            }
     
                            String[] params = key.split(":");
                            if (params == null || params.length != 2) {
                                throw new IllegalArgumentException("参数错误!");
                            }
     
                            int offset = Integer.valueOf(params[0]);
                            int limit = Integer.valueOf(params[1]);
     
                            // 可以加二级缓存: Redis -> mysql
     
                            logger.debug("load post list from DB.");
                            return discussPostMapper.selectDiscussPosts(0, offset, limit, 1);
                        }
                    });
     
            // 初始化帖子总数缓存
            postRowsCache = Caffeine.newBuilder()
                    .maximumSize(maxSize)//复用帖子列表的配置参数
                    .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
                    .build(new CacheLoader<Integer, Integer>() {
                        @Nullable
                        @Override
                        public Integer load(@NonNull Integer key) throws Exception {
                            logger.debug("load post rows from DB.");
                            return discussPostMapper.selectDiscussPostRows(key);
                        }
                    });
        }

(二)Caffeine驱逐策略

Caffeine提供三类驱逐策略:基于大小(size-based),基于时间(time-based)和基于引用(reference-based)。

(1)基于大小驱逐,有两种方式:一种是基于缓存大小,一种是基于权重。

(2)Caffeine提供了三种定时驱逐策略:

expireAfterAccess(long, TimeUnit):在最后一次访问或者写入后开始计时,在指定的时间后过期。假如一直有请求访问该key,那么这个缓存将一直不会过期。

expireAfterWrite(long, TimeUnit): 在最后一次写入缓存后开始计时,在指定的时间后过期。

expireAfter(Expiry): 自定义策略,过期时间由Expiry实现独自计算。

(3)基于引用:

Caffeine.weakKeys() 使用弱引用存储key。如果没有其他地方对该key有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。
Caffeine.weakValues() 使用弱引用存储value。如果没有其他地方对该value有强引用,那么该缓存就会被垃圾回收器回收。由于垃圾回收器只依赖于身份(identity)相等,因此这会导致整个缓存使用身份 (==) 相等来比较 key,而不是使用 equals()。
Caffeine.softValues() 使用软引用存储value。当内存满了过后,软引用的对象以将使用最近最少使用(least-recently-used ) 的方式进行垃圾回收。由于使用软引用是需要等到内存满了才进行回收,所以我们通常建议给缓存配置一个使用内存的最大值。 softValues() 将使用身份相等(identity) (==) 而不是equals() 来比较值。可以参考:https://www.jianshu.com/p/9a80c662dac4
2、Redis分布式缓存
(一)Redis过期与淘汰策略

由于Redis缓存是存在内存中,所以非常关心缓存释放、清理的及不及时,需要及时清理避免占用内存空间;还关注缓存失效的问题:缓存穿透、缓存击穿、缓存雪崩

过期策略

Redis会把设置了过期时间的key放在一个独立的字典里,在key过期时并不会立刻删除它。两种策略:i)惰性删除:客户端访问某个key时,Redis会检查key是否过期,若过期则删除;ii)定期扫描:Redis默认每秒执行10次过期扫描(可以配置),扫描策略如下:1、从过期字典中随机选择20个key2、删除20个key中已过期的key3、如果过期的key的比例超过25%,则重复步骤1。

淘汰策略

当Redis占用内存超出最大限制时,可采用策略:noeviction、volatile-ttl、volatile-lru、volatile-random、allkeys-lru、allkeys-random

(二)Redis分布式锁

Redis原子性操作:

1、Redis所有单个命令的执行都是原子性的

2、多个操作也支持事务,及原子性:通过MULTI和EXEC指令包起来

Redis分布式锁:相关命令:

setnx
    

SETNX是『SET if Not eXists』(如果不存在,则SET)将key的值设为value,当且仅当key不存在。若给定的key已经存在,则SETNX不做任何动作。

getset
    

获取旧的值并设置新的值,原子性操作

expire
    

设置键的有效期,原子性操作

del
    

删除,原子性操作

分布式锁:

在修改时,经常需要将数据读取到内存,在内存中修改后再存回去。在分布式应用中,可能多个进程同时执行上述操作,而读取和修改非原子操作,所以会产生冲突。增加分布式锁,可以解决此类问题。

基本原理:

同步锁:在多个线程都能访问到的地方,做一个标记,标识该数据的访问权限。

分布式锁:在多个进程都能访问到的地方,做一个标记,标识该数据的访问权限。

实现方式:

基于数据库实现分布式锁(将锁存到数据库中,多个service访问同一个数据库中的锁):在数据库上维护一张表,这张表上存储的是锁的信息,如哪个节点获得了锁、锁的是什么数据等信息。然后当很多个节点同时要修改某个数据时,他们要先到数据库中去获得表锁,并查找这张表是否有记录,如果有的话,就放弃更改数据,如果没有数据,就写入一条数据,表示该节点获得了锁。当获得锁的节点完成数据修改之后,再删除数据库中这张表中的记录,表示释放了锁。基于Redis实现分布式锁。基于Zookeeper实现分布式锁。

Redis实现分布式锁的原则:

1、安全属性:独享。在任意时刻,只有一个客户端持有锁。

2、活性A:无死锁。即便持有锁的客户端崩溃或者网络被分裂,锁仍然可以被获取。

3、活性B:容错。只要大部分Redis节点都活着,客户端就可以获取和释放锁。

单redis实例实现分布式锁:

1、获取锁使用Redis命令

2、通过Lua脚本释放锁

这样可以避免删除别的客户端获取成功的锁:

A加锁----A阻塞----因超时释放锁(锁超时自动被释放)----B加锁----A恢复----释放锁(之前A已经释放锁了,这里使用Redis命令来释放锁的话,就把B的锁给释放了,产生了问题)

多Redis实例实现分布式锁:

Redlock算法,该算法有现成的实现,其Java版本的库为Redission

1、获取当前Unix时间,为毫秒

2、尝试依次从N个实例,使用相同的key和随机值获取锁,并设置响应时间。如果服务器没有在规定的时间内响应,客户端应该尽快尝试另外一个Redis实例。

3、客户端使用当前时间减去开始获取锁的时间,得到获取锁使用的时间。当且仅当大多数Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算取得成功。

4、如果取到了锁,key的真正有效时间等于有效时间减去获取锁使用的时间。

5、如果获取锁失败,客户端应该在所有的Redis实例上进行解锁。
(三)Redis缓存穿透、缓存雪崩、缓存击穿

由于Redis缓存是存在内存中,所以非常关心缓存释放、清理的及不及时,需要及时清理避免占用内存空间;还关注缓存失效的问题:缓存穿透、缓存击穿、缓存雪崩
1、缓存穿透

查询根本不存在的数据,是的请求直达存储层; 其负载过大,甚至宕机。

解决方法:(1)缓存空对象:输出层未命中后,仍然将空值存入缓存层;再次访问该数据时,缓存层会直接访问空值。(2)布隆过滤器:将所有存在的key提前存入布隆过滤器,在访问缓存层之前,先通过过滤器拦截,若请求的是不存在的key,则直接返回空值。

2、缓存击穿

一份热点数据,它的访问量非常大。在其缓存失效(如过期淘汰等)的瞬间,大量请求直达存储层,导致服务崩溃。

解决方法(1)加互斥锁:对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存取值。

(2)永不过期:不设置过期时间,所以不会出现上述问题,这是“物理”上的不过期。为每个value设置逻辑过期时间,当发现该值逻辑过期时,使用单独的线程重建缓存。

3、缓存雪崩

由于某些原因,缓存层不能提供服务,导致所有的请求直达存储层,造成存储层宕机。

解决方案:(1)避免同时过期:设置过期时间时,加一个随机数,避免大量的key同时过期

(2)构建高可用的Redis缓存:部署多个Redis实例,个别节点宕机,依然可以保持服务的整体可用

(3)构建多级缓存:增加本地缓存,在存储层前面多加一级缓存,降低请求直达存储层的几率。

(4)启动限流和降级措施:对存储层增加限流措施,当请求超出限制时,对其提供降级服务。
 

发表评论