
Redis应用
缓存场景问题
常见的redis问题主要有:
- 缓存和数据库双写一致性问题
- 缓存雪崩问题
- 缓存击穿问题
- 缓存穿透问题
- 缓存的并发竞争问题
双写一致性如何保证
什么一致性
首先是一致性问题,在分布式系统中,可以理解为多个节点中数据的值是一致的。
- 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
- 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
- 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型
三个经典缓存模式
缓存可以提升性能、缓解数据库压力,但是使用缓存也会导致数据不一致性的问题。一般使用缓存的方式有三种:
- Cache-Aside Pattern,旁路缓存模式,主要特点为:
- 读的时候,先读缓存,缓存命中的话,直接返回数据
- 缓存没有命中的话,就去读数据库,从数据库取出数据,放入缓存后,同时返回响应。
- 更新时先更新数据库,然后再删除缓存
- Read-Through/Write through,读写穿透模式,服务端把缓存作为主要数据存储。应用程序跟数据库缓存交互,都是通过抽象缓存层完成的。
- 从缓存读取数据,读到直接返回
- 如果读取不到的话,从数据库加载,写入缓存后,再返回响应。
- 与Cache-Aside相似,仅是在它之上增加了一个抽象层
- Write behind,异步缓存写入模式
- 与Read-Through/Write-Through类似,都有一个抽象层负责缓存和数据库的读写
- 不同的是,Write Behind只负责更新缓存,不直接对数据库进行操作,他通过异步的批量操作的方式去更新数据库
- 这种方式下,缓存和数据库的一致性不强,对一致性要求高的系统要谨慎使用
- 适合频繁写的场景,MySQL的InnoDB Buffer Pool机制就使用到这种模式。
缓存数据更新
在操作缓存的时候,是应该去更新缓存,还是删除后重建?
一般情况下,我们是删除缓存,再下次读取时,获取到数据库数据后再新建缓存数据。
对于这两种的选择的情况一般如下
删除缓存的场景:
- 写入场景较多(当然写的场景较多的情况下,也需要考虑是否需要引入缓存)
- 缓存计算逻辑复杂
更新缓存的场景:
- 写数据库较少
- 更新频率低
双写情况下,是先操作数据库还是缓存
Cache-Aside缓存模式中,在写入请求的时候,为什么是先操作数据库呢?为什么不先操作缓存呢?
由于使用的是删除缓存的方式,如果是先操作缓存再操作数据库,此时两个线程并发读写,就会可能出现缓存中的数据与数据库数据不一致的问题。
保证一致性的一些方法
同事务强一致性
对于非分布式系统而言,我们可以使数据库操作与redis操作在同一个事务中,具体流程是:
- 开启事务
- 修改数据库
- 执行redis命令
- 提交事务
基于这种方式,可以保证数据库被修改,缓存一定被删除,但这种方式局限较多。
缓存延时双删
什么是延时双删:
写请求->删除缓存->更新数据->休眠一会->删除缓存
休眠时间一般为:读业务逻辑数据的耗时 + 几百毫秒
为了确保读请求结束,写请求可以删除读请求可能带来的缓存脏数据。
删除缓存重试机制
对于延时双删、Cache-Aside的先操作数据库再删除缓存都会存在一个问题:在删除缓存时失败了,导致数据库与缓存不一致
为了解决这一个问题,我们可以引入一个失败重试机制,引入消息队列,当第一次删除缓存失败后,将要删除的key加入消息队列中,后续由消息队列消费者执行删除逻辑,直到删除成功。
为保证一致性,这里再添加消息队列的时候,应该使用事务消息,保证消息队列添加与数据库修改在同一个事务中,以此可以进一步保证一致性。
当然,对于消息队列而言,应存在一个失败次数限制,当失败次数达到时,应该通知管理员介入进行处理。
通过binlog异步删除缓存
对于 MySQL 而言,可以通过数据库的binlog来异步淘汰key。具体的可以使用阿里的canal将binlog日志采集发送到MQ队列里面,然后通过ACK机制确认处理这条更新消息,删除缓存,保证数据缓存一致性。
当然对于其他数据库也有其他相应的插件。
缓存雪崩
什么是缓存雪崩
造成缓存雪崩的主要原因有二:
- 大量缓存数据同时过期
- redis故障
通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。
当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
针对大量缓存数据同时过期的应对措施是:
- 均匀设置过期时间:给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
- 增加互斥锁
- 如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁
- 未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
- 实现互斥锁时应该增加一个超时时间,避免程序一直处于阻塞状态
- 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新
对于后台更新缓存而言,也存在极大的弊端,当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
为解决这一弊端的方法有两个:
- 后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。这种方式也存在弊端,那就是检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据
- 通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
针对redis故障的常用的解决方法是:
- 服务熔断或请求限流机制
- 构建 Redis 缓存高可靠集群
服务熔断或请求限流机制: 暂停业务应用对缓存服务的访问,直接返回错误,使得业务线程不在继续访问数据库,从而保障数据库的正常运行。但是直接粗暴处理,会使得业务功能受限,在此可以增加限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
构建 Redis 缓存高可靠集群: 主从节点的方式构建 Redis 缓存高可靠集群。集群构建参考:redis主从复制
缓存击穿
缓存击穿主要是热点缓存数据过期。对于一个业务系统通常会有一些数据被频繁访问,对于访问频次高的数据称为热点数据。
在一个系统中,如果缓存中的某个热点数据过期了,此时大量的请求访问到该热点数据,就会出现缓存无法提供,所有的请求都直接访问数据库,从而数据库被高并发的请求冲垮,此类问题就被称为缓存击穿问题。
稍稍细心一点,不难发现,缓存击穿与缓存雪崩类似,我们可以将缓存击穿认为是缓存雪崩的一个子集。
所以对于缓存击穿,可以使用缓存雪崩的两种方式去解决:
- 增加互斥锁
- 后台更新缓存数据
缓存穿透
当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。
缓存穿透是数据既不在缓存也不在数据库
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
应对缓存穿透的方案,常见的方案有三种:
- 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 增加缓存空值或者默认值:针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
- 使用布隆过滤器进行判断:在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
缓存的并发竞争
缓存的并发竞争指的是多个客户端同时操作同一个key的值,同时进行set操作所引发的并发问题。
解决这一问题的方法也简单,只需要添加一个分布式锁即可,对于redis我们可以直接使用redis的setnx实现分布式锁,具体逻辑为:
1 | 业务逻辑处理 |
对于并发较大的分布式系统,我们还可以使用分消息队列来处理这类问题,利用消息队列的顺序消息功能,将对同一个 key 的操作顺序化
布隆过滤器
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
- 使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
- 将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
- 将每个哈希值在位图数组的对应位置的值设置为 1;
举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。
布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据
在redis中可以使用bitmaps
实现布隆过滤器,通过setbit key offset value
(设置值)、getbit key offset
(获取值)、bitcount key [start end]
(获取位图指定范围值为1的个数)命令可以完成对数据的存储。
Redisson实现布隆过滤器
在java应用中,可以通过引入Redisson
框架很容易的实现布隆过滤器功能,代码如下:
1 | import org.redisson.Redisson; |
guava实现布隆过滤器
1 | import com.google.common.base.Charsets; |
redis 布隆过滤器插件
redis官方基于bitmaps
数据结构实现了布隆过滤器,并提供了官方插件,下载地址为:https://github.com/RedisLabsModules/rebloom(推荐使用redis 6.x+集成)
安装步骤如下:
1 | 下载插件 |
编译完成后会得到一个redisbloom.so
文件,修改redis.conf将这个文件加入到配置文件中
1 | loadmodule /usr/local/soft/RedisBloom-2.2.6/redisbloom.so |
最后重启redis即完成插件安装。
插件安装完成后,redis的命令将会得到扩展:
- bf.add 添加一个元素
- bf.exists 判断一个元素是否存在
- bf.madd 添加多个元素
- bf.mexists 判断多个元素是否存在
详细的说明参考:https://redis.io/docs/data-types/probabilistic/bloom-filter/
如果不想自己安装,也可以使用官方提供的镜像:
1 | docker run -p 6379:6379 -it --rm redis/redis-stack-server:latest |
性能调优
TODO
spring 集成
TODO