理解分布式事务,首先要明白它的核心矛盾:如何在分布式系统(多个独立数据库或微服务)中,保证原本在单机数据库中轻而易举实现的 ACID 特性?

分布式事务的理论基石主要由 CAP 定理BASE 理论以及一致性协议三部分组成。


1. CAP 定理(分布式系统的枷锁)

CAP 定理指出,在一个分布式系统中,以下三个要素最多只能同时实现两点:

  • C (Consistency) 一致性: 所有节点在同一时间看到的数据是一样的(等同于强一致性)。

  • A (Availability) 可用性: 即使部分节点故障,系统依然能正常响应请求。

  • P (Partition Tolerance) 分区容错性: 节点之间的网络断开(分区)时,系统仍能继续运行。

结论: 在分布式架构中,网络分区(P)是客观存在的,因此我们只能在 AP(追求可用性,放弃强一致)或 CP(追求一致性,放弃可用性)之间做权衡。


2. BASE 理论(现实的妥协)

由于 CAP 中强一致性(C)很难在高性能场景下达成,BASE 理论提出了一种通过“最终一致性”来兼顾性能和可用性的思路。

  • BA (Basically Available) 基本可用: 系统出现故障时,允许损失部分可用性(比如响应时间延长、非核心功能降级)。

  • S (Soft State) 软状态: 数据存在中间状态(如“处理中”),允许不同节点间的数据同步存在延迟。

  • E (Eventually Consistent) 最终一致性: 系统不需要实时一致,但经过一段时间后,所有数据副本必须达到一致。


3. 核心一致性协议与模型

为了落地上述理论,工业界演化出了几种经典的实现协议:

2PC (Two-Phase Commit) - 二阶段提交

这是强一致性(CP)的代表方案。

  1. 投票阶段: 协调者询问所有参与者,是否可以提交。

  2. 执行阶段: 如果所有人都同意,则正式提交;只要有一个反对或超时,则全部回滚。

  • 问题: 同步阻塞、单点故障、脑裂问题(如果第二阶段网络断开,会导致数据不一致)。

3PC (Three-Phase Commit) - 三阶段提交

在 2PC 基础上引入了 CanCommit 阶段超时机制

  • 改进: 减少了阻塞范围,但并未从根本上解决网络分区导致的一致性问题。

TCC (Try-Confirm-Cancel)

这属于补偿事务,属于应用层的柔性事务。

  • 通过业务代码手动预留资源(Try)、确认(Confirm)和撤销(Cancel)。

  • 特点: 性能好,但对业务侵入性极强,需要处理幂等性。

Saga 模式

一种长事务解决方案,将大事务拆分为多个本地事务。

  • 每个业务节点执行自己的事务,如果其中一步失败,则反向执行前面所有步骤的“补偿操作”。

  • 适用: 业务流程长、参与者多的场景。


4. 总结对比

特性 2PC / 3PC TCC Saga
一致性 强一致 (CP) 最终一致 (AP) 最终一致 (AP)
资源锁定 长期锁定 (性能低) 短期锁定 (性能高) 无锁定
开发难度 低 (框架实现) 高 (需写补偿逻辑)
场景 数据库底层、ERP 金融支付、核心链路 外部接口集成、长流程

 

seata使用

 

为了让你直观理解 Seata AT 模式的工作流,我们以最经典的**“电商下单”场景为例:用户下单后,需要同时调用订单微服务**(创建订单)和库存微服务(扣减库存)。

如果扣减库存成功但创建订单失败,Seata 必须保证库存能自动回滚。


1. 业务架构图

在这个例子中,我们有两个独立的数据库和两个微服务,通过 Seata TC 进行全局事务管理。


2. 准备工作:undo_log

每一个参与分布式事务的业务数据库中,都必须创建这张表(Seata 依赖它来记录回滚镜像)。

SQL
 
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 AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

3. 代码实现

