CMU15-445 数据库系统播客:OLTP 与分布式事务

在互联网浪潮席卷全球的今天,从电商购物到社交分享,从在线支付到流媒体服务,数据以前所未有的规模和速度产生。传统的单体数据库,尽管稳定可靠,却早已在海量并发和高可用性的要求面前显得力不从心。分布式数据库,通过将数据和计算负载分散到多台机器上,成为了支撑现代应用不可或缺的基石。

然而,将数据“分而治之”的同时,也引入了前所未有的复杂性。当一次操作需要跨越多个网络节点时,我们如何确保它像在单台机器上一样可靠?当某个节点突然宕机时,系统如何继续提供服务而不丢失数据?

本文将带你深入分布式在线事务处理(OLTP)数据库的核心,从最基础的事务概念出发,层层剖析分布式环境下的原子性、一致性与可用性挑战,探讨从二阶段提交(2PC)、共识协议(Paxos/Raft)到最终指导所有设计的 CAP 定理,理解构建一个健壮分布式系统背后所必须做出的深刻权衡。

为什么我们离不开事务(Transaction)?

在深入“分布式”之前,我们必须先回到原点: 事务(Transaction) 。事务是数据库管理系统执行过程中的一个逻辑单位,它将一系列操作打包,确保它们要么 全部成功 ,要么 全部失败 。这种“全有或全无”的特性,就是我们熟知的 原子性(Atomicity) ,也是 ACID(原子性、一致性、隔离性、持久性)四大特性的基石。

这种保障在 在线事务处理(OLTP) 场景中至关重要。OLTP 系统的特点是处理大量、短促、高频的读写请求。想象一下你在电商网站下单的场景:

创建订单扣减商品库存从你的账户扣款增加商家账户余额

这是一个典型的 OLTP 事务。如果系统在第 3 步成功后、第 4 步执行前崩溃,你的钱被扣了,但商家没收到,这将是一场灾难。事务确保了这四个步骤被视为一个不可分割的整体,从而维护了系统的数据一致性。

在分布式环境中,这四个步骤可能发生在不同的服务器上(订单服务、库存服务、支付服务)。挑战也因此升级:我们如何协调散布在各地的节点,让它们对同一个事务的最终结果(提交或中止)达成一致的决定?

用二阶段提交(2PC)实现分布式原子性

为了解决跨节点的原子性问题, 原子提交协议(Atomic Commit Protocol) 应运而生,其中最经典、最广为人知的就是 二阶段提交(Two-Phase Commit, 2PC) 。

2PC 引入了一个 协调者(Coordinator) 的角色,来统一指挥所有参与该事务的节点,即 参与者(Participants) 。顾名思义,整个过程分为两个阶段:

阶段一:准备阶段(投票阶段)询问(Request) : 协调者向所有参与者发送一个“准备提交(Prepare)”的消息,询问它们是否可以提交事务。表态(Vote) : 每个参与者会检查本地的事务是否可以成功(例如,是否满足约束、日志是否已写入持久化存储)。如果可以,它就锁定相关资源,并将“可以提交(VOTE-COMMIT)”的响应发给协调者。如果不行(例如,发生本地错误),它就直接发送“请求中止(VOTE-ABORT)”的响应。阶段二:提交阶段(决策阶段)决策(Decision) : 协调者收集所有参与者的投票。如果所有参与者 都回复“可以提交”,协调者就做出“全局提交(GLOBAL-COMMIT)”的决定,并向所有参与者发送提交消息。只要有一个参与者 回复“请求中止”,或者有参与者超时未响应,协调者就做出“全局中止(GLOBAL-ABORT)”的决定,并向所有参与者发送中止消息。执行(Execution) : 参与者根据协调者的最终决定,完成本地事务的提交或回滚,并释放资源。2PC 的致命缺陷:阻塞

2PC 用一种简单民主的方式(一票否决)保证了原子性,但它存在一个致命的缺陷: 同步阻塞 。

协调者单点故障 :如果在第二阶段,协调者发出决策后、所有参与者确认前宕机,那么所有参与者都会被“卡住”。它们不知道最终的决定是提交还是中止,只能无限期地等待协调者恢复。这期间,事务所占有的资源无法释放,整个系统可能因此瘫痪。参与者故障 :如果某个参与者在第一阶段后宕机,协调者将无法收齐所有投票,导致整个事务超时并最终中止。即使只是网络缓慢,也会导致协调者长时间等待,拖慢整个系统的性能。

正是因为 2PC 的脆弱性,它在对可用性要求极高的现代系统中已逐渐被更健壮的协议所取代。

从 Paxos 共识到 Raft 的普及

2PC 的问题在于,决策权过于集中且需要所有节点达成一致。有没有一种方法,即使少数节点失联,系统也能继续运转?答案就是 共识协议(Consensus Protocol) 。

与 2PC 的“全票通过”不同,像 Paxos 和 Raft 这样的共识协议遵循 “少数服从多数” 的原则。它们的目标是让分布式节点集群就某个值(例如,事务是提交还是中止)达成不可撤销的一致。

