缓存(二):剖析缓存模式的选择

193 阅读12分钟

之前的缓存文章~

缓存(一):系统响应变慢?谈谈缓存的作用

我们在了解到缓存的诸多好处后,同样也需要注意使用缓存时所带来的缺陷。正确的使用缓存,可以为我们的系统性能带来很大的提升。

我们在实际使用缓存的场景中,主要以"缓存"+"数据库"为主要场景,在缓存策略的使用上,针对不同的业务场景使用不同的缓存策略,通过了解一些缓存策略,能够让你在实际工作中游刃有余。

一、Cache Aside

Cache Aside旁路缓存策略,也是我们在实际业务开发中使用最多的一个缓存策略。将缓存与数据库作为两个独立的数据源,我们可以通过业务代码来控制两者的写入顺序。

例如,当我们在实际业务场景中,使用user_id在用户表中查询用户的信息,为了提高用户信息的查询速度,我们可以以user_idKey将用户信息存储在缓存中。

当我们需要更新某一个user_id的用户信息时,我们可能会先更新数据库中的用户信息,再更新缓存中的用户信息。

image.png

例如上图客户端发送一个请求,先更新user_id=1的数据库记录,再更新key:user_id1的缓存数据。

但是,这样的更新策略在高并发的情况下会造成缓存与数据库双方数据不一致的情况。为什么呢?我们可以考虑下图的并发执行来了解发生数据不一致的原因。

image.png

上图中,首先请求A先将user_id=1的数据库记录更新为10,与此同时,请求B也开始更新user_id=1的数据库记录,请求B把数据库记录变更为11,然后变更缓存数据为11,最后请求A才更新缓存数据为10

由于变更数据库与变更缓存属于两个独立的操作,在并发情况下如果没有做任何的并发控制的话,两个请求并发执行写入就会因为写入顺序不同从而导致数据不一致的情况发生。

那我们有什么办法可以规避这种情况呢?

其实,我们可以在更新数据库数据时,不更新缓存转而删除缓存数据。当读取数据时,发现没有缓存数据,则直接从数据库中读取数据,随后更新到缓存。即Cache Aside策略(旁路缓存策略)。

image.png

Cache Aside策略的核心在于以数据库中的数据为准,保证数据库更新,缓存中的数据按需加载。因为大多数业务场景下都是以数据库为准,如果数据库写入成功,则可以基本上可以认为写操作成功。后续即使缓存写入失败,也可以重新从数据库中将数据加载到缓存中。

Cache Aside策略策略可以分为读策略写策略

读策略

  • 从缓存中读取数据
  • 若缓存命中,则直接返回数据
  • 若缓存未命中,则查询数据库数据
  • 查询到数据后,将数据回写到缓存,并将数据返回

写策略

  • 更新数据库记录
  • 删除缓存数据

或许你可能会问,为什么要先更新数据库呢,我先删除缓存再更新数据库不行吗?答案是不行的,因为如果先删除缓存的话,同样在并发情况下也可能出现数据不一致的情况。

image.png

上图中,当请求A执行写操作时,它先删除缓存,同时请求B执行读操作,先查询缓存发现缓存未命中,则会从数据库中读取数据为10并回写到缓存,之后请求A再执行数据库更新操作将数据更新为11,我可以发现此时会发生缓存与数据库不一致的情况。

那么Cache Aside策略先更新数据库,再删除缓存的方式就没有问题了吗?

其实理论上来说,在并发情况下Cache Aside策略仍然会出现缓存与数据库不一致的情况。

image.png

如上图,在理论情况下,可能会出现数据不一致的情况,但这种情况出现的几率并不高。因为缓存一般是基于内存操作的,缓存的写入通常远远快于数据库的写入,所以在实际并发过程中,很难出现上图请求B已经更新了数据库并且清空了缓存,请求A才更新完缓存的情况,当请求A更新完缓存之后,请求B再删除缓存,那么就不会出现缓存不一致的情况。

延迟双删

当然,如果你的业务对于缓存一致性有着很高的要求,在使用Cache Aside策略时,可以采取延迟双删,即两次删除缓存操作,在第一次删除操作之后设定一个定时器,在一段时间之后再次执行删除,第二次删除就是为了避开删除缓存中的读写导致数据不一致的场景。

image.png

你可能会说,步骤五同样也可能会在步骤六第二次删除缓存之后执行。从理论上来说是可能的,这种可能性只是存在理论中,因为两次删除的时间可以设置间隔很长。延迟双删下,只需要考虑在回写缓存和第二次删除之间,数据可能不一致的问题。虽然延迟双删可以极大的避免缓存与数据库的不一致,但带来的问题同样也是缓存命中率下降的问题更加严重。

Cache Aside策略是我们在日常开发过程中最经常使用的缓存策略,它可以经由我们通过业务代码来实现。

另外,Cache Aside策略实际上适用于读多写少的业务场景,在写场景多的情况下,缓存中的数据会被频繁地清理,可能会导致缓存命中率变低,所以Cache Aside策略在使用的时候需要你对实际业务有个预估,如果实际业务对缓存命中率有严格的要求,可以考虑其他的方法:

  • 写操作在更新数据库后,随后也更新缓存,只不过在更新数据库操作前,需要加入分布式锁,同一时间只允许一个线程更新数据库和缓存,保证更新串行更新,这样就不会出现并发的问题,缺点在于对于写入操作的性能有所影响,锁的粒度也比较的大,适用于写场景少且写入后需要立刻读取数据的场景。

  • 另外一种做法是,在更新数据库后,随后更新缓存,但给缓存添加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存数据也会很快过期。

