ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

InnoDB事务与锁机制深度解析:从ACID原理到高并发实战

InnoDB事务与锁机制深度解析:从ACID原理到高并发实战

1. 为什么“事务”不是数据库的装饰品,而是生存底线

很多人第一次接触 InnoDB,是在学 SQL 的时候被一句“MySQL 默认自动提交”轻轻带过。后来写业务代码,看到BEGIN、COMMIT、ROLLBACK,也只当是语法糖——直到某天凌晨三点,订单系统突然出现“同一笔支付被扣款两次”,而日志里查不到任何异常报错;又或者库存明明显示还有 5 件,下单却提示“库存不足”,刷新再看又变回 5 件。这时候才猛然意识到:事务不是可有可无的开关,它是数据世界里唯一能防止现实逻辑崩塌的承重墙。

InnoDB 的事务能力,从来就不是为“教学演示”设计的。它诞生于真实高并发场景的血泪教训:电商秒杀时千万人点同一个“立即购买”按钮,银行转账中 A 扣款成功但 B 入账失败,物流系统里运单状态在“已揽收→运输中→派送中”流转时被并发修改……这些场景下,如果数据库不能保证“要么全做,要么全不做”,不能确保“我看到的数据,在我操作期间不会被别人悄悄改掉”,那整个业务系统就等同于建在流沙上的房子。

你可能用过SELECT * FROM order WHERE status = 'unpaid' LIMIT 1 FOR UPDATE,但未必真正理解这行 SQL 背后发生了什么:它不只是加了一把“锁”,而是触发了 InnoDB 整套 MVCC(多版本并发控制)+ 行级锁 + 事务隔离级别的精密协同。它让两个线程同时查询同一笔未支付订单时,第一个线程拿到记录并加锁,第二个线程必须等待——不是简单地“查不到”,而是被阻塞在锁等待队列里,直到超时或前一个事务释放锁。这个机制,正是“库存扣减不超卖”的底层支柱。

更关键的是,InnoDB 的事务不是孤立存在的。它和 redo log(重做日志)、undo log(回滚日志)、buffer pool(缓冲池)、doublewrite buffer(双写缓冲区)深度耦合。一次COMMIT成功,并不意味着数据已经刷到磁盘文件里;它只代表 redo log 已经落盘,足以在崩溃后恢复出一致状态。而 undo log 则像时间机器,保存着事务开始前每一行的旧值,支撑起ROLLBACK和 MVCC 的快照读。这种设计,让 InnoDB 在保证 ACID 的同时,依然能扛住每秒数万次的写入压力——这不是魔法,是工程上对“一致性”与“性能”反复权衡后的最优解。

所以,“InnoDB 事务与锁的前世今生”这个标题,说的不是历史课,而是一张通往生产环境稳定性的通行证。它要求你跳出“SQL 语句怎么写”的层面,进入“数据在内存中如何组织、在磁盘上如何落盘、在并发时如何协调”的内核视角。接下来要讲的,不是教科书定义,而是我在多个高并发系统上线前压测、故障复盘、慢查优化中,亲手验证过的逻辑链条。

2. ACID 四个字母背后,藏着四层物理实现

ACID 是事务的黄金标准,但很多人只记住了四个字母,却没拆开看过它们各自对应的硬件动作和内存结构。在 InnoDB 里,A(原子性)、C(一致性)、I(隔离性)、D(持久性)不是抽象概念,而是由四组具体组件协同完成的硬核工程:

2.1 原子性(Atomicity):靠 undo log 实现的“后悔药机制”

原子性要求事务中的所有操作要么全部成功,要么全部失败回滚。InnoDB 不是靠“记住每一步然后倒着执行”来实现回滚,而是采用“预写式日志 + 版本链”策略。

当你执行UPDATE product SET stock = stock - 1 WHERE id = 1001,InnoDB 会先将原stock值(比如 10)连同该行的主键、事务 ID 等信息,写入 undo log 中的一个新段(undo log segment),生成一条 undo record。这条记录不是文本,而是二进制格式,包含足够的信息用于反向操作(如SET stock = stock + 1)。同时,当前行的聚簇索引记录会被打上“删除标记”,并指向这条 undo record。

