事务提供了一种“将多个命令打包, 然后一次性、按顺序地执行”的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。
Redis 通过 MULTI 、 DISCARD 、 EXEC 和 WATCH 四个命令来实现事务功能, 首先讨论使用 MULTI 、 DISCARD 和 EXEC 三个命令实现的一般事务, 然后再来讨论带有 WATCH 的事务的实现。
事务流程
一个事务从开始到执行会经历以下三个阶段:
- 开始事务:
MULTI
- 命令入队:一系列Redis基本指令
- 执行事务:
EXEC
开启事务:MULTI
MULTI
这个命令唯一做的就是, 将客户端的 REDIS_MULTI 选项打开, 让客户端从非事务状态切换到事务状态。
命令入队
当客户端处于非事务状态下时,所有发送给服务器端的命令都会立即被服务器执行; 但是,当客户端进入事务状态之后,服务器在收到来自客户端的命令时,不会立即执行命令, 而是将这些命令全部放进一个事务队列里,然后返回 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)。
那么程序将为客户端创建以下事务队列:
数组索引 | cmd | argv | argc |
---|---|---|---|
0 | SET | [“book-name”, “Mastering C++ in 21 days”] | 2 |
1 | GET | [“book-name”] | 1 |
2 | SADD | [“tag”, “C++”, “Programming”, “Mastering Series”] | 4 |
3 | SMEMBERS | [“tag”] | 1 |
执行事务
前面说到, 当客户端进入事务状态之后, 客户端发送的命令就会被放进事务队列里。 但其实并不是所有的命令都会被放进事务队列, 其中的例外就是 EXEC 、 DISCARD 、 MULTI 和 WATCH 这四个命令 —— 当这四个命令从客户端发送到服务器时, 它们会像客户端处于非事务状态一样, 直接被服务器执行:
如果客户端正处于事务状态, 那么当 EXEC 命令执行时, 服务器根据客户端所保存的事务队列, 以先进先出(FIFO)的方式执行事务队列中的命令: 最先入队的命令最先执行, 而最后入队的命令最后执行。
所有命令的执行结果集合到回复队列,再作为 EXEC 命令的结果返回给客户端。
带 WATCH 的事务
WATCH 命令用于在事务开始之前监视任意数量的键:当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了,那么整个事务不再执行,直接返回失败。
WATCH 命令的实现
在每个代表数据库的 redis.h/redisDb 结构类型中,都保存了一个 watched_keys 字典, 字典的键是这个数据库被监视的键,而字典的值则是一个链表,链表中保存了所有监视这个键的客户端。
比如说,以下字典就展示了一个 watched_keys 字典的例子:
其中,键 key1 正在被 client2 、client5 和 client1 三个客户端监视, 其他一些键也分别被其他别的客户端监视着。 WATCH
命令的作用, 就是将当前客户端和要监视的键在 watched_keys 中进行关联。 举个例子, 如果当前客户端为 client10086 , 那么当客户端执行 WATCH key1 key2 时, 前面展示的 watched_keys 将被修改成这个样子:
通过 watched_keys 字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。
WATCH 的触发
在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如 FLUSHDB 、 SET 、 DEL 、 LPUSH 、 SADD 、 ZREM ,诸如此类), multi.c/touchWatchedKey 函数都会被调用 —— 它检查数据库的 watched_keys 字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的 REDIS_DIRTY_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想要购买这个物品, 购买商品的大致流程是:
- 客户端向服务器发起询问,商品还在商城中吗?
- 服务器对询问进行回答,商品还在。
- 客户端对服务器发起操作,减少用户的钱包金额,然后将物品这个键从商城中移除并在用户背包添加这个物品。
- 服务器执行完指令返回OK,购买完成。
这个过程中就会出现竞态问题:
玩家B | 玩家C | redis服务器 | |
---|---|---|---|
① | 发起询问 | 发起询问 | |
② | 等待答复 | 等待答复 | 顺序答复B和C,商品还在 |
③ | 得到答复,进行下一步操作,发起物品转移和扣款 | 得到答复,进行下一步操作,发起物品转移和扣款 | 等到请求 |
④ | 顺序执行B和C的请求 | ||
⑤ | 完成 | 完成 | 完成 |
很容易发现,这个购物的过程有问题,一个商品A卖了两次,这显然是我们不愿意看到的。
解决方案
用事务和WATCH指令解决这个问题,WATCH指令其实就是CAS锁,也叫乐观锁。使用这个指令的客户端会关注指定键值对, 如果在关注期间有其他客户端修改了这个键值对,那么使用WATCH的客户端下一次的事务执行会失败 (无论事务是否和关注的键值对相关)。 使用WATCH和事务功能就很容易解决上面那个问题,先使用WATCH监视商城这个键值对, 然后将物品转移和扣款操作用MULTI和EXEC包裹成为一个事务,那么先买的B就会买到这个商品, 然后因为商城键值对发生变换,C的事务执行失败,无法购买到商品A。
pipeline 不能代替事务
学习事务的时候就在想为什么pipeline不能替代事务,都可以将命令打包执行, 而且还不需要MULTI和EXEC两个额外的指令。最核心的问题在于:pipeline无法保证原子性。
pipeline将所有要执行的消息存在客户端的缓冲区,然后将所有的消息一并发送给服务器, 即写入连接服务器的套接字。 即客户端写入的消息只是写入了socket管理的内核缓冲区,内核会自动的根据当前网络状况 (滑动窗口,拥塞窗口)将这些数据发送给TCP对端,每次到底能发多少数据只有内核知道。
然后到了通信对端,redis服务器读的消息也是从内核缓冲区中读取的, 然而read操作不能每次将缓冲区中的数据都读出来(如果用的是Epoll的边沿触发的话可以), 能读多少只有内核知道。
所以,虽然看似客户端将所有的数据打包交给了socket,但是这个数据能不能依然以这个数据包的形式交给服务器, 这就不清楚了,所以pipeline是没办法保证原子性的。比如客户端对服务器发起了扣款和物品转移两个操作, 然而,客户端的内核有可能将这两个指令分成两个包发送给了服务器(只是示例,这么短的数据一半不会分成两个TCP包), 自然就没有办法保证原子性。
参考资料
文档信息
- 本文作者:Bob.Zhu
- 本文链接:https://adolphor.github.io/2021/10/02/02-redis-transaction/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)