Redis 系列 事务

开启 redis 探索新篇章

Posted by lichao modified on December 24, 2019

Redis 事务使用非常简单,事务模型很不严格,不能像使用关系数据库事务一样来使用 Redis。

Redis 事务根本不能算「原子性」,而仅仅是满足了事务的「隔离性」,隔离性中的串行化——当前执行的事务有着不被其它事务打断的权利。

事务里没有 if-else 分支逻辑,事务的特点是一口气执行,要么全部执行要么一个都不执行.

事务

MULTI,EXEC,DISCARD 和 WATCH 是 Redis 里构成事务的 4 个基础命令。通过 Redis 的事务,可以做到一次执行一组命令,关于事务 Redis 有两方面的保证:

  1. 在一个事务中的所有命令会被串行化并且是按照顺序依次执行。在事务执行期间一定不会被插入其它客户端传入的命令。这一点保证了这些命令是一个独立的执行体(独立性)。
  2. 所有的命令要么全部被执行,要么没有一条被执行,所以一个 redis 的事务同样也具有原子性。EXEC 这个命令会触发事务中所有的命令开始执行,所以在客户端在执行了 MULTI 命令之后的事务上下文中丢生了和服务器的连接,那么任何一条命令都不会被执行,但如果 EXEC 已经开始执行了的话,所有的命令都会被执行完。当使用append-only 文件作为 Redis 的持久化方式时,Redis 会确保用 write 的系统调用来将事务写到磁盘上。然而如果 Redis 服务器在保存过程中崩溃了或者被系统管理员残忍地 kill 掉了,那么有可能只有一部分操作被注册下来。Redis 会在重启的时候检测到这种情况,并且会以 error 的形式来退出。用 redis-check-aof 工具来修正这种情况下的AOF文件,这个工具会把文件末尾没有写完全的事务移除掉,然后 redis 服务就可以启动了。

从 2.2 版本开始,Redis 对上述的两点有了更加充分的保证,使用的手段是类似于 check-and-set(CAS) 操作的乐观锁。

事务的使用

使用 MULTI 命令进入 Redis 事务。这个命令一般都会有OK作为响应。这时候用户就可以发出多条命令了。Redis 并不直接执行这些命令,Redis 会将这些命令先排队。当执行 EXEC 命令时,所有的命令才会被一次性被执行。

调用 DISCARD 命令会将事务队列里的内容清空并且会退出当前事务。

EXEC 会返回一个响应数组,事务中的每一个命令的响应都会存在这个数组中,响应数组中元素的顺序和执行命令的顺序是一样的。

当一个 Redis 连接所在环境是一个 MULTI 请求的上下文中时,所有命令都会得到一条 QUEUED 的响应。在没有执行 EXEC 之前,这些命令只是简单地被排队而已。

// 例: 原子性地为foo和bar加一。
> MULTI
OK
> INCR foo
QUEUED
> INCR bar
QUEUED
> EXEC
1) (integer) 1
2) (integer) 1

放弃事务

在事务中间可以执行 DISCARD 来放弃当前事务。这种情况下没有任何一条命令会被执行,并且当前连接会返回通常状态。(也就是没有在 MULTI 时的状态)

事务中的错误

  1. 命令入队列失败,也就是说在执行 EXEC 之前就报错了。比如可能打了一个错误的命令(比方说参数数量不对什么的,或者调用了一个根本不存在的命令名等等),或者也有可能是发生了一些严重的情况,比如 OOM(out of memory,当服务器用 maxmemory 配置了内存上限时)了。
  2. 命令在 EXEC 执行之后失败,例如对一个 key 执行了其本身不支持的操作(比如对一个 value 是 string 类型的 key 调用了一些本来是对list 类型才能做的操作)。

命令入队列失败

对于老版本 Redis 客户端来说,需要逐一检查每一个命令的返回值是不是 QUEUED,以判断命令是否会执行成功:如果服务端返回 QUEUED 那么认为是正确,否则的话认为 redis 返回了错误。如果在对命令排队时碰到了错误,大多数的客户端需要直接丢弃掉该事务并退出。

不过从 Redis 2.6.5 开始,服务器会记住在对命令排队时发生的错误,并且能够比较智能地拒绝执行整个事务了,也就是说,这种情况下客户端执行EXEC,redis 服务器也会返回一个错误,并且由服务端自动将这些排队的命令和整个事务废弃掉。(译注:在这种情况下,和mysql的事务的功能比较类似)

在 2.6.5 版本之前,Redis 会执行这些入队的命令,但只会执行那些正确地入队的命令,而会忽略在将命令排队时的错误信息。新版本的行为使其用 pipeline 来混合事务更加简单了,因此整个事务发送和接收响应只要通信一次就好了。

EXEC 执行后失败

在 EXEC 执行之后才发生的错误则不会像上面这样处理了:所有正确的命令都会被正确执行。只有那些错误的不会被执行。(注:系统设计中不一致的行为会导致很多情况下的错误使用,也比较难记。不过一般 EXEC 后才出错的命令看起来也就是因为 key 对应的 value不匹配才会导致这种情况了)

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并不会因为这个错误停止处理其它的命令。

为什么Redis不支持回滚呢?

如果有关系数据库背景的话,像 Redis 这样在事务中就算出错,也能够执行其它正确命令,而不是回滚的行为可能会很奇怪。然而对于这样的古怪行为还是有两方面的观点可以支持:

Redis命令只有在出现调用错误的时候(这种错误在命令入队的时候检测不到),或者key对应的数据类型不不匹配时:也就是说在实践中,错误的命令一般是我们程序的锅,而这种情况在开发阶段就很容易检测出来,而不是在生产环境中才发现。

因此Redis不需要回滚的能力,这样使其也可以做到内部实现的简单和快速。

还有一种和 Redis 设计相左的观点认为程序会经常出bug,你为什么不把程序的bug考虑在内。然而实际上就算redis支持回滚了,也没法帮你解决你自己代码的错误。例如你想要试图对一个key执行incr操作对其加2而不是1,或者在错误的key上执行incr操作,这种情况下没有任何的回滚策略可以帮助你来解决问题。也就是说没有人能够帮助一个码农解决他的错误。何况这些会导致Redis命令失败的错误一般也不会带人到生产环境。因此我们选择了最简单和快速的实现方式来进行报错时的回滚。

使用check-and-set实现的乐观锁

分布式锁 是一种悲观锁。Redis 提供了这种 watch 的机制,它就是一种乐观锁。有了 watch 我们又多了一种可以用来解决并发修改的方法。 watch 的使用方式如下:

watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时,也就是服务器收到了 exec 指令要顺序执行缓存的事务队列时,Redis 会检查关键变量自 watch 之后,是否被修改了 (包括当前事务所在的客户端)。如果关键变量被人动过了,exec 指令就会返回 null 回复告知客户端事务执行失败,这个时候客户端一般会选择重试。

当服务器给 exec 指令返回一个 null 回复时,客户端知道了事务执行是失败的,通常客户端 (redis-py) 都会抛出一个 WatchError 这种错误,不过也有些语言 (jedis) 不会抛出异常,而是通过在 exec 方法里返回一个 null,这样客户端需要检查一下返回结果是否为 null 来确定事务是否执行失败。

WATCH 是被用来在 Redis 事务中实现 check-and-set(CAS) 行为的。

WATCH 命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行。监控一直持续到 EXEC 命令(事务中的命令是在 EXEC 之后才执行的,所以在 MULTI 命令后可以修改 WATCH 监控的键值)