Redis 提供了两种主要的原子执行多个操作的机制:MULTI/EXEC 事务和 Lua 脚本。Redis 事务的一个特点经常让新手感到困惑,那就是没有回滚机制。在我担任 Redis 开发者倡导者的期间,我与一些具有传统 SQL 背景的工程师交流过,他们认为这令人不安。因此,我想通过这篇博客分享我的看法,并论证在 Redis 中你不需要回滚。
Redis 中的事务以 MULTI 命令开始。发送此命令后,连接将切换模式,所有后续通过该连接发送的命令将被 Redis 加入队列而不是立即执行,DISCARD 和 EXEC 命令除外(它们将分别导致事务中止或提交)。提交事务意味着执行之前加入队列的命令。
MULTI
SET mykey hello
INCRBY counter 10
EXEC
事务(和 Lua 脚本)确保两件重要的事情
关于事务,最后一件需要记住的基本事情是:即使 MULTI 事务已启动,Redis 仍会继续服务其他客户端。只有在调用 EXEC 提交事务时,Redis 才会短暂停止应用其他客户端的命令。这与 SQL 数据库非常不同,SQL 数据库中的事务会调用 DBMS 内部的各种机制来提供不同程度的隔离保证,并且客户端可以在执行事务时从数据库读取值。在 Redis 中,事务是“一次性的”——换句话说,只是一系列命令一次性全部执行。那么,如何创建依赖于 Redis 中现有数据的事务呢?为此,Redis 实现了 WATCH 命令,用于执行乐观锁。
使用 WATCH 进行乐观锁
让我从实践层面告诉你为什么在事务中不能从 Redis 读取值
MULTI
SET counter 42
GET counter
EXEC
如果你在 redis-cli 中运行这一系列命令,来自 “GET counter” 的回复将是 “QUEUED”,并且值 “42” 只会在调用 EXEC 时返回,同时返回的还有执行 SET 命令的结果 “OK”。
要编写依赖于从 Redis 读取的数据的事务,你必须使用 WATCH。运行后,该命令将确保后续事务只有在 EXEC 调用之前被 WATCH 的键没有发生变化时才会执行。
例如,如果不存在 INCRBY 命令,你可以这样实现原子增量操作
WATCH counter
GET counter
MULTI
SET counter <通过 GET 获取的值 + 任何增量>
EXEC
在此示例中,我们首先对“counter”键设置一个 WATCH 触发器,然后 GET 其值。请注意 GET 操作发生在事务体开始之前,这意味着它会立即执行并返回键的当前值。此时,我们使用 MULTI 开始事务,并通过在客户端计算“counter”的新值来应用更改。
如果多个客户端试图同时对“counter”键应用相同的事务,部分事务将被 Redis 自动丢弃。此时,通常是客户端的工作来重试事务。这类似于 SQL 事务,在较高的隔离级别下,事务有时会中止,将重试任务留给客户端。
虽然 WATCH 对于执行复杂的事务非常有用,但当你需要执行依赖于 Redis 中数据的多个操作时,通常使用 Lua 脚本更容易且效率更高。使用 Lua 脚本,你可以将逻辑发送到 Redis(以脚本本身的形式),让 Redis 在本地执行代码,而不是像上面示例那样将数据推送到客户端。这样做更快有很多原因,但主要的一点是:Lua 脚本可以从 Redis 读取数据而无需乐观锁。
这是前一个事务作为一行 Lua 脚本的实现方式
EVAL "redis.call('SET', KEYS[1], tonumber(redis.call('GET', KEYS[1]) or 0) + tonumber(ARGV[1]))" 1 counter 42
在我看来,有几种合理的情况,你可能会正当地更喜欢带有乐观锁的事务而非 Lua 脚本
除非你的应用满足以上两点,否则我建议你选择 Lua 脚本而不是 WATCH。
回顾一下:MULTI/EXEC 事务(不带 WATCH)和 Lua 脚本永远不会被 Redis 丢弃,而 MULTI/EXEC + WATCH 将导致 Redis 中止那些依赖于对应键被 WATCH 后发生变化的事务。Lua 脚本比简单(即不带 WATCH)的事务更强大,因为它们也可以从 Redis 读取值;并且比带有 WATCH 的事务更高效,因为它们无需乐观锁即可读取值。
关于乐观锁的关键点在于,当被 WATCH 的键发生变化时,客户端使用 EXEC 提交整个事务时,整个事务会立即被丢弃。Redis 有一个主单线程命令执行循环,因此当事务队列正在执行时,没有其他命令会运行。这意味着 Redis 事务具有真正的可序列化隔离级别,也意味着实现 WATCH 不需要回滚机制。
但当事务中出现错误时会发生什么?答案是 Redis 将继续执行所有命令并报告所有发生的错误。
更确切地说,有些类型的错误 Redis 可以在客户端调用 EXEC 之前捕获。一个基本例子是明显的语法错误
MULTI
GOT key? (注意:Redis 没有 GOT 命令,而且在第八季之后也永远不会有)
EXEC
但并非所有错误都可以通过检查命令语法来发现,这些错误可能导致事务行为异常。例如
MULTI
SET counter banana
INCRBY counter 10
EXEC
上面的例子会被执行,但是 INCRBY 命令将失败,因为“counter”键不包含数字。这类错误只有在运行事务时才能发现(尽管在这个简化示例中,是我们设置了错误的初始值)。
此时,有人可能会说有回滚机制会很不错。如果不是出于两个考虑,我可能会同意
第二点尤为重要,因为它也适用于 SQL:SQL DBMS 提供了许多机制来帮助保护数据完整性,但即使是它们也无法完全保护你免受编程错误的侵害。在这两个平台上,编写正确事务的责任都在于你。
如果这似乎与你使用 SQL 数据库的经验相冲突,那么让我们看看依赖错误来强制约束与依赖错误来保护数据免受代码中 bug 的区别。
在 SQL 中,通常的做法是使用索引来实现数据的约束,并在客户端依赖这些索引来确保正确性。一个常见的例子是向“username”列添加“UNIQUE”约束,以确保每个用户都有不同的用户名。此时,客户端会尝试插入新用户,并期望在已经存在同名用户时插入失败。
这是对 SQL 数据库完全合理的用法,但依赖约束来实现应用逻辑与期望回滚保护你免受事务逻辑本身错误的影响是完全不同的。.
在 AWS re:Invent 2019 大会上,当一位与会者问我“为什么 Redis 没有回滚?”时,我的回答是基于列举人们在 SQL 中使用回滚的原因。在我看来,这样做只有两个主要原因:
使用回滚的第一个原因:并发
最常见的 SQL 数据库是多线程应用,当客户端请求高隔离级别时,DBMS 宁愿触发异常也不愿停止服务所有其他客户端。这对于 SQL 生态系统来说是合理的,因为 SQL 事务是“健谈的”(chatty):客户端锁定一些行,读取一些值,计算要应用的更改,最后提交事务。
在 Redis 中,事务并非设计成如此交互式。Redis 主事件循环的单线程特性确保了事务运行时,没有其他命令会被执行。这保证了所有事务都是真正的可序列化,而不违反隔离级别。当事务使用乐观锁时,Redis 能够在执行事务队列中的任何命令之前中止它——这不需要回滚。
使用回滚的第二个原因:利用索引约束
在 SQL 中,通常使用索引约束来实现应用逻辑。我提到了 UNIQUE,但这同样适用于外键约束等。前提是应用依赖数据库已正确配置,并利用索引约束以高效的方式实现逻辑。但我相信每个人都见过当有人忘记添加 UNIQUE 约束时,应用会表现异常,例如。
虽然 SQL DBMS 在保护数据完整性方面做得很好,但你不能期望自己能免受事务代码中所有错误的侵害。有一类重要的错误不违反类型检查或索引约束。
Redis 没有内置的索引系统(Redis Modules 是另一回事,不在此处讨论)。例如,为了强制唯一性,你会使用 Set (或等效的)数据类型。这意味着在 Redis 中表达操作的正确方式与在 SQL 中的等效方式看起来不同。Redis 的数据模型和执行模型与 SQL 差异很大,以至于相同的逻辑操作会因平台而异而以不同的方式表达,但应用必须始终与数据库的状态同步。
一个尝试对包含非数字值的键执行 INCRBY 的应用,与一个期望 SQL 模式与数据库中不一致的应用是相同的。如果你的 Redis 数据库中有恶意进程进行意外更改,使用访问控制列表(ACL)将其锁定。
如果你有 SQL 背景,你可能会对 Redis 中事务的工作方式感到惊讶,这是可以理解的。鉴于 NoSQL 已经证明关系型数据库并非唯一有价值的数据存储模型,不要错误地认为任何偏离 SQL 提供功能的设计就一定不如 SQL。SQL 事务是健谈的,基于多线程模型,并与其他子系统交互以在故障时利用回滚。相比之下,Redis 事务更注重性能,并且没有可用于强制约束的索引子系统。由于这些差异,你在 Redis 中编写事务的“风格”与 SQL 根本不同。
这意味着 Redis 中缺乏回滚功能并不限制其表达能力。所有合理的 SQL 事务都可以重写为功能等效的 Redis 事务,但这在实践中并非总是轻而易举。在 Redis 中推理一个最初用 SQL 表达的问题,需要你以不同的方式思考数据,并且还需要考虑不同的执行模型。
最后,回滚确实有助于保护你的数据免受编程错误的侵害,但这并非旨在解决该问题。作为基于键值结构的多模型数据库,Redis 不像 SQL 那样提供相同的“类型检查”便利性,但有一些技巧可以提供帮助,正如 Redis 开发者倡导者负责人 Kyle Davis 在最近这篇博客文章中解释的那样:在 RedisPy 中使 Lua 脚本万无一失。
话虽如此,无论使用关系型数据库还是 Redis,你的应用都需要与数据库中的内容保持同步。对于 Redis 而言,回滚的实用性在性能和额外复杂性方面得不偿失。如果你曾经想知道 Redis 为何比其他数据库快这么多,这又是一个原因。