Redis 事务

Redis 事务

MySQL 事务 :ACID、并发带来的问题、事务的隔离级别、事务的实现 在之前的MySQL系列博客中我已经讲过了一些事务的内容,但是Redis与传统的关系型数据库不同,因此下面我会在讲解Redis事务的同时与SQL数据库的事务进行比较。

为了能帮助大家更好的理解,首先给出Redis事务的所有接口,并结合案例来讲解其具体使用方法

命令 作用
MUTLI 标记一个事务块的开始。
EXEC 执行所有事务块内的命令
DISCARD 取消事务,放弃执行事务块内的所有命令
WATCH 监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断
UNWATCH 取消 WATCH 命令对所有 key 的监视

事务的实现

Redis的事务与传统的SQL事务不同,它的本质是一组命令的集合一个事务中的所有命令都会被序列化,在事务执行过程的中,会按照顺序执行

它的事务主要存在以下三个阶段

  1. 事务开始(multi)
  2. 命令入队
  3. 事务执行(exec)

下面就结合一个具体案例,来讲解一下它的实现原理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
127.0.0.1:6379> MULTI	# 开始事务
OK
127.0.0.1:6379> SET CITY1 "beijing"	# 插入三个城市
QUEUED
127.0.0.1:6379> SET CITY2 "shanghai"
QUEUED
127.0.0.1:6379> SET CITY3 "shenzhen"
QUEUED
127.0.0.1:6379> GET CITY2	# 获取城市的名字
QUEUED
127.0.0.1:6379> GET CITY3
QUEUED
127.0.0.1:6379> EXEC	# 执行事务
1) OK
2) OK
3) OK
4) "shanghai"
5) "shenzhen"

事务开始

当我们执行MULTI命令时即代表着事务的开启,此时会将客户端从非事务状态切换到事务状态,通过为客户端状态中的flags加上REDIS_MULTI标识实现

1
2
# 打开事务标识
client.flags |= REDIS_MULTI

命令入队

当我们切换至事务状态后,Redis服务器会根据我们命令来决定执行命令还是将命令放入队列中

  • 如果客户端发送的命令是事务相关即EXEC、DISCARD、WATCH、UNWATCH、MULTI等,服务器会立刻执行命令
  • 如果客户端发送的是上面以外的命令,这时候服务器就会将命令放入事务队列中,并向客户端返回QUEUED,告知客户端命令入队

如下图 在这里插入图片描述 在这里插入图片描述

事务队列

在客户端的事务状态中维护者一个事务队列,以及队列长度的计数器

1
2
3
4
5
6
7
typedef struct multiState
{
	multiCmd* commands;	//事务队列,FIFO
	
	int count;			//命令计数器
	
} multiState

事务队列其实就是multiCmd类型的数组,其中每一个multiCmd节点都包含着每条命令的具体信息,如指向具体实现的命令指针、命令的参数、命令的数量

1
2
3
4
5
6
7
8
9
typedef struct multiCmd
{
	robj** argv;	//参数
	
	int argc;		//参数的数量
	
	struct redisCommand* cmd;	//指向具体实现的命令指针
	
} multiCmd

假设此时客户端执行以下命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
redis> MULTI
OK

redis> SET "name" "Practical Common Lisp"
QUEUED

redis> GET "name"
QUEUED

redis> SET "author" "Peter Seibel"
QUEUED

redis> GET "author"
QUEUED

此时底层的事务队列如下 在这里插入图片描述

执行事务

当客户端向服务器发送EXEC命令时,服务器会立即遍历客户端的事务队列,按照FIFO(先进先出) 的顺序执行队列中的所有命令,执行完毕后将命令所得的结果全部返回给客户端 在这里插入图片描述

讲完了原理,下面就来讲讲Redis的ACID与传统SQL的有什么不同

ACID

原子性:原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。

在Redis中,单条命令能够保证原子性,但是事务并不能保证原子性。下面我分别以编译、运行两个阶段的异常举例,来验证这个结论

编译时异常

首先我们来验证编译时异常是否能够保证原子性,我们故意产生语法错误,来验证编译异常时事务是否能够执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
127.0.0.1:6379> MULTI	#开启事务
OK					
127.0.0.1:6379> SET CITY1 "beijing"	
QUEUED
127.0.0.1:6379> SET CITY2 "shanghai"
QUEUED
127.0.0.1:6379> SET CITY3	# 故意不给value,使其语法错误
(error) ERR wrong number of arguments for 'set' command	# 编译报错
127.0.0.1:6379> EXEC	# 执行事务
(error) EXECABORT Transaction discarded because of previous errors.	# 事务中存在错误命令,执行失败
127.0.0.1:6379> KEYS *	# 所有命令都没有执行
(empty array)

从上面可以看到,在编译时异常时Redis是能够保证原子性的。

运行时异常

接着来看看运行时异常,我们故意对一个字符串使用计数操作,看看报错后事务是否能够执行

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> MULTI	# 开启事务
OK
127.0.0.1:6379> SET key1 "HELLO" # 插入一个字符串
QUEUED
127.0.0.1:6379> INCRBY key1 10   # 对这个字符串+10,必定执行失败
QUEUED
127.0.0.1:6379> SET key2 "WORLD" # 其他命令
QUEUED
127.0.0.1:6379> GET key2
QUEUED
127.0.0.1:6379> EXEC	#执行事务
1) OK
2) (error) ERR value is not an integer or out of range	# 运行错误
3) OK
4) "WORLD"
127.0.0.1:6379> GET key1	# 其他命令执行成功
"HELLO"
127.0.0.1:6379> GET key2	# 其他命令执行成功
"WORLD"

