Redis - 事务

2021/10/02 Redis 共 3537 字,约 11 分钟
Bob.Zhu

事务提供了一种“将多个命令打包, 然后一次性、按顺序地执行”的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。

Redis 通过 MULTI 、 DISCARD 、 EXEC 和 WATCH 四个命令来实现事务功能, 首先讨论使用 MULTI 、 DISCARD 和 EXEC 三个命令实现的一般事务, 然后再来讨论带有 WATCH 的事务的实现。

事务流程

一个事务从开始到执行会经历以下三个阶段:

  1. 开始事务:MULTI
  2. 命令入队:一系列Redis基本指令
  3. 执行事务:EXEC

开启事务:MULTI

MULTI 这个命令唯一做的就是, 将客户端的 REDIS_MULTI 选项打开, 让客户端从非事务状态切换到事务状态。

Redis-事务-开启事务

命令入队

当客户端处于非事务状态下时,所有发送给服务器端的命令都会立即被服务器执行; 但是,当客户端进入事务状态之后,服务器在收到来自客户端的命令时,不会立即执行命令, 而是将这些命令全部放进一个事务队列里,然后返回 QUEUED ,表示命令已入队:

redis> MULTI
OK

redis> SET book-name "Mastering C++ in 21 days"
QUEUED

redis> GET book-name
QUEUED

redis> SADD tag "C++" "Programming" "Mastering Series"
QUEUED

redis> SMEMBERS tag
QUEUED

事务队列是一个数组, 每个数组项是都包含三个属性:

  • 要执行的命令(cmd)。
  • 命令的参数(argv)。
  • 参数的个数(argc)。

那么程序将为客户端创建以下事务队列:

数组索引cmdargvargc
0SET[“book-name”, “Mastering C++ in 21 days”]2
1GET[“book-name”]1
2SADD[“tag”, “C++”, “Programming”, “Mastering Series”]4
3SMEMBERS[“tag”]1

执行事务

前面说到, 当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里。 但其实并不是所有的命令都会被放进事务队列, 其中的例外就是 EXEC 、 DISCARD 、 MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 直接被服务器执行: Redis-事务-执行事务

如果客户端正处于事务状态, 那么当 EXEC 命令执行时, 服务器根据客户端所保存的事务队列, 以先进先出(FIFO)的方式执行事务队列中的命令: 最先入队的命令最先执行, 而最后入队的命令最后执行。

所有命令的执行结果集合到回复队列,再作为 EXEC 命令的结果返回给客户端。

带 WATCH 的事务

WATCH 命令用于在事务开始之前监视任意数量的键:当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了,那么整个事务不再执行,直接返回失败。

WATCH 命令的实现

在每个代表数据库的 redis.h/redisDb 结构类型中,都保存了一个 watched_keys 字典, 字典的键是这个数据库被监视的键,而字典的值则是一个链表,链表中保存了所有监视这个键的客户端。

比如说,以下字典就展示了一个 watched_keys 字典的例子: Redis-事务-WATCH数据结构

其中,键 key1 正在被 client2 、client5 和 client1 三个客户端监视, 其他一些键也分别被其他别的客户端监视着。 WATCH 命令的作用, 就是将当前客户端和要监视的键在 watched_keys 中进行关联。 举个例子, 如果当前客户端为 client10086 , 那么当客户端执行 WATCH key1 key2 时, 前面展示的 watched_keys 将被修改成这个样子:

Redis-事务-WATCH数据结构示意

通过 watched_keys 字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。

WATCH 的触发

在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如 FLUSHDB 、 SET 、 DEL 、 LPUSH 、 SADD 、 ZREM ,诸如此类), multi.c/touchWatchedKey 函数都会被调用 —— 它检查数据库的 watched_keys 字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 REDIS_DIRTY_CAS 选项打开:

Redis-事务-WATCH-CAS