二、Read/Write Through

Read Through读穿透策略与Write Through写穿透策略的核心在于用户程序只操作缓存,由缓存来与数据库进行交互,例如写入数据或者读取数据。

Write Through写穿透

写穿透的主要策略为:先查询写入数据是否在缓存中已存在。

如果已经存在则更新缓存,随后由缓存组件同步更新数据到数据库中。

如果缓存中数据不存在,则会有两种处理方法:

  1. 写入数据到缓存,随后由缓存组件同步更新到数据库中。
  2. 数据不写入缓存,直接更新到数据库中。

一般来说,我们会选择第二种,因为无论是第一种还是第二种,都需要将数据同步到数据库中,而第二种方法能够减少一次缓存的写入,提升写入性能。

image.png

当然,如果我们采取第一种写入方法时,为了提升写入性能,我们可以在数据写入缓存后,不采取同步而采用异步的方式将数据同步到数据库中,但这种模式可能会存在丢失数据的可能,因为缓存数据存放在内存中,有可能会因为宕机或断电导致数据丢失。

Read Through读穿透

读穿透的主要策略为:

  • 先查询缓存数据是否存在,如果存在则直接返回。
  • 如果不存在则缓存组件从数据库中同步加载数据并返回。

image.png

我们可以注意到,Read Through策略当未命中缓存时,可以通过缓存组件读取数据库获取数据,此时我们可以将数据库同步写入缓存。

同样,为了提升系统性能,在缓存组件从数据库获取到数据时,我们可以直接返回数据给客户端,通过异步的方式将数据写入到缓存,但我们都知道,一般情况下内存操作都是比较快的,这种情况下,实际缓存的写入采取异步的写入收益其实不大。

如果在对响应时间比较比较苛刻的业务场景,可以考虑在缓存组件读取数据库时就直接采取异步操作,直接让整个加载数据库数据和回写缓存的过程都异步执行,即如果缓存未命中,那么就直接返回一个错误或者默认值,然后缓存异步地去数据库中加载,并且回写缓存。不过这种方案的缺陷在于有些情况下业务方可能会在第一次调用获取到错误或者默认值。

Read/Write Through策略的特点在于与缓存节点与数据库打交道,相比于Cache Aside策略,该策略在日常使用中非常的少,原因在于我们常用的缓存组件Redis、Memcached并不支持写入数据库的操作或自动加载数据库中的数据。

三、Write Back

Write Back写回策略的写策略的核心思想在于写入数据库时只写入缓存,并且将缓存标记为脏数据块,当脏数据块再次被使用时,才会将脏数据块中的数据写入到数据库中,然后将新写入的数据写入到该数据块。

image.png

Write Back读策略在于

  • 当读取缓存时缓存命中,则会直接返回数据
  • 如果缓存不命中,则会寻找一个可用的缓存块
    • 如果该缓存块是脏块,则会将该脏数据库的数据写入到数据库中,即腾出空间后再将需要查询的数据从数据库中查询出来,再写入到该缓冲块并返回数据。
    • 如果该缓存块不是脏块,则直接将需要查询的数据从数据库中查询出来,再写入到该缓存块中。

image.png

实际上,Write Back并不适用于我们日常的数据库与缓存的业务场景,缓存块的思想事实上在MySQL、操作系统等地方都有体现,例如MySQL的buffer pool与日志的异步刷盘,操作系统的Page Cache,消息队列中消息异步写入,都采用了这个缓存策略。

Write Back事实上是为了避免直接将数据写入磁盘,减速随机写,从而提升写入性能,毕竟写内存和写磁盘的随机 I/O 的延迟相差了几个数量级。

但是缓存存在内存中,而内存数据并非持久化,因此当机器宕机或断电,则会丢失内存中的数据,因此可能会存在数据丢失的情况。当然,如果你的业务支持数据的少量丢失,完全可以先将数据暂存在内存中,做一些类似统计操作或汇总操作,然后定时地刷新到持久化数据库中。

例如,在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘 I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。

四、总结

Cache Aside

Cache Aside是我们在实际业务开发中最常用的缓存策略,也契合我们常用的缓存组件,在实际工作中,我们可以根据业务情况选择删除缓存or更新缓存,同时评估缓存命中率与缓存占用内存空间,来选择最合适你的缓存策略,提高系统性能。

Read/Write Through

Read/Write Through读写穿透则需要缓存组件的支持,交由缓存组件与数据库打交道,如果使用本地缓存则可以考虑该缓存策略,后续通过定时任务等方式将数据持久化到数据库中。

Write Back

Write Back写回策略同样也需要缓存组件的支持,更多用于避免磁盘随机写,只写缓存,后续异步将数据持久化到数据库中。

熟悉这些缓存模式,在我们实际的业务开发中,综合考虑数据访问方式、系统性能、数据一致性、资源限制等方面,再结合实际的业务场景分析,相信你能够对缓存的使用姿势有更深刻的了解。