随着业务越来越复杂,应用服务都会朝着分布式、集群方向部署,而分布式CAP原则告诉我们,Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。很多场景中,需要使用分布式事务、分布式锁等技术来保证数据最终一致性。很多时候,我们需要保证某一方法同一时刻只能被一个线程执行,否则就会出现售票超卖、多次扣款、多次充值等严重生产BUG出现。
在单机(单进程)环境中,JAVA提供了很多并发相关API,像JVM层面的加锁(synchronized
和reentrantLock
),但在分布式环境中就无能为力了。
对于分布式锁,最好能够满足以下几点:
因此业界常用的解决方案通常是借助于一个第三方组件并利用它自身的排他性来达到多进程的互斥,如:
锁的实现主要基于redis的SETNX
命令:
SETNX key value
当且仅当 key 不存在时,将 key 的值设为 value;
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值:
设置成功,返回 1 。
设置失败,返回 0 。
1. 错误实现
网上有很多错误实现方法,都考虑的不够全面,像如下代码:
public String lock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) {
try {
// 定义 redis 对应key 的value值(uuid) 作用 释放锁 随机生成value,根据项目情况修改
String value = UUID.randomUUID().toString();
// 定义在获取锁之后的超时时间
int expireLock = (int) (timeOut / 1000);// 以秒为单位
// 定义在获取锁之前的超时时间
//使用循环机制 如果没有获取到锁,要在规定acquireTimeout时间 保证重复进行尝试获取锁
// 使用循环方式重试的获取锁
Long endTime = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < endTime) {
// 获取锁
// 使用setnx命令插入对应的redislockKey ,如果返回为1 成功获取锁
if (jedis.setnx(lockKey, value) == 1) {
// 设置对应key的有效期
jedis.expire(lockKey, expireLock);
return value;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
乍一看好像没什么问题,然而由于setnx
和expire
是两条Redis命令,不具有原子性。如果程序在执行完setnx()之后突然异常或崩溃,导致锁没有设置过期时间,那么就会发生死锁的情况。像部署微服务Jar包的某台机器挂掉了,程序无法调用解锁方法,所以设置过期时间是必须的。
public String lock(Jedis jedis, String lockKey, Long acquireTimeout, Long timeOut) {
try {
String value = UUID.randomUUID().toString();
Long endTime = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < endTime) {
// 获取锁
if ("OK".equals(jedis.set(lockKey, lockKey, "NX", "PX", timeOut))) {
return value;
}
}
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
// 释放redis锁
public void unLock(Jedis jedis, String lockKey, String value) {
try {
// 如果该锁的id 等于value,是同一把锁情况才可以删除
if (jedis.get(lockKey).equals(value)) {
jedis.del(lockKey);
}
} catch (Exception e){
e.printStackTrace();
}
}
这里也犯了上面加锁方法一样的错误,get
和del
是两个命令,不具有原子性。此时就可能会出现一种情况,线程A执行完get命令后,此时它的锁正好过期,但是还没来得及执行del命令。于此同时,线程B正好又抢到了锁,线程A执行del命令就会把线程B的锁给删掉,就可能会造成生产事故。而解锁的话,没有类似像加锁那样的命令既能加锁也能设置时间,那么解锁要如何保证原子性呢?有两种方法:一种是使用Lua脚本,一种是使用Redis事务,官方和生产都推荐使用Lua脚本。
public void unLock(Jedis jedis, String lockKey, String value) {
try {
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(value));
//1释放锁成功,0失败
if (1 == result) {
System.out.println(result+"释放锁成功");
}
} catch (Exception e){
e.printStackTrace();
}
}
那么为什么执行eval()方法可以确保原子性?源于Redis的特性,因为Redis是单线程,在eval命令执行Lua代码时,Lua代码将被当成一个命令去执行,并且直到eval命令执行完成,Redis才会执行其他命令。
2. 总结注意事项
上面的锁,其实还是有不少瑕疵的,像锁一般都是需要可重入的,上面的线程都是执行完锁就释放了,无法再次进入了,进去也是重新加锁了,对于一个锁的设计来说肯定不是很合理的。还有对于过期时间的设置来讲,当业务在某一时间段内执行的特别慢的话,业务没执行完锁就过期了,没有一种自动续期的机制,不够完美。所以,我们就要学习基于Redisson实现分布式锁。
Redisson是Redis官方推荐的Java版的Redis客户端,它提供的功能非常多,也非常强大,它的底层都给我们封装好了,我们只要使用即可。像Redisson的看门狗自动延期机制,有兴趣可以去了解一下。
相关Redisson文章: