重庆分公司,新征程启航
为企业提供网站建设、域名注册、服务器等服务
常见的几种分布式锁的实现方案,Redis、Mysql 、zookeeper;
目前创新互联已为上千多家的企业提供了网站建设、域名、雅安服务器托管、网站托管运营、企业网站设计、沐川网站维护等服务,公司将坚持客户导向、应用为本的策略,正道将秉承"和谐、参与、激情"的文化,与客户和合作伙伴齐心协力一起成长,共同发展。
本文主要讲的是如何使用zk实现分布式锁:
具体思路:
1、首先zookeeper中我们可以创建一个/distributed_lock持久化节点
2、然后再在/distributed_lock节点下创建自己的临时顺序节点,比如:/distributed_lock/task_00000000008
3、获取所有的/distributed_lock下的所有子节点,并排序
4、判读自己创建的节点是否最小值(第一位)
5、如果是,则获取得到锁,执行自己的业务逻辑,最后删除这个临时节点。
6、如果不是最小值,则需要监听自己创建节点前一位节点的数据变化,并阻塞。
7、当前一位节点被删除时,我们需要通过递归来判断自己创建的节点是否在是最小的,如果是则执行5);如果不是则执行6)(就是递归循环的判断)
主要包括了zk客户端的依赖,mybatis-plus的依赖。
分布式锁工具类
具体业务实现
至此一个简单的分布式锁的demo已经实现。
在分布式系统中,为了保证对数据的修改有最终一致性,通常使用分布式锁或者分布式事务。比如常见的多个系统同时修改商品,既依赖于现有数据也要修改数据,如果没有限制,高并发情况下很可能最终数据是错误的。
与单机锁不同,分布式锁更加复杂,需要考虑网络延迟、服务阻塞等,通常具有如下特点:
利用数据库主键唯一的特性,可以基于唯一主键保证多次操作只有一次成功。在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。释放锁时,直接删除数据库记录即可。
此方案存在的问题是强依赖数据库,容易形成热点,数据库锁表导致的超时会影响性能,或者数据库宕机会导致服务不可用。并且,数据库本身没有失效机制,如果任务崩溃会导致数据库中的锁不能被释放。数据库插入操作本身没有阻塞机制,故无法实现分布式锁的阻塞等待,任务线程可能需要重复尝试插入。由于唯一主键的存在,持有锁的线程也无法重复获得锁,其他线程竞争锁的过程中也无法根据优先级进行分配。
在数据库中为表增加一个版本号字段,每次操作时判断版本号,只有版本号一致才能进行对应的修改,修改后版本号加 1,通过 CAS 的方式进行修改。
此实现会增加数据库操作的次数,高并发情况下可能性能不好。
for update是一种行级锁,又叫排它锁,一旦用户对某个行施加了行级加锁,则该用户可以查询也可以更新被加锁的数据行,其它用户只能查询但不能更新被加锁的数据行。我们可以认为获得排他锁的线程即获得分布式锁,任务执行完成后通过 commit 来释放锁。for update 语句会在执行成功后立即返回,在执行失败时一直处于阻塞状态,直到成功。
但是 MySQL 会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁。
setnx 的含义就是 SET if Not Exists,主要有两个参数 setnx(key, value)。该方法是原子的,如果 key 不存在,则设置当前 key 成功,返回 1;如果当前 key 已经存在,则设置当前 key 失败,返回 0。setnx 命令不能设置 key 的超时时间,只能通过 expire() 来设置。
锁的实现步骤:
这个方案如果在第一步 setnx 执行成功后,在 expire() 命令执行成功前,发生了宕机的现象,那么就依然会出现死锁的问题。
这个方案是对上一个方案的优化版本。
getset() 命令主要有两个参数 getset(key,newValue)。该方法是原子的,对 key 设置 newValue 这个值,并且返回 key 原来的旧值。假设 key 原来是不存在的,那么首次执行的返回值是 null。
锁的实现步骤:
这个方案在任务处理超时或发生宕机时,无需担心锁超时问题,下次请求可以判断出实际上锁已经超时了。
zookeeper 由多个节点构成(单数),采用 zab 一致性协议。因此可以将 zk 看成一个单点结构,对其修改数据其内部自动将所有节点数据进行修改而后才提供查询服务。
zookeeper 数据是目录树的形式,每个目录称为 znode, znode 中可存储数据(一般不超过 1M),还可以在其中增加子节点。
子节点有三种类型。
zookeeper 提供了 Watch 机制,client 可以监控每个节点的变化,当产生变化会给 client 产生一个事件。
可以利用临时节点与 watch 机制实现分布式锁。每个锁占用一个普通节点 /lock,当需要获取锁时在 /lock 目录下创建一个临时节点,创建成功则表示获取锁成功,失败则 watch/lock 节点,有删除操作后再去争锁。临时节点好处在于当进程挂掉后锁的节点自动删除不会发生死锁。
缺点在于所有取锁失败的进程都监听父节点,很容易发生羊群效应,即当释放锁后所有等待进程一起来创建节点,并发量很大。
一个可行的优化方案是上锁改为创建临时有序节点,每个上锁的节点均能创建节点成功,只是其序号不同。只有序号最小的可以拥有锁,如果这个节点序号不是最小的则 watch 序号比本身小的前一个节点 (公平锁)。watch 事件到来后,再次判断是否序号最小。取锁成功则执行代码,最后释放锁(删除该节点)。
性能上可能没有缓存服务那么高,因为每次在创建锁和释放锁的过程中,都要动态创建、销毁临时节点来实现锁功能。zookeeper 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同步到所有的 Follower 机器上。
分布式锁比较复杂,也比较容易发生死锁。目前主流的实现方式包括:
分布式锁及其常见实现方式 - 程序之心
与分布式锁对应的是【单机锁】,我们在写多线程程序时,避免同时操作一个共享变量而产生数据问题,通常会使用一把锁来实现【互斥】,其使用范围是在【同一个进程中】。(同一个进程内存是共享的,以争抢同一段内存,来判断是否抢到锁)。
如果是多个进程,如何互斥呢。就要引入【分布式锁】来解决这个问题。想要实现分布式锁,必须借助一个外部系统,所有进程都去这个系统上去【申请加锁】。
而这个外部系统,必须要实现【互斥】的能力,即两个请求同时进来,只会给一个进程返回成功,另一个返回失败(或等待)。
这个外部系统,可以是 MySQL,也可以是 Redis 或 Zookeeper。但为了追求更好的性能,我们通常会选择使用 Redis 或 Zookeeper 来做。
依赖mysql的行锁 select for update。
一个特例,唯一索引。唯一索引是找到了就直接停止遍历,非唯一索引还会向后遍历一行。移步第八个case。
现在的索引:
分析:
单条命中只会加行锁,不加间隙锁。所以RC/RR是一样的。
事务1对id=10这条记录加行锁。所以 场景1会锁等待,场景2不会锁等待 。
分析:
RC隔离级别:
事务1未命中,不会加任何锁。所以 场景1,场景2,场景3都不会锁等待 。
RR隔离级别:
事务1未命中,会加间隙锁。因为主键查询,只会对主键加锁。
在10和18加间隙锁。
间隙锁和查询不冲突。 场景1不会锁等待 。
间隙锁和插入冲突。 场景2和场景3会锁等待 。
分析
单条命中只会加行锁,不加间隙锁。所以RC/RR是一样的。
事务1对二级索引和主键索引加行锁。 事务1和事务2都会发生锁等待 。
分析
RC隔离级别:
事务1未命中,不会加任何锁。所以 场景1,场景2,场景3都不会锁等待 。
RR隔离级别:
事务1对二级索引N0007到正无穷上间隙锁,主键索引不上锁。 场景1会锁等待,场景2不会锁等待 。
分析:
RC隔离级别:
只会加行锁。 场景1场景2会锁等待 。 场景3不会发生锁等待 。
RR隔离级别:
会加行锁和间隙锁。 场景1场景2场景3都会锁等待 。
ps: 如果是唯一索引,只会加行锁。非唯一才会加间隙锁。
RC隔离级别:
事务1未命中,不会加任何锁。所以 场景1,场景2都不会锁等待 。
RR隔离级别:
事务1未命中,会加间隙锁。间隙锁与查询不冲突, 场景1不会发生锁等待 。 场景2会发生锁等待 。
分析
RC隔离级别:
事务1加了三个行锁。 场景1会锁等待。场景2,场景3不会发生锁等待 。
RR隔离级别:
事务1加个三个行锁和间隙锁。 场景1,场景3会发生锁等待 。间隙锁与查询不冲突, 场景2不会锁等待。
分析:
RC隔离级别:
事务1加的都是行锁。 场景1会发生锁等待 , 场景2,场景3不会发生锁等待 。
RR隔离级别:
事务1会对二级索引加行锁和间隙锁,对主键索引加行锁。
场景1,场景3会发生锁等待 。间隙锁与查询不冲突, 场景2不会锁等待。
这么看,二级索引和唯一索引没什么区别。
那如果是 select * from book where name 'Jim' for update; 呢
如果name是唯一索引。因为找到jim就不会向后遍历了,所以jim和rose之间不会有间隙锁。
分析:
RC隔离级别:
由于没有走索引,所以只能全表扫描。在命中的主键索引上加行锁。 场景1会锁等待,场景2不会锁等待 。
RR隔离级别:
不开启innodb_locks_unsafe_for_binlog。 会发生锁表 。
开启innodb_locks_unsafe_for_binlog。和RC隔离级别一样。
RC隔离级别:
未命中不加锁。 场景1,场景2都不会锁等待 。
RR隔离级别:
未命中, 锁表 。
在RR隔离级别下,where条件没有索引,都会锁表。
加锁命令:
释放锁命令:
这里存在问题,当释放锁之前异常退出了。这个锁就永远不会被释放了。
怎么解决呢?加一个超时时间。
还有问题,不是原子操作。
redis 2.6.12之后,redis天然支持了
来看一下还有什么问题:
试想这样一个场景
看到了么,这里存在两个严重的问题:
释放别人的锁 :客户端 1 操作共享资源完成后,却又释放了客户端 2 的锁
锁过期 :客户端 1 操作共享资源耗时太久,导致锁被自动释放,之后被客户端 2 持有
解决办法是:客户端在加锁时,设置一个只有自己知道的【唯一标识】进去。
例如,可以是自己的线程id,也可以是一个uuid
在释放锁时,可以这么写:
问题来了,还不是原子的。redis没有原生命令了。这里需要使用lua脚本
锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险,一般的妥协方案是,尽量「冗余」过期时间,降低锁提前过期的概率。
其实可以有比较好的方案:
加锁时,先设置一个过期时间,然后我们开启一个「守护****线程****」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
这个守护线程我们一般把他叫做【看门狗】线程。
我们在使用 Redis 时,一般会采用 主从集群 + 哨兵 的模式部署,这样做的好处在于,当主库异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证可用性。
试想这样的场景:
为此,Redis 的作者提出一种解决方案,就是我们经常听到的 Redlock(红锁) 。
现在我们来看,Redis 作者提出的 Redlock 方案,是如何解决主从切换后,锁失效问题的。
Redlock 的方案基于 2 个前提:
也就是说,想用使用 Redlock,你至少要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
Redlock 具体如何使用呢?
整体的流程是这样的,一共分为 5 步:
有 4 个重点:
1) 为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩余的实例加锁成功,整个锁服务依旧可用。
2) 为什么大多数加锁成功,才算成功?
多个 Redis 实例一起来用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以,在谈论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是: 如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正确服务的。
3) 为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个节点,所以耗时肯定会比操作单个实例耗时更久,而且,因为是网络请求,网络情况是复杂的,有可能存在 延迟、丢包、超时 等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
4) 为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,网络问题导致 读取失败 ,那这把锁其实已经在 Redis 上加锁成功了。
所以,释放锁时,不管之前有没有加锁成功,需要释放「所有节点」的锁,以保证清理节点上「残留」的锁。
好了,明白了 Redlock 的流程和相关问题,看似 Redlock 确实解决了 Redis 节点异常宕机锁失效的问题,保证了锁的「安全性」。
在martin的文章中,主要阐述了4个论点:
第一:效率 。
使用分布式锁的互斥能力,是避免不必要地做同样的工作两次。如果锁失效,并不会带来「恶性」的后果,例如发了 2 次邮件等,无伤大雅。
第二:正确性 。
使用锁用来防止并发进程相互干扰。如果锁失败,会造成多个进程同时操作同一条数据,产生的后果是数据严重错误、永久性不一致、数据丢失等恶性问题,后果严重。
他认为,如果你是为了前者——效率,那么使用单机版redis就可以了,即使偶尔发生锁失效(宕机、主从切换),都不会产生严重的后果。而使用redlock太重了,没必要。
而如果是为了正确性,他认为redlock根本达不到安全性的要求,也依旧存在锁失效的问题!
一个分布式系统,存在着你想不到的各种异常。这些异常场景主要包括三大块,这也是分布式系统会遇到的三座大山: NPC
martin用一个进程暂停(GC)的例子,指出了redlock安全性的问题:
又或者,当多个Redis节点时钟发生了问题时,也会导致redlock锁失效。
在混乱的分布式系统中,你不能假设系统时钟就是对的。
个人理解,相当于在业务层再做一层乐观锁。
一个好的分布式锁,无论 NPC 怎么发生,可以不在规定时间内给出结果,但并不会给出一个错误的结果。也就是只会影响到锁的「性能」(或称之为活性),而不会影响它的「正确性」。
1、Redlock 不伦不类 :它对于效率来讲,Redlock 比较重,没必要这么做,而对于正确性来说,Redlock 是不够安全的。
2、时钟假设不合理 :该算法对系统时钟做出了危险的假设(假设多个节点机器时钟都是一致的),如果不满足这些假设,锁就会失效。
3、无法保证正确性 :Redlock 不能提供类似 fencing token 的方案,所以解决不了正确性的问题。为了正确性,请使用有「共识系统」的软件,例如 Zookeeper。
好了,以上就是 Martin 反对使用 Redlock 的观点,看起来有理有据。
下面我们来看 Redis 作者 Antirez 是如何反驳的。
首先,Redis 作者一眼就看穿了对方提出的最为核心的问题: 时钟问题 。
Redis 作者表示,Redlock 并不需要完全一致的时钟,只需要大体一致就可以了,允许有「误差」。
例如要计时 5s,但实际可能记了 4.5s,之后又记了 5.5s,有一定误差,但只要不超过「误差范围」锁失效时间即可,这种对于时钟的精度的要求并不是很高,而且这也符合现实环境。
对于对方提到的「时钟修改」问题,Redis 作者反驳到:
Redis 作者继续论述,如果对方认为,发生网络延迟、进程 GC 是在客户端确认拿到了锁,去操作共享资源的途中发生了问题,导致锁失效,那这 不止是 Redlock 的问题,任何其它锁服务例如 Zookeeper,都有类似的问题,这不在讨论范畴内 。
这里我举个例子解释一下这个问题:
Redis 作者这里的结论就是:
所以,Redis 作者认为 Redlock 在保证时钟正确的基础上,是可以保证正确性的。
这个方案必须要求要操作的「共享资源服务器」有拒绝「旧 token」的能力。
例如,要操作 MySQL,从锁服务拿到一个递增数字的 token,然后客户端要带着这个 token 去改 MySQL 的某一行,这就需要利用 MySQL 的「事物隔离性」来做。
但如果操作的不是 MySQL 呢?例如向磁盘上写一个文件,或发起一个 HTTP 请求,那这个方案就无能为力了,这对要操作的资源服务器,提出了更高的要求。
也就是说,大部分要操作的资源服务器,都是没有这种互斥能力的。
再者,既然资源服务器都有了「互斥」能力,那还要分布式锁干什么?
利用 zookeeper 的同级节点的唯一性特性,在需要获取排他锁时,所有的客户端试图通过调用 create() 接口,在 /exclusive_lock 节点下创建临时子节点 /exclusive_lock/lock ,最终只有一个客户端能创建成功,那么此客户端就获得了分布式锁。同时,所有没有获取到锁的客户端可以在 /exclusive_lock 节点上注册一个子节点变更的 watcher 监听事件,以便重新争取获得锁。
锁释放依赖心跳。集群中占用锁的客户端失联时,锁能够被有效释放。一旦占用Znode锁的客户端与ZooKeeper集群服务器失去联系,这个临时Znode也将自动删除
zookeeper的高可用依赖zab。简单的说就是写入时,半数follower ack,写入成功。
zk是100%安全的么:
分析一个例子:
所以,得出一个结论: 一个分布式锁,在极端情况下,不一定是安全的。
redlock运维成本也比较高。单机有高可用问题。所以还是主从+哨兵这样的部署方式会好一些。
redis的缺点是:不是100%可靠。
mysql的缺点是:扛不住高流量请求。
可以二者结合,先用redis做分布式锁,扛住大部分流量缓解mysql压力。最后一定要用mysql做兜底保证100%的正确性。
以前参加过一个库存系统,由于其业务复杂性,搞了很多个应用来支撑。这样的话一份库存数据就有可能同时有多个应用来修改库存数据。
比如说,有定时任务域xx.cron,和SystemA域和SystemB域这几个JAVA应用,可能同时修改同一份库存数据。如果不做协调的话,就会有脏数据出现。
对于跨JAVA进程的线程协调,可以借助外部环境,例如DB或者Redis。下文介绍一下如何使用DB来实现分布式锁。
本文设计的分布式锁的交互方式如下:
在使用synchronized关键字的时候,必须指定一个锁对象。
进程内的线程可以基于obj来实现同步。obj在这里可以理解为一个锁对象。如果线程要进入synchronized代码块里,必须先持有obj对象上的锁。这种锁是JAVA里面的内置锁,创建的过程是线程安全的。那么借助DB,如何保证创建锁的过程是线程安全的呢?
可以利用DB中的UNIQUE KEY特性,一旦出现了重复的key,由于UNIQUE KEY的唯一性,会抛出异常的。在JAVA里面,是 SQLIntegrityConstraintViolationException 异常。
transaction_id是事务Id,比如说,可以用
来组装一个transaction_id,表示某仓库某销售模式下的某个条码资源。不同条码,当然就有不同的transaction_id。如果有两个应用,拿着相同的transaction_id来创建锁资源的时候,只能有一个应用创建成功。
在写操作频繁的业务系统中,通常会进行分库,以降低单数据库写入的压力,并提高写操作的吞吐量。如果使用了分库,那么业务数据自然也都分配到各个数据库上了。
在这种水平切分的多数据库上使用DB分布式锁,可以自定义一个DataSouce列表。并暴露一个 getConnection(String transactionId) 方法,按照transactionId找到对应的Connection。
实现代码如下:
首先编写一个initDataSourceList方法,并利用Spring的PostConstruct注解初始化一个DataSource 列表。相关的DB配置从db.properties读取。
DataSource使用阿里的DruidDataSource。
接着最重要的一个实现getConnection(String transactionId)方法。实现原理很简单,获取transactionId的hashcode,并对DataSource的长度取模即可。
连接池列表设计好后,就可以实现往distributed_lock表插入数据了。
接下来利用DB的 select for update 特性来锁住线程。当多个线程根据相同的transactionId并发同时操作 select for update 的时候,只有一个线程能成功,其他线程都block住,直到 select for update 成功的线程使用commit操作后,block住的所有线程的其中一个线程才能开始干活。
我们在上面的DistributedLock类中创建一个lock方法。
当线程执行完任务后,必须手动的执行解锁操作,之前被锁住的线程才能继续干活。在我们上面的实现中,其实就是获取到当时 select for update 成功的线程对应的Connection,并实行commit操作即可。
那么如何获取到呢?我们可以利用ThreadLocal。首先在DistributedLock类中定义
每次调用lock方法的时候,把Connection放置到ThreadLocal里面。我们修改lock方法。
这样子,当获取到Connection后,将其设置到ThreadLocal中,如果lock方法出现异常,则将其从ThreadLocal中移除掉。
有了这几步后,我们可以来实现解锁操作了。我们在DistributedLock添加一个unlock方法。
毕竟是利用DB来实现分布式锁,对DB还是造成一定的压力。当时考虑使用DB做分布式的一个重要原因是,我们的应用是后端应用,平时流量不大的,反而关键的是要保证库存数据的正确性。对于像前端库存系统,比如添加购物车占用库存等操作,最好别使用DB来实现分布式锁了。
如果想锁住多份数据该怎么实现?比如说,某个库存操作,既要修改物理库存,又要修改虚拟库存,想锁住物理库存的同时,又锁住虚拟库存。其实也不是很难,参考lock方法,写一个multiLock方法,提供多个transactionId的入参,for循环处理就可以了。这个后续有时间再补上。
MySQL做分布式需要通过ndb的Cluster来实现。MySQLCluster是MySQL适合于分布式计算环境的高实用、高冗余版本。 实现的步骤比较复杂,百度云案例:《MySQLCluster(MySQL集群)分布式》 下载地址: