跳转至

13 缓存的使用姿势(一):如何选择缓存的读写策略?

上节课,我带你了解了缓存的定义、分类以及不足,你现在应该对缓存有了初步的认知。从今天开始,我将带你了解一下使用缓存的正确姿势,比如缓存的读写策略是什么样的,如何做到缓存的高可用以及如何应对缓存穿透。通过了解这些内容,你会对缓存的使用有深刻的认识,这样在实际工作中就可以在缓存使用上游刃有余了。

今天,我们先讲讲缓存的读写策略。你可能觉得缓存的读写很简单,只需要优先读缓存,缓存不命中就从数据库查询,查询到了就回种缓存。实际上,针对不同的业务场景,缓存的读写策略也是不同的。

而我们在选择策略时也需要考虑诸多的因素,比如说,缓存中是否有可能被写入脏数据,策略的读写性能如何,是否存在缓存命中率下降的情况等等。接下来,我就以标准的“缓存+数据库”的场景为例,带你剖析经典的缓存读写策略以及它们适用的场景。这样一来,你就可以在日常的工作中根据不同的场景选择不同的读写策略。

Cache Aside(旁路缓存)策略

我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有ID和年龄两个字段,缓存中我们以ID为Key存储用户的年龄信息。那么当我们要把ID为1的用户的年龄从19变更为20,要如何做呢?

你可能会产生这样的思路:先更新数据库中ID为1的记录,再更新缓存中Key为1的数据。

这个思路会造成缓存和数据库中的数据不一致。比如,A请求将数据库中ID为1的用户年龄从19变更为20,与此同时,请求B也开始更新ID为1的用户数据,它把数据库中记录的年龄变更为21,然后变更缓存中的用户年龄为21。紧接着,A请求开始更新缓存数据,它会把缓存中的年龄变更为20。此时,数据库中用户年龄是21,而缓存中的用户年龄却是20。

为什么产生这个问题呢?因为变更数据库和变更缓存是两个独立的操作,而我们并没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不同造成数据的不一致。

另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户数据,把金额变更后再写入到缓存中。

这个过程中也会有并发的问题,比如说原有金额是20,A请求从缓存中读到数据,并且把金额加1,变更成21,在未写入缓存之前又有请求B也读到缓存的数据后把金额也加1,也变更成21,两个请求同时把金额写回缓存,这时缓存里面的金额是21,但是我们实际上预期是金额数加2,这也是一个比较大的问题。

那我们要如何解决这个问题呢?其实,我们可以在更新数据时不更新缓存,而是删除缓存中的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。

这个策略就是我们使用缓存最常见的策略,Cache Aside策略(也叫旁路缓存策略),这个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策略,其中读策略的步骤是:

  • 从缓存中读取数据;
  • 如果缓存命中,则直接返回数据;
  • 如果缓存不命中,则从数据库中查询数据;
  • 查询到数据后,将数据写入到缓存中,并且返回给用户。

写策略的步骤是:

  • 更新数据库中的记录;
  • 删除缓存记录。

你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。

假设某个用户的年龄是20,请求A要更新用户年龄为21,所以它会删除缓存中的内容。这时,另一个请求B要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取到年龄为20,并且写入到缓存中,然后请求A继续更改数据库,将用户的年龄更新为21,这就造成了缓存和数据库的不一致。

那么像Cache Aside策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上还是有缺陷的。假如某个用户数据在缓存中不存在,请求A读取数据时从数据库中查询到年龄为20,在未写入缓存中时另一个请求B更新数据。它更新数据库中的年龄为21,并且清空缓存。这时请求A把从数据库中读到的年龄为20的数据写入到缓存中,造成缓存和数据库数据不一致。

不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入,所以在实际中很难出现请求B已经更新了数据库并且清空了缓存,请求A才更新完缓存的情况。而一旦请求A早于请求B清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而从数据库中重新加载数据,所以不会出现这种不一致的情况。

Cache Aside策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存(当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从分离时,会出现因为主从延迟所以读不到用户信息的情况。

而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。

Cache Aside存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:

1.一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;

2.另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。

当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的使用场景。

Read/Write Through(读穿/写穿)策略

这个策略的核心原则是用户只与缓存打交道,由缓存和数据库通信,写入或者读取数据。这就好比你在汇报工作的时候只对你的直接上级汇报,再由你的直接上级汇报给他的上级,你是不能越级汇报的。

Write Through的策略是这样的:先查询要写入的数据在缓存中是否已经存在,如果已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,如果缓存中数据不存在,我们把这种情况叫做“Write Miss(写失效)”。

一般来说,我们可以选择两种“Write Miss”方式:一个是“Write Allocate(按写分配)”,做法是写入缓存相应位置,再由缓存组件同步更新到数据库中;另一个是“No-write allocate(不按写分配)”,做法是不写入缓存中,而是直接更新到数据库中。

在Write Through策略中,我们一般选择“No-write allocate”方式,原因是无论采用哪种“Write Miss”方式,我们都需要同步将数据更新到数据库中,而“No-write allocate”方式相比“Write Allocate”还减少了一次缓存的写入,能够提升写入的性能。

Read Through策略就简单一些,它的步骤是这样的:先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库中同步加载数据。

下面是Read Through/Write Through策略的示意图:

Read Through/Write Through策略的特点是由缓存节点而非用户来和数据库打交道,在我们开发过程中相比Cache Aside策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是Memcached还是Redis都不提供写入数据库,或者自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略,比如说在上一节中提到的本地缓存Guava Cache中的Loading Cache就有Read Through策略的影子。

我们看到Write Through策略中写数据库是同步的,这对于性能来说会有比较大的影响,因为相比于写缓存,同步写数据库的延迟就要高很多了。那么我们可否异步地更新数据库?这就是我们接下来要提到的“Write Back”策略。

Write Back(写回)策略

这个策略的核心思想是在写入数据时只写入缓存,并且把缓存块儿标记为“脏”的。而脏块儿只有被再次使用时才会将其中的数据写入到后端存储中。

需要注意的是,在“Write Miss”的情况下,我们采用的是“Write Allocate”的方式,也就是在写入后端存储的同时要写入缓存,这样我们在之后的写请求中都只需要更新缓存即可,而无需更新后端存储了,我将Write back策略的示意图放在了下面:

如果使用Write Back策略的话,读的策略也有一些变化了。我们在读取缓存时如果发现缓存命中则直接返回缓存数据。如果缓存不命中则寻找一个可用的缓存块儿,如果这个缓存块儿是“脏”的,就把缓存块儿中之前的数据写入到后端存储中,并且从后端存储加载数据到缓存块儿,如果不是脏的,则由缓存组件将后端存储中的数据加载到缓存中,最后我们将缓存设置为不是脏的,返回数据就好了。

发现了吗?其实这种策略不能被应用到我们常用的数据库和缓存的场景中,它是计算机体系结构中的设计,比如我们在向磁盘中写数据时采用的就是这种策略。无论是操作系统层面的Page Cache,还是日志的异步刷盘,亦或是消息队列中消息的异步写入磁盘,大多采用了这种策略。因为这个策略在性能上的优势毋庸置疑,它避免了直接写磁盘造成的随机写问题,毕竟写内存和写磁盘的随机I/O的延迟相差了几个数量级呢。

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

当然,你依然可以在一些场景下使用这个策略,在使用时,我想给你的落地建议是:你在向低速设备写入数据的时候,可以在内存里先暂存一段时间的数据,甚至做一些统计汇总,然后定时地刷新到低速设备上。比如说,你在统计你的接口响应时间的时候,需要将每次请求的响应时间打印到日志中,然后监控系统收集日志后再做统计。但是如果每次请求都打印日志无疑会增加磁盘I/O,那么不如把一段时间的响应时间暂存起来,经过简单的统计平均耗时,每个耗时区间的请求数量等等,然后定时地,批量地打印到日志中。

课程小结

本节课,我主要带你了解了缓存使用的几种策略,以及每种策略适用的使用场景是怎样的。我想让你掌握的重点是:

1.Cache Aside是我们在使用分布式缓存时最常用的策略,你可以在实际工作中直接拿来使用。

2.Read/Write Through和Write Back策略需要缓存组件的支持,所以比较适合你在实现本地缓存组件的时候使用;

3.Write Back策略是计算机体系结构中的策略,不过写入策略中的只写缓存,异步写入后端存储的策略倒是有很多的应用场景。

而且,你还需要了解,我们今天提到的策略都是标准的使用姿势,在实际开发过程中需要结合实际的业务特点灵活使用甚至加以改造。这些业务特点包括但不仅限于:整体的数据量级情况,访问的读写比例的情况,对于数据的不一致时间的容忍度,对于缓存命中率的要求等等。理论结合实践,具体情况具体分析,你才能得到更好的解决方案。

一课一思

结合今天课程中的内容,你可以思考一下在日常工作中使用缓存时都使用了哪些缓存的读写策略呢?欢迎在留言区和我一起讨论。

最后,感谢你的阅读,如果这篇文章对你有收获,欢迎你将它分享给更多的朋友。

精选留言(15)
  • 小可 👍(40) 💬(10)

    工作中老师说的这几种缓存策略基本都用到了,特别是统计接口响应时间那个例子和我们的场景一样。管理平台统计一百多个节点的上报到队列中的数据,原来是按消费一批统计完直接批量入库,数据量太大(每秒两三千),压力全在数据库,系统也比较卡,并且如果入库不及时就会数据积压,后续都跟不上。现在是消费统计和入库分开,消费统计先放缓存,每分钟再将缓存同步到数据库,同步成功再提交消费offset,目前看还算稳定。

    2019-10-16

  • 任鹏斌 👍(53) 💬(8)

    读到这里突然想到一个开源项目https://github.com/apache/ignite,内存数据库,结合了关系型数据库和缓存的优点,如果只当缓存使用的话,可以自动加载和写入关系型数据库中的数据。完美解决一致性问题。但是好像国内使用的人不多。

    2019-10-16

  • 岁寒 👍(33) 💬(1)

    缓存一定会引入不一致的。。

    2019-10-16

  • 无形 👍(21) 💬(15)

    文中提到的第一个第一个缓存和数据不一致的问题,我认为这个问题的原因是,多个客户端更新缓存和数据库之间是无序的、并发的操作,这样必然导致数据不一致的问题,因此我们采用了监听binlog的方式,把Binlog扔到消息队列中,由一个leader来消费,负责更新缓存,保证了写缓存操作之间的顺序性,保证了缓存的准确性,避免了频繁读库。

    2019-11-04

  • 长期规划 👍(16) 💬(1)

    老师,我理解WriteBack策略相当于缓存和缓冲区合二为一了,对吧。据我所知,MySQL的buffer pool使用了WriteBack策略,但为了防止系统崩溃后数据丢失,MySQL使用了WAL(Write-Ahead Logging)机制,写先日志。好像WAL在HBase等系统也在用

    2019-10-24

  • yc 👍(14) 💬(2)

    write back策略读请求时“如果缓存不是脏的,则由缓存组件将后端存储中的数据加载到缓存中”,是不是写错了,如果缓存不是脏的,直接从缓存返回即可,为什么还要从后端记载数据到缓存然后返回?我看留言很多人都有同样的疑问,请老师解释一下,谢谢。

    2019-10-17

  • Geek_zbvt62 👍(13) 💬(3)

    Cache Aside对缓存命中率两种解决方案中的1,可能是我没看懂,感觉没解决问题啊? 这里说在“更新数据时也更新缓存”,我理解就是先更新DB再更新缓存,这样除非在更新DB之前加分布式锁,否则在更新DB之后加分布式锁,再更新缓存,依然较高可能出现不一致的情况。 实际中我们确实用在更新缓存时用分布式锁或本地锁,只不过是发现缓存为空而去读DB时,为了解决穿透问题。 纯个人见解,除了cache aside,另外两种更贴近底层系统开发而不是商业应用开发。因为我们大多数人做的系统,都是低速存储都是数据库,是有复杂的业务逻辑约束的,比如唯一性等,不是那种简单的page/cpu cahce。我们经常的写操作一般都要借助数据库来检验这些约束并且在出错之后返回给用户。而如果直接与缓存打交道,且不论有些缓存的实现并不保证数据可靠性,也不能依靠缓存检验这些约束。 其实现在很多系统用的一种缓存模式是类似CQRS,写直接修改DB,异步更新到缓存,读只从缓存读数据。适合对数据不一致窗口可以容忍的场景。

    2019-10-16

  • 程序水果宝 👍(7) 💬(2)

    使用写回策略,如果在缓存更新到数据库之前设备掉电了,那这样数据岂不是丢失了,请问这是怎么解决的呢,通过主备机制吗,缓存数据写两份?

    2019-10-22

  • fdconan 👍(6) 💬(1)

    Cache Aside(旁路缓存)策略,对于读多写少场景,当一个写操作更新db后同时删除缓存。然后多个读就会回源,这不会造成db压力么?

    2019-10-23

  • 七号叽 👍(6) 💬(10)

    老师你好,请问一下write back策略为什么读请求时是“如果缓存不是脏的,则由缓存组件将后端存储中的数据加载到缓存中”,而不是直接返回?谢谢

    2019-10-16

  • Geek_49305e 👍(5) 💬(5)

    老师,1. 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。 这个解决方案应该有些不严谨的地方,如有A,B两个线程,A先更新数据库的值为20,而后A获取到更新缓存的分布式的锁,但未释放锁,此时B更新数据库的值为21,更新后尝试获取锁,此时获取锁一定会失败,抛出异常,终止更新缓存。 最后缓存中的数据为A更新的的值20

    2019-10-18

  • 饭团 👍(5) 💬(4)

    老师问您一个问题!其实如果是使用.Cache Aside方式的话。在写的时候时候因为更新数据后,删除了缓存。在高并发情况下。那么可能会出现以下情况: 主从同步的情况下,从库没来得及同步。大量的读请求返回的是从库的旧数据。而这个时候读的数据会被动写入缓存。那就存在很大的问题!这种应该怎么处理!如果是这样的话?是不是只能依靠分布式锁来实现了!

    2019-10-16

  • MoonGod 👍(3) 💬(4)

    老师请问一个问题,在cache aside策略中,如果先更新数据库,再删除缓存。这样如果读请求访问量很大,会短时间出现大量请求穿透到数据库,这里有好的办法优化吗?

    2019-10-16

  • 👍(2) 💬(1)

    小结一下: 1:用缓存目的是为了提速,之所以能提速关键在于使用内存来存储,不过内存的特点是掉电后时会丢数据,所以,一份数据会放在DB和缓存两个地方,那么对于变化频繁的数据就会存在数据不一致的问题,缓存最适合的是静态资源或变化低频的资源 2:后面这个那个策略,都是为了解决变化频繁的数据的数据一致性问题的,在解决缓存一致性问题时会引入别的问题,比如:性能问题,复杂度问题。 3:具体怎么权衡看业务场景,不过数据一致性问题在分布式环境中是很经典和头疼的问题,因为缓存数据会引入,别的情况也会引入,比如:主从延迟

    2020-04-22

  • 长期规划 👍(2) 💬(1)

    老师,写穿策略中先写缓存再同步DB,此过程若是多线程,需要加锁吧,如果不加,也存在写覆盖问题

    2019-12-18