理解分布式事务,首先要明白它的核心矛盾:如何在分布式系统(多个独立数据库或微服务)中,保证原本在单机数据库中轻而易举实现的 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)的代表方案。
-
投票阶段: 协调者询问所有参与者,是否可以提交。
-
执行阶段: 如果所有人都同意,则正式提交;只要有一个反对或超时,则全部回滚。
-
问题: 同步阻塞、单点故障、脑裂问题(如果第二阶段网络断开,会导致数据不一致)。
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 依赖它来记录回滚镜像)。
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。
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
步骤 B:在调用方开启全局事务
这是最关键的一步。在“业务发起方”(通常是订单服务)的方法上加上 @GlobalTransactional 注解。
@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 的代理数据源会自动处理分布式逻辑。
@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. 事务执行过程拆解
-
TM (订单服务) 向 TC (Seata Server) 申请开启一个全局事务,拿到一个全局唯一的 XID。
-
库存微服务 执行时,发现有 XID,它会作为 RM 注册到 TC。
-
库存微服务 在更新数据库前,将当前库存值存入
undo_log,然后更新库存并提交本地事务(释放数据库锁)。 -
订单微服务 执行本地插入,同样存入
undo_log并提交本地事务。 -
最终结果:
-
成功: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。
-
埋点(拦截输出): 订单服务在通过 Feign 或 RestTemplate 调用库存服务时,Seata 的拦截器会悄悄地把这个 XID 塞进 HTTP 的 Header 里(默认 Header 名是
TX_XID)。 -
接收(拦截输入): 库存服务收到请求时,它的 Seata 拦截器会从 Header 中取出这个 XID。
-
绑定(上下文): 取出的 XID 会被放入当前线程的 RootContext(本质是一个 ThreadLocal)。
结论: 库存微服务通过检查自己的线程上下文(ThreadLocal)里有没有 XID,就能判断当前执行的代码是否处于一个分布式事务中。
2. 谁负责往 undo_log 塞数据?(数据源代理)
库存微服务的业务代码只写了普通的 SQL,它并没有显式操作 undo_log。这个脏活累活是由 Seata 代理数据源(DataSourceProxy) 偷偷完成的。
在 Spring 容器启动时,Seata 会把你的 DruidDataSource 或 HikariDataSource 包装成自己的 DataSourceProxy。当你执行 SQL 时,流程如下:
第一步:SQL 解析与前镜像
当 stockMapper.update() 被调用时,代理数据源会:
-
识别出这是一条
UPDATE语句。 -
根据 SQL 条件,自动生成一条
SELECT * FROM stock_tbl WHERE ...,把修改前的数据查出来,存在内存里(Before Image)。
第二步:执行业务 SQL
-
执行你原本的那条更新库存的 SQL。
第三步:后镜像获取
-
再次执行那条
SELECT,把修改后的数据查出来(After Image)。
第三步:写入 undo_log
-
代理数据源会将 Before Image、After Image 以及你的 SQL 语句 序列化成 JSON。
-
在同一个本地事务中,自动生成一条
INSERT INTO undo_log ...的 SQL 并执行。
第四步:本地提交
-
提交本地事务(业务 SQL 和
undo_log同时提交)。 -
在提交前,它还会去向 TC 申请这行数据的全局锁,确保别人不能乱动。
总结:库存服务的视角
对于库存服务的开发人员来说:
-
他只看到了一次 HTTP 请求。
-
他只运行了一段带有
@Transactional的本地代码。
但对于底层运行环境来说:
-
拦截器:发现 Header 有 XID,绑定到线程。
-
数据源代理:发现线程有 XID,开始解析 SQL、查快照、写
undo_log。
这就是 Seata AT 模式**“无侵入”**的真相:所有的复杂逻辑都被隐藏在拦截器和动态代理之后了。
这就是为什么你必须在 application.yml 里开启 seata.enable-auto-data-source-proxy=true 的原因。
库存服务update预计和insert undo_log是分开执行的吗,如果是的话两个语句不是同一个事务
这是一个非常精准的观察!如果你认为它们是分开执行的,确实会面临“库存减了但没记账”或者“记了账但库存没减”的一致性风险。
但事实是:库存服务的 UPDATE 业务 SQL 和 undo_log 的 INSERT 语句是在同一个本地数据库事务中执行并提交的。
1. 它是如何强行“捆绑”在一起的?
Seata 通过数据源代理(DataSource Proxy) 接管了数据库连接(Connection)。当你执行业务代码时,底层发生了以下神奇的操作:
-
开启本地事务: 当代码运行到
@Transactional时,代理连接(ConnectionProxy)会关闭autoCommit,即:connection.setAutoCommit(false)。 -
执行业务 SQL: 比如
UPDATE stock_tbl SET count = count - 1...。此时数据在数据库缓存中变了,但还没持久化。 -
插入 Undo Log: 代理对象通过刚才拦截到的 SQL,自动生成
INSERT INTO undo_log...。注意:它是直接复用了当前的那个 Connection 对象。 -
一并提交: 只有当这两条 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 且没在一个事务里:
-
库存减掉了。
-
程序崩了,
undo_log没写成。 -
结果: 整个分布式事务失败,订单没生成,但库存少了 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. 优化建议(避坑指南)
如果你觉得系统慢了,可以从以下几个维度优化:
-
缩短事务链路: 只在必须保证一致性的地方加
@GlobalTransactional,不要把查询操作也裹进去。 -
避免大事务: 一个事务里更新 10000 行数据,Seata 会生成 10000 条快照,内存和 IO 会瞬间爆炸。
-
TC 选址: 确保 Seata Server (TC) 与微服务在同一个内网,降低申请全局锁的时延。
-
索引优化: 确保业务 SQL 的过滤条件都有索引,否则 Seata 查快照时会触发全表扫描。
总结一下:AT 模式是用一定的性能损耗换取了极高的开发效率。如果你的场景真的对性能敏感到了毫秒级(如抢购、秒杀),建议改用 TCC 模式或消息队列异步补偿方案。
Seata 是如何通过“全局锁”来保护数据不被脏写
简单来说,Seata 的全局锁是为了弥补**“本地事务过早提交”**留下的安全漏洞。
在 AT 模式下,为了高性能,本地事务在一阶段就结束了。如果没有全局锁,这行数据就像一个没有保安的银行仓库,任何人都能进来改。
以下是 Seata 通过全局锁防止“脏写”的具体策略:
1. 锁的层级:双重锁定机制
要成功修改一条数据,必须集齐两把锁:
-
本地锁 (Local Lock): 由数据库(如 InnoDB)提供,锁定的是物理行。
-
全局锁 (Global Lock): 由 Seata TC 提供,锁定的是
服务名 + 表名 + 主键。
写入流程:
-
事务 A 获取本地锁,更新数据。
-
事务 A 在本地提交前,向 TC 注册分支事务并申请全局锁。
-
事务 A 获取全局锁成功,提交本地事务,释放本地锁(但此时仍持有全局锁)。
-
事务 B 进来想改同一行,先拿到本地锁。
-
事务 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)
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. 这个表是怎么工作的?
-
申请锁:RM 在本地事务提交前,把要修改的数据主键发给 TC。
-
写入记录:TC 尝试往
lock_table插入一条记录。-
插入成功:说明没人占用,拿到全局锁,RM 继续提交本地事务。
-
插入失败 (Duplicate Key):说明别的事务还没释放这行锁。此时 RM 会重试或报错(取决于配置)。
-
-
释放锁:
-
全局提交: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 是如何自救的?