Go 服务死锁卡死问题排查全过程
线上一段跑了大半年的 Go 服务突然开始随机卡死,CPU 曲线纹丝不动,接口直接挂起。
最后定位下来,问题就一句话:锁还没放,就去调外部回调了。
下面把整个排查过程完整复盘一遍,包括最初的有 Bug 代码、死锁触发链路、修复思路和并发编码上要守住的几条底线。
一、故障排查
我这个服务是用 k8s 搭建的 直接在宿主机执行这 2 条命令:
1. 先拿到容器里的 Go 进程 PID
crictl inspect e7f00d696be5 | grep pid144a245a184fa 就是容器 id
会输出类似:
"pid": 413500,1009747 就是你服务的 PID
2. 直接在宿主机发送信号(最关键)
kill -QUIT 10097473. 立刻看日志(卡死原因全出来)
crictl logs 144a245a184fa马上会看到的结果
日志里会打印 几百行 goroutine 堆栈,我复制日志丢给 AI 马上就知道是什么问题了:
代码内引用了一个 LRU 工具,出现了死锁
二、故障现象与原始代码
1.1 现象描述
服务表现非常典型,几乎可以一眼定性为死锁:
- 接口随机性挂起,没有 panic、没有报错日志
- pprof 看 goroutine 数量持续增长,全部堵在
sync.Mutex.Lock - CPU 占用平稳,没有忙等
- 重启后短时间正常,过几小时再次复现
1.2 原始 LRU 实现
经过排查发现是引用了某段自实现的 LRU 工具
这是一段非常常见的写法:双向链表 container/list 维护访问顺序,map 做 O(1) 查找,外加一把 sync.Mutex 保证并发安全。
还支持淘汰回调 Call,方便业务层在元素被踢出时做清理。
package utils
import (
"container/list"
"sync"
)
type Lru struct {
max int
l *list.List
cache map[interface{}]*list.Element
mu *sync.Mutex
Call func(key interface{}, value interface{}) // 淘汰回调
}
type Node struct {
Key interface{}
Val interface{}
}
func NewLru(len int) *Lru {
return &Lru{
max: len,
l: list.New(),
cache: make(map[interface{}]*list.Element),
mu: new(sync.Mutex),
}
}
func (l *Lru) Store(key, val interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
if e, ok := l.cache[key]; ok {
e.Value.(*Node).Val = val
l.l.MoveToFront(e)
return
}
ele := l.l.PushFront(&Node{Key: key, Val: val})
l.cache[key] = ele
if l.max != 0 && l.l.Len() > l.max {
if e := l.l.Back(); e != nil {
l.l.Remove(e)
node := e.Value.(*Node)
delete(l.cache, node.Key)
// 死锁点 1:锁内执行回调
if l.Call != nil {
l.Call(node.Key, node.Val)
}
}
}
}
func (l *Lru) Delete(key interface{}) {
l.mu.Lock()
defer l.mu.Unlock()
if ele, ok := l.cache[key]; ok {
delete(l.cache, key)
l.l.Remove(ele)
// 死锁点 2:锁内执行回调
if l.Call != nil {
node := ele.Value.(*Node)
l.Call(node.Key, node.Val)
}
}
}代码逻辑没问题,并发也加了锁,看起来完全合理。
但问题恰恰藏在两处 l.Call(...) 上。
三、死锁是怎么形成的
2.1 一句话定位根因
Go 并发里有一条几乎可以当铁律的经验:持锁期间不要调外部回调。
外部回调里写了什么、会不会再次访问当前对象,调用方根本控制不了。
一旦回调内部又触发同一把锁,立刻撞死。
2.2 触发链路还原
业务侧的真实使用方式大概长这样:
cache := utils.NewLru(1000)
cache.Call = func(k, v interface{}) {
// 监控、清理外部资源……
// 这里有一行很容易被忽略:
cache.Delete(someRelatedKey)
}回调里又调了 cache.Delete,于是触发链路变成:
- goroutine A 调用
Store,拿到mu Store内部触发淘汰,进入l.Call(...)- 回调里调用
cache.Delete(...),再次尝试mu.Lock() sync.Mutex不可重入,A 在等自己释放defer mu.Unlock()又必须等函数返回才执行- 互相等待,goroutine A 永久阻塞
只要业务回调里有任何一处直接或间接回到这个 LRU 上(哪怕是 Load),就会复现。
2.3 流程示意
Store 加锁
└─ 数据淘汰
└─ 持锁执行 Call 回调
└─ 回调内部 Delete
└─ 再次申请同一把锁
└─ 永久等待 → 死锁2.4 顺手挖出来的几个其他问题
排查过程中顺带把代码过了一遍,发现还有几个值得改的点:
interface{}漫天飞,Go 1.18 之后完全可以换成anyNode字段全部大写导出,外部能直接改值,破坏封装mu用指针类型*sync.Mutex,多了一次空指针风险,没必要- 删除节点的逻辑在
Store和Delete里各写了一遍 Call这个命名语义模糊,社区通用叫OnEvict
四、修复方案
3.1 核心思路:先放锁,再回调
正确顺序只有一种:
- 加锁,做纯内存的链表/map 操作
- 取出回调需要用到的
key、value,存到局部变量 - 解锁
- 执行
OnEvict回调
回调内部再怎么折腾这个 LRU 都不会出事,因为锁早已释放。
3.2 抽公共删除方法
把节点删除的内存操作抽出来,回调放在锁外执行:
// 仅做内存操作,调用方负责锁
func (l *Lru) removeElementLocked(ele *list.Element) (key, val any) {
n := ele.Value.(*node)
delete(l.cache, n.key)
l.l.Remove(ele)
return n.key, n.val
}
func (l *Lru) Delete(key any) {
l.mu.Lock()
ele, ok := l.cache[key]
if !ok {
l.mu.Unlock()
return
}
k, v := l.removeElementLocked(ele)
l.mu.Unlock()
// 锁外执行回调,回调随便玩
if l.OnEvict != nil {
l.OnEvict(k, v)
}
}Store 触发淘汰时同理:临界区内只收集要被淘汰的 key/value,出锁之后再统一回调。
3.3 其他改进
顺手做掉:
interface{}全部替换为anynode私有化,避免外部误改LoadAll改成遍历链表,保留访问顺序(map 遍历无序)- 补
Len()、Clear()工具方法 Call重命名为OnEvict,语义清晰
五、Go 并发编码上要守住的几条线
这次事故谈不上疑难,本质就是编码规范没守住。给团队补的几条硬性规则:
- 锁区间内只做内存读写,别夹带任何外部调用
- 锁内禁止调用第三方库方法、回调、HTTP/RPC、IO
- 锁内禁止递归、禁止再次加同一把锁(
sync.Mutex不可重入) - 能用更短的临界区就用更短的,
defer Unlock不是万能写法 - 如果回调真的需要在加锁状态下执行,明确文档说明,并且回调里禁止回到当前对象
六、关键数据一览
| 指标项 | 修复前 | 修复后 |
|---|---|---|
| 平均无故障运行时长 | 4~8 小时随机卡死 | 30 天 + |
| pprof goroutine 数 | 持续增长,最高 8000+ | 稳定 < 200 |
| Delete + 回调耗时 | 阻塞至超时 | < 50µs |
| 代码行数 | 约 90 行 | 约 110 行 |
回调放到锁外之后,goroutine 数量立刻回落到正常水平,问题再没复现过。
常见问题 FAQ
Q1. Go 的 sync.Mutex 是可重入锁吗?
不是。同一个 goroutine 对已经持有的 sync.Mutex 再次 Lock() 会直接卡死,这是死锁最常见的来源之一。
Q2. 为什么不直接把 sync.Mutex 换成 sync.RWMutex 解决?
RWMutex 解决的是读多写少的吞吐问题,跟死锁没关系。
锁内调用外部回调,无论用哪种锁都会出问题。
Q3. 用 defer mu.Unlock() 是不是反而增加了死锁风险?
defer Unlock 本身没错,只是把解锁时机推迟到了函数返回。
真正的问题是在解锁之前调用了不可控的外部代码。
要么不要 defer,提前手动 Unlock;要么把外部调用挪出函数。
Q4. LRU 缓存淘汰回调放在哪里执行最稳妥?
最稳的做法是:临界区内只收集要淘汰的 key/value 列表,出锁后统一遍历执行回调。
如果回调可能慢,再考虑丢到独立 goroutine 异步执行。
Q5. 怎么快速判断线上服务是不是死锁了?
抓 pprof goroutine 列表(/debug/pprof/goroutine?debug=2),看是不是大量 goroutine 堆在 sync.Mutex.Lock 或 semacquire 上,且数量随时间持续增长,CPU 又没飙高,基本就能确认。
写在最后
这种 Bug 的尴尬之处在于:单元测试基本测不出来,因为测试里的回调通常很"乖",不会反过来调 LRU。
一上线遇到真实业务的回调嵌套,立刻翻车。
写并发代码的时候,多问一句:锁里这行代码,会不会跑进我控制不了的地方? 多数死锁都能在这一步被掐掉。
如果你们项目里也踩过类似的坑,或者对锁内回调有不一样的处理方式,欢迎在评论区聊聊~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-lru-deadlock-debug-real-bug/
备用原文链接: https://blog.fiveyoboy.com/articles/go-lru-deadlock-debug-real-bug/