深入解析缓存一致性:问题与应对策略

984 阅读20分钟

人法地,地法天,天法道,道法自然”——《道德经》

1 缓存介绍(探本溯源)

1.1 为何要使用缓存

现在互联网应用(网站或App)的大致架构如下:

流程图-7.jpg

1.1.1 没有缓存

随着业务的发展和规模的扩大,用户数和访问量越来越大,在不使用缓存的情况下,会存在以下问题:

a. 频繁操作数据库,IO效率低下,性能不高,用户体验不好

b. 如果在高并发场景下,数据库极易被打崩

c. 因为有数据库的瓶颈,无法横向扩容,而且还要对请求进行限流

所以引入缓存是势在必行的。

1.1.2 引入缓存

无论是本地缓存还是分布式缓存,都能大幅度提升服务的性能,而且缓存都是基于内存操作,天然适合高并发场景,同时也能减少对DB的操作,减轻DB的压力。

流程图-8.jpg

引入缓存之后,可以解决上述问题:

a. 高性能:操作内存,速度非常快,从操作数据库的十几毫秒甚至几十毫秒,到操作内存的几毫秒,性能得到很大的提高,响应RT大大缩短,用户体验好

b. 高并发:内存操作天然适合高并发,能抵抗QPS洪峰

c. 有了缓存作为屏障,避免了频繁操作DB或者请求外部接口,这对DB和外部接口也是一种保护

d. 如果是分布式缓存,还能支持海量数据的缓存,能覆盖大部分业务场景。

1.2 哪类数据适合缓存

引入缓存虽然好处很多,但是也要注意,无论是本地缓存还是分布式缓存,都是基于内存的,而内存大小是有限制的,并不能无限制的存储数据。所以在缓存数据的时候,要有优先级。

1.2.1 频繁访问的热点数据

比如热点事件、突发新闻等,这类数据短时间内会被大量访问,放在缓存中能充分发挥缓存的高并发、高性能的优势。

1.2.2 基本不会变的数据

比如国家Id、用户的id、订单号、行政编码等,这类常量数据一旦生成就不会改变,非常适合放在缓存中。

根据缓存是否与应用进程属于同一进程,可以将内存分为本地缓存分布式缓存

1.3 本地缓存

本地缓存是在同一个进程内的内存空间中缓存数据,数据读写都是在同一个进程内完成;

流程图-9.jpg

1.4 分布式缓存

分布式缓存是一个独立部署的进程并且一般都是与应用进程部署在不同的机器,故需要通过网络来完成分布式缓存数据读写操作的数据传输。

流程图-10.jpg

1.5 本地缓存VS分布式缓存

缓存类型优势劣势适用场景一致性问题实现框架
本地缓存1、访问速度快2、无网络抖动1、集群缓存数据更新问题;2、占用应用进程内存空间;3、数据随应用进程重启丢失;1、只读数据,如统计类数据。2、每个部署节点独立的数据不同节点的缓存内容数据,不容易保持一致1、JDK的Map2、Google 的 guava;3、Caffeine;4、Encache
分布式缓存1、支持大数据量存储,不受应用进程重启影响;2、数据集中存储,(理论上)只有一份数据,保证数据一致性;3、高性能、高可用;1、存在网络传输不确定性,相对本地网络IO开销大;2、另外引入额外中间件,增加学习管理成本;1、高并发场景或对于较大其不可预见的用户访问时,采用分布式缓存;2、分布式锁等由于缓存数据需要通过网络来进行数据传输,对于同一个KEY的两次写操作,可能和DB的写操作存在错序,导致缓存中缓存的数据不是我们想要的最新数据,而是脏数据(过程数据)。1、Redis;2、memcache

1.6 一致性问题

1.6.1 本地缓存不一致

流程图-11.jpg

比如: 用户的请求在JVM 1 中进行处理,MySQL做了更新,JVM 1中相关的缓存也做了更新或者被删除, 可是JVM 2和JVM 3中缓存的数据还是旧的啊。

