Redis缓存系统详解
缓存
在计算机系统中,默认两种缓存:
- CPU 里面的末级缓存,即 LLC,用来缓存内存中的数据,避免每次从内存中存取数据;
- 内存中的高速页缓存,即 page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。
Redis 做缓存
Redis由于性能高效,通常可以做数据库存储的缓存,比如给MySQL当缓存就是常见的玩法
具体而言,就是将MySQL的热点数据存储在Redis中,通常业务都满足二八原则,80%的流量在20%的热点数据之上,所以缓存是可以很大程度提升系统的吞吐量
怎么做
一般而言,缓存分为服务器端缓存、客户端缓存
- 服务器端缓存:服务端将数据存入Redis,可以在访问DB之后,将数据缓存,或者在回包时将回包内容以请求参数为Key缓存。
- 客户端缓存:对服务端远程调用之后,将结果存储在客户端,这样下次请求相同数据时就能直接拿到结果,不会再远程调用,提高性能节省网络带宽。
用服务端还是客户端呢?
- 服务端缓存:其实是需要分析具体瓶颈在哪里,从服务角度来看,在目前的微服务架构下,每个服务其实都应该缓存一些热点数据,以减轻热点数据频繁请求给自己带来的压力,毕竟微服务也要有一定的互不信任原则。
- 客户端缓存:这个就更看场景了,频繁请求的数据,就有必要做缓存。
缓存的几种模式
- Cache-Aside Pattern:旁路缓存模式
- Read Through Cache Pattern:读穿透模式
- Write Through Cache Pattern:写穿透模式
- Write Behind Pattern:又叫Write Back,异步缓存写入模式
Cache-Aside
旁路缓存模式
是最常见的模式,应用服务把缓存当作数据库的旁路,直接和缓存进行交互。
读操作的流程
- 应用服务收到查询请求后,先查询数据是否在缓存上
- 如果在,就用缓存数据直接打包返回
- 如果不在,就去访问数据库,从数据库查询,并放到缓存中
除了查库后加载这种模式,如果业务有需要,还可以预加载数据到缓存

写操作的流程

在写操作的时候,Cache Aside模式是一般是先更新数据库,然后直接删除缓存
为什么不直接更新呢?
因为更新相比删除会更容易造成时序性问题
举个例子: thread1更新mysql为5 -> thread2更新mysql为3 -> thread2更新缓存为3 -> thread1更新缓存为5,最终正确的数据因为时序性被覆盖了。
该模式的缺点
是可能会出现缓存和数据库不一致的情况。
总结
Cache Aside适用于读多写少的场景,比如用户信息、新闻报道等,一旦写入缓存,几乎不会进行修改。
Read-Through
读穿透模式
和Cache Aside模式的区别主要在于: 应用服务不再和缓存直接交互,而是直接访问数据服务
这个数据服务可以理解为一个代理,即单独起这么一个服务,由它来访问数据库和缓存。
作为使用者来看,不知道里面到底有没有缓存,数据服务会自己来根据情况查询缓存或者数据库。
查询时
和 Cache Aside一样,也是缓存中有,就用从缓存中获得的数据,没有就查DB,只不过这些由数据服务托管保存,而对应用服务是透明的。

优点
相比 Cache Aside,Read Through 的优势是缓存对业务透明,业务代码更简洁。
缺点
是缓存命中时性能不如 Cache Aside,相比直接访问缓存,还会多一次服务间调用。
Write-Through
对比Cache Aside
在Cache Aside中,应用程序需要维护两个数据存储:一个缓存,一个数据库。
这对于应用程序来说,更新操作比较麻烦,还要先更新数据库,再去删除缓存。
Write Through
相当于做一层封装: 由这个存储服务先写入MySQL,再同步写入Redis,这样及时加载或更新缓存数据。
可以理解为,应用程序只有一个单独的访问源,而存储服务自己维护访问逻辑。
当 用。

潜在使用场景
Write-Through的潜在使用场景是银行系统。
Write-Through 适用情况
• 对缓存及时性要求更高(写入就加载了缓存,当然这种模式可能会有时序性问题)
• 不能忍受数据丢失(相对Write-Behind而言)和数据不一致,当然Cache Aside也是如此
总结
在使用Write-Through时要特别注意的是缓存的有效性管理,否则会导致大量的缓存占用内存资源,因为这种模式下只要写入数据就加载了缓存。
Write-Behind
对比 Write-Through
- 相同点:Write-Behind 和 Write-Through 都是写入时候会更新数据库、也会更新缓存。
- 不同点:Write-Through 会把数据立即写入数据库中,然后写缓存,安全性很高。
Write-Behind
而 Write-Behind 是先写缓存,然后异步把数据一起写入数据库
这个异步写操作是Write-Behind的最大特点,极大地降低了请求延迟并减轻了数据库的负担
写操作可以用不同方式完成
- 一种是时间上的灵活性,其中一个方式就是收集写操作并在某一时间点(比如数据库负载低的时候)慢慢写入
- 另一种方式就是合并几个写操作成为一个批量操作,一起批量写入

代价是安全性不够
比如先写入了Redis,更新操作先放在存储服务内存中,但是还没异步写入MySQL之前,存储服务崩溃了,那么数据也就丢失了
缓存异常场景
缓存穿透
问题背景
缓存穿透
是指缓存和数据库中都没有的数据,而用户不断发起请求。
由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。

如发起为id为"-1"的数据或id为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大。
解决方案
- 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。 这样可以防止攻击用户反复用同一个id暴力攻击
- 布隆过滤器。bloomfilter就类似于一个hash set,用于快速判某个元素是否存在于集合中,其典型的应用场景就是快速判断一个key是否存在于某容器,不存在就直接返回。
布隆过滤器
布隆(Bloom Filter)过滤器——全面讲解,建议收藏_李子捌的博客-CSDN博客
布隆过滤器的关键就在于hash算法和容器大小
布隆过滤器是一种比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉我们 "某样东西一定不存在或者可能存在"