步骤 A:引入依赖 (Maven)

在 Spring Boot 项目中引入 Seata Starter。

 

 

XML
 
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

步骤 B:在调用方开启全局事务

这是最关键的一步。在“业务发起方”(通常是订单服务)的方法上加上 @GlobalTransactional 注解。

Java
 
@Service
public class BusinessService {

    @Autowired
    private StockFeignClient stockFeignClient; // 调用库存微服务
    @Autowired
    private OrderMapper orderMapper;           // 本地订单操作

    /**
     * 下单业务:全局事务入口
     */
    @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
    public void placeOrder(String userId, String commodityCode, int count) {
        
        // 1. 远程调用库存服务:扣减库存
        // RM (库存服务) 会拦截 SQL,写入 undo_log,并提交本地事务
        stockFeignClient.deduct(commodityCode, count);

        // 2. 本地操作:创建订单
        // RM (订单服务) 同样写入 undo_log,并提交本地事务
        Order order = new Order(userId, commodityCode, count);
        orderMapper.insert(order);

        // 如果此处抛出 RuntimeException,TC 会通知所有 RM 根据 undo_log 回滚
    }
}

步骤 C:被调用方(从服务)

库存微服务只需要像往常一样编写普通的本地事务代码即可,Seata 的代理数据源会自动处理分布式逻辑。

Java
 
@RestController
public class StockController {

    @Transactional // 普通本地事务
    @RequestMapping("/stock/deduct")
    public void deduct(String commodityCode, int count) {
        // SQL: update stock_tbl set count = count - ? where commodity_code = ?
        stockMapper.updateStock(commodityCode, count);
    }
}

4. 事务执行过程拆解

  1. TM (订单服务)TC (Seata Server) 申请开启一个全局事务,拿到一个全局唯一的 XID

  2. 库存微服务 执行时,发现有 XID,它会作为 RM 注册到 TC。

  3. 库存微服务 在更新数据库前,将当前库存值存入 undo_log,然后更新库存并提交本地事务(释放数据库锁)。

  4. 订单微服务 执行本地插入,同样存入 undo_log 并提交本地事务。

  5. 最终结果

    • 成功:TM 通知 TC 提交,TC 异步通知各 RM 删除对应的 undo_log 记录。

    • 失败:TM 通知 TC 回滚,TC 通知各 RM 按照 undo_log 里的数据反向还原数据库。


5. 关键点提示

  • 代理数据源:在旧版本 Seata 中需要手动配置 DataSourceProxy,但在最新的 Spring Cloud Alibaba 中,只需配置 seata.enable-auto-data-source-proxy=true 即可自动完成。

  • 服务注册:TC Server、订单服务、库存服务都需要注册到同一个 Nacos 注册中心,否则它们找不到彼此。

 

 

库存微服务为何知道是一个分布式事务,往undo_log插入数据

这是一个非常硬核的问题,触及了 Seata 的底层设计灵魂。

库存微服务之所以能“无感知”地参与到分布式事务中,主要靠的是 XID 的透传(Propagation)数据源代理(DataSource Proxy) 这两套组合拳。


1. 它是怎么知道有事务的?(XID 的奇幻漂流)

当你在订单服务(TM)上开启 @GlobalTransactional 时,Seata 会生成一个全局唯一的事务 ID,叫做 XID

  1. 埋点(拦截输出): 订单服务在通过 Feign 或 RestTemplate 调用库存服务时,Seata 的拦截器会悄悄地把这个 XID 塞进 HTTP 的 Header 里(默认 Header 名是 TX_XID)。

  2. 接收(拦截输入): 库存服务收到请求时,它的 Seata 拦截器会从 Header 中取出这个 XID。

  3. 绑定(上下文): 取出的 XID 会被放入当前线程的 RootContext(本质是一个 ThreadLocal)。

结论: 库存微服务通过检查自己的线程上下文(ThreadLocal)里有没有 XID,就能判断当前执行的代码是否处于一个分布式事务中。


