![](https://i-blog.csdnimg.cn/blog_migrate/cd2424b5687a9a855fc617afb9a3d0b3.png)
分布式锁就那点事
分布式场景要求“锁”在某个公共的地方
在单机场景中,为了同步多线程对同一份数据的操作,我们可以利用编程语言自带的工具来构建锁。如,Java 的 synchronized 关键字、ReentrantLock 类等。对于同一服务器上的多个进程,我们可以使用操作系统级别的“锁”来实现同步。如,C# 中以 “Global\” 开头作为 Mutex 的 Name。
在分布式场景中,有多个实例(线程或进程)分布在多个服务器上,我们就需要将“锁”放在这些某个公共的地方,让这些实例都能访问。之前单机场景中,那种只能服务器本地访问的“锁”方案就无法满足这类场景了。
对于使用 “分布式锁” 的客户端来说,它们只关心 “获取锁” 和 “释放锁”。所以 “分布式” 对它们而言,主要指这些客户端是分布在多个服务器上的。
对于实现 “分布式锁” 的服务端来说,“分布式” 往往意味着有多个分布在不同服务器上的实例进行合作,对外提供统一的 “分布式锁” 服务。在客户端眼里,这些服务端实例是一个整体的黑盒;客户端无需了解这些服务实例之间如何协作的细节。
可以近似地认为这就是之前《ZooKeeper 使用硬核分析》中提到的分布式系统中两种进程协同方式:
- 客户端实例之间通过“分布式锁”这个共享存储来实现“沟通”;
- 服务端实例之间往往是直接通过网络进行信息交换。
“锁”也是数据,存放的地方不止一种(数据库、Redis、ZooKeeper)
在很多年前,因为没有特别方便的服务来充当这个“公共的地方”,很多小项目会借助关系型数据库(如,MySQL)来存放“锁”。利用数据库主键唯一的特性可以很方便地实现简易的锁。即使在 Redis、ZooKeeper 大行其道的今天,这种方案在特定场景中仍然是综合性价比很好的方案,尤其是考虑到后期运维成本的情况下。
现在很多项目都优先考虑 Redis 或 ZooKeeper 来实现分布式锁。
对于数据一致性要求高的场景,ZooKeeper 是不错的选择。如,其客户端库 Curator 提供的 DistributedBarrier 可以满足大多数业务场景。注意:在 ZooKeeper 3.5.0 之前,很多人会基于 ZooKeeper 自己实现一套分布式锁方案,并使用 Curator 的 Reaper 或 ChildReaper 类来清理那些“过期”的锁节点。而 Reaper 和 ChildReaper 内部是以 客户端轮询 的方式来发现“过期”的锁节点,非常低效。新的 Curator 中已废弃这两个类。
对于性能要求高的场景,Redis 是不错的选择。其客户端库 Redisson 提供的分布式锁方案同样可以满足大多数业务场景。
要用好“锁”,还得结合业务小心设计
Redis 本身在数据一致性方面比不上 ZooKeeper。在运维和业务逻辑鲁棒性方面需要更加注意。当然 ZooKeeper 也并不能完全保证业务数据的一致性。关键还得看我们的业务实现中是如何应用这些技术的。
如,之前《ZooKeeper 实用硬核分析》中就提到过一个案例。在客户端系统负载过高的情况下,因为操作系统线程调度的不可预知性,导致客户端的业务线程未被通知到已失去“主实例”角色,继续访问数据库,导致与新的主实例协同失败。
![](https://i-blog.csdnimg.cn/blog_migrate/d369e8124a599a8775bba232fcd5d458.png)
这类情况是分布式系统设计中容易被忽略的场景。下文以某种 Redis 分布式锁 实现方案为例,来展示另一种经常被忽略的场景。
Redis分布式锁——误“解锁”
一种有缺陷的分布式锁实现方案
首先声明,这并不是只有使用 Redis 才会存在的问题。该问题之所以在 Redis 应用中常见,是因为早期利用 Redis 实现分布式锁的方案中,解锁操作只是粗暴地调用 Redis 命令 DEL,埋下了隐患。某些知名的大型开源项目中也存在过这个问题。
这类实现方案的基本原理通常是这样的:
加锁
使用 Redis 的 SET 命令添加一个 key,并且指定仅当 key 不存在时才执行,同时为这个 key 设置一个过期时间。
命令示例:SET test:lock "" EX 360 NX
上述命令中指定锁的 key 为 test:lock,value 为空字符串(不发挥value的任何效用),过期时间为360秒,且仅当 key 不存在时才执行。
解锁
使用 Redis 的 DEL 命令直接删除“锁”所在的 key。
命令示例:DEL test:lock
一个触发缺陷的案例
![](https://i-blog.csdnimg.cn/blog_migrate/cd2424b5687a9a855fc617afb9a3d0b3.png)
上图所示案例中,有三个 Redis 客户端,它们通过前述的方案实现 Redis 分布式锁,以协调对业务数据的独占式访问。
- 首先是客户端 C1 获得锁,并对业务数据进行操作。
- 当它操作结束后,其所在服务器负载过高,导致释放锁的请求未能及时发出。
- 在 C1 高负载未发出解锁命令期间,锁对应的 key 过期了,被 Redis 自动清理了。
- 还是在 C1 高负载未发出解锁命令期间,锁自动过期后,C2 成功获得了锁,并开始操作业务数据。
- 在 C2 操作业务数据期间,C1 的解锁命令终于发出来了,并错误地将 C2 加的锁解除了。
- 还是在 C2 操作业务数据期间,锁被误“解除”后,C3 成功获得了锁,导致与 C2 同时操作业务数据。协同失败!
解决方法:增加唯一标识,表示谁获得了锁
上述问题可认为是 A-B-A 问题的一个变种。解决方法也很简单,就是给锁增加一个唯一标识,作为 value,来表示当前是哪个客户端获得了这个锁;在执行解锁操作时,先检查锁是不是客户端自己持有的,只能释放自己持有的锁。
唯一标识的生成方法可以根据具体业务选用合适的方案。UUID version-4 就是常用的一种方案。Snowflake 也很流行。有时候使用服务器的IP地址也是个性价比很高的方案。甚至为每个客户端实例人为配置独有固定值也可以。总之就是要结合业务选择方案。
示例:
加锁
SET test:lock jack EX 360 NX
此处“jack”表示是 jack 这个客户端获得锁。
解锁
为了保证操作的原子性,我们不能在客户端本地判断锁的持有者。即,应将 “判断锁持有者” 和 “释放锁” 这两个操作交由 Redis 服务端一起执行。这里可以使用 Redis 的脚本特性来实现。我们将这两个操作写在一个 Lua 脚本中,调用 Redis 的 EVAL 命令来实现。
命令示例:EVAL script 1 test:lock jack