提示:undo log 并非无限增长。InnoDB 通过 purge 线程异步清理那些已无任何事务需要访问的旧版本。但如果长事务长时间不提交,undo log 就会持续膨胀,最终可能撑爆ibdata1文件,引发ERROR 1114 (HY000): The table is full。这是线上最隐蔽的“磁盘满”原因之一。

实测案例:某促销系统曾因一个监控脚本开启事务后忘记COMMIT,导致 undo log 占用 32GB 空间,而业务表本身才 8GB。排查时发现SHOW ENGINE INNODB STATUS中History list length持续飙升至 200 万+,这就是 purge 被阻塞的明确信号。

2.2 一致性(Consistency):不是数据库的承诺,而是应用与引擎的契约

一致性常被误解为“数据库自己保证数据正确”。其实 InnoDB 只负责约束层面的一致性(如外键、主键、唯一索引、CHECK 约束),而业务逻辑层面的一致性(如“付款成功后订单状态必须变为 paid”、“库存扣减后必须生成出库单”)完全依赖应用层的事务边界设计。

InnoDB 的一致性保障,体现在它绝不允许违反约束的操作落地。例如,向设置了UNIQUE KEY (email)的用户表插入两条相同 email 的记录,InnoDB 会在唯一索引页上检测到冲突,并立即返回Duplicate entry错误,而不是让两条记录都写进去再让你去查重。

但更深层的一致性,来自事务的原子性与隔离性组合。比如转账场景:

START TRANSACTION; UPDATE account SET balance = balance - 100 WHERE id = 1; UPDATE account SET balance = balance + 100 WHERE id = 2; COMMIT;

InnoDB 保证这两条 UPDATE 要么都生效,要么都不生效(原子性);同时,在事务执行过程中,其他事务无法看到中间状态(如 A 余额已减但 B 余额未增),从而避免了“资金凭空消失”的幻觉(隔离性)。这才是业务一致性的技术基础。

2.3 隔离性(Isolation):MVCC + 锁的双轨制,并非只有“读已提交”一种解法

隔离性解决的是并发读写冲突问题。InnoDB 默认使用REPEATABLE READ(可重复读)隔离级别,但它实现方式与传统“锁住整张表”完全不同——它采用MVCC(多版本并发控制)为主、锁为辅的混合策略。

  • 快照读(Snapshot Read):SELECT语句默认走 MVCC。每个事务启动时,InnoDB 会为其分配一个唯一的transaction id,并基于read view(读视图)决定能看到哪些版本的数据。REPEATABLE READ下,这个 read view 在事务第一次 SELECT 时创建,之后所有 SELECT 都复用它,因此能保证“可重复读”。

  • 当前读(Current Read):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE这些语句必须读取最新版本,并加锁。此时 MVCC 失效,直接走行锁机制。

关键点在于:MVCC 解决了“读-写不阻塞”,而锁解决了“写-写冲突”。这解释了为什么在 RR 级别下,SELECT不会阻塞UPDATE,但两个并发UPDATE同一行会互相等待。

注意:REPEATABLE READ并不能完全避免幻读。标准定义中,幻读指“同一查询条件,前后两次 SELECT 返回行数不同”。InnoDB 通过间隙锁(Gap Lock)在 RR 级别下阻止了“新插入符合 WHERE 条件的记录”,从而在绝大多数业务场景中规避了幻读。但如果你用INSERT ... SELECT或UPDATE ... WHERE涉及范围扫描,仍可能遇到。

2.4 持久性(Durability):redo log 的“两阶段提交”才是真正的保险丝

持久性要求事务一旦提交,其结果就必须永久保存,即使数据库崩溃也不能丢失。InnoDB 的答案是redo log + doublewrite buffer + 两阶段提交(2PC)。

过程如下:

  1. 事务执行过程中,所有数据页的修改(如更新索引、修改聚簇索引记录)先写入内存 buffer pool;
  2. 同时,这些修改的“物理日志”(即“将 page X 的 offset Y 处的字节从 A 改为 B”)被顺序写入 redo log buffer;
  3. COMMIT时,InnoDB 执行两阶段提交:
    • Prepare 阶段:将事务的 XID(事务 ID)写入 redo log,并调用fsync()强制刷盘;
    • Commit 阶段:将 commit 标记写入 redo log,并再次fsync()。

只有这两个 fsync 都成功,事务才算真正提交。崩溃恢复时,MySQL 启动时会扫描 redo log,对处于 prepare 状态但没有 commit 标记的事务,检查其是否在 binlog 中存在(如果是主从架构),再决定是提交还是回滚。

实测对比:关闭innodb_flush_log_at_trx_commit=2(每秒刷一次 redo log)时,QPS 可提升 30%,但极端情况下可能丢失 1 秒内的事务;设为 1(每次 commit 都刷盘)则绝对安全,但 IO 压力显著上升。我们在线上核心交易库中,始终强制设为 1。

3. 锁的七种形态:从行锁到意向锁,每一种都在解决特定痛点

InnoDB 的锁体系,远比“共享锁/排他锁”复杂。它是一个分层、嵌套、按需申请的精密系统。理解每种锁的触发条件和作用范围,是诊断死锁、优化并发性能的前提。

3.1 行锁(Record Lock):精确打击,只锁住索引记录本身

行锁是最细粒度的锁,只作用于索引记录(注意:是索引记录,不是数据行)。它分为两种:

  • S 锁(共享锁):SELECT ... LOCK IN SHARE MODE加的锁,允许多个事务同时持有,但阻止任何 X 锁;
  • X 锁(排他锁):SELECT ... FOR UPDATE、UPDATE、DELETE加的锁,互斥,阻止其他任何锁。

关键细节:行锁必须通过索引加,且只加在搜索条件命中的索引记录上。例如:

-- 假设 id 是主键 SELECT * FROM user WHERE id = 1001 FOR UPDATE; -- 只锁住 id=1001 这一行 -- 假设 name 是普通索引,且有重复值 SELECT * FROM user WHERE name = '张三' FOR UPDATE; -- 锁住所有 name='张三' 的索引记录

但如果WHERE条件没走索引,InnoDB 会退化为全表扫描,并对扫描到的每一行都加 X 锁——这极易引发大面积锁等待。我们曾在线上遇到一个慢查询因缺失索引,导致锁住 2 万行,拖垮整个用户中心服务。

3.2 间隙锁(Gap Lock):封锁“不存在的记录”,专治幻读

间隙锁锁定的是索引记录之间的“间隙”,或者第一条记录之前、最后一条记录之后的区间。它不锁记录本身,只锁住“不允许插入新记录”的范围。

例如,user 表主键为 1,5,10:

  • SELECT * FROM user WHERE id BETWEEN 2 AND 8 FOR UPDATE;
    会加间隙锁(1,5)和(5,10),即禁止插入 id=2,3,4,6,7,8,9 的记录。

间隙锁只在REPEATABLE READ隔离级别下生效,且仅对范围查询(>,<,BETWEEN,LIKE前缀匹配)有效。等值查询(=)且命中唯一索引时,InnoDB 会自动优化为“next-key lock”的“记录锁”部分,不加间隙锁。

经验:线上排查死锁时,SHOW ENGINE INNODB STATUS输出中*** (1) WAITING FOR THIS LOCK TO BE GRANTED:后面的lock_mode X locks gap before rec就是间隙锁等待的明确标识。很多“莫名其妙”的死锁,根源就是两个事务对同一间隙加锁,又试图插入同一位置。

3.3 临键锁(Next-Key Lock):行锁 + 间隙锁的合体,RR 级别的默认选择

Next-Key Lock = Record Lock + Gap Lock。它是 InnoDB 在REPEATABLE READ下的默认加锁算法,用于解决幻读问题。

例如,对主键id执行SELECT * FROM t WHERE id > 5 FOR UPDATE,InnoDB 不会只锁住id>5的现有记录,而是会对(5, +∞)这个区间加 next-key lock,既防止修改现有记录,也防止插入新记录。