2. 谁负责往 undo_log 塞数据?(数据源代理)

库存微服务的业务代码只写了普通的 SQL,它并没有显式操作 undo_log。这个脏活累活是由 Seata 代理数据源(DataSourceProxy) 偷偷完成的。

在 Spring 容器启动时,Seata 会把你的 DruidDataSourceHikariDataSource 包装成自己的 DataSourceProxy。当你执行 SQL 时,流程如下:

第一步:SQL 解析与前镜像

stockMapper.update() 被调用时,代理数据源会:

  • 识别出这是一条 UPDATE 语句。

  • 根据 SQL 条件,自动生成一条 SELECT * FROM stock_tbl WHERE ...,把修改前的数据查出来,存在内存里(Before Image)。

第二步:执行业务 SQL

  • 执行你原本的那条更新库存的 SQL。

第三步:后镜像获取

  • 再次执行那条 SELECT,把修改后的数据查出来(After Image)。

第三步:写入 undo_log

  • 代理数据源会将 Before ImageAfter Image 以及你的 SQL 语句 序列化成 JSON。

  • 在同一个本地事务中,自动生成一条 INSERT INTO undo_log ... 的 SQL 并执行。

第四步:本地提交

  • 提交本地事务(业务 SQL 和 undo_log 同时提交)。

  • 在提交前,它还会去向 TC 申请这行数据的全局锁,确保别人不能乱动。


总结:库存服务的视角

对于库存服务的开发人员来说:

  • 他只看到了一次 HTTP 请求。

  • 他只运行了一段带有 @Transactional 的本地代码。

但对于底层运行环境来说:

  1. 拦截器:发现 Header 有 XID,绑定到线程。

  2. 数据源代理:发现线程有 XID,开始解析 SQL、查快照、写 undo_log

这就是 Seata AT 模式**“无侵入”**的真相:所有的复杂逻辑都被隐藏在拦截器和动态代理之后了。

这就是为什么你必须在 application.yml 里开启 seata.enable-auto-data-source-proxy=true 的原因。

 

库存服务update预计和insert undo_log是分开执行的吗,如果是的话两个语句不是同一个事务

这是一个非常精准的观察!如果你认为它们是分开执行的,确实会面临“库存减了但没记账”或者“记了账但库存没减”的一致性风险。

但事实是:库存服务的 UPDATE 业务 SQL 和 undo_logINSERT 语句是在同一个本地数据库事务中执行并提交的。


1. 它是如何强行“捆绑”在一起的?

Seata 通过数据源代理(DataSource Proxy) 接管了数据库连接(Connection)。当你执行业务代码时,底层发生了以下神奇的操作:

  1. 开启本地事务: 当代码运行到 @Transactional 时,代理连接(ConnectionProxy)会关闭 autoCommit,即:connection.setAutoCommit(false)

  2. 执行业务 SQL: 比如 UPDATE stock_tbl SET count = count - 1...。此时数据在数据库缓存中变了,但还没持久化。

  3. 插入 Undo Log: 代理对象通过刚才拦截到的 SQL,自动生成 INSERT INTO undo_log...注意:它是直接复用了当前的那个 Connection 对象。

  4. 一并提交: 只有当这两条 SQL 都执行成功后,代理对象才会调用 connection.commit()


2. 为什么必须是同一个事务?

根据 ACID 原则中的原子性(Atomicity),如果它们不在一个事务里:

  • 先写业务 SQL,再写 undo_log: 如果写完业务 SQL 还没来得及写 undo_log 机器断电了,全局事务回滚时找不到记录,数据就彻底乱了。

  • 先写 undo_log,再写业务 SQL: 如果业务 SQL 失败了,undo_log 却写进去了,回滚时会尝试把本来就没变的数据改回去,导致数据错乱。

