https://juejin.cn/post/6891158857708797959
首先Redis事务在实际的场景应用上也占着比较重要的地位,例如在秒杀场景中,我们就可以利用Redis事务中的watch命令监听key,实现乐观锁,保证不会出现冲突,也防止商品超卖。
另外就是Redis事务也是面试过程中面试官着重照顾的基础知识对象,假设面试官问你实现Redis事务有哪些方式?事务发生错误时Redis是怎么处理的?Redis事务支持回滚吗等等这些问题,你是否能脱口而出回答上来呢?如果你对这方便的基础知识有所欠缺,那是不是就栽跟头了呢?
两种实现方式
redis命令 MULTI、EXEC、DISCARD、WATCH
multi开启事务
exec是执行
disccard是放弃事务返回
重点讲下watch
WATCH 命令用于在事务开始之前监视任意数量的键,当调用 EXEC 命令执行事务时, 如果任意一个被监视的键已经被其他客户端修改了, 那么整个事务不再执行, 直接返回失败。
看例子:
- 首先我们在一个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命令的原理 看 https://juejin.cn/post/6891158857708797959
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不支持事务回滚机制(rollback)。
也就是说:当在事务过程中发生错误时,Redis事务失败时并不进行回滚(roll back),而是继续执行余下的命令。官方给出的理由是这样子的:
从实用性的角度来说,Redis失败的命令是由编程错误造成的(例如错误的语法,命令用在了错误类型的命令),而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
保证Redis性能。因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速
事务中发生错误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 不会停止执行事务中的命令。
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');
}