分布式事务
分布式事务
从本地事务到分布式事务
什么是事务?
想象一个我们在日常开发中遇到过无数次的场景:
你想给朋友转账 100 块钱。在后台的数据库里,这其实对应着两步简单的操作:
- 你的账户余额扣减 100 元。
- 你朋友的账户余额增加 100 元。
这看似完美,但如果在执行完第一步后,服务器突然宕机了,或者网线被拔了,会发生什么?你的钱扣了,但朋友没收到。这 100 块钱凭空消失了!
为了防止这种“人间惨剧”发生,数据库的前辈们引入了一个极其重要的概念:事务(Transaction)。
那究竟什么是事务?
简单来说,事务就是把一组操作“捆绑”在一起,变成一个不可分割的执行单元。对于这个单元里的所有操作,只有两种结果:要么全部成功(Commit),要么全部失败并回退到最初的状态(Rollback)。绝对不允许出现“只执行了一半”的中间状态。
在单机 KV 存储引擎时代,为了保证这种绝对的可靠性,一个合格的事务必须具备四个“标准”,也就是我们常说的 ACID 特性:
- A (Atomicity) 原子性:
事务里的所有操作,就像一个原子一样不可再分。要么全部成功,要么全部失败。
- C (Consistency) 一致性:
事务执行前后,业务的规则必须是一致的。以转账为例,无论怎么转,你和朋友两人的账户总余额在事务执 行前后必须是一模一样的,钱不能凭空产生或消失。
- I (Isolation) 隔离性:
在真实的高并发场景下,可能同时有几十个人在互相转账。隔离性保证了多个并发执行的事务之间不会互 相“串门”和干扰。
- D (Durability) 持久性:
一旦事务提示你“提交成功”,这笔修改就必须永久记录在硬盘上。哪怕下一秒机房断电,重启后这笔数据也绝 对不能丢。
为什么需要分布式事务?
在单体应用时代,业务逻辑全在一个工程里,所有数据都存在一个由单机数据库中。开发者只需要敲下 BEGIN 和 COMMIT,数据库就会像一个尽职尽责的管家,把 ACID 安排得明明白白。
**但是,随着业务规模的爆炸式增长,我们不得不开始对系统进行“拆分”。**这一拆,就把原本完美的单机事务拆得七零八落。主要面临两大场景的挑战:
1. 架构微服务化(服务被拆了)
假设我们正在开发一个电商系统,随着业务变复杂,系统被拆分成了“订单服务”和“库存服务”,并且每个微服务都有自己独立的数据库(Database-per-service 模式)。 当用户点击“下单”时,背后需要做两件事:
- 订单服务: 在自己的数据库里创建一条订单记录。
- 库存服务: 在自己的数据库里扣减相应的商品库存。
这时候问题来了:本地事务只能控制自己的数据库。 如果订单数据库提交成功了,但在通过 RPC/HTTP 调用库存服务时,网络突然抖动导致超时,或者库存服务刚好宕机了。结果就是:订单生成了,但库存没扣。这在电商业务中是绝对不能容忍的“超卖”事故。
2. 数据分库分表与分布式存储(数据被拆了)
哪怕你的服务没有拆分,当单表数据量达到千万级、亿级时,单机数据库的性能也会遭遇瓶颈。我们必然会走向分库分表,或者直接拥抱分布式的存储系统(比如分布式的关系型数据库,或者是底层的分布式 KV 存储引擎)。
一旦数据被分散到了不同的物理节点上,比如你要同时修改落在节点 A 的数据 X 和落在节点 B 的数据 Y。网络是不可靠的,节点也是随时可能宕机的,谁来保证对节点 A 和节点 B 的修改的原子性?单机事务对此无能为力。
不管是微服务拆分,还是分布式存储,本质上都是原本在一个内存、一个硬盘里能搞定的事情,现在必须通过“跨网络的网络通信”来协同完成。
所以为了保证数据一致性原则,我们今天要讲的——分布式事务(Distributed Transaction)。
分布式事务的妥协与权衡
CAP 定理
CAP 定理指出,在一个分布式系统中,以下三个特性最多只能同时满足其中两项:
-
C (Consistency) 一致性:
这里的 C 指的是“强一致性”或“线性一致性”。简单来说,就是所有节点在同一时间看到的数据必须是一模一样的。如果向节点 A 写入了最新的余额 100 块,紧接着去查节点 B,也必须立刻查出 100 块。
-
A (Availability) 可用性:
指系统提供的服务必须一直处于可用状态。对于用户的每一个请求,系统都必须在有限的时间内给出非错误的响应(哪怕这个节点上的数据不是最新的)。
-
P (Partition tolerance) 分区容错性:
当节点之间的网络断开,形成独立的“网络分区”(孤岛)时,系统仍然能够继续运作。
残酷的现实:只能二选一(CP 或 AP)
在真实的物理世界里,会出现各种问题,路由器会重启,网络故障(P)是必然会发生的客观事实。 当网络发生分区时(节点 A 和 B 失联了),你面临一个艰难的选择:
- 选择 CP(放弃可用性): 为了保证 A 和 B 数据一致,在网络恢复前,系统干脆拒绝所有的写入请求。系统报错,用户体验极差,但数据绝对安全(常见于底层分布式数据库、Zookeeper 等)。
- 选择 AP(放弃强一致性): 为了让用户能继续用,A 和 B 各自处理各自的请求。结果就是 A 里的余额变成了 50,B 里的余额变成了 200,数据出现了不一致(常见于高并发的电商网站、社交媒体缓存等)。
BASE 理论
既然在庞大的微服务和分布式系统中,我们无法保证强一致性(CP),那业务还做不做了?肯定不行
于是,架构师 Dan Pritchett 提出了 BASE 理论,这是对 CAP 中 AP 方案的一个延伸。
-
BA (Basically Available) 基本可用: 系统出现故障时,允许损失部分可用性,保证核心功能可用。比如双十一流量洪峰时,淘宝可能会把“退款功能”暂时降级,优先保证“下单购买”的顺畅。
-
S (Soft State) 软状态: 允许系统中的数据存在“中间状态”(也就是数据不一致的状态),并且认为这种中间状态不会影响系统的整体可用性。比如转账时,允许出现“A 扣了钱,但 B 还没收到钱”的短暂瞬间。
-
E (Eventual Consistency) 最终一致性: 这是 BASE 的核心!系统不保证任何时刻数据都是一致的,但承诺在经过一段时间的同步后,所有节点的数据最终会达到一致状态。
如果说单机的 ACID 事务是个**“刚性事务”,要么全对要么全错; 那么基于 BASE 理论的分布式事务,就是一个“柔性事务”**,它学会了妥协:允许中间有误差,允许大家慢慢同步,只要“最终结果是对的”就行。
主流解决方案盘点
2PC 与 3PC
为了在多个节点之间达成原子性的共识,我们需要引入一个核心角色:协调者(Coordinator)。其他的数据库节点则被称为参与者(Participant)。
1. 2PC(两阶段提交):最经典的强一致性方案
2PC (Two-Phase Commit) 将事务的提交过程分成了两个阶段:准备阶段(Prepare) 和 提交/回滚阶段(Commit/Rollback)。
- 第一阶段:Prepare(投票阶段)
协调者向所有参与者发送 Prepare 请求:“大家准备好提交事务了吗?” 参与者收到后,开始执行本地事务,写好 Redo 和 Undo 日志,但是不提交。如果本地执行成功,回复“Yes”;如果执行失败(比如余额不足),回复“No”。
- 第二阶段:Commit / Rollback(执行阶段)
协调者收集所有人的选票。
全票通过: 如果所有人都回复了“Yes”,协调者下达 Commit 命令,大家正式提交,释放资源。
一票否决: 只要有一个人回复“No”,或者有人超时没回复,协调者就会下达 Rollback 命令,大家根据 Undo 日志回滚刚才的操作。
2PC 的优点: 原理非常简单,逻辑清晰,并且在绝大多数情况下,能够严格保证分布式系统下的强一致性。
**2PC 的致命不足:**但在高并发的互联网场景下,2PC 简直是灾难。
同步阻塞,性能极差: 在整个两阶段过程中,所有参与者的数据库资源都被一把大锁死死锁住。别人想读写这些数据?对不起,排队等着。这在秒杀场景里是不可接受的。
单点故障: 协调者是整个系统的“大总管”。如果第一阶段结束后,协调者突然宕机了,所有的参与者都会傻傻地锁着资源,永远等不到第二阶段的指令,直接导致系统瘫痪。
数据不一致(脑裂): 如果在第二阶段,协调者发出了 Commit 指令,但刚发给一半的节点,网络断了。这就导致收到指令的节点提交了,没收到的节点还在阻塞,数据彻底不一致了。
2. 3PC(三阶段提交):2PC 的修补版
为了解决 2PC 的同步阻塞和单点故障问题,学术界提出了 3PC 。它把 2PC 的第一阶段又拆成了两步,变成了:CanCommit、PreCommit、DoCommit。
3PC 做了两个核心改进:
-
增加超时机制: 不仅协调者有超时机制,参与者也有了超时机制。如果参与者在最后阶段迟迟等不到协调者的命令,它会“自作主张”地把事务提交掉,不再无限期死锁。
-
引入 CanCommit 阶段: 第一步只是个极其轻量的“口头确认”(不锁资源),确认大家网络都通畅,再进入实质性的锁定阶段。
3PC 的优缺点:
- 优点: 缓解了 2PC 的同步阻塞问题,解决了协调者宕机导致的参与者无限期死锁。
- 致命不足: 它太复杂了!而且它依然没有解决网络分区导致的数据不一致问题。如果参与者因为网络超时自己做主提交了事务,但其实协调者原本的指令是回滚,那数据还是错乱的。

