Redis 缓存问题
2023-4-16
| 2023-8-2
0  |  阅读时长 0 分钟
type
status
date
slug
summary
tags
category
icon
password
Property

 
在实际的业务场景中,Redis一般和其他数据库搭配使用,用来减轻后端数据库的压力,比如和关系型数据库 MySQL 配合使用。
Redis会把MySQL 中经常被查询的数据缓存起来,比如热点数据,这样当用户来访问的时候,就不需要到MySQL 中去查询了,而是直接获取Redis中的缓存数据,从而降低了后端数据库的读取压力。如果说用户查询的数据Redis没有,此时用户的查询请求就会转到MySQL 数据库,当MySQL 将数据返回给客户端时,同时会将数据缓存到Redis中,这样用户再次读取时,就可以直接从Redis中获取数据。
notion image
在使用Redis作为缓存数据库的过程中,有时也会遇到一些棘手问题,比如常见缓存穿透、缓存击穿和缓存雪崩等问题
 
 

缓存雪崩

通常为了保证缓存中的数据与数据库中的数据一致性,会给Redis里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到Redis里,这样后续请求都可以直接命中缓存。
notion image
那么,当大量缓存数据在同一时间过期(失效)或者Redis故障宕机时,如果此时有大量的用户请求,都无法在Redis中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
notion image
它和缓存击穿不同,缓存击穿是在并发量特别大时,某一个热点key突然过期,而缓存雪崩则是大量的key同时过期,因此它们根本不是一个量级。
 
针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:
  • 均匀设置过期时间
    • 如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
  • 互斥锁
    • 当业务线程在处理用户请求时,如果发现访问的数据不在Redis里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到Redis里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
      实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
  • key策略
  • 后台更新缓存
    • 业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新
      事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
      解决上面的问题的方式有两种:
      1. 后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。
        1. 这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。
      1. 在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。
        1. 在业务刚上线的时候,最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。
 
针对Redis故障宕机而引发的缓存雪崩问题,常见的应对方法:
  • 服务熔断或请求限流机制
    • 因为Redis故障宕机而导致缓存雪崩问题时,可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到Redis恢复正常后,再允许业务应用访问缓存服务。
      服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作。为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到Redis恢复正常并把缓存预热完后,再解除请求限流的机制。
  • 构建Redis缓存高可靠集群
    • 服务熔断或请求限流机制是缓存雪崩发生后的应对方案,最好通过主从节点的方式构建Redis缓存高可靠集群。如果Redis缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于Redis故障宕机而导致的缓存雪崩问题。
 
 

缓存击穿

缓存击穿是指用户查询的数据缓存中不存在,但是后端数据库却存在,这种现象出现原因是一般是由缓存中key过期导致的。比如一个热点数据 key,它无时无刻都在接受大量的并发访问,如果某一时刻这个key突然失效了,就致使大量的并发请求进入后端数据库,导致其压力瞬间增大。这种现象被称为缓存击穿。
notion image
可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。应对缓存击穿可以采取前面说到两种方案:
  • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值
  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间
 
 
采用分布式锁的方法,重新设计缓存的使用方式:
  • 上锁:当通过key去查询数据时,首先查询缓存,如果没有,就通过分布式锁进行加锁,第一个获取锁的进程进入后端数据库查询,并将查询结果缓到Redis中。
  • 解锁:当其他进程发现锁被某个进程占用时,就进入等待状态,直至解锁后,其余进程再依次访问被缓存的key
 
在分布式系统中,当不同进程或线程一起访问共享资源时,会造成资源争抢,如果不加以控制的话,就会引发程序错乱。此时使用分布式锁能够非常有效的解决这个问题,它采用了一种互斥机制来防止线程或进程间相互干扰,从而保证了数据的一致性。
分布式锁并非是Redis独有,比如 MySQL 关系型数据库,以及 Zookeeper 分布式服务应用,它们都实现分布式锁,只不过 Redis 是基于缓存实现的。
Redis分布式锁有很对应用场景,比如春运时,需要在12306上抢购回家火车票,但Redis数据库中只剩一张票了,此时有多个用户来预订购买,那么这张票会被谁抢走呢?Redis服务器又是如何处理这种情景的呢?在这个过程中就需要使用分布式锁。
Redis分布式锁主要有以下特点:
  • 第一:互斥性是分布式锁的重要特点,在任意时刻,只有一个线程能够持有锁;
  • 第二:锁的超时时间,一个线程在持锁期间挂掉了而没主动释放锁,此时通过超时时间来保证该线程在超时后可以释放锁,这样其他线程才可以继续获取锁;
  • 第三:加锁和解锁必须是由同一个线程来设置;一个线程代表一个客户端。
  • 第四:Redis 是缓存型数据库,拥有很高的性能,因此加锁和释放锁开销较小,并且能够很轻易地实现分布式锁。
 
分布式锁命令
分布式锁的本质其实就是要在Redis里面占一个“坑”,当别的进程也要来占时,发现已经有人蹲了,就只好放弃或者稍做等待。这个“坑”同一时刻只允许被一个客户端占据,也就是本着“先来先占”的原则。
Redis分布式锁常用命令如下所示:
  • SETNX key val:仅当key不存在时,设置一个keyvalue的字符串,返回1;若key存在,设置失败,返回 0
  • Expire key timeout:为key设置一个超时时间,以second秒为单位,超过这个时间锁会自动释放,避免死锁
  • DEL key:删除 key