从上面我们可以看到,即使事务中有一条命令在执行期间出现了错误,整个事务也会继续执行下去,并且之前执行的命令也不会有任何影响

总结一下两种情况

  • 编译时异常(代码有问题、命令有错):事务中所有的命令都不会被执行!
  • 运行时错误(命令存在语法性错误):其他命令可以正常执行的,错误命令抛出异常!

这也就是Redis事务与传统SQL数据库事务最大的区别,即Redis不支持事务回滚机制(rollback)

在官方文档中作者是这样描述的,不支持事务回滚的原因是因为这种复杂的功能和Redis追求的简单高效不相符。并且这种运行时错误通常由编程错误产生,通常只会出现在开发环境中,而并不会在生产环境中发生,就没有必要为Redis开发事务回滚功能

基于以上几点,我们得出结论,Redis的事务不能保证原子性

一致性:一致性即事务操作前与操作后的状态始终一致

隔离性:隔离性指的是即使数据库中有多个事务并发执行,各个事务之间也不会互相影响。

由于Redis使用单线程来执行事务以及事务队列中的命令,并且在执行事务的期间不会对事务进行终端,因此Redis的事务总是以串行的方式运行的,因此也不存在隔离级别这个概念

持久性:持久性指的是事务一旦提交,其结果就是永久性的。

从上面也可以看出,除了隔离性以及原子性以外,其余部分都与传统SQL数据库区别不大。

WATCH乐观锁

并发编程中常见的锁机制:乐观锁、悲观锁、CAS、自旋锁、互斥锁、读写锁 如果不了解乐观锁及其实现原理的小伙伴可以看看我的往期博客,在这里就不再重复,我就简单的说明一下

  • 悲观锁做事比较悲观,它始终认为共享资源在我们使用的时候会被其他线程修改,容易导致线程安全的问题,因此在访问共享数据之前就要先加锁,阻塞其他线程的访问
  • 乐观锁则于悲观锁相反,它则比较乐观。它始终认为多线程同时修改共享资源的概率较低,所以乐观锁会直接对共享资源进行修改,但是在更新修改结果之前它会验证这段时间有没有其他线程对资源进行修改,如果没有则提交更新,如果有的话则放弃本次操作。

WATCH命令就是一个乐观锁,当它会监视任意数量的key,当执行事务时,如果这些key中有任何一个被修改,服务器都会拒绝执行事务,并向客户端返回事务执行失败的空回复

下面就结合具体场景来演示一下

假设此时小明的账户中有1000元,小王的账户有500元,此时小明想转250元给小王 在这里插入图片描述

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
127.0.0.1:6379> SET xiaoming 1000
OK
127.0.0.1:6379> SET xiaowang 500
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECRBY xiaoming 250	# 转账
QUEUED
127.0.0.1:6379> INCRBY xiaowang 250
QUEUED

但是在转账的途中,正好小明花呗的自动还款时间到了,扣费900元,并先他一步提交

1
2
3
# 另外一个线程中,抢先扣费
127.0.0.1:6379> DECRBY xiaoming 900
(integer) 100

当我们再次执行事务的时候,按道理来说金额不够就应该转账失败,但是此时小明的余额却变成了负数,这就出现了问题,用户可以无限的进行套现,这也就是我们通常所说的事务并发执行的问题。

1
2
3
127.0.0.1:6379> EXEC
1) (integer) -150
2) (integer) 750

为了保证安全,通常我们会使用WATCH当作乐观锁操作,对key进行监控,当另一个线程修改被监控的key时,就会让事务失败。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
127.0.0.1:6379> WATCH xiaoming	# 监控小明的账户
OK
127.0.0.1:6379> MULTI	# 开启事务,转账
OK
127.0.0.1:6379> DECRBY xiaoming 250
QUEUED
127.0.0.1:6379> INCRBY xiaowang 250

# 另一个线程修改小明余额
127.0.0.1:6379> DECRBY xiaoming 900
(integer) 100

# 继续运行事务
127.0.0.1:6379> EXEC
(nil)	# key被修改,事务执行失败
127.0.0.1:6379> GET xiaoming	# 可以看到,事务并没有执行
"100"

通过这种方法,就能够确保我们并发执行事务的安全,当我们确认当前操作不会导致恶劣影响的时候,就可以通过UNWATCH取消监控,然后WATCH来获取修改后的新余额来继续监控、执行事务。

当你看到这里的时候,是不是感觉似曾相识?没错,这就是之前博客中我提到的CAS以及版本号机制,也是乐观锁的常见实现方法

在Redis中,服务器通过一个字典来标记所有正在监控key的客户端 在这里插入图片描述 当某一个key被修改时,就会将所有监控它的客户端的REDIS_DIRTY_CAS标识打开,来标记数据已经被修改,当客户端执行EXEC命令时如果发现标识被修改,则说明此时可能会存在安全问题,于是拒绝执行事务 在这里插入图片描述 总结一下就是,我们通过CAS机制判断REDIS_DIRTY_CAS是否被打开来决定事务的执行,并通过WATCH实现版本号机制以及服务器对客户端的统一管理

总结

  • Redis单条命令保证原子性,但是事务不能保证原子性
  • Redis事务中没有隔离级别的概念
  • Redis事务的本质就是一组命令的集合,命令通过事务队列以FIFO的方式顺序执行
  • WATCH命令即乐观锁,服务器通过字典将所有监控客户端与被监控key进行关联,并通过REDIS_DIRTY_CAS来判断key是否被修改,从而决定是否执行事务
Built with Hugo
主题 StackJimmy 设计