一、锁的实现原理
在一些场景中,我们希望一个方法同一时间只被一个线程执行,如果在单机环境下我们可以通过使用Java提供的并发API来限制访问,同一个JVM的线程可以通过共享堆内存中的变量来标记,这个标记其实就是锁,synchronized是通过对象头来标记;Lock接口的实现类是通过一个volatile的int型变量state来实现多线程的可见性和有序性(防止指令被重排序);linux 内核中也是利用互斥量或信号量等内存数据做标记。但是在分布式环境下,就变成了多进程,需要标记在一个所有进程都可见的地方,即一块外部共享内存,比如数据库。除了利用内存数据做锁,实际上任何互斥的都能做锁,如流水表中与时间相关的流水号做幂等校验可以看作是一个不会释放的锁,或者使用某个文件是否存在作为锁等,只需要满足在对标记进行修改能保证原子性和内存可见性即可。
二、分布式环境对锁的诉求
我们对锁的要求一般有以下几点:
1、互斥性:首先要保证一个方法同一时间只被一台呢机器上的一个线程执行
2、防止死锁:任何情况下拿到锁的线程崩溃,没有释放锁,锁要支持能自动释放,防止死锁导致方法不能被执行
3、高可用:如果锁服务挂掉,方法就不会被执行
4、可重入:指同一台机的同一个线程在已经持有锁的条件下调用同一把锁的资源不需要重新获取锁,否则会发生死锁(非必需,按业务要求而定)
5、加锁解锁保证是同一个线程:释放锁的时候必须是同一个线程,避免释放其他线程的锁,多线程交替执行的时候,如果锁的超时时间设置的不合理,第一个线程持有了锁,但是还没等到执行完,锁超时释放了,这时第二个线程就可以加锁成功,开始执行,第一个线程执行结束如果不判断锁的owner就去释放锁,会导致第三个线程也加锁成功,也开始执行,这时就无法保证互斥性了
6、加锁解锁性能:加锁解锁要尽可能快
常见的分布式锁实现有三种:
1.基于redis实现
SET method_name thread_value NX PX TTL
如果是单机版的有单点问题,如果是master-slave架构,如果一个线程加锁成功在数据还未同步到slave时master宕机了,另一台slave成为了新的master,此时主从数据不一致,另一个线程可以从新的master那里加锁成功。还有一个问题就是如果锁的超时时间设置的不合理,第一个线程持有了锁,但是还没等到执行完,锁超时释放了,这时第二个线程就可以加锁成功,开始执行,第一个线程执行完将第二个线程的锁释放,第三个线程也可以获取锁执行,这样无法保证互斥性,所以把握好加锁时间比较重要。
2.基于数据库实现
1)基于数据库的表主键唯一性
CREATE TABLE `tb_method_lock_primary_key` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`method_name` varchar(255) DEFAULT NULL,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`method_desc` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_method` (`method_name`) USING BTREE,
KEY `idx_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
可以对某个方法的名称(可以类名+方法名)执行insert,如果成功即拿到锁;但是这种方式只要insert了如果拿到锁的线程崩溃没有删除这一条记录就会导致死锁发生,还需要启动一个定时任务去删除过期的记录。
2)基于数据库表版本号字段
CREATE TABLE `tb_method_lock_mvcc` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`method_name` varchar(255) DEFAULT NULL,
`method_desc` varchar(255) DEFAULT NULL,
`version` bigint(20) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
`update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_method` (`method_name`) USING BTREE,
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;
先查询一次 SELECT id , version FROM tb_method_lock_mvcc WHERE id=1;
如果不存在,执行插入 INSERT INTO tb_method_lock_mvcc(method_name,method_desc , version,create_time) VALUES("methodName","methodDesc",1563600714,NOW());
如果存在,执行更新操作 UPDATE tb_method_lock_mvcc SET version = 1563600715 WHERE id=1 AND version = 1563600714;
3)基于数据库排他锁
CREATE TABLE `tb_method_lock_x` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`method_name` varchar(255) DEFAULT NULL,
`create_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`method_desc` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_method` (`method_name`) USING BTREE,
KEY `idx_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
在事务中获取x锁,SELECT id FROM tb_method_lock_x WHERE method_name="methodName" FOR UPDATE;执行完成任务后commit;
4)在业务表中加一个版本号字段
更新的时候仅当期待的版本号与记录当前版本号相同才更新,其实我们业务中经常会用这种,优点就是不需要引入其他中间件,减少不必要的维护成本,只要数据库正常服务就可以进行,缺点是对业务表侵入较大,也不可复用。
优点:简单
缺点:不支持可重入,如果想支持可重入就加一个owner字段,带有与线程相关的一个标识。数据库的最大问题是单点问题,无比保证高可用。而且操作mysql数据库也有一定的开销,性能不靠谱。尤其使用排他锁拿不到锁就会一直阻塞。导致数据库连接越来越多,可能会将数据库连接池资源耗尽。
3.基于zookeeper实现
利用zookeeper先创建一个持久节点,线程获取锁的时候会在该结点下创建临时顺序节点,如果自己是序号最小的结点,就加锁成功,如果不是且想排队继续获取,就向排在它前面的那个结点注册Watcher,如果监听到前一个结点不存在了,就可以获得锁。执行完成任务显示的删除结点,如果异常崩溃,与zookeeper服务器的连接会断开,根据临时结点的特性相关联的结点会自动删除,不会造成死锁。可以直接使用zookeeper第三方库Curutor,这个客户端中封装了一个可重入的锁服务。但是使用zookeeper性能上没有缓存那么高,每次创建、销毁结点都是开销,而且只能由leader来执行,发给follower还需要请求转给leader执行再同步到follower机器上,而且zookeeper客户端与服务器直接的连接需要发送心跳包来探活,遇到网