面试官问我Redis事务,还问我有哪些实现方式

421 阅读13分钟

「第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事务的实战应用

什么是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事务有哪些方式。

命令模式

命令模式是实现redis事务比较常见的方式,该方式的主要命令有:MULTI、EXEC、DISCARD、WATCH

MULTI

MULTI 命令用于开启一个事务,它总是返回 OK

MULTI 执行之后, 客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行, 而是被放到一个队列中,等待事务被触发。

EXEC

EXEC 命令负责触发并执行事务中的所有命令

  • 如果客户端在使用 MULTI 开启了一个事务之后,却因为断线而没有成功执行 EXEC ,那么事务中的所有命令都不会被执行。
  • 如果客户端成功在开启事务之后执行 EXEC ,那么事务中的所有命令都会被执行。

EXEC 命令返回的是一个数组, 数组中的每个元素都是执行事务中的命令所产生的回复。 回复元素的先后顺序和命令发送的先后顺序一致。

DISCARD

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

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命令的实现

在每个代表数据库的 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 选项没有被打开,那么说明所有监视键都安全,服务器正式执行事务。

了解完其工作原理后,我们发现该 WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)行为

上面讲到的是如何给我们需要的key加监控,那我们应该如何取消监控呢?

  • 实际上,当 EXEC 被调用时, 不管事务是否成功执行, 对所有键的监视都会被取消。
  • 另外, 当客户端断开连接时, 该客户端对键的监视也会被取消。
  • 使用无参数的 UNWATCH 命令可以手动取消对所有键的监视

2、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 LOADEVALSHA 经常配合使用。我们看个例子:

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事务方式的有关内容,如果你之前还没有了解到第二种脚本方式,赶紧给大叔点赞打call吧哈哈~

我们接着往下看。

Redis事务是否支持回滚

Redis的事务和传统的关系型数据库事务的最大区别在于,Redis不支持事务回滚机制(rollback)

也就是说:当在事务过程中发生错误时,Redis事务失败时并不进行回滚(roll back),而是继续执行余下的命令。官方给出的理由是这样子的:

  • 从实用性的角度来说,Redis失败的命令是由编程错误造成的(例如错误的语法,命令用在了错误类型的命令),而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
  • 保证Redis性能。因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速

看个例子:

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的值,并且使用一个命令去操作一个错误的数据类型,可以看到最终事务还是成功执行了,同时也会返回事务中发生错误的指令的出错原因

事务中发生错误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');
}

好了,今天的分享就到这里了,关注公众号大叔说码 获取更多干货,我们下期见~

参考:

1、 redis.io/topics/tran…

2、zhuanlan.zhihu.com/p/146865185

3、walkingsun.github.io/WindBlog/20…

4、blog.csdn.net/fangjian120…

5、redis.io/commands/ev…

6、techlog.cn/article/lis…