SETNX命令相当于占“坑”操作,EXPIRE是为避免出现意外用来设置锁的过期时间,也就是说到了指定的过期时间,该客户端必须让出锁,让其他客户端去持有。
但还有一种情况,如果在SETNXEXPIRE 之间服务器进程突然挂掉,也就是还未设置过期时间,这样就会导致 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数据库造成很大压力,甚至于崩溃,这种现象就叫缓存穿透。
notion image
缓存穿透的发生一般有这两种情况:
  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;
 
应对缓存穿透的方案,常见的方案有三种:
  1. 非法请求的限制
    1. 当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在API入口处要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  1. 缓存空值或者默认值
    1. MySQL返回空对象时, Redis将该对象缓存起来,同时为其设置一个过期时间。当用户再次发起相同请求时,就会从缓存中拿到一个空对象,用户的请求被阻断在了缓存层,从而保护了后端数据库,但是这种做法也存在一些问题,虽然请求进不了MySQL,但是这种策略会占用Redis的缓存空间。
  1. 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在
    1. 可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
      首先将用户可能会访问的热点数据存储在布隆过滤器中(也称缓存预热),当有一个用户请求到来时会先经过布隆过滤器,如果请求的数据,布隆过滤器中不存在,那么该请求将直接被拒绝,否则将继续执行查询。用布隆过滤器方法更为高效、实用。
      notion image
      缓存预热:是指系统启动时,提前将相关的数据加载到Redis缓存系统中。这样避免了用户请求的时再去加载数据。
      即使发生了缓存穿透,大量请求只会查询Redis和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis自身也是支持布隆过滤器的。
 
 

布隆过滤器

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1
假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器
notion image
在数据库写入数据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,存在误判的情况。
所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据
 
 

数据库和缓存保证一致性

先更新数据库,还是先更新缓存

notion image
引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题
  • 先更新数据库,再更新缓存
    • 比如「请求 A 」和「请求 B 」两个请求,同时更新「同一条」数据,则可能出现这样的顺序:
      notion image
      A请求先将数据库的数据更新为1,然后在更新缓存前,请求B将数据库的数据更新为2,紧接着也把缓存更新为2,然后A请求更新缓存为1。此时,数据库中的数据是2,而缓存中的数据却是1,出现了缓存和数据库中的数据不一致的现象
  • 先更新缓存,再更新数据库
    • notion image
      A请求先将缓存的数据更新为1,然后在更新数据库前,B请求来了, 将缓存的数据更新为2,紧接着把数据库更新为2,然后A请求将数据库的数据更新为1。此时,数据库中的数据是1,而缓存中的数据却是2,出现了缓存和数据库中的数据不一致的现象
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象
 

先更新数据库,还是先删除缓存

更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。
这个策略叫旁路缓存策略,又可以细分为「读策略」和「写策略」。
notion image
写策略的步骤:
  • 更新数据库中的数据;
  • 删除缓存中的数据。
读策略的步骤:
  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
 
先更新数据库,还是先删除缓存
假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21。
notion image
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库的数据不一致。先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题
 
先更新数据库,再删除缓存
假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中。
notion image
最终,该用户年龄在缓存中是 20(旧值),在数据库中是 21(新值),缓存和数据库数据不一致。
从上面的理论上分析,先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高
因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。
而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。
所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的
为了确保万无一失,给缓存数据加上了「过期时间」,就算在这期间存在缓存数据不一致,有过期时间来兜底,这样也能达到最终一致。
 
 

保证「先更新数据库 ,再删除缓存」这两个操作能执行成功

应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在 Redis 缓存中删除 X 的缓存,但是这个操作却失败了,这个时候数据库中 X 的新值为 2,Redis 中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。
notion image
那么,后续有访问数据 X 的请求,会先在 Redis 中查询,因为缓存并没有 诶删除,所以会缓存命中,但是读到的却是旧值 1。
其实不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。该怎么解决呢?有两种方法:
  • 重试机制
    • 可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。
    • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
    • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。
    • notion image
  • 订阅 MySQL binlog,再操作缓存
    • 先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
      于是就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。
      Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
      notion image
所以,如果要想保证「先更新数据库,再删缓存」策略第二个操作能执行成功,可以使用「消息队列来重试缓存的删除」,或者「订阅 MySQL binlog 再操作缓存」,这两种方法有一个共同的特点,都是采用异步操作缓存。
 

延迟双删

「先更新数据库,再删除缓存」的方案虽然保证了数据库与缓存的数据一致性,但是每次更新数据的时候,缓存的数据都会被删除,这样会对缓存的命中率带来影响。
所以,如果业务对缓存命中率有很高的要求,可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会出现缓存未命中的情况
但是在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。
所以得增加一些手段来解决这个问题,这里提供两种做法:
  • 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
  • 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
对了,针对「先删除缓存,再更新数据库」方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删」。
加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。
所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。
但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。
因此,还是比较建议用「先更新数据库,再删除缓存」的方案。
  • Redis
  • 分区技术Python 使用Redis
    目录