如何防止死锁
源头
最近写项目时遇到一个问题:
一次删除涉及多个key,要加多把锁。
而我的删除接口是批量删除 ,一次可以传多个ID:
如下:
go
func (s *ImageService) Delete(ctx context.Context, ids []uint) error {
假设用户传了 ids = [1, 2, 3, 4],查出来这些图片指向两个不同的物理文件:
ID=1 → key="aaa.png"
ID=2 → key="aaa.png"
ID=3 → key="bbb.png"
ID=4 → key="bbb.png"
我的代码要同时锁两个 key :aaa.png 和 bbb.png。
问题出在这里------我是用 map 遍历来加锁:
go
// keyCounts = {"aaa.png": 2, "bbb.png": 2}
for key := range keyCounts { // ← Go 的 map 遍历顺序是随机的!
lock(key)
}
但两个请求恰好 都涉及这两个 key,但 Go 给它们的遍历顺序不一样:
请求 A(遍历顺序:aaa → bbb) 请求 B(遍历顺序:bbb → aaa)
──────────────────────── ────────────────────────
锁住 "aaa.png" 锁住 "bbb.png"
锁住 "bbb.png" → 被 B 占了,等着 锁住 "aaa.png" → 被 A 占了,等着
A 等 B 放 bbb ←──死锁──→ B 等 A 放 aaa
两个请求永远互相等,谁都完成不了。 这是我突然意识到,完了,死锁了
防死锁不只有"排序"一种思路,通常有好几种策略,本篇博客将会从简单到高级逐个分析。
策略一:锁排序(Lock Ordering)--- 最经典
核心原则:所有协程永远按同一顺序加锁,死锁的环就不可能形成。
go
for _, key := range slices.Sorted(maps.Keys(keyCounts)) {
lock.Lock(key)
}
适用场景:需要同时持有多把锁时。简单、可靠、零额外开销。
策略二:粗粒度单锁 --- 最简单
不锁每个 key,只用一把锁保护整个删除操作:
go
lock := redislock.NewRedisLock(ctx, "image:delete:global", 30*time.Second)
一把锁,零死锁可能。 代价是删除操作变串行。
但如果删除不是热点路径(通常不是),这反而是最优解------代码最少,心智负担最低。
策略三:Try-Lock + 全部回滚 --- 乐观策略
不阻塞等锁,拿不到就全部释放、退避重试:
go
for attempt := 0; attempt < maxRetries; attempt++ {
allLocked := true
for _, key := range keys {
if !tryLock(key) {
unlockAll(acquired) // 拿不到就全放
allLocked = false
break
}
}
if allLocked { break }
sleep(jitter) // 随机退避
}
并发度最高,但代码复杂,且 Redis 分布式锁的 tryLock 语义实现起来不简单。
大多数场景杀鸡用牛刀。
策略四:从设计上消灭锁 --- 最高级
真正的高手不是"怎么加锁不死锁",而是问自己:这个锁能不能不要?
我当时的问题是,锁的目的是在删除图片的同时,保护"引用计数检查"的正确性。但换个思路:
方案 A:数据库原子操作替代锁
把"查引用计数 + 软删除"合并成一条 SQL 或一个 DB 事务内完成,天然原子,不需要 Redis 锁:
go
// Repository 内部一个方法搞定,返回可安全清理的 keys
func (r *imageRepository) DeleteAndReturnOrphanKeys(ctx context.Context, ids []uint) ([]string, error) {
// 1. 在同一个事务内:软删除 + 查哪些 key 引用归零
// 2. 返回需要清理物理文件的 key 列表
}
数据库事务本身就是并发安全的,根本不需要 Redis 分布式锁。
方案 B:容忍孤儿文件 + 定时清理
最"懒"但最工程化的思路:删 DB 记录就完事,物理文件的清理交给一个定时任务去扫描孤儿。代码最简单,零锁,零死锁。很多云存储服务(如 S3 lifecycle)就是这个思路。
决策
需要同时持有多把锁吗?
├── 不需要 → 用单把锁(策略二)或消灭锁(策略四)
└── 需要
├── 能确定全局顺序? → 锁排序(策略一)
└── 不能 → Try-Lock + 回滚(策略三)
明智的排序是:能不加锁就不加锁 > 一把锁 > 排序多把锁 > try-lock。 越靠前越简单,越不容易出错。
而最后,我是如何解决呢?
大家在遇到死锁 的第一反应往往是:"我该怎么排好加锁的顺序?"
但一名优秀的架构师 ,这一刻的思考应该是:"我真的需要这个锁吗?"
我最终选择了方案四中的B。
虽然它引入了秒级的物理文件滞后(最终一致性),但它将一个高风险的并发同步问题,降维成了一个无风险的异步批处理问题。
重点是,代码量不仅直接减少了一大半,
更是复杂度大大降低,死锁风险降为 0%!