但有一个重要例外:当查询条件命中唯一索引(包括主键)的等值查询时,InnoDB 会降级为纯 Record Lock。这是性能优化,因为唯一性已保证不会出现幻读。

3.4 意向锁(Intention Lock):表级“占位符”,避免锁冲突检查遍历全表

意向锁是表级锁,纯粹为提高效率而生。它本身不阻塞任何操作,但告诉其他事务:“我即将在下面的某几行加 X/S 锁”。

  • IS(Intention Shared):事务打算给某些行加 S 锁;
  • IX(Intention Exclusive):事务打算给某些行加 X 锁。

意向锁之间兼容(IS 与 IX 可共存),但与表级 S/X 锁互斥。例如,当一个事务持有IX锁时,另一个事务想对整张表加X锁(如LOCK TABLES t WRITE)就会被阻塞,因为IX表明“下面有行正在被修改”,整表锁必须等待。

实操心得:SHOW ENGINE INNODB STATUS中的TABLE LOCK部分,如果看到table test.t trx id 12345 lock mode IX,说明该事务正在操作这张表的某些行,但并未锁表本身。这是正常现象,不必惊慌。

3.5 插入意向锁(Insert Intention Lock):为 INSERT 专门设计的“轻量级间隙锁”

插入意向锁是一种特殊的 gap lock,由INSERT语句在插入前自动加。它的特点是:多个事务可以同时在同一个间隙加插入意向锁,互不阻塞。

例如,间隙(5,10)存在,事务 A 插入 6,事务 B 插入 7,两者都会在(5,10)上加插入意向锁,但不会互相等待。只有当它们试图插入同一个值(如都插 6)时,才会因唯一索引冲突而触发行锁等待。

这个设计极大提升了并发插入性能。但要注意:如果间隙已被其他事务用 next-key lock 锁定(如SELECT ... FOR UPDATE范围查询),则插入意向锁会被阻塞。

3.6 自增锁(AUTO-INC Lock):保证自增主键不重复的“轻量级表锁”

当表有AUTO_INCREMENT主键时,InnoDB 为保证并发INSERT不生成重复 ID,会使用一种特殊的表级锁。它不是传统意义上的锁,而是一个 mutex(互斥量),在INSERT语句执行期间持有。

在innodb_autoinc_lock_mode=1(默认)下,对于“简单插入”(如INSERT INTO t VALUES ()),InnoDB 使用轻量级的 mutex;对于“批量插入”(如INSERT INTO t SELECT ...),则会升级为表级 AUTO-INC 锁,阻塞其他所有 INSERT。

避坑:某数据同步服务曾因大量INSERT ... SELECT导致 AUTO-INC 锁争用严重,TPS 下降 70%。解决方案是改用innodb_autoinc_lock_mode=2(交错模式),牺牲自增值的连续性,换取并发性能。

3.7 临键锁的变体:谓词锁(Predicate Lock)与插入意向锁的协同

严格来说,InnoDB 并不支持标准 SQL 的谓词锁(Predicate Lock),但通过 next-key lock + 插入意向锁的组合,在效果上实现了类似功能。例如:

-- 事务 A SELECT * FROM order WHERE status = 'pending' AND amount > 100 FOR UPDATE; -- 事务 B INSERT INTO order (status, amount) VALUES ('pending', 150);

如果事务 A 的查询范围覆盖了amount=150,那么它加的 next-key lock 会锁住(100, +∞)这个间隙,事务 B 的插入意向锁就会被阻塞。

这种机制,让 InnoDB 能在不引入复杂谓词锁管理的前提下,有效防止“符合条件的新记录插入”带来的幻读。

4. 死锁不是 Bug,而是并发系统的必然副产品

死锁在 InnoDB 中不是异常,而是高并发下的常态。它不是系统缺陷,而是锁资源竞争达到临界点的自然结果。关键不在于“如何彻底避免死锁”,而在于“如何让死锁发生得更少、影响更小、定位更快”。

4.1 死锁的四大必要条件与 InnoDB 的“精准满足”

