[译] Redis 事务

文章来自 Redis.io

事务

MULTI, EXEC, DISCARD, WATCH 是 Redis 事务的基础。它使得一组命令可以一步执行,并提供以下两点保证:

  • 事务中的全部命令都是串行、顺序执行的。来自其他客户端的请求不会打断正在运行的事务代码。这保证了事务里的命令独立运行。

  • 所有命令要么全被执行,要么一个也不执行,因此 Redis 事务也具有原子性。EXEC 命令触发执行所有事务命令。如果客户端在调用 MULTI 命令之前丢失连接,不会执行任何命令;同理,如果执行了EXEC, 那么所有命令都会被执行。如果使用了 append-only 文件,Redis 会调用一次 write(2) 系统函数来把本次事务持久化到磁盘。但是,如果 Redis 服务器崩溃或被硬件中断,那就有可能只有部分命令被记录下来。Redis 会在重启时检查这种情况,如果属实,它会报错并退出。使用 redis-check-aof 工具可以修复 append-only 文件,删除不完整的事务命令,使得服务器正常重启。

从 Redis-2.2 开始, Redis 额外提供了另一层保护,提供一种类似于 check-and-set(CAS) 操作的乐观锁机制。

用法

MULTI 标志事务的开始。这个命令总是回复 OK。之后用户可以指定多个命令。Redis 并不会立即执行它们,而是将其入队。执行EXEC后,所有命令立即被执行。
DISCARD 命令则清空整个队列,退出事务。

下面例子将 foo 和 bar 指示的值原子自增。

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

从上面可以看出, EXEC 返回一系列回复。每个响应都是单个命令的回复,顺序也跟命令的顺序一致。
当 Redis 连接处于 MULTI 请求过程时,所有的命令都以 QUEUED 响应。入队命令待由 EXEC 后执行。

事务中的错误

事务有两种可能的错误:

  • 命令入队失败,因此它是 EXEC 执行之前的错误。如,命令语法有错(参数数量不对、命令名称不对等等),或者处于紧急情况,像内存溢出(如果服务器配置了 maxmemory)。

  • 调用 EXEC 后出错。如,对一个键执行错误类型的值操作(对字符串键执行列表命令)。

在执行 EXEC 之前,客户端可以通过命令的响应来察觉第一种错误: 如果返回 QUEUED, 说明正确入队; 否则返回错误信息。如果入队过程中出错,大多数 Redis 客户端终止这个事务。

但从 Redis-2.6.5 开始,服务器会记录手机命令时的错误,在执行 EXEC 时拒绝执行事务,自动丢弃这个事务。

Redis-2.6.5 之前的行为是,事务继续执行其他正确入队的命令,尽管 EXEC 发生过错误。后来的新行为使得 Redis 更容易将事务与流水线相结合。如此,整个事务可以一次性发送出去,一次性接受回复。

发生在 EXEC 调用之后的错误处理也没有什么特别的:尽管有的命令出错了,但照样执行正确入队的命令。

从协议角度理解更加清晰。下面这个例子中,尽管语法没错,但有一个命令不能执行成功。

Trying 127.0.0.1...
Connected to localhost
Escape character is '^]'.
MULTI
+OK
SET a 3
abc
+QUEUED
LPOP a
+QUEUED
EXEC
*2
+OK
-ERR Operation against a key holding the wrong kind of value

EXEC 批量返回两个字符串型响应,一个是 OK,一个是 -ERR。
值得注意的是,虽然有一个命令出错了,但是 Redis 仍然执行了其他正确入队的命令,即,Redis 并不是啥都没做。

再来一个例子,仍使用 Telnet 连接,表明尽可能快地报告语法错误。

MULTI
+OK
INCR a b c
-ERR wrong number of arguments for 'incr' command

由于语法错误, INCR 命名没有入队。

为什么 Redis 不支持回滚?

如果你有过关系型数据库的使用经验,可能会对 Redis 事务出错但仍执行部分命令的行为感到奇怪。

现在来理解一下这个行为:

  • Redis 命令出错的两种情况:语法不对(而且这种错误不会在入队过程中被发现),和执行类型不匹配的操作:也就是说,错误的命令通常是编程错误导致的,这种行为完全可以在开发环境下检查到,不会涉及生产环境。
  • Redis 天生简洁、快速,不需要回滚功能。

反对 Redis 不支持回滚的观点之一是说,它会带来bugs。但是,请注意,回滚并不能帮你避免编程错误。比如,一条语句将值自增2,而不是1,或者对错误的键执行自增,那么没有任何回滚机制能帮得上忙。鉴于没有办法完全保证免于编程错误,以及出错的 Redis 命令不可能进入生产环境,我们选择了不支持回滚功能,以实现更简单更快的数据库。

丢弃命令队列

DISCARD 可以用来终止一个事务。这样一来,不执行任何命令,连接状态转向正常。

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

使用 check-and-set 的乐观锁

WATCH 为 Redis 事务提供了 check-and-set 行为支持。

被 WATCH 的键会被监测其可能的修改。如果在执行 EXEC 之前,至少有一个被 WATCH 的键被修改,那么整个事务将失效。EXEC 返回空回复告知事务失败。

比如我们需要原子性地自增一个键(假设还没有 INCR 这个命令)。

一开始可能会这么做

val = GET key
val = val + 1
SET key $val

这种做法只有当一个客户端在一定时间内执行时才有效。如果多用户几乎同时对这个键执行自增操作,将会产生竞态(race condition)。比如,客户端 A 和 B 都读到旧值 10。两个人都将这个值自增到 11。最终把新值赋给这个键。因此最这个键的值是11,而不是预期的12。

幸亏有 WATCH 命令,我们可以处理这一问题

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

如果竞态发生,另一个客户端在 WATCHEXEC 之间修改了 key 的值,那么整个事务将失败。

重复这个操作,期望不产生新的竞态。这种锁称作乐观锁,在锁机制中很有用。在许多应用场景中,多客户端访问不同的键,因此冲突不太频繁发生,通常也就不需要重复执行这个操作。

解释 WATCH

到底什么是 WATCH 呢?这个命令给 EXEC 加了一层限制:限制 Redis 只有当被监测的键没有被修改时才执行事务。否则不会进入事务。(注意,如果你 WATCH 一个有过期时间的键,并在 WATCH 之后过期了,那么 EXEC 仍会有效,即事务不会失败)。

WATCH 可以多次使用。WATCH 的生命周期从被调用开始,到 EXEC 执行时结束。你可以一次给 WATCH 传多个键。

当执行 EXEC 后,所有的键都处于 UNWATCHED 状态,不管事务成功失败与否。当客户端连接断开时,所有的键也归为 UNWATCHED。

也可以用 WATCH (不带参数)来清空所有被监视的键,即取消所有被监视状态。如果我们一开始监视了一些键,自己想修改它们,而对其加上乐观锁,但后来改主意了,这时候执行 UNWATCH 来把连接置于正常状态,以便开启新的事务。

使用 WATCH 实现 ZPOP

实现 ZPOP 是解释 WATCH 用来创建原子操作的好例子。这个命令原子地从有序集合中弹出分值(score)最低的元素。下面是最简单的一种实现。

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

如果 EXEC 失败了(返回空回复),重复执行。

Redis 脚本和事务

从定义上讲, Redis 脚本也具有事务性。所以通常对事务的操作也可以施加于脚本之上。脚本更简单,执行起来也更快。

之所以有这部分,是因为脚本在 Redis-2.6 才引入,而事务存在已久。但是短期内不会移除对事务的支持因为即使不借助 Redis 脚本,避免竞态也是有可能的,尤其是 Redis 事务的实现复杂性已经最低了。

但是,很有可能不久的将来, Redis 用户群只使用脚本。如果真是这样,我们会考虑把事务功能移除。

你可能感兴趣的:([译] Redis 事务)