各位小伙伴儿, 上篇我们介绍了Java中的7类锁, 现在还有一个重头戏, 那就是分布式锁, 我们接着上篇的标题,继续探索~
8. 分布式锁
8.1 为什么需要分布式锁
首先我们先了解一下分布式锁的使用场景, 然后再来理解为什么需要分布式锁, 那么我们举两个例子进行阐述:
- 银行转账问题: A在上海,B在北京同时在建行转账给杭州C,A转账时,会修改C处服务器的表,B不能在此刻转账,同理,B转账时,A不能做处理,A,B的转账操作时同步,必须保证数据的一致性,这就需要分布式锁来进行处理.
- 取任务问题: 某服务提供一组任务,A系统请求随机从任务组中获取一个任务;B系统请求随机从任务组中获取一个任务。 在理想的情况下,A从任务组中挑选一个任务,任务组删除该任务,B从剩下的的任务中再挑一个,任务组删除该任务。 同样的,在真实情况下,如果不做任何处理,可能会出现A和B挑中了同一个任务的情况。
- 真实开发中, 集群模式下对某一个共享变量进行多线性同步访问:
- 上图可以看到,变量A存在JVM1、JVM2、JVM3三个JVM内存中(这个变量A主要体现是在一个类中的一个成员变量,是一个有状态的对象,例如:UserController控制器中的一个整形类型的成员变量).
- 如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,显然结果是不对的!即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!
这种情况下就要应用分布式锁来解决了.
8.2 为什么分布式系统中不能用普通锁呢?普通锁和分布式锁有什么区别吗?
- 普通锁
- 单一系统找那个, 同一个应用程序是有同一个进程, 然后多个线程并发会造成数据安全问题, 他们是共享同一块内存的, 所以在内存某个地方做标记即可满足需求.
- 例如synchronized和volatile+cas一样对具体的代码做标记, 对应的就是在同一个内存区域作了同步的标记.
- 分布式锁
- 分布式系统中, 最大的区别就是不同系统中的应用程序都在各自机器上不同的进程中处理的, 这里的线程不安全可以理解为多进程造成的数据安全问题, 他们不会共享同一台机器的同一块内存区域, 因此需要将标记存储在所有进程都能看到的地方.
- 例如zookeeper作分布式锁,就是将锁标记存储在多个进程共同看到的地方,redis作分布式锁,是将其标记公共内存,而不是某个进程分配的区域.
8.3 分布式锁应该具备哪些条件
在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:
- 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
- 高可用的获取锁与释放锁;
- 高性能的获取锁与释放锁;
- 具备可重入特性;
- 具备锁失效机制,防止死锁;
- 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。
8.4 分布式锁的三种实现方式
- 目前几乎很多大型网站及应用都是分布式部署的, 分布式场景中的数据一致性问题一直是一个比较重要的话题.
分布式的CAP理论告诉我们任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance), 最多只能同时满足两项. - 分布式锁, 是一种思想, 它的实现方式有很多种. 比如, 我们将沙滩当做分布式锁的组件, 那么它看起来应该是这样的:
- 加锁: 在沙滩上踩一脚,留下自己的脚印,就对应了加锁操作。其他进程或者线程,看到沙滩上已经有脚印,证明锁已被别人持有,则等待
- 解锁: 把脚印从沙滩上抹去,就是解锁的过程
- 锁超时: 为了避免死锁,我们可以设置一阵风,在单位时间后刮起,将脚印自动抹去
- 因此应运而生了三种实现分布式锁的方式:
- 基于数据库实现分布式锁;
- 基于缓存(Redis等)实现分布式锁;
- 基于Zookeeper实现分布式锁;
尽管有这三种方案,但是不同的业务也要根据自己的情况进行选型,他们之间没有最好只有更适合!
8.4.1 数据库的分布式锁
基于表记录实现分布式锁
- 基于数据库的实现方式的核心思想是:
在数据库中创建一个表, 表中包含方法名等字段, 并在方法名字段上创建唯一索引.
想要执行某个方法, 就使用这个方法名向表中插入数据, 成功插入则获取锁, 执行完成后删除对应的行数据释放锁.
- 创建一个表
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='锁定中的方法';
2. 想要执行某个方法, 就使用这个方法名向表中插入数据:
INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');
因为我们对method_name
做了唯一性约束, 这里如果有多个请求同事提交到数据库的话, 数据库会保证只有一个操作可以成功, 那么我们就可以认为操作成功的那个现场恒获得了该方法的锁, 可以执行方法体内容.
3. 成功插入则获取锁, 执行完成后删除对应的行数据释放锁
delete from method_lock where method_name ='methodName';
- 基于表记录实现分布式锁的特点
- 这种锁没有失效时间, 一旦释放锁的操作失败就会导致锁记录一直在数据库中, 其他线程无法获得锁. 这个缺陷也很好解决, 比如可以做一个定时任务去定时清理.
- 这种锁的可靠性依赖于数据库, 建议设置备库, 避免单点, 进一步提高可靠性.
- 这种锁是非阻塞的, 因为插入数据失败之后会直接报错, 想要获得锁就需要再次操作. 如果需要阻塞式的, 可以来个for循环或while循环, 直至INSERT成功再返回.
- 这种锁也是非可重入的, 因为同一个线程在没有释放锁之前无法再次获得锁, 因为数据库中已经存在同一份记录了. 想要实现可重入锁, 可以在数据库中添加一些字段, 比如获得锁的主机信息、线程信息等, 那么在再次获得锁的时候可以先查询数据, 如果当前的主机信息和线程信息等能被查到的话, 可以直接把锁分配给它.
基于乐观锁实现分布式锁
系统认为数据的更新在大多数情况下是不会产生冲突的,只在数据库更新操作提交的时候才对数据作冲突检测。如果检测的结果出现了与预期数据不一致的情况,则返回失败信息.
乐观锁大多数是基于数据版本(version)的记录机制实现的. 即为数据增加一个版本标识.
- 在基于数据库表的版本解决方案中, 一般是通过为数据库表添加一个 “version”字段来实现读取出数据时, 将此版本号一同读出, 之后更新时, 对此版本号加1.
- 在更新过程中, 会对版本号进行比较, 如果是一致的, 没有发生改变, 则会成功执行本次操作; 如果版本号不一致, 则会更新失败.
- 基于乐观锁的优点
在检测数据冲突时并不依赖数据库本身的锁机制, 不会影响请求的性能, 当产生并发且并发量较小的时候只有少部分请求会失败. - 基于乐观锁的缺点
需要对表的设计增加额外的字段, 增加了数据库的冗余, 另外, 当应用并发量高的时候, version值在频繁变化, 则会导致大量请求失败, 影响系统的可用性.
综合数据库乐观锁的优缺点, 乐观锁比较适合并发量不高, 并且写操作不频繁的场景.
基于悲观锁实现分布式锁
除了通过增删操作数据库表中的记录来实现分布式锁, 我们还可以借助数据库中再带的锁来实现分布式锁.
在查询语句后面增加For Update, 数据库会在查询过程中给数据库表增加悲观锁(也成排他锁), 当某条记录被加上悲观锁后, 其他线程也就无法再该行上增加悲观锁了.
-
悲观锁与乐观锁相反, 总是假设最坏的情况, 它认为数据的更新在大多数情况下是会产生冲突的.
-
在使用悲观锁的同时, 我们需要注意一下锁的级别. 搜索引擎的不同也会带了锁级别的不同.
如果存储引擎是InnoDB, 在加锁的时候只有明确地指定主键(或索引)的才会执行行锁(只锁住被选取的数据), 否则Mysql将会执行表锁(将整个数据表单给锁住).
- 使用悲观锁实现分布式锁特点
- 在悲观锁中, 每一次行数据的访问都是独占的, 只有当正在访问该行数据的请求事务提交以后, 其他请求才能依次访问该数据, 否则将阻塞等待锁的释放.
- 悲观锁可以严格保证数据访问的安全.
- 但是缺点也明显, 即每次请求都会额产生加锁的开销, 且未获取到锁的请求将会阻塞等待锁的释放, 在高并发环境下, 容易造成大量请求阻塞, 影响系统可用性,
- 悲观锁使用不当还可能产生死锁的情况
8.4.2 Redis的分布式锁
1) 单节点Redis分布式锁
- 加锁
加锁实际上就是在Redis中, 给Key键设置一个值, 为避免死锁, 并给定一个过期时间.
Set lock_key random_value NX PX 5000
random_value
是客户端生成的唯一的字符串.NX
代表只在键不存在时, 才对键进行设置操作.PX 5000
设置键的过期时间为5000毫秒
这样, 如果上面的命令执行成功, 则证明客户端获取到了锁.
- 解锁
解锁的过程就是将Key键删除, 但也不能乱删, 不能说客户端1的请求将客户端2的锁给删掉.
通过random_value
的唯一标识来判别哪个客户端. 删除的时候先输入要删除的random_value
, 然后判断当前random_value
与先输入的是否相等, 是的话就删除Key, 解锁成功. - 单机模式Redis分布式锁优缺点:
- 实现比较容易, 如果是单机模式也容易满足需求.
- 但因为是单机单例单实例部署, 如果Redis服务宕机, 那么所有需求获取分布式锁的地方均无法获取锁, 将全部阻塞, 需要做好降级处理.
- 当锁过期后, 执行任务的进程还没有执行完, 但是锁因为自动过期已经解锁,可能被其它进程重新加锁, 这就造成多个进程同时获取到了锁, 这需要额外的方案来解决这种问题.
2) 集群模式的Redis分布式锁 Redlock
- Redlock算法是什么
针对Redis集群架构,redis的作者antirez提出了Redlock算法,来实现集群架构下的分布式锁。
Redlock算法并不复杂,我们先简单描述一下,假设我们Redis分片下,有三个Master的节点,这三个Master,又各自有一个Slave,现在客户端想获取一把分布式锁:- 记下开始获取锁的时间 startTime
- 按照A->B->C的顺序,依次向这三台Master发送获取锁的命令。客户端在等待每台Master回响应时,都有超时时间timeout。举个例子,客户端向A发送获取锁的命令,在等了timeout时间之后,都没收到响应,就会认为获取锁失败,继续尝试获取下一把锁
- 如果获取到超过半数的锁,也就是 3/2+1 = 2把锁,这时候还没完,要记下当前时间endTime
计算拿到这些锁花费的时间 costTime = endTime - startTime,如果costTime小于锁的过期时间expireTime,则认为获取锁成功 - 如果获取不到超过一半的锁,或者拿到超过一半的锁时,计算出costTime>=expireTime,这两种情况下,都视为获取锁失败
- 如果获取锁失败,需要向全部Master节点,都发生释放锁的命令,也就是那段Lua脚本
- Redlock优缺点:
- Redlock是Redis的作者antirez给出的集群模式的Redis分布式锁, 它基于N个完全独立的Redis节点.
- 部分节点宕机, 依然可以保证锁的可用性.
- 当某个节点宕机后, 又立即重启了, 可能会出现两个客户端同时持有同一把锁, 如果节点设置了持久化, 出现这种情况的几率会降低.
- 和单机模式锁相比, 实现难度要大些.
3) 集群模式的Redis分布式锁 Redisson(基于Redlock)
Redisson是一个基于Java编程框架netty进行扩展了的Redis.
- Redisson是架设在Redis基础上的一个Java驻内存数据网格, 可以理解为是一套开源框架.充分的利用了Redis键值数据库提供的一系列优势, 基于Java实用工具包中的常用接口, 为使用者提供了一系列具有分布式特性的常用工具类.
- 进一步简化了分布式环境中程序相互之间的协作.相对于Jedis而言, Redisson更强的是实现类分布式锁, 而且包含各种类型的锁.
Redisson适用于: 分布式应用, 分布式缓存, 分布式会话管理, 分布式服务(任务, 延迟任务, 执行器), 分布式Redis客户端.
目前操作Redisson有三种方式:
- 第一种:纯java操作,本文就是使用这种,所有的配置都写在一个 Class 里。
- 第二种:spring集成操作,编写一个 xml,配置一个bean,启动还需读取这个文件,一堆很原始的操作。使用这种 xml 配置我看着都烦,强烈不推荐。
- 第三种:文件方式配置,是把所有配置的参数放到配置文件声明,然后在 Class 中读取。
我们先看一张Redisson实现Redis分布式锁的底层原理
- 加锁机制: 如果某个客户端要加锁, 它面对的是Redis Cluster集群, 首先会根据hash节点选择一台机器.
- 锁互斥机制: 这个时候如果客户端2来尝试家锁, 发现myLock这个锁Key已经存在了, 在Mylock这个锁key的剩余时间内, 客户端2会进入一个while循环, 不停的尝试加锁.
- watch dog自动延期机制: 客户端1一旦加锁成功, 就会启动一个watch dog看门够, 他是一个后台线程,会每隔10秒检查一下, 如果客户端1还持有锁key, 那么就会不断的延长锁key的生存时间.
- 可重入加锁机制: 执行可重入锁,会对客户端1的加锁次数, 累加1.
- 锁释放机制: 执行释放锁, 就会对和护短加锁次数减1. 如果发现锁此时是0, 就从Redis中删除这个key, 另外客户端2就可以尝试加锁了.
8.4.3 Zookeeper分布式锁
Zookeeper分布式锁的实现, 主要是因为Zookeeper有以下特点:
- 维护了一个有层次的数据节点, 类似文件系统.
- 树状数据节点: 临时节点, 持久节点, 临时有序节点(分布式锁实现基于的是临时有序节点), 持久有序节点.
- Zookeepe可以和client客户端通过心跳的机制保持长连接, 如果客户端连接Zookeeper创建了一个临时节点, 那么客户端与Zookeeper断开连接后会自动删除.
- Zookeeper保持了统一视图, 各服务对于状态信息获取满足一致性.
Zookeeper的每一个节点, 都是一个天然的顺序发号器.
在每一个节点下面创建子节点时,只要选择的创建类型是有序(EPHEMERAL_SEQUENTIAL 临时有序或者PERSISTENT_SEQUENTIAL 永久有序)类型,那么,新的子节点后面,会加上一个次序编号。这个次序编号,是上一个生成的次序编号加一.
比如,创建一个用于发号的节点“/test/lock”,然后以他为父亲节点,可以在这个父节点下面创建相同前缀的子节点,假定相同的前缀为“/test/lock/seq-”,在创建子节点时,同时指明是有序类型。如果是第一个创建的子节点,那么生成的子节点为/test/lock/seq-0000000000,下一个节点则为/test/lock/seq-0000000001,依次类推,等等.
如何使用Zookeeper实现分布式锁呢?
1) 排它锁
排他锁,又称写锁或独占锁。如果事务T1对数据对象O1加上了排他锁,那么在整个加锁期间,只允许事务T1对O1进行读取或更新操作,其他任务事务都不能对这个数据对象进行任何操作,直到T1释放了排他锁。
排他锁核心是保证当前有且仅有一个事务获得锁,并且锁释放之后,所有正在等待获取锁的事务都能够被通知到。
Zookeeper 的强一致性特性,能够很好地保证在分布式高并发情况下节点的创建一定能够保证全局唯一性,即Zookeeper将会保证客户端无法重复创建一个已经存在的数据节点。可以利用Zookeeper这个特性,实现排他锁。
- 定义锁:通过Zookeeper上的数据节点来表示一个锁
- 获取锁:客户端通过调用 create 方法创建表示锁的临时节点,可以认为创建成功的客户端获得了锁,同时可以让没有获得锁的节点在该节点上注册Watcher监听,以便实时监听到lock节点的变更情况
- 释放锁:以下两种情况都可以让锁释放
当前获得锁的客户端发生宕机或异常,那么Zookeeper上这个临时节点就会被删除.
正常执行完业务逻辑,客户端主动删除自己创建的临时节点.
基于Zookeeper实现排他锁流程:
2) 共享锁
共享锁,又称读锁。如果事务T1对数据对象O1加上了共享锁,那么当前事务只能对O1进行读取操作,其他事务也只能对这个数据对象加共享锁,直到该数据对象上的所有共享锁都被释放。
共享锁与排他锁的区别在于,加了排他锁之后,数据对象只对当前事务可见,而加了共享锁之后,数据对象对所有事务都可见。
- 定义锁:通过Zookeeper上的数据节点来表示一个锁,是一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点
- 获取锁:客户端通过调用 create 方法创建表示锁的临时顺序节点,如果是读请求,则创建 /lockpath/[hostname]-R-序号 节点,如果是写请求则创建 /lockpath/[hostname]-W-序号节点
- 判断读写顺序:大概分为4个步骤
1. 创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听
2. 确定自己的节点序号在所有子节点中的顺序
3.1 对于读请求:1. 如果没有比自己序号更小的子节点,或者比自己序号小的子节点都是读请求,那么表明自己已经成功获取到了共享锁,同时开始执行读取逻辑 2. 如果有比自己序号小的子节点有写请求,那么等待
3.2 对于写请求,如果自己不是序号最小的节点,那么等待
4. 接收到Watcher通知后,重复步骤1) - 释放锁:与排他锁逻辑一致.
基于Zookeeper实现共享锁流程
3) 羊群效应
在实现共享锁的 “判断读写顺序” 的第1个步骤是:创建完节点后,获取 /lockpath 节点下的所有子节点,并对该节点注册子节点变更的Watcher监听。
这样的话,任何一次客户端移除共享锁之后,Zookeeper将会发送子节点变更的Watcher通知给所有机器,系统中将有大量的 “Watcher通知” 和 “子节点列表获取” 这个操作重复执行,然后所有节点再判断自己是否是序号最小的节点(写请求)或者判断比自己序号小的子节点是否都是读请求(读请求),从而继续等待下一次通知。
然而,这些重复操作很多都是 “无用的”,实际上每个锁竞争者只需要关注序号比自己小的那个节点是否存在即可。
当集群规模比较大时,这些 “无用的” 操作不仅会对Zookeeper造成巨大的性能影响和网络冲击,更为严重的是,如果同一时间有多个客户端释放了共享锁,Zookeeper服务器就会在短时间内向其余客户端发送大量的事件通知–这就是所谓的 “羊群效应”。
改进后的分布式锁实现:
-
客户端调用 create 方法创建一个类似于 /lockpath/[hostname]-请求类型-序号 的临时顺序节点。
-
客户端调用 getChildren 方法获取所有已经创建的子节点列表(这里不注册任何Watcher)。
-
如果无法获取任何共享锁,那么调用 exist 来对比自己小的那个节点注册Watcher
读请求:向比自己序号小的最后一个写请求节点注册Watcher监听
写请求:向比自己序号小的最后一个节点注册Watcher监听 -
等待Watcher监听,继续进入第二个步骤.
Zookeeper羊群效应改进前后Watcher监听图: