likes
comments
collection
share

为什么 Redis 不支持事务回滚

作者站长头像
站长
· 阅读数 9

You Don’t Need Transaction Rollbacks in Redis

原文链接:redis.com/blog/you-do…

Redis 具有两种自动执行多个操作的主要机制:MULTI/EXEC 事务和 Lua 脚本。 Redis 中事务的一大特点是缺乏回滚机制,这常常让新手感到困惑。 在我担任 Redis 开发倡导者期间,我与一些具有传统 SQL 背景的工程师交谈过,他们发现这令人不安,因此我想通过此博客分享我对此主题的看法,并认为在Redis中您不需要回滚。

MULTI/EXEC transactions

Redis 中的事务以 MULTI 命令开始。 一旦发送,连接就会切换模式,并且通过连接发送的所有后续命令将由 Redis 排队,而不是立即执行,但 DISCARD 和 EXEC 除外(这将分别导致事务中止或提交)。 提交事务意味着执行先前排队的命令。

MULTI
SET mykey hello
INCRBY counter 10
EXEC

事务(和 Lua 脚本)确保了两件重要的事情:

  1. 其他客户端不会看到部分状态,这意味着所有客户端都将在应用事务之前或之后看到状态。
  2. 如果节点发生故障,一旦 Redis 重新启动,它将重新加载整个事务,或者不从 AOF 文件中加载任何事务。

关于事务要记住的最后一件基本事情:即使启动了 MULTI 事务,Redis 仍将继续为其他客户端提供服务。 仅当通过调用 EXEC 提交事务时,Redis 才会短暂停止应用其他客户端的命令。 这与 SQL 数据库有很大不同,在 SQL 数据库中,事务利用 DBMS 内的各种机制来提供不同程度的隔离保证,并且客户端可以在执行事务时从数据库读取值。 在 Redis 中,事务是“一次性”的,换句话说,只是一次性执行的一系列命令。 那么,如何创建依赖于 Redis 中数据的事务呢? 为此,Redis 实现了 WATCH,这是一个执行乐观锁定的命令。

Optimistic locking with WATCH

让我从实战层面向您展示为什么在事务中无法从 Redis 读取值:

MULTI
SET counter 42
GET counter
EXEC

如果在redis-cli中运行这一系列命令,“GET counter”的回复将是“QUEUED”,并且只有调用EXEC才会返回值“42”,同时执行返回“OK” 设置命令。

要编写依赖于从 Redis 读取数据的事务,必须使用 WATCH。 一旦运行,该命令将确保只有在调用 EXEC 之前被监视的键没有更改的情况下才会执行后续事务。

例如,如果 INCRBY 不存在,这就是实现原子增量操作的方法:

WATCH counter
GET counter
MULTI
SET counter <the value obtained from GET + any increment>
EXEC

在此示例中,我们首先通过“counter”键创建一个 WATCH 触发器,然后获取其值。 请注意 GET 在我们启动事务主体之前是如何发生的,这意味着它将立即执行并返回键的当前值。 此时,我们使用 MULTI 启动事务,并通过在客户端计算“counter”的新值来应用更改。

如果多个客户端尝试同时对“counter”这个key执行相同的事务,则某些事务将被 Redis 自动丢弃。 此时,重试事务通常是客户端的工作。 这类似于 SQL 事务,较高的隔离级别有时会导致事务中止,从而使客户端需要重试它。

Lua scripts

虽然 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 脚本。

除非这两点同时满足,否则都推荐使用 lua。

Errors in transactions

回顾一下:MULTI/EXEC 事务(没有 WATCH)和 Lua 脚本永远不会被 Redis 丢弃,而 MULTI/EXEC + WATCH 将导致 Redis 中止依赖于相应键被监视后更改的值的事务。 Lua 脚本比简单(即无 WATCH)事务更强大,因为它们还可以从 Redis 读取值,并且比“WATCH”事务更高效,因为它们不需要乐观锁定来读取值。

乐观锁的关键点在于,当 WATCHed key 发生更改时,当客户端使用 EXEC 提交事务时,整个事务将立即被丢弃。 Redis 有一个主要的单线程命令执行循环,因此当事务队列正在执行时,不会运行其他命令。 这意味着Redis事务具有真正的可序列化隔离级别,也意味着不需要回滚机制来实现WATCH。

但是当事务出现错误时会发生什么? 答案是Redis将继续执行所有命令并报告发生的所有错误。

更准确地说,Redis 可以在客户端调用 EXEC 之前捕获某些类型的错误。 一个基本的例子是明显的语法错误:

MULTI
GOT key? (NOTE: Redis has no GOT command and, after season 8, it never will)
EXEC

但并非所有错误都可以通过检查命令语法来发现,这些错误可能会导致事务行为异常。 举个例子:

MULTI
SET counter banana
INCRBY counter 10
EXEC

上面的示例将被执行,但 INCRBY 命令将失败,因为“counter”键不包含数字。 这种类型的错误只有在运行事务时才能发现(没关系,在这个简化的示例中,我们是设置错误初始值的人)。

