5. 详解Redis中的事务

楔子

我们知道 Redis 是有事务功能的,尽管它不像关系型数据库那样常用,但是在面试中还是很容易被问到的,下面我们就来总结一下 Redis 的事务。

通过 Redis 事务的原理以及实际操作,来彻底攻略 Redis 中的事务。

事务介绍

Redis 事务是一组命令的集合,将多个命令进行打包,然后这些命令会被顺序地添加到队列中,并按照添加的顺序依次执行。

「 Redis 事务中没有像 MySQL 关系型数据库事务隔离级别的概念,不能保证原子性操作,也没有像 MySQL 那样执行事务失败时可以进行回滚的操作。 」

这个与 Redis 的特点:「 快速、高效 」有着紧密的联系,因为回滚操作、以及像事务隔离级别那样的加锁解锁,是非常消耗性能的。所以,Redis 中执行事务的流程只需要以下简单的三个步骤:

1. MULTI:「 表示开启一个事务 」,执行此命令后,后面执行的所有对 Redis 数据类型的操作命令「都会被顺序地放入队列中」。当执行 EXEC 命令后,队列中的命令会被依次执行。

2. DISCARD:「 放弃执行队列中的命令 」,可以类比为 MySQL 的回滚操作,「 并且将当前的状态从事务状态改为非事务状态」。

3. EXEC:该命令表示要「 顺序执行队列中的命令 」,执行完之后并将结果显示在客户端,「 同时将当前状态从事务状态改为非事务状态 」。若是执行该命令之前,有 key 被执行 WATCH 命令并且又被其它客户端修改,那么就会放弃执行队列中的所有命令,并在客户端显示报错信息;如果没有被修改,那么会继续执行队列中的所有命令。

除了以上三个命令之外,我们还有 WATCH 和 UNWATCH,我们先来介绍上面三个。

开启事务

MULTI 命令表示开启一个事务,当返回 OK 的时候表示已经进入事务状态。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> 

该命令执行之后客户端会将「 当前的状态从非事务状态修改为事务状态 」,这一状态的切换是通过打开客户端 flags 属性中的 REDIS_MULTI 来完成的,该命令可以理解为 MySQL 中的 BEGIN TRANSACTION 语句。

注意:multi 命令不能嵌套使用,如果已经开启了事务的情况下,再执行 multi 命令,会提示如下错误:(error) ERR MULTI calls can not be nested,因为事务是不可重复的。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> 

当客户端是非事务状态时,使用 multi 命令,客户端会返回结果 OK,如果客户端已经是事务状态,再执行 multi 命令则会报出 multi 命令不能嵌套的错误,但不会终止客户端当然的事务状态,如下图所示:

不管是那种情况,最终都是处于事务开启的一个状态,因为在 MULTI 中执行 MULTI 虽然会报错,但是不会结束事务。

命令入队

客户端进入事务状态之后,执行的所有常规 Redis 操作命令(非触发事务执行、或放弃以及导致入队异常的命令)会依次入列,命令入列成功后会返回 QUEUED,这部分也是真正的业务逻辑的部分,代码如下所示:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested  # 不会终止事务,完全可以将第二个 MULTI 忽略掉
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379>

若是当前状态处于事务状态,那么 Redis 客户端的命令执行后就会进入到队列(FIFO)中,并且返回 QUEUED 字符串;否则的话,则会立即执行命令,并将结果返回给客户端。流程图如下:

我们说事务开启之后,命令会进入到队列中,而命令队列中有如下参数:「 要执行的命令 」、「 命令的参数 」、「 参数的个数 」。以我们上面的事务为例:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379>

那么对应的队列中的参数如下:

执行事务、放弃事务

当客户端执行 EXEC 命令的时候,队列里面的命令就会按照先进先出的顺序被执行;如果是 DISCARD,那么会放弃事务。

先来看看提交事务:

127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> set name hanser
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec  # 队列中的命令依次执行
1) OK
2) "hanser"
127.0.0.1:6379> 

当执行 EXEC 的时候,先执行 SET 命令、再执行 GET 命令,并且执行后的结果也会进入一个队列中保存,最后返回给客户端:

所以最后你会在客户端中看到 「 OK、hanser」这样的结果显示,这也是一个事务成功执行的过程。

至此一个事务就完整地执行完毕了,并且此时客户端也从事务状态更改为非事务状态。

