跳转到主要内容

事务

MULTIEXECDISCARDWATCH 是 KeyDB 中事务的基础。它们允许在一个步骤中执行一组命令,并提供两个重要的保证:

  • 事务中的所有命令都会被序列化并按顺序执行。绝不会发生在 KeyDB 事务执行的**过程中**,由另一个客户端发出的请求被处理的情况。这保证了这些命令是作为一个独立的隔离操作来执行的。

  • 要么所有命令都被处理,要么一个也不处理,所以 KeyDB 事务也是原子性的。EXEC 命令触发事务中所有命令的执行,因此,如果一个客户端在调用 EXEC 命令之前,在事务上下文中与服务器失去连接,那么任何操作都不会被执行;相反,如果调用了 EXEC 命令,所有的操作都会被执行。当使用只追加文件(append-only file)时,KeyDB 确保使用单个 write(2) 系统调用将事务写入磁盘。然而,如果 KeyDB 服务器崩溃或被系统管理员以某种硬方式终止,可能只有部分操作被记录下来。KeyDB 会在重启时检测到这种情况,并会带错误退出。使用 keydb-check-aof 工具可以修复只追加文件,该工具将移除部分事务,以便服务器可以再次启动。

除了以上两个保证外,KeyDB 还提供了一个额外的保证,即乐观锁,其方式与检查并设置(CAS)操作非常相似。这一点在本页后面有详细介绍。

用法#

使用 MULTI 命令可以进入 KeyDB 事务。该命令总是回复 OK。此时,用户可以发出多个命令。KeyDB 不会立即执行这些命令,而是将它们排入队列。一旦调用 EXEC,所有命令才会被执行。

如果调用 DISCARD,则会清空事务队列并退出事务。

下面的例子原子性地增加了键 foobar 的值。

> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

从上面的会话中可以看到,EXEC 返回一个回复数组,其中每个元素都是事务中单个命令的回复,顺序与命令发出的顺序相同。

当 KeyDB 连接处于 MULTI 请求的上下文中时,所有命令都会回复字符串 QUEUED(从 KeyDB 协议的角度看,这是一个状态回复)。一个被排入队列的命令只是被安排在调用 EXEC 时执行。

事务内的错误#

在事务期间,可能会遇到两种命令错误:

  • 一个命令可能无法进入队列,因此在调用 EXEC 之前可能会出现错误。例如,命令可能存在语法错误(参数数量错误、命令名称错误等),或者可能出现一些严重情况,如内存不足(如果服务器使用 maxmemory 指令配置了内存限制)。
  • 一个命令可能在 EXEC 被调用*后*失败,例如,我们对一个持有错误类型值的键执行了操作(比如对一个字符串值调用列表操作)。

客户端过去通过检查排队命令的返回值来感知第一类错误,即在 EXEC 调用之前发生的错误:如果命令回复 QUEUED,则表示排队正确,否则 KeyDB 会返回一个错误。如果在命令排队时出现错误,大多数客户端会放弃事务并丢弃它。

服务器会记住在累积命令期间发生了错误,并会拒绝执行该事务,在 EXEC 期间也会返回一个错误,并自动丢弃该事务。

而发生在 EXEC *之后*的错误则不会以特殊方式处理:即使在事务中有命令失败,所有其他命令仍将被执行。

这一点在协议层面更为清晰。在下面的例子中,即使语法正确,其中一个命令在执行时也会失败:

正在尝试 127.0.0.1...
已连接到 localhost。
转义字符是 '^]'。
MULTI
+OK
SET a abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-ERR 对一个持有错误类型值的键执行了操作

EXEC 返回了一个包含两个元素的 @bulk-string-reply,其中一个是 OK 代码,另一个是 -ERR 回复。客户端库需要找到一种合理的方式向用户提供这个错误。

重要的是要注意,**即使一个命令失败了,队列中的所有其他命令仍然会被处理**——KeyDB *不会*停止处理命令。

另一个例子,同样使用 telnet 的线路协议,展示了语法错误是如何被尽快报告的:

MULTI
+OK
INCR a b c
-ERR 'incr' 命令的参数数量错误

这一次,由于语法错误,错误的 INCR 命令根本没有被排入队列。

为什么 KeyDB 不支持回滚?#

如果你有关系型数据库的背景,那么 KeyDB 命令在事务中可能会失败,但 KeyDB 仍然会执行事务的其余部分而不是回滚,这可能会让你觉得很奇怪。

然而,这种行为有其充分的理由:

  • KeyDB 命令只会在语法错误(且问题在命令排队期间无法检测到)或对持有错误数据类型的键进行操作时才会失败:这意味着在实际应用中,失败的命令是编程错误的结果,而且这种错误很可能在开发阶段就被发现,而不是在生产环境中。
  • KeyDB 内部更简化、更快,因为它不需要回滚的能力。