与 2PC 的核心区别在于:共识协议只需要集群中超过半数(Majority)的节点达成一致,就可以做出决策。

这意味着,在一个由 5 个节点组成的集群中,只要有 3 个节点正常工作并达成一致,系统就能继续处理请求,它可以容忍最多 2 个节点的失效。这极大地提高了系统的容错能力和可用性,彻底解决了 2PC 的阻塞问题。

Paxos : 由莱斯利·兰波特提出,是共识协议的鼻祖,以严谨但难以理解著称。为了解决多个提议者可能导致“活锁”(livelock)的问题,工程实践中通常采用其变种 Multi-Paxos ,即选举一个 领导者(Leader) 来统一发起提议,从而提高效率。Raft : 由斯坦福大学的学者设计,其目标就是提供与 Paxos 相同的容错保证,但更容易被理解和实现。Raft 通过明确的领导者选举、日志复制等步骤,使得共识过程更加清晰,如今已在 TiDB、etcd、CockroachDB 等众多知名项目中得到广泛应用。

高可用的基石:数据复制策略

解决了事务的决策问题后,我们还需要考虑数据的物理安全。如果存储数据的唯一节点宕机,数据就会永久丢失。为此, 数据复制(Replication) 成为分布式系统的标配。通过将数据存储多个副本,即使部分节点失效,系统依然可以依赖其他副本继续提供服务。

最常见的复制模型是 主从复制(Leader-Follower Replication) :

写入流程 : 所有写入请求都必须发送到 主节点(Leader) 。主节点完成写入后,负责将数据变更日志同步给所有 从节点(Followers) 。读取流程 : 读取请求既可以由主节点处理(保证读到最新数据),也可以为了分流而发送到从节点(可能读到稍有延迟的数据)。故障转移 : 当主节点宕机时,系统会通过共识协议(如 Raft)在从节点中选举出一个新的主节点,接管所有写入流量,实现自动故障恢复。

在这个复制过程中,一个关键的权衡点在于:主节点何时向客户端确认“写入成功”?这直接决定了系统的一致性模型。

同步复制(Synchronous Replication)机制 : 主节点必须等待 所有 (或指定数量的)从节点确认已成功接收并持久化日志后,才向客户端返回成功。保障 : 强一致性(Strong Consistency) 。一旦写入成功,任何后续的读取请求(无论访问哪个节点)都保证能看到最新的数据。不会有数据丢失的风险。代价 : 性能较低,因为事务的延迟取决于最慢的那个从节点。场景 : 金融交易、核心订单系统等对数据正确性要求零容忍的场景。异步复制(Asynchronous Replication)机制 : 主节点将日志发送出去后, 无需等待 从节点的确认,立即向客户端返回成功。保障 : 最终一致性(Eventual Consistency) 。数据最终会同步到所有副本,但在主节点宕机且数据尚未同步完成的极端情况下,可能会丢失少量“已提交”的数据。代价 : 数据安全性稍弱,但写入延迟极低,吞吐量高。场景 : 社交媒体点赞、发布评论等对性能和可用性要求高,但能容忍秒级数据延迟的场景。

CAP 定理的权衡艺术

至此,我们讨论了原子性、可用性、一致性等多个维度。而将这些概念统一在一个框架下的,就是分布式系统领域著名的 CAP 定理 。

该定理指出,一个分布式系统在以下三个核心特性中, 最多只能同时满足两个 :

一致性 (Consistency, C) : 所有节点在同一时间访问到的数据是完全一致的。这里的一致性通常指最严格的线性一致性(Linearizability),即所有读操作都能获取到最近一次写入的最新数据。可用性 (Availability, A) : 任何来自客户端的请求,无论成功或失败,系统都能在有限时间内给出响应。简单说,就是系统永远是“活的”。分区容错性 (Partition Tolerance, P) : 系统在遇到网络分区(即节点间的网络连接中断,导致集群被分割成多个孤岛)时,仍然能够继续运行。

在现代分布式系统中,网络故障是常态而非例外,因此 P(分区容错性)是必选项 。这意味着,我们必须在 C(一致性) 和 A(可用性) 之间做出选择。

选择 CP (Consistency / Partition Tolerance)

当网络分区发生时,为了保证数据的一致性,系统会 牺牲可用性 。通常的做法是,被分割出去的少数节点会停止服务,只有拥有“法定人数”(Majority)的主分区会继续处理请求。这避免了“脑裂”(Split-Brain)问题,即不同分区产生相互冲突的数据。代表系统 : 大多数关系型数据库、NewSQL 数据库如 Google Spanner, TiDB, CockroachDB。它们优先保证数据绝不出错。

选择 AP (Availability / Partition Tolerance)

