ETCD源码阅读(四)

发布时间 2023-04-02 15:40:31作者: 夕午

DAY3 :ETCD分布式锁: etcd/contrib/lock

这一部分代码主要是为了展示ETCD实现分布式锁的原理(Lease),并且贴出了 DDIA作者的一篇博文作为应用场景建模。那么我们就先来读这篇博文吧。

为什么要使用分布式锁

  1. 防止数据竞争:多个分布式下节点可能会同时修改同一份数据,如果不加锁,会导致数据出现错误或不一致的情况。

  2. 避免重复操作:某些操作只需要在分布式系统中执行一次,如果不使用分布式锁来控制并发访问,就有可能导致操作被重复执行,导致资源浪费甚至严重后果。

  3. 保证顺序访问:某些操作必须按照特定的顺序执行,例如在订单系统中,生成订单和扣款必须按照先后顺序执行。使用分布式锁可以确保这些操作的顺序性。

使用分布式锁来保护资源

设想这样一个场景:某一个应用需要修改一个HDFS中的文件,那么某一个client就会试着去获得一个锁;然后读取文件,将文件进行修改后上传回HDFS;最后释放锁。

下面便是一个示例代码。

    func writeData(filePath string) error {
        lock, err := lockService.acquireLock()
        if err != nil {
            // ...
            return err
        }
        defer lock.release()

        file, _ := storage.readFile(filePath)
        // update file
        // ...
        storage.writeFile(filePath, file) 
        return nil
    }

但实际上,这样的设计并不能正常工作,下面这张图展示了一种出错场景。
Alt text
假设client获取锁后,由于GC等问题,产生了锁过期(锁对应着一个租约)的情况(这是一个必要的设计,避免client永久性获得一个锁),而client并不知道锁已经过期。这种情况下,多个client会产生数据竞争,搞乱数据。这样的bug在老版本的Hbase中存在过。

在往HDFS写数据前再检查一次锁过期情况可以吗?不行,因为GC随时有可能发生,检查锁状态与回写数据这个两个操作不可能是原子的。

解决方案:fencing token( version number validation)

fencing token是一个单调递增的数字,当client成功获取锁的时候,将这个token与锁一起返回给client。而client访问共享资源的时候,需要带着这个fencing token
Alt text
至此,关于这篇博文的内容已经结束,我们已经学习到了一种分布式锁的设计方法。值得一提的是,在这篇博文中,作者对于Redis的Redlock机制提出的一些批判,Redlock的发明者也针对这些批判发起了回应,参见这篇文章

ETCD的分布式锁设计

ETCD在很多场景下被用作一个分布式协调服务,ETCD提供了event watch、lease、选主、共享分布式锁(并不是严格的分布式锁,持有锁的用户并不拥有资源的独占访问)等机制。

那么如何使用ETCD实现一个分布式锁呢?ETCD的文档种给出了详细介绍以及注意事项Notes on the usage of lock and lease

ETCD提供了一个基于租约机制(lease)的lock API:服务器发放一个租约给客户端,并且给这个租约设置TTL,当服务器检查到TTL过期后,就会收回这个租约。持有合法租约的客户端,可以访问与这个租约关联的资源(比如ETCD内的某个Key)。但是 ETCD的lock API并不能保障资源的互斥访问 我们还需要在ETCD的lock机制之上再进行一些优化。(既然这个lock API不是一个分布式锁,为什么还叫这个名字呢?ETCD官方解释说是 历史原因

ETCD租约机制最大的问题在于TTL。TTl是通过一个物理时钟来进行定义,客户端与服务端都用自己本地的时钟来监测TTl是否失效,而他们的时钟不一定同步。这就有可能产生“客户端认为自己还持有租约,而服务端已经将租约收回”的场景。甚至会有“服务端认为租约还有1s,但是100ms后网络授时服务器对其时钟进行调整,使得租约立马失效”的场景。

因此,ETCD在租约机制之上,还采用了版本号校验机制来实现分布式锁(在其他的系统种,可能被称作cas,compare and swap)。这是一种乐观锁,并不需要真的对数据进行加锁,只会在数据读取时获得一个版本号,写入时检查版本号是否被更新。在ETCD种,我们执行 PutTxn等操作时,可以验证版本号与租约ID,来作为操作条件,如果无法达成条件,则认为操作失败。

Run the Demo

etcd/contrib/lock目录下包含两个程序:client和storage。 用于演示分布式锁的典型场景,比如租约过期的问题。storage是一个非常简单的内存k-v存储库,通过json提供对外的HTTP访问。client根据ETCD分布式锁提供的协调机制,往ETCD里写数据。

编译client与storage

$ cd client
$ go build
$ cd storage 
$ go build 

启动ETCD集群

# ETCD源码根目录
$ ./build
$ goreman start

启动storage以及两个client

$ ./storage

Alt text
根据上图,我们启动两个client,分别代表client1和client2

# 启动client1,会模拟长时间GC
$ GODEBUG=gcstoptheworld=2 ./client 1
client 1 starts
creted etcd client
acquired lock, version: 1029195466614598192
took 6.771998255s for allocation, took 36.217205ms for GC
emulated stop the world GC, make sure the /lock/* key disappeared and hit any key after executing client 2:
# 启动client2
$ ./client 2
client 2 starts
creted etcd client
acquired lock, version: 4703569812595502727
this is client 2, continuing

如果一切顺利,client2将会成功往storage中写入一个数据,而client1会失败:

resuming client 1
failed to write to storage: error: given version (4703569812595502721) differ from the existing version (4703569812595502727)