本文分为两部分,首先总结分布式锁的应用场景,然后介绍分布式锁的几种实现方式及原理,其中包括数据库、zookeeper、Redis 和 Etcd 分布式锁的实现。
锁的应用场景
锁的应用场景主要在并发的情况下。并发读或者并发写都会产生问题,解决数据的不准确性以及避免资源的浪费。
并发写会导致数据的不准确性。比如在 Java 中的 HashMap 的并发写操作、多人在线文档编辑、商品超卖问题(两个人同时下单,商品库存同时减一,导致商品超卖)等。
并发读也会对数据库产生压力,在高并发的场景,同一服务部署多个实例,相同的请求并发执行两次,这两次分别调用这个服务的两个实例,为了避免重复执行,需要引入「全局锁」来判断是否其他服务已经执行过。
分布式锁的实现方式
分布式锁的实现为数据库方式实现、zookeeper 实现、Redis 实现和 Etcd 实现。实现分布式锁除了满足基本的加锁和释放锁,最好满足以下四个设计原则:
- 分布式锁的相关组件必须为高可用,满足高可用的获取锁和释放锁
- 分布式锁可以设置超时时间,避免其中一个客户端挂掉而其他客户端一直阻塞
- 最好满足两个功能,第一个功能线程获取锁失败则立刻返回,另外一个就是获取锁失败则加入阻塞队列(AQS)
- 实现锁的可重入
数据库实现
新增一个底层基础服务,所有需要加锁和释放锁等操作都由这个基础的服务完成。在基础的服务中,利用数据库的唯一索引解决加锁和释放锁的操作。如下所示,新建一张基础的表结构,通过插入数据是否成功来判断是否加锁成功,释放锁则将数据删除掉。
sql
CREATE TABLE `basic_lock_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`base_key` varchar(100) NOT NULL DEFAULT '' COMMENT 'key 信息',
`base_value` varchar(500) NOT NULL DEFAULT '' COMMENT '加锁描述',
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间',
PRIMARY KEY (`id`),
UNIQUE KEY (`base_key`, `base_value`),
) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4;
以上实现是满足基本的加锁和释放锁的条件,再来对比上文提到的四个条件,来看下使用数据库实现分布式锁的优点和缺点。
- 对于单点数据库来说,不满足高可用的条件。解决这个问题,可以使用分布式数据库,比如 TiDB,但对于高并发的条件下仍存在将数据库打挂的风险。
- 设置超时时间,可以在上面的表结构再额外增加一个超时时间的字段,然后使用定时任务定期轮训,超期的数据直接删除。额外引入定时任务,提高锁的设计复杂性。
- 满足如果获取失败则立刻返回获取锁失败,但是不满足排队阻塞获取锁条件。如需满足此条件,可以在代码中使用一定次数循环去获取锁,一定次数是为了避免耗尽服务的 CPU 资源。
- 满足可重入条件,在表中增加字段区分不同服务不同请求,进入临界区的线程先检查是否已经获取锁,如果获取锁,则在表中更新数据将锁的次数加一,返回数据。
zookeeper 实现
zookeeper 为分布式协调服务,主要解决分布式集群中的一致性问题。其本质是一个树形的文件系统,主要是以集群形式存在,如下图所示:
下面介绍下 zookeeper 的节点类型、zookeeper 角色和 watch 功能,分布式锁以及其他的使用场景都是以其节点类型和 watch 功能所实现的。watch 功能是客户端可以监听目录节点的变化,目录节点一旦变化,则会通知给相应的客户端。
zookeeper 的节点类型
- 临时节点:客户端会话结束后会删除该节点,且该节点不可能永远子节点。
- 永久节点:创建后将永久存在,直到客户端主动删除为止
- 顺序节点:创建后由父节点维护递增的整型数字
zookeeper 的角色
- leader: 处理事务请求,发起投票,更新数据。
- follower: 参与同步,处理非事务请求返回给客户端,转发事务给 leader 节点。
- observer: 不参与投票,仅同步数据,转发事务给 leader 节点,提高读写速度。
加锁的过程如下:
- 创建一个永久节点 /lock_1
- 加锁的服务在 /lock_1 下创建临时有序节点
- 服务获取全部的 /lock_1 下的节点,判断自己是否为最小的节点,如果是最小的节点,则代表获取锁,不是最小的节点,监听比自己小的节点。
- 解锁时将最小的节点删除掉,其他节点重复该过程
以上是实现锁的基本功能,我们来看下是否满足其他的条件:
- zookeeper 中本身就是一个高可用的分布式集群组件,拥有 zab 协议来支持崩溃恢复,同时可以新增observer 来提高服务的 qps。
- zookeeper 本身提供 api 创建节点时候支持传递 timeout 来实现超时控制。如果客户端因为异常挂掉,则该临时节点将会自动删除,也满足释放锁的条件。
- 上述介绍加锁和释放锁已经满足获取锁失败加入阻塞队列的条件。当获取锁失败立刻返回则可以判断,如果创建的 znode 节点不是最小节点后可以将本节点删除掉,立刻返回。
- 当一个客户端进入临界区,先检查进入临界区的客户端是否已经获取锁,如果获取锁,将该节点的数据可重入次数加一,释放锁将可重入次数减一,根据可重入次数来判断是否删除锁。
Redis 实现
Redis 分布式锁的实现有两种方式,分别是 SETNX 和 Redlock。
SETNX
SETNX 判断 Key 是否存在,如果不存在,将 key 写入数据库中,获取锁成功。如果 key 已经存在,说明已经有其他线程获取锁。SETNX 在单实例的情况下使用。具体使用命令如下所示:
js
set my_key random_value NX PX 2000
- key:加锁的唯一标识,仅在 key 不存在的情况下代表加锁成功
- value:设置随机数
- PX: 锁的超时时间
在释放锁的情况下,可以通过随机值判断是否为本线程释放锁(随机值设置为获取锁的线程号)。设置一个随机值是避免释放其他线程设置的锁,具体例子,假如线程 A 获取锁并执行相应的逻辑,此时因为超时释放锁,线程 B 获取到锁,此时线程 A 执行完毕,但是释放的确实 B 的锁。
对于如何避免锁因为超时释放被其他线程所持有,有两种解决办法,第一种是设置合理的超时时间,第二种是启动一个守护线程进,守护线程判断客户端是否还持有锁,持有锁进行延期。使用 Java 客户端可以使用 Redission 库,此库封装守护线程实现此过程,守护线程通常叫 watch dog。除了设置守护线程,Redission 还封装很多易用的功能:
- 可重入锁
- 公平锁
- 读写锁
- RedLock
RedLock
使用 RedLock 是为了提高分布式锁的高可用性。解决由于单点故障主从切换使两个客户端同时获取锁,当线程 A 获取到锁,此时主节点因为故障挂掉,从节点没有同步主节点的锁数据,从节点切换到主节点时,其他线程就会获取到锁。
RedLock 的核心思想如下所示:
- N 个独立节点,无副本,互不依赖(非集群),N 大于等于 3
- 在有限的时间内,如果客户端在超过一半以上的节点获取锁,则认为获取锁成功
RedLock 一般需要 redis 支持 lua 脚本,对于集群模式下 coredis 或者 tmemproxy 是不支持的,这类集群模式下不支持 redis 脚本。
redis 实现分布式锁一般是以 AP 方式实现,如果想要 CP 推荐使用 zookeeper 或者 etcd 实现方式。
基于 etcd 实现
etcd 介绍
go 实现,使用 raft 算法的 cp 模型,性能高,其核心功能如下所示:
- 分布式存储:etcd 的数据是分布式存储的,多个节点会通过 Raft 一致性协议同步数据。每个节点都会持久化存储数据。
- 基于 Restful Api 访问接口
- 监听机制:etcd 提供 watch 功能,可以监听某个键值的变化。可以实现分布式锁和分布式配置中心等功能。
etcd 的 watch 功能 与 informer 机制
etcd 的 watch 机制与 informer 的 list-watch 机制都是为了实现数据的实时同步和监听而存在的,它们的关系如下:
- Etcd 是 Kubernetes 中的一个重要组件,用于提供分布式键值存储,Kubernetes 中的所有配置信息和状态数据都存储在 Etcd 中。
- Etcd 提供了 Watch API,可以让客户端监听指定节点的数据变化,并在数据发生变化时及时通知客户端。
- Kubernetes 中的 Informer 利用了 Etcd 的 Watch API 实现了 List-Watch 机制,可以监听 Kubernetes 中的资源对象的变化。在 Informer 的运行过程中,首先会通过 List 接口获取当前所有符合条件的资源对象列表,然后通过 Watch 接口监听资源对象的变化。一旦资源对象的状态发生变化,Etcd 就会立即通知 Informer,Informer 会重新调用 List 接口获取最新的资源对象列表,并更新缓存中的数据,最终通知应用程序监听到这些变化。
Etcd 的 Watch 机制是 Kubernetes Informer 实现 List-Watch 机制的重要基础。在 Kubernetes 中,Informer 可以监听多种类型的资源对象的变化,比如节点、Pod、Service 等,都是基于 Watch API 实现的。Watch 机制可以将数据变化的推送模式转换为轮询的拉取模式,大大减少了网络和 CPU 的消耗,同时也能保证数据的实时性和准确性。
etcd 实现分布式锁
- Lease 机制:租约机制(TTL, time to live), etcd 支持为存储的 key-value 对进行设置租期,在租期到来之时将其删除,也支持通过客户端续租,避免程序未执行完将其删除掉。当客户端出现故障时超时也会删除掉,避免死锁
- Revision 机制 / Prefix 机制:每一个 key 带有一个 revision 版本号,每次 put 版本号进行加一。多个事务进行 put 时,根据版本号的大小实现获取锁逻辑。
- Watch 机制:etcd 的 watch 可以监听某个固定的 key 或者根据某个 key 的前缀范围进行监控,当被监听的 key 发生变化时,会通知到客户端。对于实现分布式锁,客户端可以监听比自己小的 key,如果 key 删除或者租约到期,则进行尝试获取锁逻辑。