分布式锁分析

分布式锁的目的是为了确保几个节点在处理一些任务的时候,同一个时间只有一个(或者最多只有一个)执行某任务。这些任务可能向共享存储中写入数据,或者执行某些计算。从宏观视角来看,使用锁无法两个目的:效率和准确。为了区别这两个目的,我们可以看一下没有锁会发生什么

  • 效率:采用锁避免执行多次同一个任务,特别某些昂贵的操作。如果没有锁的情况下,那么结果会明显增加执行任务的代价。
  • 准确:通过锁避免同时更改数据,从而扰乱系统状态。这里如果没有锁,那么可能结果是文件损坏,数据丢失等严重的不一致状态。 两个目的都需要锁,但是你要非常清楚你使用锁的出发点。

避免资源竞争

我们首先讨论一下分布式的一般用法。由于节点宕机和网络波动等现实问题的存在,分布式锁比多线程应用的互斥量要复杂得多。

例如,有个应用需要更新一个共享存储中的文件,这时候需要先上锁,然后读取文件并做出相应修改,接着写回文件,最后释放锁。这里锁避免了多个客户端同时更改同一个文件。这部分实现如下代码:

// 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();
    }
}

上述代码在分布式应用中非常场景。不幸的是上面的代码是有问题的,即使你有这非常优秀的锁服务。下面的图会说明这部分代码的问题所在

unsafe lock

在这个例子中,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 可以是一个单调递增的数,在每次申请到锁的时候获得。我们将结合下图来具体说明

fencing lock

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 这一功能,锁都可以不需要了?感觉这里把资源冲突的问题向下层抛去了,并没有从根本上解决问题。

关于这个疑问,我找了好多资料也想了好久,依然没想答案。

看法

最近的文章

事务隔离笔记

有人觉得为了支持两阶段提交,而对性能和可用性造成的影响,这样的代价太高。而我们却认为把这些滥用事物的性能问题留给应用程序员,而不是让他们去处理事物一致性的问题。 —James Corbett et al., Spanner: Google’s Globally-Distributed Database(2012)闲聊事务事务被创造本来就是为了简化编程模型。让下层数据库对于并发的处理,上层应用尽量够忽略潜在的并发问题。但是也不是所以应用都需要事务,为了更好的性能或可用...…

继续阅读
更早的文章

分布式锁的问题

在review导入表数据和结构处的重构时候,看到下面的逻辑,偶然发现了一个问题。 表A加写锁,加锁失败就等待。 写入数据,涉及大量的数据导入 释放锁和异常处理一个正常的锁,但是当时看到一个很老的注释,大概意思是尝试捕获OOM的异常,将表删除和锁释放掉。于是发现这里如果因为GC等待时间过程,被Server认为锁失效了那岂不是锁非常不安全了?而这是带有超时时间锁的通病。查找了一下资料发现这个问题很早就讨论过。首先简单解释一下通用的分布式锁模型。 Client A 向锁服务提供方(Ser...…

继续阅读