浅聊一下微服务的分布式事务
在微服务架构里,事务管理可是个让人头疼的难题。原本单机环境下顺风顺水的本地事务,到了分布式场景就频频 “掉链子”。今天这篇文章,就从分布式事务的基础问题说起,结合 CAP、BASE 理论,再到 Seata 框架的实战应用,带大家把微服务事务管理的思路理清楚。
一、分布式事务
1.1 本地事务:单机时代的 “优等生”
本地事务就是咱们熟悉的单机事务,得严格遵守 ACID 原则:
- 原子性(Atomicity):事务里的操作要么全成,要么全败,没有中间态。比如转账,要么钱成功转过去,要么一分没动。
- 一致性(Consistency):事务执行完,数据库得保持完整的约束,像主键唯一、外键关联这些都不能乱。
- 隔离性(Isolation):多个事务同时操作同一资源时,互相不能干扰,得有隔离级别来控制,比如读未提交、读已提交等。
- 持久性(Durability):事务一旦提交,对数据库的修改就永久保存了,就算数据库崩溃,数据也不会丢。
1.2 分布式事务:微服务下的 “拦路虎”
分布式事务,简单说就是事务操作跨了多个服务或多个数据库。比如电商下单,得调用订单服务创建订单、库存服务扣减库存、账户服务扣用户余额,这三个操作分别在三个服务和三个数据库里,要让它们要么全成功,要么全失败,这就是分布式事务要解决的问题。
原本每个服务里的本地事务都能保证 ACID,但把它们串成一个 “业务整体” 时,ACID 就很难满足了 —— 万一订单创建成功,库存扣减也成功,偏偏账户扣款失败,总不能让订单生效却没扣用户钱吧?这就是分布式事务的核心痛点。
1.3 直观感受:分布式事务问题演示
咱们拿电商下单举例:
- 订单服务:创建订单,本地事务提交,订单状态为 “已创建”。
- 库存服务:扣减对应商品库存,本地事务提交,库存减少。
- 账户服务:扣用户余额时,突然报错(比如网络超时、余额不足),本地事务回滚,余额没扣。
这时候就出问题了:订单有了,库存少了,用户钱却没扣,数据不一致了。这就是分布式事务没处理好的典型情况。
二、搞定分布式事务的理论基础
要解决分布式事务,得先懂两个核心理论:CAP 定理和 BASE 理论。
2.1 CAP 定理:分布式系统的 “三角难题”
1998 年加州大学的 Eric Brewer 提出,分布式系统有三个核心指标,但这三个指标没法同时满足,只能三选二:
- 一致性(Consistency):不管访问哪个节点,拿到的数据都得一样。比如两个节点原本数据都是 v0,一个改成 v1 后,另一个也得同步成 v1,否则用户访问不同节点看到的数据就不一样了。
- 可用性(Availability):只要节点是健康的,用户访问它就得有响应,不能超时或拒绝。比如三个节点的集群,哪怕一个节点挂了,另外两个也得正常提供服务。
- 分区容错性(Partition Tolerance):分布式系统里,节点之间可能因为网络故障断开(形成 “分区”),但整个系统还得能对外提供服务。
关键矛盾:在分布式系统里,网络故障是难免的,所以分区容错性(P)是必须要保证的。这时候就只能在一致性(C)和可用性(A)之间选一个 —— 要一致性,就得等分区恢复、数据同步完才能提供服务,期间服务不可用;要可用性,就不能等数据同步,直接提供服务,但会出现数据不一致。
2.2 BASE 理论:CAP 的 “折中方案”
BASE 理论是对 CAP 的补充,核心思路是 “退而求其次”,接受一定时间内的不一致,最终保证数据一致:
- 基本可用(Basically Available):系统出故障时,允许损失部分可用性,比如降级(高峰期关闭非核心功能)、限流(限制部分用户访问),但核心功能得正常。
- 软状态(Soft State):允许系统在一定时间内处于 “中间状态”,比如数据同步过程中,不同节点数据不一致,这个状态是暂时的。
- 最终一致性(Eventually Consistent):虽然不能保证实时一致,但过一段时间(比如数据同步完成后),所有节点的数据会达成一致。
2.3 解决分布式事务的两种思路
基于 CAP 和 BASE 理论,解决分布式事务主要有两种方向:
- CP 模式:追求强一致性。各个子事务执行完后不提交,等待协调者通知,要么一起提交,要么一起回滚。但等待期间服务处于弱可用状态,性能较差。
- AP 模式:追求高可用性。各个子事务先执行并提交,允许暂时的数据不一致,之后通过补偿机制(比如回滚、重试)恢复数据,最终达成一致。
不管哪种模式,都需要一个 “事务协调者(TC)” 来协调各个子事务(分支事务)的状态,比如通知哪个分支提交、哪个分支回滚。
三、Seata:分布式事务的 “好帮手”
Seata 是 2019 年蚂蚁金服和阿里巴巴一起开源的分布式事务解决方案,支持多种事务模式,易用性和性能都不错。
3.1 Seata 的核心架构
Seata 里有三个关键角色,分工明确:
- TC(Transaction Coordinator):事务协调者,是核心。负责维护全局事务和分支事务的状态,协调所有分支事务提交或回滚。
- TM(Transaction Manager):事务管理器。定义全局事务的范围(比如哪个方法是全局事务入口),发起全局事务的开始、提交或回滚。
- RM(Resource Manager):资源管理器。管理分支事务的资源(比如数据库连接),跟 TC 通信,注册分支事务、报告分支事务状态,还得执行 TC 下达的提交或回滚指令。
Seata 支持四种事务模式,咱们重点说最常用的 XA 和 AT 模式:
- XA 模式:强一致性,基于两阶段提交,无代码侵入,但性能一般。
- AT 模式:最终一致性,也是两阶段,但一阶段就提交事务,性能更好,无代码侵入(Seata 默认模式)。
- TCC 模式:最终一致性,需要手写补偿逻辑(Try、Confirm、Cancel),有代码侵入。
- SAGA 模式:适合长事务,也需要手写补偿逻辑,有代码侵入。
3.2 部署 TC 服务:先搭好 “协调中心”
TC 是 Seata 的核心,得先部署好。步骤很简单:
- 下载 Seata Server:去 Seata 官网(http://seata.io/)下载 Seata Server 包,比如 1.4.2 版本。
- 解压包:解压后会有 bin(启动脚本)、conf(配置文件)、lib(依赖)等文件夹。
- 修改配置文件:重点改 conf 目录下的
registry.conf,让 TC 注册到 Nacos(方便微服务发现):
registry {
# 注册中心类型,选nacos
type = "nacos"
nacos {
# TC注册到Nacos的服务名,自定义
application = "seata-tc-server"
# Nacos地址,本地就是127.0.0.1:8848
serverAddr = "127.0.0.1:8848"
# Nacos分组,默认DEFAULT_GROUP
group = "DEFAULT_GROUP"
# Nacos用户名密码,默认nacos/nacos
username = "nacos"
password = "nacos"
# 集群名,比如SH(上海)
cluster = "SH"
}
}
config {
# 配置读取方式,也从Nacos读
type = "nacos"
nacos {
serverAddr = "127.0.0.1:8848"
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
# 配置文件的DataID
dataId = "seataServer.properties"
}
}
- 在 Nacos 配置 Seata Server 参数:登录 Nacos 控制台,新建配置,DataID 为
seataServer.properties,Group 为SEATA_GROUP,配置内容主要是数据存储(用数据库存全局事务、分支事务信息):
# 数据存储方式,选db(数据库)
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
# MySQL驱动,注意版本,8.x用cj.jdbc.Driver
store.db.driverClassName=com.mysql.cj.jdbc.Driver
# 数据库连接地址,seata库需要自己创建
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=123456
# 全局事务表、分支事务表、锁表名(默认即可)
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.lockTable=lock_table
# 其他配置(日志、传输方式等)
server.undo.logSaveDays=7
transport.serialization=seata
transport.compressor=none
- 创建 Seata 数据库和表:在 MySQL 里创建
seata库,然后执行 SQL 创建global_table(全局事务表)、branch_table(分支事务表)、lock_table(锁表),SQL 脚本如下:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- 分支事务表
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint(20) NOT NULL,
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`status` tinyint(4) NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime(6) NULL DEFAULT NULL,
`gmt_modified` datetime(6) NULL DEFAULT NULL,
PRIMARY KEY (`branch_id`) USING BTREE,
INDEX `idx_xid`(`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- 全局事务表
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`timeout` int(11) NULL DEFAULT NULL,
`begin_time` bigint(20) NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- 锁表
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
SET FOREIGN_KEY_CHECKS = 1;
- 启动 TC 服务:进入 bin 目录,双击
seata-server.bat(Windows)或执行./seata-server.sh(Linux)。也可以指定端口,比如seata-server.bat -p 9000。启动成功后,去 Nacos 服务列表能看到seata-tc-server,说明 TC 部署好了。
3.3 微服务集成 Seata:让服务 “接入” 事务协调
每个需要参与分布式事务的微服务(比如订单服务、库存服务、账户服务),都要集成 Seata,步骤都一样:
- 引入 Seata 依赖:在 pom.xml 里加依赖,注意排除低版本的
seata-spring-boot-starter,用 1.4.2 版本:
<!-- Seata依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<!-- 排除低版本的seata-spring-boot-starter -->
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- 引入1.4.2版本的seata-spring-boot-starter -->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.4.2</version>
</dependency>
- 配置 TC 地址:在 application.yml 里配置 Seata,让微服务能找到 TC:
seata:
registry:
# TC注册中心类型(nacos)
type: nacos
nacos:
# Nacos地址
server-addr: 127.0.0.1:8848
# 服务名(和TC注册的一致)
application: seata-tc-server
# 分组(和TC一致)
group: DEFAULT_GROUP
# 用户名密码
username: nacos
password: nacos
# 集群名(和TC一致)
cluster: SH
# 事务组名称(自定义,比如seata-demo)
tx-service-group: seata-demo
service:
# 事务组和集群的映射(seata-demo对应SH集群)
vgroup-mapping:
seata-demo: SH
微服务找 TC 的逻辑很简单:通过 Nacos 的 namespace、group、application(服务名)、cluster(集群名)这四个信息,定位到 TC 实例。
四、实战:Seata 的 XA 和 AT 模式
4.1 XA 模式:强一致性的 “代表”
XA 模式是基于 X/Open 组织定义的 XA 规范,主流数据库(MySQL、Oracle)都支持,核心是两阶段提交。
4.1.1 两阶段提交:分两步走的 “协调”
XA 模式的核心是两阶段提交,正常情况和异常情况处理不一样:
- 正常情况:
- 一阶段(准备阶段):TC 通知所有 RM 执行本地事务,但不提交。RM 执行完后,告诉 TC “我准备好了”(成功)或 “我失败了”。
- 二阶段(提交阶段):TC 看所有 RM 都准备好,就通知大家提交事务;只要有一个 RM 失败,就通知所有人回滚。
- 异常情况:比如一阶段有个 RM 执行失败,TC 直接触发二阶段回滚,所有 RM 都回滚本地事务。
4.1.2 Seata 的 XA 模型:简化但核心不变
Seata 对 XA 模式做了封装,适配自己的架构:
- 一阶段(RM 的活):RM 注册分支事务到 TC → 执行本地业务 SQL(但不提交) → 告诉 TC 执行状态。
- 二阶段(TC 和 RM 的活):TC 检查所有分支事务状态 → 全成功就通知 RM 提交,有失败就通知 RM 回滚 → RM 执行提交或回滚。
4.1.3 XA 模式的优缺点:强一致但性能一般
- 优点:
- 强一致性,严格满足 ACID 原则,数据不会乱。
- 数据库原生支持,不用改业务代码,无侵入。
- 缺点:
- 一阶段会锁定数据库资源(比如行锁),得等二阶段结束才释放,性能差,高并发场景 hold 不住。
- 依赖关系型数据库,非关系型数据库用不了。
4.1.4 实现 XA 模式:简单配置就行
Seata 已经自动装配了 XA 模式,不用写额外代码,两步搞定:
- 开启 XA 模式:在每个微服务的 application.yml 里加配置,指定数据源代理模式为 XA:
seata:
data-source-proxy-mode: XA
- 标记全局事务入口:在发起全局事务的方法上加
@GlobalTransactional注解。比如订单服务的创建订单方法:
@Service
public class OrderServiceImpl implements OrderService {
// 注入库存服务、账户服务
@Autowired
private InventoryFeignClient inventoryFeignClient;
@Autowired
private AccountFeignClient accountFeignClient;
// 加@GlobalTransactional,标记这是全局事务入口
@GlobalTransactional
@Override
public void createOrder(Order order) {
// 1. 创建订单(本地事务)
orderMapper.insert(order);
// 2. 调用库存服务扣库存(远程事务)
inventoryFeignClient.deduct(order.getProductId(), order.getCount());
// 3. 调用账户服务扣余额(远程事务)
accountFeignClient.deduct(order.getUserId(), order.getMoney());
}
}
- 重启测试:重启所有微服务,不管中间哪个步骤失败,所有事务都会回滚,数据保持一致。
4.2 AT 模式:性能更好的 “最终一致” 方案
AT 模式是 Seata 的默认模式,也是两阶段,但解决了 XA 模式资源锁定时间长的问题,性能更好,而且也是无代码侵入。
4.2.1 Seata 的 AT 模型:一阶段就提交
AT 模式的核心是 “一阶段提交,二阶段补偿”,步骤更灵活:
- 一阶段(RM 的活):
- RM 注册分支事务到 TC。
- 执行 SQL 前,先保存数据快照(也就是修改前的数据,存在
undo_log表)。 - 执行业务 SQL,直接提交本地事务,释放数据库资源。
- 告诉 TC 执行状态。
- 二阶段(分情况):
- 提交:TC 通知所有 RM 删除自己的
undo_log(快照没用了)。 - 回滚:TC 通知所有 RM,根据
undo_log里的快照,把数据恢复到修改前。
- 提交:TC 通知所有 RM 删除自己的
4.2.2 AT 和 XA 的区别:核心在 “提交时机”
两者最大的区别就三点:
- 提交时机:XA 一阶段不提交,锁定资源;AT 一阶段直接提交,释放资源,性能更好。
- 回滚方式:XA 依赖数据库原生回滚;AT 靠
undo_log快照恢复。 - 一致性:XA 是强一致;AT 是最终一致。
4.2.3 脏写问题:AT 模式的 “小插曲”
AT 模式一阶段就提交事务,释放了数据库锁,可能出现 “脏写”。比如:
- 事务 1:扣用户余额 10 元,一阶段提交,余额从 100→90,释放 DB 锁。
- 事务 2:紧接着扣用户余额 10 元,一阶段提交,余额从 90→80,释放 DB 锁。
- 此时事务 1 突然需要回滚,根据快照(余额 100)恢复,就会把事务 2 的修改覆盖,变成 100,这就是脏写。
解决办法:Seata 引入了 “全局锁”。在 RM 释放 DB 锁前,必须先拿到全局锁 —— 只有持有全局锁的事务,才能修改数据。比如上面的场景:
- 事务 1 执行完,要释放 DB 锁前,先拿全局锁,拿到后才释放 DB 锁。
- 事务 2 想执行,得先拿 DB 锁(能拿到),但拿全局锁时发现被事务 1 持有,就会重试(默认 30 次,间隔 10ms),重试失败就回滚。
- 事务 1 回滚时,因为持有全局锁,能正常恢复数据,不会覆盖事务 2 的修改(因为事务 2 已经回滚了)。
4.2.4 AT 模式的优缺点:性能和一致的平衡
- 优点:
- 一阶段提交,释放资源快,性能比 XA 好很多。
- 全局锁解决脏写,保证读写隔离。
- 无代码侵入,快照和回滚都由框架自动做。
- 缺点:
- 两阶段之间是软状态,数据会有暂时不一致,最终才一致。
- 生成快照会消耗一点性能,但比 XA 好。
4.2.5 实现 AT 模式:加张表就行
AT 模式需要undo_log表(存快照)和lock_table(存全局锁),步骤如下:
- 创建 undo_log 表:每个微服务对应的数据库里,都要创建
undo_log表(lock_table在部署 TC 时已经创建了),SQL 如下:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `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,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
SET FOREIGN_KEY_CHECKS = 1;
- 开启 AT 模式:Seata 默认就是 AT 模式,所以 application.yml 里可以不配置,也可以显式指定:
seata:
data-source-proxy-mode: AT # 默认值,可省略
- 标记全局事务入口:和 XA 模式一样,在发起全局事务的方法上加
@GlobalTransactional注解。 - 重启测试:重启服务后,不管哪个步骤失败,Seata 会自动根据
undo_log回滚数据,最终保持一致,而且性能比 XA 好。
总结
微服务事务管理的核心是在 “一致性” 和 “可用性” 之间找平衡:
- 要强一致,选 Seata 的 XA 模式,简单无侵入,但性能一般。
- 要高可用和性能,选 Seata 的 AT 模式(默认),最终一致,无侵入,适合大多数场景。
Seata 的优势在于易用性 —— 不用写复杂的补偿逻辑,配置一下、加个注解就能用,大大降低了分布式事务的实现成本。大家可以根据自己的业务场景,选择合适的事务模式。
更多推荐


所有评论(0)