使用etcd分布式锁导致的协程泄露与死锁问题
# 简介
本文记录自己在工作中排查etcd应用分布式锁而导致的泄露与死锁问题,并通过分析源码找到根因,最终解决。
# 问题重现
# 现象描述
服务出现数据入库失败,且伴随内存持续增长。关键现象:
- 锁残留:通过
etcdctl get --prefix /my-lock/
可看到锁KEY长期存在 - 租约续期:
etcdctl lease timetolive
显示租约TTL不断重置 - 资源增长:Go程序协程数随请求量线性增长(可通过
pprof
观测)
# 最小复现代码
func main() {
// ... etcd client初始化代码不变...
// 关键问题点1:缺失session关闭
session, _ := concurrency.NewSession(cli)
// defer session.Close() // 故意注释导致协程泄漏
// 关键问题点2:未释放锁
mutex := concurrency.NewMutex(session, "/my-lock/")
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
_ = mutex.Lock(ctx)
// ...业务逻辑代码...
// 关键问题点3:阻止程序退出(仅用于demo)
select {}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 根因分析
# 架构示意图
+--------------+ 定期续约 +------+
| Go routine |----------------->| etcd |
+--------------+ (KeepAlive) +------+
▲
│ 未调用Close()
└──────+
|
+---------------------------+
| session.Close() 核心作用:|
| 1. 停止续约协程 |
| 2. 释放租约 |
+---------------------------+
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
# 源码关键路径
协程泄漏路径: NewSession() → go keepAlive协程 → client.KeepAlive() → 后台协程续约(sendKeepAliveLoop)
资源释放路径: session.Close() → Orphan() → 关闭上下文 → 触发keepAlive协程退出
# 解决方案
在创建session后,确保调用Close()方法,及时释放资源。
func main() {
// ...初始化代码不变...
session, err := concurrency.NewSession(cli)
if err != nil {
log.Fatal(err)
}
defer session.Close() // 新增关键修复
mutex := concurrency.NewMutex(session, "/my-lock/")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() // 确保上下文取消
if err := mutex.Lock(ctx); err != nil {
log.Fatal(err)
}
defer mutex.Unlock(context.TODO()) // 双保险释放锁
// ...业务逻辑...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 最佳实践
- 资源释放三原则:
- 对每个NewSession()必须配对defer Close()
- 锁操作必须包裹在Lock()/Unlock()中
- 使用带超时的上下文(建议不超过5s)
# 总结
- etcd特性:会话型锁的设计需要客户端主动维护生命周期
- 调试技巧:使用go tool pprof观察goroutine增长趋势
上次更新: 2025/05/13, 09:44:29