分布式锁

一.分布式锁:

  • 概念:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问
  • 场景:微服务中,线程A和B很可能不在同一JVM中,线程锁就无法起到作用,就要用到分布式锁来解决

二.分布式锁需要解决的问题:

  • 死锁:获得锁的客户端宕机或者异常后(无法释放锁)
  • 原子性:同一时间只有一个客户端可以获取到锁
  • 互斥性:加锁和解锁的客户端必须是同一个,不能把其他客户端加的锁给解了
  • 容错性:避免同一把锁被多个客户端持有

三.基于数据库实现:

  • 基于数据库表
  • 实现方式:
    1)创建锁表,想要锁住某个方法或资源时,在表中增加一条记录,释放锁时,删除记录
CREATE TABLE `methodLock` (
 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键',
 `method_name` varchar(64) NOT NULL DEFAULT '' COMMENT '锁定的方法名',
 `desc` varchar(1024) NOT NULL DEFAULT '备注信息',
 `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '保存数据时间,自动生成',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
  • 缺点:
    1)这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用
    2)这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁
    3)这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作
    4)这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了
  • 基于数据库排他锁
  • 实现方式:
    1)查询语句后加for update
connection.setAutoCommit(false)
//执行的sql语句,业务逻辑处理
connection.commit();
  • 优点(对比数据库表):
    1)是阻塞锁,执行成功会立即返回,执行失败会一直阻塞,直到成功
    2)相对数据库表,代码结构简洁一些

  • 问题:
    1)非重入锁
    2)排他锁长时间不提交,就会占用数据库连接,连接过多可能把数据库撑爆
    3)for update的行锁有可能会变成表锁,对数据库会有较大的开销


四.基于zookeeper实现:

  • zookeeper的特性:
  • 有序节点:
    假如当前有一个父节点为/lock,我们可以在这个父节点下面创建子节点;zookeeper提供了一个可选的有序特性,例如我们可以创建子节点“/lock/node-”并且指明有序,那么zookeeper在生成子节点时会根据当前的子节点数量自动添加整数序号,也就是说如果是第一个创建的子节点,那么生成的子节点为/lock/node-0000000000,下一个节点则为/lock/node-0000000001,依次类推
  • 临时节点:
    客户端可以建立一个临时节点,在会话结束或者会话超时后,zookeeper会自动删除该节点
  • 临时顺序节点:
    临时顺序节点,也是临时节点,不过是带有顺序的,客户端会话消失节点就消失,Zk的分布式锁主要是运用的这个特性
  • 事件监听:
    在读取数据时,我们可以同时对节点设置事件监听,当节点数据或结构变化时,zookeeper会通知客户端。当前zookeeper有如下四种事件:1)节点创建;2)节点删除;3)节点数据修改;4)子节点变更
  • zookeeper分布式锁的流程:
  • 获取分布式锁时,在lock节点下创建临时顺序节点,释放锁时删除该临时节点
    1)在zookeeper指定节点(locks)下创建临时顺序节点node_n
    2) 获取locks下所有子节点children
    3) 对子节点按节点自增序号从小到大排序
    4) 判断本节点是不是第一个子节点,若是,则获取锁;若不是,则监听比该节点小的那个节点的删除事件
    5) 若监听事件生效,则回到第二步重新进行判断,直到获取到锁
  • 使用zookeeper第三方库Curator客户端(封装了可重入锁)
//获取锁
public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException {
   try {
       return interProcessMutex.acquire(timeout, unit);
   } catch (Exception e) {
       e.printStackTrace();
   }
   return true;
}
//释放锁
public boolean unlock() {
   try {
       interProcessMutex.release();
   } catch (Throwable e) {
       log.error(e.getMessage(), e);
   } finally {
       executorService.schedule(new Cleaner(client, path), delayTimeForClean, TimeUnit.MILLISECONDS);
   }
   return true;
}

四.基于redis+lua实现:

  • 解决思路:
  • 死锁问题:给锁设置过期时间,即使出现异常,过期之后,也会自动释放锁
  • 原子性问题:利用redis的lua脚本,保证多条指令执行的原子性
  • 一致性问题:加锁的时候指定随机值(如:分布式id生成器),解锁时对比随机值是否匹配,避免解错锁
  • 容错性问题:用redlock,解决redis集群部分节点宕掉的问题

  • redis+lua分布式锁流程:
    分布式锁_第1张图片

  • 锁执行器
public Object execute(String key, RedisLockCallback redisLockCallback) {
   	String uuid = idGenerator.generatIdByIncr("RedisLockIncr")+"";
   	try {
   		long flag = lock(key,uuid);
   		if(flag ==1)
   		return redisLockCallback.doInRedisLock();
   		else return 0;
   	} finally {
   		unlock(key, uuid);
   	}
   }
  • 获取锁
    用key为方法名或接口名,value为uuid(这里由是分布式id生成器生成的),并设置过期时间,避免发生死锁。
private Long lock(String key, int millseconds, String value) {
   	String luaScript = ""
   			+ "\r\nlocal ret = tonumber(redis.call('SETNX', '" + key + "','" + value + "'));"
   			+ "\r\nredis.call('PEXPIRE','" + key + "','" + millseconds + "');"
   			+ "\r\nreturn ret";
   	List<String> keys = new ArrayList<String>();
   	RedisScript<Long> script = new DefaultRedisScript<Long>(luaScript, Long.class);
   	Long ret = redisTemplate.execute(script, keys, new Object[]{});
   	return ret;
   }
  • 可重入获取锁
    主要参数为retryAwait获取锁等待时间、retryTimes重试次数。
//根据key和uuid获锁,lockTimeout为锁的有效期
private long getRedisLock(String key, String uuid) {
   	return lock(String.valueOf(key), lockTimeout, uuid);
   }
//可重入式获取锁
private long loopForLock(String key, String uuid){
   	long getlockstate = 0;
   	//第一次获取锁,如果获取到直接返回
   	getlockstate = getRedisLock(key,uuid);
   	//未获取到锁,利用ReentrantLock可重入式获取锁
   	if(getlockstate ==0){
   		ReentrantLock pauseLock = new ReentrantLock();
   		Condition unpaused = pauseLock.newCondition();
   		try {
   			pauseLock.lock();
   			for (int i =0;getlockstate == 0&i<retryTimes;i++){
   				getlockstate = getRedisLock(key,uuid);
   				unpaused.await(retryAwait, TimeUnit.MILLISECONDS);
   			}
   		} catch (InterruptedException e) {
   			e.printStackTrace();
   		} finally {
   			pauseLock.unlock();
   		}
   	}
   	return getlockstate;
   }
  • 回调函数
    用于在获取到锁,执行相关业务逻辑后,根据返回结果做二次业务逻辑处理。
public interface RedisLockCallback {
   Object doInRedisLock();
}
  • 释放锁
    根据key获取锁的值和存入的uuid对比,匹配则释放锁,避免加锁和释放锁的客户端不是同一个。
private Long unlock(String key, String value) {
   	String luaScript = ""
   			+ "\r\nlocal val = redis.call('GET', '" + key + "');"
   			+ "\r\nlocal ret= 0;"
   			+ "\r\nif val == '" + value + "' then"
   			+ "\r\nret =redis.call('DEL','" + key + "');"
   			+ "\r\nend"
   			+ "\r\nreturn ret";
   	List<String> keys = new ArrayList<String>();
   	RedisScript<Long> script = new DefaultRedisScript<Long>(luaScript, Long.class);
   	Long ret = redisTemplate.execute(script, keys, new Object[]{});
   	return ret;
   }
  • 业务逻辑处理
   public Object addLock()  {
   	//拼接key,可以根据业务逻辑在指定唯一要素
       String redisLockKey = RedisKey.REDIS_METHOD_LOCK.concat(storageQueueKey);
       //调用锁执行器,在回调函数内执行业务逻辑
       Object res = redisLockTemplate.execute(redisLockKey, new RedisLockCallback() {
           @Override
           public Object doInRedisLock() {
               //返回业务逻辑的执行结果
               return service();
           }
       });
       }
       return res;
   }

  • 可优化的地方

1.锁与业务耦合度高:
上述代码中的addLock()方法,可以采用注解的方式替代,在拦截器里进行处理。通过注解的方式可以去除RedisLockCallback()回调函数方法,不需要再根据回调函数的返回结果进行二次业务逻辑处理,实现分布式锁和业务代码的解耦合。

  • 可以在注解里设置锁的有效期、lockKey(接口名、方法名或自由指定)、可重入锁的重试次数、可重入锁的等待时间等参数

2.线程优先级反转问题:
临界区客户端的请求,不能严格的串行执行,(如有:A、B、C、D、E,5个客户端顺序请求获取锁,其中A先获取到锁之后释放了,B、C、D、E都有机会抢到锁,最终获取到锁的顺序,并不是按照请求顺序来的)

  • 基于redis+lua的方案,创建锁对象的方式为:ReentrantLock pauseLock = new ReentrantLock(),其lock对象为非公平锁(获取锁的顺序不一定是申请锁的顺序)
    可以将锁对象改为ReentrantLock pauseLock = new ReentrantLock(true),创建一个公平锁(多个线程按照申请锁的顺序来获取锁),以此保证客户端请求的串行执行
  • 基于zookeeper的分布式锁,天生可以规避此问题

3.可重入锁临界点超时问题:可能会造成脏数据或业务异常

  • 前提条件:锁未过期,业务挂起或者执行时间过长,超过了可重入锁的wait总时间(单次wait时间*重试次数),在期间获取锁失败(包含可重入式获取锁)的客户端请求会被丢弃
  • 前提条件:锁已过期,业务挂起或者执行时间过长,前一个客户端的请求还未执行完,后面的客户端却获取到了锁
  • 这2种场景下的问题,除了合理设置锁超时时间外,尽量不要把分布式锁用于执行时间长的任务

4.释放锁失败的问题:
在加锁时设置了有效期可以有效避免死锁的问题,但仍无法避免由于释放锁失败导致的在有效期内其它客户端获取不到锁的问题

  • 释放锁失败时增加Callback()回调函数,利用回调函数做二次处理(当然回调函数也可能会出现失败的问题,这时还会再处理吗?似乎形成了死循环!)。
  • 针对回调函数可能导致的死循环问题,可以参考自旋锁,设置一个旋转循环次数,超过次数,关闭锁

5.同一把锁被多个客户端获取的问题(极限场景):
一个客户端加锁成功,由于Redis集群Master节点宕掉,数据没有及时同步到其它节点,另一个客户端也会获取到锁,这样就会造成同一把锁被多个客户端获取。(本质原因:redis的是AP模型,分布式锁是CP模型)

  • 利用redlock,解决由于redis集群部分节点宕掉导致同一把锁被多个客户端获取的问题

你可能感兴趣的:(Java,Java锁,分布式锁,redis+lua实现分布式锁,zookeeper实现分布式锁,可重入式获取锁)