缓存技术介绍与设计

一、为什么要使用缓存?

缓存分为本地缓存和分布式缓存。以 Java 为例,使用自带的 map 或者 guava 实现的是本地缓存,主要的特点是轻量以及快速,生命周期随着 jvm 的销毁而结束,并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。

使用 redis 或 memcached 之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持 redis 或 memcached服务的高可用,整个程序架构上较为复杂。

二、缓存分类

1、本地缓存

只有当前实例自身可以使用,当前大多数场景为集群部署,本地缓存不能做到集群内共享,同时本地缓存使用的是当前实例的内存。以java 为例,本地缓存有自带的Map ,Guava,Caffeine等。

1)本地缓存对比

  • 1、使用Map 或者 ConcurrentHashMap 需要自己设计和编写缓存淘汰策略。
  • 2、Guava为了解决线程安全问题, 核心的数据结构就是按照 ConcurrentHashMap 来的,但是他帮我实现了缓存淘汰策略,监控缓存加载/命中情况。
  • 3、Caffeine支持异步加载方式,直接返回CompletableFutures,相对于GuavaCache的同步方式,它不用阻塞等待数据的载入。GuavaCache是基于LRU的,而Caffeine是基于LRU和LFU的

2)总结

Guava要比使用java原生容器做本地缓存要好,但是Caffeine又再Guava的基础上进行了升级,所以Caffeine目前是作为本地缓存的最佳框架。同时Caffeine也是Spring 5默认支持的Cache。

2、分布式缓存

集群场景内相关服务都可以使用。不占用程序内存。如redis,memcached

1)Redis与Memcached的不同点

Redis 与 Memcached 主要有以下不同:

缓存技术介绍与设计插图

2)redis作为缓存的一些建议

  • 1、避免使用大key(value 超过10K)
  • 2、避免同一时间大量缓存时效(1. 大量key 设置统一时效时间,2.同一时间主动触发大量key时效操作)
  • 3、所有的缓存类信息(数据源存储在DB,都需要有防止缓存穿透的逻辑)
  • 4、避免使用key更新操作。
  • 5、有批量查询操作,使用Mget/pipeline等操作,同时不要在pipeline操作中有一些其他的计算操作等(如rpc)

3)高可用(redis)

redis 的 高可用主要有:哨兵模式和集群模式。

a)哨兵模式

哨兵模式是一主多从的模式,做读写分离的话,可以提高redis读的速度,但是他不能提高redis写的能力,所以他不能支持更高的并发。当哨兵发现master 宕机之后,会重新在从服务中选举出一个新的节点作为master。在这个选举的过程中整个redis服务是不可用的,知道选举结束。

b)redis cluster集群模式

redis官方推荐的集群方式,多主多从。这种集群模式没有中心节点,客户端通过CRC16算法对key进行hash得到一个值,以此来判断这个key应该存放在集群中的哪个主节点上。即便是某个节点宕机,在其选举的过程中,redis服务的其他节点也是可用的。同时根据官方文档介绍,他可以线性扩展到上万个节点(但是官网不推荐超过1000个节点)。所以该方案总体是要优于哨兵模式的。

三、缓存的读取

现在一个比较好的实践方案,就是Cache Aside Pattern。

1、读取规则

先来看一下数据的读取过程,规则是:

先读cache,再读db 。

2、详细步骤

详细步骤如下:

  • 每次读取数据,都从cache里读
  • 如果读到了,则直接返回,称作 cache hit
  • 如果读不到cache的数据,则从db里面捞一份,称作cache miss
  • 将读取到的数据,塞入到缓存中,下次读取的时候,就可以直接命中

四、缓存使用的常见问题

1、数据库数据和缓存数据不一致问题

1)产生原因

数据库的瓶颈是大家有目共睹的,高并发的环境下,很容易I/O锁死。当务之急,就是把常用的数据,给捞到速度更快的存储里去。这个更快的存储,就有可能是分布式的,比如Redis,也有可能是单机的,比如Caffeine。

但一旦加入缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题。使用过java多线程的,肯定会对JMM的模型记忆犹新。一个数值,只要同时在两个地方存储,那就会产生问题。

但缓存系统和数据库,比JMM更加的不可靠。因为分布式组件更加的脆弱,它随时都可能发生问题

2)解决方案

a)db更新,则删除缓存
Ⅰ)为什么是删除,而不是更新?

原因很简单,很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。如果此时及时更新缓存的话,为了保证一致性,必定会使用一个比较大的失误包裹。另外更新缓存的代价有时候是很高的。是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份?也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。

如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?像 mybatis,hibernate,都是有懒加载思想的。

Ⅱ)为什么是先更新db再删除缓存

先更新缓存,再更新db,db更新失败的话,db会回滚,而redis不能回滚,会造成缓存和db数据不一致。先更新db再删除缓存,使用事务包裹删除缓存操作。(db更新失败,缓存不会删除,db更新成功,缓存删除失败,db事务会回滚)

b)使用第三方插件监控数据库bin log

当允许短暂的数据不一致情况,可以使用第三方插件监听db log的方案(databus和canal),同时他也可以在依赖服务的db发生变更时,更新响应缓存(eg:当一个系统依赖权限系统的时候,权限变更)

缓存技术介绍与设计插图2

2、缓存穿透

第二个问题是缓存穿透。

1)产生原因

产生这个问题的原因可能是外部的恶意攻击,例如,对用户信息进行了缓存,但恶意攻击者使用不存在的用户 ID 频繁请求接口,导致查询缓存不命中,然后穿透 DB 查询依然不命中。这时会有大量请求穿透缓存访问到 DB。

