Redis事务、Lua脚本和分布式锁
Redis事务性
MULTI, EXEC, DISCARD命令
事务性提供一种将多个命令一起一次性顺序提交执行的机制,并且在执行期间不会被中断,直到所有命令被执行完成。
事务以MULTI命令为开始,接着将多个命令放入事务队列中,最后由EXEC命令提交服务器执行,如果DISCARD命令代替EXEC命令则清空事务队列中所有的命令,并且退出事务。
事务队列按照先进先出(FIFO)的方法保存入队的命令。
如果在事务中存在错误,Redis如何应对?首先,用户在客户端输入命令时,如果输入的是一个错误的命令,Redis客户端对这种错误是敏感的,会马上提示输入的命令错误。在最后执行EXEC命令提交时,运行的结果以版本Redis 2.6.5为分界,从Redis 2.6.5版本开始,Redis服务端会记录之前的这个错误,然后因为这个错误导致这个事务被丢弃;在Redis 2.6.5版本之前,Redis服务端会忽略这个错误,执行除了这个错误命令以外的所有正确入队的命令。最终,如果执行EXEC命令之前没有发生错误,那么入队的所有命令都会被执行,即使其中有一些命令在执行过程中出错。换言之,有一种可能根据Redis处理错误的方式,可能导致事务队列中一部分命令被执行,一部分命令没有被执行成功。举个例子,A需支付给B现金10元,A使用字符串对象存储现有的资金50元,B使用列表对象存储现有的资金60元,在事务中需要A资金先减去10元,B的资金再加上10元,交易结束,事务完成。执行事务的命令如下。
multi
OK
DECRBY A 10
QUEUED
INCRBY B 10
QUEUED
exec
1) 40
2) WRONGTYPE Operation against a key holding the wrong kind of value
错误地对B的列表对象执行了加法操作,此时结果只会看见A少了10元钱为40元,B的钱数未变仍旧是60元。因此这点在实际中要特别注意,并且Redis并不支持回滚操作。
WATCH, UNWATCH命令
WATCH命令是一个乐观锁(optimistic lock),可以在EXEC命令之前监视Redis中的任意数量的键,并且在执行EXEC命令时,执行先检查再设置(check-and-set)的操作,去检查监视的键值是否被修改,如果被修改,服务器将拒绝执行事务,只有未被修改时,才执行事务。UNWATCH命令解除对Redis中键的监视。
Redis数据库结构中定义着一个变量名为watched_keys的字典结构,保存被WATCH的所有键。watched_keys字典结构的键就是被WATCH的所有键,字典结构的值是一个链表记录所有WATCH相应键的客户端。
typedef struct redisDb {
dict *watched_keys;
} redisDb;
乐观锁(optimistic lock)假定并发的现象并不会经常发生,不对数据进行任何加锁控制,而在提交执行时一旦发现数据被其他线程修改,再执行相应的处理失败的策略。认为系统中偶尔执行处理失败策略的成本会低于读取数据时对数据加锁的成本。Redis的WATCH命令认为在执行事务时,一般不会产生数据的冲突,如果在提交执行时发现监视的数据被修改则拒绝事务的执行。
Redis为什么不支持回滚操作
Redis官方给出的解释是,支持回滚操作与Redis追求简洁和高效的宗旨不符合;并且事务执行的错误都是由错误的语法和键所具有错误的数据对象所导致的,这些错误都是程序编写的错误,在开发中容易被检查到并且修改的,不会出现在生产环境中。
正如我们之前的举例,A支付给B现金10元,错误出现在对B使用的列表对象执行加法操作,在开发过程中很容易发现和定位这个错误,再者记录现金的数据结构,尤其是在A用字符串对象的前提下,B使用列表对象本身就不符合常理,这显然是编程的错误。
Lua脚本
从版本2.6.0起,Redis引入了Lua脚本。执行Lua脚本具有原子性(Atomicity)。多个操作具有原子性是指要么所有的操作都执行,要么一个操作也不执行。在Redis事务性处理中,Lua脚本具有的原子性可以使得同时执行多个操作的原子性逻辑开发变得简单。因为只需要把所有的操作定义在一个Lua脚本中即可。
Redis分布式锁
Redis分布式锁原理是通过命令SETNX key value实现的。SETNX命令只在键 key不存在的情况下,将键key的值设置为value;若键key已经存在,则SETNX命令不做任何动作。当多个服务同时执行SETNX命令,只有一个服务可以设值成功,这个成功的服务就会获得分布式锁。在服务执行结束后在将key删除,其他服务重新执行SETNX命令竞争分布式锁。
存在第一个问题,如果获得分布式锁的服务因为某些原因无法释放分布式锁(无法执行删除键key的操作),那么其他服务将永远无法获得锁。因此执行SETNX命令时需要对设置的键key加上一个过期时间,当到达设定的过期时间如果仍旧没有被持有锁的服务释放,此时Redis主动删除键key完成对锁的释放。设置过期时间的命令SET key value NX PX milliseconds,其中PX milliseconds代表将键的过期时间设置为milliseconds毫秒,NX代表只在键不存在时,才对键进行设置操作。
由此又引入了另外两个新问题,如果过期时间设置短了,Redis自动释放分布式锁时,持有锁的服务并没有执行完锁内的逻辑,而其他服务此时又获得分布式锁,这样就可能两个服务同时访问临界资源导致结果执行不正确。另外,之前持有锁的服务执行后会继续尝试释放锁,但释放的分布式锁可能是新持有锁的服务设置的,也就是释放了别人的锁,锁在Redis中是相同的key。
分布式锁过期时间短的问题解决起来相对麻烦,两种思路,从Redis客户端出发,持有锁的服务可以对自己加锁的过期时间进行延长;从Redis服务端看,可以对持有锁的客户端进行监控,根据实际情况延长过期时间,当然这就需要持有锁的服务要与Redis的服务器进行通信告知当前执行情况。还有一种简单的方式,直接将过期时间设置长一些,例如5秒,一般而言线程都可以正确执行并且释放锁,所以正常情况下不会出现分布式锁持有过长导致的低效问题。
解决释放其他的服务锁的问题,可以回到最初的Redis命令SET key value NX PX milliseconds,在设置键key的时候,生成一个标识字符串token设置在value中,并记录下token,当释放锁的时候比较Redis中键key下的value是否与token一致,一致则表明是当前服务所有的锁,不一致则是其他服务产生的锁。释放锁时,需要对Redis执行查询,再比较,最后删除的逻辑,这些操作执行要保证原子性,可以使用Lua脚本实现。例如下面脚本代码。
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
上面介绍Redis分布式锁,都是从问题出发,探讨如何设计和实现分布式锁,介绍原理以及解决方法。现在从Redis服务器角度出发,假如Redis服务器突然宕机,不能提供分布式锁服务,又应该如何保证服务正确的前提下恢复呢?对于分布式锁,因为其具有过期时间,可以采用延时重启策略解决数据问题。
延时重启策略就是服务器宕机之后,并不是马上重启,而是过一段时间,等待所有的分布式锁均过期后,再进行重启。如果提前重启,因为Redis中可能不再具有锁,那么新服务可能在原来持有锁的服务尚未执行完成就获得了锁。所以延时重启策略使得所有在宕机之前具有锁的服务可以正常执行完成,其他服务因为Redis没有恢复不能获取锁而不会执行。
参考
Redis官网事务性介绍
https://redis.io/topics/transactions
Lua Language
http://www.lua.org/docs.html
Redis Lua Script
https://redis.io/commands/eval
Redis官网分布式锁
https://redis.io/topics/distlock