结论: Seata 代理确保了这两者“同生共死”。只要本地事务提交成功,就意味着:数据改了 + 回滚证据留下了


3. 全局锁的获取时机(进阶细节)

虽然本地事务是一起提交的,但在调用 connection.commit() 之前,Seata 代理还多做了一件事:向 TC 申请全局锁

  • 申请锁: 告诉 TC,“我要修改商品 A 的库存,请锁定这条记录”。

  • 如果拿不到锁: 本地事务会回滚,报错退出。

  • 如果拿到锁: 执行本地 commit

这种机制保证了即便本地事务释放了,但在全局事务结束前,其他开启了 @GlobalTransactional 的线程没法修改这行数据,从而避免了脏写


4. 如果没有这个代理会怎样?

如果你关闭了 enable-auto-data-source-proxy,或者直接通过原生 JDBC 手写两个 SQL 且没在一个事务里:

  1. 库存减掉了。

  2. 程序崩了,undo_log 没写成。

  3. 结果: 整个分布式事务失败,订单没生成,但库存少了 1 个,还没法自动找回。这就是分布式事务中最头疼的“数据幽灵”。

 

 

Seata 的性能损耗主要在哪里

既然你对性能损耗感兴趣,咱们就得拆开来看看,Seata AT 模式为了实现这种“自动补救”功能,到底在哪些地方“收了过路费”。

客观来说,AT 模式的性能损耗主要集中在 SQL 解析、两次快照查询、全局锁申请 以及 UNDO_LOG 写入


1. 性能损耗的四个关键点

① SQL 解析与重写

Seata 需要拦截你的 SQL 并解析它。比如它要分析出你的 UPDATE 语句改的是哪张表、哪一行、哪个字段。

  • 影响: 纯内存操作,损耗极小(微秒级),可以忽略不计。

② 快照查询(Before/After Image)

这是最大的损耗点之一。

  • 操作: 在执行你的业务 UPDATE 之前,Seata 必须先跑一个 SELECT 把旧值查出来;更新完后,可能还要再查一次新值。

  • 代价: 原本 1 条 SQL 变成了 1 条业务 SQL + 1 或 2 条查询 SQL

  • 优化: 如果你的业务 SQL 本身就是根据主键(PK)更新,查快照会很快;如果是大范围更新,性能下降会非常明显。

③ 全局锁的申请 (Global Lock)

在本地事务提交前,RM 必须向 TC 询问:“这行数据的全局锁我能拿吗?”。

  • 代价: 这是一个 网络 IO 过程。

  • 影响: 如果 TC 部署在远端,或者网络抖动,会直接拉长本地事务的持有时间。

  • 优化: Seata 会尝试批量申请锁来减少往返次数。

④ UNDO_LOG 的持久化

  • 操作: 将快照 JSON 化并写入数据库。

  • 代价: 增加了一次数据库写操作。由于是和业务 SQL 在同一个本地事务,这会增加 redo log 的刷盘负担。


2. AT 模式 vs. 原生 SQL 性能对比

指标 原生事务 (单机) Seata AT 模式
数据库 IO 1 次写 1 次写 + 2 次读 (快照) + 1 次写 (Undo)
网络 IO 0 次 1 次 (申请全局锁)
资源锁定时间 本地执行时间 本地执行时间 + 网络申请锁时间

经验数值: 在良好的网络环境下,AT 模式通常会带来 20% - 35% 左右的吞吐量下降。


3. 既然有损耗,为什么大家还爱用?

虽然有损耗,但 AT 模式有一个“杀手锏”:二阶段异步化。

在传统 2PC 中,二阶段(提交/回滚)是非常沉重的。但在 Seata AT 中:

  • 如果全局提交: TC 只要下发指令,RM 直接在后台异步删除 undo_log 就行了,完全不阻塞业务主线程。

  • 如果全局回滚: 虽然回滚慢,但回滚毕竟是小概率事件(不到 1%)。