死锁发生的经典四条件(互斥、占有并等待、不可剥夺、循环等待),InnoDB 几乎完美契合:

  • 互斥:X 锁与 X 锁互斥;
  • 占有并等待:事务 A 持有 id=1 的 X 锁,同时请求 id=2 的 X 锁;事务 B 持有 id=2 的 X 锁,同时请求 id=1 的 X 锁;
  • 不可剥夺:InnoDB 不会主动剥夺一个事务已持有的锁;
  • 循环等待:A→B→A 形成闭环。

InnoDB 的死锁检测器(Deadlock Detector)每 10 秒运行一次(可通过innodb_deadlock_detect控制),一旦发现环路,会主动选择一个事务作为“牺牲者”(victim),回滚它并抛出Deadlock found when trying to get lock错误。

4.2 一次典型死锁的完整复现与链路还原

我们以一个真实的电商库存场景为例,还原死锁全过程:

表结构:

CREATE TABLE inventory ( sku_id BIGINT PRIMARY KEY, stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_sku (sku_id) );

事务 A(下单流程):

START TRANSACTION; -- 步骤1:检查库存(当前读) SELECT stock, version FROM inventory WHERE sku_id = 1001 FOR UPDATE; -- 步骤2:扣减库存(此时 stock=5, version=10) UPDATE inventory SET stock = 4, version = 11 WHERE sku_id = 1001 AND version = 10; -- 步骤3:生成订单(此处省略) COMMIT;

事务 B(补货流程):

START TRANSACTION; -- 步骤1:检查库存(当前读) SELECT stock, version FROM inventory WHERE sku_id = 1002 FOR UPDATE; -- 步骤2:增加库存(此时 stock=3, version=8) UPDATE inventory SET stock = 8, version = 9 WHERE sku_id = 1002 AND version = 8; -- 步骤3:更新补货记录(此处省略) COMMIT;

死锁发生时刻:

  • 事务 A 执行完步骤1,持有 sku_id=1001 的 X 锁;
  • 事务 B 执行完步骤1,持有 sku_id=1002 的 X 锁;
  • 事务 A 执行步骤2,需要更新 sku_id=1001,但发现该行已被自己持有,继续;
  • 事务 B 执行步骤2,需要更新 sku_id=1002,同样继续;
  • 此时一切正常。

但当两个事务都尝试更新对方已锁的记录时,死锁就产生了。更常见的是:

  • 事务 A 先锁 1001,再锁 1002;
  • 事务 B 先锁 1002,再锁 1001。

SHOW ENGINE INNODB STATUS输出的关键片段:

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-10-15 14:22:33 0x7f8a1c012700 *** (1) TRANSACTION: TRANSACTION 123456, ACTIVE 5 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1001, OS thread handle 140221234567680, query id 123456 localhost root updating UPDATE inventory SET stock = 4 WHERE sku_id = 1001 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 108 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123456 lock_mode X locks rec but not gap waiting Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 0: len 8; hex 80000000000003e9; asc ;; *** (2) TRANSACTION: TRANSACTION 123457, ACTIVE 4 sec starting index read mysql tables in use 1, locked 1 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1002, OS thread handle 140221234567681, query id 123457 localhost root updating UPDATE inventory SET stock = 8 WHERE sku_id = 1002 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 108 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123457 lock_mode X locks rec but not gap Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 0: len 8; hex 80000000000003ea; asc ;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 108 page no 3 n bits 72 index PRIMARY of table `test`.`inventory` trx id 123457 lock_mode X locks rec but not gap waiting Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0 0: len 8; hex 80000000000003e9; asc ;; *** WE ROLL BACK TRANSACTION (1)

解读:

  • 事务 123456(A)在等待sku_id=1001的锁(heap no 2);
  • 事务 123457(B)持有sku_id=1002的锁(heap no 2),同时等待sku_id=1001的锁(heap no 3);
  • InnoDB 检测到环路 A→B→A,回滚了事务 123456。

4.3 死锁预防的三大实战原则

死锁无法根除,但可通过设计大幅降低概率:

