redis-事务(MULTI、EXEC、DISCARD、WATCH与lua脚本、包含lua脚本的简单介绍、乐观锁抢购案例的实现)
https://juejin.cn/post/6891158857708797959
首先Redis事务在实际的场景应用上也占着比较重要的地位,例如在秒杀场景中,我们就可以利用Redis事务中的watch命令监听key,实现乐观锁,保证不会出现冲突,也防止商品超卖。
另外就是Redis事务也是面试过程中面试官着重照顾的基础知识对象,假设面试官问你实现Redis事务有哪些方式?事务发生错误时Redis是怎么处理的?Redis事务支持回滚吗等等这些问题,你是否能脱口而出回答上来呢?如果你对这方便的基础知识有所欠缺,那是不是就栽跟头了呢?
两种实现方式
redis命令 MULTI、EXEC、DISCARD、WATCH
multi开启事务
exec是执行
disccard是放弃事务返回
Lua脚本
除了上面介绍的命令模式可以实现Redis事务外,其实还有一种非常重要的方式:Lua脚本。
为什么要夸Lua脚本呢?我们来看看Lua脚本有什么优势:
原子操作:Redis确保脚本执行期间,其它任何脚本或者命令都无法执行。也就是说,在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。
减少网络开销:可以将多个请求通过脚本的形式一次发送,减少网络时延。因此使用脚本要更简单,速度更快
复用。客户端发送的脚本会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。
香吗?真香!反正用过的都说好。可以看到相比命令模式还是优势还蛮大的。
那么Lua脚本要怎么用呢?下面跟大家介绍几个常见的常用的命令:
EVAL
EVAL 可以理解为是lua脚本的解释器,它的语法格式如下:
EVAL script numkeys key [key ...] arg [arg ...]
- script:一段 Lua 脚本或 Lua 脚本文件所在路径及文件名。
- numkeys:Lua 脚本对应参数数量
- key [key …]:Lua 中通过全局变量 KEYS 数组存储的传入参数
- arg [arg …]:Lua 中通过全局变量 ARGV 数组存储的传入附加参数
官方腔有点重对吧,没事,咱们来看个例子:
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"
redis.call() / redis.call()
如果我们想在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
SCRIPT LOAD:提前载入 Lua 脚本,返回对应脚本的 SHA1 摘要
EVALSHA:执行脚本,与EVAL相似,只不过它的参数为脚本的 SHA1 摘要
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])<tonumber(ARGV[3]) then return -1 else return 1 end else return 1 end" 1 'test_127.0.0.1' 1451460590 5 1800
Lua脚本 对于实现Redis事务确实是一种不错的选择,相信未来会有越来越多的开发者倾向于使用脚本来实现事务。不过我们在使用的时候也要注意以下两点:
- 注意Redis版本。脚本功能是 Redis 2.6 才引入的。
- 由于脚本执行的原子性,所以我们不要在脚本中执行过长开销的程序,否则会验证影响其它请求的执行。
Redis事务是否支持回滚
是否支持回滚以及redis事务的本质
不支持!
Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback)。
也就是说:当在事务过程中发生错误时,Redis事务失败时并不进行回滚(roll back),而是继续执行余下的命令。官方给出的理由是这样子的:
从实用性的角度来说,Redis失败的命令是由编程错误造成的(例如错误的语法,命令用在了错误类型的命令),而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
保证Redis性能。因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速
看到这里 我们可能觉得奇怪,redis不是有discard命令么?为什么说它不支持回滚呢?
区别在于,redis是内存型数据库,它没有mysql那种复杂的mvcc的设计,所以不支持回滚。
redis的事务和mysql事务的执行有很大区别,redis的事务是将命令串行加入一个缓存队列中,它此刻并没有真正执行,而是当你使用exec提交的时候,它才真正开始执行!只保证命令中途不被其他命令插入。
redis 127.0.0.1:6379> MULTI
OK
redis 127.0.0.1:6379> SET book-name "Mastering C++ in 21 days"
QUEUED
redis 127.0.0.1:6379> GET book-name
QUEUED
redis 127.0.0.1:6379> SADD tag "C++" "Programming" "Mastering Series"
QUEUED
redis 127.0.0.1:6379> SMEMBERS tag
QUEUED
redis 127.0.0.1:6379> EXEC
1) OK
2) "Mastering C++ in 21 days"
3) (integer) 3
4) 1) "Mastering Series"
2) "C++"
3) "Programming"
比如上面的例子 你用get获取 返回的是queued 意思是在队列中!
而discard是放弃这个缓存队列,命令并没有被执行!
如果中间的命令失败
- 组队过程中输入了错误的命令导致组队失败,整个命令队列被取消,一个都不执行
- 组队结束后使用exec其中有一条或多条命令执行失败,则报错的命令不执行,其他的正常执行
其中1是 比如你set key1 结果后面没有输入value 这种错误不运行阶段就能发现
2是,比如你对一个string类型的变量执行incr操作,这种只有运行的时候能够发现
事务中发生错误Redis如何表现
实际上,事务的错误我们可以总结两种情况:
一种是:事务在执行 EXEC 之前,入队的命令可能会出错。比如命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。
对于发生在 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 调用之后失败。比如事务中的命令可能处理了错误类型的键,例如将列表命令用在了字符串键上面
至于那些在 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 不会停止执行事务中的命令。
watch
重点讲下watch
Redis Watch 命令用于监视一个(或多个) key ,如果watch命令之后开启的第一个事务,在使用exec执行命令之前,这个(或这些) key 被其他命令所改动,那么事务直接取消执行,并且通过exec函数告知执行失败。
一旦exec,之前加的所有watch全部失效。
redis本身实现watch的底层方案是乐观锁。
watch的特性可以实现分布式锁,详见另一篇文章
WATCH 命令用于在事务开始之前监视任意数量的键,当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。
注意 这里不是执行到一半放弃执行,而是,当你调用exec的时候,redis就会去判断 watch的key被其他客户端修改过没有,由于redis的事务保证原子性,以及串行执行,所以可以保证 一旦被其他客户端修改了 则整个事务直接全部被放弃
watch仅能用于事务中
exec在编程语言中 会抛出一个异常
和zookeeper的watch有很大差别 一个是它只能用在事务中,另一个是必须被其他客户端修改,自身的变动不算 而zookeeper的 被自己修改 或者到期了 也算变动
看例子:
- 首先我们在一个Redis客户端一上使用 WATCH 命令监控两个key,分别为name和sex,然后开启事务,在事务中修改name的值,
- 在客户端一执行 EXEC 命令之前,我们另外开一个客户端二,在客户端二中我们修改sex的值为man
接着我们回到客户端一执行 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 被客户端二修改了,所以客户端一的事务不再执行
watch命令的原理

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

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

当客户端发送 EXEC 命令、触发事务执行时, 服务器会对客户端的状态进行检查:
如果客户端的 REDIS_DIRTY_CAS 选项已经被打开,那么说明被客户端监视的键至少有一个已经被修改了,事务的安全性已经被破坏。服务器会放弃执行这个事务,直接向客户端返回空回复,表示事务执行失败。
如果 REDIS_DIRTY_CAS 选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。
上面讲到的是如何给我们需要的key加监控,那我们应该如何取消监控呢?
- 实际上,当 EXEC 被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。
- 另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。
- 使用无参数的 UNWATCH 命令可以手动取消对所有键的监视
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');
}
更多推荐


所有评论(0)