
MySQL 事务这个东西平时写对了感觉不到它存在一旦写错轻则报表数据对不上重则把一整张业务表锁得死死的线上接口超时报警一个接一个。我印象最深的一次事故是凌晨在测试库跑了一条 UPDATE漏了 WHERE数据被成片覆盖当时就庆幸 MySQL 的 autocommit 还是默认开启状态语句飘过去直接就提交掉了根本没给我反应时间反过来想如果当时开着显式事务一条 ROLLBACK 就能救回来。后来我做任何涉及数据一致性的操作都会习惯性问自己一句这段逻辑到底要不要包进 MySQL 事务里要包又包到哪个隔离级别这篇内容我想把 MySQL 事务从原理到实操再梳理一遍适合刚接触数据库的开发者也对正在排查线上锁等待、死锁问题的同学有参考价值。会涉及 InnoDB 的 redo log、undo log、MVCC、事务隔离级别、锁分类以及 Spring 里 Transactional 注解为什么会失效最后还有分布式事务到底该怎么做取舍。通篇是我实际踩坑后的经验总结不是文档搬运。1. 先搞清楚 MySQL 事务在保护什么1.1 一次 UPDATE 忘带 WHERE 之后第一个故事不是段子。当时我在做数据订正在 MySQL 命令行里敲了一条UPDATE user_account SET balance balance - 100;我原本想加条件但是没加。执行完看到影响行数不对整个人醒了第一反应是“能不能回滚”。如果这条语句是在START TRANSACTION之后执行的那直接ROLLBACK但因为 MySQL 客户端默认 autocommit1单条语句自动提交数据已经落盘。幸亏这台是测试库我只好用备份重新倒灌。那次之后我给自己定了一条规矩凡是 UPDATE、DELETE 这类写操作先看一眼有没有 WHERE再看一眼当前会话 autocommit 状态。SELECT autocommit; -- 希望看到 0如果看到 1谨慎操作MySQL 事务本质上是一个“要么全部成功要么全部失败”的执行单元。你在一个事务里可以执行多条 SQL它们共享同一个提交或回滚状态。它保护的核心不是某一条语句而是一组语句之间的逻辑一致性。比如转账就是典型的两个动作扣款和收款中间任何一步失败整个操作都不能留下半成品数据。1.2 ACID 四个特性哪些是 InnoDB 替你兜底的教科书里事务有四条特性原子性、一致性、隔离性、持久性缩写 ACID。这四条不是空话每一项背后都有具体机制。原子性靠 undo log 实现。事务执行过程中如果中途出错或者主动回滚InnoDB 会用 undo log 里的旧值把数据恢复原样。隔离性靠锁和 MVCC 实现一个事务没提交之前另一个事务能看到什么取决于隔离级别。持久性靠 redo log 实现只要事务提交成功哪怕下一秒数据库宕机重启后重放 redo log 也能把结果找回来。这里最容易被忽略的是“一致性”。一致性在 MySQL 事务里其实是一个业务层面的概念数据库只保证约束不破坏但转账后总金额对不对靠你写的业务代码。所以 ACID 的理解不能只停留在“数据库帮我保证”很多时候是“我搭配数据库的机制一起去保证”。我见过不少新人以为开了事务就万事大吉然后在事务里先查询后更新选择性忽略了并发修改可能带来的覆盖写问题。事务是工具不是免死金牌。2. 事务跑得稳不稳先看存储引擎2.1 InnoDB 和 MyISAM 的差异不是升级那么简单“MySQL 事务”默认只跟 InnoDB 绑定MyISAM 不支持事务这点大家都知道。但很多人不清楚早期项目里 MyISAM 曾经是默认引擎很多老业务表没改过来结果就是同一个库里既有带事务的 InnoDB 表也有不能回滚的 MyISAM 表。MyISAM 的优势是表结构简单非并发场景下读性能不差索引结构是 BTree全文索引支持也不错。但它只有表级锁写入时整张表被锁住而且没有崩溃恢复能力。数据库机房断电之后MyISAM 表很容易出现损坏需要 repair。InnoDB 则支持行级锁、外键、MVCC、崩溃恢复这些都是事务机制的基石。如果你的业务表还是 MyISAM并且你正在执行 UPDATE 后想要 ROLLBACK现实会告诉你回滚无效SQL 已经生效。所以第一件事就是确认表引擎。SELECT TABLE_NAME, ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA your_db AND ENGINE MyISAM;如果业务正在跑不建议直接在线把整库引擎改掉。你可以用ALTER TABLE xxx ENGINE InnoDB逐表变更但操作过程中会锁表需要避开高峰期。表大时这是一次不小的维护窗口。2.2 redo log 与 undo log一个保持久一个保回滚InnoDB 里有两份核心日志redo log 和 undo log它们是事务底层最重要的支撑。意思可以这样理解redo log 是“做了什么事”的流水给崩溃恢复用undo log 是“把什么事退回去”的流水给回滚和 MVCC 用。很多人会问MySQL 不是有 binlog 吗binom 记录的是语句级别的逻辑日志用于主从复制和数据恢复redo log 是 InnoDB 存储引擎层面的物理日志记录的是数据页的修改。事务提交时默认策略是innodb_flush_log_at_trx_commit 1每一条提交都需要把 redo log 刷到磁盘保证事务一旦提交就不会丢数据。这个参数如果改成 0 或者 2性能会好一些但崩溃时可能丢最近的事务生产环境不推荐随意调整。undo log 是另一个容易忽视的点。你执行 UPDATE 时InnoDB 会把修改前的整行记录写入 undo log然后才去改缓冲池里的数据页。事务回滚时根据 undo log 构造出旧版本的数据并覆盖回去。注意 undo log 是逻辑日志不是简单的物理逆向操作因为数据页的物理位置可能已经因为页分裂等原因变化了它只能按逻辑记录反推。我实际排查慢 SQL 时就遇到过事务开启后长时间不提交导致 undo log 持续膨胀磁盘空间被占满的案例。大事务或长事务对 undo 表空间的影响往往比锁影响更隐蔽。2.3 自动提交到底该关还是该开MySQL 默认每个单独的 SQL 语句都会自动提交autocommit1。对大多数使用场景来说这是合理的因为开发者不可能每条语句都记得手动 COMMIT。但在你做数据订正、批量导入、或者脚本处理时建议先在会话里关掉自动提交SET autocommit 0; -- 执行完所有语句之后确认数据没问题再 COMMIT COMMIT;这样做的目的很明确给每个操作留下“反悔窗口”。但要注意如果你开启了一个事务执行完 SQL 之后既不 COMMIT 也不 ROLLBACK然后直接把连接还回连接池这个连接上的事务会一直悬着。我之前查过一个线上问题连接池连接数被占满应用层 API 全部卡住一查数据库几十个会话处于Sleep状态并且trx_state是 ACTIVE全部是空跑了很久的未提交事务。所以关 autocommit 是手段不是偷懒的借口。该提交就提交该回滚就回滚尤其在使用数据库连接池时千万不能把事务状态留在连接上。3. 事务级别与锁并发和一致性的平衡木3.1 四种隔离级别对应哪些脏数据问题MySQL 事务隔离级别是面试高频也是线上问题的高发区。标准 SQL 定义了四种读未提交、读已提交、可重复读、串行化。MySQL InnoDB 的默认隔离级别是 REPEATABLE READ它能在很大程度上避免幻读因为 InnoDB 的间隙锁和 MVCC 做得够好。隔离级别脏读不可重复读幻读并发性能READ UNCOMMITTED可能可能可能高但数据不可信READ COMMITTED避免可能可能中高REPEATABLE READ避免避免InnoDB 下基本避免中SERIALIZABLE避免避免避免低容易锁竞争脏读是最不能容忍的情况一个事务读到了另一个事务还没提交的数据如果后者最终回滚你读到的就是凭空消失的数据。READ UNCOMMITTED 虽然并发性能最好但业务里几乎不该出现。READ COMMITTED 是很多其他数据库Oracle、PostgreSQL的默认级别它避免脏读但同一个事务里两次 SELECT 可能读到不同的结果这就是不可重复读。不可重复读在很多业务里是能接受的比如报表类查询本来就不要求完全一致。REPEATABLE READ 保证了事务内多次读取的结果一致它靠 MVCC 的快照读实现。幻读指的是两次范围查询返回的行数不同比如商品库存表里事务 A 查询价格大于 100 的商品有 10 条事务 B 插入了一条价格 150 的新商品并提交事务 A 再查变成了 11 条。InnoDB 在默认隔离级别下通过间隙锁配合 MVCC 把幻读基本堵住了。串行化作为最后一道防线会让读写互相阻塞性能代价太高我几乎没有在生产业务上见过它作为默认级别。3.2 MVCC 版本链与 Read View 的读取规则MVCC多版本并发控制是 InnoDB 实现高并发读写的关键。它允许读操作不阻塞写操作写操作也不阻塞读操作核心思路是“每个事务看到的是数据的一个历史版本”。每行记录里InnoDB 会隐藏两个列trx_id最后修改该行的事务 ID和 roll_pointer指向 undo log 中旧版本的指针。当一条记录被多次修改undo log 里会形成一个版本链从头到尾串起这条记录的历史版本。SELECT 的时候事务会生成一个 Read View里面记录了当前活跃事务列表、最小活跃事务 ID、最大事务 ID 等信息。判断一条记录是否可见的规则简单来说就是如果记录的 trx_id 小于当前 Read View 的最小活跃事务 ID说明这个版本在事务开始前就已经提交可见如果 trx_id 大于最大事务 ID说明这个版本是未来事务产生的不可见如果 trx_id 落在活跃事务列表里说明这个版本属于未提交事务不可见。REPEATABLE READ 与 READ COMMITTED 的一个关键区别就是 Read View 的创建时机。前者是事务里第一次 SELECT 时创建之后一直复用后者是每一条 SELECT 都创建一个新的 Read View。这就是为什么 REPEATABLE READ 能保证同一个事务内多次查询结果稳定而 READ COMMITTED 做不到。我在排查问题的时候经常会用一条 SQL 看事务的开启时间再对照业务日志确认对应代码SELECT trx_id, trx_started, trx_state, trx_query FROM information_schema.innodb_trx;这条视图是找长事务最直接的入口。3.3 行锁、间隙锁、临键锁在哪里生效MySQL 锁的分类网上一搜就是一大篇但真正排查问题的时候记住几个核心的就行了。按粒度分InnoDB 有表级锁和行级锁。表级锁主要是 DDL 操作和元数据锁比如ALTER TABLE时后续对该表的写入会等待。行级锁又分共享锁和排他锁共享锁S多个事务可以同时持有适合只读场景排他锁X一个事务持有后其他事务不能读写该行。索引上的锁有三种关键类型。记录锁Record Lock锁住索引的一行间隙锁Gap Lock锁住一个范围但不含记录本身目的是阻止其他事务在范围内插入新记录临键锁Next-Key Lock是记录锁和间隙锁的组合锁住记录和它前面的间隙是 REPEATABLE READ 级别下 InnoDB 的默认行锁算法。举个例子。假设商品表里有 price 分别为 100、200、300 的几条记录你在事务里执行SELECT * FROM products WHERE price BETWEEN 100 AND 200 FOR UPDATE;这条语句不仅会把 price100 和 price200 的记录锁住还会把 (100,200] 这个区间以及 200 之后的某个间隙锁住。另一个事务想插入一条 price150 的记录会一直等待直到第一个事务提交。这就是间隙锁在起作用。但是间隙锁有一个容易踩的坑如果 WHERE 条件走的是非唯一索引锁的范围往往比你想的大。如果你的 WHERE 条件用的是没有索引的列那 InnoDB 只能退化为全表扫描相当于给全表所有记录都加锁等于把行级锁干成表级锁。线上一个大范围 UPDATE 把整个表锁住十有八九是索引没建对。另外要提一下意向锁。事务准备加行锁之前要先对表加意向锁用来协调表锁和行锁。意向锁是表级锁但它们之间是兼容的不需要在排查死锁时过度关注。4. 代码里的事务注解与分布式事务实操4.1 Transactional 在不同场景下的生效条件Java 项目里最常用的事务操作是 Spring 的Transactional注解。很多人以为往方法上一贴事务就生效了实际上它有非常多的限制条件。先看一个反面例子Service public class OrderService { Transactional public void createOrder(Order order) { orderDao.insert(order); this.updateStock(order.getProductId()); } public void updateStock(Long productId) { inventoryDao.deduct(productId); } }如果你在一个没有加Transactional注解的方法里调用同类中另一个标注了事务的方法事务不会生效。原因是 Spring 的默认事务实现是基于 AOP 代理的只有外部调用才会经过代理同类内部调用this.updateStock()走的是原始对象注解自然失效。解决办法是把被调方法拆到另一个 Bean 里或者自己注入代理对象。还有一种情况Transactional默认只在 RuntimeException 和 Error 时才回滚受检异常Exception不会触发回滚。比如Transactional public void createOrder(Order order) throws Exception { orderDao.insert(order); inventoryDao.deduct(order.getProductId()); throw new Exception(自定义异常); }这样运行下去insert 和 deduct 都会提交数据就错乱了。如果业务需要受检异常也回滚要显式声明Transactional(rollbackFor Exception.class)事务传播行为也是常被忽略的问题。默认的是 REQUIRED也就是当前没有事务就新建一个有事务就加入当前事务。这意味着在一个大事务方法里调用另一个事务方法异常会牵连整个事务回滚。有些场景你需要新开事务比如记录审计日志即使主体事务回滚日志也想保存这时要用 REQUIRES_NEW。但要非常小心REQUIRES_NEW 会让事务数量增加连接占用变多稍不留神就会拖垮数据库连接池。4.2 大事务为什么是性能杀手事务不是越大越好恰恰相反大事务是 MySQL 性能杀手。一个事务里塞入太多操作会让连接长时间不释放锁持有的时间变长undo log 膨胀还有可能拖垮主从复制。我曾经遇到过这样一个场景晚上跑定时任务循环处理 10 万条数据每条数据都要更新库存。我第一次写的代码比较草率把整个循环包在一个Transactional里结果跑了十分钟还没结束其他写操作全部在等锁应用侧超时。问题很明显10 万条数据全都在一个事务里持有锁的时间太长事务日志也很大。后面我把循环拆成了每批 500 条提交一次事务执行时长从十分钟降到了几秒锁竞争立刻缓解。这背后的道理是事务持续时间越短锁持有的时间越短其他事务等待的概率就越低。哪怕拆批之后出现中间失败最多丢失最近一批的数据配合幂等策略可以重跑整体收益远大于一个“完美大事务”的执念。另一个常见大事务来源是 RPC 调用。有些开发者习惯在事务里调用远程接口比如扣完库存再去调第三方支付整个事务会把数据库锁一直攥在手里等远程响应。远程接口一超时数据库锁就被长时间占用连锁反应直接打到整个服务。我的建议是远程调用尽量放到事务提交之后不要在数据库事务里做不相关的 IO 操作。4.3 分布式事务一致性做到什么样的程度才算合格服务多了以后一个业务要跨多个库、多个服务更新本地事务就管不住了。订单服务和库存服务如果各自有独立数据库一个下单操作要同时写订单库和库存库怎么保证一致性这就是分布式事务要解决的问题。主流的方案有几种两阶段提交2PC、TCC、本地消息表 消息队列、最大努力通知、Saga 等。实际落地时我不建议一上来就上 Seata 这类框架先想清楚你要的一致性等级。如果对实时性要求不高你可以用本地消息表在订单服务里开启本地事务写入业务数据和一条消息记录事务提交后异步任务把消息投递给库存服务。库存服务如果成功就回执失败则不断重试直到成功。这种方式是“最终一致性”实现成本低还能保证订单主流程很快。如果业务对一致性要求很高比如每一笔都要实时确认就要考虑 TCC 模式Try 阶段预留资源Confirm 阶段真正扣减Cancel 阶段释放预留。TCC 的难点在于每个操作都要写对应的冲正逻辑对业务侵入很深代码量翻倍。还有 XA 协议这种强一致方案两阶段提交会让所有参与者在 prepare 阶段锁定资源直到 commit/rollback高并发下性能损耗严重。我在金融类项目里见过它但大部分互联网业务宁可用最终一致性也不会为了强一致把性能拖垮。核心原则是不要为了“技术看起来高级”就盲目引入分布式事务中间件。很多团队连单体事务都没写好就先搞微服务、搞分布式事务最后数据对不上账问题反而更多。5. 实际故障排查与优化经验5.1 锁等待超时和死锁的定位方法锁等待超时是线上最常见的 MySQL 事务异常。报错信息大概是Lock wait timeout exceeded; try restarting transaction。这个时候要做的第一件事不是重启应用而是找出谁握着锁不放手。先看当前有哪些事务在跑SELECT * FROM information_schema.innodb_trx;再查锁等待关系SELECT * FROM performance_schema.data_lock_waits;不要忘了查会话SELECT * FROM information_schema.processlist;我通常三步走先定位到 “Sleep” 状态但开了事务的会话找trx_started最早的那条再看它最近执行过的 SQL基本能判断是哪个业务代码忘记提交确认后 KILL 掉对应的线程KILL thread_id;死锁和锁等待不是一回事。死锁是事务 A 持有锁 1 等待锁 2事务 B 持有锁 2 等待锁 1双方互相等InnoDB 检测到之后不会无限等它会回滚其中一个事务并报错。排查死锁最有效的办法是开启死锁日志SHOW ENGINE INNODB STATUS;这里会输出最近一次死锁相关的信息包括持有锁、等待锁对应的 SQL 语句。看到死锁记录后先按LATEST DETECTED DEADLOCK里的信息还原事务执行顺序再检查业务代码里的加锁顺序是否一致。最典型的死锁场景是两个事务按不同顺序更新多张表。比如事务 A 先更新表 a 再更新表 b事务 B 先更新表 b 再更新表 a恰好交叉就死锁。解决办法就是全局固定一个更新顺序。5.2 监控长事务与未提交事务长事务的危害比很多人想的大它不只是锁的问题。事务长时间不提交undo log 无法清理导致版本链越来越长查询效率下降还可能导致 purge 线程跟不上undo 表空间膨胀。我习惯写一个监控查询每天定时跑一遍SELECT trx_id, trx_mysql_thread_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds, trx_state FROM information_schema.innodb_trx WHERE trx_state RUNNING ORDER BY trx_started ASC;这个查询执行结果里如果出现运行了上千秒的事务就要立刻定位到具体业务。有一种情况是程序里手动BEGIN之后代码逻辑发生异常异常没有走回滚处理事务就一直开着。另一种是使用连接池时连接归还时没有清理事务状态下次从池里借出同一个连接发现 old 事务还开着。建议在应用框架层配置事务超时时间。Spring 的Transactional(timeout 10)只是给事务设置超时超时会抛异常但底层连接还是会尝试回滚。更彻底的做法是在数据库端设置innodb_lock_wait_timeout但这个参数只能控制锁等待不能控制事务总时长。要真正兜底还需要在运维层做监控告警。5.3 事务参数与隔离级别的调优建议我对事务相关参数的态度是默认值能用就别瞎动除非你非常清楚自己在做什么。innodb_flush_log_at_trx_commit保持默认 1也就是每次事务提交都要刷 redo log 到磁盘。如果你为了性能改成 2意味着只要操作系统不崩数据不丢一旦操作系统重启可能丢失最近的事务。很多云厂商的 MySQL 其实会把这个参数做了调整你要关注实际配置。隔离级别方面如果你从 Oracle 迁移到 MySQL习惯的是 READ COMMITTED那么可以在 MySQL 里把默认隔离级别改掉很多金融项目就是这么干的。改成 READ COMMITTED 后间隙锁的加锁范围会小一些并发度提升但不可重复读的问题需要业务接受。SET GLOBAL transaction_isolation READ-COMMITTED; SET SESSION transaction_isolation READ-COMMITTED;注意修改之后要重启验证有一些老版本 MySQL 参数名是tx_isolation新版本已经迁移到transaction_isolation。这种细节最容易坑到人。还有一个容易被忽略的点是连接池配置。应用连接池大小、事务超时、连接最大存活时间必须跟数据库的事务使用模式匹配。如果连接池允许的最大连接数是 100但你的业务并发事务超过 100那么数据库端会积压锁等待应用端表现为大量超时。与其无限调大连接数不如先排查事务时长和锁范围。我个人在实际运维中还有一个习惯上线前把涉及事务的慢 SQL 全部拉出来执行计划里是不是全表扫描、是不是没有命中最优索引逐条过一遍。倒不是每次都能发现问题但这个习惯确实帮我避掉过几次大锁表的坑。最后再分享一个小技巧做批量数据订正时不要直接在一个事务里跑完所有语句可以手动分批 COMMIT每批之间停几秒。这样即使中间发现写错了损失也控制在最近一批恢复成本低得多。事务是拿来保证一致性的不是拿来把所有操作堆成一坨的。用得聪明它就是你数据可靠性的护身符用得粗暴它就是你线上事故的导火索。