etcd contrib/lock Fencing Token 示例:复现租约过期导致的分布式锁失效与版本验证方案
【免费下载链接】etcdDistributed reliable key-value store for the most critical data of a distributed system项目地址: https://gitcode.com/GitHub_Trending/et/etcd
etcd 仓库在 contrib/lock 提供了一个可直接运行的示例程序,完整复现了“基于租约(lease)的分布式锁无法真正保证互斥”这一经典问题,并演示了如何通过 Fencing Token(版本号/围栏令牌)技术让共享存储拒绝持有过期租约的客户端写入。阅读本文后,你将掌握:如何用etcdctl lease revoke手工制造“假过期”、concurrency包中 Session/Mutex 的租约机制如何工作,以及如何在共享存储侧用版本号校验杜绝旧持锁者的非法访问。
1. 问题背景:为什么租约锁不能提供真正的互斥
一般来说,基于租约的锁服务无法为进程提供互斥性。原因是这类租约机制依赖锁服务与客户端双方的物理时钟:语言运行时的 Stop-the-world GC 停顿、网络延迟、宿主机时钟漂移等诸多因素,都可能导致一个仍然存活的客户端进程被授予的租约在服务端被判定为已过期。结果是:进程 A 以为自己还持有锁,而进程 B 此时可以拿到同一把锁——两个“持锁者”同时操作共享资源。
etcd 官方的 why lock and lease 相关说明 中也指出,这类问题可以用version number validation(版本号验证)或fencing tokens(围栏令牌)技术解决:由共享资源侧(图中的存储)对每个请求携带的令牌做校验,拒绝版本落后于当前版本的写入请求。
contrib/lock目录正是把上述思路做成了可执行的演示:
storage:一个极简的内存 KV 存储,通过 HTTP + 自定义 JSON 协议对外提供服务;client:模拟“Client 1 / Client 2”两个客户端进程,在 etcd 锁的协调下向storage写入键值,并在请求中携带自己的“版本号”。
目录结构与两个程序如下:
| 文件 | 角色 | 说明 |
|---|---|---|
| contrib/lock/client/client.go | 客户端 | 连接 etcd、通过concurrency包加锁,持租约版本调用 storage 写入 |
| contrib/lock/storage/storage.go | 共享存储 | 监听:8080,内存 map 存储,写操作前做版本比对 |
2. 示例的通信协议:storage 的 JSON 接口
storage进程是一个 HTTP 服务(http.ListenAndServe(":8080"),见 storage.go 第 104-111 行),所有请求以 JSON 形式 POST 到根路径。client与storage共用同一组协议结构体:
type request struct { Op string `json:"op"` // "read" 或 "write" Key string `json:"key"` // 键 Val string `json:"val"` // 值 Version int64 `json:"version"` // 客户端携带的 fencing 版本(etcd 租约 ID) } type response struct { Val string `json:"val"` Version int64 `json:"version"` Err string `json:"err"` }存储内部为每个键保存{val, version}两个字段(storage.go 第 26-31 行)。核心校验逻辑在handler的 write 分支中(storage.go 第 79-91 行):
} else if strings.Compare(req.Op, "write") == 0 { if val, ok := data[req.Key]; ok { if req.Version != val.version { // 版本不匹配:拒绝写入,返回 fencing 错误 writeResponse(response{"", -1, fmt.Sprintf( "given version (%x) is different from the existing version (%x)", req.Version, val.version)}, w) } else { // 版本一致:更新值与版本 data[req.Key].val = req.Val data[req.Key].version = req.Version ... } } else { // 键不存在:直接写入 data[req.Key] = &value{req.Val, req.Version} ... } }这就是 Fencing Token 的落点:共享存储只信任“版本号 >= 当前记录”的写者。旧租约持有者再来写时,其版本与存储中的最新版本不同,请求被直接拒绝。
3. 构建与运行(完整复现步骤)
以下内容完整继承自 contrib/lock/README.md,并补充了源码层面的说明。
3.1 构建
对client和storage分别在其目录下执行go build:
$ cd contrib/lock/client && go build $ cd contrib/lock/storage && go build需要注意的一点:contrib/lock目录下没有独立的go.mod,而 client.go 依赖go.etcd.io/etcd/client/v3与go.etcd.io/etcd/client/v3/concurrency。从源码结构看,在模块解析上需要本地环境能导出这两个包(例如通过 workspace 或 replace 指到本仓库的client/模块),否则直接构建可能找不到依赖。
3.2 启动 etcd(锁服务)
在 etcd 源码根目录下构建并启动 etcd,它在演示中承担“锁服务”的角色:
$ make # 构建 etcd $ bin/etcd # 启动 etcd示例客户端默认连接127.0.0.1:2379等标准端口(client.go 第 99-104 行),因此用默认配置的bin/etcd即可。
3.3 启动 storage
在storage目录运行:
$ ./storage3.4 启动 Client 1 并制造“假过期”
$ ./client 1client 1先创建 etcd 客户端与 session,然后阻塞等待人工操作,输出形如:
client 1 starts created etcd client and session acquired lock, version: 694d82254d5fa305 please manually revoke the lease using 'etcdctl lease revoke 694d82254d5fa305' or wait for it to expire, then start executing client 2 and hit any key...此时可以用etcdctl验证租约确实被创建:
$ bin/etcdctl lease list found 1 leases 694d82254d5fa305然后手动撤销租约——这等价于模拟 Client 1 进程发生了长时间 STW 停顿、其 KeepAlive 心跳中断导致服务端判定租约过期:
$ bin/etcdctl lease revoke 694d82254d5fa305 lease 694d82254d5fa305 revoked注意:由于示例把 session TTL 设置为 1 秒(见 4.1 节),如果什么都不做,租约几秒后也会自然过期,效果相同。
3.5 启动 Client 2 写入成功
$ ./client 2 client 2 starts created etcd client and session acquired lock, version: 694d82254e18770a this is client 2, continuing如果一切顺利,./client 2会很快结束,并成功把键写入storage。此时 storage 中key0记录的版本是694d82254e18770a。
3.6 恢复 Client 1,观察 fencing 生效
回到./client 1的终端,按任意键恢复该进程。它会带着旧租约版本号再次尝试写入,输出如下:
resuming client 1 expected fail to write to storage with old lease version: error: given version (694d82254d5fa305) is different from the existing version (694d82254e18770a)这正是 Fencing 的价值:Client 1 进程还“活着”,但它对共享存储的写入已被版本校验拒绝,从而避免了基于租约锁无法防止的“旧持锁者越权写入”。client程序对此做了明确的分支处理——mode 1 收到该错误属于预期行为(打印expected fail),而 mode 2 收到任何写错误则视为异常直接退出(client.go 第 136-144 行)。
4. 源码纵深:etcd 锁与租约的实现
4.1 client 侧:Session 与 Locker 的协作
client.go 第 99-125 行 展示了整个加锁流程:
client, err := clientv3.New(clientv3.Config{ Endpoints: []string{"http://127.0.0.1:2379", ...}, }) // 先做连接检查,否则 newSession 会无限挂起 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _, err = client.MemberList(ctx) session, err := concurrency.NewSession(client, concurrency.WithTTL(1)) locker := concurrency.NewLocker(session, "/lock") locker.Lock() defer locker.Unlock() version := session.Lease() // 租约 ID 即 fencing 版本号 log.Printf("acquired lock, version: %x", version)几个关键点:
- 连接预检:代码注释明确指出先调
MemberList探测连通性,否则NewSession会在连不上 etcd 时无限挂起; - TTL=1 秒:
concurrency.WithTTL(1)让租约极短,便于演示“自然过期”;若不指定,session.go 中defaultSessionTTL为 60 秒; - 版本号就是租约 ID:
session.Lease()返回的 lease ID(如694d82254d5fa305)被直接作为 fencing 版本随写请求下发给 storage。
4.2 Session 为什么能保证“活着就持有锁”
session.go 第 41-79 行 中,NewSession先用client.Grant申请一个租约,再启动client.KeepAlive并起一个 goroutine 消费 keepalive 通道:只要客户端进程存活且网络正常,租约会被持续续期;进程崩溃、连接断开或长时间停顿(GC STW)时,心跳中断,租约在服务端过期。Session.Done()通道在租约孤儿化/过期时关闭,供上层感知失效。
4.3 Mutex 如何基于租约实现排队加锁
NewLocker(session, "/lock")底层是 mutex.go 中的Mutex。其tryAcquire(mutex.go 第 111-132 行)用一次 Txn完成入队:
m.myKey = fmt.Sprintf("%s%x", m.pfx, s.Lease()) // 队列键 = 前缀 + 租约ID cmp := v3.Compare(v3.CreateRevision(m.myKey), "=", 0) put := v3.OpPut(m.myKey, "", v3.WithLease(s.Lease())) // 键绑定到租约 getOwner := v3.OpGet(m.pfx, v3.WithFirstCreate()...) // 取最早入队者 resp, err := client.Txn(ctx).If(cmp).Then(put, getOwner).Else(get, getOwner).Commit()要点是:
- 每个等待者的键为
pfx + 租约ID,并以租约 ID 作为键名的一部分天然实现了按获得锁先后排队(lease ID 单调递增); - 该键绑定了 session 的租约——进程死亡、租约过期时 etcd 自动删除该键,即自动“让位”给下一个等待者;
Lock若发现最早入队者不是自己,则通过 key.go 的 waitDeletes 监听前面等待者的键被删除(即前一位持锁者释放或租约过期),再复核自己的键仍存在后取锁;期间若 session 已失效则返回ErrSessionExpired。
这也解释了演示中的时序:Client 1 的租约被 revoke 后,其队列键/lock694d82254d5fa305被 etcd 自动删除,Client 2 随即成为最早等待者并取锁成功——整个过程无需任何显式的锁释放操作。
4.4 端到端流程小结
把上述拼起来,示例的完整因果链是:
- Client 1 以 TTL=1 的 session 加锁,获得租约
...fa305(版本号); etcdctl lease revoke撤销租约 → etcd 自动删除 Client 1 的锁队列键(模拟“假过期”);- Client 2 加锁成功,租约
...770a,携版本694d82254e18770a写key0到 storage,成功; - Client 1 恢复运行,携旧版本
694d82254d5fa305写key0,被 storage 的版本比对拒绝(见 3.6 节错误输出)。
没有第 4 步的版本校验,Client 1 就能把 storage 的数据覆盖回旧值——这就是“租约锁本身无法互斥”的具体后果。
5. 适用前提与延伸
- 运行前提:单节点 etcd 运行在默认端口
2379(客户端默认连接127.0.0.1:2379/22379/32379),storage 独占8080端口;示例未启用 TLS 与认证。 - 版本号的取舍:示例直接以 etcd 租约 ID 作为 fencing 版本,其单调性由 etcd 保证。实际系统中也可以用数据库行级版本号、存储自身的高水位 ID 等,原则是存储侧能拒绝“回退”的版本。
- 延伸阅读(本仓库内):
- client/v3/concurrency 完整实现了基于 session 的
Mutex、Locker、Election,可替代本示例中的NewLocker用法; - tests/robustness 提供了在故障注入场景下验证分布式正确性的测试框架,思路与本示例的“制造过期”一脉相承;
etcdctl lease list/revoke等命令见 etcdctl。
- client/v3/concurrency 完整实现了基于 session 的
contrib/lock用最少的代码把“分布式锁为什么不够、Fencing Token 如何兜底”这条分布式系统的核心论证链跑成了可观察的实验,配合上述源码路径阅读,可以作为理解 etcd 租约与并发原语的绝佳切入点。
【免费下载链接】etcdDistributed reliable key-value store for the most critical data of a distributed system项目地址: https://gitcode.com/GitHub_Trending/et/etcd
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考