ETCD源码阅读(四)

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)

client.go 源码分析

最主要的一部分代码逻辑如下:

    client, err := clientv3.New(clientv3.Config{
		Endpoints: []string{"http://127.0.0.1:2379", "http://127.0.0.1:22379", "http://127.0.0.1:32379"},
	})
    // ...
    session, err := concurrency.NewSession(client, concurrency.WithTTL(sessionTTL))
	if err != nil {
		fmt.Printf("failed to create a session: %s\n", err)
		os.Exit(1)
	}
    locker := concurrency.NewLocker(session, "/lock")
	locker.Lock()
	defer locker.Unlock()
	version := session.Lease()
	fmt.Printf("acquired lock, version: %d\n", version)
    // ...
    // 如果是client1:执行STW GC

client先创建一个ETCD客户端,然后获取一个带TTL的租约,再用这个租约去ETCD申请一个锁。锁的版本号就是租约的ID。

接着,client1会故意执行一个长时间的STW GC,使得租约失效;与此同时,client2也会执行上面的操作,但不执行GC

    // 数据更新请求,发送给storage服务
    err = write("key0", fmt.Sprintf("value from client %d", mode), int64(version))
	if err != nil {
		fmt.Printf("failed to write to storage: %s\n", err) // client 1 should show this message
	} else {
		fmt.Printf("successfully write a key to storage\n")
	}

client2能够成功将数据写入; 而client1会因为数据已被client2写入,导致记录的版本号与client1的版本号不同,而写入失败。

storage.go 源码分析

主要代码逻辑在handler中:

    if strings.Compare(req.Op, "read") == 0 {
		if val, ok := data[req.Key]; ok {
			writeResponse(response{val.val, val.version, ""}, w)
		} else {
			writeResponse(response{"", -1, "key not found"}, w)
		}
	} else if strings.Compare(req.Op, "write") == 0 {
		if val, ok := data[req.Key]; ok {
			if req.Version != val.version {
				writeResponse(response{"", -1, fmt.Sprintf("given version (%d) is different from the existing version (%d)", req.Version, val.version)}, w)
			} else {
				data[req.Key].val = req.Val
				data[req.Key].version = req.Version
				writeResponse(response{req.Val, req.Version, ""}, w)
			}
		} else {
			data[req.Key] = &value{req.Val, req.Version}
			writeResponse(response{req.Val, req.Version, ""}, w)
		}
	} else {
		fmt.Printf("unknown op: %s\n", req.Op)
		return
	}

首先检查request的op code:

  • 如果等于read,那么就返回key-val以及版本号,不存在则返回错误信息。
  • 如果等于write,那么就检查这个key是否存在,如果不存在就直接写入,存在就会检查request的version id是否与先前存储的id相等。
posted on 2023-04-02 15:37  夕午  阅读(136)  评论(0编辑  收藏  举报