原创文章,转载请注明原文章地址,谢谢!
分布式锁实现方式
- 基于数据库(唯一索引)
- 基于缓存(Redis)
- 基于Zookeeper
基于数据库实现分布式锁
基于数据库实现分布式锁,主要是利用数据库的唯一索引来实现,唯一索引天然具有排他性,这刚好符合对锁的要求:同一时刻只能允许一个竞争者获取锁。加锁时在数据库中插入一条锁记录,利用业务id进行防重。当第一个竞争者加锁成功后,第二个竞争者再来加锁就会抛出唯一索引冲突,如果抛出这个异常,就判定当前竞争者加锁失败。防重业务id需要自己来定义,例如锁对象是一个方法,则业务防重id就是这个方法的名字,如果锁定的对象是一个类,则业务防重id就是这个类名。
表设计
CREATE TABLE `distributed_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`unique_mutex` varchar(255) NOT NULL COMMENT '业务防重id',
`holder_id` varchar(255) NOT NULL COMMENT '锁持有者id',
`create_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `mutex_index` (`unique_mutex`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
id字段是数据库的自增id,unique_mutex字段就是防重id,也就是加锁的对象,此对象唯一。在这张表上加了一个唯一索引,保证unique_mutex唯一性。holder_id代表竞争到锁的持有者id。
加锁
insert into distributed_lock(unique_mutex, holder_id) values ('unique_mutex', 'holder_id');
如果当前sql执行成功代表加锁成功,如果抛出唯一索引异常(DuplicatedKeyException)则代表加锁失败,当前锁已经被其他竞争者获取。
解锁
delete from methodLock where unique_mutex='unique_mutex' and holder_id='holder_id';
分析
- 是否可重入:就以上的方案来说,该方式实现的分布式锁是不可重入的,即使是同一个竞争者,在获取锁后未释放锁之前再来加锁,一样会加锁失败,因此是不可重入的。解决不可重入问题也很简单,加锁时判断记录中是否存在unique_mutex的记录,如果存在且holder_id和当前竞争者id相同,则加锁成功。这样就可以解决不可重入问题。
- 锁释放时机:设想如果一个竞争者获取锁时候,进程挂了,此时distributed_lock表中的这条记录就会一直存在,其他竞争者无法加锁。为了解决这个问题,每次加锁之前先判断已经存在的记录的创建时间和当前系统时间之间的差是否已经超过超时时间,如果已经超过则先删除这条记录,再插入新的记录。另外在解锁时,必须是锁的持有者来解锁,其他竞争者无法解锁。这点可以通过holder_id字段来判定。
- 数据库单点问题:单个数据库容易产生单点问题,如果数据库挂了,锁服务就挂了。对于这个问题,可以考虑实现数据库的高可用方案,例如MySQL的MHA高可用解决方案。
基于缓存实现分布式锁
基于缓存实现分布式锁,理论上来说使用缓存来实现分布式锁的效率最高,加锁速度最快,因为Redis几乎都是纯内存操作,而基于数据库的方案和基于Zookeeper的方案都会涉及到磁盘文件IO,效率相对低下。一般使用Redis来实现分布式锁都是利用Redis的setnx key value这个命令,只有当key不存在时才会执行成功,如果key已经存在则命令执行失败。
加锁
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 锁的key
* @param requestId 竞争者id
* @param expireTime 锁超时时间,超时之后锁自动释放
* @return
*/
public static boolean getDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
//加锁的代码
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
return "OK".equals(result);
}
}
上述加锁的代码,五个参数。第一个参数是key,用key来当做锁,因为key是唯一的。第二个参数是value,锁的竞争者id,在解锁时,需要判断当前解锁的竞争者id是否为锁持有者。第三个参数是nx,当key不存在时,进行set操作,如果key已经存在,则不作任何操作。第四个参数是px,给key加一个过期时间。第五个参数是time,代表key的过期时间。总结,当执行set方法时,会出现2种结果:第一种是当前没有锁,即key不存在,那么就进行加锁操作,并对锁设置一个有效期,同时value表示加锁的客户端。第二种是锁已经存在,不做任何操作。
解锁
public class RedisTool {
private static final Long RELEASE_SUCCESS = 1L;
/**
* 释放分布式锁
* @param jedis Redis客户端
* @param lockKey 锁
* @param requestId 锁持有者id
* @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));
return RELEASE_SUCCESS.equals(result);
}
}
- 判断当前解锁的竞争者id是否为锁的持有者,如果不是直接返回失败。
- 如果是,则删除key,如果删除成功,返回解锁成功,否则解锁失败。
注意到这里解锁其实是分为2个步骤,涉及到解锁操作的一个原子性操作问题。这也是为什么解锁的时候用Lua脚本来实现,因为Lua脚本可以保证操作的原子性。那么这里为什么需要保证这两个步骤的操作是原子操作呢?设想:假设当前锁的持有者是竞争者1,竞争者1来解锁,成功执行第1步,判断自己就是锁持有者,这时还未执行第2步。这时锁过期了,然后竞争者2对这个key进行了加锁。加锁完成后,竞争者1又来执行第2步,此时错误产生了,竞争者1解锁了不属于自己持有的锁。可能会有人问为什么竞争者1执行完第1步之后突然停止了呢?这个问题其实很好回答,例如竞争者1所在的JVM发生了GC停顿,导致竞争者1的线程停顿。这样的情况发生的概率很低,但是请记住即使只有万分之一的概率,在线上环境中完全可能发生。因此必须保证这两个步骤的操作是原子操作。
分析
- 是否可重入:以上实现的锁是不可重入的,如果需要实现可重入,在SET_IF_NOT_EXIST之后,再判断key对应的value是否为当前竞争者id,如果是返回加锁成功,否则失败。
- 锁释放时机:加锁时设置了key的超时,当超时后,如果还未解锁,则自动删除key达到解锁的目的。如果一个竞争者获取锁之后挂了,锁服务最多也就在超时时间的这段时间之内不可用。
- Redis单点问题:如果需要保证锁服务的高可用,可以对Redis做高可用方案。Redis集群、主从切换。
基于Zookeeper实现分布式锁
基于Zookeeper实现分布式锁,Zookeeper一般用作配置中心,其实现分布式锁的原理和Redis类似,在Zookeeper中创建瞬时节点,利用节点不能重复创建的特性来保证排他性。
加锁和解锁
- 当一个客户端来请求时,在锁的空间下面创建一个临时有序节点。
- 如果当前节点的序列是这个空间下面最小的,则代表加锁成功,否则加锁失败,加锁失败后设置Watcher,等待前面节点的通知。
- 当前节点监听其前面一个节点,如果前面一个节点删除了就通知当前节点。
- 当解锁时当前节点通知其后继节点,并删除当前节点。
分析
- 是否可重入:客户端加锁时将主机和线程信息写入锁中,下一次再来加锁时直接和序列最小的节点对比,如果相同,则加锁成功,锁重入。
- 锁释放时机:由于创建的节点是顺序临时节点,当客户端获取锁成功之后突然session会话断开,ZK会自动删除这个临时节点。
- 单点问题:ZK是集群部署的,只要一半以上的机器存活,就可以保证服务可用性。
利用curator实现
Zookeeper第三方客户端curator中已经实现了基于Zookeeper的分布式锁。
//加锁,支持超时,可重入
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;
}
博客内容仅供自已学习以及学习过程的记录,如有侵权,请联系我删除,谢谢!
网友评论