1. 事务是什么,为什么每个写代码的人都绕不开它
在我多年的开发经历里,几乎每一个系统都离不开“事务”这个基础概念。简单说,事务就是一组操作,这组操作要么全部成功,要么全部失败。你可以把它想象成一个装食物的快递箱——里面的东西要么完好无损地送到,要么就原封不动地退回,绝不允许出现送达时少了一半菜的情况。
比如你在电商平台下单,系统要做的事不止一件:扣库存、生成订单、扣减余额。这三件事必须绑在一起发生。如果扣了库存但订单没生成,或者钱扣了但库存没减,这个系统很快就会变成一场灾难。事务就是用来解决这一连串操作的一致性问题。
这篇内容适合所有在写业务逻辑的开发者,无论是刚入行的新人,还是写了几年 CRUD 的老手,事务的理解深度直接决定了你写的代码在数据异常时是否依然可靠。我一直觉得事务是区分“会写代码”和“能写靠谱代码”的一个分水岭。
数据库系统为了描述事务定义了四个核心特性——原子性、一致性、隔离性、持久性,就是业内常说的 ACID。理解这四个词,是掌握事务的第一步,后面我每一个都会详细展开讲。
事务并不仅存在于关系型数据库中,消息队列、缓存系统、微服务调用链里都能看到事务的影子,只不过形态和应用方式各有不同。这也是为什么把这个概念彻底搞清楚,对任何一个方向的开发都很有价值。
2. ACID 四个核心特性的底层逻辑
2.1 原子性:要么全做,要么全不做
原子性强调一个事务中的所有操作,对系统外部看起来是不可分割的。数据库内部用 undo log 来实现这一点,操作之前先记录一份回滚日志,中间如果出错了,就按照日志把数据反向恢复。
举个例子,你转账 100 块给朋友,系统内部至少执行两条 SQL:一条是把你账户余额减掉 100,另一条是把朋友账户余额增加 100。在执行第二条 SQL 时如果数据库刚好发生异常,原子性会把第一条已经扣掉的钱也回滚,保证两边账户都没变化。
注意:原子性的回滚是针对已经成功执行的写操作,不是把数据库恢复到执行事务之前任意时间点,而是恢复到该事务开始时的状态。这个细节很多人理解有偏差。
很多同学会误以为原子性 = 单线程执行,其实不是。并发环境下多个事务可以同时交错执行,原子性只保证事务内部操作的整体性,不约束外部其他事务的穿插。
2.2 一致性:数据规则永远不能被破坏
一致性这个特性最容易被忽视,因为它不只是一个数据库层面的机制,更多是业务层面的约束。数据库只保证基础的一致性约束,真正复杂的一致性要由开发者自己在代码里定义。
举个例子,你规定“订单状态为已支付时,支付金额必须大于零”,那么任何事务如果不小心写入了金额为负的已支付订单,数据库从一致性角度就应该拒绝。更常见的情况是“账户余额不允许为负数”,这种约束除了字段非负校验,很多时候要靠事务的组合操作来保证——先查余额,再判断,再更新,整个过程必须在事务边界内。
一致性依赖原子性、隔离性共同协作。如果原子性被破坏,操作只执行了一半,那么数据状态就可能违反业务规则;如果隔离性失效,事务看到别人改了一半的数据,基于错误数据做计算,最后写出来的东西同样会破坏一致性。
2.3 隔离性:并发事务之间如何互不干扰
隔离性解决的是多个事务同时读写同一份数据时产生的互相干扰问题。直观感受是,事务的中间状态不应该被其他事务看到。
这里有个经典的概念叫“隔离级别”,数据库提供了四种标准级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能出现 | 可能出现 | 可能出现 |
| 读已提交 | 避免 | 可能出现 | 可能出现 |
| 可重复读 | 避免 | 避免 | 可能(部分数据库已解决) |
| 串行化 | 避免 | 避免 | 避免 |
- 脏读:读到了别的事务还没有提交的数据,一旦那个事务回滚,你读到的数据就成了脏数据。
- 不可重复读:同一个事务内,两次读取同一行数据,结果不一样。原因是有别的事务在中间修改并提交了这行数据。
- 幻读:同一个事务内,两次范围查询返回的记录条数不一样,原因是别的事务插入了新记录。
MySQL 的默认隔离级别是可重复读,并且通过间隙锁等手段,在绝大多数场景下也解决了幻读问题。PostgreSQL 默认是读已提交。Oracle 同样默认读已提交。不同数据库的默认策略不同,这让“隔离级别怎么选”成了开发中一个无法回避的问题。
2.4 持久性:数据库一断电数据就没了,那还玩什么
持久性指事务一旦提交,数据更改就被永久保存下来,即使系统崩溃也不会丢失。数据库用的是重做日志——redo log 机制。
数据实际上先写日志再写数据文件,这个过程叫 WAL(Write-Ahead Logging),写前日志。就像你记手账,怕把本子弄丢,就每次先在小纸条上记下内容,再誊写到本子里。数据库恢复时拿着小纸条把没誊写完的数据重新补上,这样就保证了已提交事务的数据不丢。
实操中,如果磁盘损坏导致日志和数据文件都丢了,那持久性也是无能为力的。所以云厂商和自建机房都会做多副本复制、跨机房容灾,靠的是多副本冗余,而不是单个节点的持久性。
3. 并发事务访问同一份数据时会发生什么
3.1 丢失更新:两个人同时改一条数据
两个事务同时读取了同一行数据,各自在本地改了之后分别提交,后提交的人把先提交的人的修改覆盖了,这就是经典的“丢失更新”问题。
就像两个人同时编辑同一个文档,各自保存,最后保存的那个人把另一个人的内容冲掉了。数据库靠加锁或版本号机制来避免这个问题。
3.2 悲观锁与乐观锁的选择逻辑
悲观锁的思路是“我改数据的时候谁也不许动”,实现方式一般是SELECT ... FOR UPDATE。这种方案简单直接,但并发量高的时候容易造成大量阻塞,导致性能雪崩。
乐观锁的思路是“随便读,提交时检查版本”,实现方式一般是加一个version字段,更新时比对。如果版本不对,说明有人在期间改过了,就拒绝提交。这种方式更适合读多写少的场景。
我维护过的一个库存系统,就是把乐观锁用在商品详情页,读取时不加锁,提交扣减时检查版本号。极端热点商品抢购场景,乐观锁的失败率会比较高,需要配合重试机制。悲观锁更适合金融账户操作这种写冲突本身就很频繁的场景。
3.3 快照读与当前读的微妙差异
MySQL InnoDB 里,普通的SELECT是快照读,不加锁,读到的是事务开始那一刻的历史版本。而SELECT ... FOR UPDATE、UPDATE、DELETE这些操作属于当前读,必须读取最新已提交版本,并且加锁。
这个差异非常关键。同一个事务内,先做一次普通查询,再做一次更新操作,更新会基于最新的数据而不是之前快照里的数据,这就可能导致“明明查出来是这个值,更新完结果却不是预想中那样”的现象。
理解了这层区别,你在排查很多并发问题时就能快速定位:是快照读造成的可重复读,还是当前读加锁竞争导致的阻塞。
4. 事务在代码里到底怎么写,有哪些坑
4.1 声明式事务和编程式事务怎么选
在 Java 生态里最常见的就是 Spring 的@Transactional注解,这就是声明式事务。用起来非常方便,在方法上标注一下,框架自动帮你开启、提交、回滚事务。
但声明式事务最大的坑是“自以为在事务里,其实不是在”。Spring 的事务是基于 AOP 代理实现的,如果调用的是同类内部方法,代理不会生效,事务自然也不会被开启。
这是我最常踩到的坑。
this.saveOrder()在同一个类里被createOrder()调用,无论你在saveOrder()上怎么标 @Transactional,它都不会产生事务效果。正确做法是把事务方法放到另一个被 Spring 管理的 bean 中,或者通过代理对象自调用。
编程式事务则提供了更精细的控制,你可以自己决定事务在哪里开始、在哪里提交或回滚。复杂业务场景,比如在一个方法里需要根据中间结果决定后续操作时,编程式事务更灵活。代价就是代码里多了一些模板化逻辑。
4.2 事务回滚的正确姿势
Spring 声明式事务默认只回滚运行时异常RuntimeException,如果你在事务方法里抛了一个受检异常Exception,事务不会回滚,而是照常提交。这是特别容易踩到的坑。
解决方式有两种:在@Transactional里指定rollbackFor = Exception.class,或者确保业务异常继承自RuntimeException。我个人的习惯是既然统一异常处理,就干脆让所有业务异常都继承自定义运行时异常,这样事务语义最统一。
4.3 事务方法内部 try-catch 会把事务“吃掉”
这也是非常经典的一个问题。很多人喜欢在事务方法里把异常捕获,记个日志,然后返回一个错误码。表面上看起来处理得挺优雅,但事务看你没抛异常,就默认一切正常,就直接提交了。
我见过一个支付回调,里面扣款成功,但后续发通知失败,代码把异常捕获了没往外抛,结果钱扣了,通知没发,用户来投诉说没到账。排查了半天才发现是异常被吞掉导致事务提前提交。这个问题的通用规避思路是:不要在事务方法内部做资源清理或外部调用这类不需要参与事务的操作,要参加事务的业务失败一定要让异常抛出去。
5. 分布式事务为什么比单机事务难那么多
5.1 从本地事务到分布式事务的跨度
当系统从一个数据库扩展成多个服务、多个数据库之后,事务不再是本地资源能解决的问题。服务 A 调用服务 B,A 的数据已经写入了自己的库,B 的库也写了,但 B 后续步骤失败,这时候靠单个数据库的事务已经无法让两边同时回滚了。
分布式事务的核心难点在于“没有全局事务管理器”,每个服务都有自己的本地事务边界。要协调多个服务,就需要额外引入一套协调机制。
5.2 强一致方案:两阶段提交和三阶段提交
两阶段提交,也就是 2PC,是最经典的强一致方案。它的思路是引入协调者,先询问所有参与者能否提交,全部说可以再统一提交,任何一个说不行就全部回滚。
2PC 最大的问题是协调者单点和阻塞。如果协调者在第二阶段宕机,参与者会一直卡住,因为他们不知道该提交还是回滚。三阶段提交(3PC)在中间引入了一个超时机制来减少阻塞窗口,但本质上仍没有完全解决所有问题。在实际业务系统里,2PC 因为性能开销大、可用性短板明显,用的已经不多了。
5.3 最终一致方案:事务消息和本地消息表
实际工程中,更多系统选择的是“柔性事务”,即允许短暂数据不一致,通过消息补偿机制最终达到一致。最典型的实现就是本地消息表和事务性消息。
本地消息表的流程是这样的:
- 开启本地事务,写业务数据,同时写一条“待发送消息”记录。
- 提交本地事务。
- 后台任务不断扫描待发送消息,发送给消息队列。
- 接收方消费消息,处理自己的业务逻辑。
- 如果消费失败,消息队列的重试机制保证最终会成功,或者进入死信队列人工介入。
这套方案的优点是实现简单,不依赖额外的中间件,很多老系统都是这么干的。缺点是消息表和业务数据耦合在一个库里,如果系统拆分得特别彻底,每个服务都要维护自己的消息表,维护成本会上升。
另一种更先进的方案是事务消息,主流消息队列比如 RocketMQ 支持这种模式:先发一个半消息,业务执行完毕再提交确认,业务失败则回滚消息。这样可以做到消息和业务的一致性,但又不用自己建表扫描,实现成本更低。
5.4 最大努力通知和补偿事务
对于很多异步场景,比如支付通知、短信发送,业界也在大量使用“最大努力通知”。意思是系统尽最大可能把消息送达对方,失败了就定时重试,达到一定次数仍失败则告警转人工。这类场景天然允许一段时间内不一致,系统设计上只要保证最终两头数据可以对上就算成功。
补偿事务(Saga)则是把一个长事务拆成一组短事务,每步都有对应的反向操作。比如“创建订单”成功后如果发现库存不足,就执行“取消订单”来补偿。Saga 没有隔离性保障,因此每一步都要考虑并发访问的中间状态,设计上难度不低。
个人经验:做分布式事务一定要先问自己业务能否接受最终一致。把不可能接受最终一致性强拉硬拽的,优先考虑合并写操作或者由底层存储直接保证一致;能接受最终一致的,尽量用事务消息 + 定时对账,而不是盲目上复杂框架。
6. 我在实际项目中总结的事务排查清单
6.1 事务失效的常见场景速查
在排查过不少线上事务问题之后,我整理出了一份自己的速查清单,每当有人问我“我事务为什么没生效”的时候,我都会按这张表逐项确认:
| 问题场景 | 原因 | 解决方式 |
|---|---|---|
| 同类内部方法调用 | 代理未生效 | 拆 bean 或改用编程式事务 |
| 异常被 try-catch 吞掉 | 事务感知不到异常 | 不吞异常或手动回滚 |
| 方法非 public | Spring 无法代理 | 修改可见性 |
| 数据库表不支持事务 | 用了 MyISAM 等引擎 | 改用 InnoDB |
| 事务粒度太大 | 执行时间过长、并发阻塞 | 拆分事务,减少锁持有时间 |
6.2 事务超时与锁等待的排查思路
事务超时一般有两种表现:程序直接报超时异常,或者数据库里锁等待超时。前者要看事务执行了哪些耗时的外部调用,后者要查看数据库当前等待锁的事务和持有锁的事务。
我用过一个很实用的排查手段:查询当前正在运行的事务列表,观察其中的 SQL 执行状态,尤其关注trx_started字段,确认是否有长期未提交的事务。代码里漏写commit或者异常路径没有正确结束事务,都会导致锁越积越多,最终把整个系统的数据库连接耗尽。
6.3 大事务拆分的心得
事务不是越大越好,恰恰相反,事务的持续时间越短越好。一个事务里如果既改订单状态,又发送短信,又调用外部接口,那数据库的锁就被一直攥在手里,其他事务只能排队,系统吞吐量直线下降。
我的习惯是:事务里只做数据修改相关的操作,外部调用、消息发送、文件处理全部放到事务提交之后再执行。必要的时候,可以用事务同步器或者在事务提交后的回调钩子里触发后续动作,确保主事务尽快释放资源。另外,大批量数据更新一定要分批提交,比如每 500 条提交一次,避免单事务积累太多 undo 和 redo 日志,导致数据库 IO 压力变大。
6.4 隔离级别选择建议
我一般建议绝大多数业务系统使用数据库默认的隔离级别,不需要刻意调高。默认级别已经在性能和正确性之间取了平衡。如果你负责的金融支付类系统对一致性要求极高,才考虑是否升级到串行化,但一定要先评估性能影响。
在实际工作中,我用得最多的还是读已提交或者可重复读,通过代码层面的锁、唯一索引、乐观锁等手段解决具体的并发问题,很少单纯依赖隔离级别来解决业务冲突。
7. 事务在消息队列和缓存中的表现形态
7.1 消息队列里的事务消息是干什么的
消息队列中的事务消息和数据库事务并不能完全等同,它的目标是解决“消息发送与本地事务的一致性问题”。流程如下:应用发送一条半消息,队列存储后不投递;应用执行本地事务;本地事务提交则确认消息,队列才开始投递;本地事务回滚则删除半消息。
这个机制打破了先写库还是先发消息的死锁问题。如果先发消息再写库,写了库但消息已经出去会导致消费方的数据提前暴露;如果先写库再发消息,发消息失败则本地库已改,两边不一致。
7.2 缓存与数据库的一致性算不算事务
严格来说,缓存和数据库之间没有原生事务支持。Redis 不支持跨库事务,和数据库保持一致需要额外的方案。最常见的做法是 Cache Aside 模式:先更新数据库,再删除缓存。
如果数据库更新成功但删除缓存失败,会出现老缓存数据被读到的情况。解决思路一是延迟双删,二是给缓存设置过期时间兜底,三是在删除失败时通过消息队列异步重试。我自己偏向用定时对账 + 过期兜底的方式,不是特别追求强一致的话,这个方案成本最低也最稳定。
8. 几个让我印象深刻的线上事故复盘
8.1 因为异常被吞掉,导致用户余额被多扣了一次
某次做订单退款,代码里先把用户账号的余额增加,再调用另一个服务同步流水。服务调用抛了异常,但被 catch 住只记了日志。事务正常提交了,流水还没同步。对账时发现两边数据差了一笔,用户余额变了但系统流水里没记录。
后来我把整个操作改成事务内只更新余额,把流水同步放到事务提交后的回调里,就算同步失败也有单独的重试机制,不再影响事务本身的完整性。
8.2 事务里有外部 HTTP 调用,数据库连接池被拖垮
有一次压测的时候发现系统突然大量报获取连接超时。看监控,数据库线程和连接池全部被打满,最后定位到刚上线的一个功能,在事务里调了第三方 HTTP 接口。接口响应慢,事务迟迟不提交,连接被一直占着,后面的请求全部等着拿连接。
从那以后我给自己立了一条规矩:凡是事务方法里出现外部网络调用,都要停下来想想能不能移到事务外面,或者改成异步。
8.3 可重复读隔离级别下的“余额覆盖”问题
还有一个典型案例是:事务 A 读取余额为 100,事务 B 在 A 读取之后把余额改成 50 并提交,事务 A 再在余额上增加 30,覆盖了原来的值,导致最终余额变成 130,B 的修改丢失了。
这个问题的本质是丢失更新,而不是脏读或不可重复读。解决方式很多,直接的办法就是更新时使用原子操作UPDATE account SET balance = balance + 30 WHERE id = ?,避免读改写三段式操作。不要让 Java 代码先读再算再写,要让数据库在一次更新里完成算术。
9. 那些事务文档里不会告诉你的细节
事务相关的技术文档到处都是,但很多真正重要的经验往往只有踩过坑才知道。
第一,事务不是越频繁提交越好。频繁提交会生成大量 redo 日志,拉高 IO 压力。这里说的是“一个业务操作多少次提交”,和前面说的大事务拆分并不矛盾。拆分是让一个事务的边界短平快,不是让一个业务逻辑碎成无数个微型事务。
第二,索引与事务性能直接相关。更新操作会让数据库行锁升级甚至间隙锁,锁的范围取决于 SQL 的扫描范围。如果你的UPDATE语句没有走索引,数据库为了锁定目标行不得不扫描大量记录,锁定范围会变得非常大,这在高并发下几乎是致命的。因此,事务中高频更新的表一定要设计好索引,最好能做到行级精确命中。
第三,事务的回滚并不便宜。回滚要依据 undo log 反向执行,数据量越大回滚越慢。有些极端情况下,一个跑了十分钟的大事务回滚可能要花更久。所以除了保证逻辑正确,还得控制每个事务影响的数据量。
第四,监控事务状态是保障系统健康的重要手段。我常用下面这条 SQL 查看是否存在长时间未提交的事务:
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX ORDER BY trx_started ASC;如果发现trx_started很早的事务,基本可以断定业务代码有问题。日常巡检把这个列为必看项,很多潜在故障能在爆发前被发现。
10. 我现在设计一个涉及数据一致性的模块时会怎么思考
这些年下来,我面对一个需要保证数据一致性的需求,形成了固定的思考路径,分享出来供大家参考。
第一步是分析业务的核心问题,确认哪些操作必须在同一份数据上原子完成。如果只是单库单表,直接本地事务就够了,不折腾别的。
第二步是识别操作是否跨库、跨服务。如果跨了,就要判断能不能接受最终一致。这里没有什么银弹,需要业务方明确给出“多长时间内一致可以接受”这个关键参数。
第三步是选择方案。单库内强一致用本地事务加合适隔离级别;跨库弱一致优先消息表;对实时性要求高的场景则要评估是否有必要引入更重的分布式事务基础设施。
第四步是设计补偿与对账流程。没有对账机制的系统就像没有保险丝的电表,迟早出问题。最简单的做法是把核心操作日志落表,定时任务定期比对不同系统的数据,发现差异就告警并尝试自动修复。
从我个人的经验看,如何正确使用事务,核心就三句话:事务边界越短越好,外部调用不进事务,失败必须让事务感知到异常。剩下的无非是在面对分布式问题时,选择好与自己系统匹配的一致性方案,而不是盲目追求所谓最强一致。