尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

分布式事务选型与实战:2PC、TCC、Saga、本地消息表深度解析

发布时间:2026/9/29 15:40:29

资讯中心
01
ARTICLE

分布式事务选型与实战:2PC、TCC、Saga、本地消息表深度解析

分布式事务选型与实战:2PC、TCC、Saga、本地消息表深度解析
分布式事务这四个字在微服务架构下就是一面照妖镜。我做过一个电商中台项目订单、库存、支付各拆一个服务第一版用本地事务解决不了跨服务问题上线当晚就出现“订单创建成功但库存没扣掉”后来引入分布式事务方案又把系统复杂度拉高了不少。2PC、TCC、Saga、本地消息表这四类方案被讨论得最多但很多人只背了概念真正落地时却容易选错、用错。我自己踩过的坑把这四种方案的原理、代码、适用场景和天坑全部拆开讲一遍。无论你是后端开发还是架构师只要正在被跨服务数据一致性问题折磨希望这篇内容能让你少走几个月的弯路。1. 分布式事务到底在解决什么问题1.1 从一次下单开始说起假设你在设计一个电商系统。用户点击“立即购买”前端会调起一个createOrder接口。单体应用时代一个数据库里就能把事情办完先插一条订单再更新库存把商品数量扣掉最后更新账户扣钱这三个操作被包在同一个Transactional里任何一个失败整个事务回滚数据不会出现“有订单但没库存”的怪象。微服务化之后订单服务、库存服务、账户服务被拆到三个独立的应用和独立数据库里。本地事务只能保证单个服务内多条SQL的原子性没法保证跨三个服务的数据一致。用户下单时订单服务成功创建了订单但库存服务在扣库存时失败订单服务和库存服务的数据就产生了矛盾。更麻烦的是每个服务都有自己的数据库连接没法用一个原生事务把三个事务串起来。这就是分布式事务要解决的核心问题多个独立资源之间的原子性与一致性。这类场景不只是电商有支付、供应链、预约系统只要业务被拆成多服务随时都可能碰上。解决起来难不是因为分布式事务本身复杂而是它必须强制面对网络不可用、服务宕机、重复调用这些异常情况。真实网络环境里什么都会发生所以同一个方案在不同团队手里稳定性可能完全不一样。1.2 一致性模型强一致性与最终一致性聊分布式事务必须先从CAP说起。分布式环境下网络分区P不可避免所以只能在一致性C和可用性A之间权衡。强一致性要求所有操作在某个时刻要么全部完成要么全部回滚结果立即可见最终一致性则允许数据在一段时间内不一致但最终会达到一致状态。两者适用的业务完全不同。业界针对最终一致性提出了BASE理论基本可用、软状态、最终一致。你可以把BASE理解成“先能用后补数据”。比如订单创建后先告诉用户下单成功后台再异步发放积分如果积分服务短暂故障通过消息重试机制最终也会补上。但“转账”这类强一致业务就不行了A扣了钱B没到账用户马上就会发现。所以选分布式事务方案第一步不是挑框架而是确定业务能接受多久不一致、不一致时的业务影响有多大。想清楚这个边界后面所有选型都会变得清晰。2. 2PC两阶段提交方案2.1 核心流程与角色2PC全称Two-Phase Commit是强一致性协议的代表。它的核心思想是引入一个协调者协调多个参与者把提交过程拆成两个阶段。第一阶段是准备阶段协调者向所有参与者发送Prepare请求参与者在本地执行事务但不提交写好日志后回复“可以提交”或“不能提交”。如果所有参与者都回复“可以提交”第二阶段就是提交阶段协调者给所有参与者发Commit指令它们正式提交。只要有一个参与者回复“不能提交”或者超时没回复协调者就发送Abort各参与者回滚本地事务。从这个流程可以看出2PC能保证强一致是因为所有参与者都必须被同步锁定到协调者最终决定之后。参与者在Prepare阶段已经持有了数据库资源的锁如果有并发事务要读写这些数据会被阻塞。锁定的时间取决于参与者数量、网络延迟和协调者处理速度。我在一个项目里引入XA后数据库监控上的锁等待从2%飙到40%当时就意识到高并发场景下2PC不适合。这个代价不是2PC本身能解决的它来自强一致的承诺。2.2 实现一个最小2PC原型日常开发中我不会手写2PC因为要做日志恢复、分布式共识复杂度远超一个团队能维护的范围。但为了彻底理解它我建议你用模拟代码跑一遍流程。下面是用线程模拟参与者、用集合模拟状态存储的极简Java示例class Coordinator { ListParticipant participants; boolean executeGlobalTransaction() { ListParticipant prepared new ArrayList(); // 阶段一prepare for (Participant p : participants) { if (!p.prepare()) { rollbackPrepared(prepared); return false; } prepared.add(p); } // 阶段二commit for (Participant p : prepared) { p.commit(); } return true; } private void rollbackPrepared(ListParticipant prepared) { for (int i prepared.size() - 1; i 0; i--) { prepared.get(i).rollback(); } } }参与者要写事务日志记录当前状态为“Ready”协调者也要记录决策日志否则宕机后无法还原现场。很多人觉得直接用数据库XA就行确实MySQL、Oracle的XA是标准2PC实现但XA要求所有数据库统一分支协调者本身如果挂了自动恢复依然很难。所以2PC听起来简单真正要落地可靠并不容易。你要有专门机制处理协调者宕机、参与者长时间无响应、日志丢失这些极端情况否则强一致最后也一致不起来。2.3 2PC的致命弱点与适用场景2PC在互联网高并发场景极少被直接用原因主要有三个同步阻塞参与者需要长时间锁住资源等待协调者命令期间如果网络抖动锁时间无限拉长系统吞吐量直线下降。协调者单点协调者一旦宕机所有事务都停在中间态只能靠人工恢复。脑裂风险协调者在提交阶段发了一部分Commit后宕机部分参与者已提交部分没收到数据依然不一致。所以我对2PC的建议很明确适合低并发、参与者少、强一致要求极高的内部系统比如2~3个内部服务同步更新一份关键数据。金融核心账务偶尔也用XA但前提是并发可控。业务量大之后尽量别碰2PC后续讲的三种方案更贴近互联网场景。3. TCC方案3.1 TCC的运作模式TCC是Try-Confirm-Cancel的缩写本质是业务层事务。它把每个操作拆成三个步骤用业务冻结代替数据库锁Try资源检查与预留。比如扣库存时不是直接把库存扣掉而是冻结一部分库存并记录预留单号。Confirm确认执行业务操作使用Try阶段预留的资源把预扣记录真正落地。Cancel取消业务操作释放Try阶段预留的资源把冻结的库存放回可用库存。TCC的优点是性能好、可以跨异构存储因为锁的粒度从数据库行锁变成了业务预留。缺点是每个业务操作都要实现三套逻辑开发量成倍增加。而且Cancel必须能够完全抵消Try的副作用这对业务理解要求很高。我见过不少团队把Cancel简单写成一个DELETE结果因外键关系删不了或者删多了反而把数据弄乱。TCC不是简单的“接口拆分”它本质上是把数据库回滚能力搬到业务层需要你像设计数据库约束一样设计业务约束。3.2 一个订单库存的TCC实现继续用订单和库存举例。假设有一个库存服务提供TCC接口public interface InventoryTccService { boolean tryDeductStock(Long orderId, Long skuId, Integer quantity); boolean confirmDeductStock(Long orderId, Long skuId); boolean cancelDeductStock(Long orderId, Long skuId); }库存表用available_stock和frozen_stock两个字段。Try逻辑里判断可用库存是否足够够就执行UPDATE inventory SET available_stock available_stock - #{quantity}, frozen_stock frozen_stock #{quantity} WHERE sku_id #{skuId} AND available_stock #{quantity};再插入一条inventory_reserve记录记录订单ID、预留数量、状态。Confirm逻辑把预留记录状态改成已确认不需要再改库存总数Cancel逻辑则执行反向SQL把可用库存加回去、冻结库存减掉同时把预留记录标记为取消。这里必须强调Try、Confirm、Cancel三个方法必须都支持幂等。因为网络超时会重试重试时如果状态已经改变应该直接返回成功。比如Cancel连调两次第二次如果不判断状态就恢复库存会把可用库存多加一次导致超卖。先查询状态再决定动作是TCC的基础规范。另外Try阶段的UPDATE语句要把available_stock #{quantity}作为条件这样并发下库存不够时SQL影响行数为0就能快速判断失败避免超卖。3.3 TCC的关键难点与经验TCC的坑主要集中在异常场景最经典的是空回滚和悬挂。空回滚Try请求因为网络原因还没到达库存服务协调者就已经超时并触发了Cancel。Cancel执行时发现没有Try记录这时正确做法是直接返回成功不修改任何数据否则会把别人的库存错误加回去。解决办法是在Cancel接口里先按订单ID和SKU查询inventory_reserve如果记录不存在直接返回成功。悬挂Cancel先执行完Try请求才姗姗来迟。资源已经被释放了Try再执行预扣就会把库存冻结在那里没人管造成悬挂。解决办法是Try执行前先查状态如果发现已经有Cancel记录就放弃执行直接返回成功。这两类问题我用一张决策表帮助团队理解场景入口前置状态动作正常Try无记录预扣记录“冻结”空回滚Cancel无记录直接成功悬挂Try已存在Cancel记录直接成功重试Try已存在“冻结”记录返回成功不重复预扣在框架选择上我推荐Seata的TCC模式。Seata用TwoPhaseBusinessAction注解帮你管理全局事务上下文但空回滚、悬挂、幂等这些业务逻辑仍要自己写框架不会替你做。如果你自己写事务管理器请一定把故障恢复方案设计好否则异常路径会成为事故高发区。4. Saga方案4.1 Saga模式与TCC的区别Saga最早来自数据库论文核心思想是把一个长事务拆成多个本地事务每个本地事务都有对应的反向补偿事务。如果流程中某个步骤失败就按逆序调用之前的补偿操作把数据回滚到起点。它和TCC最大的区别是TCC有Try阶段做资源预留Saga没有直接执行真实操作再靠补偿撤销。比如旅行预订需要预订机票、酒店、租车。如果酒店预订失败就取消已经订好的机票。这里的机票已经真实出票补偿就不是简单的“撤销预订”而是退票可能涉及手续费。Saga的补偿逻辑是真实业务操作的反向不是数据库回滚因此设计时就要考虑补偿操作的代价。TCC像信用卡预授权先冻结额度最后扣款Saga更像退货退款先用真实商品跑完流程出了问题再退货退款退货还可能收手续费。这个比喻能帮你快速向业务方解释为什么Saga下“回滚”有成本。4.2 编排式 vs 协同式Saga有两种组织方式。编排式Orchestration有一个中央编排器它告诉每个服务“下一步做什么”以及“失败后执行什么补偿”。所有流程控制都集中在一处容易追踪、调试但编排器可能成为单点和性能瓶颈。协同式Choreography没有中央协调者每个服务完成本地事务后发布领域事件其他服务监听事件决定下一步动作和补偿。这种风格去中心化、系统解耦但流程散落在事件监听器里排查时要顺着事件链一路追踪。我自己的项目大多用编排式加Saga状态机原因是中小团队排查问题能力有限流程集中更可控。举个协同式的事件链例子订单服务发布“订单已创建”库存服务监听后扣库存并发布“库存已扣减”账户服务监听后扣款。如果账户扣款失败就发布“扣款失败”事件库存服务监听后补回库存订单服务再更新订单状态为失败。没有中央协调者但每个节点都要知道自己该补偿谁、谁补偿自己。这种模式一旦链路变长对事件追踪和可观测性要求非常高团队没有成熟的链路追踪体系不建议一上来就搞。4.3 Saga的实战要点Saga落地最重要的事情是状态机、幂等和补偿完整性。状态机可以用一张事务表维护。比如saga_instance表记录全局唯一ID、当前步骤、状态、业务载荷、待补偿步骤列表。每个服务执行完本地事务后更新表失败则设置状态为“COMPENSATING”然后逆序遍历补偿列表逐个调用补偿接口。这个方法让我在几次生产故障里都能快速定位是哪个步骤卡住是重试还是直接转人工。补偿幂等同样关键。补偿接口可能被重复调用如果补偿操作本身重复执行会导致数据反向变化两次。我要求所有补偿接口都用业务主键做唯一约束先查状态再执行。还有一个细节补偿顺序必须严格反向。比如先支付、再发积分、最后通知物流物流失败时你要先取消通知、再把积分扣回最后执行退款。顺序错了积分退不掉或者退款失败用户就会在App上看到互相矛盾的状态。写Saga时画出依赖图Review时专门检查补偿顺序能避免很多线上事故。5. 本地消息表方案5.1 核心思想最终一致性 消息对账本地消息表方案也叫可靠消息最终一致性方案。它把“业务操作”和“消息写入”放在同一个本地事务中业务成功则消息一定在表里之后通过异步任务把消息投递出去。这样做一定能避免“幽灵消息”和“丢消息”两个经典问题。举例来说下单成功需要通知用户中心加积分。如果先发MQ再更新订单订单事务回滚后消息还在下游收到一条假消息如果先更新订单再发MQ订单提交后进程崩溃消息丢失用户积分永远不到账。本地消息表方案则是在下单事务中同时插入订单和消息记录事务提交后就能保证消息肯定存在。之后就算应用崩溃重启后也能从表里扫出来继续投递。这个方案的精髓是“本地事务保证消息不丢”配合后台重试达到最终一致。它不需要分布式事务管理器也没有资源锁性能非常友好。但代价是你要自己维护一套消息状态管理和重试机制还要处理下游消费幂等。5.2 入门实现事务消息与定时扫描最基础的实现就是建一张消息表再加一个定时任务轮询。我常用的表结构是这样的CREATE TABLE transaction_message ( id bigint PRIMARY KEY AUTO_INCREMENT, biz_key varchar(64) NOT NULL COMMENT 业务唯一键, biz_type varchar(32) NOT NULL COMMENT 业务类型, payload text NOT NULL COMMENT 消息内容, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待发送 1-已发送 2-已完成 3-失败, retry_count int NOT NULL DEFAULT 0, next_retry_time datetime NOT NULL, create_time datetime NOT NULL, update_time datetime NOT NULL, UNIQUE KEY uk_biz_key_type (biz_key, biz_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;业务方法里在同一个事务中插入订单和消息表Transactional public void createOrder(OrderEntity order) { orderMapper.insert(order); messageMapper.insert(TransactionMessage.buildTask( order.getId(), JSON.toJSONString(order), ORDER_CREATED)); }定时扫描的SQLSELECT * FROM transaction_message WHERE status 0 AND next_retry_time NOW() ORDER BY id LIMIT 200;拿到记录后逐条发送发送成功更新状态失败则增加重试次数并把next_retry_time设置成当前时间加上指数退避比如 1分钟、2分钟、4分钟最多重试10次。重试超过上限状态置为失败并报警人工介入。这里有一个细节很多人踩过发送成功不等同于消费成功。如果只是发到MQ就算成功下游可能因为处理失败而没消费你的消息状态却已经标记完成。所以最好等下游ACK反馈再更新状态或者在消费端做补偿。我用过两边方案建议至少在消息表中加一个“已发送待确认”状态由下游回执后更新为“已完成”。5.3 引入事务消息的实践如果团队已经使用RocketMQ可以直接用RocketMQ的事务消息不用自己维护消息表。RocketMQ先发半消息本地事务执行后根据结果Commit或Rollback半消息。它的核心逻辑和本地消息表几乎一致只是把消息存储、重试、死信处理都交给了MQ。这样做代码更少但对MQ的可用性依赖更高必须有专门的运维支持。Kafka从2.5版本开始也支持事务API但使用体验和生态环境与RocketMQ不同需要结合团队情况评估。Pulsar的事务消息也比较完善但学习曲线稍陡。我的经验是如果团队没有专职中间件运维本地消息表反而更直观、更好排查如果中间件运维成熟事务消息能省掉你大量重试代码。无论选哪个下游消费必须做幂等和去重。我见过不少项目在消费端直接处理消息没做去重重试导致重复发券。处理方式很简单在消费入口用biz_key查询是否已处理同时给业务表加唯一索引兜底保证数据只被写一次。千万别偷懒这个习惯能救你很多次。6. 方案对比与选型6.1 四大方案横评表格上面四种方案各有侧重我整理了一张对比表方便快速定位维度2PCTCCSaga本地消息表一致性强一致最终一致预留最终一致补偿最终一致异步锁与性能DB锁性能低业务冻结性能中等无锁性能高异步性能高开发成本低靠数据库高三套逻辑高补偿逻辑中表定时任务中间件依赖数据库XA或框架Seata等自研或事件中心MQ或自研表适合场景低并发强一致高并发、资源预扣长流程、跨服务异步通知、积分、状态同步6.2 选型建议我的经验选型不能只看技术酷不酷。我做选型判断时会问自己三个问题业务能不能容忍中间状态事务涉及多少个参与者团队有没有能力维护额外组件如果业务明确要求绝对强一致比如资金核心账务并且并发很低、参与者少那就用2PC不要为了架构好看硬上TCC。如果业务并发高需要扣库存、扣预算这类资源预扣TCC最顺手但要接受三套逻辑的开发量。如果是长流程、涉及多个外部系统比如旅行预订、审批流Saga最合适但补偿设计要足够细。如果业务本质上就是异步通知、积分累计、状态同步用本地消息表或事务消息最高效完全没有必要引入强一致协议。我还想提醒一句不要把Seata当成万能工具。Seata虽然集成了AT、TCC、Saga但每种模式的效果差别很大。AT模式虽然代码侵入低但全局锁对数据库性能影响明显不适合大事务和长事务。选型时一定要结合自己的业务流量实测而不是照着Demo抄。我在一个库存项目里试用过AT模式压测到每秒500并发就开始出现大量全局锁等待换成TCC后就稳定很多。这些只有测试才会告诉你答案。7. 实操心得与常见坑7.1 我在业务系统中踩过的坑写分布式事务光看原理容易落地一定会碰到几个经典坑。幂等没做好重复执行一万年。我负责过一个积分服务消费本地消息表时没做去重网络抖动导致投递重试三次用户积分多了三倍。后来我在消费端用业务主键查询在业务表加唯一索引才解决。任何消息驱动、任何补偿操作默认都要做幂等这是分布式事务的底线。TCC的Cancel同理重试时如果没检查状态库存会被多加几次账永远对不上。补偿逻辑只做了一半。Saga里某业务补偿主表状态时没处理关联子表结果主表显示正常、明细缺失对账一直不平。后来我要求所有补偿逻辑必须覆盖主流程修改的每一个表特别是明细表并且Review时专门审“补偿路径”。有一个很有效的办法每个补偿方法写完自己手动把数据恢复到动作前看看有没有遗漏字段。超时和重试参数拍脑袋。TCC里Try超时设3秒逻辑本身就要2秒再加网络耗时高峰期频繁超时大量Cancel反而加重数据库压力。正确方式是先压测用P99耗时乘以1.5~2作为超时重试用指数退避避免重试风暴。建议把超时和重试参数做成配置项不要写死在代码里这样压测后调整不需要发版。7.2 快速定位分布式事务问题的方法分布式事务难排查因为出事时面对的是多台机器日志和多张数据表。我的排查三板斧比较有效。第一统一日志链路。用全局事务ID贯穿所有参与方打印到每一行日志。比如Seata里用XID自研Saga时在请求参数里传sagaId日志平台按这个ID检索所有参与方的执行轨迹一目了然。不要只用业务订单号因为一个订单可能涉及多个事务根本串不起来。第二把状态表当调试器。无论用哪种方案把事务状态持久化到一张表每一步更新状态。出问题直接查这张表看卡在哪个步骤、状态是WAIT、SUCCESS还是FAILED比翻日志高效得多。这张表也方便做对账和告警我基本每个项目都会建。第三用对账兜底。我每个业务上线前都要求配一个对账Job每天比对业务主表和消息表检查订单、库存、积分等关键数据是否最终一致。对账SQL就是一个多表LEFT JOIN查出状态不一致的记录告警。即便出了脏数据也能通过重放消息或补偿操作修复。这个对账Job是我认为整个分布式事务方案里最重要的安全网它让你在极端异常下也能及时发现并兜底。最后再分享一个小技巧上线分布式事务方案后一定要做故障演练。随便挑一个服务手动kill进程再看事务表最终能不能自动恢复。我见过太多系统平时一切正常一故障就卡在中间态就是因为没人演练过恢复路径。把这篇文章里的方案选型、状态机、幂等设计都落到代码里再配上一套故障演练你的系统才真正算得上“能抗揍”。别等到线上事故才去验证方案那时候已经晚了。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。