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)

复制代码
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 字段。

复制代码
-- 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:

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

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

事务 B:

复制代码
-- 试图插入一条记录 (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。要看详情,需要执行:

复制代码
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 分析锁等待链,通常是因为对"不存在的记录"加锁导致的间隙锁冲突。

相关推荐
AC赳赳老秦15 小时前
DeepSeek 私有化部署避坑指南:敏感数据本地化处理与合规性检测详解
大数据·开发语言·数据库·人工智能·自动化·php·deepseek
myzshare15 小时前
实战分享:我是如何用SSM框架开发出一个完整项目的
java·mysql·spring cloud·微信小程序
YMatrix 官方技术社区15 小时前
YMatrix 存储引擎解密:MARS3 存储引擎如何超越传统行存、列存实现“时序+分析“场景性能大幅提升?
开发语言·数据库·时序数据库·数据库架构·智慧工厂·存储引擎·ymatrix
辞砚技术录16 小时前
MySQL面试题——索引2nd
数据库·mysql·面试
linweidong16 小时前
C++thread pool(线程池)设计应关注哪些扩展性问题?
java·数据库·c++
墨笔之风17 小时前
java后端根据双数据源进行不同的接口查询
java·开发语言·mysql·postgres
欧亚学术17 小时前
突发!刚刚新增17本期刊被剔除!
数据库·论文·sci·期刊·博士·scopus·发表
黑白极客18 小时前
怎么给字符串字段加索引?日志系统 一条更新语句是怎么执行的
java·数据库·sql·mysql·引擎
大厂技术总监下海18 小时前
数据湖加速、实时数仓、统一查询层:Apache Doris 如何成为现代数据架构的“高性能中枢”?
大数据·数据库·算法·apache