写在前面的
在大型的系统程序中,分布式是绕不开的话题,无论成熟的上市公司,还是刚刚起步的创业公司,没有一家公司对外的服务业务,可以部署在一台服务器,启动一个程序就能满足日常需求的。多个程序共同提供服务一定会产生资源的竞争,也会在同一个时刻对一个数据进行修改,分布式锁正是为了解决分布式系统中资源的竞争问题。
Redis分布式锁
我将按照分布式问题的产生、解决和优化的顺序,分成六部分介绍Redis的分布式锁。下面跟随着我的介绍,理清思路,按照我的叙述顺序依次思考,坚持读到最后一定会有帮助的。正是因为有了这样的顺序的思考和问题的产生,Redis的分布式锁才会越来越强大,越来越完善。
一个业务背景
假设我们的系统是图书馆管理系统,部署在两个不同服务器上(也就是启动了两个相同服务),两个服务为服务A和服务B。现在图书馆为了鼓励更多的会员参与到读书中,搞了一次活动,会员可以登入到系统中使用自己的系统积分兑换礼品,活动规定7个积分可以兑换一个纪念书签。
此时一位同学Q登入到系统中,查询到自己目前积分为10,决定申请兑换纪念书签,但是在兑换过程中,因为网络的问题,同学Q连续点击了两次兑换按钮,并且这两次兑换请求恰好被分别发送到了服务A和服务B上。
最终正确的结果应该是,同学Q余下3个积分,并且成功兑换一个纪念书签。
竞争状态
在上面的业务场景中(假定纪念书签无限多),服务A和服务B都接受到了兑换纪念书签的请求,显然两个服务只能有一个成功完成兑换服务,而另外一个服务因为纪念书签已经被兑换,同学Q余下的3个积分不能继续兑换而返回兑换失败。
这里涉及到服务A和服务B是如何处理请求,并且为什么会产生竞争?如下图,服务A接受到请求正常处理,查询同学Q的当前积分为10,接下扣除7个积分兑换纪念书签,数据库中同学Q的信息更新成功。
而在没有分布式锁控制的情况下,问题出现在服务B上,图中红色的请求(服务B查询同学Q当前积分),如果发起在绿色圆点以下的时间区间,此时查询到的同学Q剩余3积分,那么服务B很正常会因为积分不够兑换,直接返回失败;但是如果红色的请求恰好发生在红色圆点与绿色圆点之间的时间区间上,此时服务A正在处理尚未结束,数据库中同学Q的积分仍旧是10,服务B读取Q的积分为10后,将继续正常执行兑换流程,之后服务A和服务B都将会兑换成功,并且同学Q的积分会被扣除14,最终积分为-4,并且成功兑换了两个纪念书签。
Redis分布式锁解决
问题的解决方式是在红色圆点与绿色圆点的时间区间上(查询同学Q积分的时间点到成功更新同学Q积分的时间点),应该只允许服务A执行,而服务B等待,直到绿色圆点的时间点(也就是服务A执行完成)到了,服务B再执行兑换逻辑。这时就需要分布式锁,当服务A和服务B接受到请求时,首先申请分布式锁,只有获得分布式锁的服务才能执行兑换逻辑,并且在执行兑换完成之后释放锁,另外一个服务等待锁释放之后获取锁,进入兑换逻辑。
Redis分布式锁原理是通过命令SETNX key value实现的。SETNX命令只在键 key不存在的情况下, 将键key的值设置为value;若键key已经存在,则SETNX命令不做任何动作。显然,服务A和服务B同时执行SETNX命令,只有一个服务可以设值成功,当服务兑换逻辑执行结束,再删除键key(释放锁),这样下一次服务执行SETNX命令时可以正常申请到锁。
Redis分布式锁改进
服务A获得了分布式锁,服务B等待锁。如果这时服务A因为某些原因无法释放分布式锁(无法执行删除键key的操作),那么服务B(包括将来恢复的服务A)将永远无法获得锁去执行兑换逻辑。
所以在执行SETNX命令时,需要对设置的键key加上一个过期时间,当到达设定的过期时间,服务A仍旧没有释放锁,此时Redis主动删除键key完成对锁的释放。这样即使服务A最终无法释放分布式锁,将来服务仍旧可以获得锁正常运行。设置过期时间在Redis中执行命令SET key value NX PX milliseconds,其中PX milliseconds代表将键的过期时间设置为milliseconds毫秒,NX代表只在键不存在时,才对键进行设置操作。
这里顺便介绍一下Redis是如何删除过期键的。Redis将过期时间存储在一个过期字典中,处理过期键的策略分为两种,积极的方式(an active way)和消极的方式(a passive way):
- 积极的方式:定时扫描过期字典,判断存储在过期字典中的键key是否已经过期,清理过期的键key。
- 消极的方式:平时对过期的键不进行处理,只有当Redis接受到访问请求,获取键key时候,去过期字典中检查键key是否已经过期,如果过期删除键key,未过期则正常返回。
两种策略都有利弊,积极的方式可以保证过期的键被尽快清除,节省内存,却对CPU不友好,占用了系统CPU时间,无疑会降低系统的吞吐量;消极的方式刚好相反,节约了CPU时间,却因为大量过期的键不能及时清除导致内存浪费。
Redis结合了积极和消极两种方式,最大限度利用两者的优点,规避缺陷。下面介绍一个Redis官网介绍的清理过期键的算法——A trivial probabilistic algorithm。算法的Redis官网介绍
- 算法执行每秒扫描10次;
- 在过期字典中随机测试20个键
- 删除20个键中过期的键
- 如果删除的过期键数量占测试的总键数百分率超过25%(20*25%=5,换言之如果过期键超过5个),则返回第2步再次执行。
Redis分布式锁继续改进
目前为止Redis的分布式锁已经出具规模了,服务A获得锁,即使无法释放,仍旧可以可以依赖过期时间的设置,由Redis自动清除。可是,如果Redis自动释放服务A持有的分布式锁之后,服务A又恢复了正常功能,那么当它尝试去释放锁的时候,发现锁已经不在了,当然这还不会影响到系统的正确性。但是如果在服务A尝试释放锁之前,服务B获得了锁呢?
如下时序图:
服务A首先获得锁,之后在绿色圆点的时间点被Redis自动释放,接着服务B获得锁,而服务A恢复运行后在蓝色圆点时间点释放了锁,注意此时服务A释放的是服务B所有的锁。那么从蓝色圆点的时间点起,服务B就处在没有分布式锁保护的状态下工作,如果另外的请求在服务B未执行完成之前到来,就会出现在第二部分我们说的的竞争状态,这样系统就可以产生错误。
由此看来,我们的Redis分布式锁仍旧需要继续改进,保证服务A只能删除自己创建的锁,而不可以删除服务B的锁。如何实现呢?回到最初的Redis命令SET key value NX PX milliseconds,在设置键key的时候,生成一个标识字符串token设置在value中,并记录下token,当释放锁的时候比较Redis中键key下的value是否与token一致,一致则表明是当前服务所有的锁,不一致则是其他服务产生的锁。
释放锁时,需要对Redis执行查询,再比较,最后删除的逻辑,这些操作执行要保证原子性,即要么全部执行,要么都不执行,并且在执行的过程中,其他线程不能打断也不能修改值。从Redis 2.6.0版本起,引入了Lua脚本对Redis的操作,Lua脚本具有原子性,那么上面的操作逻辑放在Lua脚本中实现就在合适不过了。Lua释放分布式锁脚本(Redis官网分布式锁),如下图:
Redis分布式锁拓展
上面介绍Redis分布式锁,都是从问题出发,探讨如何设计和实现分布式锁,介绍原理以及解决方法。现在从Redis服务器角度出发,假如Redis服务器突然宕机,不再能提供分布式锁服务,又应该如何保证服务正确的前提下恢复呢?
- Redis将数据存储于内存中,一旦宕机内存中数据消失,为了重启时可以恢复数据,Redis开发了一套自己的持久化方式,持久化设置是解决服务器恢复时数据正确的一种方式;
- 另外对于分布式锁,因为其具有过期时间,可以采用延时重启策略解决数据问题。就是服务器宕机之后,并不是马上重启,而是过一段时间,等待所有的分布式锁均过期后,再进行重启(如果提前重启,因为Redis中不再具有锁,那么服务可能在原来持有锁的服务尚未执行完成就获得了锁,如此就可能产生分布式数据错误)。这样所有在宕机之前具有锁的服务可以正常执行完成,而其他服务因为Redis没有恢复不能获取锁所以不会执行。