MySQL 事务操作与四大特性:从一个转账需求说起
做后端开发的人,几乎每天都在跟数据库打交道。如果你问一个工作了三五年的Java工程师:“MySQL事务的四大特性是什么?”他大概率能脱口而出ACID——原子性、一致性、隔离性、持久性。但你要是再追问一句:“隔离性在InnoDB里到底是怎么实现的?为什么你的事务明明加了注解却不生效?”能答清楚的人就明显变少了。
这篇文章不打算给你背教科书定义,而是从一个最典型的转账需求入手,把MySQL事务的操作方式、ACID四个特性的底层实现原理、以及开发中常见的“事务失效”问题一次性讲透。不管是刚入行的新人,还是准备跳槽面试的进阶开发者,这篇内容都值得你花半小时慢慢读。看完之后,你至少能回答清楚三个问题:事务能帮我解决什么问题?ACID各自靠什么机制保障?实际项目中事务为什么经常“不听话”?
1. 内容整体设计与思路拆解
1.1 先搞清楚事务到底解决了什么问题
先看一个再常见不过的场景:用户A给用户B转账1000元。这个操作在数据库里至少要拆成两次UPDATE:一次扣减A的余额,一次增加B的余额。假如第一次UPDATE成功了,第二次UPDATE却因为磁盘满、网络抖动、字段超长等原因失败了,那么A的钱就凭空消失了1000元,B的钱也没多,整个账目就乱了。
这个时候,你就需要事务。事务的本质是:把多个数据库操作打包成一个不可分割的执行单元,这个单元里的所有操作,要么全部成功,要么全部失败回滚,绝不允许出现“做了一半”的状态。这就是事务存在的最大价值——它是数据库系统保证数据可靠性的基石。
从这个角度去理解,你会发现事务并不是MySQL独有的概念,Oracle、SQL Server、PostgreSQL都有完整的事务机制。只是MySQL在国内的普及率实在太高,尤其是互联网公司几乎清一色用MySQL,所以“MySQL事务”成了后端面试里出镜率最高的话题之一。
1.2 贯穿全文的示例表设计
后面所有内容都会围绕一个极简的账户表和订单表来展开,你可以直接在本地MySQL里执行这段DDL:
sql复制CREATE DATABASE IF NOT EXISTS demo_bank DEFAULT CHARACTER SET utf8mb4;
USE demo_bank;
CREATE TABLE account (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(32) NOT NULL,
balance DECIMAL(10, 2) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0
) ENGINE=InnoDB;
CREATE TABLE orders (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT INTO account (name, balance) VALUES ('张三', 5000.00), ('李四', 2000.00);
注意这里两个表都显式指定了 ENGINE=InnoDB。为什么要强调引擎?因为MySQL的事务能力是存储引擎层面实现的,MyISAM引擎就不支持事务。这件事后面讲事务失效场景时还会提到,先记下这个结论。
1.3 事务的天然载体:一个完整的事务生命周期
一个事务从开始到结束,标准的生命周期是这样的:
- 开启事务(START TRANSACTION)
- 执行若干条SQL(INSERT / UPDATE / DELETE)
- 判断执行结果:全部成功则提交(COMMIT),任意失败则回滚(ROLLBACK)
- 无论提交还是回滚,事务都会结束,连接回到默认的自动提交模式
理解了这个生命周期,操作层面就成功了一半。后面第三节我会把每一步的代码细节展开讲,这里先建立整体认知。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析:ACID四大特性的底层实现
2.1 原子性(Atomicity)
原子性强调的是“要么全做,要么全不做”。在InnoDB里,原子性的实现靠的是 undo log(回滚日志)。
为了方便理解,你可以把undo log想象成一支自动记账的“后悔药”。当事务执行一条UPDATE语句时,InnoDB并不是直接把旧数据抹掉,而是在修改数据页之前,先把修改前的数据快照写进undo log。如果事务执行到一半出错了,InnoDB就拿出undo log里的记录,把数据页反向恢复成修改前的样子——这条回滚操作在InnoDB内部叫 ROLLBACK。
再强调一点:ROLLBACK 和 COMMIT 一样,本身也是一个完整的过程。回滚并不是“瞬间恢复”,而是需要InnoDB根据undo log逐条执行反向操作。所以如果一个事务执行了几十万条UPDATE,中途失败要回滚,那这个回滚过程本身也可能比较耗时。这一点在做大数据量更新任务时一定要有心理准备,否则容易误判成数据库“卡死了”。
基于undo log,InnoDB还实现了另一个重要能力——MVCC(多版本并发控制)。这个机制后面讲隔离性时还会用到。
2.2 一致性(Consistency)
一致性是ACID里最容易被误解的一个特性。你需要特别明确一点:一致性不是一个独立的技术机制,而是一个“约束目标”。它要求事务执行前后,数据库始终处于一致性状态。
怎么理解一致性状态?还是以转账为例。张三余额5000,李四余额2000,两个人的总额是7000。任何一笔合法转账执行前后,为了满足“总资产不变”这个业务规则,总额都必须保持7000。如果转账过程中有人查了一次总额,意外发现变成了6000或者8000,那这个状态就是不一致的。
一致性分两个层面:
- 数据库层面的一致性:主键唯一、外键约束、非空约束、CHECK约束等都由数据库强制执行。
- 应用层面的一致性:像“转账后总金额不变”“库存不能为负数”“订单状态流转必须合法”这类业务规则,数据库无法自动感知,需要应用代码配合事务来保障。
所以,事务的原子性、隔离性、持久性本质上都是为一致性服务的工具。原子性保证不会出现部分成功,隔离性保证并发事务之间不会相互污染,持久性保证一旦提交就不会丢数据。三者合力,数据库才能在并发环境下始终保持一致性。
2.3 隔离性(Isolation)
隔离性处理的是“多个事务同时执行时,互不干扰”的问题。数据库里并发是常态,如果能做到完全互不干扰,性能会差到没法用,所以隔离性在具体实现上做了权衡,分成了四种隔离级别。
隔离级别对应的并发问题可以用一张表说清楚:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不可能 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不可能 | 不可能 | 可能(InnoDB下基本不会) |
| SERIALIZABLE(串行化) | 不可能 | 不可能 | 不可能 |
什么是脏读?事务A改了一笔数据但还没提交,事务B就读到了这笔未提交的数据。万一事务A回滚了,B读到的就是脏数据。
什么是不可重复读?事务A先读了一行数据,事务B修改并提交了这一行,事务A再读同一行时发现值变了。同一个事务里两次读到不同的值,这在某些业务中是不可接受的。
什么是幻读?事务A按条件范围查出了10行数据,事务B插入了一行符合条件的新数据并提交,事务A再次按同一条件查询时发现变成了11行。这个新增的行就是“幻影行”。
MySQL的默认隔离级别是 REPEATABLE READ(可重复读)。很多人以为这个级别下幻读依然可能发生,但InnoDB通过 MVCC + 间隙锁(gap lock) 的组合拳,在绝大多数场景下已经彻底避免了幻读问题。所以面试时你回答“InnoDB默认的可重复读级别下,通过MVCC解决快照读的幻读,通过next-key lock解决当前读的幻读”,这就是一个非常完整的答案。
2.4 持久性(Durability)
持久性保证的是:一旦事务提交成功,数据就不会丢失,即使数据库立刻宕机,重启后数据依然在。
InnoDB持久性靠的是 redo log(重做日志) 配合 WAL(Write-Ahead Logging,预写日志) 机制实现。WAL的核心思想是:先写日志,再写数据页。
这么做的原因很好理解。直接改数据页属于随机写,性能差;而redo log是顺序追加写入,性能好得多。每次事务提交时,InnoDB先把变更记录追加到redo log并落盘,然后数据页的修改可以留在内存buffer pool里慢慢刷。如果数据库在那之前宕机了,重启时InnoDB会用redo log把已经提交但还没刷进数据页的变更重新应用一遍,保证数据不丢。
你可以把redo log想象成快递单号:商品可能还在仓库里没打包好(数据页还没落盘),但快递单号已经登记了(redo log已落盘),只要单号在,包裹就一定不会丢。
至此,ACID四个特性的底层机制可以这样串起来:
| 特性 | 核心机制 | 解决的问题 |
|---|---|---|
| 原子性 | undo log | 失败不半途而废 |
| 一致性 | 应用逻辑 + 其他三性 | 前后状态合法 |
| 隔离性 | 锁 + MVCC | 并发不互相干扰 |
| 持久性 | redo log + WAL | 提交不丢数据 |
3. 实操过程与核心环节实现
3.1 命令行下的基本事务操作
先来一套最基础、但你必须形成肌肉记忆的SQL操作。连上MySQL后,执行:
sql复制USE demo_bank;
-- 开启事务
START TRANSACTION;
-- 扣减张三余额
UPDATE account SET balance = balance - 1000 WHERE id = 1;
-- 增加李四余额
UPDATE account SET balance = balance + 1000 WHERE id = 2;
-- 提交事务
COMMIT;
如果第二条UPDATE执行失败了(比如字段长度超限),你可以在COMMIT之前执行 ROLLBACK; 让整个事务回滚。
这里有一个执行顺序上的关键点:START TRANSACTION 之后执行的DML语句不会立即生效,只有 COMMIT 之后其他连接才能看到变更。你可以开两个MySQL终端窗口分别验证,会直观感受到“提交前后两个连接看到的数据不一样”。
MySQL还支持在事务中设置保存点,允许部分回滚:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 500 WHERE id = 1;
SAVEPOINT sp1;
UPDATE account SET balance = balance + 500 WHERE id = 2;
-- 这里发现第二步有问题,只想回滚到sp1,不想撤销第一步
ROLLBACK TO SAVEPOINT sp1;
COMMIT;
有了SAVEPOINT,复杂事务可以做到“局部撤销”,而不是一错全错。这个能力在长事务里尤为实用。
3.2 JDBC层面的事务控制
真实项目里,我们很少直接在命令行敲事务SQL,更多是通过代码操作数据库。用原生JDBC写事务,代码是这样的:
java复制Connection conn = null;
try {
conn = DriverManager.getConnection(DB_URL, USER, PASSWORD);
// 关闭自动提交,手动开启事务
conn.setAutoCommit(false);
PreparedStatement ps1 = conn.prepareStatement("UPDATE account SET balance = balance - ? WHERE id = ?");
ps1.setBigDecimal(1, new BigDecimal("1000"));
ps1.setInt(2, 1);
ps1.executeUpdate();
PreparedStatement ps2 = conn.prepareStatement("UPDATE account SET balance = balance + ? WHERE id = ?");
ps2.setBigDecimal(1, new BigDecimal("1000"));
ps2.setInt(2, 2);
ps2.executeUpdate();
// 所有语句执行成功,提交事务
conn.commit();
} catch (SQLException e) {
if (conn != null) {
// 发生异常,回滚事务
conn.rollback();
}
throw e;
} finally {
if (conn != null) {
conn.close();
}
}
JDBC里 setAutoCommit(false) 相当于开启事务,commit() 是提交,rollback() 是回滚。看起来不复杂,但代码里到处是try-catch-finally,每个方法都要写一遍,项目大了就非常痛苦。这就是为什么Spring的 @Transactional 注解大行其道——它把开启、提交、回滚都交给了框架处理,程序员只需要关注业务逻辑本身。
3.3 Spring @Transactional 注解的核心配置
在Spring Boot项目里,最常见的写法是在Service方法上打一个 @Transactional 注解:
java复制@Service
public class TransferService {
@Autowired
private AccountMapper accountMapper;
@Transactional(rollbackFor = Exception.class)
public void transfer(Integer fromId, Integer toId, BigDecimal amount) {
accountMapper.decreaseBalance(fromId, amount);
// 第2步可能抛异常
accountMapper.increaseBalance(toId, amount);
}
}
这个注解有几个关键属性一定要理解清楚:
rollbackFor 属性
默认情况下,Spring只对运行时异常(RuntimeException)和Error回滚,遇到受检异常(checked exception,比如IOException)不会回滚。所以最保险的写法是显式指定 rollbackFor = Exception.class。实践中有不少事故就是因为这个属性没设,导致抛了业务异常(注:有些团队会自定义受检异常)结果没有回滚,数据半成功入库。
isolation 属性
java复制@Transactional(isolation = Isolation.REPEATABLE_READ)
这个属性决定事务的隔离级别。默认值 Isolation.DEFAULT 表示采用数据库默认级别,MySQL下就是REPEATABLE READ。什么时候需要显式设置?一般是某个特殊业务场景需要更严格的隔离级别(比如对账场景用SERIALIZABLE),或者出于性能考虑想降级用READ COMMITTED。大多数项目不需要动这个值,保持默认最省心。
propagation 属性
传播行为可能是Spring事务里最容易踩坑的点。先记住一个结论:默认的 REQUIRED 就是绝大多数场景的正确选择。传播行为定义的是“当前方法遇到一个已经存在的事务时,应该怎么处理”。
| 传播行为 | 含义 |
|---|---|
| REQUIRED | 有事务就加入,没有就新建(默认) |
| REQUIRES_NEW | 无论如何都新建一个事务,原事务挂起 |
| NESTED | 有事务则创建嵌套事务(Savepoint实现) |
| MANDATORY | 必须有事务,否则抛异常 |
| SUPPORTS | 有事务就支持,没有就以非事务方式执行 |
| NOT_SUPPORTED | 以非事务方式执行,如有事务则挂起 |
| NEVER | 必须非事务执行,如有事务则抛异常 |
实际开发中,最容易出问题的是 REQUIRES_NEW。举个典型例子:转账成功后要写一条操作流水,流水表写入失败不应该影响主转账事务,这时候把写流水的方法设成 REQUIRES_NEW 就对了。如果用的是默认的REQUIRED,写流水失败会把整笔转账一起回滚,反而违背了“流水可丢、钱不能错”的业务预期。
3.4 隔离级别的实操验证
理论知识说再多,不如自己动手验证一次。我建议你开两个MySQL终端,分别执行以下命令:
终端1:
sql复制USE demo_bank;
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
START TRANSACTION;
UPDATE account SET balance = balance - 500 WHERE id = 1;
注意此时不要COMMIT,切到终端2执行:
sql复制USE demo_bank;
SELECT balance FROM account WHERE id = 1;
如果终端2查到了4500,说明你亲眼看到了“脏读”。把终端1的隔离级别改成READ COMMITTED或REPEATABLE READ再试,终端2就查不到未提交的数据了。
这个实验最直观的价值在于:它让你真正理解“未提交的数据对其他事务不可见”是什么体验。MySQL默认隔离级别是REPEATABLE READ,这个级别下InnoDB会保证事务内多次快照读的结果一致,这也是很多存量系统敢在默认隔离级别下放心跑的原因。
3.5 事务超时与只读优化
@Transactional 还有一个容易被忽视的属性:timeout。
java复制@Transactional(timeout = 5)
public void batchUpdate() {
// 5秒内必须完成提交,否则抛出异常
}
默认不设置就没超时限制,这意味着一个失控的事务可以长时间占用连接和锁资源。在高并发系统里,这往往就是死锁和连接池耗尽的导火索。业务上确实有长时间运行的批量任务,可以适当放宽超时时间,但一定不能完全不设。
另一个实用优化是只读事务:
java复制@Transactional(readOnly = true)
public OrderDetail getOrderDetail(Long orderId) {
// 只查询,不修改
}
readOnly = true 时,Spring会做两层优化:底层通过设置连接为只读模式,让MySQL优化器走只读路径,减少不必要的加锁开销;框架层面则确保该事务不会触发事务写操作。只读事务适合那些内部有多次查询、希望保证查询结果一致性(同一快照)的场景。注意,不要把只读事务用在包含写操作的方法上,否则写操作会静默失败或者报错,排查起来很隐蔽。
4. 常见问题与排查技巧实录
4.1 事务失效的8种典型场景
“为什么我加了@Transactional,数据还是没回滚?”这是我被问得最多的一个问题。下面这些场景,每一行都是真实案例,请对照自查。
| 失效场景 | 原因 | 解决方案 |
|---|---|---|
| 方法被this调用 | Spring事务通过AOP代理生效,self invocation不走代理 | 注入自身代理,或拆到另一个Bean |
| 方法不是public | Spring的@Transactional只能作用在public方法上 | 改成public |
| 方法被final/static修饰 | 代理类无法重写final方法,static方法不走代理 | 去掉final/static |
| 异常被catch吞掉 | 异常没抛出,事务感知不到 | 让异常继续抛出,或在catch块中手动回滚 |
| 抛出checked异常 | 默认只对RuntimeException回滚 | rollbackFor = Exception.class |
| 数据库引擎不支持事务 | MyISAM不保证事务 | 改成InnoDB |
| 多线程调用事务方法 | 事务上下文无法跨线程传递 | 事务方法在调用线程内执行 |
| 传播行为设置错误 | 比如NOT_SUPPORTED会让事务失效 | 按业务语义选对传播行为 |
挑两个最常见的展开说说。
自调用问题。看下面这段代码:
java复制@Service
public class OrderService {
@Transactional
public void createOrder(Order order) {
saveOrder(order);
deductStock(order.getProductId());
}
// 这个方法上的事务注解不会生效
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductStock(Long productId) {
// 库存扣减逻辑
}
}
createOrder 方法内部的 this.deductStock() 是直接的方法调用,不经过Spring的AOP代理,所以 deductStock 上的 @Transactional 完全无效。解决办法是:把 deductStock 拆到另一个Service类里,注入后调用。
异常被吞问题:
java复制@Transactional
public void transfer() {
try {
accountMapper.decreaseBalance(1, amount);
accountMapper.increaseBalance(2, amount);
} catch (Exception e) {
log.error("转账异常", e);
// 没把异常抛出去,Spring认为方法正常返回,事务提交了
}
}
你辛辛苦苦写的事务,被一个空catch直接弄成了摆设。这类问题隐蔽性极强,因为日志里明明有异常,但数据就是没有回滚。排查的时候一定要先看代码里有没有try-catch,有的话看异常有没有重新抛出来。
4.2 隔离级别设置不当引发的数据错乱
曾经有个做电商的朋友跟我分享过一次线上事故:他们做订单金额汇总报表时,因为用了默认的REPEATABLE READ隔离级别,导致报表事务启动后,事务内多次查询看到的是同一个快照,新产生的订单在报表里迟迟不出现。后来把“报表查询事务”显式降级为READ COMMITTED,每次查询都能读到最新已提交数据,问题就消失了。
这个故事延伸出两个经验:一是隔离级别不是“越高越好”,SERIALIZABLE虽然最安全但性能代价极大,并发场景下基本不可用;二是同一个数据库里,不同业务的事务可以设置不同的隔离级别,关键是理清楚“这个事务允不允许读到别的事务的新提交数据”。只读查询要求低隔离级别,资金类操作要求高隔离级别,各有各的适用场景,不要一刀切。
4.3 大事务和长事务的隐藏危害
一个事务执行时间太长,会带来一系列连锁反应:
- 持有锁的时间变长,其他事务只能排队等待,吞吐量直线下降
- 如果涉及大量行,undo log会膨胀,占用大量磁盘空间
- 连接数会被长期占用,连接池一旦耗尽,整个服务就会出现大面积超时
大事务最常见的来源有这几类:一次事务里处理了上万条数据、循环里逐条调用外部接口、纯查询操作也加了事务注解但查询时间巨长。
排查方法:MySQL 5.7及以上版本可以直接查 information_schema.innodb_trx 表:
sql复制SELECT trx_id, trx_state, trx_started, trx_rows_modified, trx_query
FROM information_schema.innodb_trx;
这个表会列出所有正在运行的事务,trx_started 可以直观看出哪些事务已经跑了很久。生产环境一旦发现长时间未结束的事务,需要第一时间确认代码里是否存在未提交的隐患。
拆分大事务的方向一般是:批量数据分批提交(比如每500条一个事务);外部调用移到事务外;只读操作不要包在写事务里。
4.4 死锁的定位与处理
死锁是并发场景下的经典问题,InnoDB检测到死锁后会自动回滚其中一个事务,表现为两个并发请求中有一个报类似这样的错误:
code复制Deadlock found when trying to get lock; try restarting transaction
处理死锁的经验法则是:
- 保持一致的加锁顺序。两个事务都先锁id=1再锁id=2,就不容易死锁;如果事务A先锁id=1再锁id=2,事务B先锁id=2再锁id=1,死锁风险就很高。
- 缩短事务执行时间。事务持有锁的时间越短,死锁概率越低。
- 命中索引。如果UPDATE语句没有走索引,InnoDB会锁住全表或大量行,锁范围扩大,死锁概率急剧上升。可以用
EXPLAIN确认执行计划。 - 死锁后的重试机制。捕获死锁异常后,等待几百毫秒再重试整个事务,在大多数业务中都能成功。
4.5 从MySQL事务到分布式事务的进阶思考
最后聊聊很多开发者在简历上写“精通分布式事务”之前必须先想明白的一个问题。本地事务解决的是“单库内多个操作的一致性”,但微服务化之后,一次业务操作可能跨越多个服务、多个数据库。比如下单操作,订单库和库存库是分开的两个MySQL实例,本地事务管不到另一个库。
分布式事务的核心挑战是:如何在多个独立的本地事务之间,维持全局一致性。业界方案有几类:
- XA协议:数据库原生支持的两阶段提交,强一致但性能开销大,应用不广泛。
- TCC(Try-Confirm-Cancel)模式:业务侵入性强,但灵活性高,适合资金类业务。
- 消息事务:通过可靠消息中间件(如RocketMQ事务消息)来实现最终一致性,适合对实时性要求不高的场景。
- 本地消息表 + 定时对账:简单可靠,很多老项目还在用。
但不管哪种方案,都不是银弹。架构设计时优先考虑“能不能把操作收敛到一个库、一个事务里”,其次是“通过消息异步解耦,容忍短暂的不一致”,最后才考虑引入分布式事务框架。分布式事务的一致性越强,性能损耗和实现复杂度越高,这个平衡点需要结合具体业务来定。
写到这,该说的重点基本都覆盖了。我在实际项目里踩过不少坑,最大的体会是:事务的细节只有在线上故障时才会被真正重视。与其等到数据错乱、用户投诉甚至资损了再去复盘,不如写代码时多花两分钟,想想你的改动能不能在异常情况下回滚干净,锁会不会拖垮其他业务,方法有没有可能被自我调用。数据库的事务机制设计得很精巧,但用好它的关键,还是靠对业务场景的清醒判断和对底层原理的扎实理解。
最后再分享一个小技巧:每次发布涉及数据库变更的需求之前,检查一遍所有 @Transactional 注解的 rollbackFor 属性和事务方法内部的try-catch,这样能避开非常多线上事故。别嫌麻烦,这几分钟的检查,比事后熬夜定位数据问题划算多了。