2)解决方案

a)缓存空值

对不存在的用户,在缓存中保存一个空对象进行标记,防止相同 ID 再次访问 DB,不过有时这个方法并不能很好解决问题,可能导致缓存中存储大量无用数据。

b)使用布隆过滤器

使用 BloomFilter 过滤器,BloomFilter 的特点是存在性检测,如果 BloomFilter 中不存在,那么数据一定不存在;如果 BloomFilter 中存在,实际数据也有可能会不存在。非常适合解决这类的问题。

3、缓存击穿

1)产生原因

第三个问题是缓存击穿,就是某个热点数据失效时,大量针对这个数据的请求会穿透到数据源。

2)解决方案

a)后台主动刷新

既然问题出现在某个key失效的问题,那我们只要不让这个key失效就行了,可以后台起一个Cron任务,去主动更新key的过期时间。比如,一个key是30分钟过期,可以让cron每29分钟执行一次,更改它的过期时间。

b)检查时更新

在获取缓存值时,可以再请求获取后,修改对应的过期时间。将缓存key的过期时间一起保存在数值中,在get操作后,将过期时间与当前时间进行对比,块过期时,修改对应的时间字段。

c)多级缓存

可以使用多套缓存实例保存缓存值。比如可以采用一级缓存、二级缓存机制,一级失效时间设置短些,二级长些,这样访问一级不存在时,则访问二级。

d)加锁限制访问

此方案为缓存失效后,使用互斥锁保护对数据库的频繁操作,是第一个失效请求到数据库后,设置缓存值,后续的直接命中缓存,从而保护数据库。

public User queryById(int id) throws InterruptedException {   
    User user= (User) redisTemplate.opsForValue().get(id+"");
    if (null==user)
    {
        //排队拿到锁,请求数据库
        if (tryLock(id+""))
        {
            try {
                System.out.println(Thread.currentThread().getName()+"拿到锁请求数据库--》");
                user=deptDao.queryById(id);
                if (user==null)
                {
                    //防止缓存穿透 设置空对象
                    redisTemplate.opsForValue().set(id+"",new User(),30, TimeUnit.MINUTES);
                }else {
                    redisTemplate.opsForValue().set(id+"",user);
                }
 
            }
            finally {
                unlock(id+"");
            }
 
        }else{
             user =(User)redisTemplate.opsForValue().get(id+"");
             if (null==user)
             {
                 System.out.println(Thread.currentThread().getName()+"等待--》");
                 Thread.sleep(100);
                 return queryById(id);
             }
        }
    }
    ...
}

4、缓存雪崩

1)产生原因

第四个问题是缓存雪崩。产生的原因是缓存挂掉,这时所有的请求都会穿透到 DB。

2)解决方案

a)发生之前

缓存失效时间添加随机值,避免同一时间大片缓存同时失效

b)在发生前,做好缓存的高可用

比如是使用 Redis,可以使用 主从+哨兵 ,Redis Cluster 来避免 Redis 全盘崩溃的情况

c)事中
  • 本地缓存 :Caffeine
  • 限流&熔断机制:常见的Hystrix等技术。
d)事后

开启持久化配置,更快的恢复线上数据。

五、集群下本地缓存更新

服务集群部署多实例的情况下,可以使用redis队列或者借助第三方消息队列来实现集群下所有实例的本地缓存统一修改。如下:

缓存技术介绍与设计插图4

六、热key

1、什么是热key?

  • MySQL等数据库会被频繁访问的热数据
  • redis的被密集访问的key

2、热key解决方案

1)热key探测的流程

热key问题归根到底就是如何找到热key,并将热key放到jvm内存的问题。只要该key在内存里,我们就能极快地来对它做逻辑,内存访问和redis访问的速度不在一个量级,流程如下:

缓存技术介绍与设计插图6

但是如何保证本地缓存存储的都是热key,如何提高他们的命中率,也是我们需要考虑的问题。

我们需要一个可以高效率的能够准确探测出热key的工具,这个工具可以我们自己开发,也可以使用一些开源框架。

2)热key探测的关键性指标

热key探测的关键性指标如下:

a)实时性

key往往是突发性瞬间就热了,根本不给你再慢悠悠手工去配置中心添加热key再推送到jvm的机会。它大部分时间不可预知,来得也非常迅速。如果短时间内没能进到内存,就有redis集群被打爆的风险。所以热key探测框架最重要的就是实时性,最好是某个key刚有热的苗头,在1秒内它就已经进到整个服务集群的内存里了,1秒后就不会再去密集访问redis了。

b)准确性

这个很重要,也容易实现,累加数量,做到不误探,精准探测,保证探测出的热key是完全符合用户自己设定的阈值。

c)集群 一致性

这个比较重要,尤其是某些带删除key的场景,要能做到删key时整个集群内的该key都会删掉,以避免数据的错误。

3、JdHotKey(热key探测组件)

JdHotKey是京东开源的一款毫秒级热key探测框架,用于快速探测项目中的热key,已经实战与618 双十一等大促销场景,此框架可以达到8核8G单机16W+的qps,16核机器每秒可达30万以上探测量。它有很强的实时性,默认情况下,500ms(可自行配置)即可探测出待测key是否热key,是热key它就会进到jvm内存中。

七、总结

使用缓存会增加系统复杂性,应根据实际业务考虑是否需要使用以及使用分布式缓存还是本地缓存,设置合理的缓存策略可以提高系统的性能,同时要合理的规避可能由于引入缓存而增加的风险。

发表评论