当网络分区发生时,为了保证系统的可用性(每个分区都能响应请求),系统会 牺牲一致性 。所有分区会继续独立处理读写请求。这意味着在分区期间,你写入一个分区的数据对另一个分区是不可见的。当网络恢复后,系统需要通过复杂的冲突解决机制来合并这些差异数据,最终达到一致。代表系统 : 大多数 NoSQL 数据库如 Amazon DynamoDB, Cassandra。它们优先保证服务永远在线,即使数据可能暂时不一致。

深度拓展(一):MySQL 内部的“二阶段提交”—— redo log 与 binlog 的双重奏

读到这里,经验丰富的后端工程师可能会联想到一个非常熟悉的场景:MySQL 中 redo log 和 binlog 的协同工作。这其实就是二阶段提交(2PC)思想在 单机系统内部、跨组件协调 中的一个绝佳应用。它与我们之前讨论的、用于多台机器间的分布式事务 2PC,在思想上同源,但在应用范畴和目标上有所不同。

为何需要这个内部 2PC?

首先,我们必须明确两个日志的核心职责:

redo log (重做日志) : 这是 InnoDB 存储引擎层面的日志。它记录了对数据页(Page)的物理修改,保证了事务的 持久性(Durability) 和 崩溃安全(Crash Safety) 。即使数据库异常宕机,InnoDB 也可以通过 redo log 恢复到宕机前的状态。binlog (二进制日志) : 这是 MySQL Server 层面的日志。它记录了所有修改数据的逻辑操作(SQL 语句或行的变更),主要用于 主从复制(Replication) 和 数据恢复 。从库通过拉取并回放主库的 binlog 来实现数据同步。

核心矛盾在于 :一个事务的提交,既要确保在 InnoDB 内部是持久的(redo log 写入),也要确保能被从库正确复制(binlog 写入)。如果这两个操作不是原子的,一旦在它们之间发生宕机,就会导致主从数据不一致的灾难:

场景A:先写 redo log,再写 binlog 。如果 redo log 写完后、binlog 写入前宕机。主库重启后通过 redo log 恢复了数据,但 binlog 里没有这次变更。结果就是,从库永远收不到这次更新,主从数据产生永久性差异。场景B:先写 binlog,再写 redo log 。如果 binlog 写完后、redo log 写入前宕机。主库重启后由于没有 redo log 记录,会回滚这个事务,数据没有变化。但 binlog 已经记录了这次变更并可能已传给从库。结果是,从库比主库“多”了一次更新,数据再次不一致。MySQL 的解决方案:内部 2PC

为了解决这个“跨组件原子写入”的问题,MySQL 巧妙地引入了内部 2PC:

准备阶段 (Prepare Phase)

当客户端执行 COMMIT 时,InnoDB 引擎会将事务的所有变更写入 redo log,并将该事务标记为 “准备(prepare)” 状态。此时,事务并未真正提交。

提交阶段 (Commit Phase)

MySQL Server 层接收到 InnoDB 的“准备好了”信号后,会将该事务的变更写入 binlogbinlog 成功写入磁盘后,Server 层会调用 InnoDB 的接口,通知其完成事务的最终提交。InnoDB 收到指令后,将 redo log 中对应的事务状态从“准备”修改为 “提交(commit)” 。

这个过程确保了 redo log 和 binlog 的内容是逻辑一致的。即使在任何一个步骤之间发生崩溃,MySQL 在重启时都有明确的恢复策略:

如果一个事务在 redo log 中是 “prepare” 状态,但在 binlog 中 找不到 ,说明崩溃发生在第一阶段之后、第二阶段之前。恢复时, 回滚 该事务。如果一个事务在 redo log 中是 “prepare” 状态,并且在 binlog 中 能找到 ,说明崩溃发生在第二阶段 binlog 写完之后、redo log commit 之前。恢复时, 提交 该事务。

通过这种方式,MySQL 保证了任何一个成功提交的事务,其 redo log 和 binlog 必然是同时存在的、完整的,从而为主从复制的正确性提供了坚实的基础。这与分布式 2PC 用于协调多个独立节点的思路异曲同工,都是为了确保一个逻辑操作在不同参与方之间的原子性。

深度拓展(二):现代高并发架构 —— MySQL、Raft 与分片的“三位一体”

了解了 MySQL 的内部机制后,我们再将视角拉回宏观架构。大型互联网公司面对每秒数万甚至数十万的请求,是如何基于 MySQL 构建高可用、高并发的存储服务的?答案并非单一技术,而是一个由 数据复制、共识算法和智能代理 组合而成的精密体系。

传统的 MySQL 主从复制(一主多从)虽然能通过读写分离分摊负载,但其“软肋”也十分明显:

故障转移(Failover)是手动的或依赖脚本 :主库宕机后,需要 DBA 或自动化脚本介入,选择一个从库提升为新主库,并修改应用配置。这个过程耗时且容易出错。存在“脑裂”风险 :在自动切换的方案中,如果因为网络问题导致主库“假死”,可能会产生两个主库,造成数据冲突。

为了解决这些问题,现代架构通常采用 基于 Raft 共识的 MySQL 高可用方案 ,如官方的 MySQL Group Replication (MGR) 或开源的  Orchestrator ,以及在此之上的代理层(如 ProxySQL )。

