在单机场景中,为了同步多线程对同一份数据的操作,我们可以利用编程语言自带的工具来构建锁。如,Java 的 synchronized 关键字、ReentrantLock 类等。对于同一服务器上的多个进程,我们可以使用操作系统级别的“锁”来实现同步。如,C# 中以 “Global\” 开头作为 Mutex 的 Name。
在分布式场景中,有多个实例(线程或进程)分布在多个服务器上,我们就需要将“锁”放在这些某个公共的地方,让这些实例都能访问。之前单机场景中,那种只能服务器本地访问的“锁”方案就无法满足这类场景了。
对于使用 “分布式锁” 的客户端来说,它们只关心 “获取锁” 和 “释放锁”。所以 “分布式” 对它们而言,主要指这些客户端是分布在多个服务器上的。
对于实现 “分布式锁” 的服务端来说,“分布式” 往往意味着有多个分布在不同服务器上的实例进行合作,对外提供统一的 “分布式锁” 服务。在客户端眼里,这些服务端实例是一个整体的黑盒;客户端无需了解这些服务实例之间如何协作的细节。
可以近似地认为这就是之前《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 实用硬核分析》中就提到过一个案例。在客户端系统负载过高的情况下,因为操作系统线程调度的不可预知性,导致客户端的业务线程未被通知到已失去“主实例”角色,继续访问数据库,导致与新的主实例协同失败。
这类情况是分布式系统设计中容易被忽略的场景。下文以某种 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
上图所示案例中,有三个 Redis 客户端,它们通过前述的方案实现 Redis 分布式锁,以协调对业务数据的独占式访问。
上述问题可认为是 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
其中 script 的内容如下:
if ARGV[1] == redis.call('GET', KEYS[1])
then
return redis.call('DEL', KEYS[1])
else
return 0
end
注意 Lua 的语法。上述示例脚本中,每行末尾有空格。