type
status
date
slug
summary
tags
category
icon
password
Property
在实际的业务场景中,
Redis
一般和其他数据库搭配使用,用来减轻后端数据库的压力,比如和关系型数据库 MySQL
配合使用。Redis
会把MySQL
中经常被查询的数据缓存起来,比如热点数据,这样当用户来访问的时候,就不需要到MySQL
中去查询了,而是直接获取Redis
中的缓存数据,从而降低了后端数据库的读取压力。如果说用户查询的数据Redis
没有,此时用户的查询请求就会转到MySQL
数据库,当MySQL
将数据返回给客户端时,同时会将数据缓存到Redis
中,这样用户再次读取时,就可以直接从Redis
中获取数据。在使用
Redis
作为缓存数据库的过程中,有时也会遇到一些棘手问题,比如常见缓存穿透、缓存击穿和缓存雪崩等问题缓存雪崩
通常为了保证缓存中的数据与数据库中的数据一致性,会给
Redis
里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到Redis
里,这样后续请求都可以直接命中缓存。那么,当大量缓存数据在同一时间过期(失效)或者
Redis
故障宕机时,如果此时有大量的用户请求,都无法在Redis
中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。它和缓存击穿不同,缓存击穿是在并发量特别大时,某一个热点
key
突然过期,而缓存雪崩则是大量的key
同时过期,因此它们根本不是一个量级。针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:
- 均匀设置过期时间
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
- 互斥锁
当业务线程在处理用户请求时,如果发现访问的数据不在Redis里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到
Redis
里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
- 双
key
策略
- 后台更新缓存
- 后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。
- 在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
解决上面的问题的方式有两种:
这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
在业务刚上线的时候,最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。
针对
Redis
故障宕机而引发的缓存雪崩问题,常见的应对方法:- 服务熔断或请求限流机制
因为
Redis
故障宕机而导致缓存雪崩问题时,可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到Redis
恢复正常后,再允许业务应用访问缓存服务。服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作。为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到Redis恢复正常并把缓存预热完后,再解除请求限流的机制。
- 构建
Redis
缓存高可靠集群
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,最好通过主从节点的方式构建
Redis
缓存高可靠集群。如果Redis
缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于Redis
故障宕机而导致的缓存雪崩问题。缓存击穿
缓存击穿是指用户查询的数据缓存中不存在,但是后端数据库却存在,这种现象出现原因是一般是由缓存中
key
过期导致的。比如一个热点数据 key
,它无时无刻都在接受大量的并发访问,如果某一时刻这个key
突然失效了,就致使大量的并发请求进入后端数据库,导致其压力瞬间增大。这种现象被称为缓存击穿。可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。应对缓存击穿可以采取前面说到两种方案:
- 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间
采用分布式锁的方法,重新设计缓存的使用方式:
- 上锁:当通过
key
去查询数据时,首先查询缓存,如果没有,就通过分布式锁进行加锁,第一个获取锁的进程进入后端数据库查询,并将查询结果缓到Redis
中。
- 解锁:当其他进程发现锁被某个进程占用时,就进入等待状态,直至解锁后,其余进程再依次访问被缓存的
key
在分布式系统中,当不同进程或线程一起访问共享资源时,会造成资源争抢,如果不加以控制的话,就会引发程序错乱。此时使用分布式锁能够非常有效的解决这个问题,它采用了一种互斥机制来防止线程或进程间相互干扰,从而保证了数据的一致性。
分布式锁并非是
Redis
独有,比如 MySQL 关系型数据库,以及 Zookeeper 分布式服务应用,它们都实现分布式锁,只不过 Redis 是基于缓存实现的。Redis
分布式锁有很对应用场景,比如春运时,需要在12306上抢购回家火车票,但Redis
数据库中只剩一张票了,此时有多个用户来预订购买,那么这张票会被谁抢走呢?Redis
服务器又是如何处理这种情景的呢?在这个过程中就需要使用分布式锁。Redis
分布式锁主要有以下特点:- 第一:互斥性是分布式锁的重要特点,在任意时刻,只有一个线程能够持有锁;
- 第二:锁的超时时间,一个线程在持锁期间挂掉了而没主动释放锁,此时通过超时时间来保证该线程在超时后可以释放锁,这样其他线程才可以继续获取锁;
- 第三:加锁和解锁必须是由同一个线程来设置;一个线程代表一个客户端。
- 第四:Redis 是缓存型数据库,拥有很高的性能,因此加锁和释放锁开销较小,并且能够很轻易地实现分布式锁。
分布式锁命令
分布式锁的本质其实就是要在
Redis
里面占一个“坑”,当别的进程也要来占时,发现已经有人蹲了,就只好放弃或者稍做等待。这个“坑”同一时刻只允许被一个客户端占据,也就是本着“先来先占”的原则。Redis
分布式锁常用命令如下所示:SETNX key val
:仅当key
不存在时,设置一个key
为value
的字符串,返回1;若key
存在,设置失败,返回 0
Expire key timeout
:为key
设置一个超时时间,以second
秒为单位,超过这个时间锁会自动释放,避免死锁
DEL key
:删除 key
SETNX
命令相当于占“坑”操作,EXPIRE
是为避免出现意外用来设置锁的过期时间,也就是说到了指定的过期时间,该客户端必须让出锁,让其他客户端去持有。但还有一种情况,如果在
SETNX
和 EXPIRE
之间服务器进程突然挂掉,也就是还未设置过期时间,这样就会导致 EXPIRE
执行不了,因此还是会造成“死锁”的问题。为了避免这个问题,Redis 作者在 2.6.12 版本后,对 SET 命令参数做了扩展,使它可以同时执行 SETNX 和 EXPIRE 命令,从而解决了死锁的问题。直接使用 SET 命令实现,语法格式如下:
EX second
:设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
PX millisecond
:设置键的过期时间为毫秒。SET key value PX millisecond 效果等同于 PSETEX key millisecondvalue
NX
:只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
XX
:只在键已经存在时,才对键进行设置操作
缓存穿透
缓存穿透是指当用户查询某个数据时,
Redis
中不存在该数据,也就是缓存没有命中,此时查询请求就会转向持久层数据库MySQL
,结果发现MySQL
中也不存在该数据,MySQL
只能返回一个空对象,代表此次查询失败。如果这种类请求非常多,或者用户利用这种请求进行恶意攻击,就会给MySQL
数据库造成很大压力,甚至于崩溃,这种现象就叫缓存穿透。缓存穿透的发生一般有这两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
应对缓存穿透的方案,常见的方案有三种:
- 非法请求的限制
当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在API入口处要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 缓存空值或者默认值
当
MySQL
返回空对象时, Redis
将该对象缓存起来,同时为其设置一个过期时间。当用户再次发起相同请求时,就会从缓存中拿到一个空对象,用户的请求被阻断在了缓存层,从而保护了后端数据库,但是这种做法也存在一些问题,虽然请求进不了MySQL
,但是这种策略会占用Redis
的缓存空间。- 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
首先将用户可能会访问的热点数据存储在布隆过滤器中(也称缓存预热),当有一个用户请求到来时会先经过布隆过滤器,如果请求的数据,布隆过滤器中不存在,那么该请求将直接被拒绝,否则将继续执行查询。用布隆过滤器方法更为高效、实用。
缓存预热:是指系统启动时,提前将相关的数据加载到
Redis
缓存系统中。这样避免了用户请求的时再去加载数据。即使发生了缓存穿透,大量请求只会查询
Redis
和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis自身也是支持布隆过滤器的。布隆过滤器
布隆过滤器由「初始值都为 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,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。
数据库和缓存保证一致性
先更新数据库,还是先更新缓存
引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题:
- 先更新数据库,再更新缓存
比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
A请求先将数据库的数据更新为1,然后在更新缓存前,请求B将数据库的数据更新为2,紧接着也把缓存更新为2,然后A请求更新缓存为1。此时,数据库中的数据是2,而缓存中的数据却是1,出现了缓存和数据库中的数据不一致的现象。
- 先更新缓存,再更新数据库
A请求先将缓存的数据更新为1,然后在更新数据库前,B请求来了, 将缓存的数据更新为2,紧接着把数据库更新为2,然后A请求将数据库的数据更新为1。此时,数据库中的数据是1,而缓存中的数据却是2,出现了缓存和数据库中的数据不一致的现象。
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
先更新数据库,还是先删除缓存
更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
这个策略叫旁路缓存策略,又可以细分为「读策略」和「写策略」。
写策略的步骤:
- 更新数据库中的数据;
- 删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
先更新数据库,还是先删除缓存
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。
先更新数据库,再删除缓存
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的。
为了确保万无一失,给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
保证「先更新数据库 ,再删除缓存」这两个操作能执行成功
应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。
那么,后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。
其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。该怎么解决呢?有两种方法:
- 重试机制
- 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
- 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
- 订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
延迟双删
「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。
所以,如果业务对缓存命中率有很高的要求,可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况。
但是在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。
所以得增加一些手段来解决这个问题,这里提供两种做法:
- 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
- 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。
加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。
但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。
因此,还是比较建议用「先更新数据库,再删除缓存」的方案。