跳转到内容
123xiao | 无名键客

《微服务架构中分布式事务的落地实践:基于 Seata 的一致性设计与性能权衡》

字数: 0 阅读时长: 1 分钟

背景与问题

微服务拆开之后,单体时代那种“一个本地事务包住所有写操作”的日子就结束了。

最典型的场景是:下单、扣库存、冻结余额、生成物流单,这些动作分散在不同服务、不同数据库里。只要其中一步失败,就会出现经典问题:

  • 订单创建成功,但库存没扣
  • 库存扣了,支付冻结失败
  • 支付成功了,但订单状态没更新
  • 某个服务超时重试,导致数据被重复修改

如果系统只追求“功能先跑起来”,往往会走到两种极端:

  1. 全靠业务补偿:写起来快,但补偿链路越来越复杂,最后谁都不敢动。
  2. 强上 2PC/XA:理论上强一致,但性能、锁持有时间、数据库兼容性经常不适合互联网业务。

这也是 Seata 出场的地方:它不是银弹,但在很多“数据库 + 微服务”的业务里,能在一致性、性能、侵入性之间找到一个比较务实的平衡点。

本文我会从架构视角讲清楚几件事:

  • Seata 到底解决了什么问题
  • AT / TCC / Saga / XA 在实践里怎么选
  • 一个订单场景怎么真正落地
  • 出问题时该看哪里、怎么排查
  • 一致性设计与性能之间如何做取舍

先讲结论:不是所有业务都该上分布式事务

在落地之前,我建议先做一层分类。因为很多团队不是“不会用 Seata”,而是用错了场景

适合使用 Seata 的场景

  • 多个服务分别操作自己的数据库
  • 核心链路要求较高一致性
  • 可以接受一点性能损耗
  • 服务之间已是同步调用,链路相对清晰
  • 数据库主要是 MySQL,且 SQL 规范较稳定

不太适合直接上 AT 的场景

  • 长事务、人工审核、跨小时流程
  • 大量复杂 SQL、批量更新、异步写入
  • 高并发热点行更新
  • 非关系型存储为主
  • 服务已经是事件驱动、最终一致为主的系统

一个很实际的经验是:核心资金、库存预占这类短事务,可以考虑 Seata;长流程审批、营销任务编排,更适合 Saga 或消息最终一致


方案对比与取舍分析

Seata 不只有一种模式。别一上来就把 AT 当成唯一答案。

四种常见模式对比

模式一致性侵入性性能适用场景
AT最终接近强一致基于关系型数据库的短事务
TCC中高核心业务、强业务控制
Saga最终一致长流程、可补偿业务
XA强一致低到中少量高一致场景,数据库支持好

一个简化判断方法

  • 能容忍短时间不一致,并且想快速落地:优先 AT
  • 业务动作天然有预留/确认/取消:优先 TCC
  • 链路很长,节点很多,有人工环节:优先 Saga
  • 场景极少但必须严格原子:谨慎评估 XA

我自己做架构设计时,通常会问三个问题:

  1. 失败后能不能补?
  2. 补偿动作是不是可靠且幂等?
  3. 热点数据会不会被全局锁打爆?

这三个问题,基本决定了你该用哪种模式。


核心原理

本文重点讲落地最常见的 Seata AT 模式,因为它对业务代码侵入最小,也是很多团队第一步实践的入口。

Seata 的角色

  • TC(Transaction Coordinator):事务协调器,负责全局事务状态管理
  • TM(Transaction Manager):事务管理器,负责开启、提交、回滚全局事务
  • RM(Resource Manager):资源管理器,负责分支事务注册、提交、回滚

AT 模式怎么工作

AT 的关键思路是:

  1. 业务方法开启全局事务
  2. 各服务执行本地 SQL 时,Seata 代理数据源
  3. 在提交前后记录 before image / after image
  4. 分支事务向 TC 注册
  5. 全局成功则提交;全局失败则根据镜像自动回滚

也就是说,AT 并不是把所有数据库操作变成“一个真正跨库的大事务”,而是:

  • 每个分支先走本地事务提交
  • Seata 通过 undo log + 全局锁来保证可回滚与隔离性

