在很多场景中,我们为了保证数据的最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等。有的时候,我们需要保证一个方法在同一时间内只能被同一个线程执行。在单机环境中,Java中其实提供了很多并发环境下的线程安全方法,在JVM层面以通过Synchronized
关键字或者Lock
接口,可协调资源像原子性一样操作。但是在分布式环境下,跨JVM无法这些都无法发挥作用,只能寻求公证人(第三方服务)进行协调,典型的场景如在进行Quartz调度的时候,如果碰到集群就可能会用类似的应用场景,见下图:

Quartz如何保证多个节点的应用只进行一次调度(即某一时刻的调度任务只由其中一台服务器执行)?针对这种场景,目前比较常用的有以下几种方案:
- 基于数据库实现分布式锁
- 基于缓存(redis,memcached)实现分布式锁
- 基于Zookeeper实现分布式锁
在分析这几种实现方案之前我们先来想一下,我们需要的分布式锁应该是怎么样的?(这里以方法锁为例,资源锁同理)
可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
- 这把锁要是一把可重入锁(避免死锁)
- 这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)
- 有高可用的获取锁和释放锁功能
- 获取锁和释放锁的性能要好
source: 《分布式锁的几种实现方式》
一、基于数据库实现分布式锁
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='锁定中的方法';
当我们想要锁住某个方法时,执行以下SQL:
insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
delete from methodLock where method_name ='method_name'
上面这种简单的实现有以下几个问题:
- 单点故障风险:这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 锁无失效时间:一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
当然解决方案也是有的:
- 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
- 没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
- 非阻塞的?搞一个while循环,直到insert成功再返回成功。
- 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
虽然直接借助数据库,容易理解。但是会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂,而且操作数据库需要一定的开销,性能问题需要考虑。
二、基于Redis实现分布式锁(推荐)
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。而且很多缓存是可以集群部署的,可以解决单点问题。目前有很多成熟的缓存产品,包括Redis,memcached以及Tair。在这里就以常见的Redis来讲讲其实现:
Quartz定时器到时间被触发,程序开始先争取一个redis锁来执行任务。redis的命令可以采用如下:
SET key value [EX seconds] [PX milliseconds] [NX|XX]
public static boolean tryGetDistributedLock(Jedis jedis, String lockKey, String requestId, int expireTime) {
String result = jedis.set(lockKey, requestId, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
if (LOCK_SUCCESS.equals(result)) {
return true;
}
return false;
}
- 第一个为key,使用
key
来当锁,因为key
是唯一的。 - 第二个为value,可以传的是
requestId
,有key
作为锁不就够了吗为什么还需要value
呢?原因就是在上面讲到可靠性时,分布式锁需要特定的客户端来解锁,通过给value赋值为requestId就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。requestId
可以使用UUID.randomUUID().toString()
方法生成。 - 第三个为nx,这个参数我们填的是
NX
,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作; - 第四个为expx,这个参数我们传的是
PX
,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。 - 第五个为timev,与第四个参数相呼应,代表key的过期时间,1000等价于1s。
总的来说,执行上面的set()
方法就只会导致两种结果:
- 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
- 已有锁存在,不做任何操作。
加锁代码满足我们可靠性里描述的三个条件。首先,set()
加入了NX
参数,可以保证如果已有key存在,则函数不会调用成功,也就是只有一个客户端能持有锁,满足互斥性。其次,由于我们对锁设置了过期时间,即使锁的持有者后续发生崩溃而没有解锁,锁也会因为到了过期时间而自动解锁(即key被删除),不会发生死锁。最后,因为我们将value赋值为requestId,代表加锁的客户端请求标识,那么在客户端在解锁的时候就可以进行校验是否是同一个客户端。
当然除了setnx()
,还有基于Redlock做分布式锁、基于Redisson做分布式锁,Redis做分布式锁是性能最好的(优于Zookeeper锁),但是可靠性方面逊于ZK,且通过超时时间来控制锁的失效时间并不是十分的靠谱。
source:Redis分布式锁的正确实现方式
三、基于Zookeeper实现分布式锁(推荐)
基于Zookeeper的临时有序节点特性可以实现分布式锁,原理是:每个客户端对某个方法加锁时,在 Zookeeper上与该方法对应的指定节点的目录下,生成一个唯一的临时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个临时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
ZK锁可分为排他锁和共享读锁两种情况:
-
排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
-
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。

以上两种锁方式在加锁解锁操作上都类似的,获取锁上都可以通过客户端通过调用create
方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher
监听,以便实时监听到lock
节点的变更情况。释放锁,两种情况都可以让锁释放:1、当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除;2、正常执行完业务逻辑,客户端主动删除自己创建的临时节点。
注意:当集群规模比较大时,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,这些 操作会对Zookeeper造成巨大的性能影响和网络冲击,引发“羊群效应”,获取锁的方式可以优化。
总结:在可靠性方面,ZK锁是优于Redis,释放锁上无需失效时间来保证更为稳定靠谱,但是牺牲了一定的性能,且实现相对复杂。
source: 《利用Zookeeper实现 - 分布式锁》
网友评论