1.6.2 分布式缓存不一致

流程图-12.jpg

比如:这是两步操作,如果有两个线程都在这么干,就出问题了! 比如MySQL的有个值是100,现在线程1想把它改成200, 线程2想把它改成300。

可以看到不论是本地缓存还是分布式缓存,都会存在一致性问题。

在下一章解决方案中会解决引入中提到的问题。

2 不一致解决方案(拨云见日)

下面我们将描述一些解决缓存一致性的方法。他们中的大多数几乎是正确的(但实质仍然是错误的)。换句话说,它们可以保证99.9%的情况下两层(缓存与DB)之间的一致性。但是,在非常高的并发性和巨大的流量下,事情可能会出错(比如缓存中的脏数据)。

然而,这些几乎正确的解决方案在一些公司中被大量使用,许多公司多年来一直在使用这些方法,却并没有遇到什么大的麻烦(因为业务数据并发量没有那么大)。有时候,从99.9%的正确性到100%的正确性太难了。对于真实的业务,更快的开发生命周期和更短的上市时间可能更重要。

2.1 缓存到期(Cache Expiry)

一些简单的解决方案试图使用缓存过期或保留策略来处理DB和缓存之间的一致性。虽然谨慎地设置你的缓存的过期时间和保留策略通常是一个很好的做法,但这对保证一致性来说是一个比较差的解决方案。假设缓存过期时间是30分钟。最长将达到半小时读取脏数据时间?如果把它设置为1分钟呢?不幸的是,我们这里讨论的是具有巨大流量和高并发性的服务。60秒可能会让我们损失数百万损失。嗯,让我们把它设置得更短,5秒怎么样?你确实缩短了不一致的周期。但是,已经破坏了使用缓存的初心!会有很多缓存丢失,系统的性能可能会下降很多。

业务场景:缓存到期方案适用于读请求比较多,但写请求是周期性且数据更新周期相对固定(读多写固定周期)的情况。例如我们依赖外部的数据接口获取相关数据存储到DB,数据更新不是很频繁,可能最少1小时才可能会发生些许变更,而我们的业务需要频繁的查询这类数据,为了提升效率,避免频繁查询DB,我们一般会考虑将数据缓存到缓存中。这时我们可以根据数据更新时长来灵活设置缓存过期时间,比如过期时间设置为30分钟,这样兼顾了性能的前提下,保障了DB和缓存的一致性。

流程图-13.jpg

2.2 旁路缓存(Cache Aside)

旁路缓存模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 缓存命中:直接从缓存返回数据,不需要查询DB;
    • 缓存未命中:查询DB获取数据,将返回的数据保存到缓存,返回结果给客户端。

流程图-14.jpg

  • 对于可变操作(create, update, delete)

    • 创建、更新或删除DB中的数据;
    • 删除Redis中的条目(总是删除而不是更新缓存,当下一次缓存未命中时,新的值将被加载到缓存中)。

流程图-16.jpg