架构蓝图:共识算法赋能的 MySQL 集群

一个典型的高可用 MySQL 集群架构如下:

数据层 : 由一组(通常至少 3 个)安装了 MySQL 实例的服务器组成。它们之间通过 Group Replication 插件进行通信。共识层 : Group Replication 内部集成了一个 Raft 变种的共识协议 。这个协议不负责同步业务数据(业务数据同步依然依赖 binlog),而是专门负责 集群成员管理、主节点选举和维护集群元数据的一致性 。代理/路由层 : 应用并不会直接连接 MySQL 实例,而是连接一个轻量级的智能代理(如 ProxySQL)。这个代理了解整个 MySQL 集群的拓扑结构和节点角色(谁是主,谁是从),并负责:自动读写分离 : 将所有写请求(INSERTUPDATEDELETE)自动路由到唯一的主节点。读负载均衡 : 将读请求(SELECT)根据策略分发到多个从节点。故障感知与无缝切换 : 实时监控集群状态。一旦共识层选举出新的主节点,代理会立刻感知到变化,并将新的写请求无缝地转发到新主库,对应用层完全透明。工作流程与高并发应对

正常运行 : Raft 协议确保集群中有且仅有一个主节点。所有写操作都经过代理,汇集到主节点。主节点通过 半同步复制(Semi-Synchronous Replication) (一种强化的复制模式,要求至少一个从库确认收到日志后才向客户端返回成功)将 binlog 同步给从库,保证了数据的高一致性(CP 倾向)。同时,海量的读请求被代理分发到所有从库,实现了读能力的水平扩展。

主库故障 :

集群内的节点通过心跳检测发现主库失联。共识模块立即触发新一轮的 领导者选举 。存活的从库节点通过 Raft 协议,在数秒内投票选举出一个数据最新的从库成为新主库。代理层检测到主库变更,立即将流量切换到新主库。整个过程 快速、自动,无需人工干预 ,极大地提升了系统的可用性(Availability)。

应对极致并发写入:分库分表(Sharding)

当单一主库的写入性能达到瓶颈时,上述架构将作为“一个单元”被水平复制。这就是 分库分表(Sharding) 。数据被按照某个维度(如用户 ID、订单 ID)切分成多个分片(Shard),每个分片都是一个独立的、由 Raft 管理的高可用 MySQL 集群。代理层(或更上层的服务治理框架)会根据请求中的 Sharding Key,将其路由到对应的集群。通过这种方式,系统的写入能力可以随着分片的增加而近乎线性地扩展。

综上所述,现代 MySQL 高并发架构,正是我们博文中所述理论的完美实践:

它使用 Raft 共识算法 解决了集群的 选主和故障转移 问题,提供了 CP 级别的元数据一致性保障。它使用 主从复制 作为 数据同步 的手段,并可在 强一致(半同步) 和 高性能(异步) 之间灵活配置。它通过 读写分离 和 分库分表 ,将负载分散到庞大的集群中,实现了强大的 横向扩展 能力。

这是一个将共识理论、数据复制模型和业务扩展策略有机结合的、优雅而强大的工程范例。

分布式事务解析(一):为何分布式事务不可避免?

当提到微服务、分库分表等架构时,必然会追问:“那如何处理跨服务/跨库的数据一致性问题?”这个问题直接引出了分布式事务。我们首先要知道这个问题的根源在何处。

分布式事务并非凭空产生,它几乎总是我们为了追求 系统可扩展性(Scalability) 而做出的架构决策所带来的“甜蜜的负担”。主要源于以下三大经典场景:

场景一:微服务拆分(业务维度)

这是最常见的场景。随着业务变得复杂,单体应用被拆分成多个独立的微服务,每个服务都有自己的专属数据库。

案例:电商下单

业务流程 :用户下单,需要同时操作 订单服务 (创建订单)、 库存服务 (扣减库存)和 账户服务 (扣减余额)。痛点 :这三个服务部署在不同节点,访问各自独立的数据库。任何一个环节失败,都必须撤销已经成功的操作,否则就会出现“创建了订单但库存没扣”或“钱扣了但订单创建失败”等严重业务错误。单个数据库的本地 ACID 事务在此已无能为力。结论 : 微服务化将单个业务流程内的多个数据操作,从“库内跨表”变成了“跨库跨服务” 。为了保证业务流程的原子性,分布式事务成为刚需。场景二:数据分片/分库分表(数据维度)

当单一数据库无法承受海量数据的存储和访问压力时,我们会对其进行水平拆分(Sharding)。

案例:朋友圈/社交转账

业务流程 :用户 A 给用户 B 转账。为了存储海量用户数据,用户表和账户表早已根据 user_id 进行了分片,用户 A 的数据在 DB1,用户 B 的数据在 DB2。痛点 :一个简单的转账操作,现在变成了对 DB1 的 UPDATE(A 扣款)和对 DB2 的 UPDATE(B 收款)。这实质上已经是一个跨两个数据库实例的事务。结论 : 数据分片将原本可能在同一个数据库内完成的事务,强制拆分成了跨库事务 。只要业务逻辑需要同时修改落在不同分片上的数据,就必然会遇到分布式事务问题。场景三:异构系统数据同步

