超卖逻辑下的加锁情况(乐观锁)
大约 5 分钟
是主键索引的情况
在 MySQL 的 InnoDB 存储引擎中,你这张截图展示的代码对应的是一个经典的 Optimistic Locking(乐观锁) 思想的实现,但其底层在 MySQL 执行时仍然会涉及到 Pessimistic Locking(悲观锁/行锁)。
我们来拆解一下这条语句在 MySQL 内部的加锁过程:
1. 物理执行的 SQL 语句
代码最终会被翻译成类似如下的 SQL:
SQL
UPDATE tb_seckill_voucher
SET stock = stock - 1
WHERE voucher_id = ? AND stock = ?;2. 加锁过程详解
A. 查找与定位(加锁阶段)
- 搜索记录:MySQL 会根据
WHERE条件查找对应的行。 - 命中索引:如果
voucher_id是主键或唯一索引(通常如此),InnoDB 会直接定位到该聚簇索引记录。 - 加 X 锁(排他锁):为了执行
UPDATE,InnoDB 会对该行记录加上 Record Lock(记录锁),也就是 X 锁。- 重点:即便你使用了乐观锁的
stock = ?条件,MySQL 在执行修改前必须先锁定这一行,防止其他事务同时修改。
- 重点:即便你使用了乐观锁的
B. 条件过滤(乐观锁逻辑)
- 数据比对:在锁定该行后,MySQL 会检查
stock的当前值是否等于你传入的voucher.getStock()。 - 冲突处理:
- 匹配成功:如果值相等,说明没有其他线程改过,执行
stock - 1操作,并在事务提交时释放锁。 - 匹配失败:如果值不相等(即被抢先一步),
UPDATE语句会返回受影响行数为 0 (Rows affected: 0),但此时锁已经产生过并会随后释放。
- 匹配成功:如果值相等,说明没有其他线程改过,执行
C. 间隙锁(Gap Lock)说明
- 由于使用的是 唯一索引 (
voucher_id) 且记录存在,此时只会产生 Record Lock。 - 如果
voucher_id不是索引或者发生了范围查询,可能会触发 Next-Key Locks(记录锁+间隙锁),导致大面积锁定,但在秒杀场景下,通常会保证voucher_id是主键。
3. 这种写法在“秒杀”中的性能瓶颈
这种写法存在一个明显的 “超卖缓解” 与 “性能损耗” 的权衡:
- 乐观锁冲突率高:在高并发下,多个线程拿到了相同的
stock值,只有一个能成功,其他的都会因为stock = ?条件不匹配而失败(success = false)。 - 锁竞争:虽然是乐观锁思想,但每一条请求在执行
UPDATE的那一刻,依然会在数据库层面竞争同一个主键记录的 X 锁。大量的请求排队等待同一个 ID 的行锁,会导致数据库连接池被占满。
4. 优化建议
如果你是为了防止超卖,更好的写法通常是将条件改为:
Java
.setSql("stock = stock - 1")
.eq("voucher_id", voucherId)
.gt("stock", 0) // 只要库存大于0就允许扣减,而不是匹配旧值这样可以大幅降低因为“版本号/旧值不匹配”导致的失败率,同时利用数据库的原子性保证不超卖。
非主键索引的情况
如果 voucher_id 不是主键(甚至没有索引),MySQL 的加锁行为会变得非常“重”,这通常是高并发系统的性能杀手。
在 InnoDB 存储引擎中,加锁是基于索引实现的。如果 voucher_id 不是主键,情况分为以下两种:
1. voucher_id 是普通索引(非唯一索引)
如果 voucher_id 上建了普通索引,MySQL 的加锁过程如下:
- 加锁对象:MySQL 会对该索引记录加 X 锁(排他锁)。
- 间隙锁(Gap Lock):为了防止幻读(Phantom Read),InnoDB 不仅会锁住对应的记录,还会加上 Next-Key Locks。它会锁住该记录前后的“间隙”,防止其他事务在这个范围内插入新的数据。
- 回表加锁:通过
voucher_id索引定位到主键后,MySQL 还会去 聚簇索引(主键索引) 上给对应的行记录加 X 锁。
2. voucher_id 完全没有索引(全表扫描)
这是最糟糕的情况。如果 voucher_id 没有索引,MySQL 无法通过索引定位数据:
- 全表加锁:MySQL 会进行全表扫描。在扫描过程中,会对表中的每一行记录都加上 X 锁。
- 锁表风险:虽然 MySQL 在扫描完不符合条件的行后可能会释放掉不匹配的锁(取决于隔离级别和版本),但在扫描期间,整个表的更新操作几乎是被阻塞的。这在生产环境下等同于“锁表”。
3. 隔离级别(RR vs RC)的影响
锁的具体表现还取决于数据库的事务隔离级别:
| 隔离级别 | voucher_id 无索引时的表现 |
|---|---|
| Repeatable Read (RR) | 默认级别。会产生大量的 Next-Key Locks,甚至在搜索时锁住全表的记录和间隙,直到事务提交。并发能力极低。 |
| Read Committed (RC) | 虽然也会全表扫描加锁,但 MySQL 优化了过滤逻辑:对于不满足 WHERE 条件的记录,会立即释放锁,只保留真正匹配行的锁。 |
总结与风险警示
如果在秒杀这种高并发场景下,voucher_id 不是主键且没有索引:
- 数据库崩溃:所有的扣减库存请求都会在数据库层面排队,且由于是全表扫描,执行时间极长,数据库连接池会迅速耗尽。
- 死锁概率激增:多个事务同时进行全表扫描加锁,极易发生循环等待,导致
Deadlock found when trying to get lock。
建议:
务必确保 WHERE 条件中的字段(如 voucher_id)是主键或具有唯一索引。
需要我帮你写一段 SQL 脚本来检查这个表目前的索引结构,或者演示如何添加索引吗?