另外在事务中命令在提交事务之后,如果成功执行,那么影响是全局的,我们再举个栗子:

127.0.0.1:6379> set name hanser  # 设置 name 为 hanser
OK
127.0.0.1:6379> get name  # 获取 name,显然没问题
"hanser"
127.0.0.1:6379> multi   # 开启事务
OK
127.0.0.1:6379> set name yousa  # 在事务中设置 name 为 yousa
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec  # 执行事务,get name 的结果为 yousa 显然没问题
1) OK
2) "yousa"
127.0.0.1:6379> get name  
"yousa"  # 但是我们说事务中的命令的影响是全局的,即便事务结束,里面执行的命令在外部也是生效的
127.0.0.1:6379> 

然后是放弃事务:

127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> multi 
OK
127.0.0.1:6379> set name yousa
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> discard  # 取消事务,里面的命令根本没有执行
OK
127.0.0.1:6379> get name  # 所以外部的 name 还是 hanser
"hanser"
127.0.0.1:6379> 

DISCARD 命令取消事务的时候,会将命令队列清空,并且将客户端的状态从事务状态修改为非事务状态。

事务错误&回滚

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

  • 1. 执行时才会出现的错误(简称:执行时错误);
  • 2. 入队时错误,不会终止整个事务;
  • 3. 入队时错误,会终止整个事务;

1. 执行时错误:

127.0.0.1:6379> set name hanser  # 设置 name
OK
127.0.0.1:6379> get name  # 获取 name
"hanser"
127.0.0.1:6379> multi   # 开启事务
OK
127.0.0.1:6379> incr name  # name 自增1,显然这是不合法的,因为 name 不是数字
QUEUED
127.0.0.1:6379> set name yousa  # 再次设置name
QUEUED
127.0.0.1:6379> exec  # 我们看到事务里面第一条命令执行失败,但是第二条执行成功了
1) (error) ERR value is not an integer or out of range
2) OK 
127.0.0.1:6379> get name  # 事务结束后,获取 name 发现被修改了
"yousa"
127.0.0.1:6379> 

从以上结果来看,即使事务队列中某个命令在执行期间出现了错误,事务也会继续执行,直到事务队列中所有命令都执行完成。

所以这样就会导致,正确的命令被执行,而错误的命令不会被执行。而这也反映了 Redis 的事务不能保证数据的一致性,因为执行的途中出现了错误,但有些语句还是被执行了。因此最终的结果只能是程序猿根据之前的命令自己一步一步地回滚,所以自己的烂摊子自己收拾。

 

2. 不会导致事务结束的入队时错误:

127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> multi  # 在入队时就已经出现了错误,但是事务依旧没有结束
(error) ERR MULTI calls can not be nested
127.0.0.1:6379> set name yousa  # 修改 name
QUEUED
127.0.0.1:6379> exec
1) OK
127.0.0.1:6379> get name  # name 被修改
"yousa"
127.0.0.1:6379>

可以看出,重复执行 multi 会导致入列错误,但不会终止事务,最终查询的结果表示事务执行成功了。除了重复执行 multi 命令,还有在事务状态下执行 watch 也是同样的效果,下文会详细讲解关于 watch 的内容。

 

3. 会导致事务结束的入队时错误:

127.0.0.1:6379> multi  # 开启一个事务
OK
127.0.0.1:6379> set name1 hanser  # 设置 name1
QUEUED
127.0.0.1:6379> dadsadsadsa  # 输入一条不存在的命令
(error) ERR unknown command `dadsadsadsa`, with args beginning with: 
127.0.0.1:6379> set name2 yousa  # 设置 name2
QUEUED
127.0.0.1:6379> exec  # 执行,提示我们由于前面的错误,导致整个事务被取消了
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name1  # name1 为 nil
(nil)
127.0.0.1:6379> get name2  # name2 为 nil,所以不管错误在事务的哪个地方,只要出现了,整个事务就完蛋了
(nil)
127.0.0.1:6379> 

所以我们看到错误主要可以分为两种:一种是事务执行时才会发现的错误;另一种是在入队的时候就能发现的错误。

  • 执行时出现的错误,不会影响事务队列中其它的命令;即使某条命令失败,但其它命令依旧可以正常执行。
  • 入队发现的错误,如果是 multi、watch 这种错误也不会终止事务,只是不会让它入队;但如果是命令不符合 Redis 的规则,那么这种错误就属于类似于编程语言的语法错误,直接编译时报出语法错误,没必要等到执行了,所以在 Redis 中的表现就是整个事务都废弃掉,里面的命令一条也不会执行。

