Redis缓存设计

如何避免缓存雪崩/击穿/穿透

缓存雪崩是什么?

通常为了保证数据库和缓存的数据一致性,Redis里的数据一般都有过期时间。当缓存数据过期后,用户访问该过期数据,因此就会访问数据库获得数据,并重新生成缓存。

缓存雪崩:但如果大量缓存数据在同一时间过期/失效或者Redis故障宕机时,此时若有大量的用户请求,则无法在Redis中处理,进而全部访问数据库,导致数据库压力骤增,可能导致系统崩溃。

avalanche

如何避免缓存雪崩?

由此,雪崩发生的原因有两种:大量缓存同时过期和Redis故障宕机。针对不同原因,有不同的解决方法。

大量缓存同时过期

常见的解决方法有:

  • 均匀设置过期时间

    给缓存数据的过期时间加上一个随机数,使得数据不会在同一个时间过期。

  • 后台更新缓存

    业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

    事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。

    解决上面的问题的方式有两种。

    第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。

    这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。

    第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

    在业务刚上线的时候,最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。

  • 互斥锁

    当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

    实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

Redis故障宕机

常见的解决方法有:

  • 服务熔断/请求限流

    因为Redis故障宕机而导致缓存雪崩问题时,可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不再继续访问数据库,从而降低数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

    服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服务系统,全部业务都无法正常工作。

    为了减少对业务的影响,可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

  • 构建Redis集群

    服务熔断或请求限流机制是缓存雪崩发生后的应对方案,在事前最好通过主从节点的方式构建 Redis 缓存高可靠集群

    如果Redis缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了Redis故障宕机而导致的缓存雪崩问题。


缓存击穿是什么?

业务中有一些数据会被高频访问,例如秒杀活动。若某个热点数据过期了,大量请求又来访问该数据,只能去数据库获取,数据库就容易被高并发的请求冲垮

如何避免缓存击穿?

  • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 热点数据不设置过期时间,由后台异步更新缓存,或者在热点数据要过期前,提前通知后台线程更新缓存以及重新设置过期时间。

缓存穿透是什么?

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相应数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

如何避免缓存穿透?

缓存穿透的原因主要有两个:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 外界恶意攻击,故意大量访问某些读取不存在数据的业务;

应对缓存穿透,常见的解决方法有:

  • 限制非法请求,在API入口处判断请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  • 缓存空值或者默认值,发现缓存穿透的现象时,针对查询的数据,在缓存中设置一个空值/默认值,后续请求就会读取到空值/默认值,从而避免访问数据库。
  • 使用布隆过滤器快速判断数据是否存在,避免查询数据库。

布隆过滤器工作原理

布隆过滤器由「初始值都为0的位图数组」和「N个哈希函数」两部分组成。当数据被写入数据库时,会在布隆过滤器里做个标记,下次查询该数据时,只需要查询布隆过滤器,如果数据没有被标记,则不在数据库中。

布隆过滤器会通过3个操作完成标记:

  • 第一步,使用N个哈希函数分别对数据做哈希计算,得到N个哈希值
  • 第二步,将N个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为1

例如,有一个位图数组长度为8,哈希函数个数为3的布隆过滤器:

bloom

在数据库中写入数据x后,把x标记在布隆过滤器时,其会被3个哈希函数分别计算出3个哈希值,然后将3个哈希值分别对8取模,假设结果分别为1,4,6,然后将位图数组索引为1,4,6的值设为1。

当有应用查询数据x时,通过布隆过滤器只需查询位图数组的第1,4,6索引的值是否全为1,只要有一个为0,则认为x不在数据库中。

动态缓存热点数据的策略

系统不可能将所有数据都放入缓存,只是将一部分热点数据进行缓存,所以一般需要设计一个热点数据动态缓存的策略。

总体思路为:通过数据最新访问时间进行排序,并过滤掉不常访问的数据,只留下经常访问的数据

以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:

  • 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;
  • 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;
  • 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。

在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操作。

常见的缓存更新策略

