业务背景: 在分布式系统中, 当收到一次退款请求,进行退款操作的期间, 可能会接收到重复的退款请求,造成订单的重复退款,从而造成资产亏损; 所以我们一般都会在对一笔订单进行退款操作的同时,进行"锁订单"的操作,当收到请求的时候,先判断该订单是否被锁,然后进行操作; 下面介绍几种常用的分布式锁的实现方式以及这些方式可能存在的问题;
1. 基于传统数据库实现
使用传统的数据库实现分布式锁,只需要新建一个表, 设置字段: "主键唯一标识", "订单编号", "创建时间"三个字段,每次进行退款操作的时候, 插入一条数据, 操作完毕或者退款失败(包括各种异常失败)后,删除该条记录;
其实这样实现会出现很多弊端,比如:
(1) 删除该记录是出现异常,造成订单变成永久锁怎么办?
回答: 我们可以做一个定时的Task,根据锁的创建时间字段,定时的删除过期记录,释放掉锁;
(2) 数据库发生单点故障, 数据库挂掉, 此时退款业务就不能进行下去了?
回答: 我们可以新建多个数据库实例, 获取锁时实现写入多个数据库表,成功数达一半以上时候就算是获取订单锁成功;
上面解释了使用传统数据库实现分布式锁出现问题怎么解决, 或许使用传统型数据库实现分布式锁是我们最好理解的一种实现方式,但是伴随着各种不稳定的因素,导致实现起来会很复杂,要考虑的特殊情况会很多,造成这个方案变得越来越复杂; 比如: 如何保证多个数据库实例的稳定? 如果保证Task执行时间和锁的过期时间保持平衡?....
2.基于内存数据库实现
首先,基于缓存实现分布式锁, 性能上相对传统数据库会有所提升; 并且缺少了对表操作,具体到开发效率上也会有很大的提升; 我们拿常用市面上比较成熟的内存数据库Redis为例:
(1) 把订单号作为Key, 当前线程操作的唯一标识作为value, 使用SETNX命令, 返回值为0 , 则获取锁失败; 返回值为1, 则获取锁成功
SETNX [订单号] [当前时间戳]
说明: SETNX命令在赋值时判断该KEY是否存在,若存在:返回0; 不存在: 赋值并返回1
(2) 使用EXPIRE 对key设置过期时间,防止出现死锁
EXPIRE [订单号] [过期时间,单位:s]
(3) 订单操作完成, 释放锁之前: 拿之前设置的时间戳去执行" GET [订单号]" 获取值;
作比较: 若不相等: 说明锁已经被释放; 若相等: 执行下面命令释放锁
DEL [订单号]
存在的问题?
(1) 单点故障:
如果只有一台Redis, 挂掉之后, 相关分布式锁服务全部挂掉; 这时有人考虑使用“主从”尝试解决单点故障的问题, 但是因为"主从"模式在数据同步时,使用的是异步通信,不能保证数据的强一致, 可能会出现多个客户端同时获取到一个订单锁的情况,所以这个“主从”方案被PASS
针对Redis单点故障最优解决方案
Redlock是Redis的作者antirez给出的集群模式的Redis分布式锁,它基于N个(通常是5个)完全独立的Redis节点,用来解决Redis的单点故障问题
具体实现:
(1) 获取当前时间(毫秒数)。
(2) 按顺序依次向N个Redis节点执行获取锁的操作。这个获取操作跟前面基于单Redis节点的获取锁的过程相同,包含当前时间戳,设置KEY的过期时间。为了保证在某个Redis节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis节点获取锁失败以后,应该立即尝试下一个Redis节点。这里的失败,应该包含任何类型的失败,比如该Redis节点不可用,或者该Redis节点上的锁已经被其它客户端持有。
(3) 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数Redis节点(>= N/2+1)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。
(4) 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
(5) 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于N/2+1,或者整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应该立即向所有Redis节点发起释放锁的操作。
Redlock的不足:
同样,RedLock分布式锁方案的实现也存在不足;
比如我们介绍下面一个场景:
(1)客户端A请求锁,此时锁住了Redis节点1 2 3 ,4和5两台节点由于各种不确定的原因没有被锁住,但是此时已经符合(>= N/2+1)的条件,所以获取锁成功;
(2)此时节点3发生服务崩溃,“加锁”数据没有被持久化,丢失了;节点3重启后,导致客户端B锁住了节点3 4 5, 此时客户端A和客户端B同时获得了锁 ;
上述的场景中,客户端A和B同时获取到锁。
针对,RedLock中存在的不足,《掘金》社区作者Wang_Coder提出了优化的方案:延迟节点3的恢复时间,时间长度应大于等于一个锁的过期时间。的确,这个能在大大的降低不同客户端同时获取锁的概率。
前辈对RedLock的使用,给我们总结出来的优化方案已经相对比较完善,但是看似完美的优化方案,实际上还有很多漏洞,当数据量达到一定程序,这些漏洞会被一一的暴露出来。
本文参考:《掘金》社区作者Wang_Coder文章 和 Redis官方文档