这就是它性能比 XA 好很多,但又不是绝对强一致的根本原因。

一张整体流程图

flowchart LR
    A[订单服务 TM\n开启全局事务] --> B[库存服务 RM\n执行扣减库存]
    B --> C[账户服务 RM\n执行冻结金额]
    C --> D[订单服务 RM\n写订单状态]
    B --> E[TC 注册分支事务]
    C --> E
    D --> E
    E --> F{全局事务结果}
    F -->|成功| G[各分支提交]
    F -->|失败| H[根据 undo_log 回滚]

一次正常提交的时序

sequenceDiagram
    participant Client as Client
    participant Order as OrderService(TM)
    participant TC as Seata TC
    participant Stock as StockService(RM)
    participant Account as AccountService(RM)

    Client->>Order: 创建订单
    Order->>TC: begin global tx
    TC-->>Order: XID

    Order->>Stock: 扣库存(XID透传)
    Stock->>TC: 注册分支
    TC-->>Stock: OK
    Stock-->>Order: success

    Order->>Account: 冻结金额(XID透传)
    Account->>TC: 注册分支
    TC-->>Account: OK
    Account-->>Order: success

    Order->>TC: commit global tx
    TC-->>Order: committed
    Order-->>Client: success

回滚的关键点

AT 模式依赖两样东西:

  • undo_log:用于回滚数据
  • 全局锁:防止脏写

如果没有 undo_log 表,或者数据源没被 Seata 代理,那你看到“全局事务注解写了”,其实事务并没有真正生效。


一致性设计:别只盯着技术框架

Seata 只是事务协调工具,真正决定系统可靠性的,是你的一致性设计边界

我建议把业务拆成三层:

1. 强一致核心

必须一起成功或一起失败,例如:

  • 订单创建
  • 库存预占/扣减
  • 账户冻结

这部分适合 Seata AT 或 TCC。

2. 可延迟一致部分

允许稍后补齐,例如:

  • 发短信
  • 发送站内信
  • 推送营销券
  • 写搜索索引

这部分不要硬塞进全局事务,应该走 MQ 或异步任务。

3. 最终校准部分

用于兜底,例如:

  • 对账任务
  • 库存与订单定时校验
  • 死信重放
  • 补偿任务扫描

一个成熟系统不是“靠分布式事务解决一切”,而是: 短链路强约束 + 长链路异步解耦 + 后台对账兜底


实战代码(可运行)

下面给一个简化但可运行的 Spring Boot + Seata AT 示例。场景是:

  • order-service:创建订单
  • stock-service:扣减库存

为了控制篇幅,这里重点展示核心代码。你可以按这个结构扩展到账户服务。

依赖配置

<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

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

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

数据库表结构

订单表

CREATE TABLE `t_order` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT NOT NULL,
  `product_id` BIGINT NOT NULL,
  `count` INT NOT NULL,
  `amount` DECIMAL(10,2) NOT NULL,
  `status` VARCHAR(20) NOT NULL,
  PRIMARY KEY (`id`)
);

库存表