常见的缓存更新策略有3种:

  • Cache Aside 旁路缓存策略
  • Read/Write Through 读穿/写穿策略
  • Write Back 写回策略

实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。

Cache Aside 旁路缓存策略

应用程序直接与数据库、缓存交互,并负责对缓存的维护,该策略可细分为读策略和写策略。

cacheAside

写策略

  • 先更新数据库中的数据,再删除缓存中的数据

读策略

  • 如果读取的数据命中了缓存,则直接返回数据
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,再将其写入到缓存中,并返回给客户端

写策略的先更新后删除顺序不可颠倒,否则在读写并发的情况下,会出现缓存和数据库的数据不一致问题。

例如,假设数据库和缓存中原本存放了一个值为20的数据X,请求A想将其更新为21,若先删除缓存,此时请求B想要读取这个数据,查询缓存未命中后,会从数据库中读到这个数据的值为20,并且将20写入到缓存中,此时请求A继续更新数据库,将该数据的值更新为21。这就导致缓存中存放了旧值20,数据库中存放了新值21。

inconsistency

但先更新数据库,后删除缓存是否就能避免数据不一致的情况呢?

假设该数据起初并未存放在缓存中,请求A读取时从数据库获得该值为20,在其将20写入缓存的过程中,请求B更新了数据库中该数据的值为21,并且删除了对应的缓存,这时请求A将20写入到缓存的动作完成。最终,缓存中存放的是旧值20,数据库中存放的是新值21。

fake

但是,这种情况在实际中发生概率极低。因为缓存的写入速度远高于数据库的写入速度。

Cache Aside策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

  • 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
  • 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

Read/Write Through 读穿/写穿策略

应用程序只和缓存交互,不再与数据库交互,相当于更新数据库的操作由缓存代理了。

Read Through策略

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

Write Through策略

当有数据更新的时候,先查询要写入的数据是否已存入缓存中:

  • 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
  • 如果缓存中数据不存在,直接更新数据库,然后返回;

Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,但经常使用的分布式缓存组件,无论是 Memcached还是Redis都不提供写入数据库和自动加载数据库中的数据的功能。在使用本地缓存的时候可以考虑使用这种策略。

Write Back 写回策略

Write Back写回策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

实际上,Write Back策略也不能应用到常用的数据库和缓存的场景中,因为Redis并没有异步更新数据库的功能。

Write Back是计算机体系结构中的设计,比如CPU的缓存、操作系统中文件系统的缓存都采用了Write Back策略。

Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。

但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以系统在掉电之后,之前写入的文件会有部分丢失,就是因为Page Cache还没有来得及刷盘造成的。

数据库和缓存如何保证数据一致性

前文已述,不论是先更新数据库后更新缓存还是先更新缓存后更新数据库,都存在读写并发的问题,可能出现数据不一致的情况。

因此,考虑不更新缓存,而是直接删除缓存。即前文所述的Cache Aside策略

但是,Cache Aside也并不能保证过程中不出现任何差错。例如,在先更新数据库后删除缓存的过程中,第二个操作,即删除缓存,在执行的时候失败了,最终导致缓存中是旧值,数据库中是新值。

例如,应用要将数据的值从1更新为2,第一步更新数据库的操作顺利执行,第二步删除缓存的操作执行失败,那么缓存中留存的就仍是旧值。

delErr

如何保证「更新数据库」和「删除缓存」两个操作都执行成功?

有两种解决方法:

  • 重试机制
  • 订阅MySQL的binlog,再操作缓存

重试机制

引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列中,由消费者来操作数据。

  • 如果删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存;持续失败超过一定次数后,向业务层返回报错信息
  • 如果删除缓存成功,将该数据从消息队列中移除,防止重复操作

retry

订阅MySQL binlog,再操作缓存

由于第一个操作是先更新数据库,所以更新成功的话会产生一条变更日志,记录在binlog里。

因此可以订阅binlog日志,获得具体的操作数据,然后再执行缓存删除,Canal中间件就是基于该原理实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 binlog 给 Canal,Canal 解析 binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。