❝「第12期」 距离大叔的80期小目标还有68期,今天大叔要跟大家分享的内容是 —— Reids中的事务。同样,这也是redis中重要指数为四颗星的必备基础知识点。下面一起来了解一下吧。
❞
相信大家对Redis并不陌生了吧,对 Redis五种数据类型(String,Hash,List,Set, SortedSet) 的使用也应该是得心应手了。今天为什么要跟大家聊聊Redis的事务呢?
首先Redis事务在实际的场景应用上也占着比较重要的地位,例如在秒杀场景中,我们就可以利用Redis事务中的watch命令监听key,实现乐观锁,保证不会出现冲突,也防止商品超卖。
另外就是Redis事务也是面试过程中面试官着重照顾的基础知识对象,假设面试官问你实现Redis事务有哪些方式?事务发生错误时Redis是怎么处理的?Redis事务支持回滚吗等等这些问题,你是否能脱口而出回答上来呢?如果你对这方便的基础知识有所欠缺,那是不是就栽跟头了呢?
所以,这就是大叔想聊聊Redis事务的必要性所在。下面大叔将围绕以下几点与大家分享:
官方给出的定义是这样子的:
Redis事务可以一次执行多个命令, 并且带有以下两个重要的保证:
官方腔换成方言就是:
Redis事务提供了一种 “将多个命令打包, 然后一次性、按顺序地执行” 的机制, 并且事务在执行的期间不会主动中断 —— 服务器在执行完事务中的所有命令之后, 才会继续处理其他客户端的其他命令。
或者你也可以把Redis事务理解为一个队列,开启事务后,往后的提交的Redis命令都会依次入队,遇到触发当前事务指令时,队列中的指令会依次被取出并执行。
「值得注意的是」:
“事务中的命令要么全部被执行,要么全部都不执行” 这句话单纯想表达的是:“事务执行需要对应的触发条件(命令)”
下面看个例子先整体了解一下Redis事务:
127.0.0.1:6379> get name
"zhangsan"
127.0.0.1:6379> get sex
"female"
127.0.0.1:6379> MULTI # 开启事务
OK
127.0.0.1:6379> set name dashu
QUEUED # 命令入队
127.0.0.1:6379> set sex male
QUEUED # 命令入队
127.0.0.1:6379> EXEC # 触发当前事务
1) OK
2) OK
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> get sex
"male"
127.0.0.1:6379>
了解完Redis事务是什么回事后,接下来我们继续看看实现Redis事务有哪些方式。
命令模式是实现redis事务比较常见的方式,该方式的主要命令有:MULTI、EXEC、DISCARD、WATCH。
MULTI 命令用于开启一个事务,它总是返回 OK 。
MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行, 而是被放到一个队列中,等待事务被触发。
EXEC 命令负责触发并执行事务中的所有命令
EXEC 命令返回的是一个数组, 数组中的每个元素都是执行事务中的命令所产生的回复。 回复元素的先后顺序和命令发送的先后顺序一致。
DISCARD 命令可以理解为是搞破坏的。当 DISCARD 命令被执行时, 事务会被丢弃, 事务队列会被清空, 并且客户端会从事务状态中退出。
我们看个例子:
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set name saycode
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379>
我们可以看到虽然开启事务后我们重新设置了name的值,但是当我们执行DISCARD命令后,该事务被成功丢弃了,所以当我们再次获取name的值的时候,我们可以看到它的值并没有发生改变。
WATCH 命令用于在事务开始之前监视任意数量的键,当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。
看例子:
# 客户端一
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> get sex
"male"
127.0.0.1:6379> WATCH name sex
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set name saycode
QUEUED
127.0.0.1:6379> EXEC
(nil) # 事务失败
127.0.0.1:6379> get sex
"man"
127.0.0.1:6379> get name
"dashu"
#--------- 这是一条分割线 ---------#
# 客户端二
127.0.0.1:6379> get sex
"male"
127.0.0.1:6379> set sex man
OK
从上面执行的结果可以看到,客户端一中的事务失败了,事务中所修改的name的值也不成功。主要原因是:调用 EXEC 命令执行事务时,被监控的sex 被客户端二修改了,所以客户端一的事务不再执行
在每个代表数据库的 redis.h/redisDb 结构类型中, 都保存了一个 watched_keys 字典, 字典的键是这个数据库被监视的键, 而字典的值则是一个链表, 链表中保存了所有监视这个键的客户端。
比如说,以下字典就展示了一个 watched_keys 字典的例子:
其中, 键 key1 正在被 client2 、 client5 和 client1 三个客户端监视, 其他一些键也分别被其他别的客户端监视着。
WATCH 命令的作用, 就是将当前客户端和要监视的键在 watched_keys 中进行关联。
举个例子, 如果当前客户端为 client10086 , 那么当客户端执行 WATCH key1 key2 时, 前面展示的 watched_keys 将被修改成这个样子:
通过watched_keys字典, 如果程序想检查某个键是否被监视, 那么它只要检查字典中是否存在这个键即可; 如果程序要获取监视某个键的所有客户端, 那么只要取出键的值(一个链表), 然后对链表进行遍历即可。
在任何对数据库键空间(key space)进行修改的命令成功执行之后 (比如FLUSHDB、SET、DEL、LPUSH、SADD、ZREM,诸如此类),multi.c/touchWatchedKey函数都会被调用 —— 它检查数据库的watched_keys字典, 看是否有客户端在监视已经被命令修改的键, 如果有的话, 程序将所有监视这个/这些被修改键的客户端的REDIS_DIRTY_CAS选项打开:
当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:
了解完其工作原理后,我们发现该 WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为。
上面讲到的是如何给我们需要的key加监控,那我们应该如何取消监控呢?
除了上面介绍的命令模式可以实现Redis事务外,其实还有一种非常重要的方式:Lua脚本。
为什么要夸Lua脚本呢?我们来看看Lua脚本有什么优势:
香吗?真香!反正用过的都说好。可以看到相比命令模式还是优势还蛮大的。
那么Lua脚本要怎么用呢?下面跟大家介绍几个常见的常用的命令:
EVAL 可以理解为是lua脚本的解释器,它的语法格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
官方腔有点重对吧,没事,咱们来看个例子:
eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
eval的第一个参数是脚本的内容,第二个参数是脚本里面KEYS数组的长度(不包括ARGV参数的个数),这里是两个;紧接着就会有两个参数,用于传递个KEYS数组;后面剩下的参数全部传递给ARGV数组,相当于命令行参数。
127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 username age jack 20
1) "username"
2) "age"
3) "jack"
4) "20"
如果我们想在lua脚本中调用redis的命令该如何操作?其实我们可以在脚本中使用 redis.call() 或 redis.pcall() 直接调用。两者用法类似,只是在遇到错误时,返回错误的提示方式不同。
举个例子:
127.0.0.1:6379> get name
"saycode"
127.0.0.1:6379> eval "return redis.call('set',KEYS[1],'dashu')" 1 name
OK
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> eval "return redis.call('get','name')" 0
"dashu"
127.0.0.1:6379>
SCRIPT LOAD 和 EVALSHA 经常配合使用。我们看个例子:
127.0.0.1:6379> SCRIPT LOAD "return redis.call('set',KEYS[1],'30')"
"6445747e70ce11ad0b9717d78e8ff16fb0faed46"
127.0.0.1:6379> evalsha 6445747e70ce11ad0b9717d78e8ff16fb0faed46 1 age
OK
127.0.0.1:6379> get age
"30"
127.0.0.1:6379>
更多命令可以参看Redis Script 官方文档
有了上面的知识,我们就可以使用lua脚本来灵活的使用redis的事务,这里举几个简单的例子:
场景1:使用redis限制30分钟内一个IP只允许访问5次
思路:每次想把当前的时间插入到redis的list中,然后判断list长度是否达到5次,如果大于5次,那么取出队首的元素,和当前时间进行判断,如果在30分钟之内,则返回-1,其它情况返回1。我们来看一下具体实现:
eval "redis.call('rpush', KEYS[1],ARGV[1]);if (redis.call('llen',KEYS[1]) >tonumber(ARGV[2])) then if tonumber(ARGV[1])-redis.call('lpop', KEYS[1]) 1 'test_127.0.0.1' 1451460590 5 1800
Lua脚本 对于实现Redis事务确实是一种不错的选择,相信未来会有越来越多的开发者倾向于使用脚本来实现事务。不过我们在使用的时候也要注意以下两点:
好了,以上就是实现Redis事务方式的有关内容,如果你之前还没有了解到第二种脚本方式,赶紧给大叔点赞打call吧哈哈~
我们接着往下看。
Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback)。
也就是说:当在事务过程中发生错误时,Redis事务失败时并不进行回滚(roll back),而是继续执行余下的命令。官方给出的理由是这样子的:
看个例子:
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set name saycode
QUEUED
127.0.0.1:6379> lpop name
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get name
"saycode"
127.0.0.1:6379>
上面例子中,我们在事务中重新设置name的值,并且使用一个命令去操作一个错误的数据类型,可以看到最终事务还是成功执行了,同时也会返回事务中发生错误的指令的出错原因
实际上,事务的错误我们可以总结两种情况:
对于发生在 EXEC 执行之前的错误,客户端的做法是检查命令入队所得的返回值:如果命令入队时返回 QUEUED ,那么入队成功;否则,就是入队失败。如果有命令在入队时失败,那么大部分客户端都会停止并取消这个事务。看例子:
127.0.0.1:6379> get name
"saycode"
127.0.0.1:6379> get sex
"man"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set name dashu
QUEUED
127.0.0.1:6379> sett sex woman
(error) ERR unknown command `sett`, with args beginning with: `sex`, `woman`,
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get name
"saycode"
127.0.0.1:6379> get sex
"man"
至于那些在 EXEC 命令执行之后所产生的错误, 并没有对它们进行特别处理: 即使事务中有某个/某些命令在执行时产生了错误, 事务中的其他命令仍然会继续执行。
127.0.0.1:6379> get name
"dashu"
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set name saycode
QUEUED
127.0.0.1:6379> lpop name
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get name
"saycode"
127.0.0.1:6379>
我们可以看到:即使事务中有某条/某些命令执行失败了, 事务队列中的其他命令仍然会继续执行 —— Redis 不会停止执行事务中的命令。
了解完Redis事务的基础,最后我们来写个Demo来实现乐观锁,业务场景是商品抢购,伪代码如下:
# 乐观锁
public function actionBuy(){
$userId = mt_rand(1,99999999);
$goods = $this->goods;
$redis = Yii::$app->redis;
$lock = "Huawei p40";
try {
$inventory['num'] = $redis->get('goodNums');
if($inventory['num']<=0){
throw new \Exception('活动结束');
}
$redis->watch($lock);
$redis->multi();
//todo:这里还需要重新判断下库存,否则会出现超发,高并发情况下$inventory['num']肯定会出现同时读取一个值;为了方便测试,没写db操作
//redis事务是将命令放入队列中,无法取goodNums来判断库存是否结束,此处使用数据库来判断库存合理
//业务处理 减库存,创建订单
$redis->decr('goodNums');
$redis->sadd('order',$userId);
$redis->exec();
Common::addLog('shop.log',$userId.' 抢购成功');
}catch (\Exception $e){
$redis->discard();
Common::addLog('shop.log',$e->getMessage());
throw new \Exception('抢购失败');
}
die('success');
}
好了,今天的分享就到这里了,关注公众号「大叔说码」 获取更多干货,我们下期见~
参考:
1、 https://redis.io/topics/transactions
2、https://zhuanlan.zhihu.com/p/146865185
3、https://walkingsun.github.io/WindBlog/2019/03/14/redis/
4、https://blog.csdn.net/fangjian1204/article/details/5058508
5、https://redis.io/commands/eval
6、https://techlog.cn/article/list/10183180