这种“一阶段沉重、二阶段极轻”的设计,在大多数互联网场景下是性能最优解。


4. 优化建议(避坑指南)

如果你觉得系统慢了,可以从以下几个维度优化:

  1. 缩短事务链路: 只在必须保证一致性的地方加 @GlobalTransactional,不要把查询操作也裹进去。

  2. 避免大事务: 一个事务里更新 10000 行数据,Seata 会生成 10000 条快照,内存和 IO 会瞬间爆炸。

  3. TC 选址: 确保 Seata Server (TC) 与微服务在同一个内网,降低申请全局锁的时延。

  4. 索引优化: 确保业务 SQL 的过滤条件都有索引,否则 Seata 查快照时会触发全表扫描。

总结一下:AT 模式是用一定的性能损耗换取了极高的开发效率。如果你的场景真的对性能敏感到了毫秒级(如抢购、秒杀),建议改用 TCC 模式或消息队列异步补偿方案。

 

Seata 是如何通过“全局锁”来保护数据不被脏写

简单来说,Seata 的全局锁是为了弥补**“本地事务过早提交”**留下的安全漏洞。

在 AT 模式下,为了高性能,本地事务在一阶段就结束了。如果没有全局锁,这行数据就像一个没有保安的银行仓库,任何人都能进来改。

以下是 Seata 通过全局锁防止“脏写”的具体策略:


1. 锁的层级:双重锁定机制

要成功修改一条数据,必须集齐两把锁:

  • 本地锁 (Local Lock): 由数据库(如 InnoDB)提供,锁定的是物理行。

  • 全局锁 (Global Lock): 由 Seata TC 提供,锁定的是 服务名 + 表名 + 主键

写入流程:

  1. 事务 A 获取本地锁,更新数据。

  2. 事务 A 在本地提交前,向 TC 注册分支事务并申请全局锁

  3. 事务 A 获取全局锁成功,提交本地事务,释放本地锁(但此时仍持有全局锁)。

  4. 事务 B 进来想改同一行,先拿到本地锁

  5. 事务 B 去申请全局锁,发现被 A 占用了,于是 B 只能本地回滚并重试(或超时报错)。


2. 核心防御:读写隔离与回滚校验

全局锁主要解决以下两个致命场景:

场景 A:防止其他分布式事务“插队”(脏写)

如果事务 A 正在处理分布式事务,事务 B 也通过 Seata 修改同一行。

  • 结果: B 会因为拿不到全局锁而阻塞,直到 A 提交(释放锁)或回滚(补偿完数据后释放锁)。

  • 意义: 保证了分布式环境下的 写-写隔离

场景 B:针对非 Seata 管理的 SQL(数据校验)

如果有人直接用命令行或者旧代码(没走 Seata 代理)强行修改了数据库:

  • 结果: 此时全局锁拦不住这个 SQL。

  • Seata 的最后防线: 当事务 A 准备回滚时,RM 会对比数据库里的“当前值”和 undo_log 里的“后镜像”。

    • 如果发现不相等,说明数据被外界偷偷改了(脏写发生)。

    • 处理: 为了防止回滚导致更严重的数据错乱,Seata 会拒绝回滚,将该事务标记为异常并发出告警,要求人工介入。


3. 全局锁的性能损耗如何?

你会担心 lock_table(TC 端的锁记录表)变成性能瓶颈吗?Seata 做了这些优化:

  • 内存锁结构: 在 TC 端,全局锁通常存储在内存缓存中,查询极快。

  • 批量申请: 如果一个本地事务修改了 10 行,RM 会一次性向 TC 申请这 10 行的全局锁,减少网络开销。

  • 异步释放: 全局提交时,TC 只需要标记锁释放,不需要等待所有微服务反馈。


4. 特殊配置:@GlobalLock

