分布式事务方案选型:从 Seata AT 到可靠消息最终一致性

0 阅读37分钟

概述

在之前的系列中,我们已经深入掌握了 Spring 在单机环境下的强大事务管理能力,包括 PlatformTransactionManager 的抽象、声明式事务的 AOP 实现、以及多数据源场景下的动态路由。然而,在微服务架构下,事务的边界从单个数据库跨越到了多个网络节点和数据源。此时,传统的 ACID 事务面临着严峻挑战。Spring 生态通过整合 Seata、消息队列以及自定义的 Saga 等方案,将分布式事务的实现从“不可能”变为了“可权衡”。本文将正面拆解这些方案的底层机制,帮助读者建立起一套完善的技术选型决策框架。

分布式事务一直被视为分布式系统开发中最复杂的难题之一。直接套用 XA 二阶段提交协议会导致系统性能急剧下降,而选择放弃强一致性的最终一致性方案又需要精心设计幂等、重试和补偿逻辑。Seata AT 模式试图在易用性和性能之间寻找平衡,通过代理数据源自动生成 undo 回滚日志,让开发者可以像操作本地事务一样操作全局事务;而基于 MQ 的可靠消息方案则彻底拥抱最终一致性,通过事务发件箱保证消息可靠投递,通过 Saga 补偿处理业务异常。本文将从 Seata 的内部引擎到消息退避重试,系统梳理这些方案的工程实现,不仅教你如何用,更会分析其背后的核心源码,揭示它们在 CAP 理论中的真实站位。

核心要点

  • Seata AT 的奥秘:全局锁与 undo_log 如何实现自动回滚。
  • TCC/Saga 的权衡:高自定义性带来的复杂补偿逻辑。
  • 最终一致性的基石:事务发件箱、本地消息表与 MQ 的原子性整合。
  • 与 Spring 的深度集成DataSourceProxy@GlobalTransactional 注解的原理。
  • 选型决策树:根据事务粒度、并发量与业务容错率制定技术方案。

文章组织架构图

flowchart TD
    n1["1. 分布式事务理论基础<br>CAP、BASE 与 XA"] --> n2["2. Seata AT 架构深度剖析<br>TC/TM/RM 的协作"]
    n2 --> n3["3. undo_log 回滚与全局写隔离<br>源码实现"]
    n2 --> n4["4. TCC 与 Saga 模式<br>补偿设计与对比"]
    n5["5. 基于 MQ 的可靠消息<br>最终一致性方案"] --> n6["6. 事务发件箱与 CDC<br>工程落地"]
    n7["7. 与 Spring 声明式事务<br>无缝集成"] --> n8["8. 分布式事务选型决策框架"]
    n1 --> n5
    n3 --> n7
    n4 --> n7
    n6 --> n7
    n7 --> n9["9. 生产事故排查专题"]
    n7 --> n10["10. 面试高频专题"]
    n8 --> n9
    n8 --> n10

    classDef topic fill:#f8f9fa,stroke:#333,stroke-width:2px,rx:5,color:#333;
    class n1,n2,n3,n4,n5,n6,n7,n8,n9,n10 topic;

架构图说明

  • 总览说明:全文 10 个模块从理论基础出发,逐层剖析 Seata AT 的引擎、TCC/Saga 的补偿设计、消息一致性方案,再汇总到集成与选型,最后通过事故和面试完成闭环。
  • 逐模块说明:模块 1-2 建立理论基础与 Seata 架构全景;模块 3-4 深入最核心的回滚和补偿技术;模块 5-6 展现代码侵入最小的一致性替代方案;模块 7 揭示技术集成原理;模块 8 构建工程师的决策思维;模块 9-10 聚焦实战与应试。
  • 关键结论分布式事务的选型没有银弹。Seata AT 提供了最接近本地事务的开发体验,但牺牲了全局锁的并发度;MQ 最终一致性性能卓越,但需要精心设计业务幂等性。理解它们内部的回滚与补偿机制,是做出正确架构决策的唯一途径。

1. 分布式事务理论基础:CAP、BASE 与 XA

1.1 从单体 ACID 到分布式挑战

在单机 RDBMS 中,ACID 事务通过如下机制保证:

  • 原子性 (Atomicity):利用 undo/redo 日志,要么全部成功,要么全部失败回滚。
  • 一致性 (Consistency):约束、触发器、外键等保证状态正确。
  • 隔离性 (Isolation):锁机制和 MVCC 提供不同级别隔离。
  • 持久性 (Durability):write-ahead log 和 checkpoint 确保已提交事务不丢失。

这些机制强依赖数据库内部的资源管理器与全局锁表。一旦跨越多个数据库实例或微服务,单一数据库的协调能力就失效了。例如,在电商下单场景中,订单服务写订单表,库存服务扣减库存,支付服务扣款,任何一步失败都可能造成数据不一致。

分布式事务需要解决的核心问题:如何跨多个自治数据资源保证所有操作要么全部成功,要么全部回滚

1.2 CAP 定理与分布式事务的分类

CAP 定理(Brewer 定理)指出一个分布式系统不可能同时满足:

  • Consistency(一致性):所有节点在同一时刻看到相同数据。
  • Availability(可用性):每个请求都能获得非错响应。
  • Partition Tolerance(分区容错性):系统能处理任意消息丢失或部分节点故障。

由于网络分区不可避免,系统必须在 CP 和 AP 之间权衡。对于分布式事务:

  • CP 倾向方案:两阶段提交(2PC/XA),Seata AT 模式(通过全局锁实现写隔离,但牺牲部分可用性),以及社区强一致性实现。在网络分区时,可能会阻塞直到恢复。
  • AP 倾向方案:最终一致性方案,如基于 MQ 的可靠消息、Saga 模式,系统持续可用,但可能存在短暂不一致。

Seata AT 模式通过全局锁和 undo_log 提供了较接近 CP 的弱隔离性,但在一阶段本地已提交,其他事务可以读到未全局提交的数据(可能出现脏读)。因此其隔离性并非严格串行化,是在易用性、性能和一致性之间的折衷。

1.3 BASE 理论:最终一致性的理论基础

BASE 基本含义:

  • Basically Available(基本可用):系统出现故障时,允许损失部分可用性(如响应延迟或功能降级),但不会完全不可用。
  • Soft state(柔性状态):允许系统存在中间状态,且该状态不会影响系统整体可用性。
  • Eventually consistent(最终一致):经过一段时间后,所有数据副本最终达到一致,但不能保证严格的实时一致。

BASE 理论是分布式事务中除 XA 外几乎所有方案的指导原则。例如 TCC 模式中,Try 阶段完成资源预留后,数据处于“冻结”中间态,后续 Confirm 或 Cancel 使其到达终态;MQ 消息方案中,订单已创建但库存尚未扣减会短暂存在,消费者最终执行后达到一致。

