1、分布式锁要解决的问题
不同进程间采用互斥的方式操作共享资源。
常见的场景是作为一个sdk被引入到大型项目中,主要解决两类问题。
提升效率:
加锁是为了避免不必要的重复处理。
例如防止幂等任务被多个执行者抢占。此时对锁的正确性要求不高;
保证正确性:
加锁是为了避免RaceCondition导致逻辑错误。
例如直接使用分布式锁实现防重,幂等机制。此时如果锁出现错误会引起严重后果,因此对锁的正确性要求高。
2、分布式锁要解决的问题
使用分布式锁的场景一般需要满足以下场景:
1. 系统是一个分布式系统,java的锁已经锁不住了。
2. 操作共享资源,比如库里唯一的用户数据。
3. 同步访问,即多个进程同时操作共享资源。
Java本身提供了两种内置的锁的实现,一种是由JVM实现的synchronized 和 JDK 提供的 Lock,以及很多原子操作类都是线程安全的,当你的应用是单机或者说单进程应用时,可以使用这两种锁来实现锁。
3、那常见的分布式锁有哪些解决方案
Reids的分布式锁,很多大公司会基于Reidis做扩展开发。
基于Zookeeper
基于数据库,比如Mysql。
4、Redis分布式锁实现
加锁:
//使用setnx命令加锁
public static void wrongGetLock1(Jedis jedis, String lockKey, String requestId, int expireTime) {
// 第一步:加锁
Long result = jedis.setnx(lockKey, requestId);
if (result == 1) {
// 第二步:设置过期时间
jedis.expire(lockKey, expireTime);
}
}
代码解释:
setnx命令:如果lockKey不存在,把key存入Redis,保存成功后如果result返回1,表示设置成功;如果非1,表示失败,别的线程已经设置过了
expire(),设置过期时间,防止死锁
存在问题:
加锁总共分两步,第一步jedis.setnx,第二步jedis.expire设置过期时间,setnx与expire不是一个原子操作;
如果程序执行完第一步后异常了,第二步jedis.expire(lockKey, expireTime)没有得到执行,相当于这个锁没有过期时间,有产生死锁的可能。
//改进:将加锁和设置过期时间合二为一,一行代码搞定,原子操作
public class RedisLockDemo {
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 getLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
// 两步合二为一,一行代码加锁并设置 + 过期时间。
if (1 == jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime)) {
return true;//加锁成功
}
return false;//加锁失败
}
}
解锁:
//使用del命令解锁
public static void unLock(Jedis jedis, String lockKey, String requestId) {
// 第一步: 使用 requestId 判断加锁与解锁是不是同一个客户端
if (requestId.equals(jedis.get(lockKey))) {
// 第二步: 若在此时,这把锁突然不是这个客户端的,则会误解锁
jedis.del(lockKey);
}
}
通过 requestId 判断加锁与解锁是不是同一个客户端和 jedis.del(lockKey) 两步不是原子操作,理论上会出现在执行完第一步if判断操作后锁其实已经过期,并且被其它线程获取,这是时候在执行jedis.del(lockKey)操作,相当于把别人的锁释放了,这是不合理的。
当然,这是非常极端的情况,如果unLock方法里第一步和第二步没有其它业务操作,把上面的代码扔到线上,可能也不会真的出现问题,原因第一是业务并发量不高,根本不会暴露这个缺陷,那么问题还不大。
//改进:通过 jedis 客户端的 eval 方法和 script 脚本一行代码搞定,解决方法一中的原子问题。
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;
}
}
5、基于 ZooKeeper 的分布式锁实现原理
分布式锁的基本逻辑:
1. 客户端调用create()方法创建名为“/dlm-locks/lockname/lock-”的临时顺序节点。
2. 客户端调用getChildren(“lockname”)方法来获取所有已经创建的子节点。
3. 客户端获取到所有子节点path之后,如果发现自己在步骤1中创建的节点是所有节点中序号最小的,就是看自己创建的序列号是否排第一,如果是第一,那么就认为这个客户端获得了锁,在它前面没有别的客户端拿到锁。
4. 如果创建的节点不是所有节点中需要最小的,那么则监视比自己创建节点的序列号小的最大的节点,进入等待。直到下次监视的子节点变更的时候,再进行子节点的获取,判断是否获取锁。
释放锁的过程相对比较简单,就是删除自己创建的那个子节点即可,不过也仍需要考虑删除节点失败等异常情况。
6、ZK和Reids的区别,各自有什么优缺点
Reids:
1. Rdis只保证最终一致性,副本间的数据复制是异步进行(Set是写,Get是读,Reids集群一般是读写分离架构,存在主从同步延迟情况),主从切换之后可能有部分数据没有复制过去可能会「丢失锁」情况,故强一致性要求的业务不推荐使用Reids,推荐使用zk。
2. Redis集群各方法的响应时间均为最低。随着并发量和业务数量的提升其响应时间会有明显上升(公有集群影响因素偏大),但是极限qps可以达到最大且基本无异常
ZK:
1. 使用ZooKeeper集群,锁原理是使用ZooKeeper的临时节点,临时节点的生命周期在Client与集群的Session结束时结束。因此如果某个Client节点存在网络问题,与ZooKeeper集群断开连接,Session超时同样会导致锁被错误的释放(导致被其他线程错误地持有),因此ZooKeeper也无法保证完全一致。
2. ZK具有较好的稳定性;响应时间抖动很小,没有出现异常。但是随着并发量和业务数量的提升其响应时间和qps会明显下降。
7、Mysql如何做分布式锁
设置一个 UNIQUE KEY,这样对锁的竞争就交给了数据库