Consistency between Redis Cache and SQL Database

English 简体中文 繁体中文 ภาษาไทย Tiếng Việt
Summary

Integrating Redis cache with SQL databases presents a common challenge in maintaining data consistency while optimizing performance. The prevalent Cache Aside pattern, while widely adopted, can lead to eventual consistency issues under specific race conditions or process failures. Alternative strategies like Read Through, Write Through, and Write Behind often face limitations such as lack of native Redis support or durability concerns. More robust solutions include the "Double Delete" pattern, which mitigates some race conditions, and a superior "Write Behind" variant that leverages MySQL's binlog for reliable, asynchronous replication. Ultimately, achieving absolute strong consistency typically sacrifices performance, making highly probable eventual consistency a practical and acceptable trade-off for many real-world applications.

如今,Redis 已成为互联网行业中最流行的缓存解决方案之一。虽然关系数据库系统 (SQL) 带来了许多出色的特性,例如 ACID,但为了维护这些特性,数据库的性能在高负载下会下降。

为了解决这个问题,许多公司和网站决定在应用层(即处理业务逻辑的后端代码)和存储层(即 SQL 数据库)之间添加一个缓存层。这个缓存层通常使用内存缓存来实现。这是因为,正如许多教科书中所述,传统 SQL 数据库的性能瓶颈通常是到辅助存储(即硬盘)的 I/O。由于过去十年中主内存 (RAM) 的价格下降,现在将(至少部分)数据存储在主内存中以提高性能是可行的。一个流行的选择是 Redis。

当然,大多数系统只会将所谓的“热数据”存储在缓存层(即主内存)中。这符合帕累托原则(也称为 80/20 法则),对于许多事件,大约 80% 的影响来自 20% 的原因。为了提高成本效益,我们只需要将这 20% 存储在缓存层中。为了识别“热数据”,我们可以指定一个驱逐策略(例如 LFU 或 LRU)来确定要过期哪些数据。

背景

如前所述,来自 SQL 数据库的部分数据将存储在内存缓存中,例如 Redis。即使性能得到了提高,这种方法也会带来一个巨大的难题,即我们不再拥有单一的事实来源。现在,同一条数据将存储在两个地方。我们如何在避免阻塞的同时,确保存储在 Redis 中的数据和存储在 SQL 数据库中的数据之间的一致性?

下面,我们列出一些常见的错误,并指出可能出现的问题。我们还提出了解决这个棘手问题的一些方案。

注意:为了方便我们在此处的讨论,我们以 Redis 和传统 SQL 数据库为例。但是,请注意,本文中提出的解决方案可以扩展到其他数据库,甚至内存层次结构中任意两层之间的一致性。

各种解决方案

下面我们描述几种解决这个问题的方法。它们中的大多数都是几乎正确的(但仍然是错误的)。换句话说,它们可以在 99.9% 的时间内保证两层之间的一致性。但是,在高并发和巨大流量下,可能会出现问题(例如缓存中的脏数据)。

然而,这些几乎正确的解决方案在行业中被广泛使用,许多公司多年来一直在使用这些方法,而没有出现重大问题。有时,从 99.9% 的正确性到 100% 的正确性太具有挑战性。对于现实世界的业务而言,更快的开发生命周期和更短的上市时间可能更重要。

缓存过期

一些幼稚的解决方案试图使用缓存过期或保留策略来处理 MySQL 和 Redis 之间的一致性。虽然通常来说,为您的 Redis 集群仔细设置过期时间和保留策略是一个好习惯,但这对于保证一致性来说是一个糟糕的解决方案。假设您的缓存过期时间为 30 分钟。您确定可以承担读取长达半小时的脏数据的风险吗?

将过期时间设置得更短怎么样?假设我们将其设置为 1 分钟。不幸的是,我们这里讨论的是具有巨大流量和高并发的服务。60 秒可能会让我们损失数百万美元。

嗯,让我们将其设置得更短,5 秒怎么样?好吧,您确实缩短了不一致的时期。但是,您已经违背了使用缓存的初衷!您将会有大量的缓存未命中,并且系统的性能可能会大大降低。

缓存旁路

缓存旁路模式的算法是:

  • 对于不可变操作(读取):
    • 缓存命中:直接从 Redis 返回数据,无需查询 MySQL;
    • 缓存未命中:查询 MySQL 以获取数据(可以使用读取副本以提高性能),将返回的数据保存到 Redis,将结果返回给客户端。
  • 对于可变操作(创建、更新、删除):
    • 创建、更新或删除 MySQL 中的数据;
    • 删除 Redis 中的条目(始终删除而不是更新缓存,新值将在下次缓存未命中时插入)。

