跳到主要内容

缓存的常见套路

· 阅读需 26 分钟

缓存的几种模式

在高并发场景下,缓存在数据库前面挡住了大量的查询流量,减轻了数据库的压力。对于缓存的更新,通常有以下几种模式:

模式优点缺点
Cache Aside1. 实现简单1. 需要调用方维护缓存和数据库的更新逻
2. 代码侵入性大
Read/Write Through1. 引入缓存管理组件,缓存和数据库的维护对应用方是透明的
2. 应用代码侵入性小,逻辑清洗
1. 引入缓存管理组件,实现复杂
Write Behind Caching1. 读写直接与缓存打交道,异步批量更新数据库,性能最好
2. 缓存和数据库的维护对应用方是透明的
1. 实现最复杂
2. 存在数据丢失风险
3. 一致性最弱

Cache Aside Pattern

这种模式应用最为广泛,其逻辑如下:

  • 读过程:先读缓存,若命中则返回;否则从数据库中查询数据,写入缓存并返回
  • 写过程:先更新数据库,然后让缓存失效

image

为什么不是先失效缓存,再更新数据库?

在这种情况下,当同时存在2个并发的读和写请求容易导致脏数据:

  1. 写请求A先失效缓存,但还未更新数据库
  2. 读请求B查询缓存未命中,然后查询数据库,查询出旧值并写入缓存
  3. 写请求A更新数据库,此时缓存中的数据就出现了不一致,并一直脏下去

image

为此,可以使用缓存延时双删策略,也就是在更新数据库后睡眠一会儿(比如1秒),然后再次失效缓存。这个睡眠时间应大于读取业务逻辑数据的耗时,如果数据库使用了读写分离架构,这个睡眠时间还应大于主从同步的延时时间。为了提高吞吐量,可在线程里异步实现延时双删。

为什么是失效缓存而不是更新缓存?

如果是更新缓存,存在2个问题:

  1. 同时2个并发写请求时可能导致脏数据
  2. 违背数据懒加载

导致脏数据的情形:

  1. 写请求A更新数据库
  2. 写请求B更新数据库,并成功更新缓存
  3. 写请求A最后更新缓存。此时写请求A的数据已经是脏数据,造成了数据不一致。

image

违背数据懒加载的含义是:有些缓存值需要经过复杂的计算得出,如果每次更新数据都更新缓存,但后续一段时间内并没有命中该缓存,就会浪费大量的计算性能,完全可以在后续读取时再去计算即可,这样可以避免不必要的计算消耗,降低计算开销。

Cache Aside Pattern也会出现不一致的问题

先更新数据库再失效缓存理论上也可能出现问题,但实际出现的概率非常低。导致数据不一致的情形:

  1. 读请求A读取缓存未命中,然后读取数据库成功
  2. 写请求B更新数据库成功,并失效缓存成功
  3. 读请求A将查询数据库的结果写入到缓存中,而此时请求A写入的数据已是脏数据,就造成了数据不一致。

image

但是,要造成数据不一致,需要读请求先于写请求,但后于写请求返回。但是数据库的查询耗时会小于写入耗时,因此这种情况出现的概率很小。

Read/Write Through Pattern

在Cache Aside Pattern中,应用放维护数据库和缓存的读写,导致应用方数据库和缓存的维护侵入业务代码,数据层的耦合较大,代码的复杂性增加。而在Read/Write Through Pattern中,调用方无需管理缓存和数据库调用,抽象出一层缓存管理组件来负责缓存和数据库的读写维护,将更新数据库的操作由缓存自己代理了,调用方直接和缓存管理组件交互,缓存和数据库对于调用方来说视为一个整体,由此解耦业务代码。

Read Through:在查询操作中更新缓存,也就是当缓存失效时,由缓存管理组件查询数据库然后写入缓存,并返回给应用。

image

Write Through:当更新数据时,将请求发送给缓存管理组件,由缓存管理组件同步更新数据库和缓存。

image

Write Behind Caching Pattern

