转账系统的死锁是个经典问题,但"经典"不代表"抽象"——本机真实用 Go 写了一个最简版转账系统,故意让它按转账方向的顺序加锁,真实触发了 Go 运行时自己的死锁检测(fatal error: all goroutines are asleep - deadlock!);再按账户 ID 固定加锁顺序重写一遍,真实跑了两万轮、约四万个并发 goroutine 的反向转账压力测试,零死锁,总额分毫不差。
最直白的写法:Transfer(from, to) 先锁 from,再锁 to——听起来没毛病,直到有人反过来转账。
func transferUnsafe(from, to *Account, amount int) {
from.mu.Lock()
defer from.mu.Unlock()
time.Sleep(20 * time.Millisecond) // 故意拉大窗口,让交错稳定复现
to.mu.Lock()
defer to.mu.Unlock()
from.Balance -= amount
to.Balance += amount
}
func main() {
a := &Account{ID: 1, Balance: 1000}
b := &Account{ID: 2, Balance: 1000}
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); transferUnsafe(a, b, 100) }() // A -> B
go func() { defer wg.Done(); transferUnsafe(b, a, 50) }() // B -> A,反向
wg.Wait()
}
fatal error: all goroutines are asleep - deadlock!
goroutine 4 [sync.Mutex.Lock]:
main.transferUnsafe(0x16da...A, 0x16da...B, 0x64)
/tmp/deadlock_demo/main.go:19 +0x108
main.main.func1()
/tmp/deadlock_demo/main.go:31 +0x4c
goroutine 5 [sync.Mutex.Lock]:
main.transferUnsafe(0x16da...B, 0x16da...A, 0x32)
/tmp/deadlock_demo/main.go:19 +0x108
main.main.func2()
/tmp/deadlock_demo/main.go:32 +0x144
这不是猜测,是本机第一次真实运行就复现的结果(time.Sleep(20ms) 把交错窗口拉得足够大,几乎每次都能稳定触发)。两个 goroutine 各自锁住了自己先拿到的那个账户,然后都卡在 to.mu.Lock() 那一行——goroutine 4(执行 A→B)拿着 A 等 B,goroutine 5(执行 B→A)拿着 B 等 A,谁也不会释放。Go 运行时发现整个程序里所有 goroutine 都动不了了(不是只有这两个,是包括 main 在内的全部),直接判定为死锁,打印真实的错误信息和每个卡住的 goroutine 的调用栈,进程退出。
这就是死锁四个必要条件在这个场景里的具体样子:互斥(账户锁本来就排他)、持有并等待(锁着 A 还想要 B)、不可抢占(锁不会被强制收走)、循环等待(A→B 等 B,B→A 等 A,首尾相接)。前三条是互斥锁与生俱来的性质,没法绕开;真正能下手根治的,只有第四条。
思路很朴素:不管转账方向是什么,加锁顺序永远只看账户 ID——ID 小的先锁。这样"A→B"和"B→A"两个方向,加锁顺序变成完全一样的"先锁 A 再锁 B",循环等待的两个箭头直接合并成一条,不可能首尾相接。
func transferSafe(from, to *SafeAccount, amount int) {
first, second := from, to
if first.ID > second.ID {
first, second = second, first // 只看 ID,不看转账方向
}
first.mu.Lock()
defer first.mu.Unlock()
time.Sleep(1 * time.Millisecond)
second.mu.Lock()
defer second.mu.Unlock()
from.Balance -= amount
to.Balance += amount
}
光看代码改动很小,得靠真实的压力测试才能证明它真的解决了问题——20 个账户,两万轮,每轮起两个方向相反的 goroutine(i→j 和 j→i 同时发起,故意制造最容易死锁的交错),一共接近四万个并发 goroutine:
const rounds = 20000
for r := 0; r < rounds; r++ {
i, j := r%20, (r+7)%20
wg.Add(2)
go func(i, j int) { defer wg.Done(); transferSafe(accounts[i], accounts[j], 1) }(i, j)
go func(i, j int) { defer wg.Done(); transferSafe(accounts[j], accounts[i], 1) }(j, i)
}
// select { case <-done: ... case <-time.After(30*time.Second): "超时:疑似死锁" }
全部完成,没有死锁
初始总额: 20000 最终总额: 20000 一致: true
用一个 30 秒超时兜底(真死锁了就等不到 done,超时分支会打印出来),真实跑完全部两万轮,一次死锁都没有。初始总额 == 最终总额 这一验证同样重要:证明固定顺序加锁不只是"不死锁"这么简单,每一次转账的原子性(扣了多少、加了多少)在近四万个并发 goroutine 疯狂互相插队的情况下依然完全正确,没有出现中间状态被读到或者写丢失的情况。
固定加锁顺序是最干净、最推荐的根治方案,但不是唯一方案,工程上常常几种一起用。
三种思路不是互斥的:固定加锁顺序打底,消灭这一类结构性死锁;数据库自身的死锁检测留着兜底那些排序管不到的场景;关键路径视情况换成乐观锁降低锁粒度——这是面试里聊这道题比较完整的答法。
这篇用的是进程内的 sync.Mutex,只在单机、单进程有效。如果服务是分布式部署的,不同实例各有各的内存,进程内的锁排序完全帮不上忙,得把"按 ID 排序加锁"这件事搬到数据库层——分布式转账加锁这篇本机真实验证了 SELECT ... FOR UPDATE 该怎么用、会不会退化成表锁。
对应上面两组真实实验,切换看"不排序"和"按 ID 排序"两种场景下,两个 goroutine 各自的加锁轨迹。
数据对应上面真实实测的两组场景:不排序场景严格对应真实捕获的 goroutine 栈(G1/G2 各自锁住第一个账户后,尝试锁第二个账户时永久阻塞);排序场景对应固定加锁顺序后两个 goroutine 通过共享的第一把锁(账户 A)天然串行化、无阻塞完成的真实执行结果。