3.10:分布式锁
本文将梳理微服务架构下,分布式锁的常用方案。整体包含以下三部分:
- 分布式锁的提出
- 分布式锁主流方案
- 分布式锁选择
分布式锁的提出
Java锁概念
一说到锁,我们可能一下会有很多锁的知识往外涌。以Java为例,有JVM内置的锁机制synchroized,也有基于队列同步器实现的Lock(可重入锁和读写锁),甚至我们可以基于队列同步器自己定制一把锁。
那锁到底是什么呢,本质功能是什么呢,个人认为,锁的作用是控制在多线程场景下对同一资源的访问(读写)顺序。锁是每个线程的访问凭证,只有持有锁的线程才可以对共享资源进行访问。
再简单点来说,锁就是将多线程并发访问共享资源串行化。
要实现串行化,个人觉得需要先了解锁的三个基本要素:
- 锁:就是上述所讲的访问凭证,对synchroized来说锁一般是某个对象,对Lock来说锁就是new出来的锁对象。不管什么类型的锁,最终在锁里面都会存储当前持有锁的线程信息。
- 锁状态:标识锁当前的状态,通过锁状态的变更来决定是否可以竞争锁。
- 同步队列:所有需要来申请锁的线程,在未获得锁前,都需要存储在队列中等待参与竞争。
进程内的锁大部分都是通过上述三个基本要素来实现锁功能的,关于Java锁更详细的内容将在后续文章中单独深入了解。
分布式锁
分布式锁提出的根本原因就是,分布式架构下应用单元集群部署了。用户的请求分散到不同的进程中,这时Java锁这种进程内的锁机制就无法满足并发资源的串行化了。
了解上述原因后,我们参考Java锁的知识可以知道,要实现分布式锁主要技术点有
- 锁集中存储,分布式锁要可以被所有部署在不同单元的线程访问。
- 锁要有状态,参与竞争的线程可以通过锁的状态来确定是申请到了还是继续等待竞争。
根据上面的两点,我们就可以从了解到的已有系统哪些可以用来实现分布式锁,以及如何实现。
分布式锁主流方案
分布式锁方案
目前经常用来实现分布式锁的服务有MySQL、Redis、Zookeeper、Etcd等。考虑Mysql一般都是系统的性能瓶颈点,基本上很少有公司会使用了。
分布式锁功能
我们首先从分布式锁设计上来分析,一个分布式锁要包含哪些基本功能。
- 锁申请:提供申请锁的功能,同时为了保证不会因进程故障造成的死锁问题,在申请锁的同时需要给锁加上有效期,保证锁在异常情况下可以释放。
- 锁释放:锁释放可以分为两种,一种是业务完成后主动释放锁,另外一种是需要在锁过期后自动释放锁。
- 锁续租:为了保证耗时较长的业务功能能正常完成,需要在锁到期前可以自动续租。
分布式锁只有支持上述最基本的三个功能,才能算是健壮的。下面我们就分别使用Redis、Etcd来实现分布式锁。
Redis分布式锁实现
在用Redis来实现分布式锁过程中,一共经历了以下几个阶段,可以理解成Redis的进化过程。
第一阶段
最原始简单实现:
- 申请锁:调用setnx,原理是利用Redis的setnx能在key不存在时才保存数据,设置成功则返回true,若key已经存在则返回false。这样业务调用时根据返回结果就可以知道是否申请锁成功。
- 设置过期时间:调用expirt,申请锁成功后再调用expirt给锁设置过期时间。
- 释放锁:调用del,业务执行完后,调用del来主动删除Key,也就是释放了锁。
第一阶段的锁最大的问题就是申请锁和设置过期时间不是原子的。若申请锁成功后,服务故障或网络异常导致没有设置过期时间,这样锁就永远不会释放了。
第二阶段
基于上面方案的非原子性问题,又引入了lua脚本+Redis事务来解决原子问题。
- 编写Redis命令:开启事务->setnx命令->expirt命令->提交事务。
- 将上述Redis命令放到lua脚本里面。
- 在业务调用时通过执行Redis的EVAL来执行lua脚本来实现申请锁+设置过期时间的原子性。
这种方案相较第一种来说有一定的改进,但是因为Redis事务是不保证一致性的,也就是说lua脚本中存在部分成功部分失败。因此这种方案其实也是有较大缺点。
第三阶段
Redis在2.6以后扩展了setnx命令,支持原来的setnx功能之外同时可以设定过期时间。因此到第三阶段,基于Redis实现分布式锁就只有以下两步:
- 申请锁:调用setnx,设置成功后,命令同时提供了过期时间的参数。
- 释放锁:调用del,业务执行完后,调用del来主动删除Key,也就是释放了锁。
上面的三个阶段的实现都只是满足锁申请和锁释放两个最基本功能。在锁申请失败时,需要业务实现不断的重试功能。因此开源框架中又出现了基于上述命令的封装组件Redisson和RedLock
Redisson
Redisson在实现时,主要增加了以下改进
- 申请锁失败后,申请锁的lua脚本会返回当前锁的过期时间。
- 申请锁失败后,一先订阅锁对于的Channel,等待锁释放后通知消息;二会根据上述收到的过期时间,在到时间后再次申请锁,只要没有申请锁成功将一直重复。
- 申请锁成功后,将发布一条锁释放消息,通知所有正在申请当前锁的线程。
- Redisson还增加了可重入性,根据锁上的线程id来判断释放当前线程持有锁。
Redisson的调用还是比较简单的,操作和Jvm的Lock类似就不再累述。
RedLock
在上述所有方案中,使用的都是单点的Redis,就算是主从或者是Cluster部署,最终锁都是只存在一个节点中,在节点故障时系统可用性得不到保证。同时主从切换时若锁未同步到从库,将导致出现多个业务持有锁,造成数据不一致性。
RedLock主要解决的就是Redis单点的问题,假设部署的Redis集群数量未N,需要在(N/2)+1上加锁成功,即半数以上加锁成功,才算申请成功。相当于RedLock实现了一套基本的分布式数据一致性算法。
RedLock解决的问题,由于RedLock保证了(N/2)+1工作即可加锁成功,因此解决单点以及在主从切换时多个客户端获取锁问题。
<font color=red>
总结一下Redis实现分布式锁的问题和适用场景。基于CAP原则来分析的话,Redis本质其实是为了满足AP。因为Redis的集群模式是主从模型,在网络分区(脑裂)情况下,Redis也能保证服务的可用性(主从切换)。但是未同步到从库的数据是会丢失的,也就是数据不一致了。</font>
因此Redis锁适用于数据一致性没有强制要求的场景,其实大部分的业务场景都是满足AP即可,基于Redis的高性能,Redis锁是大部分锁的首选。
Etcd分布式锁实现
Etcd是使用分布式协议raft作为一致性算法,目标是提供高可用的分布式键值(Key-Value)数据库。相较Redis最大的优点就是Etcd是强一致性的,同时提供简单的API功能,应用可以直接通过http协议来访问。
使用Etcd实现分布式锁和Redis是基本类似的
- 申请锁:利用租约在Etcd集群创建一个Key(可设置过期时间),若Key不存在,则线程创建Key,说明申请锁成功。若Key存在,则返回创建失败。
- 释放锁:调用Etcd删除Key即可。
后续再上传使用Etcd实现分布式锁的代码。
分布式锁选择
在实际项目中,我们该怎么选择使用哪种分布式锁呢?个人总结主要从以下两个方面考虑:
- 场景是CP还是AP的,比如在支付类需要强一致性的业务就是CP场景,这时候我们应该选择使用Etcd或Zookeeper强一致性的存储系统来实现。若是AP类的场景,比如社交等,就可以采用Redis实现即可,比较Redis组件在大部分项目中已经使用,就不需要再引入额外的系统了。
- Etcd还是Zookeeper,这个就看公司选择即可,从性能上来说,Etcd会有一定的优势,单也不是量级的优势,若对Zookeeper比较熟悉了,而且技术生态已经有Zookeeper了就直接使用Zookeeper即可。