这种方法主要适用于常见的情况。上也是实现DB和缓存一致性的事实标准。然而,这种方法也存在一些问题:

  • 在正常的情况下(假设我们假设进程永远不会被杀死,写入DB、缓存永远不会失败),它基本上可以保证最终的一致性。假设进程A更新一个现有值。A成功更新了DB中的值。在它删除缓存中的值之前,另一个进程B试图读取相同Key的值。B会命中缓存(因为条目在缓存中还没有被删除)。因此,B将读取过期值。但是,缓存中的旧条目最终会被删除,其他进程最终会得到更新的值。

  • 在极端情况下,它也不能保证最终的一致性。让我们考虑相同的场景。如果进程A在试图删除缓存中的值之前被杀死,那么旧的条目将永远不会被删除。因此,此后的所有其他进程将继续读取旧值。

  • 即使在正常情况下,也是存在一种极端情况,在这种情况下,最终一致性可能会被打破的概率非常低。假设进程C试图读取一个值,但缓存未命中,然后C查询DB并得到返回结果。突然,C被操作系统卡住并暂停了一段时间。此时,另一个进程D试图更新相同的值。D更新了DB,并删除了缓存。之后C恢复查询并将查询结果保存到缓存中。因此,C会将旧的值保存到缓存中,所有后续进程都会读取脏数据。这听起来可能很可怕,但它的可能性很低,因为:

    a. 如果D试图更新一个现有的值,当C试图读取它时,这个数据应该在缓存中存在。如果C命中缓存,这种情况就不会发生。为了使这种情况发生,该条目必须已经过期,并已从缓存删除。然而,如果这个数据是“非常热”(即,有巨大的读流量),它应该在它过期后很快再次保存到缓存。如果它属于“冷数据”,那么它的一致性应该很低,因此很少会同时有一个读请求和一个更新请求。

    b. 大多数情况下,写缓存要比写DB快得多。实际上,C在缓存上的写操作应该比D在缓存上的删除操作早得多。

业务场景:只有当有应用来请求时,才将对应的对象进行缓存。并且这种策略适用于读取频繁但是写入或更新不频繁的场景,即数据一旦写入后主要用于查询展示,基本不会更新。

流程图-17.jpg

2.2.1 旁路缓存变种1(Cache Aside - Variant 1)

旁路缓存变种1模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 缓存命中:直接从缓存返回数据,不需要查询DB;
    • 缓存未命中:查询DB获取数据,将返回的数据保存到缓存,将结果返回给客户端。

流程图-18.jpg

  • 对于可变操作(create, update, delete)

    • 删除缓存;
    • 创建、更新或删除DB中的数据;

流程图-19.jpg

这可能是一个非常可怕的解决方案。假设进程A试图更新一个现有值。在某一时刻,A成功删除了缓存。在A更新DB的值之前,进程B试图读取相同KEY的值,但是缓存未命中。然后,进程B查询DB,并将返回的数据保存到缓存中。注意:DB中的数据目前还没有更新。因为A以后不会再删除缓存的条目,所以原来的值会留在缓存中,以后所有对这个值的读取都是错误的。

根据上面的分析,假设极端情况不会发生,在某些情况下,原始旁路缓存模式及其变体1都不能保证最终的一致性。然而,变种1的出错的概率比原模式要高得多。

2.2.2 旁路缓存变种2(Cache Aside - Variant 2)

旁路缓存变种2模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 缓存命中:直接从缓存返回数据,不需要查询DB;
    • 缓存未命中:查询DB获取数据,将返回的数据保存到缓存,将结果返回给客户端。

流程图-20.jpg

  • 对于可变操作(create, update, delete)

    • 创建、更新或删除DB中的数据;
    • 创建、更新或删除缓存中的数据;

流程图-21.jpg

这也是一个糟糕的解决方案。假设有两个进程A和B都试图更新一个现有值。A在B之前更新DB;但是,B在A之前更新缓存条目,最终,DB中的值被B更新;然而,在缓存的值是由A更新的,这将导致不一致。

同样,变体2的出错概率也比原模式高得多。

2.2.3 旁路缓存变种3(Cache Aside - Variant 3)

旁路缓存变种3模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 缓存命中:直接从缓存返回数据,不需要查询DB;
    • 缓存未命中:查询DB获取数据,将返回的数据保存到缓存,将结果返回给客户端。

流程图-22.jpg

  • 对于可变操作(create, update, delete)

    • 创建、更新或删除DB中的数据(加锁);
    • 创建、更新或删除缓存中的数据(加锁);

流程图-23.jpg

旁路缓存变种3是对变种2方案的改良,通过相同的KEY作为分布式锁的限定条件,保证同一个KEY同一时刻有且仅有一个线程对DB和缓存存在操作。不会出现变种2方案出现的错乱导致脏数据的问题。

这种也是我们比较常见的处理方案(分布式锁)