1.4 XA 两阶段提交详解与局限性

XA 规范定义了两个阶段:

  1. Prepare 阶段:TM 向所有 RM 发起 prepare 请求,RM 执行事务操作但未提交,将 undo/redo 写入日志并返回 ok 或 fail。
  2. Commit 阶段:若所有 RM 返回 ok,TM 发送 commit 指令;若有任何失败则发送 rollback。

协议保证了强一致性,但引入了严重问题:

  • 同步阻塞:所有参与者在 prepare 后必须持有锁等待协调者指令,如果协调者死机,锁可能会长时间不释放,导致其他事务阻塞。
  • 单点故障:协调者(TM)挂掉后,事务可能会悬挂,需要复杂的恢复协议。
  • 数据不一致风险:部分 commit 消息丢失时会导致部分提交,部分未提交,产生不一致。
  • 性能低下:多次网络往返、持久化等待、锁竞争,吞吐量很小。

因此,高并发互联网应用几乎不使用纯粹的 XA 分布式事务。Spring 中通过 JtaTransactionManager 支持 XA,但在微服务中极少采用。


2. Seata AT 架构深度剖析:TC/TM/RM 的协作

2.1 Seata 总体架构

Seata 1.x 定义三种角色:

  • TC (Transaction Coordinator):事务协调者,独立部署的 Seata Server,维护全局事务状态,驱动分支提交/回滚。
  • TM (Transaction Manager):事务管理器,嵌入应用,负责开启、提交或回滚全局事务。由 @GlobalTransactional 注解标记的服务扮演。
  • RM (Resource Manager):资源管理器,管理本地资源(数据库),注册分支到 TC,处理分支提交/回滚。

AT 模式下的 RM 即 DataSourceProxy 代理的数据源,通过拦截 SQL 生成 undo_log,并向 TC 注册分支及反馈状态。

2.2 全局事务交互序列图

sequenceDiagram
    participant TM as TM(OrderService)
    participant TC as TC(Seata Server)
    participant RM1 as RM(OrderDS)
    participant RM2 as RM(StorageDS)

    TM->>TC: 1. 开启全局事务,获得 xid
    TC-->>TM: xid=xxx
    TM->>TM: RootContext.bind(xid)
    TM->>RM1: 2. 执行业务SQL
    RM1->>TC: 3. 注册分支 branch1,申请全局锁
    TC-->>RM1: 分支注册成功,锁获取成功
    RM1->>RM1: 4. 执行SQL + 生成undo_log,本地提交
    RM1->>TC: 5. 报告分支状态 Phase1_DONE
    TM->>RM2: 6. RPC调用库存服务(传递xid)
    Note right of RM2: 通过Feign拦截器自动传xid
    RM2->>TC: 7. 注册分支 branch2,申请全局锁
    TC-->>RM2: 成功
    RM2->>RM2: 8. 执行SQL + undo_log,本地提交
    RM2->>TC: 9. 报告分支状态 Phase1_DONE
    TM->>TC: 10. 全局提交 (GlobalCommit)
    TC->>RM1: 11. 二阶段异步删除undo_log
    TC->>RM2: 12. 二阶段异步删除undo_log

图片功能:完整展示一次成功的分布式事务,从全局事务开启、各分支一阶段执行与注册、到二阶段异步清理。
关键节点:TM 绑定 xid 到 RootContext;RM 一阶段本地提交并生成 undo_log,同时向 TC 注册分支并获取全局锁;二阶段由 TC 发出 commit,RM 异步删除 undo_log。
数据流转:xid 通过 RootContext (ThreadLocal)在线程内传递,Feign 拦截器将其放入 HTTP 头部传播;undo_log 写入各服务本地库,全局锁维护在 TC 的 lock_table 表中。
工程启示:一阶段成功后,本地事务已提交,即使全局事务最终回滚,也可以通过 undo_log 反向恢复;二阶段异步化则锁持有时间极短(仅分支注册期间持有全局锁),极大提高并发。

2.3 全局事务核心对象与生命周期

GlobalTransaction 接口与默认实现

public interface GlobalTransaction {
    String begin(String applicationId, String txServiceGroup, String name, int timeout) throws TransactionException;
    void commit() throws TransactionException;
    void rollback() throws TransactionException;
    GlobalStatus getStatus();
    String getXid();
}

DefaultGlobalTransaction 实现:

public class DefaultGlobalTransaction implements GlobalTransaction {
    private String xid;
    private GlobalStatus status = GlobalStatus.UnKnown;
    private TransactionManager transactionManager;

    @Override
    public String begin(String applicationId, String txServiceGroup, String name, int timeout) throws TransactionException {
        // 通过netty向TC发起全局事务开始
        this.xid = transactionManager.begin(applicationId, txServiceGroup, name, timeout);
        this.status = GlobalStatus.Begin;
        RootContext.bind(this.xid);  // 绑定xid到当前线程
        return this.xid;
    }

    @Override
    public void commit() throws TransactionException {
        transactionManager.commit(xid);
        status = GlobalStatus.Committed;
    }

    @Override
    public void rollback() throws TransactionException {
        transactionManager.rollback(xid);
        status = GlobalStatus.Rollbacked;
    }
}

关键点RootContext.bind(xid) 将 xid 放入 ThreadLocal,使得后续数据库操作能被 DataSourceProxy 感知,从而自动化多分支注册。这一机制完全类似 Spring 的 TransactionSynchronizationManager.bindResource 将数据库连接绑定到当前线程的思想。

@GlobalTransactional 拦截原理

GlobalTransactionalInterceptor 继承了 Spring 的 MethodInterceptor

public class GlobalTransactionalInterceptor implements MethodInterceptor {
    @Override
    public Object invoke(final MethodInvocation methodInvocation) throws Throwable {
        GlobalTransactional ann = getAnnotation(methodInvocation.getMethod());
        if (ann == null) {
            return methodInvocation.proceed();
        }
        // 构造事务模板
        TransactionalTemplate template = new TransactionalTemplate(
            ann.timeoutMills(), ann.name(), ann.propagation());
        return template.execute(new TransactionalExecutor() {
            @Override
            public Object execute() throws Throwable {
                return methodInvocation.proceed();
            }
            @Override
            public GlobalTransaction getTransaction() {
                return template.getTransaction();
            }
        });
    }
}

TransactionalTemplate.execute 内部调用 DefaultGlobalTransaction.begin 开启全局事务,然后执行业务方法,根据异常类型决定 commit 或 rollback,最后清理 RootContext。这一模板方法与 Spring 的 TransactionTemplate 如出一辙,将模板方法模式发扬光大。


3. undo_log 回滚与全局写隔离源码实现

AT 模式的一阶段自动回滚特性建立在 undo_log 表及全局锁机制上。下面深入表结构、镜像生成、反向 SQL 执行和全局锁冲突处理。

3.1 undo_log 表结构

业务数据库需提前创建:

CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • rollback_info 存储前后镜像及反向 SQL 的序列化数据,是回滚的核心。
  • log_status:0=正常(未回滚也未删除),1=全局事务已完成(已全局提交删除或已回滚)。用于处理二阶段成功删除后可能的网络重传。

3.2 一阶段 SQL 解析与镜像生成

Seata 通过 SQLRecognizer 工厂识别 SQL 类型(INSERT、UPDATE、DELETE),并借助 TableMetaCache 获取表结构。

以 INSERT 为例:

// AbstractDMLBaseExecutor.execute
public T execute(Object... args) throws Throwable {
    // 前镜像:查询不存在(INSERT无前镜像)
    TableRecords beforeImage = beforeImage();
    // 执行原始SQL
    statementCallback.execute(statementProxy.getTargetStatement());
    // 后镜像:查询刚插入的行
    TableRecords afterImage = afterImage(beforeImage);
    // 构建SQLUndoLog
    SQLUndoLog sqlUndoLog = buildUndoItem(beforeImage, afterImage);
    // 将undo log保存到数据库undo_log表,和业务数据在一个本地事务中
    UndoLogManagerFactory.getUndoLogManager().flushUndoLogs(connectionProxy);
}

后镜像查询:执行 SELECT * FROM table WHERE (PK in ?) FOR UPDATE 获取刚插入的行(因为一阶段本地事务未提交,需要使用 FOR UPDATE 在当前连接读取未提交数据或利用数据库的自治事务特性)。

反向 SQL 生成

  • INSERT 生成 DELETE ... WHERE PK = ?
  • DELETE 生成 INSERT ... VALUES (before_image)
  • UPDATE 生成 UPDATE ... SET ... WHERE PK = ? 将数据恢复到 before_image

镜像和反向 SQL 最终被序列化成 JSON 放入 rollback_info,如:

{
  "undoItems": [{
    "tableName": "order",
    "beforeImage": {"rows": []},
    "afterImage": {"rows": [{"fields": [{"name":"id","value":1}, {"name":"amount","value":99}]}]},
    "sqlType": "INSERT"
  }]
}

3.3 二阶段回滚流程

当 TC 发出分支回滚命令,RM 的 RmBranchRollbackProcessor 处理:

// io.seata.rm.datasource.undo.UndoLogManager.undo
public static void undo(UndoLogManager undoManager, ConnectionProxy connectionProxy, String xid, long branchId, 
                        String resourceId, ...) {
    // 1. 根据 xid 和 branchId 查询 undo_log,要求 log_status = 0
    UndoLog undoLog = getUndoLog(connectionProxy.getTargetConnection(), xid, branchId);
    if (undoLog == null) {
        return;
    }
    // 2. 反序列化 rollback_info
    List<SQLUndoLog> sqlUndoLogs = DefaultUndoLogParser.decode(undoLog.getRollbackInfo());
    // 3. 脏写校验:比较当前数据快照与 after image 是否一致
    for (SQLUndoLog sqlUndoLog : sqlUndoLogs) {
        TableRecords currentRecords = queryCurrentRecords(connectionProxy, sqlUndoLog);
        if (!DataValidation.isFieldEquals(sqlUndoLog.getAfterImage(), currentRecords)) {
            throw new SQLException("Has dirty write, rollback fail.");
        }
    }
    // 4. 反向执行 undo SQL
    Collections.reverse(sqlUndoLogs); // 逆序执行
    for (SQLUndoLog sqlUndoLog : sqlUndoLogs) {
        AbstractUndoExecutor executor = UndoExecutorFactory.getUndoExecutor(sqlUndoLog);
        executor.execute(connectionProxy);
    }
    // 5. 删除 undo_log 或将 log_status 置为 1
    deleteUndoLog(connectionProxy.getTargetConnection(), xid, branchId);
}

脏写校验机制:回滚时重新读取当前数据,与 afterImage 对比,如果字段值变化,说明一阶段成功之后至回滚之间,有其他事务修改了此记录,此时 Seata 抛出“脏写异常”,回滚失败,需要人工介入或重试。这是 AT 模式弱隔离性的直接体现:Seata 不能阻止脏读和不可重复读,但通过脏写校验在一定程度上防止更新丢失

3.4 全局锁与写隔离

Seata 维护 lock_table 在 TC 端(共享数据库或内嵌存储):