此时此刻,人们可能会说回滚是件好事。 如果不是出于两个考虑,我可能会同意:

  1. 实现回滚所需的快照机制会产生相当大的计算成本。 这种额外的复杂性与 Redis 的理念和生态系统格格不入。
  2. 回滚无法捕获所有错误。 在上面的示例中,我们将“counter”设置为“banana”以显示明显的错误,但在现实世界中,以错误方式使用“counter”键的进程可能会删除它,或者放入一个 例如,信用卡号码。 回滚会增加相当多的复杂性,并且仍然无法完全解决问题。

第二点特别重要,因为它也适用于 SQL:SQL DBMS 提供了许多机制来帮助保护数据完整性,但即使它们也不能完全保护您免受编程错误的影响。 在这两个平台上,编写正确交易的负担仍然由您承担。

Rollbacks in SQL DBMSs

如果这似乎与您使用 SQL 数据库的体验相冲突,让我们看看「依靠报错来强制约束」与「依靠报错来保护数据免受代码中的错误影响」之间的区别。

SQL 中的常见做法是使用索引来实现对数据的约束,并依赖客户端的这些索引来确保正确性。 一个常见的示例是向“用户名”列添加“UNIQUE”约束,以确保每个用户具有不同的用户名。 此时,客户端将尝试插入新用户,并预计当另一个同名用户已存在时插入会失败。

这是 SQL 数据库的完全合法使用,但是依靠约束来实现应用程序逻辑与期望回滚来保护您免受事务逻辑本身的错误有很大不同。

在 AWS re:Invent 2019 上,当一位与会者问我“为什么 Redis 没有回滚功能?” 我的回答是基于:举例人们在 SQL 中使用回滚的原因。 在我看来,这样做的主要原因只有两个:

First reason to use rollbacks: concurrency

最常见的 SQL 数据库是多线程应用程序,当客户端请求高隔离级别时,DBMS 更愿意触发异常,而不是停止为所有其他客户端提供服务。 这对于 SQL 生态系统来说是有意义的,因为 SQL 事务是“喋喋不休的”:客户端锁定几行,读取一些值,计算要应用的更改,最后提交事务。

在 Redis 中,事务并不意味着具有交互性。 Redis 中主事件循环的单线程性质确保事务运行时不会执行其他命令。 这可确保所有事务真正可序列化,而不会违反隔离级别。 当事务使用乐观锁定时,Redis 将能够在执行事务队列中的任何命令之前中止事务,这不需要回滚。

Second reason to use rollbacks: leveraging index constraints

在 SQL 中,通常使用索引约束来实现应用程序中的逻辑。 我提到了 UNIQUE,但这同样适用于外键约束等。 前提是应用程序依赖于已正确配置的数据库并利用索引约束以有效的方式实现逻辑。 但我确信每个人都见过应用程序行为不当,例如,当有人忘记添加 UNIQUE 约束时。

虽然 SQL DBMS 在保护数据完整性方面做得很好,但您不能指望能够避免事务代码中的所有错误。 有一类重要的错误不会违反类型检查或索引约束。

Redis 没有内置索引系统(Redis 模块是另一回事,不适用于此)。 例如,要强制唯一性,您可以使用 Set(或等效)数据类型。 这意味着 Redis 中表达操作的正确方式看起来与 SQL 中的等效方式不同。 Redis的数据模型和执行模型与SQL有很大不同,相同的逻辑操作会根据平台以不同的方式表达,但应用程序必须始终与数据库的状态同步。

尝试 INCRBY 包含非数字值的键的应用程序与期望 SQL 架构与数据库上的内容不一致的应用程序相同。

Redis vs. SQL

如果您有 SQL 背景,您可能会对 Redis 中事务的工作方式感到惊讶,这是可以理解的。 鉴于 NoSQL 已经证明关系数据库并不是存储数据的唯一有价值的模型,因此不要错误地认为任何偏离 SQL 提供的功能本质上都是低劣的。 SQL 事务非常繁琐,基于多线程模型,并与其他子系统进行互操作,以在发生故障时利用回滚。 相比之下,Redis 事务更注重性能,并且没有索引子系统可用于强制执行约束。 由于这些差异,您在 Redis 中使用的事务编写“风格”与 SQL 有着根本的不同。

这意味着 Redis 中缺乏回滚并不会限制表达能力。 所有合理的 SQL 事务都可以重写为功能等效的 Redis 事务,但在实践中并不总是那么容易。 推理最初在 Redis 中的 SQL 中阐明的问题需要您以不同的方式思考数据,并且还需要考虑不同的执行模型。

最后,回滚确实有助于保护数据免受编程错误的影响,但它们并不是解决该问题的方法。 作为基于键值结构的多模型数据库,Redis 无法提供与 SQL 相同级别的“类型检查”便利性,但有一些技术可以帮助实现这一点,正如 Kyle Davis,Head of Developer Advocacy at Redis,在最近的博客文章中进行了解释: Bullet-Proofing Lua Scripts in RedisPy.

也就是说,无论是使用关系数据库还是使用 Redis,您的应用程序都需要与数据库中的内容同步。 对于 Redis 而言,回滚的效用不会超过性能和额外复杂性方面的成本。 如果您想知道 Redis 为何比其他数据库快得多,这也是一个原因。