上一篇介绍了 Redis 的切片集群。这节开始介绍 Redis 作为缓存服务器时的问题。
Redis 最常见的应用场景就是作为缓存数据库,以提高数据访问的速度。缓存的一大问题就是一致性问题,即如何尽量与存储数据库的数据保持一致的问题。
接下来先介绍一下三种常见的缓存策略:Cache Aside、Read/Write Through、Write Back(Write Behind Caching)。
Redis 通常会选择旁路缓存策略作为缓存服务器。旁路缓存策略可以分为读策略和写策略:
旁路缓存的意思是,读取和更新数据的操作都需要在应用程序中完成,缓存本身不对数据进行操作。
由于更新数据库和删除缓存是两个操作,所以可能出现数据与缓存不一致的问题。
无论是「先更新数据库,再删除缓存」,还是「先删除缓存,再更新数据库」,这两个方案都存在并发问题,当多个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
写策略中如果先删除缓存中的数据,再更新数据库中的数据,可能在并发时出现数据不一致的问题。比如在写操作删除缓存后,且在更新数据库前有读操作,就会读取到旧数据并写入缓存中。
理论上,先更新数据库,再删除缓存也可能会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高。因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现写请求已经更新了数据库并且删除了缓存,而读请求才更新完缓存的情况。
面对这种由于高并发导致的数据不一致问题,有以下几个解决方案:
1、过期时间
由于「先更新数据库,再更新缓存」实际出现数据不一致的概率很低,所以在对缓存数据一致性要求不那么高的场景下,可以选择忽略这种情况。另外,给缓存加上过期时间,也可以减少数据不一致所造成的影响。
2、延迟双删
可以在更新完数据库、删除完缓存之后,休眠一个较短的时间再次删除缓存,这样可以很大程度上解决问题。
3、分布式锁
如果真的对缓存数据一致性要求特别高,可以使用额外的分布式锁来保证「更新数据库和删除缓存」这两个操作是原子执行的。但是这种方案可能会对性能造成较大的影响。
除了并发导致的一致性问题外,旁路缓存策略并不能够保证删除缓存数据的成功执行,所以仍然可能存在数据不一致问题。对于这种情况有两种解决方案:
1、重试机制
可以引入消息队列,将删除缓存要操作的数据加入到消息队列,由消费者来操作数据。
2、订阅 MySQL binlog,再操作缓存
「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。
于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。
除了数据一致性的问题,缓存本身还会存在三个缓存异常问题,分别是:缓存雪崩、缓存击穿、缓存穿透。
当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
对于不同原因造成的缓存雪崩问题,有不同的解决方案。
1、均匀设置过期时间
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
2、互斥锁
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
3、双 key 策略
我们对缓存数据可以使用两个 key,一个是主 key,会设置过期时间,一个是备 key,不会设置过期,它们只是 key 不一样,但是 value 值是一样的,相当于给缓存数据做了个副本。
当业务线程访问不到「主 key 」的缓存数据时,就直接返回「备 key 」的缓存数据,然后在更新缓存的时候,同时更新「主 key 」和「备 key 」的数据。
双 key 策略的好处是,当主 key 过期了,有大量请求获取缓存数据的时候,直接返回备 key 的数据,这样可以快速响应请求。而不用因为 key 失效而导致大量请求被锁阻塞住(采用了互斥锁,仅一个请求来构建缓存),后续再通知后台线程,重新构建主 key 的数据。
4、后台更新缓存
业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。
有两种可以解决这个问题:
在业务刚上线的时候,最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好适合进行缓存预热。
1、请求限流机制
可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
2、构建 Redis 缓存高可靠集群
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,最好通过主从架构的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
缓存击穿是缓存雪崩的一个子集,可以用互斥锁和后台更新缓存(缓存不过期)两种方案解决。
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生一般有这两种情况:
应对缓存穿透的方案,常见的方案有三种:
1、非法请求的限制
当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
2、缓存空值或者默认值
当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
3、使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。
我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
布隆过滤器是如何工作的呢?
布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。
布隆过滤器会通过 3 个操作完成标记:
举个例子,假设有一个位图数组长度为 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 作为缓存服务器时的问题,Redis 作为缓存服务器时,通常会选用旁路缓存策略。这种策略的优点是性能好,非常适合作为读缓存,但是在更新缓存时可能存在数据一致性的问题。缓存本身还会有雪崩、击穿、穿透三个问题,每个问题都有各自的应对方案。
下一节将介绍 Redis 性能方面的问题。