从执行时错误的例子中我们可以看到,Redis 是不支持事务回滚的。而不支持事务回滚的原因,Redis 作者提出了两个理由:

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

监控

Redis 的监控会使用到锁机制,而锁分为悲观锁和乐观锁。

类似于 MySQL 里面的 "表锁" 和 "行锁"。"表锁" 就是为了保证数据的一致性,将整张表锁上,这样就只能一个人修改,好比进卫生间,进去之后就把大门锁上了,但这样的结果也可想而知,虽然数据的一致性、安全性好,但是并发性会极差,因为其他人进不去了。比如一张有 20 万条记录的表,但是你只修改第 520 行,而另一个哥们修改第 250 行,本来两者不冲突,但是你把整个表都锁了,那就意味这后面的老铁只能排队了,这样显然效率不高。于是就出现了 "行锁","行锁" 在 MySQL 中,就类似于表中有一个版本号的字段,假设有一条记录的版本号为 1,A 和 B 同时修改这条记录,那么一旦提交,就会改变那个版本号,假设变为 2。如果 A 先提交了,那么数据库中对应记录的版本号已经变了,但是 B 对应的版本号还是之前的,那么提交之后会立即报错,这样就知道这条记录被人修改了,需要重新获取对应版本号的记录。

悲观锁:

pessimistic lock,顾名思义,就是很悲观,每次拿数据的时候都会认为别人会修改,所以每次拿数据的时候都会上锁,这样别人想拿到这个数据就会 block 住,直到拿到锁。

乐观锁:

optimistic lock,顾名思义,就是很乐观,每次拿数据的时候都会认为别人不会修改,所以每次拿数据的时候都不会上锁。但是在更新数据的时候会判断一下在此期间别人有没有去更新这条数据,可以使用版本号等机制。乐观锁使用于多读的应用类型,这样可以提高吞吐量。乐观锁策略就是:提交版本必须大于记录的当前版本才能更新。

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

WATCH:表示监视指定的 key,「 该命令只能在 MULTI 命令之前执行 」,如果监视的 key 被其它客户端修改,那么 EXEC 将会放弃执行队列中的所有命令。

下面就来演示一下,首先 watch 是需要搭配 multi 事务来使用的。一般是先 watch key,然后开启事务对 key 操作。

127.0.0.1:6379> set money 100
OK
127.0.0.1:6379> watch money  # 监控
OK
127.0.0.1:6379> multi  # 开启事务
OK
127.0.0.1:6379> decrby money 20  # money 自减 20
QUEUED
127.0.0.1:6379> exec  # 执行
1) (integer) 80
127.0.0.1:6379> get money  # 获取
"80"
127.0.0.1:6379>

上面执行的结果显然没有问题,但是往下看。

127.0.0.1:6379> flushdb  # 清空 db
OK
127.0.0.1:6379> set money 100  # 设置 money 为 100
OK
127.0.0.1:6379> watch money  # 监控
OK
127.0.0.1:6379> set money 200  # 但是在开启事务之前将 money 修改了
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr money
QUEUED
127.0.0.1:6379> exec  # 此时执行会返回一个nil
(nil)
127.0.0.1:6379> get money  # money是我们开启事务之前修改的 200
"200"
127.0.0.1:6379> 
127.0.0.1:6379> 
127.0.0.1:6379>   
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> watch name  # 监控一个不存在的key也是可以的
OK
127.0.0.1:6379> set name hanser  # 开启事务之前设置
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name yousa
QUEUED
127.0.0.1:6379> exec  # 执行已经不会成功
(nil)
127.0.0.1:6379> get name  # name 依旧是之前的 hanser
"hanser"
127.0.0.1:6379>

因此我们可以得出一个结论,那就是一旦监视了 key,那么这个 key 如果想改变,则需要开启一个事务,在事务中修改,然后 exec 执行来改变这个 key。如果在事务没有执行之前,将 watch 监视的 key 修改了,那么不好意思,事务会失效。