原则一:锁顺序一致性(Ordering)
所有业务逻辑,对多行记录的加锁顺序必须严格一致。推荐按主键升序排列:

# 错误:按业务参数顺序锁 skus = [1002, 1001] for sku in skus: cursor.execute("SELECT * FROM inventory WHERE sku_id = %s FOR UPDATE", (sku,)) # 正确:先排序,再锁 skus = sorted([1002, 1001]) # [1001, 1002] for sku in skus: cursor.execute("SELECT * FROM inventory WHERE sku_id = %s FOR UPDATE", (sku,))

原则二:事务粒度最小化(Minimization)
一个事务中只做必须原子化的操作。库存扣减、订单生成、优惠券核销,如果可以拆成独立事务,就不要强绑在一起。我们曾将一个 200ms 的大事务拆分为 3 个 30ms 的小事务,死锁率下降 92%。

原则三:快速释放锁(Speed)
在FOR UPDATE之后,尽快执行UPDATE,避免在持有锁的同时做耗时操作(如远程 HTTP 调用、复杂计算)。我们曾在一个事务中加入 Redis 缓存更新,导致锁持有时间从 5ms 延长到 120ms,死锁激增。

4.4 死锁日志的深度解析技巧

SHOW ENGINE INNODB STATUS是金矿,但很多人只看最后一行。真正有用的线索藏在细节里:

字段含义实战价值
space id/page no数据页的物理位置结合SELECT * FROM INFORMATION_SCHEMA.INNODB_SYS_TABLES WHERE NAME='test/inventory'可定位具体表
n bits 72页内锁位图大小数值越大,说明该页记录越多,锁竞争越激烈
heap no 2记录在页内的堆编号与SELECT * FROM INFORMATION_SCHEMA.INNODB_SYS_INDEXES结合,可反查具体哪条记录
lock_mode X locks rec but not gap锁类型rec but not gap是行锁,gap before rec是间隙锁,next key是两者结合

提示:线上高频死锁时,可配置innodb_print_all_deadlocks=ON,将所有死锁信息写入 error log,便于集中分析。但注意日志体积会剧增,建议只在问题定位期开启。

5. 从原理到压测:一个库存扣减接口的全链路优化实录

理论终须落地。我们以一个真实的“秒杀商品库存扣减”接口为例,展示如何从 InnoDB 内核原理出发,一步步完成性能调优。

5.1 初始版本:朴素实现与它的致命缺陷

初始代码(伪代码):

def deduct_stock(sku_id: int, quantity: int) -> bool: conn = get_db_connection() try: conn.begin() # 1. 查询当前库存 cur = conn.cursor() cur.execute("SELECT stock FROM inventory WHERE sku_id = %s", (sku_id,)) row = cur.fetchone() if not row or row[0] < quantity: return False # 2. 扣减库存 cur.execute("UPDATE inventory SET stock = stock - %s WHERE sku_id = %s", (quantity, sku_id)) conn.commit() return True except Exception as e: conn.rollback() raise e

问题分析:

  • 无当前读:SELECT是快照读,可能读到过期库存;
  • 无锁:两个事务同时读到stock=10,都判断成功,都执行UPDATE,最终stock=8,超卖 2 件;
  • 事务过长:网络 IO、Python 计算都在事务内,锁持有时间不可控。

5.2 第一版优化:引入当前读与行锁

def deduct_stock_v1(sku_id: int, quantity: int) -> bool: conn = get_db_connection() try: conn.begin() cur = conn.cursor() # 关键:FOR UPDATE 强制当前读 + 加 X 锁 cur.execute("SELECT stock FROM inventory WHERE sku_id = %s FOR UPDATE", (sku_id,)) row = cur.fetchone() if not row or row[0] < quantity: return False # 扣减 cur.execute("UPDATE inventory SET stock = stock - %s WHERE sku_id = %s", (quantity, sku_id)) conn.commit() return True except Exception as e: conn.rollback() raise e

效果:超卖问题解决,但 QPS 仅 800,P99 延迟 1200ms。瓶颈在FOR UPDATE的锁等待。