如果你只想做单纯的“查询”,但又怕查到别的分布式事务正在修改的中间数据(脏读),你可以使用 @GlobalLock 注解。

  • 这会让这个查询 SQL 也去检查一下有没有全局锁,如果有,就等别人处理完再查。

总结

全局锁 = 逻辑占位符。 它确保了在“本地事务已提、全局事务未结”的真空期内,数据依然处于逻辑锁定状态。

在 Seata Server (TC) 端,如果你选择了 db 存储模式,你会发现数据库中有一张非常核心的表:lock_table。它就像是分布式系统的“登记簿”,记录了谁拿走了哪行数据的锁。

以下是 lock_table 的标准 DDL 定义(以 MySQL 为例)及其各个字段的含义:

1. 表结构定义 (DDL)

SQL
 
CREATE TABLE `lock_table` (
  `row_key`        VARCHAR(128) NOT NULL, -- 组合主键:resource_id + table_name + pk
  `xid`            VARCHAR(128) NULL,     -- 全局事务 ID
  `transaction_id` BIGINT       NULL,     -- 全局事务 ID 的数字部分(优化查询用)
  `branch_id`      BIGINT       NOT NULL, -- 分支事务 ID
  `resource_id`    VARCHAR(256) NULL,     -- 资源 ID (通常是微服务的数据库连接 URL 或 Name)
  `table_name`     VARCHAR(32)  NULL,     -- 业务表名
  `pk`             VARCHAR(36)  NULL,     -- 业务表的主键值
  `gmt_create`     DATETIME     NULL,     -- 创建时间
  `gmt_modified`   DATETIME     NULL,     -- 修改时间
  PRIMARY KEY (`row_key`),
  INDEX `idx_xid` (`xid`)                 -- 方便通过 XID 批量释放锁
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

2. 字段详细解析

  • row_key (主键):

    这是最关键的字段。它的格式通常是 resource_id:table_name:pk。由于它是主键,利用了数据库的唯一性索引。如果两个事务同时尝试插入相同的 row_key,后到的那个就会因为“主键冲突”而失败,这就是全局锁竞争失败的底层原理。

  • xid:

    标记这把锁属于哪个全局事务。当事务提交或回滚时,TC 会根据 XID 快速找到所有相关的锁并删除。

  • pk:

    保存的是业务数据的具体主键(比如订单 ID 1001)。

  • resource_id:

    区分不同的数据库。即使两个微服务的表名都叫 t_order,只要 resource_id 不同,它们的锁就不会冲突。


3. 这个表是怎么工作的?

  1. 申请锁:RM 在本地事务提交前,把要修改的数据主键发给 TC。

  2. 写入记录:TC 尝试往 lock_table 插入一条记录。

    • 插入成功:说明没人占用,拿到全局锁,RM 继续提交本地事务。

    • 插入失败 (Duplicate Key):说明别的事务还没释放这行锁。此时 RM 会重试或报错(取决于配置)。

  3. 释放锁

    • 全局提交:TC 接收到提交信号,异步删除 lock_table 中对应 XID 的记录。

    • 全局回滚:RM 完成补偿(把数据改回去)后,TC 才删除记录。


4. 性能与注意事项

  • 索引压力:由于每次写操作都要查/写这个表,如果你的业务主键(pk)非常长,或者并发量极大,这个表会成为瓶颈。建议使用自增 ID 或短 UUID。

  • 清理机制:正常情况下 Seata 会自动清理。但如果 TC 异常宕机且没配置好,可能会残留“幽灵锁”。重启 TC 后,它会根据 global_table 的状态重新校验并清理。

  • Redis 模式:如果你追求极致性能,可以将 store.mode 改为 redis。在 Redis 中,锁不再是表记录,而是一个个 Hash 结构,响应速度会提升一个量级。

现在你已经看过了“后悔药”(undo_log) 和“登记簿”(lock_table),是否想了解当发生“死锁”(例如 A 等 B 的本地锁,B 等 A 的全局锁)时,Seata 是如何自救的?