从区间锁到行锁:一次高并发写入死锁治理实战

资源账户写入链路长期存在 MySQL 死锁报警,日常量级达到 99+。虽然业务层依赖消息总线重试后多数请求可以成功,但从数据库和链路治理角度看,这类问题已经属于稳定存在的并发设计缺陷。

本次治理聚焦一个典型场景:同一 uid 并发发放多个新资源,且对应账户记录尚未创建。原始实现中,select ... for update 与 insert ... on duplicate key update 的组合在记录不存在时触发 gap lock / next-key lock,导致后续插入互相等待,最终形成死锁。

优化目标不是简单去掉 for update,而是在保留数据准确性的前提下缩小锁范围。最终方案分两步:

  • 在正式加锁前先尝试插入一条数量为 0 的初始化记录,将"锁区间"收敛为"锁行"。
  • 废弃 on duplicate key update 的写法,改为显式 insert,在唯一键冲突时回退到 update,把高风险死锁降级为可控的并发竞争。

一、问题背景与影响

问题发生在资源账户表 user_resource_account 的写入路径中。业务动作很直接:用户获得某个资源后,需要把对应数量累加到账户表。

线上主要出现以下并发场景:

  • 同一个 uid
  • 极短时间内并发发放多个不同的 resourceId
  • 这些 (uid, resourceId) 记录在账户表中尚不存在 在这种情况下,应用日志和数据库侧会频繁出现死锁报警。由于上层 bus 具备重试机制,最终业务结果大多可恢复,因此问题一度没有直接暴露为用户故障。

但从工程视角看,这类死锁仍然需要治理,原因主要有 3 点:

  • 数据库需要反复做无效锁竞争,增加了事务回滚和重试成本。
  • 消息链路承担了额外的消费抖动,放大了热点用户或热点活动下的链路不稳定性。
  • 高频死锁会稀释异常告警的信噪比,不利于真正高风险故障的识别。 因此,这次治理的目标不是"让重试成功率更高",而是把这条写入路径从"依赖重试兜底"改造成"并发行为本身更稳定"。

二、现网实现与问题入口

原始实现的核心 SQL 如下:

sql 复制代码
select *
from user_resource_account
where uid = #{uid} and resource_id = #{resourceId}
for update;
sql 复制代码
insert into user_resource_account
set uid = #{uid},
resource_id = #{resourceId},
free_num = #{num},
total_num = #{num}
on duplicate key update
free_num = free_num + #{num},
total_num = total_num + #{num};

如果只看业务语义,这套实现很自然:

  • 通过 select ... for update 保证并发安全。
  • 如果账户记录不存在,则通过 insert 建立新记录。
  • 如果多个线程并发命中同一唯一键,则依赖 on duplicate key update 完成累加。 问题在于,这段逻辑在"记录已经存在"的情况下通常没有问题,而真正的高风险点恰恰出现在"记录不存在"的分支。

三、死锁形成机制

3.1 关键事实:记录不存在时,锁的不一定是空

这次问题的关键认知是:select ... for update 在目标记录不存在时,并不是"什么都不锁"。

在 InnoDB 的可重复读锁模型下,如果查询命中的是唯一索引上的一个不存在的键值,数据库可能会在相邻索引区间上加 gap lock 或 next-key lock,以阻止其他事务在该区间插入数据。

这意味着,业务虽然只是"查不到一条记录",但数据库实际保护的是"一段索引范围"。

3.2 死锁序列还原

假设同一个用户 U 并发发放两个新资源 ResourceA 和 ResourceB,且两条账户记录都不存在。一个可行的死锁序列如下:

  • 事务 T1 执行 select ... where uid = U and resource_id = ResourceA for update
  • 事务 T2 执行 select ... where uid = U and resource_id = ResourceB for update
  • 由于两条记录都不存在,T1 与 T2 分别在相邻索引区间上持有 gap lock / next-key lock
  • T1 继续执行 insert ResourceA
  • T2 继续执行 insert ResourceB
  • 插入阶段需要申请插入意向锁,而该锁与对方之前持有的区间锁冲突
  • 最终形成循环等待,MySQL 选择回滚其中一个事务

3.3 为什么不是简单的唯一键冲突

如果是两个线程并发写同一个 (uid, resourceId),更常见的现象通常是:

  • 一个线程插入成功
  • 另一个线程抛出 DuplicateKeyException 而本次线上实际出现的是 Deadlock found when trying to get lock。这说明事务失败点发生在"拿锁阶段",而不是"唯一键校验阶段"。因此,问题本质不是主键或唯一键冲突,而是锁顺序与锁粒度导致的互相等待。

四、方案设计约束

在确定优化方案前,先明确几个边界条件。

第一,不能简单取消 for update。 这条链路对应的是资源账户数量,后续需要支持账务核对和问题追踪,因此写入过程仍然需要明确的并发控制。

第二,不能只依赖重试来消化问题。 重试解决的是结果可恢复,不解决数据库层的高频锁竞争。

第三,优化目标不是"完全无竞争",而是把冲突从死锁互等收敛成系统能够稳定处理的形式。

基于这些约束,优化方向应当是:在保留数据准确性的前提下,缩小锁范围、简化写入路径、降低锁竞争的不确定性。

五、优化方案

5.1 方案一:预创建 0 值记录