5.3 第二版优化:减少锁持有时间与锁粒度

def deduct_stock_v2(sku_id: int, quantity: int) -> bool: conn = get_db_connection() try: conn.begin() cur = conn.cursor() # 1. 当前读 + 扣减,一步到位 cur.execute(""" UPDATE inventory SET stock = stock - %s, version = version + 1 WHERE sku_id = %s AND stock >= %s AND version = %s """, (quantity, sku_id, quantity, get_current_version(sku_id))) # 2. 检查影响行数 if cur.rowcount == 0: return False conn.commit() return True except Exception as e: conn.rollback() raise e

改进点:

  • 合并读写:UPDATE语句自身完成“检查+扣减”,避免SELECT后再UPDATE的两次网络往返;
  • WHERE 条件强化:stock >= %s确保不超扣,version = %s防止ABA问题(乐观锁);
  • 锁粒度不变,但持有时间缩短:从“读+写”2次操作,压缩为1次操作。

效果:QPS 提升至 2200,P99 降至 450ms。

5.4 第三版优化:引入缓存与最终一致性

极致性能需跳出数据库。我们采用“缓存预减 + DB 最终校验”策略:

def deduct_stock_v3(sku_id: int, quantity: int) -> bool: # 1. Redis 预减(Lua 脚本保证原子性) lua_script = """ local stock = redis.call('GET', KEYS[1]) if not stock or tonumber(stock) < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 """ result = redis.eval(lua_script, 1, f"stock:{sku_id}", quantity) if result == 0: return False # 2. 异步写 DB(消息队列) send_to_queue("inventory_deduct", {"sku_id": sku_id, "quantity": quantity}) return True # 消费者进程 def consume_deduct_message(msg): sku_id = msg["sku_id"] quantity = msg["quantity"] # DB 扣减,失败则告警(极少发生) execute("UPDATE inventory SET stock = stock - %s WHERE sku_id = %s", (quantity, sku_id))

效果:QPS 突破 15000,P99 < 50ms。DB 从“实时决策者”降级为“最终记账员”,压力锐减。

经验:这种方案适用于“允许极短时间(秒级)内超卖,但最终必须一致”的场景。金融类强一致业务不适用。

6. 那些年我们踩过的锁与事务深坑

纸上得来终觉浅。以下是我亲身经历、团队复盘、社区高频提问中提炼出的 5 个最具迷惑性的陷阱,每一个都曾让资深工程师深夜抓狂。

6.1 “SELECT ... FOR UPDATE” 在唯一索引等值查询时,真的不加间隙锁吗?

结论:是的,但仅限于“查询条件完全命中唯一索引,且该索引是主键或唯一二级索引”。

反例:

-- 表结构:id 是主键,name 是普通索引(非唯一) SELECT * FROM user WHERE name = '张三' FOR UPDATE; -- 即使 name='张三' 只有一行,InnoDB 也会加 next-key lock(行锁+间隙锁) -- 因为 name 索引不是唯一索引,InnoDB 无法确定“张三”是否唯一

验证方法:执行SELECT * FROM INFORMATION_SCHEMA.INNODB_LOCK_WAITS;查看锁等待,或观察SHOW ENGINE INNODB STATUS中的lock_mode。

6.2UPDATE ... WHERE没走索引,锁的是全表还是全聚集索引?

答案是:锁住所有被扫描到的聚簇索引记录。

InnoDB 的行锁,本质是加在聚簇索引(主键索引)的记录上。当UPDATE语句因缺失索引而进行全表扫描时,它会逐行读取聚簇索引页,并对每一页中所有满足 WHERE 条件的记录加 X 锁。如果 WHERE 条件恒真(如WHERE 1=1),那就是锁全表。

我们曾在线上执行UPDATE order SET status='closed' WHERE create_time < '2020-01-01',因create_time无索引,导致锁住 300 万行,整个订单库雪崩。

6.3INSERT ... ON DUPLICATE KEY UPDATE的锁行为有多复杂?

这是最易被低估的语句。它实际执行三步:

  1. 尝试插入,
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进