MySQL事务处理实战指南:吃透ACID属性,避免数据踩坑

事务是什么?先搞懂MySQL里的「操作单元」

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

MySQL事务处理实战指南:吃透ACID属性,避免数据踩坑

这种「不可分割的操作组」,就是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个最佳实践

  1. 尽量缩小事务范围:事务越长,占用锁的时间越久,性能越低。比如不要在事务里做无关的操作(比如调用外部API、sleep)。
  2. 避免在事务里循环操作:比如循环更新1000条记录,会导致事务变得很长,容易锁表。可以拆成多个小事务(比如每次更新100条)。
  3. 明确事务边界:要么用BEGIN+COMMIT,要么关闭自动提交,别混合使用。
  4. 选对隔离级别:不要盲目用最高级别(串行化),根据场景选合适的。
  5. 监控事务状态:用SHOW ENGINE INNODB STATUS;查看事务状态,避免长时间未提交的事务。

原创文章,作者:,如若转载,请注明出处:https://zube.cn/archives/193

(0)