转账这个场景,你大概率听过很多次:账户A给账户B转100块,数据库里要执行两条SQL,A的余额先减100,B的余额再加100。看起来平平无奇,但有一个经典问题悬在头顶——如果第一条SQL执行成功,第二条SQL在执行前数据库突然崩溃,A的钱已经扣了,B却没收到,这100块去哪里找?
所谓“事务”,就是用来解决这类由多个步骤组成、但必须保证整体生效的问题。很多同学能背出ACID,却说不清每个特性到底由什么机制兜底,也说不清Spring里@Transactional失效、隔离级别怎么调、分布式事务为什么那么难做。这篇文章我会把事务特性从概念、底层实现一路讲到工程落地,适合正在学习数据库原理的后端开发,也适合准备面试、想系统梳理事务体系的人。
1. 从“转账少了100块”说起:先搞清楚事务到底在保护什么
1.1 一个动作拆成多条SQL后,麻烦就来了
事务(Transaction)的定义很简单:一组数据库操作形成一个逻辑工作单元,要么全部成功,要么全部失败。
如果把转账放到代码里看,它大概是这个结构:
sql复制-- 伪SQL,一个转账动作被拆成两步
UPDATE account SET balance = balance - 100 WHERE account_id = 'A';
UPDATE account SET balance = balance + 100 WHERE account_id = 'B';
正常人看到这个设计,第一反应可能是:这不就是两句SQL吗,执行完不就行了?但在生产环境里,问题远比想象中多。
第一类问题是“执行一半”:第一句SQL执行成功,数据库宕机,或者第二句SQL因为字段长度、约束冲突报错。这时A少了100,B没多100,整个账本不平。第二类问题是“别人看到了中间态”:如果A扣完100后、B还没入账之前,刚好有人查了A和B的总资产,查出来的结果会少100块,这个数据对业务来说是脏的。第三类问题是“数据明明提交了却丢失”:假设两条SQL都执行完成,应用也收到了提交成功的响应,但如果提交后的数据没有真正落盘,一次断电可能就让这笔转账灰飞烟灭。
事务的ACID四个特性,本质上就是针对这三类问题设计的底线。
1.2 数据库怎么表达“这是同一个事务”
在MySQL这类关系型数据库里,开发者通过一组指令划出事务的范围,最常见的写法是:
sql复制START TRANSACTION;
UPDATE account SET balance = balance - 100 WHERE account_id = 'A';
UPDATE account SET balance = balance + 100 WHERE account_id = 'B';
COMMIT;
如果执行过程中发现某一步出错,可以用ROLLBACK回滚,让所有改动都还原到事务开始之前。
这里要注意一个细节:MySQL默认开启autocommit=1,也就是每一条SQL都会被当成一个独立事务自动提交。很多人刚开始没意识到这一点,多写两句SQL就以为自己在一个事务里,结果每句都自动提交了,一旦中间出错,前面的已提交语句根本撤不回来。
事务模式大致可以分成三类,理解它们能避免很多应用层的低级问题:
| 模式 | 触发方式 | 典型特点 |
|---|---|---|
| 自动提交事务 | 默认配置autocommit=1 |
每条SQL独立提交,无显式事务边界 |
| 显式事务 | START TRANSACTION / COMMIT / ROLLBACK |
程序员自己控制边界,适合多SQL业务 |
| 隐式事务 | 由框架或数据库会话机制自动管理 | Spring的@Transactional底层走的就是这种逻辑 |
不管哪种模式,底层落到数据库引擎时,事务都有一个唯一标识、一组操作日志、以及一套锁或版本控制机制。理解了事务的边界,我们才好在ACID的框架里继续拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. ACID四个特性逐个拆开:每一步背后都有真实事故
ACID是英文缩写:Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。这四个词几乎出现在所有数据库教材里,但很多人背下来之后,并不知道它们各自对应什么样的代码事故、由什么机制兜底。
2.1 原子性靠的不是运气,是undo log兜底
原子性是最好理解的一条:事务里的全部操作,要么都提交成功,要么都回滚成没发生过。拿转账来说,扣钱和加钱不能只在A端成功。
真正让原子性落地的机制,是数据库里的undo log(回滚日志)。
InnoDB引擎在事务执行过程中,会为每一次数据修改记录一条“反向操作”日志:如果是update,就记录修改前的旧值;如果是insert,就记录这条插入的索引信息;如果是delete,就记录删除前的完整行数据。事务一旦回滚,引擎就顺着undo log把每条操作反向执行一遍,把数据恢复到事务开始前的样子。
用一个生活化类比:undo log就像文字编辑器里的撤销记录。你在一段话里做了十次修改,编辑器并不会在你撤销时再重新打字,而是根据记录一步步倒退,很快就能回到初始状态。
这里有一个开发中容易踩的坑:原子性不等于“程序一报错,数据库就会自动回滚整个事务”。在一些数据库实现里,如果事务中间某条SQL失败,可能只回滚到该语句之前,而不是整个事务。尤其在使用Spring时,如果方法内抛了异常却被上层catch吞掉,事务不知道该回滚,最终可能依然提交。原子性的“回滚”需要明确的ROLLBACK信号,或者是框架帮你传递异常信号。
2.2 一致性是目标,不是某个独立技术
一致性在ACID里最容易被误解成一种具体技术,实际上它是四者里最接近“业务目标”的概念。
正式定义:事务开始前和结束后,数据库都必须处于一致状态。什么叫一致状态?一是数据库的完整性约束没有被破坏,比如主键不能重复、外键必须存在、金额不能为负;二是业务层面的账目关系没有被破坏,比如转账前后A和B的余额总和保持不变。
数据库能做什么?它可以通过主键、唯一索引、外键、CHECK约束等手段,阻止明显违反约束的数据写入。但数据库做不了所有业务层面的判断。比如“一个用户一天只能下单十次”这种规则,如果应用代码写错,数据库本身是不知道的。所以,真正的业务一致性,从来都是应用层和数据库层共同保证的。
ACID里A、I、D都是“手段”,C才是“目的”。原子性保证单个事务不半途而废,隔离性保证并发事务之间不串味,持久性保证提交结果不丢失——这三者共同服务的最终目标,就是让数据从一个合理状态平滑地走到另一个合理状态。
2.3 隔离性:并发场景下的“防串味”机制
隔离性的出发点很直白:假设事务A正在给账户扣钱,还没提交,这个时候事务B也想读同一账户的余额,它到底应该读到哪个版本?
最强的隔离当然是全串行,一个事务执行完再执行下一个,但那样并发性能会惨不忍睹。所以数据库没有把隔离做成“一刀切”,而是提供了不同挡位。
背后的核心机制有两个:锁和MVCC(多版本并发控制)。
锁很好理解,写同一行数据时互斥,防止两个人同时改动。MVCC则更巧妙:它让读操作不用等待写操作释放锁,通过记录数据的历史版本来实现“读快照”。你可以想象成一本记账本,每个人翻开时都记住自己看到的那一版,别人在后面改写不影响你已经记住的内容。
隔离性在实践里比听上去复杂得多,因为“读别人正在修改的数据”会产生脏读、不可重复读、幻读等不同现象,这也是下一章要重点展开的内容。这里先记住一点:隔离性不是“完全不隔离”,而是让开发者在正确性和并发性能之间做取舍。
2.4 持久性:提交了就别怕重启
持久性说的是:事务一旦提交成功,即使系统立刻断电、进程崩溃,数据也不会丢失。
很多人以为数据库只要把值写进内存就算成功,其实不完全是这样。为了性能,数据库通常会先在内存中的Buffer Pool里修改数据页,这些内存页被称为“脏页”,并不会每次提交都立刻刷回磁盘。因为刷磁盘是随机I/O,太慢了。
那怎么保证崩溃后数据不丢?靠的是redo log(重做日志)。数据库遵循WAL(Write-Ahead Logging)机制,在修改数据页之前,先把“这次要改哪个页、偏移量多少、改成什么值”顺序写入日志文件。顺序写磁盘比随机写快得多。提交成功那一刻,保证redo log已经落盘;将来无论系统怎么崩溃,重启后都能根据redo log把数据重新回放出来。
事务提交时是否强制redo log落盘,由MySQL的innodb_flush_log_at_trx_commit参数控制:
| 参数值 | 每次提交是否刷盘 | 安全性/性能特征 |
|---|---|---|
| 1 | 每次提交都刷redo log到磁盘 | 最安全,性能损耗最大,交易类系统首选 |
| 2 | 只写到操作系统缓存,由OS定期刷盘 | MySQL崩溃不丢,OS崩溃可能丢最近一秒事务 |
| 0 | 每秒才刷一次盘 | 性能最高,崩溃时可能丢最多一秒事务 |
我在实际项目里通常建议:资金、订单、库存这类核心数据用1,节流性能可以配合批量提交减少fsync次数;日志、点赞等允许少量丢失的场景用2。不要轻易用0,除非你能明确接受最近一秒数据丢失的代价。
3. 隔离级别:把“隔离性”拧到哪个挡位,是需要权衡的
3.1 并发读会出什么事:脏读、不可重复读、幻读
谈隔离级别之前,得先认识并发事务下最常见的三类事故。
脏读(Dirty Read):事务A修改了某行数据但还没提交,事务B读到了这个未提交的修改。接着事务A回滚了,B刚才读到的那份数据就成了“幽灵数据”。这就像有人在黑板上写了个草稿还没拍板,旁边的人就把草稿内容当成最终结果抄走了。
不可重复读(Non-Repeatable Read):事务A第一次读某行是100,事务B修改并提交成了200,事务A再次读同一行,发现变成200。同一个事务里读到不同的结果,叫做不可重复读。问题在于update操作,是行内容被改动。
幻读(Phantom Read):事务A执行了一次范围查询,比如SELECT * FROM order WHERE user_id = 1,查出来3条。事务B插入了一条满足同样条件的新订单并提交,事务A再次执行同样的范围查询,发现变成了4条。多出来的行像幻觉一样出现,所以叫幻读。它和不可重复读的区别是:不可重复读针对同一行内容变了,幻读针对查询结果集合的数量变了。
3.2 四个隔离级别各防什么
SQL标准用这三级问题划出了四个隔离级别,由低到高分别是:
| 隔离级别 | 能防脏读 | 能防不可重复读 | 能防幻读 | 典型场景 |
|---|---|---|---|---|
| READ UNCOMMITTED | 不防 | 不防 | 不防 | 几乎不该在正式业务使用 |
| READ COMMITTED | 防 | 不防 | 不防 | Oracle默认级别,互联网高并发常用 |
| REPEATABLE READ | 防 | 防 | MySQL InnoDB下基本可防 | MySQL默认级别 |
| SERIALIZABLE | 防 | 防 | 防 | 强一致要求且并发量低 |
先说脏读。READ UNCOMMITTED级别下,事务能直接读到别人未提交的数据,代价很大,正式系统基本不用。
**READ COMMITTED(读已提交)**的意思是:一个事务只能读到已经提交的事务修改。这样脏读被解决了,但因为每次查询都可能看到其他事务最新提交的结果,同一个事务里两次读同一行可能不一样,于是不可重复读依然存在。
**REPEATABLE READ(可重复读)**是MySQL InnoDB的默认级别。它在事务第一次查询时创建一份相对固定的快照,之后这个事务的普通查询都从快照读,因此同一行数据反复读结果一致,不可重复读被解决。
**SERIALIZABLE(串行化)**最严格,让事务之间彻底串行执行或加更强的锁,脏读、不可重复读、幻读全部不会出现,但并发度也最低,只适合对一致性要求极高且写并发很小的场景。
这里要特别说明一点:标准SQL认为REPEATABLE READ不能防幻读,但MySQL InnoDB通过MVCC加间隙锁,在大多数场景下连幻读也一起防了。细节后面接着聊。
3.3 MySQL默认可重复读,为什么还有人改成读已提交
Oracle默认隔离级别是README COMMITTED,MySQL却默认REPEATABLE READ,不少面试官喜欢问为什么。
主流说法和MySQL主从复制历史有关。早期版本binlog只支持statement格式,主库执行的是一条SQL语句,从库要把这条语句原样重放一遍。如果主库隔离级别是READ COMMITTED,事务执行期间可能被其他提交事务影响,同样的SQL在从库重放时,由于上下文不同,可能产生不同结果,导致主从数据不一致。而REPEATABLE READ配合InnoDB的快照和锁机制,能让语句执行结果更可预测,对statement格式的复制更友好。后来即使binlog支持了row格式,这个默认值也作为历史习惯一直被保留了下来。
MySQL默认隔离级别虽然是RR,但很多高并发团队会把数据库改成READ COMMITTED,原因很现实:REPEATABLE READ下InnoDB会使用间隙锁和next-key lock来防空范围插入,锁范围更大,出现死锁和锁竞争的概率也更高;READ COMMITTED只锁住真正命中的索引行,并发度更友好。如果你的系统对“同一个事务里重复读必须完全一致”这种事没有强要求,切到RC是常见的性能优化手段。
调整隔离级别的SQL很简单:
sql复制-- 查看当前事务隔离级别
SELECT @@transaction_isolation;
-- 会话级调整
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局调整(需要高权限,重启依然生效需要改配置文件)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
改隔离级别之前,务必想清楚你容忍哪个问题:如果业务能接受“事务内的两次查询可能看到别的事务新提交的行”,RC是划算的;如果业务需要长时间对一个范围加锁并防止并发插入,RR更合适。
3.4 可重复读下的“幻读残留”:快照读与当前读要分清
这是事务隔离性里最容易混淆的地方,多写一点。
先区分两种读法。
快照读(Snapshot Read):普通SELECT语句。它走MVCC,事务第一次查询时生成ReadView(可见版本视图),之后沿用同一份视图。在RR级别下,其他事务即使插入了新行并提交,只要这个事务的快照视图不包含新行版本,反复SELECT就看不到新记录,这等于从机制上防住了普通查询的幻读。
当前读(Current Read):SELECT ... FOR UPDATE、UPDATE、DELETE这类语句必须读最新已提交数据并加锁。在RR级别下,如果第一次操作就是给某个范围加锁,InnoDB会使用next-key lock(记录锁+间隙锁),锁住记录和它前面的间隙,阻止其他事务在这个范围内插入新记录,从而防止当前读下的幻读。
但如果你前面用的是快照读,后面又用当前读,偶尔会撞见“看起来多出几行”的意外。因为当前读始终拿的是最新版本,不会受旧ReadView保护。网络上常见的“MySQL可重复读下依然有幻读”的说法,指的大多是这种场景:要彻底杜绝幻读,要么事务从一开始就使用SELECT ... FOR UPDATE把范围锁住,要么直接提升到SERIALIZABLE。
所以我在面试里见到的最佳表述是:InnoDB的REPEATABLE READ下,普通快照读不会出现幻读;当前读配合next-key lock也能避免幻读,但需要加锁条件和执行顺序配合正确,不能简单地说“RR绝对不幻读”。
4. Spring/Java工程里,事务特性是怎么落地的
4.1 @Transactional 帮你完成了哪些事情
SpringBoot开发中,最常用的方式是在方法上打@Transactional注解。表面看是加了一行注释,实际上Spring通过AOP为Bean生成代理对象:进入方法前开启事务,方法正常结束就提交,方法抛出异常就回滚。
回滚规则的默认逻辑值得反复强调:Spring默认只在遇到RuntimeException和Error时回滚,遇到受检异常(Exception的子类但不继承RuntimeException,比如IOException)不会自动回滚。要让所有异常都触发回滚,建议统一写明:
java复制@Transactional(rollbackFor = Exception.class)
public void transfer(Account from, Account to, BigDecimal amount) {
accountMapper.deduct(from.getId(), amount);
accountMapper.increase(to.getId(), amount);
}
很多人踩过这样一个坑:数据库操作抛了SQLException,方法外层通过try...catch把异常吞掉,然后返回一个友好的提示给前端。事务拦截器压根没收到异常信号,于是之前执行成功的SQL全部提交。等发现问题时,数据已经写进去了。
如果你用声明式注解比较多,建议在项目里定一条规矩:事务方法内部只处理业务成功路径,异常交给代理层回滚,或者至少把捕获到的异常重新抛出。如果真的希望在事务方法里吞异常并提交,那要非常明确自己正在做什么。
4.2 七种事务传播行为,用的时候别只记得REQUIRED
事务传播行为,解决的核心问题是:如果一个方法已经在一个事务里执行,调用另一个声明了事务的方法时,这个被调方法到底该怎么处理?是加入当前事务,还是挂起当前事务单独开一个?
Spring定义了7种传播行为,最常用的其实就那么几个:
| 传播行为 | 行为说明 | 典型使用场景 |
|---|---|---|
| REQUIRED | 有事务就加入,没有就新建 | 默认值,适合业务主流程 |
| REQUIRES_NEW | 无论当前有没有事务,都挂起当前事务并新建一个 | 日志记录、审计追踪,内层提交不受外层影响 |
| NESTED | 有事务则创建保存点,内层回滚只回滚到保存点 | 部分回滚场景 |
| SUPPORTS | 有事务就加入,没有就以非事务方式执行 | 只读辅助方法 |
| NOT_SUPPORTED | 有事务也挂起,以非事务执行 | 不希望事务拖累的查询类操作 |
| MANDATORY | 必须有事务,否则抛异常 | 强制要求调用方开启事务的检查方法 |
| NEVER | 必须没有事务,否则抛异常 | 对事务敏感且有明确非事务要求的操作 |
REQUIRED和REQUIRES_NEW的区别是面试常客。用一个具体场景:订单主流程创建订单后调用库存服务扣库存。如果库存方法用的是REQUIRED,它会直接加入到订单方法所在的事务里,底层共用一个数据库连接和事务上下文;一旦库存方法异常,整个订单创建也会回滚。如果使用REQUIRES_NEW,库存扣减会挂起外层事务,单独提交,即使之后订单方法回滚,库存已经扣掉的记录也不会跟着回滚——这会导致数据不一致,要谨慎设计。
NESTED则聪明一些。外层事务存在时,它会创建一个savepoint(保存点),内层方法失败,只回滚到保存点位置,外层还能决定是继续提交还是整体回滚。这种“部分回滚”的语义,适合那种想在主流程里隔离某个非关键步骤失败影响的场景。注意NESTED依赖底层数据库保存点机制,比如MySQL的SAVEPOINT。
4.3 事务失效的典型场景自查清单
下面这些“事务失效”场景,我几乎每年都会遇到几次,和热词里出现的“springboot事务失效场景”高度一致。
**场景一:同类内部方法直接调用子方法。**这是最经典的一个。Spring的事务控制基于代理对象,外层调用时走的是Spring生成的代理,但如果同一个类里方法A直接调用方法B,B上的@Transactional根本不会经过代理对象,而是从this直接调用。解决方法有三种:把需要事务的方法拆到另外一个Bean里注入调用;使用TransactionTemplate手动控制;或者配置exposeProxy=true后通过AopContext.currentProxy()获取代理对象再调用。
**场景二:事务方法不是public。**Spring默认的@Transactional只对public方法生效,private/protected方法上的注解默认为空配置,切面不会拦截。这不是Spring故意“坑人”,而是动态代理重写方法时对非public方法支持有限。
**场景三:异常被catch吞掉。**前面已经说过,事务本来要回滚,却没收到异常信号。
**场景四:方法抛了受检异常但没有rollbackFor。**Spring默认不处理受检异常,最好显式声明rollbackFor = Exception.class。
**场景五:数据库引擎不支持事务。**比如MySQL把表建成了MyISAM引擎,它本身不支持事务,别说注解了,手动START TRANSACTION都不生效。检查表引擎是排查事务问题的第一步。
**场景六:在多线程子线程里执行数据库操作。**Spring的事务默认绑定当前线程,子线程拿到的是新连接,不在主线程的事务上下文里。想靠父子线程共享事务基本不可能,遇到这种需求最好拆成独立事务,并用分布式方案或消息补偿保证最终一致。
我把这几个典型场景整理成一张自查表,排障时可以直接对照:
| 失效表现 | 可能根因 | 处理思路 |
|---|---|---|
| 中间出错但数据 |
