MySQL事务机制详解:ACID特性、隔离级别与高并发问题排查

发布时间:2026/10/22 5:16:19
MySQL事务机制详解:ACID特性、隔离级别与高并发问题排查
1. 事务到底在解决什么问题先别急着写 SQL做业务开发的尤其是涉及订单、账务、库存这类核心链路的人几乎绕不开 MySQL 事务。这个标题看起来基础但真正能把“事务操作”和“四大特性”都讲清楚、并且上线后还敢拍胸脯保证数据不出问题的人其实不算多。我几年前也天真地以为事务就是 begin、commit、rollback 三件套直到线上出现重复扣款、库存超卖、报表对不上账才明白事务背后那套机制才是核心。这篇文章我会从实际项目出发先讲清楚什么时候该用事务再完整演示事务的操作流程接着把 ACID 四个特性逐个拆开最后聊聊隔离级别、死锁、长事务这些真实问题。无论你是刚接触后端的新人还是写过几年 SQL 但隔离级别老是犯迷糊的老手应该都能找到有用的东西。1.1 一个最典型的转账场景先看一个所有教程都会提、但也是线上最容易出问题的场景转账。假设account表里有两个账户A 要给 B 转 100 块钱。如果不加事务最简单的写法是这样的UPDATE account SET balance balance - 100 WHERE user_id 1; UPDATE account SET balance balance 100 WHERE user_id 2;这两条 SQL 正常情况下没问题可一旦第一条执行成功、第二条执行失败A 的钱扣了B 的钱没到账。这个场景不是段子而是真的会在生产环境出现。数据库连接中断、程序异常、服务器重启、磁盘满、主从切换任何一个环节出问题都可能导致两条更新只执行了其中一条。事务就是用来解决这种“多条操作要么全部成功、要么全部失败”的问题。你用START TRANSACTION开启用COMMIT提交用ROLLBACK回滚数据库自己会保证中间状态不会暴露给其他会话。1.2 不是所有存储引擎都支持事务MySQL 里的存储引擎比较多但支持完整事务能力的主要是 InnoDB。MyISAM 在 MySQL 8.0 里已经被移除了但很多老项目的数据表还在用。MyISAM 有表级锁但根本没有事务日志也没有崩溃恢复能力。你执行了一条 UPDATE数据是直接改掉的没有回滚余地。所以如果你要处理强一致性数据建表时第一件事就是确认引擎是 InnoDB。SHOW CREATE TABLE account;执行结果里如果看到ENGINEMyISAM业务又涉及金额、库存、状态变更这类需要回滚的场景那就要尽早做迁移。建新表时直接指定ENGINEInnoDB是最稳妥的做法。MySQL 8.0 默认就是 InnoDB一般不需要特意写但老库迁移时值得检查一遍。2. 从开启到提交一套完整的事务操作流程2.1 最基础的三条语句先看一个标准的手动事务操作START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1; UPDATE account SET balance balance 100 WHERE user_id 2; COMMIT;如果第二步发现余额不足或者应用层收到异常就执行ROLLBACK;START TRANSACTION是更接近标准的写法MySQL 也支持BEGIN两者作用基本一致但我个人习惯用START TRANSACTION因为一看就知道是显式开启事务不容易和BEGIN...END这种存储过程语法混淆。事务开启之后所有执行的增删改操作先不真正落库而是记录在事务系统里。只有执行COMMIT之后其他事务才能看到这些改动执行ROLLBACK之后所有改动都会被撤销。COMMIT和ROLLBACK都会结束当前事务下一次执行任何 SQL 又进入自动提交模式。这一点很多人会忽略不是说执行完START TRANSACTION就一直处于事务里事务结束之后如果不再次开启单条 SQL 仍然自动提交。2.2 autocommit 和你以为的“事务”MySQL 默认是自动提交模式autocommit1。也就是说如果你没有显式执行START TRANSACTION每一条 UPDATE、INSERT、DELETE 都会被当成一个独立事务立刻提交。这符合大部分简单操作的习惯但也给人造成一个错觉好像 SQL 执行完就安全了。真正危险的是下面这种情况UPDATE order SET status paid WHERE id 1; UPDATE inventory SET stock stock - 1 WHERE product_id 100;如果你以为这两条是一个事务那就错了。它们实际上是两个独立的事务中间任何一条失败都不会回滚另一条。必须显式写在同一个事务块里才行。需要长期保持手动事务模式的话可以SET autocommit 0;autocommit0之后事务结束不会自动提交需要手动执行COMMIT。但我不建议全局改它尤其不要在客户端工具里长期设置因为很容易出现“明明执行了 UPDATE另一个窗口却一直看不到数据”的问题其实是上一个事务没提交行锁一直没释放。更推荐的方式是默认保持自动提交需要多步操作时在应用代码里显式开启事务并配合 try-catch 或框架的事务注解来保证最终提交或回滚。2.3 SAVEPOINT部分成功也能回滚有时候一个大的事务里分成几个步骤我不希望整个事务全部回滚只想回滚到中间某个点。MySQL 提供了保存点功能START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1; SAVEPOINT sp_after_deduct; UPDATE account SET balance balance 100 WHERE user_id 2; -- 发现 B 账户异常但 A 的扣款要保留 ROLLBACK TO SAVEPOINT sp_after_deduct; COMMIT;执行完ROLLBACK TO SAVEPOINT后事务并不会结束只是撤销保存点之后的改动。之后你还可以继续操作然后整体提交。RELEASE SAVEPOINT可以删除一个保存点但保留事务本身。这个功能在做子任务拆解、批量数据导入时很有用。注意保存点不能跨事务上一个事务结束之后保存点自动失效。2.4 事务里写 SQL 的顺序也有讲究事务操作不只是语法问题还是并发控制问题。我见过很多线上事故都是因为事务里 SQL 顺序太随意。第一个原则是先锁不常变的、范围小的数据后锁范围大的。两个事务如果都更新同一批行尽量保持一致的加锁顺序否则很容易死锁。比如事务 A 先更新 id1 再更新 id2事务 B 正好相反那两边都可能卡在等待对方的行锁上最后触发死锁检测。第二个原则是不要在事务中间做耗时操作。事务开启后已经修改的行会持有锁这个锁会一直保持到提交或回滚。如果你在事务里调外部 HTTP 接口、等 Redis 返回、甚至 sleep 几秒别人对这些行的操作都要排队数据库的并发能力会肉眼可见地下降。第三个原则是能减少 UPDATE 就减少 UPDATE。每一条 UPDATE 都要记录 undo log、redo log持有行锁并且会触发索引更新。事务里尽量用批量 SQL不要用循环逐条执行。3. 四大特性逐一拆解ACID 的底层是数据组织方式3.1 原子性由 undo log 保证原子性说的是事务里所有操作要么全部成功要么全部失败不存在中间状态。InnoDB 的实现手段是 undo log。每一个事务修改数据之前都会先记录一条“反操作”日志。你执行 UPDATEundo log 里就记录成“把这一行恢复成修改前的值”你执行 INSERTundo log 里就记录“删除这一行”。事务回滚时数据库根据 undo log 逆向执行把数据还原到事务开始前的样子。如果事务执行到一半数据库崩溃重启后也会利用 undo log 将未提交事务进行回滚。所以原子性不是靠“同时成功”实现的而是靠“失败后能恢复原状”实现的。这也解释了为什么事务里更新越多回滚时间越长。一个事务里塞了几十万行更新回滚时可能比你想象的慢得多。3.2 一致性是业务规则也是事务约束一致性经常被误解。它不是说数据库自己知道“A 减 100 之后 B 必须加 100”而是说事务的执行前后数据库必须从一个合法状态变成另一个合法状态。数据库能直接帮你的是约束层面的东西主键唯一、字段非空、外键关联、CHECK 约束。这些规则不满足整个事务会失败。但“转账前后总金额不变”“库存不能为负数”“订单金额必须等于明细合计”这类业务一致性必须由应用层在事务里通过代码保证。这也是为什么我一直强调事务不是万能的。它保证的是底层操作原子不等于业务逻辑自动正确。你在事务里写了一个错误的计算公式数据库照样会按错误结果提交。3.3 隔离性靠 MVCC 与锁协同隔离性要解决的是多个事务并发操作同一批数据时彼此不能互相干扰。InnoDB 的隔离实现不是单一机制而是 MVCC 加锁的组合拳。MVCC多版本并发控制核心思路是给每一行数据保存多个历史版本。普通SELECT走的是快照读读的是事务启动那一刻的快照不用加锁并发能力很高。而UPDATE、DELETE、SELECT ... FOR UPDATE这类写操作或加锁读走的是当前读会自动对涉及的行加锁。简单理解读不阻塞写写不阻塞读但写和写之间要排队。这个设计保证了在默认的可重复读隔离级别下普通查询几乎不会被并发写入卡住。这也是 InnoDB 能支撑高并发业务的关键。3.4 持久性由 redo log 撑起持久性说的是事务提交后数据不能因为掉电、崩溃就丢失。InnoDB 不是每提交一个事务就把数据页物理刷到磁盘那样性能太差。它采用预写日志WAL 机制。事务修改数据时先写 redo log再写内存里的数据页。COMMIT 成功的前提是 redo log 已经落盘。即使数据页还没刷到磁盘数据库突然宕机重启后也会通过 redo log 把已提交事务的修改恢复出来。这里有个关键参数innodb_flush_log_at_trx_commit。值为 1 时每次事务提交都把 redo log 刷到磁盘安全性最高也是默认值。值为 0 时每秒刷一次性能好但数据库崩溃可能丢最多 1 秒的数据。值为 2 时每次提交写入操作系统缓存每秒再刷磁盘操作系统崩溃可能丢数据。涉及订单、支付、账务的系统我强烈建议保持默认的 1不要为了那点性能去调低这个参数。掉电丢数据的代价远大于性能收益。4. 隔离级别的取舍从“能读脏数据”到“全串行”4.1 三种并发异常先搞清楚讨论隔离级别之前先明确三种并发异常。脏读事务 A 修改了数据但还没提交事务 B 就读到了这个未提交的值。如果 A 后来回滚B 读到的就是不存在的数据。不可重复读事务内两次读同一行第一次读到的值和第二次不一致。原因是事务 B 在中间修改并提交了这行数据。幻读事务内执行同一条范围查询两次得到的结果集行数不一样。别的事务插入了符合条件的新行并提交。这三种异常按严重程度和隔离强度递增脏读最严重幻读相对最难防。4.2 四种隔离级别对比隔离级别脏读不可重复读幻读典型实现Read Uncommitted可能可能可能直接读最新版本不加隔离Read Committed不会可能可能语句级快照Repeatable Read不会不会基本不会事务级快照 Next-Key LockSerializable不会不会不会全部加锁事务串行化MySQL 默认隔离级别是 Repeatable Read这一点和其他很多数据库不一样。在 Read Committed 下普通 SELECT 会产生不可重复读因为每次语句执行都会生成新的读取快照在 Repeatable Read 下第一个普通 SELECT 的时机决定整个事务的快照后续读到的都是同一份数据。如果觉得自己业务对一致性要求没那么高可以考虑 Read Committed并发性能更好死锁概率也更低。但千万别让“可以接受”变成“上线后报表对不上”。4.3 MySQL 的 RR 为什么能防住幻读标准 SQL 里Repeatable Read 是防不了幻读的但 MySQL 的 InnoDB 在 RR 下使用 Next-Key Lock也就是“行锁 间隙锁”的组合把查询范围里的间隙也锁住其他事务不能向这个范围插入新行所以幻读能在大多数场景下被挡掉。间隙锁不是没有代价。它会让并发插入的性能明显下降两个事务如果都锁了同一个间隙还可能出现奇怪的锁等待甚至死锁。这也是为什么有些高并发团队会把隔离级别调成 Read Committed再用业务层或唯一索引解决实际冲突。另外要注意普通SELECT在 RR 下是快照读不加锁。真正防幻读靠的是加锁读比如SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE。如果你只是普通 SELECT同一事务里偷偷插入的新行依然不会再被读到这本身已经符合“读不到幻行”的要求。5. 高并发环境下的常见问题与排查实录5.1 长事务最容易被忽略的“慢性病”长事务意思是一个事务从开启到提交持续了很长时间。它不一定体积大可能只是开了事务之后一直不提交或者事务里有慢查询甚至只是应用代码忘了调用提交方法。长事务的危害非常隐蔽持有行锁和间隙锁阻塞别人undo log 不断堆积导致数据库膨胀影响 MVCC 的历史版本清理purge 线程可能跟不上在主从复制里从库也可能因为长事务导致复制延迟。排查长事务最直接的办法是查系统表SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx;看到trx_started时间很早、但trx_stateRUNNING的会话基本就是长事务需要立刻定位应用代码。也可以用SHOW PROCESSLIST查看对应会话在干什么。如果确认无业务意义可以用KILL结束对应线程但线上操作要谨慎最好先联系负责人确认。5.2 死锁与锁等待错误码 1213 和 1205死锁是事务开发绕不开的问题。InnoDB 会自动检测死锁让其中一个事务回滚并返回错误码 1213另一个继续执行。这本身是数据库的自我保护机制但应用层必须能处理死锁重试否则用户会直接看到报错。排查死锁最常用的命令是SHOW ENGINE INNODB STATUS\G在输出里找到LATEST DETECTED DEADLOCK部分可以看到两个事务各自持有哪些锁、在等哪些锁、执行了什么 SQL、涉及哪些表和索引。这些信息足够定位到具体代码。锁等待超时返回错误码 1205默认超时时间是 50 秒。如果说死锁是双方互相等待那锁等待就是单纯的一方被另一方长期占着锁。系统表也能查SELECT * FROM sys.innodb_lock_waits;如果经常出现 1205首先要查有没有长事务其次要检查索引是否合理。UPDATE 的 WHERE 条件如果不走索引InnoDB 可能锁住大量扫描到的行锁范围远超预期。5.3 DDL 隐式提交与事务失控这是很多人踩过但没注意的坑DDL 语句会触发隐式提交。MySQL 中CREATE TABLE、ALTER TABLE、DROP TABLE、TRUNCATE TABLE、RENAME TABLE等语句都会先提交当前事务再执行 DDL。也就是说像下面这样START TRANSACTION; UPDATE account SET balance balance - 100 WHERE user_id 1; ALTER TABLE account ADD COLUMN remark VARCHAR(50); UPDATE account SET balance balance 100 WHERE user_id 2; ROLLBACK;执行完ALTER TABLE之后前面那笔 UPDATE 已经被自动提交了。后面你再ROLLBACK只能回滚第二个 UPDATEA 账户的扣款是撤不回来的。这不是 MySQL 的 bug而是它保证 DDL 必须拥有一个干净的上下文。所以坚决不要把建表、改表、清空表这类语句混进业务事务里。还有一个类似的坑TRUNCATE TABLE不能回滚它不是逐行删除而是直接释放表空间所以没有逐行操作的 undo 可以逆推。5.4 常用排查 SQL 和工具思路实际项目里我一般按这个顺序排查事务问题先看有没有长事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx;再看当前所有连接在干什么SHOW PROCESSLIST;如果怀疑锁等待直接看SELECT * FROM sys.innodb_lock_waits;最后看死锁现场用SHOW ENGINE INNODB STATUS。这套组合基本能覆盖大部分事务并发问题。性能监控工具也能看到事务相关指标但系统表永远是第一手资料。6. 我在实际项目里沉淀的事务经验6.1 几个值得写进规范的习惯以下几点不是教科书上的东西而是我踩过几次坑之后真正确认有用的经验。第一事务尽量短。开启事务之前先完成所有查询、外部请求、计算逻辑事务内部只做必要的写操作。一个事务里跑的 SQL 越多持有锁的时间越长冲突概率越大。第二同一批数据的更新所有事务都按主键或唯一键从小到大排列。这个习惯可以显著降低死锁概率。死锁检测机制虽然能自动解除但代价是一个事务被强制回滚应用层还要重试。第三应用层一定要保证事务最终会提交或回滚。比如 Java 开发里最好使用声明式事务让框架统一处理异常回滚如果用 Python、Go 这类手写 SQL 的场景也要在接口入口处统一封装不能每个方法各自为战。第四线上准备一个方便查询当前事务的只读账号并且把常见排查 SQL 存成团队文档。出问题的时候等待是最大的成本能一条命令查出长事务比临时翻资料有效率得多。第五涉及库存、金额这类容易并发冲突的更新优先用“条件更新”而不是“先查后算再更新”。比如扣库存不要先 SELECT 库存量再在代码里减一最后 UPDATE 一个计算后的值。直接写UPDATE stock SET quantity quantity - 1 WHERE product_id ? AND quantity 0;受影响行数为 0 就说明库存不足这种写法既减少了一次查询也天然避免了更新丢失问题。6.2 什么时候不要用事务、什么时候要换解决方案事务不是越用越多越好。像记录日志、埋点数据、异步任务表这种允许丢失一点、也允许短暂不一致的数据塞进一个大事务反而拖慢主流程。如果单库事务已经扛不住并发比如秒杀、热点库存、高频计数可以考虑用 Redis Lua 脚本做原子扣减再异步对账落库也可以把库存做成独立服务用消息队列串行化处理。但不能指望 MySQL 事务在极端热点下还能保持很高的吞吐合理拆分才是根治方案。另外如果业务涉及多个数据库比如订单在 A 库、用户余额在 B 库MySQL 本地事务就管不了跨库的一致性了。这个时候要么用分布式事务方案要么从业务上接受最终一致通过定时对账和数据补偿来兜底。不要盲目在应用层自己写“手工分布式事务”那比事务本身更难维护。最后说一点个人体会事务操作本身不难难的是判断它的边界。MySQL 事务是数据库保证一致性的最后一道防线但它不是业务一致性的全部。把事务用好除了熟记COMMIT、ROLLBACK更重要的是知道什么时候该开、什么时候不该开、锁会覆盖哪些范围、提交后谁负责兜底。把这些想清楚线上数据问题能少一大半。