Write Behind又叫Write Back,类似于Linux文件系统的Page Cache算法。

Write Behind模式与Write Through模式类似,区别在于Write Through是同步更新数据库,而Write Behind是异步的。也就是,在更新数据时,只更新缓存,不更新数据库,缓存管理组件异步地批量更新数据库。异步更新数据库的过程叫flush,触发flush的条件可以是定时或达到一定的容量阈值时。并且,在flush时可以使用批量写、合并写等策略,有效减少了更新数据的频率。这样做的优点是读写响应非常快,吞吐量很高。但带来的问题是,数据不是强一致的,而且如果缓存在flush到数据库之前发生了宕机就会丢失数据。另外,Write Behind的实现逻辑较为复杂,因为它需要跟踪有哪些数据是脏的,需要刷到持久层上。操作系统的Write Back会在Cache需要失效时才会被真正持久化,如内存不足、进程退出等,该操作又叫lazy write。

image

缓存一致性

一致性问题

由于引入缓存,数据就分散在不同的数据源。如果将读写缓存包含在数据库的事务控制内,会增加事务控制粒度与事务释放耗时,造成大量的数据库连接挂起,严重降低系统性能。因此,缓存和数据库的更新通常是两个事务,缓存和数据库的一致性问题是分布式一致性的问题范畴。造成问题的原因通常有以下两个层面:

  • 业务层面:主要是选择缓存更新模式的不同导致,如Cache Aside Pattern在高并发的情况下可能造成不一致的情况,不过不同的更新方式造成不一致的概率不同。
  • 系统层面:单个节点系统问题导致失败造成的不一致,如缓存服务宕机,网络抖动造成更新失败等。

解决方案

如果追求强一致性,可以采用强一致性协议,或将并行请求串行化,但这将严重降低系统的吞吐量。因此,大部分场景下,尤其是互联网场景下,大多是保证最终一致性。

重试机制

image

  1. 应用更新数据库,如果失败,那么更新失败事务回滚
  2. 应用删除缓存失败,将删除失败的key写入MQ
  3. 消费MQ得到缓存删除失败的key,重试删除缓存

缺点是,对业务线代码造成大量的侵入。

订阅binlog

引入一个binlog订阅中间件,订阅数据更新的binlog,从而解耦缓存更新过程。这样的中间件有databuscanal等。

image

  1. 应用更新数据库,binlog日志同步到binlog订阅中间件
  2. 缓存管理组件订阅binlog,并删除缓存,若删除失败则将缓存key写入MQ
  3. 缓存管理组件订阅缓存删除失败的key的MQ,重试删除缓存

另外,现在的数据库通常是主从架构来提升整体的查询QPS,因数据库的主从同步延迟,删除缓存后,如果此时从库的数据还未同步完成,新来的请求未命中缓存然后从从库中查询了已经过期的数据放到缓存中,也会造成数据的不一致。而通过订阅binlog的同步的延迟性,使得删除缓存的时序延后,进一步降低不一致的几率。

常见缓存问题

使用缓存减轻了高并发场景下的查询压力,但也带来了缓存访问时的一些风险,常见的缓存问题有:

问题 原因 解决方案
缓存穿透 访问的数据既不在缓存,也不在数据库 1. 限制非法请求
2. 缓存空对象
3. 使用布隆过滤器
缓存击穿 频繁访问的热点数据过期 1. 互斥锁
2. 热点数据不过期
缓存雪崩 大量数据同时过期 1. 分散过期时间
缓存服务宕机 1. 构建高可用缓存
2. 数据库限流与服务熔断降级
热点key 热点key的访问压力过大 1. 多级缓存
2. 多副本
3. 迁移热点key
大value/多key 单个简单key存储的value很大 1. 拆分成几个key-value
2. 拆分成hash
hash、set、zset、list存储了过多的元素 1. 分桶拆分
集群存储的key过多 1. 使用hash,旧key为field
2. 分桶转hash
大bitmap或布隆过滤器 1. 拆分成多个独立的bitmap