那2PC/3PC都有自己的问题,那我们怎么解决这些问题呢,对此提出了一个用代码改变世界的智慧----TCC
TCC
既然让数据库自己去锁资源(2PC)会导致性能急剧下降,那我们能不能把控制权收回来,交给我们自己写的业务代码来管理?
这就是 TCC (Try-Confirm-Cancel) 模式的核心思想。它不再依赖底层数据库的强锁,而是要求开发者针对每一个业务操作,都手动写出三个对应的方法。它是一种典型的补偿型事务。
1. Try(预留/检查阶段): 这是最关键的一步。它不做真正的业务提交,只做两件事:业务检查(看余额够不够)和 资源预留(把要扣的钱先“冻结”起来)。
- 场景: A 给 B 转账 100 块。
- Try 动作: 检查 A 账户有没有 100 块。如果有,把这 100 块标记为“冻结/不可用”状态。注意,此时 A 的总余额其实还没变,只是可用余额少了 100。
2. Confirm(确认执行阶段): 当所有参与者的 Try 都成功后,协调者(通常是 TCC 框架)就会调用所有节点的 Confirm 方法。
- Confirm 动作: 真正扣除 A 账户里被冻结的那 100 块,同时给 B 账户加上 100 块。因为 Try 阶段已经把资源预留好了,所以理论上 Confirm 阶段是一定会成功的。
3. Cancel(取消/回滚阶段): 如果在 Try 阶段,有任何一个节点失败了(比如 B 账户状态异常),或者网络超时了,协调者就会调用所有节点的 Cancel 方法。
- Cancel 动作: 把 A 账户刚才“冻结”的那 100 块钱解冻,恢复可用余额。一切就像没发生过一样。

