ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Seata与分库分表、数据库代理的分布式事务落地指南

Seata与分库分表、数据库代理的分布式事务落地指南 分库分表之后事务问题从“单机小麻烦”变成了“分布式大工程”。Seata作为分布式事务框架里的熟面孔一旦碰上有数据库代理、分库分表这类基础设施很多人就开始困惑TC、TM、RM这三家伙到底在忙什么代理把路由逻辑藏起来了Seata还管得住每个分片上的事务吗这篇文章我就围绕这些核心问题把Seata在分库分表数据库代理场景下的工作方式、集成姿势、落地配置和踩坑经验完整梳理一遍。适合正在搭微服务架构、搞分布式改造或者被“分布式事务到底怎么落地”折磨过的后端开发、架构师参考。1. 分库分表之后事务问题不是“要不要做”而是“怎么做”1.1 分库分表解决了容量压力却撕开了数据一致性口子单库单表扛不住增长时最常见的解法是分库分表按订单号 hash 到不同的库按用户 id 拆分到不同的表把压力分摊开。这套方案确实解决了读写性能、存储容量、索引膨胀这些问题但代价也很直接——原本一个事务里可以同时更新的多张表现在可能分布在不同的物理库上。本地事务只能管住自己那个库跨库的原子性、一致性全都得重新设计。举个最常见的场景下单操作要扣库存、写订单、加积分。分库后订单库、库存库、积分库是三个独立的数据源每个库各自的本地事务都成功了整体却不一定是成功的。一旦中间某个节点失败另外两个库已经提交的数据就回不去了数据对不上账是早晚的事。很多人觉得“我可以做补偿”但补偿方案有几个绕不开的问题业务侵入性极强每个模块都要写正向和逆向接口补偿顺序和时机很难控制网络抖动、进程重启都会让补偿链路断掉更重要的是补偿只是“最终一致”中间状态长时间不一致对账、客服、报表全都遭殃。这才是为什么需要一套统一协调分布式事务的框架而Seata正好是干这个的。1.2 数据库代理在分库分表架构里到底扮演什么角色分库分表落地的时候应用侧访问数据库的方式有两种主流选择。一种是ShardingSphere-JDBC这种客户端集成模式路由规则在应用进程内完成应用直连多个真实数据源。另一种是ShardingSphere-Proxy、MyCat这类代理模式规则收敛到代理层应用只需要连接一个代理地址代理根据SQL自动路由到底层真实库表。代理模式最大的好处是业务接入成本低连接串换一下读写逻辑不用改。但它也给事务处理带来一个隐蔽问题——应用看到的是一个“逻辑库”而底层是几十个物理分片。数据库代理帮你把路由给挡在了后头事务框架却需要知道“数据到底落在哪个物理节点上”。如果代理层和事务框架没有配合好就会出现一种很尴尬的局面事务协调器看到一个分支实际却涉及多个物理库回滚根本没法精准执行。这里要澄清一下Seata本身不是数据库代理它做的是分布式事务协调分库分表代理做的是SQL路由与数据分片。两者解决的完全是不同维度的问题。现实中它们经常同时出现所以更重要的是搞清楚它们之间的关系、边界以及整合时的正确姿势。1.3 没有分布式事务兜底分库分表越彻底越危险分片越多单点故障的概率和局部失败的暴露面也在变大。原来一个数据库就够了事务失败就是那一亩三分地的事现在一个事务要跨三四个物理库每个库独立提交任何一个环节出问题全局数据就可能处于“部分成功、部分失败”的中间状态。所以分库分表做得好不好事务能力是一个重要的衡量维度。你在评估一个分库分表方案时至少要问自己三个问题跨分片事务所依赖的协调机制是什么协调器挂掉之后事务链路怎么恢复回滚操作能否精确命中每个物理分片这三个问题想清楚了再谈Seata才有意义。接下来我把Seata的TC、TM、RM逐一拆开讲毕竟很多人在代理场景下真正卡住就是因为没理解这三个角色各自负责什么边界在哪。2. 先把Seata的核心部件看透TC、TM、RM到底谁是谁2.1 RM资源管理器最容易被忽略的“前线执行者”RM的全称是Resource Manager中文叫资源管理器。它不是一个独立进程而是以客户端库的形式嵌入到业务应用里。核心工作有两块一是管理分支事务把业务数据变更注册给TC二是在第二阶段执行全局回滚或全局提交时真正去操作数据库。以AT模式为例RM会在业务SQL执行前生成数据快照执行后再次生成快照并把这些“前镜像”“后镜像”写入undo_log表。如果全局事务最终要回滚TC会通知RM根据undo_log里的镜像数据反向补偿把数据恢复到执行前的状态。这个“反向补偿”必须精确到每一行数据如果SQL经过代理层路由到了不同的物理分片那每个分片上的RM都得有对应的undo_log。RM还有一个很容易被忽略的职责——获取和释放全局锁。AT模式的写操作会对修改的主键记录加全局锁目的是防止其他全局事务在同一时间改同一行数据。这个锁不是数据库的行锁而是记录在单独一张lock_table里的逻辑锁。多个RM都要访问这张表所以在分库分表场景下每张分表所在的物理库都要准备lock_table。2.2 TM事务管理器发起和决策但不管执行TM是Transaction Manager负责全局事务的发起、提交和回滚决策。它同样嵌入在业务应用中通常就是我们标注了GlobalTransactional注解的那个入口方法所在的地方。当调用链进入这个方法时TM会向TC申请一个全局事务ID也就是我们常说的XID并把它绑定到当前线程的上下文中。后面所有参与这个全局事务的分支都会通过这个XID关联到同一个全局事务上。TM只做“决策和收口”它汇总各分支的执行结果最终决定是提交还是回滚全局事务。它不去管具体的SQL怎么执行、锁怎么释放那些都是RM的活。一个容易踩坑的点在于TM通常是在最外层业务入口加注解但很多人习惯把GlobalTransactional加到Service方法上却不知道代理层和RPC调用链是否能把这个XID传递下去。如果XID没有通过RPC消息头传到下游服务下游创建的RM分支就和全局事务关联不上到时候回滚只会发生在入口服务本地下游的数据就孤零零地留在那里了。这个我后面实操部分会细说。2.3 TC事务协调器全局事务的大脑与“故障单点”TC是Transaction Coordinator的缩写它是Seata中唯一独立部署的Server端组件。TC的职责可以概括为三件事接收TM的全局事务注册请求、管理所有RM分支事务的状态、在全局提交或回滚时向各相关RM下发通知。TC本身需要持久化事务状态因为它要支撑宕机恢复。如果TC挂了所有正在进行中的全局事务都会卡在“待处理”状态业务会出现大量超时和阻塞。所以TC的高可用部署几乎是必须的至少要搞个集群配合注册中心做实例发现再在配置里开启持久化与恢复机制。这一点在代理模式场景下尤其重要因为RM这类资源客户端通过代理访问数据库多跳了一层网络链路更容易出现超时TC的稳定性对整个分布式事务的影响会被放大。2.4 一次全局事务的完整生命周期把三者串起来看一笔分布式事务的完整流程大概是这样的应用入口的GlobalTransactional被触发TM向TC发送“开启全局事务”请求拿到XID。业务方法执行到数据库操作时RM拦截JDBC请求在本库内执行SQL并生成undo_log、获取全局锁。RM向TC注册分支事务报告分支状态。如果调用链走了RPCXID会随消息传递到下游服务下游同样执行上述分支事务注册。全部业务逻辑执行完毕TM向TC发送“全局提交”或“全局回滚”请求。TC根据所有分支的状态下发二阶段指令。若全部分支可用则通知各RM删除undo_log、提交本地事务如果有任意分支失败或超时则通知各RM依据undo_log做反向补偿完成回滚。这个流程理解透了再看Seata和分库分表、数据库代理的结合很多疑惑都会迎刃而解。3. Seata与数据路由/代理的三种结合姿势3.1 姿势一客户端路由 Seata AT最顺滑的搭配如果你用的是ShardingSphere-JDBC这类客户端集成模式应用进程内通过路由引擎直接连接多个真实数据源那么每个逻辑表对应的物理数据源对Seata来说其实都是可见的。每个物理数据源可以被包装成Seata的DataSourceProxyRM能够精确地拦截每个分片上的SQL并注册对应的分支事务。这种方案下事务边界和路由边界统一在同一个进程里分支事务的注册数量与实际物理分片一一对应回滚的时候undo_log也能准确落在每个分片库的各自表里。全局锁的管理、恢复、补偿链路都比较清晰。这个搭配也是我在生产环境里用得最多、踩坑最少的方案。选型的时候如果团队对路由的透明性要求不是特别高我建议优先走这条。它相当于把分库分表规则和分布式事务管理都放在了应用内部虽然代码里需要引入路由依赖但换来的是事务行为的高度可控。3.2 姿势二中间代理 Seata隐蔽的路由落差问题一旦你连接的是ShardingSphere-Proxy或MyCat这类代理情况就复杂了。代理层拦截了SQL根据分片规则把语句转发到不同的物理库。此时应用层的数据源只有一个“代理数据源”Seata的RM会把这个代理数据源当作一个整体资源来管理。问题就出在这里一条逻辑SQL经过代理后可能被拆成多条物理SQL分别落在不同的真实库上。代理在物理库上执行SQL时RM并不在物理库那一端无法精确感知每个分片上的执行边界。它注册给TC的分支事务对应的是“代理数据源”这个抽象资源而不是具体的物理库。换句话说代理模式天然地把路由细节吞掉了RM拿不到“这个分支到底改了哪几个物理库”的信息。如果强行按分支事务来处理全局回滚时很可能出现两个结果要么回滚范围过大代理对多个物理库都执行反向补偿造成误伤要么回滚不完整部分分片上的数据还被留在已提交状态。那么代理模式下Seata是不是完全不能用也不是但要有取舍。一个可行思路是放弃AT这种依赖本地undo_log的自动补偿模式改用XA模式或事务消息、TCC这类业务方介入更深的方案。XA模式由数据库本身保证分布式一致性代理可以把XA事务的参与范围透传到各物理库Seata只负责协调提交/回滚不需要在代理侧判断具体改了多少张表。3.3 姿势三XA模式绕开undo_log换来的是锁持有的代价XA模式下Seata不再需要生成前后镜像也不需要维护undo_log和lock_table。它在第一阶段把事务标记为XA分支第二阶段根据全局结果统一向数据库发布XA commit或XA rollback。各物理库的XA支持会保障自身的原子性和一致性。这个模式的优点很明显实现简单代码侵入小不需要维护镜像数据也没有什么中间表要建。代价也很明显XA事务在第一阶段就会持有数据库行锁直到第二阶段才释放。在跨多个物理分片的长事务里这会让其他业务在等待锁上耗费大量时间高并发场景下很容易出现阻塞堆叠。所以XA模式更适合那些并发压力不算极致、但一致性和完整性要求高的业务比如金额结算、库存强校验这类场景。如果你的业务是有大量热点数据的高并发写这类模式可能不适合你。针对这三种姿势我整理了一张对比表集成姿势路由位置事务模式建议优点主要代价ShardingSphere-JDBC Seata AT应用进程内AT优先分支事务精确、回滚准确、全局锁可控应用需引入路由依赖复杂度前置ShardingSphere-Proxy/MyCat XA独立代理进程XA兜底业务侵入小、无需undo_log数据库锁持有时间变长性能受影响代理 Seata AT独立代理进程不建议接入成本低路由被吞分支与物理分片无法对应回滚风险高4. 实操环节在分库分表代理场景落地Seata的关键配置与步骤4.1 事务分组与分片键的路由边界要提前对齐很多团队在接入Seata的时候已经分好了业务库分片键也定死了。这时候如果分片键选得不好全局事务的分支可能分布在多个物理库上分支数量不稳定、不可控问题就会很头疼。实操中我的习惯是先梳理当前业务的事务边界找出哪些操作必须同库完成。例如“订单订单明细”强烈建议根据order_id分片并落到同一物理库这样这一组数据就是一个本地事务不需要全局协调大大降低对Seata的依赖。只有那些不得不跨库的业务才交给Seata去管。事务分组方面Seata的配置里有transactionServiceGroup的概念。它类似于给一组服务划定一个逻辑组组内的应用共同使用同一个TC集群。分库分表之后不同业务集群的库位完全不一样我不建议所有服务都塞到同一个事务组里那样会让TC的协调密度过高而且某个服务的事务风暴会波及不相关的业务。合理的做法是按照业务线和物理库的归属关系拆分成多个事务组比如trade-group、inventory-group、member-group每组对应各自的TC服务或TC集群节点。4.2 代理数据源与RM拦截器的配合细节不管选哪种模式有一点是通用的在业务代码里数据源一定要经过Seata的DataSourceProxy包装。拿客户端路由模式举例每个物理数据源都要被包一层类似这样Bean public DataSource dataSource() { MapString, DataSource dataSourceMap new HashMap(); dataSourceMap.put(ds_order_0, orderDataSource0()); dataSourceMap.put(ds_order_1, orderDataSource1()); ShardingRule shardingRule buildShardingRule(); ShardingDataSource shardingDataSource new ShardingDataSource(dataSourceMap, shardingRule, new Properties()); return new DataSourceProxy(shardingDataSource); }注意一个细节如果ShardingSphere-JDBC已经把自己的ShardingDataSource封装了一层你再用DataSourceProxy去包装它要确认整个数据源的代理链不会破坏路由规则。如果路由和事务的两个拦截器产生冲突有可能出现SQL被重复改写或者路由失效的情况。我的经验是要把DataSourceProxy放在最外层保证Seata先看到经过路由后的物理数据源再执行事务相关拦截。如果是代理模式应用直接连代理地址DataSourceProxy包装的就是单一的代理数据源。RM拦截到的SQL经过代理后再分发到物理库这时候Snapshot Service和undo_log的处理范围就可能覆盖到多个物理库二阶段回滚的准确性全看代理层是否透传了上下文。这个问题我前面已经讲过所以实际操作时更建议代理场景走XA。4.3 每个分片都要提前准备的内部表AT模式依赖两张内部表很多人在分库分表时只把业务表做了水平拆分却忘了在每一个物理分片库里都建好undo_log和lock_table。一旦全局事务回滚RM根据XID去查undo_log结果在这个分片库上查不到回滚就直接失败了。建表语句不复杂但要注意每张分表所属的物理库都必须有一份不能只在主库建。undo_log的表结构里包含branch_id、xid、context、rollback_info等关键字段。rollback_info里存的是序列化之后的完整前后镜像字段类型要留够空间否则数据量大的时候会报行超限。lock_table则记录了全局锁的归属包含xid、branch_id、lock_key等字段。锁的粒度直接与主键值绑定分表之后主键生成策略要格外当心尽量不要用简单的自增推荐用雪花算法或号段模式。如果不同分片出现了相同的主键全局锁会在不同分片间产生语义冲突事务隔离性直接被打乱。4.4 验证事务流转的几个关键观察点配置完成之后很多人想确认“到底生效没有”但不知道怎么验证。我的做法是按照下面几个环节逐层检查第一启动日志里是否能看到TC的注册信息、事务组加载信息。看不到说明服务没连上TC先检查网络和配置。第二调用一个加了GlobalTransactional的业务接口去TC的监控端或日志里查XID的生成记录。如果XID没生成说明TM没起作用大概率是注解放错了位置或者入口被代理类拦截了。第三观察RM的分支注册日志。正常情况下每个参与分支的数据源都会打印分支注册成功的信息。如果只看到一个分支但业务明明跨了多个库那要怀疑是不是代理层或路由层把分支吞掉了。第四做一个“人为制造失败”的验证在一个事务内第一张表插入数据然后第二个操作故意抛异常。观察undo_log是否生成TC是否触发回滚目标库的数据是否被还原。这个演练最好在测试环境用小数据量独立跑一遍。5. 踩坑实录与排查方法论5.1 坑一分片键没透传下去undo_log找不到对应数据有一次排查一个诡异的“回滚部分成功”问题业务侧看到整体事务返回失败但某个库上的数据还是被改了。查来查去发现是分片键在服务间传递时丢了一段上游根据user_id分片下游Service却根据order_id找分片两个key不一致导致RM执行反向SQL时路由到了错误的分片undo_log当然查不到镜像。这种问题的本质是分片路由的上下文没有在事务链路里保持统一。Seata的XID虽然能传递全局事务标识但它不负责路由上下文。解决方法是把分片键同样放入RPC的透传字段下游从上下文中取同一个key来做路由。排查的时候先看两个服务最终生成的物理SQL落在哪个库再倒推上游透传的值到底哪里变了味。5.2 坑二代理模式下分支事务数量物理库数量对不上使用代理模式时我发现监控面板上的分支事务数总是小于该事务实际影响的物理分片数量。这是因为代理做了SQL的重写和聚合RM看到的是多次逻辑SQL却不一定能感知代理内部拆分出来的每一条物理SQL。分支数量对不上可不是个数字游戏它意味着TC在下发回滚指令时无法精确覆盖所有被修改过的物理库。我在这类问题上的处理思路是如果业务对一致性等级要求极高就不要纠结在代理模式下把AT模式讲出花来直接切换XA或引入消息事务。如果是已经在跑的存量系统临时要救火可以限制事务内路由的库数量把所有分片键强制映射到同一个物理库让代理模式退化为“逻辑分表但物理同库”的形态至少能保证回滚不丢节点。5.3 坑三超时回滚与全局锁等待互相干扰分布式事务最让人头疼的连锁反应是一个分支卡在数据库锁等待上TM等不到结果就开始超时回滚回滚本身又要抢全局锁结果两边撞在一起事务长时间不能结束。Seata的默认超时时间是60秒看起来不短但在一个调用链很深、分片很多的悲观锁场景里这个时间很容易被消耗殆尽。经验是不要把全局事务超时时间当摆设要结合接口的历史耗时去设定。另外要检查业务代码里有没有在全局事务内执行RPC调用、操作Redis、调用外部接口这类非数据库IO。这些操作不做锁抢占却拖长了全局事务的生命周期让锁的持有时间成倍放大。正确做法是把这些非事务性操作移到GlobalTransactional外面或者放到补偿逻辑里去处理。5.4 坑四undo_log、lock_table没在每个分片建全这个问题听起来很基础但恰恰是最多线上事故的来源。很多团队的数据库脚本只把业务表做了分片内部表却只在默认数据源里建了一份。等到某个分片的数据参与回滚RM反向查镜像表时发现表不存在报错信息还特别误导人经常显示的是“找不到Table”或“Column not found”。我建议在初始化脚本里用模板的方式生成每个分片的内部表并且要加一个启动自检任务遍历所有数据源节点检查表结构是否齐全。这东西一劳永逸别指望靠上线前的人工检查。5.5 排查工具箱日常排查这类问题我常用下面几招排查目标手段关键日志/信息TC连接状态TC服务日志、网络端口连通性客户端与TC的注册成功记录XID传递链路在RPC上下游打印XID下游能否看到上游生成的XID分支注册情况TC端日志、业务日志分支注册数量与预期不符undo_log数据正确性直接查各分片undo_log表rollback_info的镜像数据是否完整全局锁竞争查询lock_table持锁记录锁不释放时看xid和branch_id归属这些排查工具不复杂难的是养成“在每个分片上都查一遍”的习惯。很多事务问题表象一致但根因分布在不同分片上只看一个库很容易被假象骗过去。6. 说点大实话我对这套架构选型的个人经验我在实际项目里见过太多团队一上来就全量引入分布式事务框架觉得有Seata就万事大吉结果被各种边界问题拖得筋疲力尽。这里分享几个我个人沉淀下来的判断标准希望能帮你少走弯路。第一能用本地事务解决的坚决不要上全局事务。分片键设计得好不好直接决定了有多少操作能落在同一个库里。好的分片规则能让50%以上的事务都退化成单库本地事务全局事务只处理真正无法避免的跨库部分。第二分库分表和数据库代理是不同层次的选择但它们必须放在一起设计。如果先选了代理路由又希望追求精确回滚那就要做好接受XA或扩展机制的代价如果先选了Seata AT模式那客户端路由模式会更合适。架构里每一层都不该被当成孤立组件去决策。第三高可用永远在功能之后被忽视。我见过不少团队在测试环境用单机TC跑得很欢上线前才发现TC没有做集群部署也没有配置持久化恢复。分布式事务协调器一旦成为单点比数据库本身更容易玩脱。TC高可用、事务日志持久化这些基础功必须在设计阶段就纳入计划。这篇文章讲到的核心链路——TC、TM、RM如何协作代理模式下的路由落差AT与XA的取舍内部表的建设——都是我在生产环境反复验证过的内容。分库分表不是终点把分片之后的跨库事务管住这套架构才能真正扛住业务增长的压力。后面再遇到“Seata和分库分表没法配”的说法你可以拿出这篇文章里的场景逐条对比多半能找到答案。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进