那如果是先开启的事务,再在另一个终端中把 key 修改了,会怎么样呢?我们来试一下。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set money 100  # 设置 money 为 100
OK
127.0.0.1:6379> watch money  # 开启监控
OK
127.0.0.1:6379> multi  # 开启事务
OK
127.0.0.1:6379> set money 120  # 设置 money 为 120
QUEUED
127.0.0.1:6379> exec  # 但是在事务开启后、事务提交前,我在另一个终端将 money 设置成了 250
(nil)  # 看到此时结果依旧为nil
127.0.0.1:6379> get money  # 获取 money,是我们在另一个终端中设置的250。
"250"
127.0.0.1:6379>

正如 MySQL 的行锁一样,两个人都可以对同一条记录做修改,但是一个人先改好之后,另一个人提交就会失败,必须查找到对应的版本号,然后重新查找对应记录,修改才能提交。这在 Redis 中如何实现呢,答案很简单,如果开始事务之前被修改了,那么取消监视就好了。

UNWATCH:「 取消监视之前通过 WATCH 命令监视的 key 」,通过执行 EXEC、DISCARD 两个命令,之前监视的 key 也会被取消监视。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> set name hanser  # 设置 name
OK
127.0.0.1:6379> watch name  # 监控 name
OK
127.0.0.1:6379> set name yousa  # 再次设置 name
OK
127.0.0.1:6379> get name # 从结果来看,这个 name 对应的值已经被修改了。如果此时开启事务,那么事务必然无效。
"yousa"
127.0.0.1:6379> unwatch  # 因此先取消监视
OK
127.0.0.1:6379> watch name  # 然后重新监视
OK
127.0.0.1:6379> multi  # 开启事务
OK
127.0.0.1:6379> set name marblue  # 设置name
QUEUED
127.0.0.1:6379> exec  # 提交事务
1) OK
127.0.0.1:6379> get name  # 执行成功
"marblue"
127.0.0.1:6379> 

另外记住一点:一个 watch 对应一个事务,如果 watch 之后,执行了事务,那么对这个 key 的监视就算结束了。如果想继续监视,那么必须再次 watch key。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> watch name  # 监视 name
OK
127.0.0.1:6379> set name hanser  # 开始事务之前将其修改
OK
127.0.0.1:6379> multi  # 开启事务,显然此时如果设置 name 的话必然不会成功,因为 name 在被监视的时候就已经被修改
OK
127.0.0.1:6379> exec  # 直接提交事务
(nil)
127.0.0.1:6379> multi  # 再次开启事务
OK
127.0.0.1:6379> set name yousa  # 设置
QUEUED
127.0.0.1:6379> exec  # 提交
1) OK
127.0.0.1:6379> get name  # 发现执行成功
"yousa"
127.0.0.1:6379> 

所以原因就在于一个 watch 对应一个事务,watch 之后只要执行了事务,不管里面的命令是成功还是失败,这个 watch 就算是结束了。再次开启事务,设置的 key 就是不被监视的 key 了。

但如果在事务中使用了 watch,那么会报错:(error) ERR WATCH inside MULTI is not allowed,但事务不会终止。所以 watch 只可以在开启事务之前使用。

Python实现Redis中的事务和监控

下面看看如何使用Python实现Redis中的事务和监控

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

# 设置key
client.set("name", "古明地觉")

# 开启事务, Python操作Redis开始事务需要创建一个管道
pipe = client.pipeline()

# 监视key 
pipe.watch("name")
pipe.multi()  # 此时事务算是开启了
pipe.set("name", "古明地恋")
# 退出事务的话,使用pipe.exit()
pipe.execute()  # 执行事务

# 获取name
print(client.get("name"))  # 古明地恋

小结

最后总结一下Redis中关于事务的特性:

  • 单独的隔离操作:事务中所有的命令都会被序列化,按照顺序执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 没有隔离级别的状态:队列中的命令在没有提交之前(exec),都不会被实际地执行,因为开启事务之后、事务提交之前,任何指令都不会被实际地执行。也就不存在"事务内的查询要看到更新,事务外查询无法看到"这个让人头疼的问题。
  • 不保证原子性:我们之前演示过,如果是在运行时出错,那么后面的命令会继续执行,不会回滚。

正常情况下 Redis 事务分为三个阶段:开启事务、命令入队、执行事务。Redis 事务并不支持运行时错误的事务回滚,但在某些入队错误,如 dasdasda等命令本身错误 或者是 watch 监控项被修改时,提供整个事务回滚的功能(或者说直接就把事务给取消了)

posted @ 2020-07-14 13:32  古明地盆  阅读(1068)  评论(0编辑  收藏  举报