对比 2PC,TCC 最大的优势就是性能极高。因为 Try 阶段的“冻结”仅仅是业务数据状态的改变(比如更新了某个字段的值),它不需要在数据库层面一直持有一个长事务的锁。别的事务依然可以正常读取和修改该用户的其他未冻结资金,并发能力瞬间拉满。
虽然 TCC 性能好,但它是拿开发者的头发换来的。因为你原本只需要写一个 transfer() 方法,现在被迫要写 tryTransfer(), confirmTransfer(), cancelTransfer() 三个方法。业务侵入性极强!
TCC 是一种“重武器”,虽然配置繁琐、代码量大,但它是应对强一致性要求极高的核心业务链路(比如资金扣减、核心库存扣减、积分结算)的最佳选择。只要涉及到钱,哪怕多写几倍的代码,也是值得的。
可靠消息最终一致性
在互联网的高并发场景下,我们最喜欢做的事情就是异步和解耦。
比如,用户下单成功后,我们不需要让用户在屏幕前傻等着系统去依次调用“积分服务”和“短信服务”。我们只需要在订单系统里抛出一个消息:“订单已生成!”,然后让其他服务自己去订阅这个消息慢慢处理就好了。
这听起来很完美,但里面隐藏着一个经典的**“双写难题”**:本地数据库的事务,和发送 MQ(消息队列)消息,这两步操作怎么保证“同生共死”?
- 先写数据库,再发消息? 如果数据库写成功了,但在发消息的那一瞬间服务器宕机了。结果就是:订单生成了,但积分永远没发。
- 先发消息,再写数据库? 如果消息发送成功了,但随后本地数据库写入失败回滚了。结果就是:积分白白发了,但根本没这笔订单。
为了解决这个原子性问题,业界总结出了两种最经典的玩法:
1. 朴实无华但极度好用:本地消息表
这是当年 eBay 提出来的经典方案,它的核心思想非常巧妙:既然跨系统的状态很难保持一致,那我们就把它转化为单机的事务!
具体怎么做呢? 在订单系统的数据库里,除了原本的 Order 表,我们再新建一张 Message表。 当用户下单时,我们开启一个本地事务,在写入订单数据的同时,也往 Message 表里写入一条状态为“未发送”的消息记录。因为它们在同一个数据库里,所以单机的 ACID 就能完美保证它们要么同时成功,要么同时失败!
接下来,我们只需要写一个后台定时任务(或者监听 Binlog),不断去扫描 Message 表里“未发送”的消息,把它们投递到 MQ 里。如果投递成功,就把状态改成“已发送”;如果投递失败,MQ 本身的机制会不断重试,直到成功为止。
2. 更优雅的终极方案:RocketMQ 事务消息
本地消息表虽然好用,但它有一个缺点:你的业务数据库里混入了与业务无关的消息数据,而且定时任务扫描数据库也会消耗一定的性能。
于是,像阿里开源的 RocketMQ 就直接在中间件层面支持了“事务消息”。它引入了**“半消息(Half Message)”**的概念:
- 发送半消息: 订单系统先给 MQ 发送一条“半消息”。这条消息 MQ 收到了,但暂时不会让下游(积分服务)看到。
- 执行本地事务: 半消息发送成功后,订单系统开始执行本地的下单数据库操作。
- 提交或回滚: 如果本地事务成功,就告诉 MQ“提交”这条消息,下游就能看到了;如果失败,就告诉 MQ“回滚”,这条消息就被直接销毁。
- 回查机制: 如果第 3 步因为网络断了,MQ 一直没收到提交或回滚的指令怎么办?RocketMQ 会主动发起“回查”,反向询问订单系统:“老哥,你刚才那个本地事务到底成没成啊?”从而确保最终状态的一致。
看完了这么多方案,你可能想问:“到底哪种方案才是最完美的?”
分布式事务所有的方案不过是在 一致性(Consistency)、可用性(Availability)、性能(Performance)以及开发成本 之间做妥协和平衡。
很多时候,我们可以通过合理地划分微服务边界,把原本跨服务的操作合并到一个服务、一个单机数据库里,用最原始的本地事务(ACID)去解决。因为引入任何一种分布式事务机制,都会带来极高的运维成本和测试复杂度。


