分布式锁的目的是为了确保几个节点在处理一些任务的时候,同一个时间只有一个(或者最多只有一个)执行某任务。这些任务可能向共享存储中写入数据,或者执行某些计算。从宏观视角来看,使用锁无法两个目的:效率和准确。为了区别这两个目的,我们可以看一下没有锁会发生什么
- 效率:采用锁避免执行多次同一个任务,特别某些昂贵的操作。如果没有锁的情况下,那么结果会明显增加执行任务的代价。
- 准确:通过锁避免同时更改数据,从而扰乱系统状态。这里如果没有锁,那么可能结果是文件损坏,数据丢失等严重的不一致状态。 两个目的都需要锁,但是你要非常清楚你使用锁的出发点。
避免资源竞争
我们首先讨论一下分布式的一般用法。由于节点宕机和网络波动等现实问题的存在,分布式锁比多线程应用的互斥量要复杂得多。
例如,有个应用需要更新一个共享存储中的文件,这时候需要先上锁,然后读取文件并做出相应修改,接着写回文件,最后释放锁。这里锁避免了多个客户端同时更改同一个文件。这部分实现如下代码:
// THIS CODE IS BROKEN
function writeData(filename, data) {
var lock = lockService.acquireLock(filename);
if (!lock) {
throw 'Failed to acquire lock';
}
try {
var file = storage.readFile(filename);
var updated = updateContents(file, data);
storage.writeFile(filename, updated);
} finally {
lock.release();
}
}
上述代码在分布式应用中非常场景。不幸的是上面的代码是有问题的,即使你有这非常优秀的锁服务。下面的图会说明这部分代码的问题所在

在这个例子中,Client 1成功申请了锁,但是由于 GC 的介入导致锁超时(为了满足 liveness 的要求,超时是非常有必要的),并且锁服务认为相应的锁已经释放了。但是此时 Client 1并没有意识到锁处于超时状态,因此还会继续执行任务。就在锁释放后 Client 2 成功申请了该资源锁,因此此时有两个 Client 同时拿着同一个资源锁,违背了 Safety 要求。
上述 Bug 不是一个纯理论的存在,HBase 就曾经遇到过 类似问题 。而且 GC 特别是 Full GC 导致超时并不少见,在实际生成环境中也遇到过由于 GC 导致Spark Executor 心跳丢失等问题。
这里需要注意的是,上述场景只是改问题中的个例,千万不要认为在写数据之前检测锁的有效性就能避免上述Bug,GC 是可能发生在任何时候的。
如果还觉得自己的程序肯定不会有长时间的 GC。即便如此这个 Bug 还是存在,因为除了 GC 时间,还有网络波动带来的延迟,从磁盘加载延迟等风险存在,甚至提供锁服务的机器的 Timer 向前跳跃等等问题的存在。总而言之,在分布式的语义下,节点的宕机后恢复,网络等等都是默认存在的,因此在设计算法的时候必须要考虑的。更不能轻易假设某些因素肯定不存在。
Fencing
确保锁安全解决上述问题的方法非常简单,你只需要在每一次写操作的时候带上一个fencing token。fencing token 可以是一个单调递增的数,在每次申请到锁的时候获得。我们将结合下图来具体说明

Client 1 获得锁的同时得到了值为33的 token,但是同时也经历了很长时间的机器停顿直到锁超时。因此Client 2 获得了该锁同时获得34的 token,并且使用该 token 向存储服务写入了数据。
Client 1 从停顿中恢复,将带有33的 token 的写请求发送到存储服务。然而此时存储服务器已经接收了34的 token 因此拒绝了 Client 1的写入请求。
上述方法需要存储服务能够提供token 检测到功能,这实现起来并不复杂。关于 token 我们可用通过 zookeeper 作为锁服务,zxid 或者 znode的版本作为 fencing token。
Fencing Token
上面是关于分布式锁全部主要内容。但是细细想来还是让人困惑的。主要有以下几点
如果在 Client 2拿到锁后开始 GC,此时 Client 1恢复并且成功写完,最后 Client 2恢复,也成功写完。fencing 无冲突。
文章开始已经确定了一个为什么加锁的范围。其中并不包括数据的不一致,或者不是为了解决冲突的,更没有事务的概念,例如 read(A)和 Write(A) 这样不在考虑范畴内。因此这里对于写这个操作发生的这段时间,锁是处于安全状态(近似安全,毕竟还是多Client持有锁)。
如何确保带 token 的写请求的原子性。如果说 lock 在此时已经是不安全的了,那是不是和没有锁没有区别了呢?既然如此,也就是说存储层确保了资源的安全。那么对于数据准确这一目的说,只要存储成做到能够检测 token 这一功能,锁都可以不需要了?感觉这里把资源冲突的问题向下层抛去了,并没有从根本上解决问题。
关于这个疑问,我找了好多资料也想了好久,依然没想答案。