分布式锁的实现方式
分布式锁应该具备哪些条件
在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
分布式锁的三种实现方式:
基于数据库实现分布式锁;
基于缓存(Redis等)实现分布式锁;
基于Zookeeper实现分布式锁;
一、基于数据库的实现方式
1. 悲观锁
利用select … where … for update 排他锁
注意: 其他附加功能与实现一基本一致,这里需要注意的是“where id=key”,id字段必须要走索引,否则会锁表。有些情况下,mysql优化器会不走这个索引,导致锁表问题。
2. 乐观锁 :
所谓乐观锁与前边最大区别在于基于CAS思想,是不具有互斥性,不会产生锁等待而消耗资源,操作过程中认为不存在并发冲突,只有update version失败后才能觉察到。我们的抢购、秒杀就是用了这种实现以防止超卖。通过增加递增的版本号字段实现乐观锁。
sql示例:select version from items where id=1
update items set quantity=2,version = 3 where id=1 and version = 2;
除了version以外,还可以使用时间戳,因为时间戳天然具有顺序递增性。
以上SQL其实还是有一定的问题的,就是一旦高并发的时候,就只有一个线程可以修改成功,那么就会存在大量的失败。
对于像淘宝这样的电商网站,高并发是常有的事,总让用户感知到失败显然是不合理的。所以,还是要想办法减小乐观锁的粒度的。
sql示例: update item set quantity=quantity - 1 where id = 1 and quantity - 1 > 0
3.基于数据库表获取
此时这张表类似一个公共资源池,每个线程都要来这边获取条件,看能不能获取到当前方法的锁
建表语句如下:
DROP TABLE IF EXISTS `method_lock`; CREATE TABLE `method_lock` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名', `desc` varchar(255) NOT NULL COMMENT '备注信息', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';
3.1.1 获取锁时,只要执行insert语句insert into lock_table("method_name","time");
3.1.2 释放锁时,执行对应的delete语句就行。
一个简单的分布式锁就实现了,但是里面会存在很多问题,因为这只是一个初步方案,需要不断改进。
可能出现的问题
3.2.1 这个表中没有设计失效时间,一旦出现加锁成功但是解锁失败的情况,会出现其他线程无法获取到锁。
3.2.2 这把锁不是可重入的,同一个线程在没有释放之前无法再insert。
3.2.3 这把锁不是阻塞的,这边阻塞的意思就是有加锁时间限制,在这个时间内不断去尝试,类似Java里面的自旋。超过时间就失败。出现这个问题的原因和1.2.2一致。
3.2.4 最后一点也是要考虑的,它的可用性怎么样?并不好,一旦数据库挂了,就不能使用了。
针对的解决方案
3.2.1 -->
3.2.1.1可以存在一个定时任务,但是要注意判定失效的时间点的把握,既不能太短也不能太长。
3.2.1.2 代码中在加锁时可以先判断当前记录是不是已经超过最大允许时间,超过了说明已经失效了,先手动释放锁,再加锁
3.2.2 -->
重入的需求可以加入一个字段记录当前JVM的机器标识和线程标识,再次获取时判断一下。
3.2.3 -->
阻塞的问题,代码里执行while循环,设置一个允许最大时间,超过了,直接失败就是了。
3.2.4 -->
单机问题,上两台,互为主备,随时备份。
二、基于Redis实现分布式锁
- 选用Redis实现分布式锁原因:
- Redis有很高的性能;
- Redis命令对此支持较好,实现起来比较方便
- 使用命令介绍:
- SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
- expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
- delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
- 实现思想:
- 获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
- 获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
- 释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
代码示例:
三. 基于Zookeeper实现分布式锁
基于zookeeper临时有序节点可以实现的分布式锁。大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的
瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导
致的锁无法释放,而产生的死锁问题。
来看下Zookeeper能不能解决前面提到的问题。
- 锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(
Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户
端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
- 不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的
时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
可以直接使用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;
}
Curator提供的InterProcessMutex是分布式锁的实现。acquire方法用户获取锁,release方法用于释放锁。
使用ZK实现的分布式锁好像完全符合了本文开头我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务
那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。
总结
使用Zookeeper实现分布式锁的优点: 有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
使用Zookeeper实现分布式锁的缺点 : 性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。