本文聊聊为Layotto和Dapr设计分布式锁API时候的思考,作为一个案例供参考。
做需求时想实现一个分布式锁并不难,但设计一套跨平台的分布式锁API就有点难了,因为你的API要能方便的用各种各样的存储系统去实现,而且最难的是你要能说服社区里足够多的人(至少要说服这个社区所有核心维护者,既要用中文也要用英文)、让他们相信你这样设计是合理的,不然你的PR合并不进去。相比之下,写代码反而是最简单的环节了 :(
这个过程往往需要先定义一套“能力成熟度模型”,然后用这套模型去评估每一个存储系统,看看他们能做哪些feature,不能做哪些feature
1. 实现分布式锁的必要条件是什么?
工程实践中,实现分布式锁的常见方案是基于一个“写操作线性一致(linearizable)”的存储,例如用zookeeper或etcd,甚至有用Mysql实现分布式锁、通过轮询来Watch锁释放。具体实现方案zookeeper论文有讲,或者看网上各种文章的讨论。不过网上博客写的方案大多都有问题,强烈建议先看看Martin的文章
这里不谈实现细节,抽象的看,实现分布式锁的必要条件是什么?
我们可以这么说:
观察1. 分布式协调服务=有Watch和过期机制的存储
这里说的分布式协调服务包括通用目的的协调服务,比如zookeeper、etcd;也包括领域特定的协调服务,比如注册中心(如eureka)、配置中心,比如分布式锁服务(如chubby)。不同领域的分布式协调服务对一致性有不同的要求,比如注册中心一般不追求强一致性,但分布式锁服务就要求强一致。
观察2. 分布式注册中心=有Watch和过期机制的存储+一套针对服务的运维方法集合
引自我们做出了一个分布式注册中心。分布式注册中心可以看成是一种领域特定的分布式协调服务,对一致性要求不高。
观察3. 能拿来做tryLock非阻塞锁的协调服务=cas(compare-and-set) 写操作线性一致+有过期机制的存储
tryLock的时候要传锁的ttl,因此需要存储支持过期机制
观察4. 能拿来做lock阻塞锁的协调服务=cas写操作线性一致+有Watch和过期机制的存储
和非阻塞锁不同的是,实现阻塞锁需要watch机制
观察5. 不管是非阻塞锁还是阻塞锁,如果想实现“自动续租”,需要过期机制带故障检测(failure detect)能力
比如节点A找存储申请了一把锁,存储持续检测节点A是否存活。检测到节点A故障后,存储会等一定的过期时间才销毁锁;如果没检测到节点A故障,节点A永远持有该锁。
下面讨论这些必要条件:
1.1. 实现非阻塞锁tryLock的必要条件
观察3. 能拿来做tryLock非阻塞锁的协调服务=cas(compare-and-set) 写操作线性一致+有过期机制的存储
1.1.1. 写操作线性一致
下文详述
1.1.2. 过期机制
1.1.2.1. 想实现分布式锁?必须要有过期机制
如果锁没有过期时间,持有锁的节点带锁故障(比如机器炸了)会导致死锁,其他节点再也拿不到锁了。
1.1.2.2. 想实现自动续租?还要有故障检测能力
如果只是想实现“给我锁10秒”这种功能,只要存储支持ttl即可,超过ttl后存储自动删除某个key。
但是如果想实现“给我一直锁住,除非我主动释放,或者我挂了,才释放锁”,就一定要有故障检测能力,协调服务要能检测持有锁的节点是否故障。
细粒度心跳
比如zk、etcd的sdk可以和server建立长连接,用于发送“给我续租锁A”这样的细粒度请求。这里要注意,统一使用同一个粗粒度心跳是不够的,连接可以复用,但心跳请求一定要细粒度,原因详见https://mosn.io/layotto/#/zh/design/lock/lock-api-design
这就导致sdk必须写一些细粒度状态管理的逻辑。
存储不支持故障检测?通过更新ttl来模拟故障检测
如果协调服务(用的存储)没有故障检测能力,那么客户端就要每隔一段时间主动更新ttl,以此模拟“自动续租”。
因此我们可以说:
观察6. 有过期机制的存储,只要支持更新ttl,就有故障检测能力
1.2. 实现阻塞锁Lock的必要条件
回忆一下:
观察4. 能拿来做lock阻塞锁的协调服务=写线性一致+有Watch和过期机制的存储
相比于非阻塞锁,实现阻塞锁还需要watch机制。
题外话:想想OS是怎么实现阻塞锁的?
最原始的方案是自旋cas(写个死循环、调硬件提供的cas指令,如果cas成功就退出循环),后来OS引入了等待/唤醒机制,避免死循环浪费cpu。
分布式锁的watch机制其实就类似于OS的等待/唤醒机制,避免大量的循环重试。
1.2.1. Watch机制
1.2.1.1. 实现阻塞锁需要Watch机制
阻塞锁要hang住等待锁释放,因此需要watch机制、监听锁释放。实现非阻塞锁不需要watch
1.2.1.2. 怎么实现"hang住等待锁释放"
# 方案1:基于cas写+监听
多个节点对存储系统做原子cas写,谁写到谁就抢到锁。其他节点Watch。这种Watch其实是Centralized Failure Detect,会有惊群问题。
# 方案2. Ring-based failure detect
每个节点存储不同的id,id最大的作为主节点,每个节点Watch比自己大的节点。可以理解成Ring-based fd。
# 方案3. 轮询
比如用Mysql实现分布式锁、通过轮询做Watch。当然缺点是Mysql没提供过期机制
1.3. 实现可重入锁的必要条件
//TODO
2. 保证正确性:实现分布式锁时可能遇到的问题
实现分布式锁,最麻烦的是要小心review各种异常场景,避免异常时影响正确性。所以code review其他人提交的分布式锁代码是一件很麻烦的事:)
分布式系统中的正确性(correctness)可以分为liveness和safety,下文分开讨论。
题外话:关于liveness与safety
More generally, a liveness property states that "something good will eventually occur", contrasting a safety property which states that "something bad does not occur"
2.1. Liveness
2.1.1. 存储不可用
如果存储是单节点,宕机会导致lock service不可用
比如用单节点redis实现分布式锁,有较高的不可用风险
2.1.2. 死锁
如果锁没有过期时间,持有锁的节点带锁故障时,会导致其他等待获取锁的节点长时间hang住(阻塞锁),或者该笔业务长时间hang住(非阻塞锁)。
这也是很多用mysql当分布式锁的方案存在的问题,如https://segmentfault.com/a/1190000023045815
当然,如果不用分布式锁、用db的事务就不会有这问题,db在感知到事务超时等异常发生时能自动关闭事务、回滚。比如https://blog.csdn.net/weixin_29474431/article/details/113602581
2.2. Safety
2.2.1. 要求lock service的写操作线性一致
一般主从模型的存储,写操作都能保证线性一致,但容易出问题的地方是fo可能丢锁,这就导致出现fo后,写操作不具有线性一致性。有几种处理方式:
- 普通redis集群,fo后锁丢了、可能被重新抢走,达不到“写操作线性一致”
- 粗粒度锁,保证fo不丢数据
-
细粒度锁,保证fo慢,超过一般锁过期时间,数据丢就丢了可以容忍
参考chubby论文
2.2.2. 不可能只靠分布式锁保证100%正确性。正确性最终要靠存储资源自己支持事务
遗憾的是,分布式锁并不能保证你的并发处理结果是100%正确的。假设被保护的存储资源没有事务能力,那我们可以举出很多corner case。
ACID中的A:保证不了
比如多对象事务(读写了多个key的事务)写写并发的场景,事务1读写了key1、还没来得及读写key2就带锁failure了,如果存储没有事务回滚能力,此时存储中的key1、key2就不再满足业务上的“完整性约束”(可以用转账的例子理解一下)。锁并不能保证这种可回滚性;
ACID中的I: 最弱的read committed都保证不了
除了保证不了可回滚性,即使client1没fail,只是在写完key1后gc了、没来得及写key2,client2基于client1的部分写操作做出了check-then-modify的操作,本质上属于脏读问题。所以连隔离级别中的rc也保证不了。
只操作一个key的事务呢?
上述各种解决不了的问题都是多对象事务,那如果事务只操作单个key、使用分布式锁能否保证正确性呢?
如果被保护的资源本身就支持线性一致的compare and set,那么天生就能使用乐观锁做到单对象事务的并发安全、不需要分布式锁就能保证正确性,用分布式锁只是为了提高效率;
如果被保护的资源给不出上述保证,比如做不到单对象并发写安全(比如某种文件系统或块存储,并发写同一个文件/块会出问题),比如做不到写操作线性一致,用分布式锁还是没法保证100% 正确性,即使用sequencer(或者叫fencing token)也保证不了,下文详述
2.2.3. 分布式锁"尽量保证正确性"的手段
既然没法保证一定正确,工程上会有很多"尽量保证正确性"的手段。从强到弱:
有几种比较强的保证:
2.2.3.1. sequencer + 存储系统自己能保证并发安全
什么是sequencer
sequencer是 chubby paper提出的概念,Martin管这个叫fencing token,见https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
sequencer可以解决“丢锁不自知”问题,如图所示
chubby的描述类似,节点fail会导致请求乱序,乱序导致修改顺序和观测顺序不一致,因此引入sequencer来避免这种问题:
sequencer也保证不了正确性
虽然martin和chubby都推荐使用sequencer(fencing token),但不幸的是,存储系统即使加上判断squencer自增的逻辑,如果自己不能保证并发安全也还是有问题。举例:
被保护的资源不保证“并发写安全”
举例如下,使用了fencing token的场景还是可能出问题。client1获得锁后pause,client1和client2的两个请求按顺序到达资源,还是有并发写,如果是非并发安全的存储(比如某种文件系统),还是有冲突
即使调用Chubby提供的回调API,CheckSequencer(),也一样会有并发写同一份数据的情况。
被保护的资源不保证“写操作线性一致”
再比如,假设被保护的资源有多副本、写操作不能保证线性一致,那么很可能写冲突。试想同时有两个请求,一个先写token 35写成功,另一个请求写token 34写到了另一个副本、也写成功,此时还是有两个请求并发操作同一份数据,违反safety。
如果被保护的资源支持线性一致的compare-and-set
上文已述,如果被保护的资源支持线性一致的compare-and-set,那么天生就能使用乐观锁做到单对象事务的并发安全、不需要分布式锁就能保证正确性,用分布式锁只是为了提高效率。
当然,这只能保证单对象事务的正确性,多对象事务还是保证不了正确性,需要被保护的资源支持事务
既然存储系统自己能保证并发安全了,那还要分布式锁干嘛?sequencer的价值是啥?
如果被保护的资源本身并发安全(支持repeatable read隔离级别+自动冲突检测。值得一提的是mysql的rr没有自动冲突检测能力),那么加锁可以提高效率,sequencer没啥用。
如果被保护的资源支持compare-and-cas操作(天生支持单对象事务的并发操作)、但是多对象事务的隔离性差,那么sequencer的价值有:
-
一定程度上避免“脏写”。有了全局自增版本号后,业务可以把单个多对象事务拆成多个单对象事务,而不用担心旧覆盖新的乱序问题(缺点是避免不了“脏读”)。举例如下,两个client并发执行事务,先写key0,再写key1,有了sequencer可以避免旧的请求覆盖新的:
-
sequencer能“缓解”一些多对象事务的乱序问题、尽量保证"修改顺序和观测顺序一致"。举例如下,client发起读写事务,读的key和修改的key不一样:
当然这种保证覆盖不了所有场景,比如下面这样
这里的问题在于读keyx时,并不会把keyx对应的lockversion修改掉,如果读keyx时能顺便把keyx的lockversion也改了,就能避免这个问题。
但是真实世界中keyx可能是通过rpc调别的系统查询,锁也保护不到别人系统里存的东西,没法改keyx对应的lockversion
2.2.3.2. 基于时间的分布式锁算法+ TrueTime API
很多分布式锁算法都是基于时间的,比如RedLock,比如chubby的lockDelay。
分布式系统中的时钟是很不靠谱的,依赖时钟会有问题,最好是不依赖时钟假设:
但如果使用TrueTime API就能保证这些算法不会因为时钟出错。当然上文一开始写的可回滚性,脏读等问题还是避免不了
2.2.3.3. 现实世界的分布式锁:或多或少正确性都有问题
看一些blog就会发现,大家其实默认了忽略corner case,不追求理论100%
比如https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/
我过去工作中用到的很多是基于分布式缓存的锁,把key设计的足够细粒度,然后超时时间设置个几秒(大于业务监控报警的时间),然后监控业务处理时间,真有超慢请求了再处理脏数据或者应急(一般不会慢到gc几秒)
工程上讲究够用就行,不追求理论上一定能正确,corner case通过监控+人工介入+配套措施解决(比如对账报表,脏数据处理程序等)
3. 评估业界产品的成熟度
有了上述的分析,我们就可以来评估业界产品的成熟度了。
系统 | 非阻塞锁+unlock | 阻塞锁(基于watch) | 可用性(liveness) | 写操作线性一致(safety) | sequencer(safety) | 续租 |
---|---|---|---|---|---|---|
单机redis | √ | x | unavailable when single failure | √ | √(need poc) | √ |
redis集群 | √ | x | yes | no. Lose lock when failure over | √(need poc) | yes |
redis Redlock | √ | × | √ | √. 因为重启会丢锁,所以前提是重启延迟足够高 | × | ? maybe yes |
nacos | × | |||||
consul | √ | |||||
eureka | × | |||||
zookeeper | √ | √ | √ 有fo能力,200 ms内完成选举 | √ | √ 使用zxid作为sequencer | √ |
etcd | √ | √ | √ | √ | √使用revision | √ lease.KeepAlive |
alicloud | ||||||
azure | ||||||
aws | ||||||
Google cloud |
Redis
http://zhangtielei.com/posts/blog-redlock-reasoning.html
https://juejin.cn/post/6844903830442737671
https://xiaomi-info.github.io/2019/12/17/redis-distributed-lock/
- tryLock
SET resource_name my_random_value NX PX 30000
lua做unlock
lua做续租
见https://blog.csdn.net/fly910905/article/details/114529199 本质上是compare and set能否通过watch机制做阻塞锁?
虽然有Keyspace Notifications,但是不保证可靠传递,不适合做watch锁
https://redis.io/topics/notifications能否用redis事务或自增命令实现sequencer?
对于普通redis集群,看描述应该是可以用Lua写脚本、实现sequencer。没写poc
Redlock不支持redlock做分布式锁的话,咋实现续租??
不知道。想了想,续租也得过半写,如果部分失败就需要无限重试。
但是有很多问题:
- 重试时间太长的话,相当于变相延长了租约;
- 比如先续租节点1成功,然后续租节点2失败,重试了5秒,节点2成功,但是问题这时候节点1和节点2的ttl不一样了
对于问题1,感觉重试时长可以做一下限制;
对于问题2,出现这个问题可以放着不管,相当于续租时长在[min,max]之间
zk
https://fpj.me/2016/02/10/note-on-fencing-and-distributed-locks/
http://zookeeper.apache.org/doc/r3.4.9/recipes.html#sc_recipes_Locks
见上
etcd
可以直接看sdk包里的lock实现。https://segmentfault.com/a/1190000021603215
- tryLock
申请lease+用txn做抢锁 - unlock
lease.Revoke主动unlock - lock
可以基于watch做阻塞锁
Mongo
https://www.jianshu.com/p/779191772852
Q: TTL自动删除要等多久?
设置了自动过期时间, 也就是expires属性, 这个属性对应mongoDB中的expireafterseconds的属性. 避免节点获取锁后, 挂掉, 从而导致死锁. 超时后, MongoDB会自动删除. 注意: MongoDB的expire调度是每分钟一次, 所以不是一过期就立马删除的
https://docs.mongodb.com/manual/core/index-ttl/
The background task that removes expired documents runs every 60 seconds. As a result, documents may remain in a collection during the period between the expiration of the document and the running of the background task.
Q: 写操作是否线性一致?
https://jelly.jd.com/article/5f990ebbbfbee00150eb620a
consul
https://www.cnblogs.com/jiujuan/p/10527786.html
https://guidao.github.io/lock.html#org4fb7b5a
https://blog.didispace.com/spring-cloud-consul-lock-and-semphore/
有类似于chubby的lock-delay机制,很有意思
The final nuance is that sessions may provide a lock-delay. This is a time duration, between 0 and 60 seconds. When a session invalidation takes place, Consul prevents any of the previously held locks from being re-acquired for the lock-delay interval; this is a safeguard inspired by Google's Chubby. The purpose of this delay is to allow the potentially still live leader to detect the invalidation and stop processing requests that may lead to inconsistent state. While not a bulletproof method, it does avoid the need to introduce sleep states into application logic and can help mitigate many issues. While the default is to use a 15 second delay, clients are able to disable this mechanism by providing a zero delay value.
https://www.consul.io/docs/dynamic-app-config/sessions
4. Reference
Martin(DDIA作者)的一些讨论:
http://zhangtielei.com/posts/blog-redlock-reasoning.html
http://zhangtielei.com/posts/blog-redlock-reasoning-part2.html
https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
chubby
- paper: https://static.googleusercontent.com/media/research.google.com/zh-TW//archive/chubby-osdi06.pdf
http://duanple.com/?p=155 (in chinese) - talk https://www.youtube.com/watch?v=PqItueBaiRg&feature=youtu.be&t=487
mit 6.824
https://zhuanlan.zhihu.com/p/217348352