发布于

常见的缓存更新策略

作者

现实中常见的应用几乎都是 I/O Bound 的,应用的性能受到网络与磁盘读写效率的限制。想要提高网络性能,只需利用钞能力堆叠网络基础设施即可得到几乎线性的性能提升。而对于数据库而言,增强磁盘硬件(HDD -> SSD,低速、低寿命存储颗粒 -> 高速、长寿命存储颗粒)对于读写效率的提升并不是线性的,简单堆叠磁盘硬件很快会触发边际效应。计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决:对于提高磁盘性能而言,最容易想到的方式是添加一层缓存中间件来提高应用的整体读写性能。

考虑下面一个场景:一张数据表中存储了一百亿条用户数据,每条用户数据的大小约为 1KB,那么这张表的大小约为 9600GB。目前主流存储大厂生产的民用 DDR5 内存(所有 DDR5 内存都集成了 ECC 芯片,可以满足大多数服务器的需要)价格约为 32 RMB/GB,那么将这张 9600GB 大小的数据表完整存放在内存中(可以通过配置 Buffer Pool 的大小实现)的存储成本约为 307200 RMB,对于可以收集到一百亿条用户数据的业务而言,这个成本几乎可以忽略不计。

当然,从成本、可用性、可扩展性、数据命中率等层面综合考量,在目前主流的系统设计中,往往会通过引入一层缓存中间件以提高数据库性能。引入缓存中间件一方面可以降低响应延迟,提高用户体验,另一方面可以降低数据库服务器负载,提高系统整体的可用性。但是,引入缓存中间件在解决问题的同时又引入了新的麻烦:数据库和缓存中间件是两个单点,一方面两个单点之间进行数据同步涉及到同步策略的选择(先更新数据库,还是先更新缓存),另一方面,单点之间并不(或者说难以)共享事务,因而难以做到数据的原子化同步,进而产生缓存和数据库之间的数据一致性的问题。接下来我们将对这两个问题进行讨论。

缓存更新策略

目前缓存中间件与数据库之间的同步策略(缓存更新策略)的最佳实践主要有三种:Cache-Aside Pattern,Read/Write Through Pattern 与 Write-Behind Caching Pattern。

Cache-Aside Pattern

Cache-Aside Pattern 是目前最主流的更新策略,其在读取数据时首先去缓存中间件中查询,如果查询到(Cache Hit)则直接返回,如果没有查询到(Cache Miss),则会先去数据库中查询对应数据,拿到数据后将缓存更新为最新值(Cache Invalidation)。在写入数据时,首先将数据写入数据库,然后将数据对应的缓存删除(先写数据库,再写缓存)。

Cache-AsidePattern

Read/Write Through Pattern

Read/Write Through Pattern 将缓存视为主要存储,应用程序直接对缓存进行读写,缓存中间件负责将数据同步到数据库。该策略在读数据时和 Cache-Aside Pattern 的行为非常相似,如果缓存中有数据则直接返回,没有则将数据库中的最新数据写入到缓存中,之后返回给应用程序。而在写入数据时,则直接将数据写入到缓存中,并在之后由缓存中间件负责将数据更新到数据库中。在 Cache-Aside Pattern 中,程序编码者负责缓存更新与同步的逻辑,而在 Read/Write Through Pattern 中,数据库与缓存的同步的工作交由缓存中间件负责,缓存与数据库的同步过程对于程序编码者而言是透明的。

Write-Behind Caching Pattern

Write Behind Caching Pattern 与 Read/Write Through Pattern 非常相似,同样是将缓存视为主要存储并对缓存进行直接读写,不同点在于 Write Behind Caching Pattern 在将缓存中的数据同步到数据库时是异步的动作,其不会在缓存变动后直接同步到数据库,而是异步批量的进行同步,因而效率更高,但是会有数据丢失的风险。

数据一致性问题

缓存中间件与数据库的数据出现不一致的主要原因是数据在二者之间同步的操作不是原子的,多个线程对缓存的逻辑写(更新或删除)操作的顺序无法保证。在 Cache-Aside Pattern 中,如果一个线程 A 在读数据时没有命中缓存(缓存已过期、缓存的数据已被 Evict,待缓存的数据还未 Load 到缓存中),其则会尝试去数据库中读取最新的数据并在读取数据后将其写入缓存中并返回数据给应用程序。这里假设此刻数据库中线程 A 想要读取的数据的值为 1。此时假如有一个线程 B 尝试写数据库,其会先将数据写入到数据库(这里假设写入的值为 2),然后将缓存中的数据删除。

这里线程 A 想要将旧值 1 写入到缓存,而线程 B 则想要将缓存中的数据删除,此时数据库的最新值为 2。正确的顺序应该是线程 A 先将旧值 1 写入缓存,随后线程 B 将 A 写入的旧值 1 删除掉。但如果线程 A 的写入是在线程 B 的删除之后发生的,那么此时缓存中的数据将会是旧值 1,而数据库中的数据则是新值 2,这里就出现了数据不一致的情况。但是出现这种情况的条件比较苛刻,一方面需要线程 A 刚好没有命中缓存(缓存失效),尝试去读磁盘的最新值并写入到缓存中,且线程 A 的动作要先于线程 B 发生,另一方面在此时需要刚好有一个线程 B 尝试去写数据库并将缓存删除,且缓存的删除操作先于线程 A 的写入完成。考虑到缓存的更新操作要远远快于数据库的更新操作,因而在实际中很难出现线程 B 更新了数据库且删除了缓存之后,线程 A 才完成了缓存的写入。再者,大多数缓存的应用场景是对读多写少的数据进行缓存(如果发现应用程序对某些缓存中数据的写操作非常频繁,则应该重新考虑是否继续缓存这些数据),因而上述情况出现的概率会更低。即使出现了,也可以通过设置一个合适的缓存过期时间,或者在应用程序中提供一个强制刷新的方式来更新缓存来保证用户体验,比如点击刷新按钮,界面下拉刷新等。

Cache-Aside Pattern 只保证了数据的最终一致性,想要保证强一致性需要引入分布式锁(在读写数据库时对数据上分布式锁以保证缓存更新和删除操作的顺序性),或者使用支持分布式事务的缓存中间件来避免缓存与数据库之间的非原子同步操作,锁和事务的引入又导致了性能的下降,与加入缓存的初衷相违背,应当慎重考虑。此外,Cache-Aside Pattern 是先写数据库,后写缓存的,那么先写缓存后写数据库是否能够避免数据不一致的问题呢?答案是不能,因为这种先写缓存后写数据库的策略还是没有保证数据同步操作的原子性,数据一致性问题依然存在。

如果真的想要保证数据的强一致性,可以考虑只在数据库中读写那些需要保证强一致性的数据,毕竟一个大到可以装入所有热点数据的 Buffer Pool 与缓存中间件之间的性能差距几乎可以忽略不计,只不过这种方式对于服务器的硬件性能提出了更高的要求。