布隆过滤器原理
布隆过滤器底层是一个64位的整型,将字符串用多个Hash函数映射不同的二进制位置,将整型中对应位置设置为1。
在查询的时候
如果一个字符串所有Hash函数映射的值都存在,那么数据可能存在。
为什么说可能呢
就是因为其他字符可能占据该值,即不同的 key 但是 hash 冲突了,
可以看到,布隆过滤器优缺点都很明显,优点是空间、时间消耗都很小,缺点是结果不是完全准

缓存击穿
问题背景
- 缓存击穿,一般指是指热键在过期失效的一瞬间,还没来得及重新产生,就有海量数据,直达数据库
- 缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。
解决方案
- 热点数据支持续期,持续访问的数据可以不断续期,避免因为过期失效而被击穿
- 发现缓存失效,重建缓存加互斥锁
- 当线程查询缓存发现缓存不存在就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库,然后重建缓存,争抢锁失败的线程,可以加一个睡眠然后循环重试

缓存雪崩
问题背景
缓存雪崩
是指大量的应用请求因为异常无法在Redis缓存中进行处理,像雪崩一样,直接打到数据库。
雪崩的原因,主要是
缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机
宕机算雪崩吗
在一些资料里,会把Redis宕机算进来,原因是Redis宕机了也就无法处理缓存请求,但这里会觉得有些牵强,如果这里能算,缓存击穿不也可以算?
所以这里建议是不把宕机考虑到雪崩里去。
和缓存击穿不同的是,缓存击穿指一条热点数据在Redis没得到及时重建,缓存雪崩是一大批数据在Redis同时失效
解决方案
- 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
- 重建缓存加互斥锁,当线程拿到缓存发现缓存不存在就会尝试加锁,线程争抢锁,拿到锁的线程就会进行查询数据库,然后重建缓存,争抢锁失败的线程,可以加一个睡眠然后循环重试
缓存一致性
缓存一致性问题是什么
数据源与缓存的数据不一致
比如数据源为 MySQL,缓存用 Redis
以旁路缓存 Cache-Aside 为基础
大方向有 3 种
- 更新MySQL即可,不管Redis,以过期时间兜底
- 更新MySQL之后,操作Redis
- 异步将MySQL的更新同步到Redis
方向一:更新MySQL即可,不管Redis,以过期时间兜底
做法
- 使用redis的过期时间,mysql更新时,redis不做处理,等待缓存过期失效,再从mysql拉取缓存。
优点
- 这种方式实现简单,但不一致的时间会比较明显,具体由业务来配置。
- redis原生接口,开发成本低,易于实现;
- 管理成本低,出问题的概率会比较小。
不足
- 完全依赖过期时间,时间太短容易造成缓存频繁失效,太长容易有较长时间不一致,对编程者的业务能力,有一定要求。
- 如果读请求非常频繁,且过期时间设置较长,则会产生很多脏数据。
方向二:更新MySQL之后,操作Redis
做法
不光通过key的过期时间兜底,还需要在更新mysql时,同时尝试操作redis
这里的操作分两种方式
- 更新,直接将结果写入Redis,但实际上很少用更新
- 删除,等待下次访问再加载回来
为什么是删除而不是更新?因为更新容易带来时序性问题。
举个例子
假设a的初始值为2,两台业务服务器在同一时间发出两条请求:
给a的值加1设置a的值为5
若mysql中先执行第 1 条,再执行第 2 条,则mysql中 a 的值先变成 3 ,最终为 5 ;
但由于网络传输本身有延迟,所以无法保证两条Redis更新操作谁先执行,如果第二条对应的更新先执行,Redis的数据就先变成了5,然后在加1变成了6。
这就出现数据对不上的问题,相比于数据延迟而言,这更让人疑惑和不能接受。所以一般都选择删除。
注意
上面有提到,这里是尝试删除,这样说是这一步操作是可能失败了,失败就我们可以忽略,也就是不能让删除成为一个关键路径,影响核心流程
因为有key本身的过期时间作为保障,所以最终一致性是一定达成的,主动删除redis数据只是为了减少不一致的时间
优点
- 相对方向 1,达成最终一致性的延迟更小
- 实现成本较低,只是在方向 1 的基础上,增加了删除逻辑。
不足
- 如果更新mysql成功,删除redis却失败,就退化到了方向 1
- 在更新时候需要额外操作Redis,带来了损耗。
方向三:异步将MySQL的更新同步到Redis
把我们搭建的消费服务作为mysql的一个slave,订阅mysql的binlog日志,解析日志内容,再更新到redis
此方案和业务完全解耦,redis的更新对业务方透明,可以减少心智成本。

难点:如何解析MySQL的BinLog
这需要对binlog文件以及MySQL有非常深入的理解
同时由于binlog存在Statement/Row/Mixedlevel多种形式,分析binlog实现同步的工作量是非常大的
优点
- 和业务完全解耦,在更新mysql时,不需要做额外操作
- 无时序性问题,可靠性强。
缺点
- 引入了消息队列这种算比较重的组件,还要单独搭建一个同步服务,维护他们是非常大的额外成本
- 同步服务如果压力比较大,或者崩溃了,那么在较长时间内,redis中都是老旧数据
方案选型
首先确认产品上对延迟性的要求,如果要求极高,且数据有可能变化,别用缓存。
通常来说,过期时间兜底是行之有效的办法,根据实时性期待不一样,可以增加个删除逻辑,提升一致性。
从解耦层面来看,可以使用订阅binlog的模式来更新,缺点就是重,比较适合的场景是数据不过期场景