在复杂的系统中,数据往往需要在不同类型的存储或系统间保持一致。

案例:数据库与缓存/搜索引擎双写

业务流程 :用户修改了商品信息。操作需要 先更新 MySQL 数据库,然后更新 Redis 缓存和 Elasticsearch 中的商品索引 ,以确保用户能立即看到并搜到最新的信息。痛点 :如何保证数据库写入成功后,缓存和 ES 也一定能更新成功?如果更新缓存或 ES 失败,就会导致用户看到旧数据,产生数据不一致。结论 :只要一个业务操作需要原子化地修改多个异构数据源,就产生了分布式事务的需求 。

分布式事务解析(二):解决方案从“刚性”到“柔性”的权衡

分布式事务的解决方案,本质上是在 一致性、性能、可用性和实现复杂度 之间做选择。我们可以将其划分为两大阵营: 刚性事务 和 柔性事务 。

阵营一:刚性事务(追求强一致性)

这类方案严格遵循 ACID 原则,特别是原子性(Atomicity)和隔离性(Isolation),试图在分布式环境下复制单机事务的体验。

代表:两阶段提交 (2PC) / 三阶段提交 (3PC)

核心思想 :引入一个“协调者”来统一指挥所有“参与者”,通过“投票(准备)”和“执行(提交/回滚)”两个阶段,强制所有节点达成一致。致命缺陷 : 2PC 的三大问题: 同步阻塞 (资源锁定时间长,严重影响并发性能)、 协调者单点故障(协调者宕机导致整个事务阻塞甚至不一致)、极端情况下的数据不一致 (协调者在第二阶段部分通知后宕机)。三阶段提交(3PC)相比 2PC 做了哪些改进?

三阶段提交(Three-Phase Commit, 3PC)是在 2PC 的基础上为了解决 2PC 在协调者故障时导致参与者长时间阻塞 的问题而提出的改进。3PC 通过把“做出提交决定”拆成三个阶段,引入一个中间的 预提交(pre-commit / can-commit → pre-commit → do-commit) 阶段,目的是让参与者在协调者失联时也能做出安全的局部决策,从而尽量避免无限等待。但必须强调:3PC 的安全性依赖于网络延迟有界(bounded delay)和不存在长期网络分区的假设——在实际互联网环境中,这个假设往往不成立,所以 3PC 并没有像 Raft 那样在工程实践中广泛替代 2PC。

3PC 的三个阶段(简要)

CanCommit(询问 / 准备投票) :协调者询问参与者是否能准备提交(与 2PC 的 Prepare 类似)。参与者检查本地条件并返回 YES/NO(可以/不可以)。PreCommit(预提交 / 锁定) :如果所有参与者都返回 YES,协调者发送 PRE-COMMIT。参与者在收到 PRE-COMMIT 后进入“已准备但尚未最终提交”的状态,并在本地做必要的持久化(但仍可安全回滚)。DoCommit(最终提交 / 决策) :协调者广播 DO-COMMIT(或 ABORT),参与者据此完成提交或回滚。

3PC 相对 2PC 的改进点 :

在协调者在第二阶段宕机的情况下,参与者在 PRE-COMMIT 状态下能通过与其他参与者交换信息,确定是否可以安全地自主完成提交,从而 减少长期阻塞的概率 。通过增加一个全局“预提交”步骤,让参与者在进入真正提交之前把状态写到可恢复的持久化日志,这样在协调者短时失联时,参与者能依据已知的“预提交”信息做出更安全的判断。

局限与现实原因(为什么不常用) :

3PC 依赖于 网络延迟有界且不存在持久分区 的假设。在真实分布式系统(互联网)中,这个假设通常无法保证,因此 3PC 在分区发生时可能导致不安全或仍然阻塞。相比之下,共识算法(Paxos/Raft)以“多数派可决”为核心,不依赖网络延迟有界的强假设,并且在网络分区与领导选举上表现更稳健,所以工程实践中更倾向于用共识协议配合其他模式解决分布式一致性问题。

总结一句话:3PC 在理论上通过引入“预提交”缓解了 2PC 的一些阻塞场景,但其对网络假设的依赖使它在不可靠网络环境中并非通用解,因此在互联网级别的系统中并未广泛替代 2PC/被普遍采用。

正因为这些严重缺陷,2PC/3PC 这类方案 性能极差、对网络容错性低 ,在现代互联网高并发、高可用的微服务架构中 几乎不被采用 。它更多是理论基础和传统单体应用集成领域的方案(如 XA 规范)。

阵营二:柔性事务(追求最终一致性)

这类方案放宽了对强一致性的要求,不要求数据在事务执行过程中时刻一致,而是承诺在经历一个短暂的延迟后,数据 最终 会达到一致状态。这是基于 BASE 理论(Basically Available, Soft state, Eventually consistent) 的设计,是当前互联网架构的主流选择。

