概述

事务是数据库的一个重要功能。所谓的事务,就是指对数据进行读写的一系列操作。事务在执行时,会提供专门的属性保证,包括原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),也就是 ACID 属性。

那么,Redis 可以完全保证 ACID 属性吗?毕竟,如果有些属性在一些场景下不能保证的话,很可能会导致数据出错,所以,我们必须要掌握 Redis 对这些属性的支持情况,并且提前准备应对策略。

接下来,我们就先了解 ACID 属性对事务执行的具体要求,有了这个知识基础后,我们才能准确地判断 Redis 的事务机制能否保证 ACID 属性。

ACID的概念

  • 原子性:原子性的要求很明确,就是一个事务中的多个操作必须都完成,或者都不完成。业务应用使用事务时,原子性也是最被看重的一个属性。

  • 一致性:一致性表示数据库中的数据在事务执行前后是一致的。

  • 隔离性:它要求数据库在执行一个事务时,其它操作无法存取到正在执行事务访问的数据。

  • 持久性:数据库执行事务后,数据的修改要被持久化保存下来。当数据库重启后,数据的值需要是被修改后的值。

了解了 ACID 属性的具体要求后,那么 Redis 是如何来实现事务机制的呢?

事务基本使用

Redis 中的事务从开始到结束也是要经历三个阶段:

  • 开启事务(multi)
  • 命令入列 (Redis基本指令)
  • 执行事务/放弃事务(exec/discard)

执行事务/放弃事务

执行事务的命令是 exec,放弃事务的命令是 discard。

执行事务示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> multi 
OK
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> set k2 v2
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) OK
127.0.0.1:6379> mget k1 k2
1) "v1"
2) "v2"

放弃事务示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> multi 
OK
127.0.0.1:6379(TX)> set k1 val1
QUEUED
127.0.0.1:6379(TX)> set k2 val2
QUEUED
127.0.0.1:6379(TX)> discard
OK
127.0.0.1:6379> mget k1 k2
1) "v1"
2) "v2"

事务错误及回滚

Redis事务执行中的错误可以分为以下三类:

  • 命令入队时不报错,exec执行时才报错(执行时错误),不会终止事务,不保证原子性;
  • 命令入队时报错,导致事务终止,保证原子性;
  • EXEC 命令执行时Redis发生了故障,如果开启了 AOF 日志,可以保证原子性;

命令入队时不报错,exec执行时才报错

这种一般发生在事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误,所以能正常入队,但是执行时会报错。

1
2
3
4
5
6
7
8
9
10
11
12
13
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 val1
QUEUED
127.0.0.1:6379(TX)> expire k1 5s # 错误指令,过期时间只能为数字,不能为字符串
QUEUED
127.0.0.1:6379(TX)> exec
1) OK
2) (error) ERR value is not an integer or out of range # 执行指令expire k1 5s 失败
127.0.0.1:6379> get k1
"val1"

由此得出,即使事务队列中某个命令在执行期间发生了错误,事务也会继续执行,直到事务队列中所有正确的命令执行完成

命令入队时就报错,导致事务终止

这种情况主要发生客户端发送的命令本身就有问题(如语法错误、使用不存在的命令等),导致在入列时就被发现了。

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 val1
QUEUED
127.0.0.1:6379(TX)> set k1 # 错误指令(和上面执行时错误中的区别为,上文expire k1 5s格式是对的,只不过第三个只能是数字,所以能入列,但是执行会报错)
(error) ERR wrong number of arguments for 'set' command # 入列错误,不能没有value值
127.0.0.1:6379(TX)> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get k1
"v1"

上面的例子可以得出,入列时错误,事务是不能执行成功的。

在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败。

在这种情况下,如果 Redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。我们需要使用 redis-check-aof 工具检查 AOF 日志文件,这个工具可以把未完成的事务操作从 AOF 文件中去除。这样一来,我们使用 AOF 恢复实例后,事务操作不会再被执行,从而保证了原子性。

