事务是什么?先搞懂MySQL里的「操作单元」
假设你要实现一个转账功能:A账户转100元到B账户。这个操作需要两步:A的余额减100,B的余额加100。这两步必须「绑在一起」——要么全做(转账成功),要么全不做(转账失败)。如果只做了第一步,A的钱少了但B没收到,数据就乱了。

这种「不可分割的操作组」,就是MySQL里的事务。用代码还原这个场景:
-- 1. 创建测试表(账户id、余额)
CREATE TABLE account (
id INT PRIMARY KEY,
balance INT COMMENT '账户余额'
);
-- 2. 插入初始数据:A(id=1)和B(id=2)各有1000元
INSERT INTO account VALUES (1, 1000), (2, 1000);
-- 3. 开启事务:这组操作要一起完成
BEGIN;
-- A减100
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- B加100
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- 4. 提交事务:操作生效
COMMIT;
-- 查看结果:A=900,B=1100,总余额不变
SELECT * FROM account;
如果中间某步出错(比如第二个UPDATE
写错了表名),只需要执行ROLLBACK
(回滚),所有操作都会撤销——A的余额回到1000,B的也不变。这就是事务的核心价值:保证操作的完整性。
ACID不是口号,每个字母都对应你的数据安全
经常听人说「事务要满足ACID」,但很少有人讲清楚:这四个字母到底能帮你解决什么实际问题?我们拆开讲:
原子性(Atomicity):要么全做,要么全不做
原子性是事务的「底线」——就像转账,不能只做一半。MySQL通过undo log(回滚日志)保证原子性:事务中的每个操作,都会记录反向操作(比如UPDATE
会记录旧值,INSERT
会记录删除操作)。如果事务失败,InnoDB会用undo log恢复到事务开始前的状态。
比如刚才的错误场景:
BEGIN;
-- A减100(成功,余额900)
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- 错误:表名写成了accoun(少了个t)
UPDATE accoun SET balance = balance + 100 WHERE id = 2;
-- 回滚:所有操作撤销,A的余额回到1000
ROLLBACK;
一致性(Consistency):数据从合法到合法
一致性是「业务逻辑的保证」——比如转账前后,A和B的总余额必须不变(1000+1000=900+1100)。事务能帮你保证原子性,但一致性需要业务代码+事务共同实现:
- 比如你得确保扣钱和加钱的金额一致(A减100,B必须加100);
- 比如你得限制余额不能为负(如果A只有50元,就不能转100元)。
如果业务代码写得烂,即使事务成功,数据也会出错。比如:
BEGIN;
-- A减100(余额900)
UPDATE account SET balance = balance - 100 WHERE id = 1;
-- B加200(余额1200)——金额写错了
UPDATE account SET balance = balance + 200 WHERE id = 2;
COMMIT;
-- 结果:总余额从2000变成2100,一致性被破坏
这时候事务没出错,但数据逻辑错了——这就是「一致性依赖业务代码」的意思。
隔离性(Isolation):不同事务互不干扰
隔离性解决的是「多个事务同时操作时的干扰问题」。比如你在查余额的时候,别人刚好在转账,你不能读到未提交的脏数据。
MySQL用隔离级别控制干扰程度,常见的四个级别如下(用表格帮你理清差异):
隔离级别 | 脏读(读未提交数据) | 不可重复读(同一事务内多次读结果不同) | 幻读(读不到新增数据) |
---|---|---|---|
读未提交(Read Uncommitted) | ✅是 | ✅是 | ✅是 |
读提交(Read Committed) | ❌否 | ✅是 | ✅是 |
可重复读(Repeatable Read) | ❌否 | ❌否 | ✅是(MySQL默认级别) |
串行化(Serializable) | ❌否 | ❌否 | ❌否 |
举个「读未提交」的脏读例子:
- 事务1(会话1):
BEGIN; UPDATE account SET balance = 900 WHERE id=1;
(不提交) - 事务2(会话2):
SELECT balance FROM account WHERE id=1;
(读到900,脏数据) - 事务1回滚:
ROLLBACK;
- 事务2再读:
SELECT balance FROM account WHERE id=1;
(回到1000)
这就是脏读——读到了未提交的、最终会被撤销的数据。读未提交级别几乎不会用,因为数据太不可靠。
持久性(Durability):提交后数据永久保存
持久性是「数据的最终保障」——事务提交后,MySQL会把数据写入redo log(重做日志),即使服务器崩溃,重启后也能通过redo log恢复数据。比如你执行COMMIT
后,哪怕MySQL立刻宕机,重启后数据依然是提交后的状态。
事务的开启与提交:别再用错BEGIN和COMMIT
很多人用事务时踩的第一个坑,就是「没搞懂自动提交」。
MySQL默认开启自动提交(autocommit)——也就是说,每一条SQL语句都是一个独立的事务,执行后自动提交。比如:
-- 自动提交开启时,这条UPDATE会立刻生效,无法回滚
UPDATE account SET balance = 900 WHERE id=1;
如果要手动控制事务,有两种方式:
方式1:用BEGIN/START TRANSACTION开启
-- 开启事务
BEGIN;
-- 执行操作(比如转账)
UPDATE account SET balance = balance - 100 WHERE id=1;
UPDATE account SET balance = balance + 100 WHERE id=2;
-- 提交(生效)
COMMIT;
-- 或者回滚(撤销)
-- ROLLBACK;
方式2:关闭自动提交
-- 关闭自动提交(仅当前会话有效)
SET autocommit = 0;
-- 执行操作(不会自动提交)
UPDATE account SET balance = balance - 100 WHERE id=1;
UPDATE account SET balance = balance + 100 WHERE id=2;
-- 提交
COMMIT;
-- 恢复自动提交
SET autocommit = 1;
注意:如果开启事务后忘记提交(比如BEGIN
后没写COMMIT
),事务会一直占用锁资源,导致其他事务无法修改相关数据。遇到这种情况,要么赶紧COMMIT
,要么ROLLBACK
。
隔离级别里的坑:为什么你的查询总是读到旧数据?
坑1:读提交(Read Committed)的「不可重复读」
读提交是很多互联网公司的默认级别(比如阿里),它能避免脏读,但会出现「不可重复读」——同一事务内,两次读同一数据的结果不同。
比如:
- 事务2(会话2):
BEGIN; SELECT balance FROM account WHERE id=1;
(读到1000) - 事务1(会话1):
BEGIN; UPDATE account SET balance=900 WHERE id=1; COMMIT;
(提交) - 事务2(会话2):
SELECT balance FROM account WHERE id=1;
(读到900,结果不同)
这种情况对「实时性要求高的场景」(比如电商库存查询)是合理的——你总不想用户看到的库存是10分钟前的吧?但对「需要一致结果的场景」(比如报表统计)就有问题——你总不想统计到一半,数据突然变了吧?
坑2:可重复读(Repeatable Read)的「幻读」
可重复读是MySQL的默认级别,它能避免不可重复读,但会出现「幻读」——同一事务内,两次查询同一条件的结果行数不同。
比如:
- 事务2(会话2):
BEGIN; SELECT * FROM account WHERE id > 3;
(查到2条数据) - 事务1(会话1):
BEGIN; INSERT INTO account VALUES (4, 1000); COMMIT;
(插入一条id=4的记录) - 事务2(会话2):
SELECT * FROM account WHERE id > 3;
(还是查到2条,幻读) - 事务2(会话2):
UPDATE account SET balance=900 WHERE id > 3;
(会修改3条数据——包括新增的id=4)
这是因为可重复读级别下,MySQL用MVCC(多版本并发控制)保证同一事务内的读一致性:每个事务都会看到一个「快照」,快照里的数据是事务开始时的状态。但插入操作会突破MVCC的限制——因为插入的记录不在快照里,所以更新时会包含新增的记录。
怎么选隔离级别?
- 实时性高的场景(电商、社交):选读提交(避免脏读,允许不可重复读)
- 需要一致结果的场景(报表、财务):选可重复读(避免不可重复读,允许幻读)
- 数据绝对不能错的场景(金融、支付):选串行化(完全隔离,但性能极低)
故障场景应对:事务回滚到底怎么用?
场景1:代码错误导致的回滚
比如你写了一个批量更新的脚本,其中某条记录出错,需要回滚所有操作:
BEGIN;
-- 更新id=1~5的账户,各减100(成功)
UPDATE account SET balance = balance - 100 WHERE id IN (1,2,3,4,5);
-- 更新id=6的账户(id=6不存在,出错)
UPDATE account SET balance = balance - 100 WHERE id = 6;
-- 回滚:前5条的更新也会撤销
ROLLBACK;
场景2:数据库崩溃导致的回滚
如果事务执行到一半,MySQL突然宕机,InnoDB引擎会在重启时自动回滚未提交的事务——这是因为InnoDB会把未提交的事务记录在undo log里,重启后遍历undo log,撤销所有未提交的操作。
场景3:需要部分回滚怎么办?
MySQL不支持「部分回滚」——事务要么全滚,要么全不滚。如果需要部分操作生效,可以把事务拆分成多个小事务:
-- 事务1:更新id=1~5(成功后提交)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id IN (1,2,3,4,5);
COMMIT;
-- 事务2:更新id=6~10(出错后回滚,不影响事务1)
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id IN (6,7,8,9,10);
-- 出错,回滚
ROLLBACK;
最后:事务的5个最佳实践
- 尽量缩小事务范围:事务越长,占用锁的时间越久,性能越低。比如不要在事务里做无关的操作(比如调用外部API、sleep)。
- 避免在事务里循环操作:比如循环更新1000条记录,会导致事务变得很长,容易锁表。可以拆成多个小事务(比如每次更新100条)。
- 明确事务边界:要么用
BEGIN
+COMMIT
,要么关闭自动提交,别混合使用。 - 选对隔离级别:不要盲目用最高级别(串行化),根据场景选合适的。
- 监控事务状态:用
SHOW ENGINE INNODB STATUS;
查看事务状态,避免长时间未提交的事务。
原创文章,作者:,如若转载,请注明出处:https://zube.cn/archives/193