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

简介: 预防死锁的最高级手段,就是不加锁

源头

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

一次删除涉及多个 key,要加多把锁

而我的删除接口是批量删除,一次可以传多个ID:
如下:

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 遍历来加锁:

// 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)— 最经典

核心原则:所有协程永远按同一顺序加锁,死锁的环就不可能形成。

for _, key := range slices.Sorted(maps.Keys(keyCounts)) {
   
    lock.Lock(key)
}

适用场景:需要同时持有多把锁时。简单、可靠、零额外开销。


策略二:粗粒度单锁 — 最简单

不锁每个 key,只用一把锁保护整个删除操作:

lock := redislock.NewRedisLock(ctx, "image:delete:global", 30*time.Second)

一把锁,零死锁可能。 代价是删除操作变串行。
但如果删除不是热点路径(通常不是),这反而是最优解——代码最少,心智负担最低。


策略三:Try-Lock + 全部回滚 — 乐观策略

不阻塞等锁,拿不到就全部释放、退避重试:

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 锁:

// 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月前
|
前端开发 JavaScript 应用服务中间件
手把手教你给项目配 HTTPS(Nginx 实战教程,前端 + 后端)
本文章中你既能收获"为什么",也会收获"怎么做"。
364 5
手把手教你给项目配 HTTPS(Nginx 实战教程,前端 + 后端)
|
1月前
|
网络安全 Go Docker
CI/CD全流程
记录 后端go 算法平台 / python 爬虫网关 / 前端vue项目 CI-CD部署流程
280 8
|
1月前
|
缓存 安全 测试技术
GO项目开发规范文档解读
本篇博客的目的,更多是为快速翻阅与回忆使用。
188 1
|
1月前
|
缓存 前端开发 JavaScript
首屏优化实践:如何将 Vue3 + Vite 项目的加载速度提升3倍
本篇博客,将会带着你,走一遍首屏优化实践。手把手给你演示,如何将 Vue3 + Vite 项目的加载速度提升3倍。
256 6
首屏优化实践:如何将 Vue3 + Vite 项目的加载速度提升3倍
|
1月前
|
缓存 人工智能 JSON
|
17天前
|
消息中间件 运维 监控
海尔智家 x 阿里云 Kafka 实践:轻松支撑百亿级消息,稳定性与效率双提升
海尔智家通过与阿里云深度共创,采用定制化迁移与调优方案,平滑升级至Kafka Serverless,不仅保障了极致稳定性,更实现运维自动化,大幅释放研发人力。
123 20
|
1月前
|
文字识别 NoSQL API
Go-Zero微服务实战:高并发场景下的学生认证系统设计与实现
在校园社交等垂直领域应用中,"学生身份认证"是构建信任体系的核心基石。本文将会基于 Go-Zero 微服务框架,详细拆解了一个生产级的学生认证系统实现。涵盖了 OCR 双通道故障转移、WebSocket 实时推送、事件驱动架构 (EDA)、敏感数据加密 以及 有限状态机(FSM) 的设计模式。
196 7
|
1月前
|
NoSQL 关系型数据库 MySQL
面向对象的七大设计原则
经艺术设计过的接口,就像蝴蝶一样在指尖翩翩起舞,令人沉醉....
108 0
|
1月前
|
人工智能 Java Go
并发编程【深度解剖】
本篇文章更多用诙谐的语调讲解,易于理解。
129 2
并发编程【深度解剖】
|
17天前
|
人工智能 安全 Nacos
群虾智能——AI 原生应用开源开发者沙龙·杭州站精彩回顾 & PPT 下载
关注「阿里云云原生」公众号,后台回复:0331,免费获得杭州站讲师 PPT 合辑。