为何失败时不支持事务回滚?

Redis 官方文档的解释如下:

If you have a relational databases background, the fact that Redis commands can fail during a transaction, but still Redis will execute the rest of the transaction instead of rolling back, may look odd to you.

However there are good opinions for this behavior:

  • Redis commands can fail only if called with a wrong syntax (and the problem is not detectable during the command queueing), or against keys holding the wrong data type: this means that in practical terms a failing command is the result of a programming errors, and a kind of error that is very likely to be detected during development, and not in production.
  • Redis is internally simplified and faster because it does not need the ability to roll back.

An argument against Redis point of view is that bugs happen, however it should be noted that in general the roll back does not save you from programming errors. For instance if a query increments a key by 2 instead of 1, or increments the wrong key, there is no way for a rollback mechanism to help. Given that no one can save the programmer from his or her errors, and that the kind of errors required for a Redis command to fail are unlikely to enter in production, we selected the simpler and faster approach of not supporting roll backs on errors.

大概的意思是,作者不支持事务回滚的原因有以下两个:

  • 他认为 Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以他认为没有必要为 Redis 开发事务回滚功能;
  • 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。

这里不支持事务回滚,指的是不支持执行时错误的事务回滚。

Redis 的事务机制能保证哪些属性?

原子性

从上文我们知道了,Redis 对事务原子性属性的保证情况如下:

  • 命令入队时不报错,exec执行时才报错(执行时错误),不会终止事务,不保证原子性;
  • 命令入队时报错,导致事务终止,保证原子性;
  • EXEC 命令执行时Redis发生了故障,如果开启了 AOF 日志,可以保证原子性;

所以,在命令执行错误或 开启了 AOF时Redis 发生故障的情况下,Redis 事务机制对一致性属性是有保证的。

一致性

事务的一致性保证会受到错误命令、实例故障的影响。所以,我们按照命令出错和实例故障的发生时机,分成三种情况来看。

情况一:命令入队时就报错

在这种情况下,事务本身就会被放弃执行,所以可以保证数据库的一致性。

情况二:命令入队时没报错,实际执行时报错

在这种情况下,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。

情况三:EXEC 命令执行时实例发生故障

在这种情况下,实例故障后会进行重启,这就和数据恢复的方式有关了,我们要根据实例是否开启了 RDB 或 AOF 来分情况讨论下。

如果我们没有开启 RDB 或 AOF,那么,实例故障重启后,数据都没有了,数据库是一致的。

如果我们使用了 RDB 快照,因为 RDB 快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据库里的数据也是一致的。

如果我们使用了 AOF 日志,而事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,我们可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的。

所以,总结来说,在命令执行错误或 Redis 发生故障的情况下,Redis 事务机制对一致性属性是有保证的。接下来,我们再继续分析下隔离性。

隔离性

事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段,所以,我们就针对这两个阶段,分成两种情况来分析:

  1. 并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;
  2. 并发操作在 EXEC 命令后执行,此时,隔离性可以保证。

我们先来看第一种情况。一个事务的 EXEC 命令还没有执行时,事务的命令操作是暂存在命令队列中的。此时,如果有其它的并发操作,我们就需要看事务是否使用了 WATCH 机制。

WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。

WATCH 机制的具体实现是由 WATCH 命令实现的,我给你举个例子,你可以看下下面的图,进一步理解下 WATCH 命令的使用。

img

我来给你具体解释下图中的内容。

在 t1 时,客户端 X 向实例发送了 WATCH 命令。实例收到 WATCH 命令后,开始监测 a:stock 的值的变化情况。

紧接着,在 t2 时,客户端 X 把 MULTI 命令和 DECR 命令发送给实例,实例把 DECR 命令暂存入命令队列。

在 t3 时,客户端 Y 也给实例发送了一个 DECR 命令,要修改 a:stock 的值,实例收到命令后就直接执行了。

