文章来自 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
如果竞态发生,另一个客户端在 WATCH
和 EXEC
之间修改了 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 用户群只使用脚本。如果真是这样,我们会考虑把事务功能移除。