Mysql的事务和锁相信大家都很熟悉,其实redis也是有的,只是因为redis的事务比较鸡肋很少被人谈起,至于为什么鸡肋下面我们就会见到。同时redis的分布式锁还是非常值得我们了解一下的。
什么是事务
和Mysql一样,redis中的事务也是一组用关键字作为边界的有序排列的命令,用来将命令进行打包执行。但是redis的事务并没有mysql事务那么多优秀的特性,例如原子性,隔离性等等。
事务基本操作
下面这个表格是和事务相关的一些操作命令
命令 | 目的 | 格式 |
---|---|---|
multi | 开启事务,等待执行 | multi |
exec | 执行事务,如果watch的key被修改停止执行 | exec |
discard | 清除事务,并且释放监控的key | discard |
watch | 将1到多个key进行监控,用于事务执行的条件 | watch key [key …] |
unwatch | 清楚所有key的监控,如果调用了exec或者discard就没有必要再手动跑unwatch | unwatch |
下面用实例来看看这几个命令是如何使用的,开两个redis客户端去连接同一个redis服务端,进行下面的操作。
首先在client1操作如下
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name xiaofu
QUEUED
127.0.0.1:6379> set age 18
QUEUED
127.0.0.1:6379>
可以看到,在multi之后执行的操作并不会返回OK,而是QUEUED(前提是命令敲对了),表示放进了一个队列。
这时候如果在client2操作的话是看不到新增加的key的
127.0.0.1:6379> get age
(nil)
除非在client1成功执行了事务
127.0.0.1:6379> exec
1) OK
2) OK
可以看到按顺序将队列中的命令执行并返回了结果。
这时候在client2上也能看到新加的key了
127.0.0.1:6379> get age
"18"
也可以在没有执行的时候就用discard
命令将队列清除并取消事务
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name xiaofu
QUEUED
127.0.0.1:6379> set age 18
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> get name
(nil)
所以可以得到redis服务端对命令的处理逻辑。来了一个普通命令,如果没有现存队列会直接执行,有现存队列则放进队列等待执行;来了一个特殊命令,multi
会创建队列,exec
会执行队列中的命令并删除队列,discard
则是直接删除队列。
现在问题来了,如果开启了事务,来了一个错误的命令会怎么处理呢?
错误处理
这里分两种情况来看。
语法错误
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name xiaofu
QUEUED
127.0.0.1:6379> seet age 18
(error) ERR unknown command `seet`, with args beginning with: `age`, `18`,
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
可以看到,如果是语法错误,redis会直接将整个事务抛弃,之前创建的队列也会直接被销毁。
逻辑错误
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name xiaofu
QUEUED
127.0.0.1:6379> lpush name test
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) "xiaofu"
127.0.0.1:6379> get name
"xiaofu"
可以看到,这里的name
虽然是string类型,逻辑上是不能用lpush去操作的,但是redis还是把lpush name test
放进了队列中,并没有报错。真正执行的时候该条语句会报错,而其余语句还是正常被执行了。这一点相信很多朋友和我一样很困惑,因为这意味着redis的事务没有原子性,所以也无法做回滚操作。也正是因为这一点,很多人觉得redis中的事务非常的鸡肋,索性就放弃使用了。关于这一点redis认为逻辑错误是程序员应该考虑的事情,而不是redis,所以redis舍弃了这一部分的特性。当然好处是提升了运行性能。
原子性既然这么鸡肋,那么隔离性呢?
锁
下面看看watch和unwatch的用法。
watch和事务是一一对应的,在事务之前用watch去监视一到多个key,如果在监控开始到事务执行这期间,所监视的key的值有变化,该事务会直接被抛弃,队列也会消失。
在client1创建监视和事务
127.0.0.1:6379> watch name
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set age 18
QUEUED
127.0.0.1:6379> get name
QUEUED
127.0.0.1:6379> get age
QUEUED
这里要注意两点,首先被监视的key不一定要存在,然后在watch命令和multi之间是可以有别的指令的。
这时候在client2上修改name的值
127.0.0.1:6379> set name xiaofu
OK
然后去client1执行事务
127.0.0.1:6379> exec
(nil)
127.0.0.1:6379> get name
"xiaofu"
127.0.0.1:6379> get age
(nil)
这时候发现exec
的返回结果为空,说明事务没有被执行,可以看到name
已经被修改,而age
并没有被赋值。这提供了一种方式保证一个事务在做操作的时候不会和别的事务或者命令冲突。
事务被成功执行以后对应的watch也会消失,而如果想手动取消watch可以用unwatch。但是注意不能在事务的过程中使用unwatch,因为这个unwatch会被放进队列,并不会起到取消watch的效果。
同时要注意的是,unwatch是对所有watch的keys取消,并不能指定特定的key。
分布式锁
实际场景中往往是多个客户端连接同一个redis服务端,redis是单线程,能够保证没有命令对同一个key同时进行操作。但是往往更需要关注的是不要让多个客户端的命令集合交叉执行。
而要完成这一个目标,就需要用到分布式场景下的锁了,也就是分布式锁。一个客户端执行操作的时候别的客户端就不能操作。
和多线程中的锁不一样的是,分布式场景下的不同服务属于多进程,并不能访问内存中的同一个变量来作为锁标记。于是只能找一个每个进程都能看得到的地方做一个锁标记,在这里我们可以用一个key来完成。
更多分布式锁和单进程多线程的锁的区别可以参考 https://zhuanlan.zhihu.com/p/42056183
127.0.0.1:6379> setnx lock 1
(integer) 1
每个客户端要操作前,首先执行setnx
去创建一个key作为锁,如果锁不存在则成功创建返回1,就可以继续执行;如果锁已经存在就返回0,不能执行,如下。
127.0.0.1:6379> setnx lock 1
(integer) 0
当命令执行完毕以后删除key来释放锁
127.0.0.1:6379> del lock
(integer) 1
死锁
问题又来了,假设加了锁以后,某个进程一直在死循环或者操作完毕忘记解锁,就会照成所有进程无法使用锁的情况,也就是死锁。
为了解决这个问题,可以引入带过期时间的key作为锁。
127.0.0.1:6379> setnx lock 1
(integer) 1
127.0.0.1:6379> pexpire lock 10
(integer) 1
在设置了key以后马上创建过期时间,因为redis操作很快,所以通常使用pexpire
用毫秒做单位。例如上面的例子就是10ms过期,需要确保本次操作在10ms内完成,不然锁就会被别的进程抢占了。
值得一提的是,这种分布式锁是一种典型的防君子不防小人的做法,因为别的进程完全可以不检测规定的key,或者用别的key作为锁。所以只有所有的进程都遵循同样的规则,分布式锁才有存在的意义。
我是T型人小付,一位坚持终身学习的互联网从业者。喜欢我的博客欢迎在csdn上关注我,如果有问题欢迎在底下的评论区交流,谢谢。