做后端开发的人应该都有过这种经历:一条UPDATE语句把整张表的金额字段都改错了,或者订单创建到一半程序崩溃,结果库存扣了、订单却没生成。这种数据“半吊子”状态,正是事务要解决的问题。MySQL里的事务,说白了就是一组SQL操作打包成一个整体,要么全部成功,要么全部失败,不存在中间状态。这篇文章我就从实际操作出发,把事务的开启、提交、回滚,到ACID四个特性的底层逻辑,一次性讲透。
不管是刚入门的学生,还是写了好几年业务代码的开发,事务这块都值得花时间理清楚。很多线上事故,本质上就是事务边界画错了、隔离级别选错了、回滚没处理好。下面这些内容都是我实际排查问题时的经验总结,希望对你有用。
1. 为什么需要事务:先搞清楚它解决了什么事
1.1 从一次转账说起
假设你的账户系统里有这样两条SQL:
UPDATE account SET balance = balance - 500 WHERE user_id = 1; UPDATE account SET balance = balance + 500 WHERE user_id = 2;第一条是扣款,第二条是入账。如果第一步执行成功,第二步执行前数据库突然断电、进程被杀、或者网络闪断,那么钱扣了但对方没收到,账就平不上。
这就是典型的"部分成功"问题。事务的存在,就是让这两条SQL变成一个不可分割的整体。用事务包裹后,要么两步都执行完,要么两步都不生效,系统重启后数据依然是对得上的。
1.2 并发场景下的数据一致性
单线程场景可能还不明显,一旦并发上来,问题就多了。比如两个用户同时抢购最后一件商品:
- 用户A和B同时读到库存为1;
- A执行扣减,库存变0;
- B不知道库存已变,也执行扣减,库存变-1。
如果不做控制,超卖就发生了。事务配合锁机制,能保证同一时刻只有一个事务能修改这条库存记录,另一个事务必须等它提交后才能继续。
1.3 事务不是MySQL独有的概念
其实所有主流关系型数据库都有事务,MySQL只是实现方式有所不同。你明白了MySQL里的运作机制,以后再接触其他数据库,核心概念都是相通的。差别主要在语法细节、锁策略、日志实现上。
注意:MyISAM引擎是不支持事务的,支持事务的是InnoDB引擎。MySQL 5.5之后InnoDB是默认引擎,但建表时依然可以指定MyISAM,所以排查事务不生效的问题时,第一反应应该是看表引擎。
2. 事务的完整操作手册
2.1 开启一个事务
MySQL默认是自动提交模式,也就是每一条SQL执行完就立刻提交,不需要你手动管。真正要操作事务时,第一件事是关闭自动提交,或者显式开启事务。
常用写法:
-- 方式一:标准SQL START TRANSACTION; -- 或者 BEGIN; -- 执行你的业务SQL... UPDATE account SET balance = balance - 500 WHERE user_id = 1; UPDATE account SET balance = balance + 500 WHERE user_id = 2; -- 确认无误后提交 COMMIT;BEGIN和START TRANSACTION在大多数场景下效果一样,但有一个细节值得注意:START TRANSACTION是标准SQL语法,写起来也更语义化,我个人的习惯是用它。另外START TRANSACTION后面还可以跟修饰符,比如START TRANSACTION WITH CONSISTENT SNAPSHOT,这是针对一致性读的,后面讲隔离级别时会再提到。
2.2 提交与回滚
事务结束只有两种正确方式:COMMIT提交,或者ROLLBACK回滚。
-- 提交 COMMIT; -- 回滚 ROLLBACK;提交的含义是把事务期间的所有修改持久化到磁盘,其他事务从此能读到这些改动。回滚则相反,事务期间做的所有修改全部撤销,就像从来没发生过。
实际操作中,业务代码里最常见的回滚逻辑是这样:
START TRANSACTION; UPDATE account SET balance = balance - 500 WHERE user_id = 1; -- 模拟异常:比如这里通过程序抛出了一个错误 -- 如果发生异常,执行 ROLLBACK ROLLBACK;生产环境里一般不靠人眼判断该提交还是回滚,而是靠程序里的异常捕获机制。比如Java里就是try-catch块,catch到异常就执行rollback,正常执行完就走commit。
2.3 中途存档点:SAVEPOINT
有些场景下,事务内部操作很多,但你不希望因为最后一步失败就把前面所有操作都回滚掉。这时候可以用SAVEPOINT,也就是保存点。
START TRANSACTION; UPDATE account SET balance = balance - 500 WHERE user_id = 1; SAVEPOINT sp1; UPDATE account SET balance = balance + 500 WHERE user_id = 2; -- 发现入账金额错了,回滚到sp1位置 ROLLBACK TO SAVEPOINT sp1; -- 继续执行其他操作 UPDATE account SET balance = balance + 600 WHERE user_id = 2; -- 最终提交 COMMIT;这个机制在实际业务里用得不算多,但一旦用上就很救命。比如批量导入数据时,你按1000条一个批次处理,希望跳过出错的那一条而保留前面正确的数据,SAVEPOINT就是干这个的。
注意:
ROLLBACK TO SAVEPOINT并不会释放所有锁,也不会结束事务。它只是撤销保存点之后的变更。事务本身还活着,直到你执行COMMIT或ROLLBACK。
2.4 自动提交和隐式提交
默认情况下,MySQL每个单独的SQL语句都是一个自动提交的事务。也就是说你执行一条UPDATE,成功就自动commit了,失败就自动回滚,不需要你干预。
要查看当前是否开启自动提交:
SHOW VARIABLES LIKE 'autocommit';输出结果里autocommit的值是ON的话,就是自动提交模式。临时关闭可以这样:
SET autocommit = 0;注意,这个设置只对当前会话有效,不影响其他连接。而且从autocommit = 0切换到事务操作时,不需要额外写BEGIN,因为关闭自动提交后,你执行的第一条SQL就隐式开启了事务,直到你手动COMMIT或ROLLBACK。
另外还有一个"隐式提交"的坑。有些SQL语句执行时会强制提交当前事务,哪怕你正在一个事务中间:
- DDL语句,比如
CREATE TABLE、ALTER TABLE、DROP TABLE; - 账户和权限管理语句,比如
CREATE USER、GRANT等; - 锁相关操作,比如
LOCK TABLES; - 管理类语句。
我踩过的坑是:在一个事务里先更新了业务数据,然后执行了一个ALTER TABLE加字段,结果前面的更新被自动提交了,后面业务异常回滚时,发现前面的操作根本没撤销掉。所以事务边界内尽量别混用DDL。
2.5 事务操作速查表
| 操作 | SQL | 说明 |
|---|---|---|
| 开启事务 | START TRANSACTION / BEGIN | 显式开启事务 |
| 提交事务 | COMMIT | 持久化所有修改 |
| 回滚事务 | ROLLBACK | 撤销所有未提交修改 |
| 设置保存点 | SAVEPOINT xxx | 在事务内标记位置 |
| 回滚到保存点 | ROLLBACK TO SAVEPOINT xxx | 撤销保存点后的操作 |
| 释放保存点 | RELEASE SAVEPOINT xxx | 删除保存点 |
| 查看自动提交 | SHOW VARIABLES LIKE 'autocommit' | 查看当前会话状态 |
3. 四大特性拆解:ACID背后的实现逻辑
3.1 原子性(Atomicity):要么全做,要么全不做
原子性保证事务是一个不可分割的工作单元。事务内的所有操作,要么全部执行成功,要么全部不执行。这个特性靠的是undo log(回滚日志)。
事务每次修改数据之前,会先把修改前的旧值写入undo log。如果事务中途失败需要回滚,InnoDB就根据undo log里的旧值把数据还原回去。这个机制很像写作文之前先复印一份原稿,涂坏了就照着原稿重新抄一遍。
以那条转账SQL为例:
START TRANSACTION; UPDATE account SET balance = balance - 500 WHERE user_id = 1; UPDATE account SET balance = balance + 500 WHERE user_id = 2; ROLLBACK;执行完第一条UPDATE后,旧值5000被写入undo log。执行第二条前发现有问题,直接ROLLBACK,InnoDB根据undo log把user_id=1的余额从4500还原成5000,数据回到初始状态。
3.2 一致性(Consistency):数据永远符合约束
一致性指的是事务执行前后,数据库都处于合法状态,不会破坏完整性约束、唯一索引、外键、触发器等规则。
这里容易混淆的是:事务机制本身只是手段,一致性的保证需要应用层和数据库层配合。比如账户余额不能为负数这种业务规则,数据库并不知道,需要你在SQL或代码层面判断。
从实现角度看,一致性部分是靠原子性、隔离性共同支撑的。原子性保证了没有中间状态被持久化,隔离性保证并发事务之间不会互相干扰,两者都满足了,最终呈现出来的数据自然是一致的。
举一个反面例子:
-- user_id=1 的余额只有 300 START TRANSACTION; UPDATE account SET balance = balance - 500 WHERE user_id = 1; COMMIT;这条SQL在事务层面没有任何问题,MVCC机制、锁机制都会正常工作。但余额变成了-200,业务上完全说不通。这就是为什么说一致性需要开发者主动参与,数据库只能保证它“知道”的约束,业务规则还得靠你自己把关。
3.3 隔离性(Isolation):并发事务互不干扰
隔离性保证多个事务并发执行时,一个事务的中间状态对另一个事务是不可见的。这就要提到事务隔离级别。
我先说结论,具体操作下一节展开。InnoDB支持四种隔离级别:
- READ UNCOMMITTED(读未提交)
- READ COMMITTED(读已提交)
- REPEATABLE READ(可重复读)
- SERIALIZABLE(串行化)
MySQL默认是REPEATABLE READ。隔离级别越高,并发性能越低,但数据一致性越强。实际选型时不是越高越好,而是根据业务需求匹配。
隔离性的实现主力是锁和MVCC(多版本并发控制)。InnoDB用了行锁、间隙锁、next-key lock去控制并发写,用MVCC读快照去控制一致性读。这两个机制配合,才保证了不同隔离级别下的不同表现。
3.4 持久性(Durability):提交了就别想丢
持久性是指事务一旦提交,修改就是永久性的,即使数据库立刻宕机,数据也不会丢。这个特性靠的是redo log(重做日志)。
InnoDB有个经典的策略:修改数据页时不会立即写磁盘(因为随机IO太慢),而是先写redo log,再异步刷盘。redo log是追加写的顺序IO,速度快得多。
流程是:
- 事务提交时,把修改记录写入redo log buffer;
- 把redo log buffer刷到磁盘上的redo log文件;
- 事务才算真正提交成功;
- 之后数据页什么时候刷到磁盘,由后台线程慢慢处理。
如果刷盘前数据库崩了,重启时InnoDB读取redo log,把已经提交的事务重新应用一遍,数据就找回来了。这就是"崩溃恢复"的底层逻辑。
注意:redo log是物理日志,记录的是"对哪个页的哪个偏移做了什么修改";undo log是逻辑日志,记录的是"怎么把数据撤销回去"。两者作用完全不同,别搞混。
4. 隔离级别与并发实践:实操中最容易踩坑的地方
4.1 隔离级别的表现
四种隔离级别对应的并发问题,用表格列出来最直观:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不会 | 可能 | 可能 |
| REPEATABLE READ | 不会 | 不会 | InnoDB下基本不会 |
| SERIALIZABLE | 不会 | 不会 | 不会 |
- 脏读:一个事务读到了另一个事务未提交的数据。如果那个事务回滚了,你读到的就是坏数据。
- 不可重复读:同一个事务里,两次读取同一行数据,结果不一样。因为另一个事务在这期间提交了修改。
- 幻读:同一个事务里,两次查询同一范围的数据,结果行数不一样。另一个事务插入或删除了符合条件的行。
4.2 InnoDB默认级别为什么是可重复读
MySQL官方把默认隔离级别设为REPEATABLE READ,很多人以为是为了和标准SQL对齐,其实不是。根本原因是InnoDB在REPEATABLE READ级别下,通过next-key lock和MVCC消除了幻读现象,所以REPEATABLE READ在MySQL里实际达到了SQL标准的串行化读效果。
这里有个具体表现:在REPEATABLE READ下,事务第一次执行SELECT时创建了一个一致性快照,之后事务内所有普通SELECT都读这个快照,不会看到其他事务后来提交的新数据。这就是为什么同一个事务里两次查询同一行,结果永远一致。
但要注意,这个"看不到新数据"只针对普通一致性读。如果你用SELECT ... FOR UPDATE或者LOCK IN SHARE MODE,那就走当前读,会读到最新已提交数据,会加锁。
4.3 修改隔离级别的两种方式
全局设置,影响所有新连接的会话:
SET GLOBAL transaction_isolation = 'READ-COMMITTED';当前会话设置,只影响当前连接:
SET SESSION transaction_isolation = 'REPEATABLE-READ';注意:MySQL 8.0里控制隔离级别的变量名是transaction_isolation,5.7及以前是tx_isolation。很多人从5.7迁移到8.0时,脚本里写的还是老变量名,会直接报错。
查看当前隔离级别:
SELECT @@transaction_isolation;4.4 四种级别怎么选,我的建议
互联网高并发业务,大部分场景我推荐用READ COMMITTED。理由是这种级别下锁的范围更小,死锁概率低,吞吐量更高。MySQL在READ COMMITTED下,会把行锁退化掉一些间隙锁的持有,并发度更好。
金融类、订单类对强一致要求高的场景,用REPEATABLE READ或者SERIALIZABLE更稳。SERIALIZABLE几乎就是所有并发操作排队执行,性能代价很大,我实际中很少见到生产环境用它。
实操心得:隔离级别不是越高越好。我自己经历过一个系统,使用默认REPEATABLE READ,一个批量更新操作因为间隙锁互相阻塞,导致大量超时。改成READ COMMITTED后,阻塞降了一个数量级。所以如果你发现系统有频繁的锁等待,优先排查隔离级别是不是设高了。
5. 事务实操中常见的翻车现场与定位方法
5.1 事务没生效
这是新手最容易遇到的问题。排查顺序如下:
第一,看表的存储引擎。使用命令确认:
SHOW TABLE STATUS LIKE 'your_table'\G;或者:
SELECT engine FROM information_schema.tables WHERE table_schema = 'your_db' AND table_name = 'your_table';如果是MyISAM,事务直接不生效。建议直接改成InnoDB:
ALTER TABLE your_table ENGINE = InnoDB;线上表改引擎前先评估锁表时间,数据量大会阻塞读写。
第二,看是不是有框架层面的自动提交。很多ORM框架默认把每条SQL当作一个事务自动提交,你还需要手动开启事务才能包住多条SQL。比如用某些框架的数据库访问层,如果没有显式调用事务方法,就相当于没开启事务。
第三,看事务的传播行为和边界。比如在一个方法里开了事务,调了另一个方法,那个方法内部又开了新事务,两个事务相互独立,一个回滚不影响另一个。这种问题用日志和SHOW ENGINE INNODB STATUS里的活跃事务数可以判断。
5.2 锁等待超时
出现Lock wait timeout exceeded是并发写同一行数据最常见的错误。默认锁等待时间是50秒,可通过参数调整:
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';如果只是偶尔锅不在这,调大也没用,反而拖慢了故障感知。优先做的是:
- 定位持有锁的事务;
- 分析事务的SQL,看是不是没用索引,导致行锁升级成了表锁;
- 看是不是事务长时间不提交。
定位持锁事务,可以用:
SELECT * FROM information_schema.innodb_trx;主要关注trx_started字段。如果某个事务开启时间很长,基本就是它一直占着锁不释放,后面的只能排队。
5.3 死锁怎么处理
死锁的特征是互相等待对方持有的锁,谁都没法前进。InnoDB检测到死锁后,会回滚其中一个事务,另一个继续执行,并抛出一个报错:Deadlock found when trying to get lock。
死锁常见原因是多个事务以不同顺序加锁。比如事务A先更新表1再更新表2,事务B先更新表2再更新表1,两者就可能互相卡住。
解法有几种:
- 强制所有事务按相同顺序访问表或行;
- 尽量缩小事务范围,快速提交,减少持有锁的时间;
- 在业务里加锁重试机制,死锁发生后重试整个事务;
- 检查SQL的索引使用情况,避免全表扫描导致锁范围扩大。
死锁不是数据库故障,而是并发冲突的必然现象,设计上要接受它可能发生,并做好重试。
5.4 事务到底有没有提交成功
有些时候,程序日志显示事务执行完了,但数据没变化。先检查:
- 是不是连的不是同一个MySQL实例,读写分离下你查的从库落后;
- 事务是不是被框架级别静默回滚了;
- 有没有在
COMMIT之前抛异常,异常被吞了。
排查事务状态,最直接的是看information_schema.innodb_trx:
SELECT trx_id, trx_state, trx_started, trx_query FROM information_schema.innodb_trx;trx_state为RUNNING表示事务还活着,如果长时间没提交,基本可以判定是有问题的事务。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 事务里的SQL没生效 | 表引擎不支持事务 | SHOW TABLE STATUS |
| 回滚没效果 | 事务已被隐式提交 | 检查事务中是否混入DDL |
| 频繁锁等待 | 隔离级别过高/没走索引 | 查看慢查询和锁监控 |
| 事务开启过多 | 连接池里的会话自动提交关闭后没重置 | 检查连接池配置和回话状态 |
| 数据不一致 | 读写分离,从库延迟 | 检查主从延迟用时 |
| 死锁报错 | 加锁顺序不一致 | 统一访问顺序、加锁重试 |
6. 写在最后的实操心得
事务这个东西,概念上不难,真正难的是边界划分和并发控制。我见过太多系统出事,不是不懂ACID,而是把一堆网络请求、远程调用、消息发送都塞进了一个大事务里,事务长时间不提交,最后锁住了半张表。所以有个非常实用的建议:控制事务的粒度和时长,事务里尽量只保留数据库操作,别把外部调用放进来。
另一个建议是,给业务表设计时提前考虑好隔离级别和唯一约束。比如用唯一索引天然挡住重复提交,很多时候比锁和事务更高效。事务能保证你对数据库的操作安全,但保证不了业务逻辑本身是对的。
我这几年的体感是:把事务用好的核心不在于背下多少命令,而在于你在写SQL之前,先想清楚这句话会不会被并发影响、会不会产生中间状态、失败时能不能干净地退回去。带着这三个问题去写代码,很多坑还没踩就绕过去了。