likes
comments
collection
share

浅谈分布式锁的实现原理

作者站长头像
站长
· 阅读数 11

1、前言

最近在做项目的过程中用到了分布式锁,然后仔细调研了一波市场上常用的分布式锁,顺便借此机会总结一下分布式锁的原理和实现。

2、什么是分布式锁

为了防止分布式系统中的多个进程之间相互干扰,我们需要一种分布式协调技术来对这些进程进行调度。而这个分布式协调技术的核心就是来实现这个分布式锁

3、分布式锁应该具备哪些特点

  • 互斥性:锁的目的是获取资源的使用权,所以只让一个竞争者持有锁,这一点要尽可能保证;

  • 安全性:避免死锁情况发生。当一个竞争者在持有锁期间内,由于意外崩溃而导致未能主动解锁,其持有的锁也能够被正常释放,并保证后续其它竞争者也能加锁;

  • 对称性:同一个锁,加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了,这又称为锁的可重入性。

  • 可靠性:需要有一定程度的异常处理能力、容灾能力。

4、分布式锁的实现方式

分布式场景中的数据一致性问题一直是一个比较重要的话题,分布式的CAP理论告诉我们任何一个分布式系统都很难同时满足高可用性和强一致性,所以鱼和熊掌不可兼得,我们往往需要在二者之间做取舍。

目前主流的实现分布式锁的方法有如下三种:

1、基于MySQL实现

MySQL的for update语句可以生成一个悲观锁利用这个悲观锁来实现分布式锁,也可以基于乐观锁来实现。

2、基于Redis实现

这是目前市场上最主流的分布式锁的实现方式之一。利用 Redis 的 setnx 命令。此命令同样是原子性操作,只有在 key 不存在的情况下,才能 set 成功。

3、基于zookeeper实现

zookeeper实现的分布式锁很健壮,一致性很高。是基于zookeeper的顺序临时节点来实现的。

下面就来详细分析一下各种分布式锁的优缺点:

4.1 基于MySQL实现

MySQL是存储在磁盘上的,用它自身来实现分布式锁在性能上是比不上Redis和zookeeper的,所以也不经常使用。 MySQL实现的分布式锁适用于那种对于性能要求不高,并发请求少,而且不希望因为分布式锁引入第三方组件导致系统变复杂的场景

基于MySQL实现分布式锁的方式比较简单,就是在事务中在select语句之后使用for update查询语句,然后生成一个排他锁,防止其他事务访问该行数据。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁,我们可以认为获得排它锁的线程即可获得分布式锁。最后对该行数据进行读写完成之后提交事务,提交事务之后就相当于释放了该锁。

优点:

实现简单,不用引入第三方组件。

缺点:

容易引起交叉死锁:当事务1占据了记录1的锁,事务2占据了记录2的锁,此时事务1 又要去占记录2的锁,事务2又要去占记录1的锁,这个时候就会出现互相等待的情况,造成死锁。

解决死锁的方式可以是设置一个等待时间,如果等待锁的时间超时了,就要释放自己占用的锁,防止死锁的发送。

但在高并发情况下,出现的大部分请求都会排队等待,所以“基于关系型数据库实现分布式锁”的方式在性能上存在缺陷。

基于乐观锁实现:

如果在并发请求不高的情况下,可以使用乐观锁来实现分布式锁。

因为使用for update关键字生成的是悲观锁,会一直占据该锁,一直阻塞到事务的提交,很影响性能。所以可以在表中加一个int型的版本号ver字段,用来记录比较记录的版本号。在select的时候同时获取ver版本号,然后在update的时候比较此时的版本号是否与之前获取行记录的时候的版本号一致,如果一致就更新数据,否则就放弃操作。

浅谈分布式锁的实现原理

4.2 基于Redis实现

因为Redis是在内存里的,不用访问硬盘,减少了很多IO读取的次数,所以性能会好很多。

实现分布式锁的方式其实就是用set命令设置key和value,key就是锁的名称,value就是owner,表明该锁的所有者的id。为了实现锁的排他性,要在后面加上NX关键字,表示一旦该key的值已经存在了就不能再设置新值了,否则就可以占据该锁。为了防止获取锁的客户端拿到锁之后宕机一直不释放锁,要在后面加上ex命令,设置过期时间。如果在规定时间之内还不释放锁的话,锁就会过期。

比如: SET lock_key unique_value NX PX 10000

  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

最后解锁的时候,要先检查锁是不是自己的,如果是的话再释放,不是的话就不能释放。但是这里就有一个问题:如果检查的时候锁是自己的,但是释放的时候就不是自己的了,就会误删别人的锁。导致这个问题的原因就是检查锁和释放锁不是原子性的要保证操作的原子性,就需要用事务,Redis里用lua脚本来实现事务

但是到这里,还是会遇到一些异常场景无法解决,比如Redis节点宕机了,这个时候就需要考虑容灾问题。

前面提到的Redis分布式锁都是基于单节点来考虑的,那么如果redis服务器宕机了,就会导致锁不能获取了。那么如何解决这个问题呢?

有两种解决思路:

1、设置主从节点,对数据进行备份。

为主节点配置从节点, 如果主节点挂了,就用从节点顶替主节点。

为了在从节点中选取主节点出来,需要通过哨兵模式来选择。,可以省去人为选择的成本。

但是这么做也有缺点,主从节点的数据有可能不一致,导致锁信息的丢失。

2、设置redis集群,进行多机部署。

更可靠的办法就是多级部署,Redis针对多级部署实现了一个分布式锁的算法:redlock。

redlock的大致思路就是,部署多个redis节点,每次要依次向每个节点申请分布式锁,只有半数以上的节点通过了请求,才算是拿到锁。一般来说,不太可能多个节点都宕机,所以这样做大大提高了分布式锁的可靠性。

但是这里要注意,redis分布式锁都加了锁的时间,如果最后获取锁的总耗时小于锁的时间的话,就算加锁成功了,否则失败。

4.3 基于Zookeeper实现

基于Zookeeper的分布式锁大致思路就是,利用zookeeper的临时顺序节点来实现。

在zookeeper里创建一个节点,然后每个客户端或者线程去获取锁的时候,就是在这个节点下面创建一个临时顺序节点。

然后拉取该节点下面的所有临时顺序节点,判断自己是不是这些临时顺序节点里序号最小的那个节点,如果是,就获取锁,如果不是,就监听它的上一个临时顺序节点。

上一个线程完成操作把锁释放之后,会删除它所对应的临时顺序节点,这个时候监听该被删除的临时顺序节点的节点就会收到通知,就回去重新获取锁。

具体过程可见:七张图彻底讲清楚ZooKeeper分布式锁的实现原理【石杉的架构笔记】 - 掘金 (juejin.cn)

5、总结

本篇文章总结了三种常见的分布式锁的实现方式,它们分别是基于MySQL加锁,基于Redis和zookeeper。

从性能角度(从高到低):

缓存 > Zookeeper > 数据库

从可靠性角度(从高到低):

Zookeeper > 缓存 > 数据库

在工作中,Redis实现的分布式锁在大多数场景下已经够用了,Redis的性能最高,适合高并发场景,虽然不能保证强一致性,但是大多数场景下也够用了。在我的项目里,我就是选择用Redis实现的分布式锁。