这种方法在大多数情况下都适用。事实上,缓存旁路是实现 MySQL 和 Redis 之间一致性的事实标准。著名的论文《在 Facebook 上扩展 Memecache》也描述了这种方法。但是,这种方法也存在一些问题:

  • 在正常情况下(假设进程永远不会被杀死,并且写入 MySQL/Redis 永远不会失败),它基本上可以保证最终一致性。假设进程 A 尝试更新现有值。在某一时刻,A 已成功更新 MySQL 中的值。在它删除 Redis 中的条目之前,另一个进程 B 尝试读取相同的值。B 将获得缓存命中(因为 Redis 中的条目尚未被删除)。因此,B 将读取过时的值。但是,Redis 中的旧条目最终将被删除,其他进程最终将获得更新后的值。
  • 在极端情况下,它也不能保证最终一致性。让我们考虑相同的场景。如果进程 A 在尝试删除 Redis 中的条目之前被杀死,则该旧条目将永远不会被删除。因此,此后的所有其他进程都将继续读取旧值。
  • 即使在正常情况下,也存在一种概率非常低的极端情况,即最终一致性可能会被破坏。假设进程 C 尝试读取一个值并获得缓存未命中。然后 C 查询 MySQL 并获得返回的结果。突然,C 由于某种原因被操作系统卡住并暂停了一段时间。此时,另一个进程 D 尝试更新相同的值。D 更新 MySQL 并删除了 Redis 中的条目。之后,C 恢复并将其查询结果保存到 Redis 中。因此,C 将旧值保存到 Redis 中,并且所有后续进程都将读取脏数据。这听起来可能很可怕,但它的概率非常低,因为:
    • 如果 D 尝试更新现有值,则当 C 尝试读取该值时,该条目应该存在于 Redis 中。如果 C 获得缓存命中,则不会发生这种情况。为了发生这种情况,该条目必须已过期并已从 Redis 中删除。但是,如果此条目是“非常热”的(即,对其有巨大的读取流量),则它应该在过期后很快再次保存到 Redis 中。如果这属于“冷数据”,则其一致性应该很低,因此很少会同时对该条目发出一个读取请求和一个更新请求。
    • 通常,写入 Redis 的速度应该比写入 MySQL 的速度快得多。实际上,C 对 Redis 的写入操作应该比 D 对 Redis 的删除操作发生得早得多。

缓存旁路 - 变体 1

缓存旁路模式的第一个变体的算法是:

  • 对于不可变操作(读取):
    • 缓存命中:直接从 Redis 返回数据,无需查询 MySQL;
    • 缓存未命中:查询 MySQL 以获取数据(可以使用读取副本以提高性能),将返回的数据保存到 Redis,将结果返回给客户端。
  • 对于可变操作(创建、更新、删除):
    • 删除 Redis 中的条目;
    • 创建、更新或删除 MySQL 中的数据。

这可能是一个非常糟糕的解决方案。假设进程 A 尝试更新现有值。在某一时刻,A 已成功删除 Redis 中的条目。在 A 更新 MySQL 中的值之前,进程 B 尝试读取相同的值并获得缓存未命中。然后,B 查询 MySQL 并将返回的数据保存到 Redis 中。请注意,此时 MySQl 中的数据尚未更新。由于 A 稍后不会再次删除 Redis 条目,因此旧值将保留在 Redis 中,并且所有后续对此值的读取都将是错误的。

根据以上分析,假设不会发生极端情况,原始缓存旁路算法及其变体 1 在某些情况下都不能保证最终一致性(我们称这种情况为“不愉快路径”)。但是,变体 1 的不愉快路径的概率比原始算法的概率高得多。

缓存旁路 - 变体 2

缓存旁路模式的第二个变体的算法是:

  • 对于不可变操作(读取):
    • 缓存命中:直接从 Redis 返回数据,无需查询 MySQL;
    • 缓存未命中:查询 MySQL 以获取数据(可以使用读取副本以提高性能),将返回的数据保存到 Redis,将结果返回给客户端。
  • 对于可变操作(创建、更新、删除):
    • 创建、更新或删除 MySQL 中的数据;
    • 创建、更新或删除 Redis 中的条目。

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

同样,变体 2 的不愉快路径的概率比原始方法的概率高得多。

读取穿透

读取穿透模式的算法是:

  • 对于不可变操作(读取):
    • 客户端将始终简单地从缓存中读取。缓存命中或缓存未命中对客户端是透明的。如果是缓存未命中,则缓存应具有自动从数据库中获取数据的能力。
  • 对于可变操作(创建、更新、删除):
    • 此策略不处理可变操作。它应与写入穿透(或写入后置)模式结合使用。

读取穿透模式的一个主要缺点是许多缓存层可能不支持它。例如,Redis 将无法自动从 MySQL 中获取数据(除非您为 Redis 编写插件)。

写入穿透

写入穿透模式的算法是:

  • 对于不可变操作(读取):
    • 此策略不处理不可变操作。它应与读取穿透模式结合使用。
  • 对于可变操作(创建、更新、删除):
    • 客户端只需要在 Redis 中创建、更新或删除条目。缓存层必须以原子方式将此更改同步到 MySQL。

写入穿透模式的缺点也很明显。首先,许多缓存层本身不支持此功能。其次,Redis 是缓存而不是 RDBMS。它并非设计为具有弹性。因此,更改可能会在复制到 MySQL 之前丢失。即使 Redis 现在支持 RDB 和 AOF 等持久性技术,也不建议使用此方法。

写入后置

写入后置模式的算法是:

  • 对于不可变操作(读取):
    • 此策略不处理不可变操作。它应与读取穿透模式结合使用。
  • 对于可变操作(创建、更新、删除):
    • 客户端只需要在 Redis 中创建、更新或删除条目。缓存层将更改保存到消息队列中,并向客户端返回成功。更改会异步复制到 MySQL,并且可能会在 Redis 向客户端发送成功响应后发生。

写入后置模式与写入穿透模式的不同之处在于,它会异步地将更改复制到 MySQL。它提高了吞吐量,因为客户端不必等待复制发生。具有高持久性的消息队列可能是一种可行的实现方式。Redis 流(自 Redis 5.0 起支持)可能是一个不错的选择。为了进一步提高性能,可以将更改合并并批量更新 MySQL(以节省查询次数)。

写入后置模式的缺点类似。首先,许多缓存层本身不支持此功能。其次,使用的消息队列必须是 FIFO(先进先出)。否则,对 MySQL 的更新可能会乱序,从而导致最终结果不正确。

双重删除

双重删除模式的算法是:

  • 对于不可变操作(读取):
    • 缓存命中:直接从 Redis 返回数据,无需查询 MySQL;
    • 缓存未命中:查询 MySQL 以获取数据(可以使用读取副本以提高性能),将返回的数据保存到 Redis,将结果返回给客户端。
  • 对于可变操作(创建、更新、删除):
    • 删除 Redis 中的条目;
    • 创建、更新或删除 MySQL 中的数据;
    • 休眠一段时间(例如 500 毫秒);
    • 再次删除 Redis 中的条目。

此方法结合了原始缓存旁路算法及其第一个变体。由于它是基于原始缓存旁路方法的改进,因此我们可以声明它在正常情况下基本上可以保证最终一致性。它还试图修复这两种方法的不愉快路径。

通过将进程暂停 500 毫秒,该算法假设所有并发读取进程都已将旧值保存到 Redis 中,因此对 Redis 的第二次删除操作将清除所有脏数据。尽管仍然存在此算法会破坏最终一致性的极端情况,但其概率可以忽略不计。

写入后置 - 变体

最后,我们介绍一个由中国阿里巴巴集团开发的 canal 项目引入的新方法。

这种新方法可以被视为 写入后置 算法的变体。但是,它在另一个方向执行复制。它不是将更改从 Redis 复制到 MySQL,而是订阅 MySQL 的 binlog 并将其复制到 Redis。这比原始算法提供了更好的持久性和一致性。由于 binlog 是 RDMS 技术的一部分,因此我们可以假设它在灾难下是持久且有弹性的。这种架构也相当成熟,因为它已被用于在 MySQL 主服务器和从服务器之间复制更改。

结论

总之,以上任何方法都不能保证强一致性。强一致性对于 Redis 和 MySQL 之间的一致性来说可能也不是一个现实的要求。为了保证强一致性,我们必须在所有操作上实现 ACID。这样做会降低缓存层的性能,这将违背我们使用 Redis 缓存的目标。

但是,以上所有方法都试图实现最终一致性,其中最后一种(由 canal 引入)是最好的。以上一些算法是对其他一些算法的改进。为了描述它们的层次结构,绘制了以下树状图。在图中,每个节点通常会比其子节点(如果有)实现更好的一致性。

我们得出结论,100% 的正确性和性能之间总是会存在权衡。有时,99.9% 的正确性对于现实世界的用例来说已经足够了。在未来的研究中,我们提醒人们应该记住不要违背该主题的最初目标。例如,在讨论 MySQL 和 Redis 之间的一致性时,我们不能牺牲性能。

参考资料

注意:这篇文章已获得原作者 Yunpeng Niu(一位优秀的 NUS CS 学生)的授权,可以在此处重新发布。要阅读他更多的文章,请访问 https://yunpengn.github.io/blog/

DATABASE CACHE REDIS

  RELATED

  COMMENT

1
Aviv Kandabi
Feb 23, 2021 at 2:55 am

Great article! really helped me out understanding a correct workflow :)