当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:

  • 如果客户端的 REDIS_DIRTY_CAS 选项已经被打开,那么说明被客户端监视的键至少有一个已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。
  • 如果 REDIS_DIRTY_CAS 选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。

为什么需要事务

Reactor模型

Redis服务器是一个Reactor模型,即NIO+IO复用,通过IO复用获取有请求的对象, 然后执行对应操作并将结果返回给对应客户端。且除redis虽然是多线程程序, 但是其处理网络IO和执行客户端请求的只有一个线程,对于客户端而言是个单线程服务器。

while(!quit) {
	clients=epoll_wait();
	for(client c:clients) {
		read(client);
		handle(client);
		write(client);
	}
}

大致的处理逻辑应该如上所示,所以对于redis服务器本身而言是没有竞态的,将活跃的客户端一个一个取出, 将客户端中的请求一条一条执行,所有的处理都是one by one的。 但是,对于客户端而言是存在竞态的,一个redis服务器会有多个客户端进行连接,他们之间可能会出现竞态的。

举个例子:现在需要完成一个游戏的商城系统,商城中有一件物品A,现在有两名玩家B和C想要购买这个物品, 购买商品的大致流程是:

  1. 客户端向服务器发起询问,商品还在商城中吗?
  2. 服务器对询问进行回答,商品还在。
  3. 客户端对服务器发起操作,减少用户的钱包金额,然后将物品这个键从商城中移除并在用户背包添加这个物品。
  4. 服务器执行完指令返回OK,购买完成。

这个过程中就会出现竞态问题:

玩家B玩家Credis服务器 
发起询问发起询问 
等待答复等待答复顺序答复B和C,商品还在
得到答复,进行下一步操作,发起物品转移和扣款得到答复,进行下一步操作,发起物品转移和扣款等到请求
  顺序执行B和C的请求
完成完成完成

很容易发现,这个购物的过程有问题,一个商品A卖了两次,这显然是我们不愿意看到的。

解决方案

用事务和WATCH指令解决这个问题,WATCH指令其实就是CAS锁,也叫乐观锁。使用这个指令的客户端会关注指定键值对, 如果在关注期间有其他客户端修改了这个键值对,那么使用WATCH的客户端下一次的事务执行会失败 (无论事务是否和关注的键值对相关)。 使用WATCH和事务功能就很容易解决上面那个问题,先使用WATCH监视商城这个键值对, 然后将物品转移和扣款操作用MULTI和EXEC包裹成为一个事务,那么先买的B就会买到这个商品, 然后因为商城键值对发生变换,C的事务执行失败,无法购买到商品A。

pipeline 不能代替事务

学习事务的时候就在想为什么pipeline不能替代事务,都可以将命令打包执行, 而且还不需要MULTI和EXEC两个额外的指令。最核心的问题在于:pipeline无法保证原子性。

Redis-事务-WATCH-pipeline

pipeline将所有要执行的消息存在客户端的缓冲区,然后将所有的消息一并发送给服务器, 即写入连接服务器的套接字。 即客户端写入的消息只是写入了socket管理的内核缓冲区,内核会自动的根据当前网络状况 (滑动窗口,拥塞窗口)将这些数据发送给TCP对端,每次到底能发多少数据只有内核知道。

然后到了通信对端,redis服务器读的消息也是从内核缓冲区中读取的, 然而read操作不能每次将缓冲区中的数据都读出来(如果用的是Epoll的边沿触发的话可以), 能读多少只有内核知道。

所以,虽然看似客户端将所有的数据打包交给了socket,但是这个数据能不能依然以这个数据包的形式交给服务器, 这就不清楚了,所以pipeline是没办法保证原子性的。比如客户端对服务器发起了扣款和物品转移两个操作, 然而,客户端的内核有可能将这两个指令分成两个包发送给了服务器(只是示例,这么短的数据一半不会分成两个TCP包), 自然就没有办法保证原子性。

参考资料

文档信息

Search

    Table of Contents