MySQL事务原理ACID、隔离级别与并发问题全解析作者黒漂技术佬适用读者对事务概念模糊、不理解隔离级别的同学关联场景无人售货柜订单并发扣减库存、智慧农业设备控制一、事务是什么从转账说起假设用户在无人售货柜买一瓶可乐整个流程包含两步操作扣减库存product表的stock字段从48减到47创建订单orders表插入一条新订单记录如果第1步执行完第2步还没执行时数据库崩了——库存扣了但订单没生成钱也没收。用户白拿一瓶可乐你亏了3.5元。事务就是把多个操作打包成一个不可分割的工作单元——要么全做要么全不做。用SQL表达-- 开启事务STARTTRANSACTION;-- 扣库存UPDATEproductSETstockstock-1WHEREproduct_id1001ANDstock0;-- 创建订单INSERTINTOorders(order_id,cabinet_id,product_id,quantity,total_amount,pay_status)VALUES(500001,1,1001,1,3.50,1);-- 提交事务两步都成功才提交COMMIT;-- 如果中间出错回滚两步都撤销-- ROLLBACK;事务的本质把多步操作变成原子操作。要么全成功要么全失败回滚不存在中间状态。二、ACID四大特性事务的基石2.1 原子性Atomicity定义事务中的操作要么全部成功要么全部回滚不存在部分执行的情况。上面的例子中如果INSERT失败UPDATE的库存扣减也会自动撤销。InnoDB实现原子性靠的是回滚日志undo log。执行UPDATE时InnoDB先把旧值记录到undo log如果需要回滚根据undo log恢复原值。执行前stock 48 undo log记录stock 48 执行UPDATEstock 47 如果回滚读undo log恢复stock 482.2 一致性Consistency定义事务执行前后数据库从一个合法状态变成另一个合法状态。合法状态指的是满足所有约束和规则主键不重复、外键关联正确、CHECK约束满足、余额不能为负等。一致性例子 - 扣库存后stock不能为负 → 如果UPDATE后stock-1事务回滚 - 订单金额 单价 × 数量 → 金额计算必须正确一致性是原子性隔离性持久性应用层约束共同保障的结果。它是目标其他三个特性是手段。2.3 隔离性Isolation定义多个事务并发执行时一个事务的中间状态对其他事务不可见。这是最复杂的特性。想象两个用户同时买最后一个可乐时间线 T1: 用户A开始事务 → 读到stock1 T2: 用户B开始事务 → 读到stock1 T3: 用户A扣库存 → stock0 → 提交 T4: 用户B扣库存 → stock-1 超卖了如果没有隔离性并发事务会互相干扰产生各种数据错误。隔离级别就是控制事务之间能看到多少对方数据的机制。2.4 持久性Durability定义事务提交后数据永久保存即使数据库崩溃也不会丢失。InnoDB实现持久性靠的是重做日志redo log。事务提交时先把变更写入redo log顺序写入很快再异步刷到数据文件。如果数据库崩溃重启后用redo log恢复已提交的数据。事务提交流程 1. 写undo log用于回滚 2. 修改数据页在内存Buffer Pool中 3. 写redo logWAL机制先写日志再写数据 4. 提交成功返回给客户端 5. 后台线程异步刷数据页到磁盘WALWrite-Ahead Logging先写日志再改数据。这是几乎所有数据库持久性的核心机制。因为日志是顺序写入快数据页是随机写入慢先写日志能保证崩溃后可恢复同时不影响性能。ACID总结特性含义InnoDB实现机制原子性全做或全不做undo log回滚日志一致性合法状态到合法状态应用约束 其他三个特性隔离性并发互不干扰锁机制 MVCC持久性提交后不丢失redo log重做日志 WAL三、并发事务问题脏读、不可重复读、幻读当多个事务并发操作同一批数据时如果不做好隔离会出现三种经典问题。3.1 脏读Dirty Read事务A读到了事务B还没提交的修改。时间 事务A 事务B T1 UPDATE stock SET stock0 WHERE id1001 T2 SELECT stock → 0 读到未提交数据 T3 ROLLBACK事务B回滚了 T4 stock实际还是1 A读到了不存在的数据在售货柜场景中事务B把库存改成0但还没提交事务A读到0告诉用户没货了。然后事务B回滚库存恢复到1——用户其实有货但被通知没货了。这就是脏读。脏读是最严重的问题——读到了根本不该存在的数据。MySQL在默认隔离级别下不会出现脏读。3.2 不可重复读Non-Repeatable Read事务A两次读取同一行结果不一样——因为中间被事务B修改了并提交了。时间 事务A 事务B T1 SELECT price → 3.50 T2 UPDATE price SET price3.80 T3 COMMIT T4 SELECT price → 3.80 同一条记录两次读到不同值不可重复读和脏读的区别脏读读到的是未提交的数据不可重复读读到的是已提交的数据。前者是错误后者是你确实改了但我读着前后不一致。3.3 幻读Phantom Read事务A两次执行相同查询结果集行数不同——因为中间被事务B插入/删除了数据并提交了。时间 事务A 事务B T1 SELECT COUNT(*) FROM orders WHERE product_id1001 → 100条 T2 INSERT INTO orders(...) VALUES (..., product_id1001) T3 COMMIT T4 SELECT COUNT(*) FROM orders WHERE product_id1001 → 101条 多了幽灵行幻读和不可重复读的区别不可重复读是同一行数据被修改幻读是行数变化新增或删除了行。一个改了内容一个改了数量。三种问题总结问题读到了什么严重程度脏读未提交的数据⭐⭐⭐ 最严重不可重复读已提交的修改同一行内容变了⭐⭐ 中等幻读已提交的新增/删除行数变了⭐ 最轻四、四种隔离级别隔离与性能的权衡隔离级别越高数据越安全但并发性能越低因为加锁更多、等待更多。SQL标准定义了四种隔离级别从低到高隔离级别脏读不可重复读幻读性能READ UNCOMMITTED❌ 可能❌ 可能❌ 可能最高READ COMMITTED✅ 避免❌ 可能❌ 可能高REPEATABLE READ✅ 避免✅ 避免❌ 可能*中SERIALIZABLE✅ 避免✅ 避免✅ 避免最低*MySQL的REPEATABLE READ通过MVCC间隙锁也能避免幻读比SQL标准更强。4.1 READ UNCOMMITTED读未提交事务可以读到其他事务未提交的数据。基本没有隔离可言等于裸奔。SETSESSIONTRANSACTIONISOLATIONLEVELREADUNCOMMITTED;没有任何主流业务系统用这个级别。仅用于教学或极端性能场景。4.2 READ COMMITTED读已提交事务只能读到其他事务已提交的数据。解决了脏读但还有不可重复读和幻读。这是Oracle和PostgreSQL的默认隔离级别。SETSESSIONTRANSACTIONISOLATIONLEVELREADCOMMITTED;InnoDB在RC级别下的实现每条SELECT语句都会生成一个新的Read View读视图读到的是语句执行时刻已提交的最新数据。RC级别 T1: 事务A开始 T2: 事务A SELECT → 读到 stock1此时Read View 1 T3: 事务B UPDATE stock0, COMMIT T4: 事务A SELECT → 读到 stock0新建Read View 2看到了B的提交 → 两次读到不同值 不可重复读4.3 REPEATABLE READ可重复读—— MySQL默认事务内多次读取同一行结果一致。解决了脏读和不可重复读。这是MySQL InnoDB的默认隔离级别。-- 查看当前隔离级别SELECTtransaction_isolation;-- 输出: REPEATABLE-READ-- 修改隔离级别SETSESSIONTRANSACTIONISOLATIONLEVELREPEATABLEREAD;InnoDB在RR级别下的实现事务第一次SELECT时生成Read View之后整个事务期间复用这个Read View。即使其他事务提交了修改本事务读到的还是旧值。RR级别 T1: 事务A开始 T2: 事务A SELECT → 读到 stock1Read View记录此时状态 T3: 事务B UPDATE stock0, COMMIT T4: 事务A SELECT → 读到 stock1复用Read View看不到B的修改 → 两次读到相同值 可重复读 ✅InnoDB的RR级别还能防止幻读这得益于MVCC多版本并发控制和间隙锁。普通SELECT用MVCC快照读看不到新插入的行当前读SELECT … FOR UPDATE / LOCK IN SHARE MODE用间隙锁阻止其他事务在范围内插入。4.4 SERIALIZABLE串行化所有事务排队执行相当于单线程。最强隔离但并发性能最差。SETSESSIONTRANSACTIONISOLATIONLEVELSERIALIZABLE;生产环境几乎不用。如果用了说明你的业务逻辑有问题应该在应用层解决并发。隔离级别选择建议场景推荐隔离级别原因通用业务MySQL默认REPEATABLE READ平衡安全性和性能报表/统计容忍读到最新数据READ COMMITTED不需要可重复读的开销资金/库存强一致REPEATABLE READ 行锁防止超卖五、MVCC让读不阻塞写、写不阻塞读MVCCMulti-Version Concurrency Control多版本并发控制是InnoDB在RR和RC级别下实现隔离的核心机制。传统方案中读操作要加共享锁写操作要加排他锁读写互相阻塞——这在高并发场景下性能极差。MVCC的做法给每行数据维护多个版本读操作读旧版本快照写操作创建新版本。两者互不干扰。5.1 MVCC的三个组件隐藏字段每行数据有两个隐藏字段trx_id最后修改这行的事务IDroll_pointer指向undo log中该行的旧版本undo log版本链每次修改都在undo log中形成一条历史版本通过roll_pointer串成链表Read View事务执行SELECT时生成的快照记录当时活跃事务的ID列表当前行: stock0, trx_id300, roll_pointer → undo log undo log: stock1, trx_id200, roll_pointer → 更旧的undo log 更旧的undo log: stock48, trx_id100, roll_pointer → NULL5.2 Read View的可见性判断规则事务A做SELECT时生成Read View包含当时活跃事务的ID列表。对于每行数据的trx_idtrx_id 最小活跃事务ID→ 这行在Read View之前就提交了 →可见trx_id 最大活跃事务ID→ 这行在Read View之后才修改的 →不可见走undo log找旧版本trx_id在活跃事务列表中 → 这行对应的事务还没提交 →不可见走undo log找旧版本通过这套机制RR级别下事务始终读到第一次SELECT时的数据快照不管其他事务怎么改。实现了可重复读且不需要加锁。六、实战无人售货柜并发扣减库存场景100台售货柜促销活动期间每秒有几十个用户同时下单。热门商品库存只有10个可能出现超卖问题。问题描述-- ❌ 危险写法先查后扣STARTTRANSACTION;SELECTstockFROMproductWHEREproduct_id1001;-- 读到stock1-- 如果两个事务同时读到stock1...UPDATEproductSETstockstock-1WHEREproduct_id1001;-- 都执行-1INSERTINTOorders(...)VALUES(...);COMMIT;-- 结果stock变成-1超卖了解决方案方案1悲观锁SELECT … FOR UPDATE-- ✅ 用FOR UPDATE加行锁其他事务必须等待STARTTRANSACTION;SELECTstockFROMproductWHEREproduct_id1001FORUPDATE;-- 加排他锁-- 其他事务执行到这里会阻塞等待直到当前事务提交UPDATEproductSETstockstock-1WHEREproduct_id1001ANDstock0;INSERTINTOorders(...)VALUES(...);COMMIT;-- 释放锁下一个事务才能继续悲观锁适合竞争激烈的场景。缺点是并发度降低事务需要排队。方案2乐观锁CAS思想-- ✅ 在UPDATE条件中加入库存判断利用行锁保证原子性STARTTRANSACTION;-- 先查当前库存不加锁SELECTstockFROMproductWHEREproduct_id1001;-- 读到stock1-- 更新时检查库存是否还是查到的值UPDATEproductSETstockstock-1WHEREproduct_id1001ANDstock1;-- 条件中带上期望值-- 如果affected_rows0说明被别人抢了重试或返回已售罄INSERTINTOorders(...)VALUES(...);COMMIT;乐观锁不加锁并发度高但竞争激烈时重试率高。适合竞争不激烈的场景。方案3直接条件扣减最简洁-- ✅ 利用UPDATE的原子性一条SQL搞定UPDATEproductSETstockstock-1WHEREproduct_id1001ANDstock0;-- 如果affected_rows0说明库存不足-- 然后插入订单INSERTINTOorders(...)VALUES(...);这是最推荐的方式简单、原子、不需要显式加锁。stock 0的条件保证了不会超卖行锁保证了并发安全。库存扣减的工程实践-- 生产环境推荐扣库存 记录扣减流水放在一个事务中STARTTRANSACTION;-- 扣减库存利用行锁保证不超卖UPDATEproductSETstockstock-1WHEREproduct_id1001ANDstock0;-- 检查affected_rows为0则库存不足-- 插入订单INSERTINTOorders(order_id,cabinet_id,product_id,quantity,total_amount,pay_status,created_at)VALUES(500001,1,1001,1,3.50,1,NOW());-- 记录库存扣减流水用于对账和回滚INSERTINTOstock_log(product_id,order_id,change_amount,type,created_at)VALUES(1001,500001,-1,order_deduct,NOW());COMMIT;关键点UPDATE ... WHERE stock 0这一条语句既扣库存又防超卖。因为InnoDB的行锁机制保证同一行不会有两个事务同时修改成功。一个事务在修改时其他事务必须等待。七、死锁并发事务的阴暗面死锁是两个事务互相等待对方释放锁陷入死循环。事务A锁了product_id1001想锁product_id1002 事务B锁了product_id1002想锁product_id1001 → A等B释放1002B等A释放1001 → 死锁InnoDB有死锁检测机制发现死锁后会选择一个代价较小的事务回滚让另一个继续执行。-- 查看死锁日志SHOWENGINEINNODBSTATUS;预防死锁的实践事务尽量短小减少持锁时间多表操作时保持相同的加锁顺序批量操作时按主键排序后再操作合理使用索引避免锁升级行锁变表锁总结概念核心要点事务多操作打包成原子单元全做或全不做ACID原子性(undo log)、一致性、隔离性(锁MVCC)、持久性(redo log)脏读读到未提交数据不可重复读同一行两次读到不同值幻读同一查询两次行数不同READ COMMITTED避免脏读每次SELECT生成新Read ViewREPEATABLE READMySQL默认避免脏读不可重复读事务期间复用Read ViewSERIALIZABLE最强隔离事务串行执行MVCC多版本并发控制读旧版本快照写不阻塞读悲观锁FOR UPDATE加排他锁适合竞争激烈场景乐观锁CAS思想更新时检查版本适合竞争不激烈场景事务是数据库区别于文件系统的核心能力。理解了ACID和隔离级别才能在高并发场景下保证数据正确性。无人售货柜的并发扣库存、转账、支付等场景都离不开事务的保障。