代表一:TCC (Try-Confirm-Cancel)

核心思想 :将业务逻辑分为三个阶段: 资源预留 (Try) 、 确认执行 (Confirm) 、 取消预留 (Cancel) 。

举一个形象的例子 :比如“预订机票”。Try 阶段是“冻结”机票库存和用户资金,但并未实际扣减;Confirm 阶段是实际扣减库存和资金,完成出票;Cancel 阶段是释放被冻结的库存和资金。

下面给出一个简化的 TCC 伪代码示例,演示预留(Try)、确认(Confirm)和取消(Cancel)三个接口的调用关系与幂等性要求。示例以“下单 -> 冻结库存 -> 冻结余额 -> 确认支付/取消”为场景。

复制
# 服务 A (Order service) 调用示例(伪代码) def place_order(user_id, item_id, amount): # 1. Try 阶段:各服务做资源预留 ok1 = inventory.try_reserve(item_id, qty=1, tx_id=tx_id) ok2 = account.try_freeze(user_id, amount, tx_id=tx_id) if not (ok1 and ok2): # 如果任一预留失败,执行 Cancel inventory.cancel(item_id, tx_id=tx_id) account.cancel(user_id, tx_id=tx_id) return False # 2. 如果预留都成功,调用 Confirm(通常协调者在确认业务可以完成后触发) confirm1 = inventory.confirm(item_id, tx_id=tx_id) confirm2 = account.confirm(user_id, tx_id=tx_id) if not (confirm1 and confirm2): # 若 Confirm 任一失败,需要调用 Cancel 作为补偿(或重试策略) inventory.cancel(item_id, tx_id=tx_id) account.cancel(user_id, tx_id=tx_id) return False return True # 单个参与者(库存服务)内部逻辑示意 class InventoryService: def try_reserve(self, item_id, qty, tx_id): # 写入本地预留表,锁定库存(应幂等) with db.tx() as cur: if already_tried(tx_id): return True if available(item_id) < qty: return False insert_reservation(tx_id, item_id, qty) # 不做最终扣减,只标记已预留 cur.commit() return True def confirm(self, item_id, tx_id): # 将预留转化为实际扣减:幂等且持久 with db.tx() as cur: if already_confirmed(tx_id): return True apply_deduct(item_id, get_reserved_qty(tx_id)) mark_confirmed(tx_id) cur.commit() return True def cancel(self, item_id, tx_id): # 释放预留:幂等 with db.tx() as cur: if already_cancelled_or_confirmed(tx_id): return True release_reservation(tx_id) mark_cancelled(tx_id) cur.commit() return True1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.53.54.55.

要点提醒:

每个接口必须 幂等 ,以应对重试和网络不确定性。Try 阶段应尽量只做 资源预留 (轻量),避免长时间持有不可释放的锁,否则影响并发。Confirm/Cancel 必须能根据 tx_id 判断状态并安全执行。

优点 :一致性很高,因为它在业务提交前已经确保所有资源都可用,可以实现“准强一致性”。回滚(Cancel)逻辑通常很清晰。

缺点 : 对业务代码侵入性极强 。你需要为每个TCC服务都编写 TryConfirmCancel 三个接口,开发和维护成本高。

代表二:SAGA 模式

核心思想 :将一个长事务拆分为多个 本地事务 的序列,每个本地事务都有一个对应的 补偿事务 。如果正向流程中的任何一步失败,系统会反向调用前面已成功步骤的补偿事务,以达到回滚的目的。

对比 TCC :SAGA 没有“预留”阶段,是“先斩后奏”。TCC 是预留-执行,SAGA 是执行-补偿。

协调方式 :两种协调方式。

编排式(Orchestration) :由一个中央协调器(Saga Coordinator)统一调度。协同式(Choreography) :各个服务通过订阅/发布事件(通常借助消息队列)来驱动流程。编排式 SAGA(Centralized Orchestrator)
复制
# Saga 协调器(中央编排) def saga_place_order(order_id, user_id, item_id, amount): try: # Step 1: 创建订单(本地事务) svc_order.create_order(order_id, user_id, item_id) # Step 2: 调用库存服务 svc_inventory.decrease(item_id, qty=1) # 本地事务 # Step 3: 调用支付服务 svc_payment.charge(user_id, amount) # 本地事务 # Step 4: 发货 svc_shipping.ship(order_id, item_id) # 本地事务 # 全部成功,Saga 结束 return True except Exception as e: # 出现任何错误,按已完成步骤依次执行补偿 # 注意:补偿顺序通常是反序 try: svc_shipping.cancel_ship(order_id) except: pass try: svc_payment.refund(user_id, amount) except: pass try: svc_inventory.increase(item_id, qty=1) except: pass try: svc_order.cancel_order(order_id) except: pass return False1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.
协同式 SAGA(Event-driven Choreography)
复制
# 服务间通过事件驱动,每个服务在处理完自己的本地事务后发布事件 # 示例:Order Service 创建订单后,发布 OrderCreated 事件 # Order Service def create_order(order_id, user_id, item_id): with db.tx(): insert_order(order_id, user_id, item_id, status=CREATED) publish_event(OrderCreated, {order_id: order_id, item_id: item_id}) # Inventory Service (订阅 OrderCreated) def on_order_created(event): try: with db.tx(): decrease_stock(event.item_id, 1) publish_event(InventoryReserved, {order_id: event.order_id}) except: publish_event(InventoryFailed, {order_id: event.order_id}) # Payment Service (订阅 InventoryReserved) def on_inventory_reserved(event): try: with db.tx(): charge_user(order.user_id, amount) publish_event(PaymentSucceeded, {order_id: event.order_id}) except: publish_event(PaymentFailed, {order_id: event.order_id}) # 下游服务根据成功/失败事件决定下一步或触发补偿 # 例如:如果 PaymentFailed,发布 PaymentFailed 事件,Order Service 或其它服务会收到并触发 refund/rollback1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

