在电商或金融系统中,并发控制是最核心的挑战之一。
很多开发者在写"扣减库存"逻辑时,习惯写出这样的代码:
- 查询当前库存:SELECT stock FROM products WHERE id = 1;
- 应用层判断:if (stock > 0)
- 更新库存: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)
注意:
- 尽量让 UPDATE/DELETE 语句命中唯一索引,避免产生间隙锁。
- 在高并发写入场景下,考虑将隔离级别降级为 RC (Read Committed)(RC 没有间隙锁),通过业务层逻辑保证一致性。

总结
并发控制是数据库技术的深水区:
- 防止超卖 :优先考虑 乐观锁 (Version CAS) ,只有在高冲突场景下才使用 悲观锁 (FOR UPDATE)。
- 隔离级别 :清楚你的数据库跑在 RC 还是 RR 下。MySQL 默认的 RR 虽然安全,但会引入额外的 Gap Lock 开销和死锁风险。
- 死锁分析:遇到死锁别慌,通过 SHOW ENGINE INNODB STATUS 分析锁等待链,通常是因为对"不存在的记录"加锁导致的间隙锁冲突。