CREATE TABLE `t_stock` (
  `id` BIGINT NOT NULL AUTO_INCREMENT,
  `product_id` BIGINT NOT NULL,
  `total` INT NOT NULL,
  `used` INT NOT NULL DEFAULT 0,
  `residue` INT NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_product_id` (`product_id`)
);

undo_log 表

每个参与 AT 的数据库都要建。

CREATE TABLE `undo_log` (
  `branch_id` BIGINT NOT NULL,
  `xid` VARCHAR(128) NOT NULL,
  `context` VARCHAR(128) NOT NULL,
  `rollback_info` LONGBLOB NOT NULL,
  `log_status` INT NOT NULL,
  `log_created` DATETIME NOT NULL,
  `log_modified` DATETIME NOT NULL,
  UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
);

订单服务配置

# application.yml
server:
  port: 8081

spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/order_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

seata:
  enabled: true
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

库存服务配置

server:
  port: 8082

spring:
  application:
    name: stock-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/stock_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

seata:
  enabled: true
  tx-service-group: my_tx_group
  service:
    vgroup-mapping:
      my_tx_group: default
    grouplist:
      default: 127.0.0.1:8091

DataSource 代理配置

很多人最容易漏这里。AT 模式下必须让业务走代理数据源。

// DataSourceProxyConfig.java
package com.example.order.config;

import com.zaxxer.hikari.HikariDataSource;
import io.seata.rm.datasource.DataSourceProxy;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
public class DataSourceProxyConfig {

    @Bean
    public DataSource dataSource(
            @Value("${spring.datasource.url}") String url,
            @Value("${spring.datasource.username}") String username,
            @Value("${spring.datasource.password}") String password,
            @Value("${spring.datasource.driver-class-name}") String driverClassName) {

        HikariDataSource ds = new HikariDataSource();
        ds.setJdbcUrl(url);
        ds.setUsername(username);
        ds.setPassword(password);
        ds.setDriverClassName(driverClassName);
        return new DataSourceProxy(ds);
    }
}

库存服务

// StockController.java
package com.example.stock.controller;

import com.example.stock.service.StockService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/stock")
public class StockController {

    private final StockService stockService;

    public StockController(StockService stockService) {
        this.stockService = stockService;
    }

    @PostMapping("/deduct")
    public String deduct(@RequestParam Long productId, @RequestParam Integer count) {
        stockService.deduct(productId, count);
        return "ok";
    }
}
// StockService.java
package com.example.stock.service;

public interface StockService {
    void deduct(Long productId, Integer count);
}
// StockServiceImpl.java
package com.example.stock.service.impl;

import com.example.stock.service.StockService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class StockServiceImpl implements StockService {

    private final JdbcTemplate jdbcTemplate;

    public StockServiceImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void deduct(Long productId, Integer count) {
        int updated = jdbcTemplate.update(
                "UPDATE t_stock SET used = used + ?, residue = residue - ? WHERE product_id = ? AND residue >= ?",
                count, count, productId, count
        );
        if (updated == 0) {
            throw new RuntimeException("库存不足");
        }
    }
}

订单服务调用库存服务

// RestTemplateConfig.java
package com.example.order.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}
// OrderService.java
package com.example.order.service;

public interface OrderService {
    void create(Long userId, Long productId, Integer count, Double amount);
}
// OrderServiceImpl.java
package com.example.order.service.impl;

import io.seata.spring.annotation.GlobalTransactional;
import com.example.order.service.OrderService;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class OrderServiceImpl implements OrderService {

    private final JdbcTemplate jdbcTemplate;
    private final RestTemplate restTemplate;

    public OrderServiceImpl(JdbcTemplate jdbcTemplate, RestTemplate restTemplate) {
        this.jdbcTemplate = jdbcTemplate;
        this.restTemplate = restTemplate;
    }

    @Override
    @GlobalTransactional(name = "create-order-tx", rollbackFor = Exception.class)
    public void create(Long userId, Long productId, Integer count, Double amount) {
        jdbcTemplate.update(
                "INSERT INTO t_order(user_id, product_id, count, amount, status) VALUES(?,?,?,?,?)",
                userId, productId, count, amount, "CREATING"
        );

        restTemplate.postForObject(
                "http://127.0.0.1:8082/stock/deduct?productId=" + productId + "&count=" + count,
                null,
                String.class
        );

        // 模拟异常,观察全局回滚
        if (amount > 1000) {
            throw new RuntimeException("金额超限,触发回滚");
        }

        jdbcTemplate.update(
                "UPDATE t_order SET status = ? WHERE user_id = ? AND product_id = ? ORDER BY id DESC LIMIT 1",
                "SUCCESS", userId, productId
        );
    }
}
// OrderController.java
package com.example.order.controller;

import com.example.order.service.OrderService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/order")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping("/create")
    public String create(@RequestParam Long userId,
                         @RequestParam Long productId,
                         @RequestParam Integer count,
                         @RequestParam Double amount) {
        orderService.create(userId, productId, count, amount);
        return "ok";
    }
}

如何验证回滚是否生效

启动 Seata Server、订单服务、库存服务后,调用:

curl -X POST "http://127.0.0.1:8081/order/create?userId=1&productId=1001&count=2&amount=1200"

预期现象:

  • 接口报错
  • t_order 不保留最终成功数据
  • t_stock 的库存扣减被回滚
  • undo_log 中会产生对应记录

如果你发现订单回滚了、库存却没回滚,基本就是以下几类问题:

  • XID 没透传
  • 库存服务没接入 Seata
  • 没有使用代理数据源
  • undo_log 缺失
  • SQL 不满足 AT 解析条件

容量估算与性能权衡

架构设计不能只讲“能不能做”,还要讲“能跑多快”。

Seata AT 的性能成本来自哪里

  1. 额外 SQL 解析
  2. undo_log 写入
  3. 分支注册与全局事务协调
  4. 全局锁竞争
  5. 失败场景下回滚成本

这意味着,在高并发场景里,AT 一定比纯本地事务慢。

一个简单的量化思路

假设某个下单接口原本只做本地事务,平均 RT 是 25ms。接入 Seata AT 后,常见变化可能是:

  • 本地 SQL 与代理开销:+5~10ms
  • TC 通信:+3~8ms
  • undo_log:+2~5ms
  • 库存热点冲突:波动增加

最终 RT 可能来到 35~60ms。如果再叠加多个下游服务,尾延迟会更明显。

哪些情况下性能会突然恶化

  • 同一商品被高频扣减,形成热点行
  • 一个全局事务里串行调用太多服务
  • SQL 过于复杂,AT 解析或镜像生成成本高
  • 事务时间过长,锁持有时间拉长
  • 失败率高,导致大量回滚

一个建议的链路拆分方式

flowchart TD
    A[下单请求] --> B[强一致主链路]
    B --> C[订单创建]
    B --> D[库存预占]
    B --> E[账户冻结]
    A --> F[异步链路]
    F --> G[通知消息]
    F --> H[优惠券发放]
    F --> I[搜索索引更新]

核心建议就一句话:
把必须原子成功的步骤留在全局事务里,把可以补偿的动作移出去。


常见坑与排查

这一部分我尽量写得“接地气”一点,因为很多坑不是文档没写,而是排查时容易想偏。

1. @GlobalTransactional 写了,但根本没生效

常见原因

  • 注解加在 private 方法上
  • 同类内部自调用,AOP 没代理到
  • 引用的是普通数据源,不是 DataSourceProxy
  • 服务没正确注册到 Seata TC

排查方式

先看日志里有没有全局事务 XID,例如:

Begin new global transaction
xid=192.168.1.10:8091:2012345678

如果连 XID 都没有,说明事务压根没开启。

2. 订单回滚了,库存没回滚

常见原因

  • 调用下游时 XID 没透传
  • 下游服务没有 Seata 依赖或配置错误
  • 下游数据库没建 undo_log

排查顺序

  1. 看上游日志是否生成 XID
  2. 看下游日志是否拿到相同 XID
  3. 查下游是否有 branch register 日志
  4. undo_log 是否生成记录

3. 出现全局锁冲突,吞吐突然下降

现象

  • 接口 RT 飙升
  • 日志里出现 lock conflict / retrying
  • 某些商品、账户成为热点

根因

AT 模式需要对修改的数据加全局锁。热点行竞争时,冲突会放大。

处理建议

  • 避免多个请求频繁更新同一行
  • 库存设计从“单行总库存”改为“分段库存”或“预扣桶”
  • 缩短事务执行时间
  • 将非核心更新移出全局事务

4. 回滚失败,出现脏数据

常见原因

  • 业务 SQL 提交后,被外部事务改写
  • 使用了 Seata 不支持或不稳定解析的 SQL 形式
  • 手工改数据导致 before image / after image 不一致

我的建议

AT 模式下,关键业务表尽量:

  • SQL 简单化
  • 不要随意混用触发器
  • 避免绕开应用直接改库
  • 保持主键更新路径稳定

5. 空回滚与悬挂问题

这类问题在 TCC 更常见,但做模式切换时经常被忽略。

  • 空回滚:Try 没执行成功,Cancel 先到了
  • 悬挂:Cancel 执行后,迟到的 Try 又来了

如果你后续转向 TCC,一定要在业务表中设计事务状态幂等控制字段。


安全/性能最佳实践

这里我把“安全”和“性能”放一起讲,因为工程里这两件事经常互相影响。

1. 不要把所有操作都塞进一个全局事务

这是最常见的误区。全局事务不是越大越稳,而是越大越慢、越脆弱。

建议控制在:

  • 2~4 个核心服务以内
  • 单次事务耗时尽量低于 200ms
  • 不要夹杂远程查询、复杂计算、第三方调用

2. 外部系统调用不要放进全局事务

比如短信、支付网关、物流接口,这些系统:

  • 不受你控制
  • 响应不稳定
  • 很难回滚

正确做法:

  • 事务内只写核心业务状态
  • 事务后发消息
  • 由异步消费者执行业务扩展动作

3. 热点数据要提前做拆分设计

如果库存表只有一行 product_id=1001,在大促时它一定会变成冲突中心。

可选优化:

  • 分桶库存
  • 预占库存池
  • 本地缓存 + 异步扣减
  • 活动库存和日常库存隔离

4. 幂等要独立建设

Seata 能协调事务,但不能替你解决重复请求问题。

至少要有:

  • 业务唯一号
  • 幂等键
  • 防重表或唯一索引
  • 消息消费幂等控制

5. 监控指标必须补齐

上线前至少监控这些指标:

  • 全局事务数 / 成功率 / 回滚率
  • 分支事务注册失败数
  • 全局锁冲突次数
  • undo_log 增长速度
  • 接口 RT P95 / P99
  • TC 节点 CPU / 内存 / 连接数

6. 日志里一定打印 XID

这是排查跨服务事务问题的生命线。
我的习惯是把 XID 放进 MDC,这样整个调用链日志都能串起来。

import io.seata.core.context.RootContext;
import org.slf4j.MDC;

String xid = RootContext.getXID();
MDC.put("xid", xid);

7. 数据库层面的安全建议

  • 生产环境限制直接改核心业务表
  • 核心表变更前做 Seata 兼容性验证
  • undo_log 不要随意清理
  • 做好备份与回滚预案

什么时候该从 AT 升级到 TCC

很多团队在 Seata AT 跑一段时间后,会遇到边界:

  • 库存热点太重
  • 回滚逻辑不够可控
  • SQL 复杂度越来越高
  • 业务需要更明确的预留、确认、取消语义

这时可以把关键链路升级为 TCC:

  • Try:预留库存/冻结金额
  • Confirm:确认扣减
  • Cancel:释放库存/解冻金额

TCC 的代价

  • 业务侵入高
  • 需要自己写 Confirm/Cancel
  • 幂等、空回滚、防悬挂都要处理

TCC 的好处

  • 业务可控性更强
  • 不依赖 SQL 解析与 undo_log
  • 更适合核心高价值链路

所以一个成熟演进路线通常是:

  1. 先用 AT 快速统一事务框架
  2. 找出热点和高风险链路
  3. 对关键路径逐步改造为 TCC
  4. 非关键链路继续走异步最终一致

总结

Seata 的价值,不在于“帮你神奇地解决分布式事务”,而在于给了你一套工程上可落地的折中方案

落地时请记住这几个核心判断:

  1. 先分层,不要所有链路都追求强一致
  2. AT 适合短事务、关系型数据库、标准 SQL
  3. 热点行、高并发、长事务,是性能和稳定性的主要敌人
  4. 异步解耦、幂等、防重、对账,必须和 Seata 配套建设
  5. 关键高价值链路,可以从 AT 逐步演进到 TCC

如果你现在正准备在订单、库存、账户这类场景接入 Seata,我的建议很直接:

  • 第一阶段:只覆盖最核心的 2~3 个写服务
  • 第二阶段:补齐 XID 日志、监控、压测、回滚演练
  • 第三阶段:识别热点链路,再决定是否升级到 TCC 或拆成异步流程

最后一句经验话:
分布式事务从来不是“框架选型题”,而是“一致性边界设计题”。框架只是工具,边界划对了,系统才稳。


分享到:

上一篇
《Java开发踩坑实战:排查并修复线程池误用导致的接口超时与内存飙升问题》
下一篇
《Docker 多阶段构建与镜像瘦身实战:从构建加速到安全发布的完整优化方案》