缓存穿透

原因

正常情况下,如果缓存设计比较合理,通常是能命中缓存的。但是,如果有大量的非法请求都去查询数据库中不存在的数据,也就是数据既不存在于缓存也不存在于数据库,那么请求每次都会打到数据库上,缓存就形同虚设,这种情况就称之为缓存穿透。

解决方案

1、限制非法请求

在上层业务做参数合法性校验,尽量避免非法参数的请求。

2、缓存空对象

对于数据库中不存在的key,缓存空对象,同时设置一个过期时间,之后再访问这个key将会从缓存中获取。

这种方式的优点在于实现简单,但是会占用缓存空间,如果空数据的命中率不高,而且遇到较多的非法请求时,会增加缓存空间的压力。

3、使用布隆过滤器

关于布隆过滤器的介绍详见之前的博客:一文了解布隆过滤器 - 木然轩

利用布隆过滤器存储所有的key,在向数据库中写入数据时,将key存储到布隆过滤器。查询缓存前,先查询布隆过滤器中是否存在,如果不存在就返回。布隆过滤器判断不存在的key则必然是不存在的,而布隆过滤器判断存在的key则不一定存在。也可以先查询缓存,如果未命中缓存,则先查询布隆过滤器快速判断数据是否存在,如果不存在就不需要再查询数据库了。

缓存击穿

原因

缓存中会有部分热点数据,查询量很大,并且通常会设置过期时间。当某个热点key失效时,很多请求在此时都查不到缓存,然后都打到了数据库去查询并更新缓存,造成数据库的压力突增甚至宕机。

解决方案

1、互斥锁

全部请求去查询数据库并更新缓存是不需要的,只需要一个请求去更新缓存即可。因此,可以使用分布式锁,只有一个请求能访问数据库并更新缓存,其余未能获得互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或默认值。

2、热点数据不过期

预先将热点数据存入缓存中,不给热点数据设置过期时间,由后台线程异步更新缓存。或者在热点数据即将过期时,提前通知后台线程更新缓存以及重新设置过期时间。

缓存雪崩

原因

缓存挡在数据库前面,抗住了非常多的流量。缓存作为一种资源,当发生缓存崩溃时,流量集中涌入数据库,称之为缓存雪崩。造成这种问题通常有2种原因:

  1. 业务层面:大量的缓存key同时失效,大量请求打到数据库,造成数据库压力过大而崩溃
  2. 系统层面:缓存服务宕机

解决方案

1、分散过期时间

业务层面的原因,主要是缓存key过期时间一致,从而造成同一时间大量缓存key同时失效。对此,主要是防止缓存在同一时间过期,因此可以在过期时间的基础上加上1~5分钟的随机值,使得缓存失效时间较为均匀。

2、构建高可用缓存

缓存作为一种系统资源,通常充当关键路径上的关键资源,应尽可能提高缓存的可用性,构建一套Redis高可用集群,如Redis的Sentinel和Cluster机制等。

还可以使用双缓存热备份方案来尽可能提升缓存资源的可用性,当主缓存熔断时,触发缓存切换,由备缓存提供缓存服务。备缓存可以是性能介于Redis与数据库之间的缓存。

3、数据库限流与服务熔断降级

当缓存服务宕机时,大量请求打到数据库,为了保护数据库,在数据库访问层加入限流,避免过多的请求打到数据库。当获取数据异常时,直接返回错误或降级页。

热点key问题

原因

缓存击穿是指热点key失效后大量并发查询涌向数据库造成压力,而这里的热点key问题侧重的是热点key的访问压力已经大到超出了Redis的性能极限。

分布式缓存组件通常会进行分片切分,查询某个key时路由到对应分片的机器上。当热点key出现时,所有的热点key访问请求都路由到同一台Redis服务器,造成该节点的负载严重加剧,并且这种现象通常并不是马上加机器就能解决,因为同一请求key还是会落到同一台机器上,瓶颈依然存在。并且如果该热点key是大key,甚至还可能达到物理网卡的极限,服务器被打垮宕机,造成雪崩。

