1. 为什么需要使用分布式锁?
在实际项目中,经常会遇到多个客户端对同一个资源或数据进行访问,为了避免并发访问带来错误,就会对该资源或数据加一把锁,只允许获得锁的客户端进行操作。
总结来说,分布式锁是解决分布式系统访问共享资源时,避免并发访问共享资源带来错误而采取的解决方法;其实现思路是,通过为共享资源加锁,让各个访问互斥,保证并发访问的安全性以及写入操作唯一性,并保持数据一致;根据锁的用途还可以细分为以下2类:
- 允许多个客户端操作共享资源
这种情况下,对共享资源的操作需要满足幂等性,保证无论操作多少次都不会出现不同结果。在这里使用锁,是为了避免重复操作共享资源从而提高效率。比如:服务A对数据库获取某个数据,并缓存到redis中;希望将数据从数据库提取—>业务处理—>缓存到redis中,这一过程只有一个操作,后续请求该数据时,均从redis中获取,就可以给该项操作加锁操作。 - 只允许一个客户端操作共享资源
这种情况下,对共享资源的操作一般是非幂等性操作。在该情况下,如果出现多个客户端操作共享资源,就可能出现数据不一致或数据丢失等情况。比如:请求A更新数据x.cnt为x.cnt+1,请求B更新数据x.cnt为x.cnt+1,两个请求同时发生,希望x.cnt为x.cnt+2,最终结果可能是x.cnt为x.cnt+1,导致了数据不一致。
2. 分布式锁有哪些实现方式?
在分布式系统中,常用来实现分布式锁的方式有:基于数据库和分布式缓存 2种方式。基于数据库实现,主要有数据库的事务功能和乐观锁两种方式;基于分布式缓存实现,主要有基于内存实现和中间件实现两种方式,其中基于中间件实现有redis、zookeeper和etcd等实现。
2.1 基于关系型数据库MySql实现
2.1.1 基于数据库的事务功能
实现思路:先查询数据库是否存在记录,为防止幻读取通过数据库行锁select for update锁住这行数据,然后将查询和插入的SQL在同一个事务中提交。
以订单表为例:
select if from order where order_id=xx for update
那么存在如下情况:
对于上述中事务1和事务2,希望要么全部成功要么全部失败;但实际上会根据数据库设置的事务隔离级别决定,不同的隔离级别会出现不同的结果。
数据库系统提供了4种事务隔离级别:
隔离级别 | 脏读(Dirty Read) | 不可重复读(Non Repeatable Read) | 幻读(Phantom Read) |
---|---|---|---|
读未提交 | yes | yes | yes |
读已提交 | - | yes | yes |
可重复读 | - | - | yes |
可串行化 | - | - | - |
数据库的隔离级别越高其系统的并发性能越差。当设置较低事务隔离级别时,容易出现数据不一致情况;当设置隔离级别太高时,处理时长增加,会降低系统可用性。适合对数据一致性要求不高的场景。
2.1.2 基于乐观锁实现
实现思路:在表结构中增加version字段标识数据版本,更新过程中对版本号进行比较,版本号一致,则成功执行,反之,更新失败。
以订单表为例:
# Select 时获取version值
select amount, old_version from order where order_id = xxx;
# update 的时候检查ver值是否与第2步中获取相同的值
update order set version=old_version+1, amount = yyy where order_id =xxx and ver=old_ver
2.3 基于分布式缓存实现
基于缓存的分布式锁,可以避免大量请求直接访问数据库,提高系统的响应能力。主要将分布式锁存在在内存中,提高了读写速度。引入中间件,将分布式锁设置到中间件。
基于中间件实现分布式锁
有如下优点:性能高效、实现方便、避免单点故障
同时存在如下一些问题需要解决:
- 可用性:无论何时都要保证锁服务的可用性;避免服务端单点部署,当节点故障时,锁服务无法提供服务,导致使用锁服务的客户端无法工作。
- 死锁:避免锁一直存在;客户端一定可以获取锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达。
- 脑裂:集群同步时产生的数据不一致,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。
常见的实现分布式锁的中间件有:redis、etcd、zookeeper等。
3. 基于缓存实现分布式锁
3.1 基于Redis实现分布式锁
实现思路:
- 加锁:当键值不存在时,对键值设置操作并返回成功,否则返回失败;
Set lock_key unique_value NX PX 10000;
# lock_key: key键值
# unique_value: 客户端生产的唯一标识
# NX代表只在lock_key不存在时,才对lock_key进行设置操作
# PX 10000表示设置lock_key的过期时间为10s,这是为了避免客户端发生异常无法释放锁
- 解锁:删除lock_key,通过删除键值对释放锁,释放后其他线程可以通过设置setnx获取锁;
- 超时:设置lock_key的超时时间,保证锁在没有显示释放的情况,锁也可以自动释放,这样避免了资源被永远锁住(死锁情况)。
注意事项: - Redis 2.6.12之前的版本中,加锁和设置超时是2步操作,需要注意原子性操作;这里可以选择使用redis 2.6.12之后版本或使用LUA脚本,保证原子性操作。
- del误删:客户端A执行时间超过设置锁的超时时间,导致锁被释放,客户端B获取锁,然后出现客户端A的任务结束,客户端A删除客户端B设置的锁。
为避免这种情况,可以为客户端B设置唯一ID,释放锁时先匹配ID是否一致,然后再删除key。但这种情况无法解决客户端A锁被释放问题。 - 超时解锁导致并发:在上图中存在A客户端A和B同时操作共享资源的情况;为解决该情况,需要合理设置超时时间,但过长的超时时间会增加客户端B获取锁时间;因此,可以通过基于续约设置超时时间;在服务内部,设计监听任务执行情况的程序,当锁快超时但任务未结束时,给锁增加时间。
- redis集群下的分布式锁:在集群中,需要解决锁存在于多个节点的问题。
架构设计层面上Redis怎么解决集群情况下分布式锁的可靠性问题
redis官方设计了分布式算法Redlock。RedLock 算法旨在解决单个 Redis 实例作为分布式锁时可能出现的单点故障问题,通过在多个独立运行的 Redis 实例上同时获取锁的方式来提高锁服务的可用性和安全性。
实现思路:RedLock是对集群的每个节点进行加锁,如果大多数节点(N/2+1)加锁成功,则会认为加锁成功。这样即使集群中有某个节点挂掉了,因为大部分集群节点都加锁成功了,所以分布式锁仍然可以使用。
工作流程:
- 客户端向多个独立的Redis实例尝试获取锁,设置锁的过期时间非常短;
- 如果客户端能在大部分节点上成功获取锁,并且花费的时间小于过期时间的一半,那么认为客户端成功获取到了分布式锁;
- 当客户端完成对受保护资源的操作后,它需要向所有曾获取锁的Redis实例释放锁;
- 若在释放锁的过程中,客户端因无法完成,由于设置了锁的过期时间,锁最终会自动过期释放,避免了死锁。
存在问题: - 性能问题:RedLock需要等待大多数节点返回之后,才能加锁成功,而这个过程中可能会因为网络问题,或节点超时的问题,影响加锁的耗时;
- 并发安全性问题:当客户端加锁时,如果遇到GC可能会导致加锁失效(还有其他类似节点中某一个节点失效等问题),但GC后误认为加锁成功的安全事务,例如:
- 客户端A请求3个节点进行加锁
- 在节点回复处理之前,客户端A进入GC阶段(存在STW,全局停顿)
- 因为加锁时间太长,锁失效
- 客户端B请求加锁(和客户端A请求的是同一把锁),加锁成功
- 客户端A GC完成,继续处理前面节点的消息,误以为加锁成功
- 此时客户端B和客户端A同时加锁成功,出现并发安全性问题
- redis集群内节点的时间要保持一致:若节点时间不一致会存在时间差导致加锁失败
- Other:Distributed Locks with Redis
Go实现redis分布式锁
3.2 基于Zookeeper实现分布式锁
Zookeeper分布式锁的实现,主要利用了Zookeeper的临时顺序节点特点、监听机制,保证锁的公平性、可重入。
大致思想是:1)请求排队,先来先得;2)上一个任务结束后释放锁并通知下一个排队对象;3)当前排队对象判断是否满足某个规则(获取锁的规则),若是则获取锁。具体实现原理如下:
- ZooKeeper的每一个节点,都是一个天然的顺序发号器
在每个节点下面创建临时顺序节点(EPHEMERAL_SEQUENTIAL)类型,新的子节点后面,会加上一个次序编号,而这个生成的次序编号,是在上一个生成次序编号基础上+1 - ZooKeeper节点的递增有序性,可以确保锁的公平
一个Zookeeper分布式锁,首先需要创建一个节点,尽量是持久节点(PERSISTENT类型),然后每个要获得锁的线程,都在这个节点下创建个临时顺序节点。由于ZK节点,是按照创建的次序,依次递增的。为了确保公平,可以简单的规定:编号最小的那个节点,表示获得了锁。所以,每个线程在尝试占用锁之前,首先判断自己是排号是不是当前最小,如果是,则获取锁。 - ZooKeeper的节点监听机制,可以保障占有锁的传递有序而且高效
每个线程抢占锁之前,先尝试创建自己的ZNode。同样,释放锁的时候,就需要删除创建的Znode。创建成功后,如果不是排号最小的节点,就处于等待通知的状态。等待前一个ZNode删除事件通知,收到上一个Znode删除事件时,判断自己是否为当前最小的序号,若是则获取锁。
使用ZooKeeper实现分布式锁的算法,主要有以下几个步骤: - 一把分布式锁通常使用一个Znode节点表示;如果锁对应的Znode节点不存在,首先创建Znode节点。这里假设为“/test/lock”,代表了一把需要创建的分布式锁;
- 抢占锁的所有客户端,使用锁的Znode节点的子节点列表来表示;如果某个客户端需要占用锁,则在“/test/lock”下创建一个临时有序的子节点;(这里,所有临时有序子节点,尽量共用一个有意义的子节点前缀。)
- 判定客户端是否占有锁。客户端创建子节点后,需要进行判断:自己创建的子节点,是否为当前子节点列表中序号最小的子节点;如果是,则认为加锁成功;如果不是,则监听前一个Znode子节点变更消息,等待前一个节点释放锁;
- 监听上一个节点变更通知,判断自己是否为当前子节点列表中序号最小的节点,若是则加锁成功;否则继续监听,直到获取锁;
- 获取锁后,处理业务逻辑,完成之后,删除对应的子节点,完成锁释放工作。
存在的问题: - 并发安全问题:zookeeper若长时间检测不到客户端的心跳时,会认为session过期,那么由该session创建的所有ephemeral类型的znode节点会被自动删除,这时就会出现如下的问题:
如上图所示,客户端A发生GC停顿的时候,zookeeper检测不到心跳,也是有可能出现多个客户端同时操作共享资源的情形。 - 性能方面:每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。在Zookeeper 中创建和删除节点只能通过 Leader 服务器来执行,然后 Leader 服务器还需要将数据同步到所有的 Follower 机器上,这样频繁的网络通信,性能的短板是非常突出的;zookeeper是一个XP系统,在网络分区情况下,系统优先保证一致性,可能牺牲可用性。
Go 使用zookeeper实现分布式锁
3.3 基于ETCD实现分布式锁
ETCD支持以下功能,并依赖这些功能来实现分布式锁:
- Lease机制:即租约机制(TTL,Time to Live),Etcd可以为存储的key-value对设置租约,当租约到期,key-value将失效删除;同时也支持续约续期(KeepAlive)
- Revision机制:每个Key带有一个Revision属性值,Etcd每进行一次事务对应的全局Revision值都会加一,因此每个key对应的Revision属性值是全局唯一的。通过比较Revision的大小可以知道进行写操作的顺序。在实现分布式锁时,多个程序同时抢锁,根据Revison值大小依次获得锁,可以避免惊群效应,实习公平锁
- Prefix机制:即前缀机制(或目录机制)。可以根据前缀(目录)获取该目录下所有key及对应的属性(包括key、value及revision等)
- Watch机制:即监听机制,Watch机制支持Warch某个固定的Key,也支持Watch一个目录前缀(前缀机制),当被warch的key活目录发生变化,客户端将收到通知
实现分布式锁的步骤: - 假设分布式锁的Name为/root/lockname,用来控制某个共享资源,concurency会自动将其转换为目录形式:/root/lockname
- 客户端A连接Etcd,创建一个租约Leaseid_A,并设置TTL,以/root/lockname为前缀创建全局唯一的key,该key的组织形式为/root/lockname/{leaseid_A},客户端A将此key绑定租约写入Etcd,同时调用TXN事务查询写入情况和具有相同前缀/root/lockname/的Revision的排序情况
- 客户端A判断自己是否获得锁,以前缀/root/lockname/读取keyValue列表(keyValue中带有key对应的Revison),判断自己Key是否为当前列表中最小的,如果是则认为获得锁;否则阻塞监听列表中前一个Revison比自己小的Key删除事件,一旦监听到删除事件或因租约失效而删除的事件,则自己获得锁
- 执行业务逻辑,操作共享资源
- 释放分布式锁:业务逻辑执行完或异常退出时,删除锁;
- 当客户端持有锁期间,其他客户端只能等待,为避免等待期间租约失效,客户端需要创建一个定时任务进行续约操作;若持有锁期间客户端崩溃,心跳停止,key将因租约到期而被删除,从而锁被释放,避免死锁
Go 基于Etcd实现分布式锁
总结
在分布式系统中,常见的实现分布式锁方式是基于数据库、基于分布式缓存方式等;其中基于数据库方式主要依赖于数据库的事务功能或表结构的实现乐观锁,基于分布式缓存方式常用的有redis、etcd、zookeeper等;redis实现分布式锁性能最好、zookeeper和etcd实现可靠性更好;其中zookeeper和etcd实现方式比较类似,但etcd在通讯、锁设置上性能较zookeeper更好。
参考资料
为什么需要分布式锁?(Redis分布式锁)
mysql事务隔离
Distributed Locks with Redis
Go实现redis分布式锁
Zookeeper 分布式锁 – 图解 – 秒懂
浅谈分布式锁:安全与性能的取舍之道
Zookeeper分布式锁
Redis分布式锁
为什么分布式要有分布式锁!
Etcd 应用开发之分布式锁