CREATE TABLE `lock_table` (
  `row_key` varchar(128) NOT NULL,
  `xid` varchar(96) NOT NULL,
  `transaction_id` bigint(20) DEFAULT NULL,
  `branch_id` bigint(20) NOT NULL,
  `resource_id` varchar(256) DEFAULT NULL,
  `table_name` varchar(32) DEFAULT NULL,
  `pk` varchar(36) DEFAULT NULL,
  `gmt_create` datetime DEFAULT NULL,
  `gmt_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`row_key`),
  KEY `idx_branch_id` (`branch_id`)
);

关键字段 row_keyresourceId + table + pk 组成,通过唯一索引防止竞争。分支注册时调用 LockManager.acquireLock

public boolean acquireLock(LockDO lockDO) {
    return lockStore.acquireLock(lockDO); // 插入 lock_table,利用唯一约束进行锁竞争
}

如果插入成功,则获得该行的全局锁;否则会抛出 LockConflictException。这种轻量级的全局锁实现,将分布式锁的竞争放到了数据库唯一索引,凭借 TC 数据库的高可用和行锁来保障。

全局锁释放时机:在二阶段提交或回滚完成后,Seata 发送分支完成通知,TC 清理 lock_table 记录。如果二阶段失败(如网络中断),TC 有定时任务 PeriodicCheck 根据超时时间清理过期锁。

写隔离综合流程:当并发事务尝试更新同一行时,一阶段本地事务获取数据库行锁(SELECT FOR UPDATE),同时注册全局锁;后者通过 TC 的 lock_table 唯一记录保证全局冲突检测。这类似于读写锁中“写-写”互斥,但允许“读-写”并发(可能产生脏读)。

3.5 DataSourceProxy 与 Spring 事务同步

Seata 通过 SeataAutoDataSourceProxyCreator 自动代理数据源:

public class SeataAutoDataSourceProxyCreator extends AbstractAutoProxyCreator {
    @Override
    protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
        if (bean instanceof DataSource && !(bean instanceof DataSourceProxy)) {
            return new DataSourceProxy((DataSource) bean);
        }
        return bean;
    }
}

创建的 DataSourceProxy 返回 ConnectionProxy,其 prepareStatement 等方法会被拦截,在执行业务 SQL 前后通过 ExecuteTemplate 选择具体执行器(InsertExecutorUpdateExecutor 等),完成镜像生成和 undo 日志持久化。并且 ConnectionProxy 利用 RootContext.getXid() 判断当前是否在全局事务中,若是则自动注册分支。

与 Spring 的 TransactionSynchronizationManager 协作:本地事务由 DataSourceTransactionManager 或 JPA 事务管理器管理,ConnectionProxy 的 commit/rollback 委托给原生的 Connection,但在 commit 之前会先完成分支注册和 undo_log 写入,使得一阶段的本地事务提交包含业务数据和 undo_log。


4. TCC 与 Saga 模式的补偿设计与对比

4.1 TCC 模式 Try/Confirm/Cancel 全生命周期

TCC 由三个接口组成:

  • Try:资源检查和预留,如冻结库存、预扣余额。
  • Confirm:真正的业务执行,使用 Try 预留的资源完成操作。
  • Cancel:释放资源,如解冻库存、返还余额。

TCC 交互序列图

sequenceDiagram
    participant TM as TM
    participant TryOrder as 订单服务(Try)
    participant TryStock as 库存服务(Try)
    participant ConfirmOrder as 订单(Confirm)
    participant CancelStock as 库存(Cancel)

    TM->>TryOrder: Try: 创建订单PENDING
    TryOrder-->>TM: 成功
    TM->>TryStock: Try: 冻结库存2件
    TryStock-->>TM: 成功
    TM->>ConfirmOrder: Confirm: 订单状态→CONFIRMED
    TM->>TryStock: Confirm: 冻结库存→已扣减
    TryStock-->>TM: 成功
    Note over TM,CancelStock: 假设库存Confirm超时失败
    TM->>ConfirmOrder: 触发补偿Cancel
    TM->>CancelStock: Cancel: 释放之前冻结的库存
    CancelStock-->>TM: 已回滚

图片功能:演示 TCC 的正常流程和失败补偿,展示 Try 阶段资源预留的重要性和 Cancel 的补偿作用。
关键节点:Try 必须在业务表中标记资源的冻结态;Confirm 要求幂等,不能再次进行业务校验;Cancel 允许空回滚(Try 未执行时 Cancel 到达)和悬挂控制(Cancel 比 Try 先到达)。
数据流转:资源状态通过数据库字段(如 status=FROZEN)流转;xid 同样可以通过上下文传递,用于关联 TCC 各个阶段。
工程启示:TCC 开发成本极高,需要为每个接口设计 Try/Confirm/Cancel,并处理好超时、重试、幂等和异常。但性能卓越,无全局锁,适合高并发资源争抢场景。

4.2 TCC 异常处理:空回滚、幂等、悬挂

  • 空回滚:Cancel 先于 Try 执行(如 Try 超时未响应而 Cancel 发起),此时 Cancel 不能失败,应查询是否有 Try 记录,若无则直接返回成功。
  • 幂等:Try、Confirm、Cancel 都可能被多次调用,需基于 xid + branchId + 阶段 做唯一约束,如设计表 tcc_flow 记录操作状态。
  • 悬挂:Cancel 比 Try 先到达并执行成功,之后 Try 才到达。此时 Try 必须检测到 Cancel 已执行,拒绝执行,避免资源被错误锁定。

防悬挂设计:在 Try 执行前,查询是否有 Cancel 记录,若有则抛异常。这要求 Cancel 操作需要写入相应的防悬挂记录。

4.3 Saga 模式

Saga 不要求资源预留,直接执行各本地事务,每个事务有对应的补偿操作。Seata Saga 基于状态机驱动,开发者定义 JSON 状态语言(DSL)描述调用图。

优点:

  • 无全局锁,性能高。
  • 适合长事务,尤其包含外部不可回滚的调用。
  • 补偿逻辑可编排,但需保证补偿的最终成功。

缺点:

  • 缺乏隔离性,一个 Saga 事务在执行中间可能暴露不一致状态给其他事务。
  • 补偿的实现通常需要业务语义(如退款代替取消订单),比 AT 模式更复杂。

4.4 TCC/Saga vs AT 综合对比

对比维度ATTCCSaga
业务侵入性极低(仅需undo_log表)高(需实现三个接口)中(需定义补偿服务)
性能中(undo_log开销+全局锁)
一致性及时性最终一致,弱隔离最终一致,业务隔离最终一致,无隔离
回滚方式自动反向SQLCancel服务补偿服务
开发易错点全剧锁冲突、脏写空回滚、悬挂、幂等补偿逻辑设计
适用场景通用CRUD微服务金融/库存/资源预留长流程、第三方服务

5. 基于 MQ 的可靠消息最终一致性方案

5.1 事务发件箱模式原理

事务发件箱核心思想:在业务数据库内建立一个 outbox 表,与业务数据操作在同一个本地事务中。后台调度任务轮询 outbox 表中状态为 NEW 的消息,将其发送到 MQ,成功后更新状态为 SENT。消费端实现幂等。

发件箱序列图

sequenceDiagram
    participant Biz as 订单服务
    participant DB as 订单数据库
    participant Poller as 消息发件箱轮询线程
    participant MQ as RabbitMQ
    participant Consumer as 库存服务

    Biz->>DB: 开始本地事务
    Biz->>DB: 插入订单 (status=CREATED)
    Biz->>DB: 插入outbox (event_type='OrderCreated', payload=json, status='NEW')
    Biz->>DB: 提交本地事务
    Poller->>DB: 轮询 status=NEW 且创建时间>阈值
    DB-->>Poller: 返回待发消息列表
    Poller->>MQ: 发送消息,带唯一message_id
    MQ-->>Poller: confirm
    Poller->>DB: 更新outbox状态 status='SENT'
    MQ->>Consumer: 推送消息
    Consumer->>Consumer: 幂等校验,若重复则忽略
    Consumer->>Consumer: 扣减库存 (本地事务)
    Consumer->>MQ: ACK

图片功能:说明 outbox 如何解决“发送消息与业务操作原子性”问题,通过后台轮询实现 at-least-once 投递。
关键节点:消息先存库,后发送,保证数据库的事务特性;轮询器可采用分布式调度(如 Elastic-Job)避免单点;消息状态至少三种:NEW、SENT、FAILED,需支持重试。
数据流转:outbox 表承担了临时消息队列的角色,将 MQ 的发送滞后于业务事务。
工程启示:此方案增加了数据库压力(轮询),但极为可靠;可引入 CDC (Debezium + Kafka) 将轮询转为日志捕获,达到准实时投递。

5.2 本地消息表方案

本地消息表是发件箱的变种,将消息与业务数据放在同一行或关联表,通过业务表的状态字段驱动。比如订单表添加 mq_status 字段,0=待发送,1=已发送。定时任务扫描 mq_status=0 的记录进行发送。这种方式更简单,但耦合较重。

5.3 幂等性设计

消费者处理消息必须支持幂等,常见手段:

  • 数据库唯一约束:收到消息后,根据 message_id 插入消费记录表,利用唯一索引防止重复处理。
  • Redis token:消费前置操作是尝试 SETNX 一个以 message_id 为 key 的 Redis 键并设超时,操作成功才进行处理。
  • 业务状态检查:在业务表中根据状态判断是否已处理(如订单已支付状态),但要小心并发。

5.4 消息驱动的 Saga

在 MQ 最终一致性基础上,可以构建 Saga 编排。每个服务订阅事件并执行本地事务,然后发布新事件,失败时发布补偿事件。例如:

sequenceDiagram
    participant OrderSvc as 订单服务
    participant MQ as MQ
    participant StockSvc as 库存服务
    participant PaySvc as 支付服务

    OrderSvc->>OrderSvc: 创建订单,状态PENDING
    OrderSvc->>MQ: 发布 OrderCreated 事件
    MQ->>StockSvc: 消费
    StockSvc->>StockSvc: 扣减库存
    StockSvc->>MQ: 发布 StockDeducted
    MQ->>PaySvc: 消费
    PaySvc->>PaySvc: 发起支付,若失败
    PaySvc->>MQ: 发布 PayFailed 事件
    MQ->>OrderSvc: 补偿,取消订单
    OrderSvc->>MQ: 发布 OrderCancelled
    MQ->>StockSvc: 补偿,释放库存

这种架构完全去中心化,优雅但极其考验开发者和运维的素养,需要完善的监控和死信处理。


6. 事务发件箱与 CDC 的工程落地

6.1 基于 Debezium 的 CDC 方案

CDC (Change Data Capture) 通过读取数据库 binlog 实时捕获 outbox 表的数据变更,推送到 Kafka,代替轮询。Spring 生态可以通过 Debezium Embedded 或 Debezium Server 实现。

架构变化:不再需要定时轮询线程,而是 Debezium 连接器订阅 binlog,outbox 数据插入后毫秒级就被投递。这极大降低了延迟,同时也减轻了数据库轮询压力。

6.2 事务发件箱示例代码

// 订单服务:本地事务包含业务数据和outbox
@Service
public class OrderService {

    @Autowired
    private OrderDao orderDao;
    @Autowired
    private OutboxDao outboxDao;

    @Transactional
    public void createOrder(Order order) {
        order.setStatus("CREATED");
        orderDao.insert(order);
        OutboxMessage msg = new OutboxMessage(
            UUID.randomUUID().toString(),
            "OrderCreated",
            order.getId().toString(),
            "NEW"
        );
        outboxDao.insert(msg);
    }
}

轮询器实现示例(Spring Scheduler):

@Component
public class OutboxPoller {

    @Autowired
    private OutboxDao outboxDao;
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @Scheduled(fixedDelay = 1000)
    public void pollAndSend() {
        List<OutboxMessage> messages = outboxDao.findByStatus("NEW");
        for (OutboxMessage msg : messages) {
            try {
                rabbitTemplate.convertAndSend("order.exchange", "order.created", msg.getPayload(),
                    message -> {
                        message.getMessageProperties().setMessageId(msg.getId());
                        return message;
                    });
                outboxDao.updateStatus(msg.getId(), "SENT");
            } catch (Exception e) {
                outboxDao.incrementRetry(msg.getId());
                // 可记录失败日志,后续人工处理或重试
            }
        }
    }
}

消费方幂等处理:

@Component
public class StockConsumer {
    @Autowired
    private MessageRecordDao recordDao;
    @Autowired
    private StockService stockService;

    @RabbitListener(queues = "stock.deduct")
    public void handle(Message message) {
        String msgId = message.getMessageProperties().getMessageId();
        try {
            // 插入消费记录,唯一索引保证幂等
            recordDao.insert(new MessageRecord(msgId));
            // 实际业务
            stockService.deduct(parseOrderId(message.getBody()));
        } catch (DuplicateKeyException e) {
            // 重复消费,直接确认
        }
    }
}

6.3 MQ 消息可靠性保障

  • 生产者确认:RabbitMQ 的 publisher confirm,Kafka 的 acks=all,确保消息成功落地 broker。
  • 消费者手动 ACK:处理成功才确认,失败需 nack 并重试或入死信。
  • 重试与死信队列:对于业务失败(非系统错误),设置最大重试次数,超限后移入死信队列,人工介入。
  • 补偿机制:消费异常时可选发送补偿消息。

7. 与 Spring 声明式事务体系的无缝集成

7.1 @GlobalTransactional 注解的解析过程

GlobalTransactionScanner 作为一个 AbstractAutoProxyCreator,在 Spring 初始化 Bean 的后处理阶段检测方法上的 @GlobalTransactional 注解,生成 AOP 代理。

// AbstractAutoProxyCreator -> wrapIfNecessary
protected Object wrapIfNecessary(Object bean, String beanName, Object cacheKey) {
    if (matches(bean.getClass())) {
        return createProxy(bean);
    }
    return bean;
}

织入的 GlobalTransactionalInterceptor 实现了方法拦截,其 invoke 方法核心逻辑使用 TransactionalTemplate 模板:

// io.seata.tm.api.TransactionalTemplate
public Object execute(TransactionalExecutor business) throws Throwable {
    GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
    try {
        tx.begin(transactionInfo.timeout, transactionInfo.name);
        Object rs = business.execute();  // 执行原方法
        tx.commit();
        return rs;
    } catch (Throwable ex) {
        tx.rollback();
        throw ex;
    }
}

对比 Spring 的 TransactionInterceptor,两者都采用了模板方法模式,在固定步骤(创建事务→执行→提交/回滚)中嵌入变化。

7.2 DataSourceProxy 与 Spring 事务管理器的关系

在 Spring 中,本地事务由 DataSourceTransactionManager 管理,当 Seata 的 DataSourceProxy 包装原数据源后,事务管理器获取的连接实际上是 ConnectionProxy。事务管理器调用 con.commit() 时,ConnectionProxy 会进行如下操作:

  • 如果当前有全局事务 RootContext.getXid() != null,则先向 TC 注册分支,并将 undo_log 刷盘,然后才提交本地事务。
  • 如果没有全局事务,直接提交。

这样,Seata 将本地事务的提交做了拦截增强,使之成为全局事务的一个分支。这种无缝集成依赖于 Spring 的事务管理器抽象,Seata 无需修改事务管理器本身。

7.3 xid 传播传递

微服务调用通过 Feign 拦截器传播 xid:

public class SeataFeignRequestInterceptor implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate template) {
        String xid = RootContext.getXid();
        if (xid != null) {
            template.header(RootContext.KEY_XID, xid);
        }
    }
}

下游服务通过 SeataHandlerInterceptor 从 HTTP 头部获取 xid 并绑定到 RootContext,实现全链路上下文传递。这种隐式传参完全遵循了 Spring 的上下文传递模式。

7.4 与 @Transactional 的协同与注意事项

  • @GlobalTransactional 应标注在发起分布式事务的入口方法。
  • 推荐同时添加 @Transactional 管理本地事务,但非必须。
  • 传播行为:默认 Propagation.REQUIRED,如果已存在全局事务则加入,否则新建。
  • 异常回滚:@GlobalTransactional 默认对 RuntimeExceptionError 进行回滚,可通过 rollbackFor 定制。

8. 分布式事务选型决策框架

8.1 决策维度建模

选择分布式事务方案需要权衡以下维度:

  1. 一致性需求:是否允许中间状态、最大延迟容忍度。
  2. 并发量与性能要求:高并发下是否承受全局锁或 undo_log 开销。
  3. 业务逻辑复杂度:是否有资源预留需求、是否包含外部服务。
  4. 开发运维成本:团队的学习曲线、后期的维护难度。
  5. 系统规模与调用链长度:长链路适用 Saga 或消息驱动。

8.2 决策树

flowchart TD
    Start[业务需要分布式事务] --> Q1{是否要求实时强一致<br>且可接受锁冲突?}
    Q1 -->|是| AT[Seata AT 模式]
    Q1 -->|否| Q2{是否涉及资源冻结<br>或金融级预留?}
    Q2 -->|是| TCC[TCC 模式]
    Q2 -->|否| Q3{流程是否冗长<br>包含不可回滚服务?}
    Q3 -->|是| Saga[Saga 模式]
    Q3 -->|否| Q4{是否以异步解耦<br>为主要诉求?}
    Q4 -->|是| MQ[MQ 最终一致性]
    Q4 -->|否| AT
  • Seata AT:通用 CRUD,低并发,需要简单实现分布式事务。
  • TCC:高性能并发,需预占资源(如库存、账户余额),业务自定义锁粒度。
  • Saga:长事务,涉及多个服务,可能有第三方调用,没有资源预留诉求。
  • MQ 最终一致性:异步解耦,无需同步响应,高吞吐场景。

8.3 速查对比表

方案一致性级别并发性能开发量运维复杂度适用场景
Seata AT最终一致 (读已提交+脏写校验)中(全局锁)中(需TC服务器)通用微服务
TCC最终一致 (业务隔离)极高资金、库存
Saga最终一致长流程、第三方
MQ 消息最终一致非常高中(MQ集群)异步、通知、解耦

9. 生产事故排查专题(深度扩展)

本节详述两个因分布式事务使用不当引发的典型线上事故,包括根因分析、排查步骤、解决方案和预防措施。

9.1 案例一:Seata AT 全局锁未释放导致订单大面积超时

事故背景:某电商平台订单服务(TM)和库存服务(RM)通过 Seata AT 模式处理下单流程,日均订单量40万。在一次“秒杀”活动中,流量激增10倍,活动开始10分钟后,大量用户反馈订单提交长时间转圈,最后失败。

现象描述

  • 订单服务线程池耗尽,接口响应超时。
  • 库存服务频繁打印 LockConflictException
  • 数据库监控显示锁等超现象严重,lock_table 记录数飙升到数十万条。

排查步骤

  1. 查看应用日志Seata 客户端输出大量 LockConflictException: get global lock fail, xid=..., branchId=...,表明全局锁竞争激烈。
  2. 检查 TC 端 lock_table
    SELECT count(*), status FROM lock_table GROUP BY status;
    
    发现大量 status=1(已提交)的记录,并未被按时清除。
  3. 分析 TC 日志:发现二阶段提交日志中出现 SocketTimeoutChannelClosed 异常,部分 RM 未收到二阶段成功响应。TC 的 PeriodicCheck 清理任务配置间隔60秒,超时时间默认10分钟,导致全局锁释放严重滞后。
  4. 数据库锁诊断:分析 INFORMATION_SCHEMA.INNODB_LOCK_WAITS,确认存在大量因 SELECT FOR UPDATE 等待行锁的情况,等待链指向全局锁竞争的记录。

根本原因

  • 秒杀活动导致同一商品库存行成为热点,多个全局事务竞争对同一行的全局锁。
  • TC 与部分 RM 之间的二阶段通信因网络拥堵超时,但 TC 却认为分支已完成,未释放锁。
  • PeriodicCheck 清理任务间隔太长,默认锁超时时间10分钟,导致锁长时间堆积,后续请求全部冲突。

解决方案

  1. 紧急操作:手动清理 lock_table 中超过5分钟的记录(需确认对应全局事务已经结束)。
    DELETE FROM lock_table WHERE gmt_modified < DATE_SUB(NOW(), INTERVAL 5 MINUTE);
    
  2. 调整 Seata 参数
    • 减小 lock.retryIntervallock.retryTimes,快速失败而非长时间重试锁。
    • 设置 server.recovery.committingRetryPeriod 为更短值(如30s)。
    • 减小全局锁超时时间 lock.defaultLockTimeout 至60秒,加快过期锁释放。
  3. 业务优化
    • 拆分热点库存行,例:将总库存分散到多个子库存(如库存桶),减少竞争。
    • 引入排队预约机制,秒杀前预先分配库存锁。
  4. 监控加强:增加 lock_table 记录数、全局事务平均时间、锁冲突率等指标监控,设置阈值报警。

预防措施

  • 上线压测必须包含全局锁持有时间、冲突频率等负载。
  • TC 集群部署保证高可用,避免单点网络瓶颈影响二阶段。
  • 做好降级预案,一旦分布式事务异常,可暂时关闭全局事务,改为本地事务+异步补偿。

9.2 案例二:事务发件箱消息重复投递导致对账不平

事故背景:某支付系统通过发件箱+RabbitMQ 实现“支付成功”事件通知账户系统增加积分。某个周末,财务对账发现当天积分赠送总额竟比支付金额多出一倍。

现象

  • 部分用户积分被重复增加。
  • MQ 监控显示某些消息存在重新投递记录,consumer 重试多次。
  • 发件箱 outbox 表中部分消息状态为 SENT,但消费端记录的 message_id 存在重复。

排查步骤

  1. 分析消费者日志:发现同一 message_id 被处理了两次,时间相隔约2分钟。
  2. 检查 RabbitMQ 管理界面:发现队列中存在消息重发的记录(redelivered=true)。原因是消费者处理时间有时较长,触发 RabbitMQ 的确认超时,broker 重新推送。
  3. 检查消费幂等逻辑:消费者并没有根据 message_id 做数据库唯一约束,仅通过 Redis 缓存做重复检查,但 Redis 因内存淘汰策略导致某些 key 被清理,第二次消费时 Redis 中无记录,造成重复处理。
  4. 发件箱状态检查:outbox 表正常,消息只发送了一次,SENT 状态正常,问题出在消费端。

根因

  • 消费幂等依赖了不可靠的缓存(Redis eviction),且没有持久化防护。
  • 消费者处理时间不稳定,网络抖动导致 ack 超时,MQ 重复投递,缺乏业务级别的幂等控制。

解决方案

  1. 立即修复:在消费者数据库中创建 consumer_record 表,message_id 字段建立唯一索引。
    try {
        consumerRecordDao.insert(new ConsumerRecord(messageId));
        // 业务处理...
    } catch (DuplicateKeyException e) {
        // 确认已处理,直接返回
    }
    
  2. 优化消费者:将重业务逻辑拆分,确保消费速度稳定,或合理设置 prefetch count 和 ack 超时。
  3. 增加保险:即使数据库幂等成功,也可在业务表上增加版本号(乐观锁)双重保障。

预防措施

  • 设计幂等方案时,必须依赖持久化存储(DB)。
  • 对所有消费者,强制要求实现幂等,并编写集成测试验证重复消息场景。
  • 完善消息链路监控,包括投递次数、重复率、死信数量等。

10. 面试高频专题(深度扩展)

此处精选15道面试题,覆盖理论、源码、设计,提供详细的答案解析,助你从容面对分布式事务的考察。

Q1: CAP 理论在分布式事务中如何体现?Seata AT 属于 CP 还是 AP?

: CAP 定理指出任何分布式系统只能同时满足一致性、可用性和分区容错性中的两个。由于网络分区不可避免,系统必须选择 CP 或 AP。

  • CP 系统:出现分区时,为了保证一致性,系统可能取消部分请求或阻塞等待,如 XA 二阶段提交。
  • AP 系统:分区时保证可用性,接受短时不一致,事后达成最终一致,如 MQ 消息方案。

Seata AT 模式通过全局锁提供写隔离(防脏写),同时一阶段本地已经提交,其他事务可读到未全局提交的数据(脏读),因此隔离级别弱于串行化。在锁竞争下,全局锁会阻塞并发请求,在锁超时前,系统倾向于一致性而牺牲部分可用性(获取锁失败的请求抛异常)。因此整体上Seata AT 偏向 CP,但并非严格的 CP(因为有脏读可能)。而 TCC、Saga、MQ 则更偏 AP。

Q2: 对比 Seata AT 的一阶段和二阶段,解释为什么一阶段本地提交就能保证最终一致性?

: AT 一阶段执行了实际的业务 SQL,并同时生成了 undo_log 记录,此时本地事务提交,业务数据和 undo_log 一同持久化。二阶段如果全局提交,RM 只是异步删除 undo_log;如果全局回滚,RM 根据 undo_log 中保存的前镜像执行反向 SQL 恢复数据。这种机制的核心在于 undo_log 充当了回滚快照,所以一阶段可以大胆提交,因为回滚时能通过快照精确恢复。同时,通过 脏写校验 可以检测出在一阶段提交后到回滚前,数据是否被其他事务修改,若被修改则回滚失败,需要人工介入。因此,只要保证 undo_log 的可靠性和二阶段的重试机制,最终一致性得到满足。

Q3: Seata AT 的 undo_log 如何生成?为什么需要前后镜像?

  • Seata 通过 SQLRecognizer 解析业务 SQL,得到表名、列名、条件、类型。
  • 对于 INSERT:前镜像为空,后镜像通过 SELECT * FROM table WHERE pk=? FOR UPDATE 查询刚插入的行。
  • 对于 UPDATE:执行前先通过 SELECT * FROM table WHERE ... FOR UPDATE 获取前镜像,执行更新后再获取后镜像。
  • 对于 DELETE:执行前查询前镜像。

前后镜像记录了数据变更的完整状态,回滚时 UPDATE 类型可以用前镜像作为 SET 子句的值,将数据恢复原状;DELETE 类型用前镜像生成 INSERT;INSERT 类型生成 DELETE。前镜像保证能够恢复原始数据,后镜像用于脏写校验(对比当前数据是否与后镜像一致,判断是否被篡改)。

Q4: Seata 全局写隔离是怎样实现的?lock_table 的具体作用和加锁流程?

: Seata 使用 TC 端的 lock_table 来实现全局写隔离。表结构包含 row_keyresourceId + tableName + pk组合),唯一索引保护。

加锁流程

  1. 一阶段分支注册时,RM 解析 SQL 提取影响行的主键,组装 LockDO
  2. 向 TC 发起锁获取请求,TC 执行 INSERT INTO lock_table VALUES(...),依靠唯一索引,若插入成功则获取锁;若冲突则锁已被其他事务持有。
  3. 如果获取失败,RM 会根据配置重试或直接抛出 LockConflictException

释放:最终全局提交或回滚时,TC 删除对应的锁记录。全局锁配合本地事务的 SELECT FOR UPDATE,确保了写写互斥,防止更新丢失。

Q5: TCC 模式中的空回滚、悬挂是什么?如何解决?

  • 空回滚:Cancel 比 Try 先执行的情况。如 Try 阶段网络超时,TM 发起 Cancel,但 Try 还没执行。此时 Cancel 需要识别没有 Try 记录,直接返回成功,防止错误撤销。
  • 悬挂:Cancel 比 Try 先到达并成功执行,后续迟到的 Try 尝试执行。为了防止资源被错误预留,Try 操作需要检查是否已有 Cancel 记录,若存在则拒绝执行。

解决方案:在业务数据库中设计一张事务状态表,记录全局事务的分支阶段状态。Try 执行前检查是否有成功的 Cancel 记录;Cancel 执行前检查 Try 是否执行过。同时所有操作基于 xid + branch_id 做幂等处理,利用唯一约束保证。

Q6: 基于 MQ 的分布式事务,如何保证消息发送和业务操作的原子性?

: 通过事务发件箱模式。将业务数据和消息数据写入同一本地数据库事务,数据库保证原子性。之后后台任务(或 CDC)可靠地将消息投递到 MQ。如果 MQ 投递成功,更新 outbox 状态;如果投递失败,可重试。消费端做好幂等,整个链路达到最终一致。

Q7: Seata AT 模式下,为什么仍然可能出现脏读?业务上如何避免?

: 因为 AT 一阶段本地事务已经提交,释放了数据库行锁。在全局事务二阶段提交前,其他非 Seata 管理的事务或者 Seata 读操作(未被代理忽略)可以直接读到已提交的一阶段数据。如果全局事务最终回滚,这些读到的数据就成了脏读。避免手段:

  • 将读操作也纳入全局事务控制,通过 @GlobalTransactional 并使用 SELECT FOR UPDATE 加锁。
  • 业务设计上容忍短暂的不一致,通过补偿任务修复。

Q8: 如何设计一个秒杀系统的分布式事务方案?请对比不同的实现。

: 秒杀核心链路:下单→扣减库存→支付。

  • Seata AT:最简单,但热点库存行会导致全局锁冲突严重,不适合高并发秒杀。
  • TCC:库存预先冻结(Try),业务可控制锁粒度,支持极高并发,适合秒杀。但需要实现 Confirm/Cancel。
  • MQ 最终一致:用户请求先快速落库订单,通过消息异步扣库存,可配合 Redis 预减库存,最终一致。但需要解决超卖。 一般优化手段会结合Redis 预减库存 + 异步 MQ,不属于严格分布式事务,而是最终一致性+业务控制。

Q9: Seata 的 PeriodicCheck 任务是怎么防止死锁的?

PeriodicCheck 定时任务扫描 lock_table,检查锁的修改时间,如果超过指定超时(如10分钟),则强制删除锁记录。这避免了因网络分区或 TC 异常导致全局锁永不释放的死锁问题。但同时,强制删除锁可能导致正在回滚的分支事务感知不到锁释放而出现不一致,Seata 通过二阶段重试和日志保障最终一致。

Q10: 什么是 Saga 模式的补偿操作?与 TCC 的 Cancel 有什么区别?

: Saga 补偿操作是对正向事务的“语义反操作”,如退款对应支付,取消订单对应创建订单。补偿操作不要求 Try 阶段的资源预留,所以实现时容易缺失前置状态,需要保证补偿也能幂等且不受中间状态影响。而 TCC 的 Cancel 是建立在 Try 已经预留资源的基础上,Cancel 只需释放这些预留资源即可,通常比 Saga 补偿简单,但前提是 Try 必须存在。Saga 补偿更像一个独立的业务操作。

Q11: 为什么 Seata AT 不需要 XA 协议中的协调者持久化事务日志?

: Seata AT 将回滚所需的日志存储在业务库的 undo_log 表中,而非 TC 端。TC 只维护全局事务状态和锁信息(global_tablebranch_tablelock_table),这些信息相对轻量,并且 TC 本身可以使用数据库持久化。这种设计使得 RM 可以独立管理回滚数据,减轻了 TC 的存储压力,也有利于性能。

Q12: 在 Spring 中,如何处理 @GlobalTransactional@Transactional 的嵌套?

: 通常 @GlobalTransactional 在最外层,@Transactional 在内层,对应分布式全局事务和本地事务的关系。本地事务的提交由 Seata 代理的 DataSourceProxy 控制:在全局事务内,本地事务的提交会延迟到一阶段完成,但业务数据已经落地。注意,如果内部方法抛异常,会被 @Transactional 捕获并标记回滚,但 @GlobalTransactional 可能根据异常类型决定全局回滚,两者异常处理需要一致,避免内层回滚但外层提交的冲突。

Q13: 比较 Seata AT 模式的性能瓶颈和优化方式。

: 瓶颈:

  • undo_log 写入带来的额外 I/O。
  • 全局锁竞争,尤其热点数据。
  • 二阶段异步通信开销。

优化:

  • 缩短全局事务边界,尽早一阶段提交。
  • 对热点数据进行拆分(如库存分桶)。
  • 合理配置全局锁重试策略,快速失败。
  • 批量处理 undo_log 记录。
  • 监控 TC 状态。

Q14: 什么是分布式事务中的“幂等消费”?如何实现?

: 幂等消费是指同一个消息无论被消费多少次,产生的结果与一次消费相同。实现方式:

  • 数据库唯一约束:记录消息 ID,插入消费记录表,利用唯一索引。
  • Redis tokenSETNX 加锁,但要注意持久化策略。
  • 业务状态机:通过业务状态判断,如订单已支付则忽略重复消息。

推荐数据库唯一约束作为兜底,因为持久化抗故障。

Q15: 系统设计题:设计一个支持分布式事务的“电商订单系统”,包括订单、库存、支付服务,请阐述方案。

: 可采用 TCC 或 AT 结合 MQ 的混合方案。以 TCC 为例:

  • 订单服务 (Try):创建订单,状态为 PENDING,预扣优惠券等。
  • 库存服务 (Try):冻结库存,记录冻结数量。
  • 支付服务:发起支付前,需要 Try 预扣账户余额(冻结)。
  • Confirm:全部 Try 成功后,逐阶段 Confirm,订单状态变为 CONFIRMED,库存实际扣减,余额实际扣款。
  • Cancel:任何 Try 或 Confirm 失败,执行 Cancel 释放冻结资源。
  • 通过 TCC 框架(如 Seata TCC 模式)协调。
  • 幂等和防悬挂:所有接口基于全局事务 ID 记录执行状态。
  • 降级方案:如果分布式事务不可用,可降级为本地事务 + 异步对账补偿,保证最终一致。

此外,可结合 MQ 发运单通知等解耦操作,实现核心链路强一致(CP 倾向)、非核心链路最终一致的组合策略。


分布式事务方案对比速查表

特性Seata ATTCCSagaMQ 发件箱
一致性最终一致(读已提交)最终一致(业务锁)最终一致最终一致
隔离性写隔离(全局锁+本地锁)业务隔离
性能瓶颈全局锁竞争,undo I/O低(无全局锁)极低
开发量极高
回滚机制自动反向SQLCancel服务补偿服务补偿消息/人工
适用场景通用低并发微服务金融、高并发库存长流程、第三方异步解耦、通知
运维需求TC集群TCC注册中心状态机引擎MQ集群+发件箱

延伸阅读

  • Seata 官方文档 & GitHub 源码
  • 《分布式事务关键技术》华钟明
  • 《数据密集型应用系统设计》 Martin Kleppmann
  • Spring Cloud 微服务分布式事务实践资料

总结:分布式事务是一场深刻的范式转移,从集中式数据库 ACID 走向 BASE 最终一致。Spring 生态提供了丰富的整合选择,本文深入剖析了 Seata AT 的源码引擎、TCC 资源预留与 Saga 补偿,以及基于 MQ 的发件箱模式。每一种方案都是对 CAP 的权衡结果,没有完美解,只有最合适的解。通过构建选型决策树和吸收生产事故教训,相信读者可以在实际架构中从容应对分布式一致性的挑战。