MySQL并发与锁:从“防止超卖”到排查“死锁”

在电商或金融系统中,并发控制是最核心的挑战之一。

很多开发者在写"扣减库存"逻辑时,习惯写出这样的代码:

  1. 查询当前库存:SELECT stock FROM products WHERE id = 1;
  2. 应用层判断:if (stock > 0)
  3. 更新库存:UPDATE products SET stock = stock - 1 WHERE id = 1;

在低并发下这没问题。但在高并发场景下,两个线程可能同时读到 stock=1,同时通过校验,分别执行 UPDATE。结果是:卖出了两件商品,库存变成了 -1。

这就是典型的竞态条件。解决这个问题的终极战场不在应用层,而在数据库层。

一、 悲观锁 vs 乐观锁:如何安全地扣减库存?

1. 悲观锁 (Pessimistic Locking):SELECT ... FOR UPDATE

悲观锁的核心思想是:"总有人想跟我抢,我先锁住再说。"

在 MySQL InnoDB 中,我们可以通过 FOR UPDATE 显式添加排他锁 (X Lock)

sql 复制代码
START TRANSACTION;

-- 1. 显式加锁。此时其他事务如果想查或改这行数据,必须等待
SELECT stock FROM products WHERE id = 1 FOR UPDATE;

-- 2. 只有拿到锁的事务才能执行后续逻辑
-- (应用层判断 stock > 0)
UPDATE products SET stock = stock - 1 WHERE id = 1;

COMMIT;

优点: 强一致性,完全避免超卖。

缺点: 性能开销大。锁的持有时间 = 事务执行时间。在高并发下,会造成大量请求阻塞,甚至超时。

适用场景: 写入频繁、强一致性要求极高、并发量适中的场景(如转账)。

2. 乐观锁 (Optimistic Locking):基于 CAS (Compare-and-Swap)

乐观锁的核心思想是:"大家随便拿,但我更新时要检查一下数据有没有变。"

这不需要数据库显式加锁,而是通过版本号 (version) 机制在 SQL 层面实现。

我们需要在表中增加一个 version 字段。

ini 复制代码
-- 1. 查询数据,同时拿到版本号 (假设 version = 10)
SELECT stock, version FROM products WHERE id = 1;

-- 2. 更新时,校验版本号是否依然是 10
UPDATE products 
SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = 10;

逻辑解析:

如果 SQL 返回 Affected rows: 1,说明扣减成功。

如果返回 0,说明在查询和更新之间,有别的事务修改了数据(version 变成了 11)。此时应用层应捕获这个失败,并进行重试(Retry)或报错。

优点: 无锁(No-Locking),并发性能极高。

缺点: 在冲突激烈的场景下,重试率高,可能浪费 CPU。

适用场景: 读多写少、追求高吞吐量的场景(如秒杀)。

二、 事务隔离级别:脏读与幻读的权衡

MySQL 默认的隔离级别是 RR (Repeatable Read) ,而 Oracle/PostgreSQL 默认通常是 RC (Read Committed)。这两者的区别直接影响业务逻辑的正确性。

1. 脏读 (Dirty Read)

A 事务读取了 B 事务未提交的数据。

  • 发生场景: READ UNCOMMITTED 级别。
  • 结论: 生产环境严禁使用,除非你在做极其粗略的统计。

2. 不可重复读 (Non-Repeatable Read)

A 事务里两次读取同一行数据,结果不一样(因为 B 事务在中间提交了修改)。

  • RC 级别允许发生。
  • RR 级别 通过 MVCC (多版本并发控制) 解决了这个问题。在 RR 下,事务开启时会生成一个快照 (Snapshot),后续读取的都是快照版本。

3. 幻读 (Phantom Read)

A 事务读取了一个范围(如 id > 10),B 事务插入了一条新数据 id=11。A 再次读取时,发现多了一条"幽灵"数据。

  • MySQL 的 RR 级别 通过 Next-Key Lock (间隙锁) 在很大程度上解决了幻读,但也带来了死锁隐患。

三、 死锁 (Deadlock) 排查:看不见的"间隙锁"

很多开发者认为:"我只锁了一行数据,为什么会死锁?"

在 MySQL 的 RR 隔离级别下,最常见的死锁凶手是 间隙锁 (Gap Lock)。

场景复现:

表 t_user 有 id 为 10, 20 的两条记录。

事务 A:

ini 复制代码
-- 试图更新一条不存在的记录 (id=15)
UPDATE t_user SET name = 'A' WHERE id = 15;

解析: 因为 id=15 不存在,MySQL 为了防止幻读,会锁住 (10, 20) 这个间隙 (Gap),不许别人在这个范围内插入数据。

事务 B:

sql 复制代码
-- 试图插入一条记录 (id=16)
INSERT INTO t_user (id, name) VALUES (16, 'B');

解析: id=16 落在 (10, 20) 间隙内,被事务 A 的间隙锁阻塞,进入等待。

死锁形成:

如果事务 A 此时也想执行 INSERT INTO t_user (id, name) VALUES (16, 'A');,它会发现事务 B 也在申请锁,于是形成环路:A 等 B 释放意向锁,B 等 A 释放间隙锁 -> Deadlock found。

如何排查?

当发生死锁时,应用层通常只会收到一个 Generic Error。要看详情,需要执行:

ini 复制代码
SHOW ENGINE INNODB STATUS;

在输出的 LATEST DETECTED DEADLOCK 章节中,你会看到清晰的日志:

  • LOCK WAIT
  • RECORD LOCKS space id ... index PRIMARY ... lock_mode X locks gap before rec (关键词:locks gap)

注意:

  1. 尽量让 UPDATE/DELETE 语句命中唯一索引,避免产生间隙锁。
  2. 在高并发写入场景下,考虑将隔离级别降级为 RC (Read Committed)(RC 没有间隙锁),通过业务层逻辑保证一致性。

总结

并发控制是数据库技术的深水区:

  1. 防止超卖 :优先考虑 乐观锁 (Version CAS) ,只有在高冲突场景下才使用 悲观锁 (FOR UPDATE)
  2. 隔离级别 :清楚你的数据库跑在 RC 还是 RR 下。MySQL 默认的 RR 虽然安全,但会引入额外的 Gap Lock 开销和死锁风险。
  3. 死锁分析:遇到死锁别慌,通过 SHOW ENGINE INNODB STATUS 分析锁等待链,通常是因为对"不存在的记录"加锁导致的间隙锁冲突。
相关推荐
星浩AI16 小时前
LCEL:打造可观测、可扩展、可部署的 LangChain 应用
人工智能·后端·python
用户2986985301416 小时前
C#: 在Word文档中添加或移除可编辑区域
后端·c#
初次攀爬者16 小时前
RAG核心升级|多LLM模型动态切换方案
人工智能·后端·ai编程
EntyIU16 小时前
自己实现mybatisplus的批量插入
java·后端
用户6174332731016 小时前
MySQL 表的类 Git 版本控制
后端
pany16 小时前
程序员近十年新年愿望,都有哪些变化?
前端·后端·程序员
杨宁山16 小时前
Java 解析 CDR 文件并计算图形面积的完整方案(支持 MultipartFile / 网络文件)@杨宁山
后端
朱昆鹏16 小时前
IDEA Claude Code or Codex GUI 插件【开源自荐】
前端·后端·github
HashTang16 小时前
买了专业屏只当普通屏用?解锁 BenQ RD280U 的“隐藏”开发者模式
前端·javascript·后端