浅谈Java中的分布式锁
作者:澄风
带入到场景讨论分布式锁的意义
上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象
例如:UserController控制器中的一个整形类型的成员变量),如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!
即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
如果我们业务中确实存在这个场景的话,我们就需要一种方法解决这个问题!
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。
在单机环境中,Java中提供了很多并发处理相关的API。
但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。
为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!
分布式锁的实现讨论
分布式锁一般有三种实现方式:
- 数据库乐观锁;
- 基于ZooKeeper的分布式锁;
- 基于Redis的分布式锁;
Redis实现分布式锁
基于Redis命令:
SET key value NX EX max-lock-time
这里补充下: 从2.6.12版本后, 就可以使用set来获取锁, Lua 脚本来释放锁。
setnx
是老黄历了,set命令nx,xx等参数, 是为了实现 setnx 的功能。
1.加锁
public class RedisTool { private static final String LOCK_SUCCESS = "OK"; private static final String SET_IF_NOT_EXIST = "NX"; private static final String SET_WITH_EXPIRE_TIME = "PX"; /** * 尝试获取分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @param expireTime 超期时间 * @return 是否获取成功 */ public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) { String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime); if (LOCK_SUCCESS.equals(result)) { return true; } return false; } } jedis.set(String key, String value, String nxxx, String expx, int time)
这个set()方法一共有五个形参:
- 第一个为key,我们使用key来当锁,因为key是唯一的。
- 第二个为value,我们传的是requestId,很多童鞋可能不明白,有key作为锁不就够了吗,为什么还要用到value?原因就是我们在上面讲到可靠性时,分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为requestId,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId可以使用UUID.randomUUID().toString()方法生成。
- 第三个为nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
- 第四个为expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
- 第五个为time,与第四个参数相呼应,代表key的过期时间。
总的来说,执行上面的set()方法就只会导致两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 已有锁存在,不做任何操作。
2.解锁
public class RedisTool { private static final Long RELEASE_SUCCESS = 1L; /** * 释放分布式锁 * @param jedis Redis客户端 * @param lockKey 锁 * @param requestId 请求标识 * @return 是否释放成功 */ public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String requestId) { String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; Object result = jedis.eval(script,Collections.singletonList(lockKey),Collections.singletonList(requestId)); if (RELEASE_SUCCESS.equals(result)) { return true; } return false; } }
那么这段Lua代码的功能是什么呢?其实很简单,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)。为什么要写这段lua脚本呢?
因为判断key存不存在和删除key的操作必须是源自的,否则就有可能发生我下一个请求本应该能获取到锁,结果因为还没有被删除而获取不到,因为redis是单线程更新缓存的,如果不适用lua脚本,获取和删除操作就不是一个连续的事务操作。仔细观察上面代码实现还有什么问题呢?
直接解答好了,一般情况下我们的key都会设置过期防止死锁,假如我们程序执行阶段占用时间过长,就会导致key过期了但是程序还没执行完。假如这个时候下一个请求进来就有可能获取到锁,这个时候执行这个方法的线程就不是安全的了。为了解决这个问题redission引入了redlock。
关于这个问题,目前常见的解决方法有两种:
1、守护线程“续命”:额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。Redisson 里面就实现了这个方案,使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间。
2、超时回滚:当我们解锁时发现锁已经被其他线程获取了,说明此时我们执行的操作已经是“不安全”的了,此时需要进行回滚,并返回失败。
同时,需要进行告警,人为介入验证数据的正确性,然后找出超时原因,是否需要对超时时间进行优化等等。
守护线程续命的方案有什么问题吗
Redisson 使用看门狗(守护线程)“续命”的方案在大多数场景下是挺不错的,也被广泛应用于生产环境,但是在极端情况下还是会存在问题。
问题例子如下: 1、线程1首先获取锁成功,将键值对写入 redis 的 master 节点 2、在 redis 将该键值对同步到 slave 节点之前,master 发生了故障 3、redis 触发故障转移,其中一个 slave 升级为新的 master 4、此时新的 master 并不包含线程1写入的键值对,因此线程2尝试获取锁也可以成功拿到锁 5、此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据
解决方法:上述问题的根本原因主要是由于 redis 异步复制带来的数据不一致问题导致的,因此解决的方向就是保证数据的一致。 当前比较主流的解法和思路有两种:
1)Redis 作者提出的 RedLock; 2)Zookeeper 实现的分布式锁。
接下来介绍下这两种方案。
RedLock
首先,该方案也是基于文章开头的那个方案(set加锁、lua脚本解锁)进行改良的,所以 antirez 只描述了差异的地方,大致方案如下。
假设我们有 N 个 Redis 主节点,例如 N = 5,这些节点是完全独立的,我们不使用复制或任何其他隐式协调系统,为了取到锁,客户端应该执行以下操作:
1、获取当前时间,以毫秒为单位。
2、依次尝试从5个实例,使用相同的 key 和随机值(例如UUID)获取锁。当向Redis 请求获取锁时,客户端应该设置一个超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在 5-50 毫秒之间。这样可以防止客户端在试图与一个宕机的 Redis 节点对话时长时间处于阻塞状态。如果一个实例不可用,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
3、客户端通过当前时间减去步骤1记录的时间来计算获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁使用的时间小于锁失效时间时,锁才算获取成功。
4、如果取到了锁,其有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
5、如果由于某些原因未能获得锁(无法在至少N/2+1个Redis实例获取锁、或获取锁的时间超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
可以看出,该方案为了解决数据不一致的问题,直接舍弃了异步复制,只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点,官方建议是 5。
该方案看着挺美好的,但是实际上我所了解到的在实际生产上应用的不多,主要有两个原因:
1)该方案的成本似乎有点高,需要使用5个实例;
2)该方案一样存在问题。
该方案主要存以下问题:
1)严重依赖系统时钟。如果线程1从3个实例获取到了锁,但是这3个实例中的某个实例的系统时间走的稍微快一点,则它持有的锁会提前过期被释放,当他释放后,此时又有3个实例是空闲的,则线程2也可以获取到锁,则可能出现两个线程同时持有锁了。
2)如果线程1从3个实例获取到了锁,但是万一其中有1台重启了,则此时又有3个实例是空闲的,则线程2也可以获取到锁,此时又出现两个线程同时持有锁了。
针对以上问题其实后续也有人给出一些相应的解法,但是整体上来看还是不够完美,所以目前实际应用得不是那么多。
数据库乐观锁和悲观锁
乐观锁
基本原理为:乐观锁一般通过 version 来实现,也就是在数据库表创建一个 version 字段,每次更新成功,则 version+1,读取数据时,我们将 version 字段一并读出,每次更新时将会对版本号进行比较,如果一致则执行此操作,否则更新失败!
乐观锁的简单场景描述: 订单服务有A、B两台服务器,使用Nginx轮询访问A、B。两台服务的数据库数据库都是D. 假如同时有两个用户U1和U2对一个商品下单,同时进入到下单方法需要扣减库存,那么如何保证同一时间只能有一个用户可以下单扣减库存成功呢?
提交订单的时候会带上当前的乐观锁版本号,在进入到下单的方法中的时候,比对传过来的版本号和数据库中的版本号是否一致,如果一致则调用扣减库存服务。否则购买失败,用户需要重新下单。如果购买成功的话需要库存-1的同时乐观锁的版本号也需要+1,这样同一时间只能有一个用户可以下单成功扣减库存。
数据库更新版本号的SQL必须这样写
update set NAME='XXX' ... , version = version +1 where version ='传参:期望值,就是你一开始查数据的时候查出的version ' and ...
缺点: 同时只能有一个用户下单成功,失败了之后用户就需要重新进入订单获取新的版本号。假如说是有1000个人同时下单,那么可能这个时间只有1个人能成功,其他人都会失败,失败之后需要自己去重试或者用户重新点击。
悲观锁
//SELECT * FROM xxxx WHERE id=31212221321123 FOR UPDATE; begin;/begin work;/start transaction; (三者选一就可以) //1.查询出商品信息 select goods_status from goods where id=1 forupdate; //2.根据商品信息生成订单 insert into orders (goods_id,goods_count) values (3,5); //3.修改商品status为2 update goods set status=2; //4.提交事务 commit; //commit work;
悲观锁是可以实现分布式锁的,借助上述场景,我们在提交订单的时候,先根据商品锁住商品对应的库存记录,在扣减完库存之后提交事务释放锁。这个时候其实其他请求是会被阻塞的,等到上一个用户购买成功之后释放锁,下个请求线程竞争就会拿到锁,进行商品库存的扣减。
xxljob就是使用悲观锁来做分布式锁,来控制任务的触发。
缺点: 1.多服务必须同一个数据库,加锁的记录必须在同一张表中,假如说有分表那就不能用了。
2.性能低。 3.注意扣减库存的SQL要在同一个数据库连接中。
Zookeeper
Zookeeper 的分布式锁实现方案如下: 1、创建一个锁目录 /locks,该节点为持久节点
2、想要获取锁的线程都在锁目录下创建一个临时顺序节点
3、获取锁目录下所有子节点,对子节点按节点自增序号从小到大排序
4、判断本节点是不是第一个子节点,如果是,则成功获取锁,开始执行业务逻辑操作;如果不是,则监听自己的上一个节点的删除事件
5、持有锁的线程释放锁,只需删除当前节点即可。
6、当自己监听的节点被删除时,监听事件触发,则回到第3步重新进行判断,直到获取到锁。
由于 Zookeeper 保证了数据的强一致性,因此不会存在之前 Redis 方案中的问题,整体上来看还是比较不错的。
Zookeeper 方案的主要问题在于性能不如 Redis 那么好,当申请锁和释放锁的频率较高时,会对集群造成压力,此时集群的稳定性可用性能可能又会遭受挑战。
总结
通过以上的实例可以得出以下结论: 通过数据库实现分布式锁是最不可靠的一种方式,对数据库依赖较大,性能较低,不利于处理高并发的场景。
通过 Redis 的 Redlock 和 ZooKeeper 来加锁,性能有了比较大的提升。
针对 Redlock,曾经有位大神对其实现的分布式锁提出了质疑,但是 Redis 官方却不认可其说法,所谓公说公有理婆说婆有理,对于分布式锁的解决方案,没有最好,只有最适合的,根据不同的项目采取不同方案才是最合理的。
下面是从各个方面进行三种实现方式的对比
从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper
从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库
从性能角度(从高到低)
缓存 > Zookeeper >= 数据库
从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
到此这篇关于浅谈Java中的分布式锁的文章就介绍到这了,更多相关Java分布式锁内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!