目录

Go 服务死锁卡死问题排查全过程

线上一段跑了大半年的 Go 服务突然开始随机卡死,CPU 曲线纹丝不动,接口直接挂起。

最后定位下来,问题就一句话:锁还没放,就去调外部回调了

下面把整个排查过程完整复盘一遍,包括最初的有 Bug 代码、死锁触发链路、修复思路和并发编码上要守住的几条底线。

一、故障排查

我这个服务是用 k8s 搭建的 直接在宿主机执行这 2 条命令:

1. 先拿到容器里的 Go 进程 PID

crictl inspect e7f00d696be5 | grep pid

144a245a184fa 就是容器 id

会输出类似:

"pid": 413500,

1009747 就是你服务的 PID

2. 直接在宿主机发送信号(最关键)

kill -QUIT 1009747

3. 立刻看日志(卡死原因全出来)

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,于是触发链路变成:

  1. goroutine A 调用 Store,拿到 mu
  2. Store 内部触发淘汰,进入 l.Call(...)
  3. 回调里调用 cache.Delete(...),再次尝试 mu.Lock()
  4. sync.Mutex 不可重入,A 在等自己释放
  5. defer mu.Unlock() 又必须等函数返回才执行
  6. 互相等待,goroutine A 永久阻塞

只要业务回调里有任何一处直接或间接回到这个 LRU 上(哪怕是 Load),就会复现。

2.3 流程示意

Store 加锁
   └─ 数据淘汰
        └─ 持锁执行 Call 回调
              └─ 回调内部 Delete
                    └─ 再次申请同一把锁
                          └─ 永久等待 → 死锁

2.4 顺手挖出来的几个其他问题

排查过程中顺带把代码过了一遍,发现还有几个值得改的点:

  • interface{} 漫天飞,Go 1.18 之后完全可以换成 any
  • Node 字段全部大写导出,外部能直接改值,破坏封装
  • mu 用指针类型 *sync.Mutex,多了一次空指针风险,没必要
  • 删除节点的逻辑在 StoreDelete 里各写了一遍
  • Call 这个命名语义模糊,社区通用叫 OnEvict

四、修复方案

3.1 核心思路:先放锁,再回调

正确顺序只有一种:

  1. 加锁,做纯内存的链表/map 操作
  2. 取出回调需要用到的 keyvalue,存到局部变量
  3. 解锁
  4. 执行 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{} 全部替换为 any
  • node 私有化,避免外部误改
  • LoadAll 改成遍历链表,保留访问顺序(map 遍历无序)
  • Len()Clear() 工具方法
  • Call 重命名为 OnEvict,语义清晰

五、Go 并发编码上要守住的几条线

这次事故谈不上疑难,本质就是编码规范没守住。给团队补的几条硬性规则:

  1. 锁区间内只做内存读写,别夹带任何外部调用
  2. 锁内禁止调用第三方库方法、回调、HTTP/RPC、IO
  3. 锁内禁止递归、禁止再次加同一把锁(sync.Mutex 不可重入)
  4. 能用更短的临界区就用更短的,defer Unlock 不是万能写法
  5. 如果回调真的需要在加锁状态下执行,明确文档说明,并且回调里禁止回到当前对象

六、关键数据一览

指标项 修复前 修复后
平均无故障运行时长 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.Locksemacquire 上,且数量随时间持续增长,CPU 又没飙高,基本就能确认。

写在最后

这种 Bug 的尴尬之处在于:单元测试基本测不出来,因为测试里的回调通常很"乖",不会反过来调 LRU。

一上线遇到真实业务的回调嵌套,立刻翻车。

写并发代码的时候,多问一句:锁里这行代码,会不会跑进我控制不了的地方? 多数死锁都能在这一步被掐掉。

如果你们项目里也踩过类似的坑,或者对锁内回调有不一样的处理方式,欢迎在评论区聊聊~

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-lru-deadlock-debug-real-bug/

备用原文链接: https://blog.fiveyoboy.com/articles/go-lru-deadlock-debug-real-bug/