Distributed Locks
什么是分布式锁
线程锁:主要用来给方法、代码块加锁。当某个方法或代码使用锁,在同一时刻仅有一个线程执行该方法或该代码段。线程锁只在同一JVM中有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
进程锁:为了控制同一操作系统中多个进程访问某个共享资源,因为进程具有独立性,各个进程无法访问其他进程的资源,因此无法通过synchronized等线程锁实现进程锁。
分布式锁:当多个进程不在同一个系统中(比如分布式系统中控制共享资源访问),用分布式锁控制多个进程对资源的访问。
设计原则
分布式锁的最小设计原则:安全性和有效性
- 互斥(属于安全性):在任何给定时刻,只有一个客户端可以持有锁。
- 无死锁(属于有效性):即使锁定资源的客户端崩溃或被分区,也总是可以获得锁;也不会影响后续的客户端加锁。通常通过超时机制实现。也叫可重入性:同一线程多次获取锁避免死锁
- 容错性(属于有效性):只要大多数 Redis 节点都启动,客户端就可以获取和释放锁。
除此之外,分布式锁的设计中还可以/需要考虑:
- 加锁解锁的同源性:A加的锁,不能被B解锁
- 获取锁是非阻塞的:如果获取不到锁,不能无限期等待;(考虑业务是否需要阻塞)
- 高性能:加锁解锁是高性能的原子性操作,避免多次请求获得锁
实现方案
- 基于数据库实现分布式锁
- 基于数据库表(锁表,很少使用)
- 乐观锁(基于版本号)
- 悲观锁(基于排它锁)
- 基于 redis 实现分布式锁:
- 单个Redis实例:setnx(key,当前时间+过期时间) + Lua
- Redis集群模式:Redlock
- 基于 zookeeper实现分布式锁
- 临时有序节点来实现的分布式锁,Curator
- 基于 Consul 实现分布式锁
基于数据库表(锁表,很少使用)
最简单的方式可能就是直接创建一张锁表,然后通过操作该表中的数据来实现了。当我们想要获得锁的时候,就可以在该表中增加一条记录,想要释放锁的时候就删除这条记录。
为了更好的演示,我们先创建一张数据库表,参考如下:
1 | CREATE TABLE database_lock ( |
当我们想要获得锁时,可以插入一条数据:
1 | INSERT INTO database_lock(resource, description) VALUES (1, 'lock'); |
当需要释放锁的时,可以删除这条数据:
1 | DELETE FROM database_lock WHERE resource=1; |
这种实现方式存在的问题:
- 锁强依赖数据库的可用性,数据库是单点,一旦数据库挂掉,会导致业务系统不可用。
- 锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
- 锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
- 这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在。
相应的解决方法:
- 同时部署两个数据库,一台业务用,另一台做热备。
- 设置一个定时任务,每隔一定时间把数据库中的超时锁清理掉。
- 使用while重复执行。同时需要设置重试次数,防止持续拿不到锁导致服务器资源耗尽。
- 在表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果主机信息和线程信息与表中的信息吻合,直接把锁分配给该线程。
基于悲观锁
悲观锁实现思路
- 在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。 具体响应方式由开发者根据实际需要决定。
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。
- 其间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。
以MySQL InnoDB中使用悲观锁为例
要使用悲观锁,必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,也就是说,当你执行一个更新操作后,MySQL会立刻将结果进行提交。set autocommit=0;
1 | // 0.开始事务 |
上面的查询语句中,使用了select…for update
的方式,这样就通过开启排他锁的方式实现了悲观锁。此时在 t_goods 表中,id为1的 那条数据就被锁定了,其它的事务必须等本次事务提交之后才能执行。这样可以保证当前的数据不会被其它事务修改。
上面提到,使用select…for update
会把数据给锁住,不过需要注意一些锁的级别,MySQL InnoDB默认行级锁。行级锁都是基于索引的,如果一条SQL语句用不到索引是不会使用行级锁的,会使用表级锁把整张表锁住,这点需要注意。
无法解决数据库单点和可重入问题。
此外,要使用排他锁来进行分布式锁的lock,那么一个排他锁长时间不提交,就会占用数据库连接。一旦类似的连接变得多了,就可能把数据库连接池撑爆。
在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。但需要注意的是MySQL会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由MySQL通过判断不同执行计划的代价来决定的:如果MySQL认为全表扫效率更高,比如对一些数据量小的表,MySQL就不会使用索引。这种情况下查询将出现表锁,而不是行锁,这会导致所有sql写操作阻塞。
基于乐观锁
乐观并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新之前,每个事务会先检查在该事务读取数据后,有没有其他事务又修改了该数据。如果其他事务有更新的话,正在提交的事务会进行回滚。
以使用版本号实现乐观锁为例
使用版本号时,可以在数据初始化时指定一个版本号,每次对数据的更新操作都对版本号执行+1操作。并判断当前版本号是不是该数据的最新的版本号。
1 | // 1.查询出商品信息 |
需要注意的是,乐观锁机制往往基于系统中数据存储逻辑,因此也具备一定的局限性。由于乐观锁机制是在我们的系统中实现的,对于来自外部系统的用户数据更新操作不受我们系统的控制,因此可能会造成脏数据被更新到数据库中。在系统设计阶段,应该充分考虑到这些情况,并进行相应的调整(如将乐观锁策略在数据库存储过程中实现,对外只开放基于此存储过程的数据更新途径,而不是将数据库表直接对外公开)。
- 缺陷
对数据库依赖,开销问题,行锁变表锁问题,无法解决数据库单点和可重入的问题。
- 原本一次的UPDATE操作,变为2次操作:SELECT版本号一次和UPDATE一次。增加了数据库操作的次数。
- 如果业务场景中的一次业务流程中,多个资源都需要用保证数据一致性,那么如果全部使用基于数据库资源表的乐观锁,就要让每个资源都有一张资源表,这个在实际使用场景中肯定是无法满足的。而且这些都基于数据库操作,在高并发的要求下,对数据库连接的开销一定是无法忍受的。
基于 redis 单机 SETNX
使用命令介绍:
(1)SETNX
SETNX key val:当且仅当key不存在时,set一个key为val的字符串,返回1;若key存在,则什么都不做,返回0。
(2)expire
expire key timeout:为key设置一个超时时间,单位为second,超过这个时间锁会自动释放,避免死锁。
(3)delete
delete key:删除key
在使用Redis实现分布式锁的时候,主要就会使用到这三个命令。
实现思想:
(1)获取锁的时候,使用setnx加锁,并使用expire命令为锁添加一个超时时间,超过该时间则自动释放锁,锁的value值为一个随机生成的UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置一个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过UUID判断是不是该锁,若是该锁,则执行delete进行锁释放。
加锁时利用NX的原子性,多线程并发时,只有一个线程可以设置成功,从而获得锁。
在删除锁、给锁加长超时时间等操作,我们是先获取锁在删除或者延长超时时间,两者操作并不是原子性操作,如果在获取锁成功之后,redis宕机,那么也会出现业务紊乱,所以在redis操作要尽量保证原子性操作。
释放锁时利用 redis 的 delete 命令配合 LUA 脚本实现,校验之前设置的随机数,相同才能释放,证明此时 redis 里面的值是当前线程设置的,避免释放了其他线程的锁
set NX PX + Lua
加锁: set NX PX + 重试 + 重试间隔
向Redis发起如下命令: SET productId:lock 0xx9p03001 NX PX 30000
其中,”productId”由自己定义,可以是与本次业务有关的id,”0xx9p03001”是一串随机值,必须保证全局唯一(原因在后文中会提到),“NX”指的是当且仅当key(也就是案例中的”productId:lock”)在Redis中不存在时,返回执行成功,否则执行失败。”PX 30000”指的是在30秒后,key将被自动删除。执行命令后返回成功,表明服务成功的获得了锁。
解锁:采用lua脚本
在删除key之前,一定要判断服务A持有的value与Redis内存储的value是否一致。如果贸然使用服务A持有的key来删除锁,则会误将服务B的锁释放掉。
这种实现方式存在的问题:
- 这把锁只能是非阻塞的,无论成功还是失败都直接返回。
- 这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在Redis中已经存在。
- 分布式锁过期,而业务逻辑没执行完。
相应的解决方法:
- 使用while重复执行并设置重试次数,防止持续拿不到锁导致服务器资源耗尽。
- 在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。
- 自行维护续期逻辑。
补充
从Redis 2.6.12版本开始,SET命令的行为可以通过一系列参数来修改:
- EX seconds:将键的过期时间设置为seconds秒。执行
SET key value EX seconds
的效果等同于执行SETEX key seconds value
。 - PX milliseconds:将键的过期时间设置为 milliseconds 毫秒。执行
SET key value PX milliseconds
的效果等同于执行PSETEX key milliseconds value
。 - NX:只在键不存在时,才对键进行设置操作。执行
SET key value NX
的效果等同于执行SETNX key value
。 - XX:只在键已经存在时,才对键进行设置操作。
因为SET命令可以通过参数来实现SETNX、SETEX以及PSETEX命令的效果,所以Redis将来的版本可能会废弃并移除SETNX、SETEX和PSETEX这三个命令。
基于 Redisson
目前互联网公司在生产环境用的比较广泛的开源框架 Redisson 很好地解决了锁被提前释放这个问题,非常的简便易用,且支持Redis单实例、Redis主从、Redis Sentinel、Redis Cluster等多种部署架构。
Redisson框架会开启一个定时器的守护线程,每expireTime/3执行一次,去检查该线程的锁是否存在,如果存在则对锁的过期时间重新设置为expireTime,即利用守护线程对锁进行“续命”,防止锁由于过期提前释放。
- 线程去获取锁,获取成功: 执行lua脚本,保存数据到redis数据库。
- 线程去获取锁,获取失败: 订阅了解锁消息,然后再尝试获取锁,获取成功后,执行lua脚本,保存数据到redis数据库。
如果这个时候客户端B来尝试加锁,执行了同样的一段lua脚本。第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在。接着第二个if判断,判断myLock锁key的hash数据结构中,是否包含客户端B的ID,但明显没有,那么客户端B会获取到pttl myLock返回的一个数字,代表myLock这个锁key的剩余生存时间。此时客户端B会进入一个while循环,不听的尝试加锁。
可重入:每次lock会调用incrby,每次unlock会减一
这种实现方式存在的问题和解决方法:
- Redission的分布式锁也是非阻塞的,同样需要while重复执行。
- Redission的分布式锁是可重入的。因为Redisson的锁是hset结构,key值就是客户端的身份标识,value是加锁次数,从而实现了可重入加锁。
基于 Redis 多机 Redlock
基于Redis单机实现的分布式锁都存在一个问题:加锁时只作用在一个Redis节点上,即使Redis通过Sentinel或者Cluster保证了高可用,但由于Redis的复制是异步的,Master节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。
为了解决这个问题,Redis的作者antirez提供了RedLock的算法来实现一个分布式锁。这是他推荐的分布式集群情况下的方式,该算法流程是这样的:
假设有 N(N>=5)个Redis节点,这些节点完全互相独立。(不存在主从复制或者其他集群协调机制,确保这N个节点使用与在Redis单实例下相同的方法获取和释放锁)
获取锁的过程,客户端应执行如下操作:
- 获取当前Unix时间戳,以毫秒为单位。
- 依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个创建锁的超时时间,这个超时时间应该远小于锁的失效时间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在一直等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
- 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁消耗的时间。当且仅当从大多数(大于N/2+1,这里是3个节点)的Redis节点都取到锁,并且获取锁消耗的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key的真正有效时间等于有效时间减去获取锁消耗的时间(步骤3计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁。(虽然某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)
而分布式系统专家Martin针对Redlock提出了一个场景:假设多节点Redis系统有五个节点A/B/C/D/E和两个客户端C1和C2,如果其中一个Redis节点上的时钟向前跳跃会发生什么?
- 客户端C1获得了对节点A、B、c的锁定,由于网络问题,法到达节点D和节点E。
- 节点C上的时钟向前跳,导致锁提前过期。
- 客户端C2在节点C、D、E上获得锁定,由于网络问题,无法到达A和B。
- 客户端C1和客户端C2现在都认为他们自己持有锁。
这说明时钟跳跃对于Redlock算法影响较大,这种情况一旦发生,Redlock是没法正常工作的。
对此,Antirez指出Redlock算法对系统时钟的要求并不需要完全精确,只要误差不超过一定范围不会产生影响,在实际环境中是完全合理的,通过恰当的运维完全可以避免时钟发生大的跳动。
基于 Zookeeper 实现
ZooKeeper是一个分布式协调服务的开源框架。主要用来解决分布式集群中应用系统的一致性的问题。
ZooKeeper本质上是一个分布式的小文件存储系统。提供基于类似于文件系统的目录树方式的数据存储,并且可以对树的节点进行有效管理。
使用ZooKeeper实现分布式锁的过程:
- 客户端连接ZooKeeper,并在/tmp下创建临时且有序的子节点,第一个客户端对应的子节点为lock-0000,第二个为lock-0001,以此类推。
- 客户端获取/lock下的子节点列表,判断创建的节点是否为当前子节点列表中序号最小的节点,如果是则认为获得锁,否则监听前一个子节点的删除消息。
- 获取锁后,执行业务代码流程,删除当前客户端对应的子节点,锁释放。
例如:/tmp下的子节点列表为:lock-0000、lock-0001、lock-0002,序号为1的客户端监听序号为0000子节点的删除消息,序号为2的监听序号为0001子节点的删除消息(业务代码执行完结束后删除子节点)。
这种实现方式存在的问题和解决方法:
- 针对分布式锁无法自动释放的问题,Zookeeper可以有效地解决。因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
- 针对分布式锁最好是阻塞锁的问题,Zookeeper通过在节点上绑定监听器,当获取到锁的时候,调用回调函数的方式,实现了阻塞锁的效果。
- 针对分布式锁的可重入特性,Zookeeper可以有效地解决。客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
- 针对分布式单点问题问题导致的锁失效问题,Zookeeper可以有效地解决。Zookeeper是集群部署,只要集群中有半数以上的机器存活,就可以对外提供服务。
Apache Curator是一个Zookeeper的开源客户端,它提供了Zookeeper各种应用场景(如共享锁服务、master选举、分布式计数器等)的抽象封装,简化了ZooKeeper的操作。
使用Zookeeper实现分布式锁的优点:
有效的解决单点问题、不可重入问题、非阻塞问题以及锁无法释放的问题。实现起来较为简单。
使用Zookeeper实现分布式锁的缺点:
因为Zookeeper集群采用zab一致性协议,所以高并发场景,性能上不如使用Redis实现分布式锁。
基于 etcd 的分布式锁
etcd 本身是基于 raft 算法实现的副本的状态复制,是有可靠的共识理论支撑的工程实现,另外 etcd 号称其 raft 算法实现有着比 paxos 算法更好的性能(这个没求证,多数情况下paxos算法可能性能更优点,也不一定非得有master节点)
基于etcd的分布式锁实现,已经内置在etcd中了,直接使用即可。
https://github.com/hitzhangjie/codemaster/blob/master/distlock/etcd/main.go
分布式锁的技术选型
理论上
生产环境应该很少使用数据库来做分布式锁,即使基于数据库的分布式锁实现比较简单。
目前比较热门的技术选型有基于Redis、Zookeeper和etcd的分布式锁。其中基于Redis单机和Redisson的分布式锁,都属于AP模型;而基于Zookeeper与etcd的分布式锁属于CP模型。
在CAP理论中,由于分布式系统中多节点通信不可避免出现网络延迟、丢包等问题一定会造成网络分区,在造成网络分区的情况下,一般有两个选择:CP或者AP。
- 选择AP模型实现分布式锁时,client在通过集群主节点加锁成功之后,则立刻会获取锁成功的反馈。在主节点还没来得及把数据同步给从节点时就发生宕机的话,系统会在从节点中选出一个节点作为新的主节点,新的主节点没有宕机的主节点的锁数据,导致其他client可以在新的主节点上拿到相同的锁。这就会导致多个进程来操作相同的临界资源数据,从而引发数据不一致性等问题。
- 选择CP模型实现分布式锁,只有在主节点把数据同步给大于1/2的从节点之后才被视为加锁成功。此时,主节点突然宕机,系统会在从节点中选取出数据比较新的一个从节点作为新的主节点,从而避免锁数据丢失的问题。
对于严格的分布式锁来说,CP模型会更为理想。虽然,基于Redlock实现的分布式锁也可以看做是CP模型,但由于需要部署、维护比较复杂,在生产环境很少被使用。所以在对一致性要求很高的业务场景下(电商、银行支付),一般选择使用Zookeeper或者etcd。如果可以容忍少量数据丢失,出于维护成本等因素考虑,AP模型的分布式锁可优先选择Redis。
业务上
业务中如果希望引入分布式锁,选型的时候可以思考下自己到底是解决哪类问题?
- 如果是为了解决效率类问题,直接用redis方案就挺不错的,考虑到单点问题可以挂个slave,也没啥问题;
- 如果是为了解决正确性问题,也可以用redis redlock方案,但是要明白其可能存在的风险,业务能否接受;
- 如果对正确性非常重视,对于并发写冲突的情况完全不可接受,反而可以接受一些可用性损失,那我建议直接用etcd、zk等方案更合适;
- 如果业务更追求可用性,同时尽最大可能保证正确性,那也不妨考虑redis redlock,如果也不想引入额外组件增加运维工作量,也不妨考虑自研一个分布式锁;
Reference
Distributed Locks with Redis | Redis
How to do distributed locking | Martin Kleppmann’s blog
https://segmentfault.com/a/1190000041172633
https://pdai.tech/md/arch/arch-z-lock.html
GitHub - go-redsync/redsync: Distributed mutual exclusion lock using Redis for Go