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

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

源头

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

一次删除涉及多个 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天前
|
人工智能 JSON 机器人
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
本文带你零成本玩转OpenClaw:学生认证白嫖6个月阿里云服务器,手把手配置飞书机器人、接入免费/高性价比AI模型(NVIDIA/通义),并打造微信公众号“全自动分身”——实时抓热榜、AI选题拆解、一键发布草稿,5分钟完成热点→文章全流程!
10132 27
让龙虾成为你的“公众号分身” | 阿里云服务器玩Openclaw
|
13天前
|
人工智能 安全 Linux
【OpenClaw保姆级图文教程】阿里云/本地部署集成模型Ollama/Qwen3.5/百炼 API 步骤流程及避坑指南
2026年,AI代理工具的部署逻辑已从“单一云端依赖”转向“云端+本地双轨模式”。OpenClaw(曾用名Clawdbot)作为开源AI代理框架,既支持对接阿里云百炼等云端免费API,也能通过Ollama部署本地大模型,完美解决两类核心需求:一是担心云端API泄露核心数据的隐私安全诉求;二是频繁调用导致token消耗过高的成本控制需求。
5855 14
|
21天前
|
人工智能 JavaScript Ubuntu
5分钟上手龙虾AI!OpenClaw部署(阿里云+本地)+ 免费多模型配置保姆级教程(MiniMax、Claude、阿里云百炼)
OpenClaw(昵称“龙虾AI”)作为2026年热门的开源个人AI助手,由PSPDFKit创始人Peter Steinberger开发,核心优势在于“真正执行任务”——不仅能聊天互动,还能自动处理邮件、管理日程、订机票、写代码等,且所有数据本地处理,隐私完全可控。它支持接入MiniMax、Claude、GPT等多类大模型,兼容微信、Telegram、飞书等主流聊天工具,搭配100+可扩展技能,成为兼顾实用性与隐私性的AI工具首选。
22900 119
|
7天前
|
人工智能 JavaScript API
解放双手!OpenClaw Agent Browser全攻略(阿里云+本地部署+免费API+网页自动化场景落地)
“让AI聊聊天、写代码不难,难的是让它自己打开网页、填表单、查数据”——2026年,无数OpenClaw用户被这个痛点困扰。参考文章直击核心:当AI只能“纸上谈兵”,无法实际操控浏览器,就永远成不了真正的“数字员工”。而Agent Browser技能的出现,彻底打破了这一壁垒——它给OpenClaw装上“上网的手和眼睛”,让AI能像真人一样打开网页、点击按钮、填写表单、提取数据,24小时不间断完成网页自动化任务。
1738 4