对于某个JVM进程,要保证某个操作的唯一执行,可以使用synchronized关键字或ReentrantLock在执行前加锁,对于多个JVM进程,要保证这个操作在多个进程中的唯一执行,那就需要依赖第三方系统,例如DB,for update nowait等,除此之外,还可以借助redis、zookeeper实现分布式锁。
目录
测试代码
实现一
实现二
实现三
呼哈哈
Redis锁实现思路
业务操作会有编号m,线程1往redis中set一个key是m的数据,表示m操作已经加锁,别的线程判断如果redis中已经有了key为m的数据,不再执行操作,线程1执行完毕删除此数据,即m操作释放锁。
50个线程同时抢锁
@SpringBootTest
@RunWith(SpringRunner.class)
public class TestRedisLock {
@Test
public void test_redislock() throws InterruptedException {
Thread[] threads = new Thread[50];
CountDownLatch cdl = new CountDownLatch(50);
String busiID = "lxyceshi";
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
DistributedLock redisLock = new RedisLock1(busiID);
if (!redisLock.tryLock()) { // 尝试加锁
System.out.println("线程" + Thread.currentThread().getId() + "获取锁失败!");
cdl.countDown();
return;
}
try {
System.out.println("线程" + Thread.currentThread().getId() + "成功获取到锁,开始执行任务!");
try {
// 业务操作睡一会
Thread.sleep(200);
} catch (InterruptedException e) {}
} finally {
redisLock.unlock(); // 解锁
}
cdl.countDown();
}
});
}
for (int i = 0; i < threads.length; i++) {
threads[i].start();
}
cdl.await();
}
}
很简单,判断不存在后set,解锁直接删除key
public class RedisLock1 implements DistributedLock{
// redis锁的前缀
private static String redisLockPrefix = "lock_";
// 单例的redis操作工具对象,只是把redisTemplete套了一层
private RedisUtil redisUtil = RedisUtil.getInstance();
// 业务号,加锁的对象
private String busiId;
public RedisLock1(String busiId) {
if (StringUtils.isEmpty(busiId)) {
throw new IllegalArgumentException("RedisLock1.new : busiId should not be empty");
}
this.busiId = busiId;
}
@Override
public void lock() throws AppException {
if (!tryLock()) {
throw new AppException("获取【" + busiId + "】锁失败,业务正在执行中,请稍后再试!");
}
}
@Override
public boolean tryLock() {
String lockKey = redisLockPrefix + busiId;// 锁key
// 是否已经被加锁
boolean exists = redisUtil.exists(lockKey);
if (!exists) {
// 不存在key表示可以加锁
redisUtil.set(lockKey, "");
}
return !exists;
}
@Override
public void unlock(){
String lockKey = redisLockPrefix + busiId;// 锁key
// 删除锁
redisUtil.del(lockKey);
}
}
测试运行情况
结果如下,惨不忍睹跟没写一样:
缺陷
1、严重!非原子性操作,多个线程同时判断exists为不存在,同时走到了if判断中进行set,错误的以为自己获取到了锁
2、严重!任何线程都可以解开不属于自己的锁,只要是调用了unlock()
3、不可重入,同线程再次调用lock()会报错
4、死锁,程序执行了lock,宕机没有执行unlock,进入死锁状态需要人为干预
对实现一的缺陷1改进,就需要使用原子性进行exists判断和set加锁,redis提供了一个操作setNX,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果,即redisTemplete.setIfAbsent
对实现一的缺陷2改进,也就是锁需要有所属标识,在删除的时候判断是否是自己的锁,是再执行删除,方案:使用当前线程id作为锁的value,删除时判断,但是redis并没有提供类似setNX的对应删除操作,这里的判断为了保证原子性,使用lua脚本实现
对实现一的缺陷3改进,锁需要有计数,加锁操作计数加一,解锁操作计数减一,计数为0时删除锁
,方案:锁使用hash结构,key为业务操作编号,hashkey为当前线程id,hashvalue为计数
对实现一的缺陷4改进,锁需要定义超时时间,超时则自动删除锁,这里引入了超时时间,也就引入了一个新问题,加入业务操作耗时较长,大于超时时间,锁此时超时删除是错误的,也就是需要增加锁延时机制,定时判断当前锁业务是否结束,未结束则需要将锁续期。
这里实现二提供一个对于缺陷1,2的改进版,这版对要求不高的场景已经算可以了。3,4的改进需要改数据结构,对value计数加减等,一个setIfAbsent实现不了,有较大改动,放到实现三。
public class RedisLock2 implements DistributedLock{
// redis锁的前缀
private static String redisLockPrefix = "lock_";
// 单例的redis操作对象
private RedisUtil redisUtil = RedisUtil.getInstance();
// 产生个uuid作为本应用jvm的标识
private static String jvmID = UUID.randomUUID().toString();
// 业务号,加锁的对象
private String busiId;
public RedisLock2(String busiId) {
if (StringUtils.isEmpty(busiId)) {
throw new IllegalArgumentException("RedisLock2.new : busiId should not be empty");
}
this.busiId = busiId;
}
@Override
public void lock() throws AppException {
if (!tryLock()) {
throw new AppException("获取【" + busiId + "】锁失败,业务正在执行中,请稍后再试!");
}
}
@Override
public boolean tryLock() {
String lockKey = redisLockPrefix + busiId;// 锁key
String lockValue = jvmID + Thread.currentThread().getId();// 锁value = static uuid + 线程id
// redisTemplete.setIfAbsent 就是 setNX
return redisUtil.setIfAbsent(lockKey, lockValue);
}
@Override
public void unlock(){
String lockKey = redisLockPrefix + busiId;// 锁key
String lockValue = jvmID + Thread.currentThread().getId();// 锁value = static uuid + 线程id
// 删除锁, 脚本中先get判断是否为当前线程加的锁,如果是del,否则返回
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript redisScript = new DefaultRedisScript();
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
redisUtil.execute(redisScript, Collections.singletonList(lockKey), new String[]{lockValue});
}
}
运行测试如下,解决!还剩下死锁和重入!
解决死锁和可重入需增加超时时间和更换为hash结构
加锁流程:
解锁流程:
锁续期:
当线程加锁成功后,开启定时任务(间隔需要比超时时间小),定时判断该线程是否还拥有此锁,即hashvalue为此线程的锁是否还存在,若存在表示线程还未执行完,还未释放锁,那么重置超时时间,如果不存在,表示执行完不需要续期,不再执行定时任务
以上操作均使用lua脚本保证原子性,延时任务的执行使用ScheduledThreadPoolExecutor 执行器定时执行,参考redssion实现
public class RedisLock implements DistributedLock{
// 默认30秒后自动释放锁
private static long defaultExpireTime = 30 * 1000;// 30秒
// redis锁的前缀
private static String redisLockPrefix = "lock_";
// 用于锁延时任务的执行
private static ScheduledThreadPoolExecutor renewExpirationExecutor;
// 加锁和解锁的lua脚本
private static String lockScript;
private static String unlockScript;
// 锁延时脚本
private static String renewScript;
static {
StringBuilder sb = new StringBuilder();
sb.setLength(0);
sb.append(" if (redis.call('exists', KEYS[1]) == 0) then ");// 如果不存在这个lockKey锁
sb.append(" redis.call('hset', KEYS[1], ARGV[1], 1) ");// 设置锁 ,hash结构,hashkey为当前线程id,加锁数为1
sb.append(" redis.call('pexpire', KEYS[1], ARGV[2]) ");// 设置锁超时时间
sb.append(" return nil ");
sb.append(" end ");
sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then ");// 如果当前线程已经加锁
sb.append(" redis.call('hincrby', KEYS[1], ARGV[1], 1) ");// 可重入,增加锁计数
sb.append(" redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重设置锁超时时间
sb.append(" return nil ");
sb.append(" end ");
sb.append(" return redis.call('pttl', KEYS[1]) ");// 如果别的线程已经加锁,返回剩余时间
lockScript = sb.toString();
sb.setLength(0);
sb.append(" if (redis.call('exists', KEYS[1]) == 0) then ");// 不存在锁,返回1表示解锁成功
sb.append(" return 1 ");
sb.append(" end ");
sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then ");// 存在锁,不是本线程加的,返回0失败
sb.append(" return 0 ");
sb.append(" end ");
sb.append(" local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1) ");// 存在自己加的锁,锁计数减一
sb.append(" if (counter > 0) then ");// 判断是否要删除锁,或重置超时时间
sb.append(" redis.call('pexpire', KEYS[1], ARGV[2]) ");
sb.append(" return 0 ");
sb.append(" else ");
sb.append(" redis.call('del', KEYS[1]) ");
sb.append(" return 1 ");
sb.append(" end ");
sb.append(" return nil ");
unlockScript = sb.toString();
sb.setLength(0);
sb.append(" if (redis.call('hexists', KEYS[1], ARGV[1]) == 1) then ");// 锁还存在
sb.append(" redis.call('pexpire', KEYS[1], ARGV[2]) ");// 重置超时时间
sb.append(" return 1 ");
sb.append(" end ");
sb.append(" return 0 ");
renewScript = sb.toString();
renewExpirationExecutor = new ScheduledThreadPoolExecutor(2);
}
// 单例的redis操作对象
private RedisUtil redisUtil = RedisUtil.getInstance();
// 业务编号,加锁的对象
private String busiId;
public RedisLock(String busiId) {
if (StringUtils.isEmpty(busiId)) {
throw new IllegalArgumentException("RedisLock实例化出错,业务编号为空");
}
this.busiId = busiId;
}
@Override
public boolean tryLock() {
String lockKey = redisLockPrefix + busiId;// 锁key
long threadId = Thread.currentThread().getId();// 当前线程id
String lockValue = redisUtil.getId().toString() + threadId;// 相当于jvmID+线程id
DefaultRedisScript redisScript = new DefaultRedisScript();
redisScript.setScriptText(lockScript);
redisScript.setResultType(Long.class);
Long result = redisUtil.execute(redisScript, Collections.singletonList(lockKey), new Object[]{lockValue, defaultExpireTime});
boolean isSuccess = result == null;
if (isSuccess) {
// 若成功,增加延时任务
scheduleExpirationRenew(threadId);
}
return isSuccess;
}
@Override
public void unlock(){
String lockKey = redisLockPrefix + busiId;// 锁key
long threadId = Thread.currentThread().getId();// 当前线程id
String lockValue = redisUtil.getId().toString() + threadId;// 相当于jvmID+线程id
DefaultRedisScript redisScript = new DefaultRedisScript();
redisScript.setScriptText(unlockScript);
redisScript.setResultType(Long.class);
redisUtil.execute(redisScript, Collections.singletonList(lockKey), new Object[]{lockValue, defaultExpireTime});
}
@Override
public void lock() throws AppException {
if (!tryLock()) {
throw new AppException("获取【" + busiId + "】锁失败,正在执行中,请稍后再试!");
}
}
/**
* 锁延时,定时任务队列,超时默认30s。每隔10s判断一次是否续期
*/
private void scheduleExpirationRenew(final long threadId) {
Runnable renewTask = new Runnable(){
@Override
public void run() {
String lockKey = redisLockPrefix + busiId;// 锁key
String lockValue = redisUtil.getId().toString() + threadId;// 相当于jvmID+线程id
DefaultRedisScript redisScript = new DefaultRedisScript();
redisScript.setScriptText(renewScript);
redisScript.setResultType(Long.class);
Long result = redisUtil.execute(redisScript, Collections.singletonList(lockKey), new Object[]{lockValue, defaultExpireTime});
if (result == 1) {
// 延时成功,再定时执行
scheduleExpirationRenew(threadId);
logger.info("redis锁【" + lockKey + "】延时成功!");
}
}
};
renewExpirationExecutor.schedule(renewTask, defaultExpireTime / 3, TimeUnit.MILLISECONDS);
}
}
把测试代码业务操作耗时睡眠改为睡30s,运行,很完美
至此,假如生产是单机模式的redis,实现三已经可以算得上是满足需要。
但它真的是完美的吗,试想,假如线程1拿到锁后,延时任务因为执行器执行耗时任务迟迟得不到执行或者系统长时间GC导致STW,锁超时删除后,线程1继续执行,而线程2依旧拿到了锁,此时两个线程都认为拥有锁从而导致发生了错误。
若是集群模式,主从同步也可能会造成锁丢失
线程1set加锁命令在主库执行,成功获取锁,此时主库宕机,set命令还未同步到从库,从库升级为主库,导致锁丢失,此时另外线程可以再次成功获取锁。
官方给出了一种解决算法,红锁Redlock,Redission就支持红锁。因为目前项目中基本不会对正确性要求很高,单机已经够用,即使为了高可用部署主从,偶尔出现锁丢失也没什么大不了的。所以没有仔细了解实现和写demo,官方介绍
红锁的思想就是主从异步同步会导致数据丢失,那就不同步不部署从库,只部署独立的主库,官方建议部署至少5个主库,获取锁时,向5个主库同时申请锁,如果大多数过半(3)个库都返回加锁成功,且申请锁耗费的时间少于锁超时时间则加锁成功。解锁时将向所有节点发起解锁请求。
因此现在实现三在极端情况下,也不保证是一定安全的,如果应用中对正确性要求很高,那么除了redis分布式锁,在资源层也需要加点手段保证唯一性,例如数据库更新时,版本号之类的控制。这样redis将大部分情况拦截在上层,资源层也进行控制,这样能满足大部分场景。