现在的面试,动不动就微服务、分布式、高并发、缓存、并发编程等,不管用不用得到,你反正得会才行,分布式锁也算是很重要的一块,之前我在 Github 的 issues 中写过,现在单独摘出来再总结下,因为真的问的太多了。
分布式锁,是控制分布式系统之间同步访问共享资源的一种方式。
在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。
传统实现分布式锁的方案一般是利用持久化数据库(如利用 InnoDB 行锁,或事务、乐观锁),大部分时候可以满足大部分人的需求。
而如今互联网应用的量级已经几何级别的爆发,利用诸如 zookeeper、redis 等更高效的分布式组件来实现分布式锁,可以提供高可用的更强壮的锁特性,并且支持丰富化的使用场景。
开源实现已有不少比如 Redis 作者基于 Redis 设计的 Redlock、Redission 等。
常见的分布式锁的实现:
- Memcached 分布式锁
利用 Memcached 的 add 命令。此命令是原子性操作,只有在 key 不存在的情况下,才能 add 成功,也就意味着线程得到了锁。 - Redis 分布式锁
和 Memcached 的方式类似,利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。(setnx 命令并不完善,后续可能会介绍替代方案) - Zookeeper 分布式锁
利用 Zookeeper 的顺序临时节点,来实现分布式锁和等待队列。Zookeeper 设计的初衷,就是为了实现分布式锁服务的。 - Chubby
Google 公司实现的粗粒度分布式锁服务,底层利用 Paxos 一致性算法。 - Etcd
后起之秀,从读写性能、可靠性、可用性、安全性和复杂度等方面综合考量,它完全媲美业界 “名宿” ZooKeeper,在有些方面,Etcd 甚至超越了 ZooKeeper。
这里也就说说他们实现的原理,具体的代码并不会完整的贴出来。
Memcached实现
Memcached 是一个自由开源的,高性能,分布式内存对象缓存系统。
Memcached 是一种基于内存的 key-value 存储,用来存储小块的任意数据(字符串、对象)。这些数据可以是数据库调用、API 调用或者是页面渲染的结果。
Memcached 简洁而强大。它的简洁设计便于快速开发,减轻开发难度,解决了大数据量缓存的很多问题。它的 API 兼容大部分流行的开发语言。本质上,它是一个简洁的 key-value 存储系统。
一般的使用目的是,通过缓存数据库查询结果,减少数据库访问次数,以提高动态Web应用的速度、提高可扩展性。
分布式锁也就是用了 add 操作原子性的特点,用伪代码表示:
1 | if (mc.Add("LockKey", "Value", expiredTime)){ |
不过,现在大部分都用 Redis 来搞了。
与Redis比较
看到这里就不得不说它和 Redis 的区别了:
- Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash, 有序&无序列表 数据结构的存储。
- Redis支持数据的备份,即 master-slave 模式的数据备份。
- Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用。
- Redis 可以实现主从复制,实现故障恢复。
- Redis 的 Sharding 技术: 很容易将数据分布到多个 Redis 实例中
- Redis 支持服务器端的数据操作
- 使用简单的 key-value 存储的话,Memcached 的内存利用率更高,而如果 Redis 采用 hash 结构来做 key-value 存储,由于其组合式的压缩,其内存利用率会高于 Memcached。
- 由于 Redis 只使用单核,而 Memcached 可以使用多核,单实例吞吐量极高,可以达到几十万 QPS,但是平均每一个核上 Redis 在存储小数据时比 Memcached 性能更高。
- Memcached 是多线程,分为监听线程、worker 线程,引入锁,带来了性能损耗。Redis 使用单线程的 IO 复用模型,将速度优势发挥到最大,也提供了较简单的计算功能 。
Redis 中有不少好用的命令,例如 getset,比先 get 然后再 set 来回的网络开销不知道好了多少倍。
不过还是要根据实际情况来选择使用。
为什么 Redis 采用单核单线程?
因为 CPU 不是 Redis 的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽。
既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。
PS:普通笔记本轻松处理每秒几十万的请求如果万一 CPU 成为你的 Redis 瓶颈了,或者,你就是不想让服务器其他核闲置,那怎么办?
那也很简单,你多起几个 Redis 进程就好了。Redis 是 key-value 数据库,又不是关系数据库,数据之间没有约束。只要客户端分清哪些 key 放在哪个 Redis 进程上就可以了。
redis-cluster 可以帮你做的更好。
Redis实现
参见笔记地址:MyRecord
使用 Redis 实现分布式锁首先要先知道几个 Redis 的命令,分布式锁就是通过这几个命令来实现的
- setnx
只有不存在的时候,setnx 才会设置值成功;
可以理解为是否存在和设置值这两条命令的集合版,不过是原子性的。 - getset
先 get 再 set,也是两条命令的整合,具有原子性。 - expire
设置有效期 - del
删除
实现原理-流程
首先使用 setnx 存入一个值,key 为锁名,val 为当前的时间戳加一个超时时间,这是为了防止死锁。
仔细看这个架构好像有点问题,因为我们设置的 val 根本没用,也没有任何的防死锁措施,只是实现比较简单而已,更完善的第二版在这:
当获取锁失败时,为了防止死锁,我们还需要进行一些判断,只要判定时间已经超时,就可以认为可以尝试去得到锁,然后接下来判断新的值写进去了没,只有新的时间戳写进去了才能认为是得到锁了,这样基本就不会出现死锁的情况了,下面来看看具体的代码。
第一版
按照有瑕疵的第一张流程实现:
1 | public void closeOrderTaskV1(){ |
很显然,这个防不了死锁,我们设置的超时时间也没用到,当执行到 closeOrder 方法之前宕掉的话,那么因为这个 key 没有设置有效期,就会到期其他模块一直进不去。
closeOrder 中的设置有效期和执行后的删除键(释放锁)也是双重防死锁,这个有效期需要根据线上运行的实际情况来得出一个合理的时间。
第二版
循序渐进,来看看如何解决死锁问题:
1 | "0 */1 * * * ?") (cron= |
这样看上去基本就是万无一失了,前半段并不需要修改,我们在 else 后做了一个超时判断,来觉得是否可以重置锁,这个判断可是不简单呢。
首先通过 get 方法来获取 val,用这个 val 和当前时间的时间戳来判断是否超时,然后我们使用 getset 方法重新获取老值,并且重新设置超时时间(原子操作);
根据返回的旧值,判断是否可以获取锁,这里会有三种情况:
- 当 key 没有旧值时,即 key 不存在时,返回 nil 对应 Java 中的 Null 这说明其他分布式程序已经执行完使用 del 删除了键(释放了锁)或者过了 Redis 的生存时间; 这时可以安全获取锁。
- 当 key 有旧值,并且旧值和之前获取的一致的情况下 这说明这段时间没有程序操作这把锁,并且因为 getset 之后重新设置了有效期,可以保证现在也是安全的,可以获取锁。
- 当 key 有旧值,并且旧值和之前获取的不一致的情况下 这说明在程序执行期间有其他的分布式模块也操作了这把锁,并且对方比较快,先执行了 getset 这就导致两个旧值对不起来,这种情况下只能放弃,等待下次获取。
使用Redisson
先来看看基本的介绍:
Redisson 是架设在 Redis 基础上的一个 Java 驻内存数据网格(In-Memory Data Grid)。
充分的利用了 Redis 键值数据库提供的一系列优势,基于 Java 实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间的协作。
Redisson 采用了基于 NIO 的 Netty 框架,不仅能作为 Redis 底层驱动客户端,具备提供对 Redis 各种组态形式的连接功能,对 Redis 命令能以同步发送、异步形式发送、异步流形式发送或管道形式发送的功能,LUA 脚本执行处理,以及处理返回结果的功能,还在此基础上融入了更高级的应用方案。
Redisson 生而具有的高性能,分布式特性和丰富的结构等特点恰巧与 Tomcat 这类服务程序对会话管理器(Session Manager)的要求相吻合。利用这样的特点,Redisson 专门为 Tomcat 提供了会话管理器(Tomcat Session Manager)。
在此不难看出,Redisson 同其他 Redis Java 客户端有着很大的区别,相比之下其他客户端提供的功能还仅仅停留在作为数据库驱动层面上,比如仅针对 Redis 提供连接方式,发送命令和处理返回结果等。像上面这些高层次的应用则只能依靠使用者自行实现。
可以看出 Redisson 对分布式一些工具做了很好的封装,如今分布式盛行的年代下,越来越多的项目使用 Redisson 作为 Redis 的客户端,使用它可以更方便的使用 Redis 分布式锁,来看第三版:
1 | public void closeOrderTaskV3(){ |
这段代码中使用了 Redisson 提供的 RLock 对象来获取、释放锁,这其实是一种可重入锁,Redisson 还提供了其他的多种锁,就不多说了;用这个来实现分布式锁原理其实是一样的,只不过被 Redisson 封装后更加的简单了。
使用 RLock 的 tryLock 方法来尝试获取锁,可以使用三个参数的构造,第一个是最多等待时间(超时就直接过了),第二个是自动解锁时间,第三个是时间单位。
这里的等待时间如果预估不准可以写 0,否则就会出现同时获得锁的情况,也就是程序执行的太快,还没超过等待时间所以又被第二个拿到了。
其他
另外,关掉 Tomcat 的时候如果你不是直接 kill 掉,而是温柔的杀死他,使用 shutdown,那么可以使用这个注解来保证在它死之前执行 del 删除锁来避免死锁,虽然这很不现实,如果方法执行时间过长很多人也不能忍受。
1 |
|
还有类似的好用注解,例如 @PostConstruct 标注 init 方法,会在构造完成后执行这个初始化。
数据库实现分布式锁
常见的实现方式又分两种,但总的来说并不常用,因为用数据库的话比较费资源,效率也不高:
- 完全基于数据库表的
- 基于数据库排它锁
参见:http://www.hollischuang.com/archives/1716
基于数据库表
要实现分布式锁,最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。
当我们要锁住某个方法或资源时,我们就在该表中增加一条记录,想要释放锁的时候就删除这条记录。
创建这样一张数据库表:
1 | CREATE TABLE `methodLock` ( |
当我们想要锁住某个方法时,执行以下SQL:
1 | insert into methodLock(method_name,desc) values (‘method_name’,‘desc’) |
因为我们对method_name
做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕之后,想要释放锁的话,需要执行以下Sql:
1 | delete from methodLock where method_name ='method_name' |
上面这种简单的实现有以下几个问题:
- 这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。
- 这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 这把锁只能是非阻塞的,因为数据的 insert 操作,一旦插入失败就会直接报错。
没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。 - 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
当然,我们也可以有其他方式解决上面的问题。
- 数据库是单点?
搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。 - 没有失效时间?
只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。 - 非阻塞的?
搞一个 while 循环,直到 insert 成功再返回成功。 - 非重入的?
在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
基于数据库排他锁
除了可以通过增删操作数据表中的记录以外,其实还可以借助数据中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于 MySql 的 InnoDB 引擎,可以使用以下方法来实现加锁操作:
1 | public boolean lock(){ |
在查询语句后面增加for update
,数据库会在查询过程中给数据库表增加排他锁。
这里再多提一句,InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。
这里我们希望使用行级锁,就要给 method_name 添加索引,值得注意的是,这个索引一定要创建成唯一索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上。
当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。
我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:
1 | public void unlock(){ |
通过connection.commit()
操作来释放锁。
这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。
- 阻塞锁?
for update
语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。 - 锁定之后服务宕机,无法释放?
使用这种方式,服务宕机之后数据库会自己把锁释放掉。
但是还是无法直接解决数据库单点和可重入问题。
这里还可能存在另外一个问题,虽然我们对 method_name 使用了唯一索引,并且显示使用
for update
来使用行级锁。
但是,MySql 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。如果发生这种情况就悲剧了。。。
还有一个问题,就是我们要使用排他锁来进行分布式锁的 lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆
总结
总结一下使用数据库来实现分布式锁的方式,这两种方式都是依赖数据库的一张表,一种是通过表中的记录的存在情况确定当前是否有锁存在,另外一种是通过数据库的排他锁来实现分布式锁。
- 优点
直接借助数据库,容易理解。 - 缺点
会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
使用数据库的行级锁并不一定靠谱,尤其是当我们的锁表并不大的时候。
关于其他的各种锁,参加 issues,整理中…
Zookeeper实现
基于 zookeeper 临时有序节点可以实现的分布式锁。
大致思想即为:每个客户端对某个方法加锁时,在 zookeeper 上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点,操作完成后断开自动删除。
判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个(非阻塞情况下,直接判断有没有节点就好了)。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
来看下 Zookeeper 能不能解决前面提到的问题。
- 锁无法释放?
使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 ZK 中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session 连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。 - 只能是非阻塞锁?
使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在 ZK 中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。 - 不可重入?
使用 Zookeeper 也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。
如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。 - 单点问题?
使用 Zookeeper 可以有效的解决单点问题,ZK 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
可以直接使用 zookeeper 第三方库 Curator 客户端,这个客户端中封装了一个可重入的锁服务。
1 | public boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException { |
Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire 方法用户获取锁,release 方法用于释放锁。
需要注意的问题
使用 ZK 实现的分布式锁好像完全符合了我们对一个分布式锁的所有期望。但是,其实并不是,Zookeeper 实现的分布式锁其实存在一个缺点,那就是性能上可能并没有缓存服务那么高。
因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同不到所有的 Follower 机器上。
其实,使用 Zookeeper 也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可能和 ZK 集群的 session 连接断了,那么 zk 以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。
就可能产生并发问题。这个问题不常见是因为 zk 有重试机制,一旦 zk 集群检测不到客户端的心跳,就会重试, Curator 客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。
所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。
总结
- 优点
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。
实现起来较为简单。 - 缺点
性能上不如使用缓存实现分布式锁。
需要对 ZK 的原理有所了解。
方案比较
上面几种方式,哪种方式都无法做到完美。就像 CAP 一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。
- 从理解的难易程度角度(从低到高)
数据库 > 缓存 > Zookeeper - 从实现的复杂性角度(从低到高)
Zookeeper >= 缓存 > 数据库 - 从性能角度(从高到低)
缓存 > Zookeeper >= 数据库 - 从可靠性角度(从高到低)
Zookeeper > 缓存 > 数据库
目前来说,一提到分布式锁很多人第一反应就是 Redis,但是分布式锁本质是一个 CP 需求,基于 Redis 的实现的是一个 AP 需求,不过脱离业务场景来谈架构都是耍流氓。
例如,业务是金融交易这种需要强锁的情况下,Redis 就不太行了,需要 CP 的实现,例如 etcd 等。
一个分布式计算系统来说,不可能同时满足以下三点:
- 一致性(Consistency)
等同于所有节点访问同一份最新的数据副本- 可用性(Availability)
每次请求都能获取到非错的响应,但是不保证获取的数据为最新数据- 分区容错性(Partition tolerance)
以实际效果而言,分区相当于对通信的时限要求。
系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在 C 和 A 之间做出选择。根据定理,分布式系统只能满足三项中的两项而不可能满足全部三项。
参考
其他没有说到的就自己搜索探寻吧!
评论框加载失败,无法访问 Disqus
你可能需要魔法上网~~