2.3 读穿透(Read Through)

读穿透模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 客户端总是简单地从缓存中读取。缓存命中或缓存未命中对客户端是透明的。如果缓存没有命中,缓存应该能够自动从数据库中获取数据。

流程图-24.jpg

  • 对于可变操作(create, update, delete)

    • 这种模式不处理可变操作。它应该与写穿透(或后写)模式结合使用。

读穿透模式的一个关键缺点是许多缓存层可能不支持它。例如,缓存不能自动从DB中获取数据。

2.4 写穿透(Write Through)

写穿透模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 这种模式不处理不可变操作。应与读穿透模式模式相结合。
  • 对于可变操作(create, update, delete)

    • 客户端只需要在缓存中创建、更新或删除条目。缓存层必须自动地将这个更改同步到DB。

流程图-25.jpg

写穿透模式的缺点也很明显。首先,许多缓存层本身并不支持这一点。其次,缓存不是关系数据库系统。它不是被设计成有弹性的。因此,更改可能会在复制到DB之前丢失。即使可能如Redis现在已经支持了RDB和AOF等持久性技术,这种方法仍然不推荐。

2.5 后写(Write Behind)

后写模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 这种模式不处理不可变操作。应与透读模式相结合。
  • 对于可变操作(create, update, delete)

    • 客户端只需要在缓存中创建、更新或删除条目。缓存层将更改保存到消息队列中,并将成功返回给客户机。更改会异步复制到DB中,并且可能在缓存确认向客户端发送成功响应后发生。

流程图-26.jpg

后写模式不同于写穿透模式,因为它异步地将更改复制到DB中。它提高了吞吐量,因为客户机不必等待复制发生。具有高持久性的消息队列可能是一种可能的实现。例如:Redis流(从Redis 5.0开始支持)可能是一个很好的选择。为了进一步提高性能,可以将更改和批量更新DB结合起来(以节省查询的数量)。

后写模式的缺点也类似。首先,许多缓存层本身并不支持这一点。其次,所使用的消息队列必须是FIFO(先进先出)。否则,DB的更新可能会出错,从而导致最终结果不正确。

2.5.1 后写变种(Write Behind - Variant)

最后,我们以阿里巴巴集团开发的CANAL项目为例,提出了一种新颖的方法。

流程图-27.jpg

这种新方法可以看作是写后模式的一个变体。但是,它在另一个方向进行复制。它不是将更改从缓存复制到DB,而是订阅MySQL的binlog并将其复制到缓存。这比原来的算法提供了更好的持久性和一致性。由于binlog是RDMS技术的一部分,我们可以假设它在灾难下是持久的和有弹性的。这样的架构也非常成熟,因为它已经被用于在MySQL主服务器和从服务器之间复制更改。

2.6 双删(Double Delete)

双删模式的大致过程是这样的:

  • 对于不可变操作(read):

    • 缓存命中:直接从缓存返回数据,不需要查询DB;
    • 缓存未命中:查询DB获取数据,将返回的数据保存到缓存,将结果返回给客户端。

流程图-29.jpg

  • 对于可变操作(create, update, delete)

    • 删除缓存中的条目;
    • 创建、更新或删除DB中的数据;
    • 睡眠一会(如500ms);
    • 再次删除缓存中的条目。

流程图-30.jpg

这种方法结合了最初的旁路缓存的模式和它的第一个变种。由于它是在原始旁路缓存模式的基础上改进的,我们可以声明它在正常情况下基本上保证了最终的一致性。它还试图修复这两种做法的不当之处。

3 总而言之

方案是否需要配合使用优点缺点适用场景推荐指数
Cache Expiry使用简单、不需要考虑更新未考虑更新情况,适用场景较少仅适用于读多写少且相对固定周期性的数据,如:OPS城市编码和城市名⭐️⭐️⭐️
Cache Aside使用简单、兼顾数据更新情况极端情况进程被hold住可能造成脏数据读多写少且数据更新频度低且无固定周期性数据⭐️⭐️⭐️
Cache Aside - Variant 1——缓存脏数据概率比原方案更大——
Cache Aside - Variant 2——缓存脏数据概率比原方案更大——
Cache Aside - Variant 3使用简单、兼顾数据更新情况,会在本次请求将数据加载到缓存中在对同一维度变更较多情况下,可能会出现排队等待的情况(但应该不会出现,因为如果更新特别频繁,可能需要考虑是否使用缓存)读多写少且数据更新频度不高且无固定周期性数据⭐️⭐️⭐️⭐️⭐️
Read Through使用简单,读是否命中客户端透明不能单独使用,需要配合其他方案且需要缓存框架支持——
Write Through使用简单,写对客户端透明不能单独使用,需要配合其他方案且需要缓存框架支持——
Write Behind吞吐率高、不需要等待缓存到DB同步完成需要缓存框架支持、另外消息可能出现积压导致DB与缓存差异较大——
Double Delete尝试解决了原Cache Aside方案和Cache Aside - Variant 1方案的大多数情况休眠时间比较难把控,休眠时间太短可能无效,休眠时间过长可能造成不必要等待——
Write Behind - Variant优先DB,基于binlog是RDMS技术的一部分,具有较高可靠性极端情况,消息积压可能造成缓存DB差异大(理论上不会,因为缓存操作比较快)适用系统并发较高数据量较大,且需要频繁读取DB的情况⭐️⭐️⭐️⭐️⭐️

综上所述,以上方法都不能保证很强的一致性。对于缓存和DB来说,强一致性可能也不是一个现实的要求。为了保证强一致性,我们必须在所有操作上实现ACID。这样做会降低缓存层的性能,这将破坏我们使用缓存的目标。

然而,以上所有的方法都试图达到最终的一致性,其中最后一种(CANAL引入)是最好的。上面的一些算法是对其他算法的改进。为了描述它们的层次结构,我们绘制了下面的树形图。在图中,每个节点通常会比它的子节点(如果有的话)获得更好的一致性。

我们得出的结论是,在100%的正确性和性能之间总是需要权衡的。有时候,99.9%的正确性对于真实的用例来说已经足够了。在未来的研究中,我们提醒人们不要违背主题的原始目标。例如,在讨论DB和缓存之间的一致性时,我们不能牺牲性能。

贴切的建议:

使用缓存的收益使用缓存的成本
1、加速读写2、降低后端负载1、数据不一致性;2、代码维护成本;3、架构复杂度;

1、是否需要使用缓存?

应该考虑使用缓存建议暂时不需要引入缓存
1. 读密集型应用;1. 更新频繁,对于更新频率过高的数据,频繁同步缓存中的数据,功过相抵,甚至功不抵过;
2. 存在热数据的应用;2. 对一致性要求严格,比如财务系统的财务数据,这就是个一致性要求严格的情况;
3. 对响应失效要求较高的应用;3. 读少,没必要缓存
4. 对一致性要求不严格;4. 数据量很小的情况下,当然也没必要使用缓存了,因为数据库本身完全可以支持。

2、使用缓存(面临一致性问题)建议

性能和一致性不能同时满足,为了性能考虑,通常会采用「最终一致性」的方案。所以针对第三章提到的不一致解决方案,应充分考虑业务的使用场景而定:

  • 针对固定时间周期更新且读压力比较大的场景,只需要使用缓存到期策略设定固定时间周期的1/2即可;
  • 针对读多写少、数据更新频度不高、有唯一性维度(比如订单ID、userID、cityID)、无固定周期性数据,建议使用旁路缓存变种3。
  • 针对系统并发较高数据量较大,且大量需要高度频繁读取DB的情况,建议采用写后变种(CANAL)方案,该方案MYSQL保证ACID,顺序消息队列以及重试机制保证了缓存和DB的数据达到相对完美近一致性。