21.分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?
21.分布式锁:为什么基于etcd实现分布式锁比Redis锁更安全?
本文笔记来自:「极客时间ETCD实战课」,原文链接:https://time.geekbang.org/column/article/354292?cid=100069901
在软件开发过程中,我们经常会遇到各种场景要求对共享资源进行互斥操作,否则整个系统的数据一致性就会出现问题。典型场景如商品库存操作、Kubernertes调度器为Pod分配运行的Node。
那要如何实现对共享资源进行互斥操作呢?
锁就是其中一个非常通用的解决方案。在单节点多线程环境,你使用本地的互斥锁就可以完成资源的互斥操作。然而单节点存在单点故障,为了保证服务高可用,你需要多节点部署。在多节点部署的分布式架构中,你就需要使用分布式锁来解决资源互斥操作了。
但是为什么有的业务使用了分布式锁还会出现各种严重超卖事故呢?分布式锁的实现和使用过程需要注意什么?
从茅台超卖案例看分布式锁要素
首先我们从去年一个因Redis分布式锁实现问题导致 茅台超卖案例 说起,在这个真实案例中,因茅台的稀缺性,事件最终定级为P0级生产事故,后果影响严重。
那么它是如何导致超卖的呢?
首先简单介绍下此案例中的Redis简易分布式锁实现方案,它使用了Redis SET命令来实现。
1 |
|
简单给你介绍下SET命令重点参数含义:
EX
设置过期时间,单位秒;NX
当key不存在的时候,才设置key;XX
当key存在的时候,才设置key。
此业务就是基于Set key value EX 10 NX命令来实现的分布式锁,并通过JAVA的try-finally语句,执行Del key语句来释放锁,简易流程如下:
1 |
|
以上流程中其实存在以下思考点:
- NX参数有什么作用?
- 为什么需要原子的设置key及过期时间?
- 为什么基于Set key value EX 10 NX命令还出现了超卖呢?
- 为什么大家都比较喜欢使用Redis作为分布式锁实现?
首先来看第一个问题,NX参数的作用。NX参数是为了保证当分布式锁不存在时,只有一个client能写入此key成功,获取到此锁。我们使用分布式锁的目的就是希望在高并发系统中,有一种互斥机制来防止彼此相互干扰,保证数据的一致性。
因此分布式锁的第一核心要素就是互斥性、安全性。在同一时间内,不允许多个client同时获得锁。
再看第二个问题,假设我们未设置key自动过期时间,在Set key value NX后,如果程序crash或者发生网络分区后无法与Redis节点通信,毫无疑问其他client将永远无法获得锁。这将导致死锁,服务出现中断。
有的同学意识到这个问题后,使用如下SETNX和EXPIRE命令去设置key和过期时间,这也是不正确的,因为你无法保证SETNX和EXPIRE命令的原子性。
1 |
|
这就是分布式锁第二个核心要素,活性。在实现分布式锁的过程中要考虑到client可能会出现crash或者网络分区,你需要原子申请分布式锁及设置锁的自动过期时间,通过过期、超时等机制自动释放锁,避免出现死锁,导致业务中断。
再看第三个问题,为什么使用了Set key value EX 10 NX命令,还出现了超卖呢?
原来是抢购活动开始后,加锁逻辑中的业务流程1访问的用户身份服务出现了高负载,导致阻塞在校验用户身份流程中(超时30秒),然而锁10秒后就自动过期了,因此其他client能获取到锁。关键是阻塞的请求执行完后,它又把其他client的锁释放掉了,导致进入一个恶性循环。
因此申请锁时,写入的value应确保唯一性(随机值等)。client在释放锁时,应通过Lua脚本原子校验此锁的value与自己写入的value一致,若一致才能执行释放工作。
更关键的是库存校验是通过get and compare方式,它压根就无法防止超卖。正确的解决方案应该是通过LUA脚本实现Redis比较库存、扣减库存操作的原子性(或者在每次只能抢购一个的情况下,通过判断 Redis Decr命令 的返回值即可。此命令会返回扣减后的最新库存,若小于0则表示超卖)。
从这个问题中我们可以看到,分布式锁实现具备一定的复杂度,它不仅依赖存储服务提供的核心机制,同时依赖业务领域的实现。无论是遭遇高负载、还是宕机、网络分区等故障,都需确保锁的互斥性、安全性,否则就会出现严重的超卖生产事故。
再看最后一个问题,为什么大家都比较喜欢使用Redis做分布式锁的实现呢?
考虑到在秒杀等业务场景上存在大量的瞬间、高并发请求,加锁与释放锁的过程应是高性能、高可用的。而Redis核心优点就是快、简单,是随处可见的基础设施,部署、使用也及其方便,因此广受开发者欢迎。
这就是分布式锁第三个核心要素,高性能、高可用。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,确保业务不会出现中断。
那么除了以上案例中人为实现问题导致的锁不安全因素外,基于Redis实现的以上分布式锁还有哪些安全性问题呢?
Redis分布式锁问题
我们从茅台超卖案例中为你总结出的分布式核心要素(互斥性、安全性、活性、高可用、高性能)说起。
首先,如果我们的分布式锁跑在单节点的Redis Master节点上,那么它就存在单点故障,无法保证分布式锁的高可用。
于是我们需要一个主备版的Redis服务,至少具备一个Slave节点。
我们又知道Redis是基于主备异步复制协议实现的Master-Slave数据同步,如下图所示,若client A执行SET key value EX 10 NX命令,redis-server返回给client A成功后,Redis Master节点突然出现crash等异常,这时候Redis Slave节点还未收到此命令的同步。
若你部署了Redis Sentinel等主备切换服务,那么它就会以Slave节点提升为主,此时Slave节点因并未执行SET key value EX 10 NX命令,因此它收到client B发起的加锁的此命令后,它也会返回成功给client。
那么在同一时刻,集群就出现了两个client同时获得锁,分布式锁的互斥性、安全性就被破坏了。
除了主备切换可能会导致基于Redis实现的分布式锁出现安全性问题,在发生网络分区等场景下也可能会导致出现脑裂,Redis集群出现多个Master,进而也会导致多个client同时获得锁。
如下图所示,Master节点在可用区1,Slave节点在可用区2,当可用区1和可用区2发生网络分区后,部署在可用区2的Redis Sentinel服务就会将可用区2的Slave提升为Master,而此时可用区1的Master也在对外提供服务。因此集群就出现了脑裂,出现了两个Master,都可对外提供分布式锁申请与释放服务,分布式锁的互斥性被严重破坏。
主备切换、脑裂是Redis分布式锁的两个典型不安全的因素,本质原因是Redis为了满足高性能,采用了主备异步复制协议,同时也与负责主备切换的Redis Sentinel服务是否合理部署有关。
有没有其他方案解决呢?
当然有,Redis作者为了解决SET key value [EX] 10 [NX]命令实现分布式锁不安全的问题,提出了 RedLock算法。它是基于多个独立的Redis Master节点的一种实现(一般为5)。client依次向各个节点申请锁,若能从多数个节点中申请锁成功并满足一些条件限制,那么client就能获取锁成功。
它通过独立的N个Master节点,避免了使用主备异步复制协议的缺陷,只要多数Redis节点正常就能正常工作,显著提升了分布式锁的安全性、可用性。
但是,它的实现建立在一个不安全的系统模型上的,它依赖系统时间,当时钟发生跳跃时,也可能会出现安全性问题。你要有兴趣的话,可以详细阅读下分布式存储专家Martin对 RedLock的分析文章,Redis作者的也专门写了 一篇文章进行了反驳。
分布式锁常见实现方案
了解完Redis分布式锁的一系列问题和实现方案后,我们再看看还有哪些典型的分布式锁实现。
除了Redis分布式锁,其他使用最广的应该是ZooKeeper分布式锁和etcd分布式锁。
ZooKeeper也是一个典型的分布式元数据存储服务,它的分布式锁实现基于ZooKeeper的临时节点和顺序特性。
首先什么是临时节点呢?
临时节点具备数据自动删除的功能。当client与ZooKeeper连接和session断掉时,相应的临时节点就会被删除。
其次ZooKeeper也提供了Watch特性可监听key的数据变化。
1 |
|
接下来重点介绍一下基于etcd的分布式锁实现。
etcd分布式锁实现
那么基于etcd实现的分布式锁是如何确保安全性、互斥性、活性的呢?
事务与锁的安全性
从Redis案例中我们可以看到,加锁的过程需要确保安全性、互斥性。比如,当key不存在时才能创建,否则查询相关key信息,而etcd提供的事务能力正好可以满足我们的诉求。
正如之前介绍的事务特性,它由IF语句、Then语句、Else语句组成。其中在IF语句中,支持比较key的是修改版本号mod_revision和创建版本号create_revision。
在分布式锁场景,你就可以通过key的创建版本号create_revision来检查key是否已存在,因为一个key不存在的话,它的create_revision版本号就是0。
若create_revision是0,就可发起put操作创建相关key,具体代码如下:
1 |
|
要注意的是,实现分布式锁的方案有多种,比如你可以通过client是否成功创建一个固定的key,来判断此client是否获得锁,你也可以通过多个client创建prefix相同,名称不一样的key,哪个key的revision最小,最终就是它获得锁。(解决惊群效应的方式之一)。
相比Redis基于主备异步复制导致锁的安全性问题,etcd是基于Raft共识算法实现的,一个写请求需要经过集群多数节点确认。因此一旦分布式锁申请返回给client成功后,它一定是持久化到了集群多数节点上,不会出现Redis主备异步复制可能导致丢数据的问题,具备更高的安全性。
Lease与锁的活性
通过事务实现原子的检查key是否存在、创建key后,我们确保了分布式锁的安全性、互斥性。那么etcd是如何确保锁的活性呢? 也就是发生任何故障,都可避免出现死锁呢?
正如在租约特性中介绍的,Lease就是一种活性检测机制,它提供了检测各个客户端存活的能力。你的业务client需定期向etcd服务发送”特殊心跳”汇报健康状态,若你未正常发送心跳,并超过和etcd服务约定的最大存活时间后,就会被etcd服务移除此Lease和其关联的数据。
通过Lease机制就优雅地解决了client出现crash故障、client与etcd集群网络出现隔离等各类故障场景下的死锁问题。一旦超过Lease TTL,它就能自动被释放,确保了其他client在TTL过期后能正常申请锁,保障了业务的可用性。
具体代码如下:
1 |
|
Watch与锁的可用性
当一个持有锁的client crash故障后,其他client如何快速感知到此锁失效了,快速获得锁呢,最大程度降低锁的不可用时间呢?
答案是Watch特性。正如在Watch特性中和介绍的,Watch提供了高效的数据监听能力。当其他client收到Watch Delete事件后,就可快速判断自己是否有资格获得锁,极大减少了锁的不可用时间。
具体代码如下所示:
1 |
|
etcd自带的concurrency包
为了简化分布式锁、分布式选举、分布式事务的实现,etcd社区提供了一个名为concurrency包实现更简单、正确地使用分布式锁、分布式选举。
下面简单介绍下分布式锁 concurrency 包的使用和实现,它的使用非常简单,如下代码所示,核心流程如下:
- 首先通过concurrency.NewSession方法创建Session,本质是创建了一个TTL为10的Lease。
- 其次得到session对象后,通过concurrency.NewMutex创建了一个mutex对象,包含Lease、key prefix等信息。
- 然后通过mutex对象的Lock方法尝试获取锁。
- 最后使用结束,可通过mutex对象的Unlock方法释放锁。
1 |
|
那么mutex对象的Lock方法是如何加锁的呢?
核心还是使用了上面介绍的事务和Lease特性,当CreateRevision为0时,它会创建一个prefix为/my-lock的key( /my-lock + LeaseID),并获取到/my-lock prefix下面最早创建的一个key(revision最小),分布式锁最终是由写入此key的client获得,其他client则进入等待模式。
详细代码如下:
1 |
|
那未获得锁的client是如何等待的呢?
答案是通过Watch机制各自监听prefix相同,revision比自己小的key,因为只有revision比自己小的key释放锁,才能有机会获得锁,如下代码所示,其中waitDelete会使用上面的介绍的Watch去监听比自己小的key,详细代码可参考 concurrency mutex 的实现。
1 |
|
小结
首先通过一个Redis分布式锁实现问题——茅台超卖案例,介绍了分布式锁的三个主要核心要素,它们分别如下:
- 安全性、互斥性。在同一时间内,不允许多个client同时获得锁。
- 活性。无论client出现crash还是遭遇网络分区,都需要确保任意故障场景下,都不会出现死锁,常用的解决方案是超时和自动过期机制。
- 高可用、高性能。加锁、释放锁的过程性能开销要尽量低,同时要保证高可用,避免单点故障。
随后通过这个案例,继续分析了Redis SET命令创建分布式锁的安全性问题。单Redis Master节点存在单点故障,一主多备Redis实例又因为Redis主备异步复制,当Master节点发生crash时,可能会导致同时多个client持有分布式锁,违反了锁的安全性问题。
为了优化以上问题,Redis作者提出了RedLock分布式锁,它基于多个独立的Redis Master节点工作,只要一半以上节点存活就能正常工作,同时不依赖Redis主备异步复制,具有良好的安全性、高可用性。然而它的实现依赖于系统时间,当发生时钟跳变的时候,也会出现安全性问题。
最后重点介绍了etcd的分布式锁实现过程中的一些技术点。它通过etcd事务机制,校验CreateRevision为0才能写入相关key。若多个client同时申请锁,则client通过比较各个key的revision大小,判断是否获得锁,确保了锁的安全性、互斥性。通过Lease机制确保了锁的活性,无论client发生crash还是网络分区,都能保证不会出现死锁。通过Watch机制使其他client能快速感知到原client持有的锁已释放,提升了锁的可用性。最重要的是etcd是基于Raft协议实现的高可靠、强一致存储,正常情况下,不存在Redis主备异步复制协议导致的数据丢失问题。
思考题
这节课到这里也就结束了,最后我给你留了两个思考题。
第一,死锁、脑裂、惊群效应是分布式锁的核心问题,你知道它们各自是怎么一回事吗?ZooKeeper和etcd是如何应对这些问题的呢?
死锁:加锁后由于没有添加锁的过期时间或者锁的时间设置过长,服务异常crash或者服务执行后漏执行释放锁操作,导致锁长时间没有释放
脑裂:分布式体系结构中的集中式结构(也称为 Master/Slave 架构),一个Master节点多个Slave节点,所有的请求数据处理必须先经过Master中央服务器,由Master统一进行资源和任务调度,中央服务器根据这些信息,将任务下达给节点服务器,节点服务器执行任务,并将结果反馈给中央服务器。脑裂是在某些特殊条件下,如主备切换,Slave在与Master的网络出现故障的时候,Slave会认为Master已经故障,从而成为新的master,而原来的master也没有卸任,从而导致存在两个Master在对外服务,存在多master会导致共享资源的互斥性遭到破坏,出现资源争抢,数据不一致等问题
惊群效应:指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。(当你往一群鸽子中间扔一块食物,虽然最终只有一个鸽子抢到食物,但所有鸽子都会被惊动来争夺,没有抢到食物的鸽子只好回去继续睡觉, 等待下一块食物到来。这样,每扔一块食物,都会惊动所有的鸽子,即为惊群。)
etcd如何避免死锁:利用Lease的活性检测机制,它提供了检测各个客户端存活的能力。你的业务 client 需定期向 etcd 服务发送”特殊心跳”汇报健康状态,若你未正常发送心跳,并超过和 etcd 服务约定的最大存活时间后,就会被 etcd 服务移除此 Lease 和其关联的数据。通过 Lease 机制就优雅地解决了 client 出现 crash 故障、client 与 etcd 集群网络出现隔离等各类故障场景下的死锁问题。一旦超过 Lease TTL,它就能自动被释放,确保了其他 client 在 TTL 过期后能正常申请锁,保障了业务的可用性。
etcd如何避免集群脑裂:在leader选举上采用了过半机制,即得到一半以上的follow才能成为leader,且新leader就任后旧leader必须卸任,所以etcd不存在脑裂问题
etcd如何避免惊群效应:mutex,通过 Watch 机制各自监听 prefix 相同,revision 比自己小的 key,因为只有 revision 比自己小的 key 释放锁,才能有机会,获得锁