等到 t4 时,实例收到客户端 X 发送的 EXEC 命令,但是,实例的 WATCH 机制发现 a:stock 已经被修改了,就会放弃事务执行。这样一来,事务的隔离性就可以得到保证了。

当然,如果没有使用 WATCH 机制,在 EXEC 命令前执行的并发操作是会对数据进行读写的。而且,在执行 EXEC 命令的时候,事务要操作的数据已经改变了,在这种情况下,Redis 并没有做到让事务对其它操作隔离,隔离性也就没有得到保障。下面这张图显示了没有 WATCH 机制时的情况,你可以看下。

img

在 t2 时刻,客户端 X 发送的 EXEC 命令还没有执行,但是客户端 Y 的 DECR 命令就执行了,此时,a:stock 的值会被修改,这就无法保证 X 发起的事务的隔离性了。

刚刚说的是并发操作在 EXEC 命令前执行的情况,下面我再来说一说第二种情况:并发操作在 EXEC 命令之后被服务器端接收并执行

因为 Redis 是用单线程执行命令,而且,EXEC 命令执行后,Redis 会保证先把命令队列中的所有命令执行完。所以,在这种情况下,并发操作不会破坏事务的隔离性,如下图所示:

img

持久性

因为 Redis 是内存数据库,所以,数据是否持久化保存完全取决于 Redis 的持久化配置模式。

如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。

如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。

所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。

总结

事务的 ACID 属性是我们使用事务进行正确操作的基本要求。通过这节课的分析,我们了解到了,Redis 的事务机制可以保证一致性和隔离性,但是无法保证持久性。不过,因为 Redis 本身是内存数据库,持久性并不是一个必须的属性,我们更加关注的还是原子性、一致性和隔离性这三个属性。

原子性的情况比较复杂,只有命令入队时不报错,执行时才报错时,原子性得不到保证,在其它情况下,事务都可以原子性执行。

附录

Watch与unwatch

Watch指令

watch 命令用于客户端并发情况下,为事务提供一个乐观锁(CAS,Check And Set),也就是可以用 watch 命令来监控一个或多个变量,如果在事务的过程中,某个监控项被修改了,那么整个事务就会终止执行。watch的语法格式为:watch key [key …]

示例如下:

image-20221208160031085

Tips: watch 命令只能在客户端开启事务之前执行,在事务中执行 watch 命令会引发错误,但不会造成整个事务失败,如下代码所示:

1
2
3
4
5
6
7
8
9
10
11
12
127.0.0.1:6379> get k1
"v1"
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 val1
QUEUED
127.0.0.1:6379(TX)> watch k1
(error) ERR WATCH inside MULTI is not allowed
127.0.0.1:6379(TX)> exec
1) OK
127.0.0.1:6379> get k1
"val1"

unwatch 命令

用于清除所有之前监控的所有对象(键值对)。

unwatch 示例如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:6379> set k v
OK
127.0.0.1:6379> watch k
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> unwatch
QUEUED
127.0.0.1:6379> set k v2
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
127.0.0.1:6379> get k
"v2"

可以看出,即使在事务的执行过程中,k 值被修改了,因为调用了 unwatch 命令,整个事务依然会顺利执行。

mulil命令不能嵌套使用

1
2
3
4
5
6
7
8
9
10
127.0.0.1:6379> multi
OK
127.0.0.1:6379(TX)> set k1 v1
QUEUED
127.0.0.1:6379(TX)> multi
(error) ERR MULTI calls can not be nested
127.0.0.1:6379(TX)> exec
1) OK
127.0.0.1:6379> get k1
"v1"

Tips:multi 命令用于开启事务,它让客户端从非事务态转变为事务态,但multi 命令不能嵌套使用,如果已经开启了事务的情况下,再执行 multi 命令,会提示错误。但是仍旧会将正确的指令执行完毕