反对 KeyDB 观点的一个论点是,错误总会发生。但是,应该注意的是,通常回滚并不能让你免于编程错误。例如,如果一个查询将一个键的值增加了 2 而不是 1,或者增加了错误的键,回滚机制是无法提供帮助的。鉴于没有人能从程序员自己的错误中拯救他们,而且导致 KeyDB 命令失败的那种错误不太可能进入生产环境,我们选择了更简单、更快的方法,即不支持错误时的回滚。

丢弃命令队列#

DISCARD 可以用来中止一个事务。在这种情况下,没有命令会被执行,连接的状态会恢复到正常。

> SET foo 1
OK
> MULTI
OK
> INCR foo
QUEUED
> DISCARD
OK
> GET foo
"1"

使用检查并设置的乐观锁#

WATCH 用于为 KeyDB 事务提供一种检查并设置(CAS)的行为。

WATCH 的键会被监控,以检测它们是否被更改。如果在 EXEC 命令之前,至少有一个被监视的键被修改,整个事务将中止,并且 EXEC 会返回一个 @nil-reply 来通知事务失败。

例如,假设我们需要原子地将一个键的值增加 1(我们假设 KeyDB 没有 INCR 命令)。

第一次尝试可能是这样的:

val = GET mykey
val = val + 1
SET mykey $val

这只有在同一时间只有一个客户端执行该操作时才能可靠地工作。如果多个客户端几乎同时尝试增加该键的值,就会出现竞态条件。例如,客户端 A 和 B 都会读取旧值,比如 10。两个客户端都会将该值增加到 11,并最终将 11 SET 为该键的值。所以最终的值将是 11 而不是 12。

感谢 WATCH,我们能够很好地解决这个问题:

WATCH mykey
val = GET mykey
val = val + 1
MULTI
SET mykey $val
EXEC

使用上面的代码,如果存在竞态条件,并且另一个客户端在我们调用 WATCH 和调用 EXEC 之间的时间内修改了 val 的结果,事务将会失败。

我们只需要重复这个操作,希望这次不会再遇到新的竞争。这种形式的锁定被称为*乐观锁*,是一种非常强大的锁定形式。在许多用例中,多个客户端会访问不同的键,因此冲突不太可能发生——通常不需要重复操作。

WATCH 详解#

那么 WATCH 到底是什么?它是一个使 EXEC 成为有条件的命令:我们要求 KeyDB 仅在没有一个被 WATCH 的键被修改的情况下才执行事务。这包括由客户端进行的修改,如写命令,以及由 KeyDB 自身进行的修改,如过期或驱逐。如果在键被 WATCH 之后、EXEC 收到之前,键被修改了,整个事务将被中止。

注意

  • 在 KeyDB 6.0.9 之前的版本中,一个过期的键不会导致事务被中止。
  • 事务中的命令不会触发 WATCH 条件,因为它们只是排队等待,直到发送 EXEC

WATCH 可以被多次调用。简单来说,所有的 WATCH 调用都会有效果,从调用开始监视变化,直到 EXEC 被调用。你也可以向单个 WATCH 调用发送任意数量的键。

当调用 EXEC 时,所有的键都会被 UNWATCH,无论事务是中止还是成功。同样,当一个客户端连接关闭时,所有东西都会被 UNWATCH

也可以使用 UNWATCH 命令(不带参数)来清除所有被监视的键。有时这很有用,因为我们乐观地锁定了一些键,可能需要执行一个事务来改变这些键,但在读取了键的当前内容后,我们不想继续了。当这种情况发生时,我们只需调用 UNWATCH,这样连接就可以自由地用于新的事务了。

使用 WATCH 实现 ZPOP#

一个很好的例子来说明如何使用 WATCH 来创建 KeyDB 本身不支持的新的原子操作,就是实现 ZPOP(ZPOPMINZPOPMAX 及其阻塞变体),这是一个以原子方式从有序集合中弹出分数最低的元素的命令。这是最简单的实现:

WATCH zset
element = ZRANGE zset 0 0
MULTI
ZREM zset element
EXEC

如果 EXEC 失败(即返回一个 @nil-reply),我们只需重复该操作。

KeyDB 脚本和事务#

一个 KeyDB 脚本 从定义上就是事务性的,所以你能用 KeyDB 事务做的任何事,你也可以用脚本来做,而且通常脚本会更简单、更快。

这种功能上的重复是因为脚本是在事务已经存在很长时间之后才被引入的。然而,我们短期内不太可能移除对事务的支持,因为从语义上看,即使不借助 KeyDB 脚本,仍然能够避免竞态条件,这似乎是适宜的,特别是考虑到 KeyDB 事务的实现复杂性很小。

然而,在不远的将来,我们可能会看到整个用户群都只使用脚本,这并非不可能。如果这种情况发生,我们可能会弃用并最终移除事务。