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

如何防止死锁

    • 源头
    • [策略一:锁排序(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%!

相关推荐
有味道的男人16 分钟前
对接亚马逊平台接口,商品全量信息一键抓取
数据库
BenD-_-31 分钟前
CVE-2026-31431 Copy Fail:Linux 内核本地提权漏洞风险与缓解
linux·网络·安全
Web极客码35 分钟前
2026年Linux VPS安全加固清单:SSH、防火墙与审计就绪配置
运维·服务器·数据库
一粒黑子1 小时前
【实测】GitNexus实测:拖入GitHub链接秒出代码知识图谱,今天涨了857星
人工智能·gpt·安全·ai·大模型·ai编程
王大傻09281 小时前
WASC 团队报告的安全威胁分类
网络·安全·web安全
逻辑驱动的ken2 小时前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
qq_392690662 小时前
Redis怎样应对Redis集群整体宕机带来的雪崩
jvm·数据库·python
xixixi777772 小时前
英伟达Agent专用全模态模型出击,仿冒AI智能体泛滥成灾,《AI伦理安全指引》即将落地——AI治理迎来“技术-风险-规范”三重奏
人工智能·5g·安全·ai·大模型·英伟达·智能体
快乐非自愿3 小时前
Redis--SDS字符串与集合的底层实现原理
数据库·redis·缓存
这儿有一堆花3 小时前
住宅代理(Residential Proxy)技术指南
开发语言·数据库·php