要点说明:

编排式 可控性更强,易于监控与补偿(中央协调器负责全局状态),但集中化会增加单点逻辑复杂度。协同式 更松耦合、弹性好,但容易变得分散且难以跟踪全局状态,补偿和异常处理需要更复杂的事件治理与幂等设计。无论哪种方式, 补偿事务必须设计为幂等 ,并且要能部分或逐步恢复系统一致性。

SAGA 优点 :耦合度低,实现相对 TCC 简单(只需关心补偿逻辑),性能好,特别适合流程长、需要异步执行的业务。

SAGA 缺点 : 不保证隔离性 。在事务提交到一半时,其他请求可能会读到中间状态(例如,订单已创建,但库存还未扣减)。补偿逻辑的设计可能非常复杂。

代表三:基于可靠消息的最终一致性 (Transactional Outbox)

核心思想 :这是目前应用最广泛的方案之一。其精髓在于 利用本地事务的原子性,来保证“业务操作”和“发送消息”这两个动作的原子性 。

流程 :

开启 数据库本地事务 。执行业务操作(如扣库存)。将要发送的消息(如“库存已扣减”)写入到 同一个数据库 的一张“本地消息表”中。提交本地事务 。此时,业务数据和消息数据要么都成功,要么都失败。一个独立的后台任务/服务,轮询该消息表,将消息投递到真正的消息队列(MQ)中。下游服务(如订单服务)消费 MQ 中的消息,执行自己的本地事务。

Transactional Outbox 的核心在于: 把要发送的事件/消息先写入与业务数据相同的数据库事务中(outbox 表) ,保证写业务数据和写 outbox 的原子性;随后由独立的投递器将 outbox 的消息可靠地投递给消息队列(MQ),并将其标记为已发送。

复制
# 生产者:在同一个本地事务中写业务数据和 outbox def deduct_inventory_and_emit_event(item_id, qty): with db.tx() as cur: # 1. 本地业务修改 update_inventory(item_id, -qty) # 2. 写入 outbox 表(消息尚未发送) outbox_msg = { id: gen_uuid(), topic: inventory.deducted, payload: json.dumps({item_id: item_id, qty: qty}), status: PENDING, created_at: now() } insert_outbox(outbox_msg) cur.commit() # 投递器(异步后台任务) def outbox_dispatcher_loop(): while True: msgs = select_pending_outbox(limit=100) for msg in msgs: try: mq.publish(msg.topic, msg.payload) # 将消息发到 MQ(需保证幂等/至少一次) mark_outbox_sent(msg.id, sent_at=now()) except Exception as e: # 发布失败:记录日志,稍后重试(幂等性在消费端保证) log.error("publish failed", e) sleep(poll_interval)1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

消费端需保证 幂等性 以应对消息可能的重复投递。优点是实现简单、与现有关系型数据库天然集成,且能利用数据库事务保证本地原子性;缺点是需要一个额外的投递进程和 outbox 表的运维,且消息发出存在短时延迟(取决于投递器的轮询频率或触发策略)。

优点 :与业务逻辑解耦,实现相对简单(依赖数据库和 MQ),性能好,可靠性高。

缺点 :一致性有时效性延迟;需要额外维护消息表和轮询服务;下游服务需要保证消费的幂等性。

为了简化本地消息表的模式,像 RocketMQ 这样的消息中间件提供了内置的 事务消息 功能,它通过 半消息 和 事务回查 机制,在 MQ 层面实现了业务操作与消息发送的原子性,开发者只需关注业务逻辑和回查接口的实现。

分布式事务解析(三):如何在具体场景中做技术选型?

比如:“如果要你设计一个电商平台的下单到支付流程,你会选择哪种分布式事务方案?”

分析业务场景,明确一致性要求

下单扣库存 :这个环节,用户对延迟不敏感,但库存数据必须准确。这是一个典型的异步、长流程场景。