在正式执行 for update 之前,先尝试插入一条数量为 0 的账户记录;对于新资源相关的过期记录,也执行同样的预创建逻辑。

这一步的价值在于,把原来的"查不到记录"转化为"查得到记录"。后续再执行 for update 时,锁住的就是目标行本身,而不是索引区间。

从锁行为上看,这个改动的核心收益是:

  • 原先的热点竞争发生在"索引区间"
  • 改造后,主要竞争收敛到"真实存在的记录" 也就是说,这一步并没有消除并发,而是显著缩小了锁影响范围。

5.2 方案二:显式 insert,冲突后回退 update

第二个改动是废弃 on duplicate key update,改成更明确的写入路径:

java 复制代码
private ResourceUserAccount addResourceAccount(Long uid, Resource Resource, int num, Date currentTime) {
try {
ResourceUserAccountMapper.insertOldData(uid, Resource.getPlatform(), Resource.getresourceId(), num, currentTime);
} catch (DuplicateKeyException e) {
log.warn("ResourceUserAccount duplicate key on insert, fallback to update. uid:{}, resourceId:{}",
uid, Resource.getresourceId(), e);
ResourceUserAccountMapper.updateNum(uid, Resource.getresourceId(), num, currentTime);
}  catch (Exception e) {
log.error("addResourceAccountException", e);
throw e;
}
return ResourceUserAccountMapper.getUserResourceInfo(uid, Resource.getresourceId());
}

采用这条路径后,并发场景会变得更可解释:

  • 如果记录尚未建立,则插入成功。
  • 如果其他线程已经抢先插入,则本线程拿到 DuplicateKeyException。
  • 捕获异常后,再执行 update 完成数量累加。 这相当于把原来耦合在一条 SQL 里的"插入或更新"语义拆开,使冲突路径更透明,也更便于监控和告警分类。

六、方案收益与边界

6.1 机制层面的收益

这次改造带来的收益主要体现在机制上,而不是语法层面:

  • 把锁从区间锁收敛为行锁,降低了"记录不存在"分支的锁放大问题。
  • 把异常从死锁回滚收敛为唯一键竞争,降低了事务互等的复杂度。
  • 把写入路径从"数据库自行决定插入或更新"改成"应用明确控制回退逻辑",提升了问题定位与可观测性。

6.2 边界与残余风险

这个方案并不意味着该链路从此不会再有锁竞争。仍然需要注意几个边界:

  • 如果后续在同一事务中继续引入更多表、更多索引或更复杂的锁顺序,仍然可能产生新的死锁路径。
  • 如果同一 uid 的热点写入强度持续升高,虽然死锁概率下降,但行级竞争和重试成本仍然可能成为性能瓶颈。
  • 如果初始化逻辑和正式写入逻辑之间缺乏统一约束,可能引入新的数据一致性分支。 因此,这个方案的正确解读不是"彻底消灭冲突",而是"把原来最难处理的冲突形态降级为更可控的竞争形态"。

七、可复用经验

结合这次治理,可以沉淀出几条更通用的经验:

  • 在高并发场景下,最危险的往往不是更新已有记录,而是并发创建原本不存在的记录。
  • select ... for update 不能只从业务语义理解,还必须结合 InnoDB 的锁语义理解,尤其要关注不存在记录时的区间锁行为。
  • on duplicate key update 适合简化普通写入逻辑,但在热点写入、强一致或复杂事务场景下,需要评估其锁行为是否可接受。
  • 处理并发问题时,与其追求零冲突,不如优先把冲突变成可预测、可恢复、可观测的形态。

八、总结

这次资源账户死锁治理,本质上不是一次 SQL 改写,而是一次对锁模型的纠偏。

业务层看到的是"账户不存在,先查再插";数据库层看到的却可能是"多个事务正在竞争同一段索引区间"。只要这个认知偏差不被修正,类似的问题就会在库存、额度、账户、券包等场景反复出现。

这次优化的价值,归根到底是两点:

  • 让锁落在真实存在的记录上,而不是落在不确定的区间上。
  • 让冲突以应用能够明确处理的方式暴露出来,而不是以事务互锁的方式随机失败。 这也是后续处理类似并发写入问题时,一个值得优先复用的思路。

以上是本期分享。如果你想和我们进一步交流技术问题、获取独家干货,欢迎扫码加入花椒技术交流群。

相关推荐
随风,奔跑2 小时前
Spring Cloud Alibaba(四)---Spring Cloud Gateway
后端·spring·gateway
用户8356290780512 小时前
Python 设置 PowerPoint 文档属性与页面参数
后端·python
Rust研习社2 小时前
Once、OnceCell、OnceLock:Rust 一次性初始化终极指南
后端·rust·编程语言
Rust研习社2 小时前
从入门到实践:Rust 异步编程完全指南
开发语言·后端·rust
GreenTea2 小时前
DeepSeek-V4 技术报告深度分析:基础研究创新全景
前端·人工智能·后端
用户8356290780512 小时前
使用 Python 自动管理 PowerPoint 幻灯片分节的方法
后端·python
逸风尊者3 小时前
XGBoost模型工程使用
java·后端·算法
ekuoleung3 小时前
量化平台中的 DSL 设计与实现:从规则树到可执行策略
前端·后端
小研说技术3 小时前
实时通信对比,一场MCP协议的技术革命
前端·后端·面试