一次批量删除引发的死锁,最终我选择不加锁

如何防止死锁

    • 源头
    • [策略一:锁排序(Lock Ordering)--- 最经典](#策略一:锁排序(Lock Ordering)— 最经典)
    • [策略二:粗粒度单锁 --- 最简单](#策略二:粗粒度单锁 — 最简单)
    • [策略三:Try-Lock + 全部回滚 --- 乐观策略](#策略三:Try-Lock + 全部回滚 — 乐观策略)
    • [策略四:从设计上消灭锁 --- 最高级](#策略四:从设计上消灭锁 — 最高级)
    • 决策

源头

最近写项目时遇到一个问题:

一次删除涉及多个 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"

我的代码要同时锁两个 keyaaa.pngbbb.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%!

相关推荐
这周也會开心1 小时前
Redis数据类型的底层实现和数据持久化
数据库·redis·缓存
独行soc2 小时前
2026年渗透测试面试题总结-20(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
数据知道2 小时前
PostgreSQL 核心原理:系统内部的对象寻址机制(OID 对象标识符)
数据库·postgresql
翼龙云_cloud2 小时前
阿里云渠道商:阿里云 ECS 从安全组到云防火墙的实战防护指南
安全·阿里云·云计算
倔强的石头_2 小时前
关系数据库替换用金仓:数据迁移过程中的完整性与一致性风险
数据库
程序猿阿伟2 小时前
《TypeScript中Protobuf到运行时类型安全的转换指南》
javascript·安全·typescript
Elastic 中国社区官方博客2 小时前
使用 Groq 与 Elasticsearch 进行智能查询
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
小白电脑技术2 小时前
飞牛漏洞焦虑?别瞎折腾WAF了!用Lucky五步搞定“防爬墙”
服务器·网络·安全
一战成名9962 小时前
深度解析 CANN 模型转换工具链:从 ONNX 到 OM
人工智能·学习·安全·开源