支付扣款 :这个环节涉及到真实的资金,对一致性要求极高。用户不能容忍钱扣了但订单状态没更新,或者订单显示支付成功但钱没扣。

方案选型与组合

一个复杂的业务流程,通常不是单一方案能搞定的,而是 组合拳 。

对于“下单 -> 扣库存 -> 通知发货”这类非核心资金链路

首选:基于可靠消息的最终一致性 或 SAGA 模式理由 :将“创建订单”作为核心的上游本地事务。订单创建成功后,通过“本地消息表”模式,可靠地向 MQ 发送一个 OrderCreated 事件。下游的“库存服务”、“物流服务”、“积分服务”等各自订阅这个消息,并执行自己的业务逻辑。这种方式 松耦合 ,允许各服务异步执行, 系统吞吐量高 。即使某个下游服务暂时失败,重试机制也能保证数据 最终一致 ,完全满足这类场景的需求。

对于“支付 -> 更新订单状态”这类核心资金链路

首选:TCC 模式 ,或者通过巧妙的业务设计规避。理由 :支付环节,优先考虑 TCC。当用户点击支付时,支付网关会调用我们的支付服务。Try 阶段 :支付服务调用订单服务,尝试将订单状态置为“支付中”(预留状态),并调用账户服务冻结用户余额。Confirm 阶段 :如果支付渠道返回成功,则调用订单服务的 Confirm 接口,将状态改为“已支付”,并调用账户服务真正扣款。Cancel 阶段 :如果支付失败或超时,则调用 Cancel 接口,将订单状态回滚,并解冻用户余额。

TCC 方案虽然开发复杂,但它能提供 准强一致性 的保障,防止资金类业务出现差错,这是业务的底线。

如下是更贴近支付场景的具体伪代码,展示 Try/Confirm/Cancel 的典型调用顺序与幂等性保障。

复制
# 支付协调器(伪代码) def process_payment(order_id, user_id, amount): tx_id = f"pay-{order_id}-{now_ts()}" # 1. Try 阶段:订单服务和账户服务进行预留/冻结 ok_order = order_service.try_mark_paying(order_id, tx_id) # 将订单置为 PAYING(预留) ok_account = account_service.try_freeze(user_id, amount, tx_id) if not (ok_order and ok_account): # 如果任一 Try 失败,调用 Cancel order_service.cancel_mark_paying(order_id, tx_id) account_service.cancel_freeze(user_id, tx_id) return False # 2. 调用第三方支付渠道(同步或异步) success = payment_gateway.charge(user_id, amount) # 3. 根据渠道结果调用 Confirm 或 Cancel if success: order_service.confirm_paid(order_id, tx_id) account_service.confirm_charge(user_id, tx_id) return True else: order_service.cancel_mark_paying(order_id, tx_id) account_service.cancel_freeze(user_id, tx_id) return False # 账户服务内部(示意) class AccountService: def try_freeze(self, user_id, amount, tx_id): with db.tx(): if already_tried(tx_id): return True if get_balance(user_id) < amount: return False insert_freeze_record(tx_id, user_id, amount) # 不实际扣款,只冻结 cur.commit() return True def confirm_charge(self, user_id, tx_id): with db.tx(): if already_confirmed(tx_id): return True deduct_balance(user_id, get_freeze_amount(tx_id)) mark_confirmed(tx_id) cur.commit() return True def cancel_freeze(self, user_id, tx_id): with db.tx(): if already_cancelled_or_confirmed(tx_id): return True remove_freeze_record(tx_id) mark_cancelled(tx_id) cur.commit() return True1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.30.31.32.33.34.35.36.37.38.39.40.41.42.43.44.45.46.47.48.49.50.51.52.

要点回顾:

支付相关的 Try 必须保证对外部渠道调用之前,资金/状态已被“锁住”或标记,否则会产生竞态。Confirm/Cancel 都必须是幂等的,以便在网络或重试下不会重复扣款或错误释放资金。实际工程中,外部支付渠道通常是异步回调,协调器需要设计回调处理逻辑,并把回调与原始 tx_id 关联起来以完成 Confirm/Cancel。

对 2PC 的态度 :在这个场景中,一般不会考虑使用 2PC。因为它对性能的损耗和对可用性的影响,对于面向海量用户的互联网应用是不可接受的。

结语

分布式数据库的世界,本质上是一个充满权衡的艺术。

为了实现 分布式原子性 ,我们从有阻塞风险的 2PC 演进到了基于多数共识的 Paxos/Raft 。为了实现 高可用和数据安全 ,我们采用 主从复制 ,并在 同步(强一致) 与 异步(最终一致) 之间权衡。而这一切顶层设计的指导原则,都落在了 CAP 定理 上——在网络分区不可避免的前提下,我们究竟是选择数据的绝对正确(CP),还是选择服务的永不中断(AP)。

理解这些核心概念与它们背后的取舍,不仅能帮助我们更好地选择和使用数据库,更能让我们在设计任何分布式系统时,都能做出更明智、更贴合业务需求的决策。

阅读剩余
THE END