解决方案

1、多级缓存

  • 在客户端添加本地缓存,热点数据直接命中本地缓存。这种方案的问题是本地缓存容量有限,对业务有入侵,可在Redis的SDK进行改造,集成本地缓存功能,对业务无感知。
  • 如果缓存集群为代理模式,可以在代理节点中添加本地缓存,利用代理节点可以水平扩容的特点,解决本地缓存容量有限的问题。

2、多副本

增加热点key所在节点的从副本,对于读多写少的情况比较有效,但也增加了多副本同步不一致的风险。

3、迁移热点key

当发现某个slot里有热点key时,将该slot单独迁移到新的节点,与集群其它节点隔离,避免影响到集群节点的其它业务。

热点key的发现

如果热点key已经出现,没有及时发现和处理,再去处理就为时已晚,因此通常如何提前发现热点key并能及时处理热点key就非常重要。热点key的发现可以有如下解决方案:

  1. 人为预测。如电商促销活动,通过历史促销情况预测能达到热点访问量,从而提前加载热点缓存。
  2. 客户端计数。在客户端统计热点key,由于无法预知key的数量,存在内存泄漏的风险,并且无法实现集群维度的运维统计。
  3. 机器抓包统计。通过机器上Redis端口的TCP数据包进行抓取完成热点key的统计,但是是以机器为单位进行的统计。
  4. 服务端monitor。Redis的monitor命令可以统计出一段时间的所有命令,对QPS最高的节点调用monitor 命令,解析出热点key。发现热点key时,对热点key所在的slot进行迁移。但是,monitor 命令执行期间会降低Redis的性能。
  5. 热点发现系统。建立热点key发现系统,通过对实时请求上报计算,提前发现热点key的产生。当计算监控到产生了热点key,将热点key推送到客户端,客户端建立本地缓存。

大value/多key问题

由于Redis是单线程运行的,如果一次操作的value很大会影响整个Redis的响应,也会导致集群不同节点间的数据倾斜,所以,业务上大value能拆则拆。

单个简单key存储的value很大

如果该对象每次都整存整取,则可以尝试将其拆分成几个key-value,然后使用multiGet 获取值,这样可以分拆单次操作的压力,将操作压力平摊到多个Redis实例中,降低对单个Redis的影响。

如果该对象只需要存取部分数据,还可以将其存储在hash中,然后使用hgethmget 来获取部分的value。

hash、set、zset、list存储了过多的元素

可以将这些元素分拆,例如按key的hash值进行分桶,或者按时间进行分桶等。

集群存储的key过多

如果集群上存储了上亿的key,会带来更多的内存空间占用,因为key本身占用空间,以及在集群模式中,服务端需要建立slot与key的映射关系。

因此,减少key的个数可以减少内存消耗,也就是转hash结构存储。如果key本身有很强的相关性,可以直接按特定对象的特征来设置一个hash结构的新key,旧key作为这个hash的field。如果key本身没有相关性,则可以使用分桶转为hash存储。需要注意的是hash取模时对负数的处理,预分桶时一个hash中存储的值最好不要超过512个,100个左右为宜。

大bitmap或布隆过滤器

使用bitmap或布隆过滤器的场景,往往是数据量极大的情况,此时bitmap或布隆过滤器占用的空间也很大。在这种场景下,需要将bitmap拆分成多个足够小的bitmap。不过,在拆分时,要将每个key落在一个bitmap上。如果一个key落在多个bitmap上,那么一个key请求需要查询多个节点、多个bitmap,大大降低了查询效率。因此,要把所有拆分后的bitmap当作独立的bitmap,然后通过hash将不同的key分配给不同的bitmap上,而不是将所有的小bitmap当作一个整体。对于布隆过滤器,在分配key给不同的bitmap时,尽可能均匀地拆分,误判率基本不会改变。建议布隆过滤器的哈希函数个数k取13个,单个布隆过滤器控制在512KB以下。

参考