DAY3 :ETCD分布式锁: etcd/contrib/lock
这一部分代码主要是为了展示ETCD实现分布式锁的原理(Lease),并且贴出了 DDIA作者的一篇博文作为应用场景建模。那么我们就先来读这篇博文吧。
为什么要使用分布式锁
-
防止数据竞争:多个分布式下节点可能会同时修改同一份数据,如果不加锁,会导致数据出现错误或不一致的情况。
-
避免重复操作:某些操作只需要在分布式系统中执行一次,如果不使用分布式锁来控制并发访问,就有可能导致操作被重复执行,导致资源浪费甚至严重后果。
-
保证顺序访问:某些操作必须按照特定的顺序执行,例如在订单系统中,生成订单和扣款必须按照先后顺序执行。使用分布式锁可以确保这些操作的顺序性。
使用分布式锁来保护资源
设想这样一个场景:某一个应用需要修改一个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
}
但实际上,这样的设计并不能正常工作,下面这张图展示了一种出错场景。
假设client获取锁后,由于GC等问题,产生了锁过期(锁对应着一个租约)的情况(这是一个必要的设计,避免client永久性获得一个锁),而client并不知道锁已经过期。这种情况下,多个client会产生数据竞争,搞乱数据。这样的bug在老版本的Hbase中存在过。
在往HDFS写数据前再检查一次锁过期情况可以吗?不行,因为GC随时有可能发生,检查锁状态与回写数据这个两个操作不可能是原子的。
解决方案:fencing token( version number validation)
fencing token是一个单调递增的数字,当client成功获取锁的时候,将这个token与锁一起返回给client。而client访问共享资源的时候,需要带着这个fencing token
至此,关于这篇博文的内容已经结束,我们已经学习到了一种分布式锁的设计方法。值得一提的是,在这篇博文中,作者对于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种,我们执行 Put
、Txn
等操作时,可以验证版本号与租约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
根据上图,我们启动两个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相等。