Saga补偿风暴的触发链路与限流策略——分布式事务从2PC到Saga的选型实践

简介: TCC的Cancel执行了但Try根本没执行过——这是空回滚。Try在Cancel之后才到达——这是悬挂。Saga的补偿操作级联触发导致系统被补偿请求淹没——这是补偿风暴。三个问题有一个共同特征:代码在测试环境跑得好好的,一到生产就出问题,而且出问题时往往账已经对不上了。本文拆解TCC空回滚、悬挂、幂等三大坑的时序成因,给出事务记录表的解决方案,分析Saga补偿风暴的触发链路和限流策略。

大家好,我是小耶,写功课只是为了我踩过的坑,你们别再踩了!

分布式事务有一个诡异的定律:测试环境跑一百遍都没事,上线第一分钟就翻车。

TCC的代码在测试环境跑了几百遍,Try、Confirm、Cancel三个接口都写得漂漂亮亮。上线之后,账对不上了。查了半天日志才发现——Cancel执行了,但Try根本没执行过。

这就是空回滚。今天把TCC和Saga的三个核心陷阱彻底拆开讲清楚。

先搞懂几个词:

TCC(Try-Confirm-Cancel) :分布式事务的一种实现模式。Try做资源预留,Confirm确认提交,Cancel取消回滚。相比2PC的同步阻塞,TCC不长时间持有锁,性能更好。

空回滚:Cancel执行了,但Try根本没执行过。

悬挂:Try在Cancel之后才到达,资源被冻结但没有对应的Confirm或Cancel来处理。

事务记录表:TCC解决空回滚和悬挂的核心工具。Try执行前先插入一条记录,Cancel执行前先查这条记录是否存在。

补偿风暴:Saga模式下,补偿操作级联触发,导致系统被补偿请求淹没。

一、TCC空回滚:Cancel执行了,但Try从没来过

时序成因

空回滚的触发链路是这样的:

  1. 调用方发起Try请求

  2. Try请求在网络中拥塞,超时了

  3. 调用方认为Try失败,触发Cancel

  4. Cancel执行,但Try根本没有执行过

  5. Try请求最终到达了——但Cancel已经执行完了

为什么会出问题?

Cancel的逻辑是“释放Try阶段预留的资源”。但如果Try根本没有执行,就没有资源被预留。Cancel执行时,会尝试“解冻”一笔不存在的冻结金额——如果代码写得不严谨,可能直接把账户余额给改了,造成数据不一致。

解决方案:事务记录表

核心思路是:Try执行前先记录,Cancel执行前先检查。

CREATE TABLE tcc_transaction (
    tx_id VARCHAR(64) PRIMARY KEY COMMENT '事务ID',
    status VARCHAR(16) NOT NULL COMMENT 'INIT/TRIED/CONFIRMED/CANCELLED',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
// Try:先标记,再冻结
public void try(String txId) {
   
    insert(txId, "INIT");
    freezeAmount(100);
    updateStatus(txId, "TRIED");
}

// Cancel:先检查Try是否执行过
public void cancel(String txId) {
   
    String status = queryStatus(txId);
    if (status == null) {
   
        // 空回滚:Try从未执行过,记录并返回成功
        insert(txId, "CANCELLED");
        return;
    }
    if ("CANCELLED".equals(status)) {
   
        // 幂等:已经回滚过了
        return;
    }
    unfreezeAmount(100);
    updateStatus(txId, "CANCELLED");
}

关键点:Cancel识别到空回滚后,不能直接返回成功——必须先记录一条CANCELLED记录。否则悬挂问题会紧随其后。

二、TCC悬挂:Cancel已经执行完了,Try才姗姗来迟

时序成因

悬挂的触发链路:

  1. Try请求在网络中拥塞,超时了

  2. 调用方触发Cancel,Cancel执行成功

  3. Try请求最终到达,开始执行业务操作

  4. 资源被冻结了,但事务管理器认为这个事务已经结束,不会再发Confirm或Cancel

  5. 冻结的资源永远无法释放

空回滚和悬挂是一对孪生问题。 空回滚是“Cancel先到了”,悬挂是“Try后到了”。两者都源于网络延迟导致的乱序。

解决方案

在Cancel空回滚返回成功之前,先记录该事务已回滚。Try接口在执行前检查这条记录:

// Try:检查是否已经被Cancel过
public void try(String txId) {
   
    String status = queryStatus(txId);
    if ("CANCELLED".equals(status)) {
   
        // 悬挂:Cancel已经执行过了,拒绝执行Try
        throw new RuntimeException("事务已被取消,拒绝执行Try");
    }
    insert(txId, "INIT");
    freezeAmount(100);
    updateStatus(txId, "TRIED");
}

空回滚和悬挂的完整防御需要三个检查:

场景 检查时机 检查内容 处理方式
空回滚 Cancel执行前 Try是否执行过 未执行过 → 记录CANCELLED,返回成功
悬挂 Try执行前 是否已被Cancel 已被Cancel → 拒绝执行Try
幂等 Confirm/Cancel执行前 是否已执行过 已执行过 → 直接返回成功

三、幂等:Confirm和Cancel可能被重复调用

TCC的三个接口(Try/Confirm/Cancel)都可能被重复调用。原因有两个:网络重试机制、事务管理器故障恢复后的重试。

解决方案:Confirm和Cancel执行前先查询事务状态。如果状态已经是CONFIRMED或CANCELLED,直接返回成功。

public void confirm(String txId) {
   
    String status = queryStatus(txId);
    if ("CONFIRMED".equals(status)) {
   
        return; // 幂等
    }
    commitAmount(100);
    updateStatus(txId, "CONFIRMED");
}

四、Saga补偿风暴:补偿链越长,风险越大

Saga模式和TCC不同。TCC是“预留+确认”,Saga是“直接执行+反向补偿”。每个本地事务执行后立即提交,失败时按反向顺序执行补偿操作。

补偿风暴的触发链路

Saga流程是:LT1 → LT2 → LT3 → ... → LTn。如果LTn失败,需要按反向顺序执行:CTn → CTn-1 → ... → CT2 → CT1。

补偿风暴发生在补偿操作本身也失败时。如果CTn-1补偿失败,事务管理器会重试。如果重试多次仍然失败,可能需要人工介入。更严重的是,如果补偿操作触发了新的Saga流程(比如补偿操作本身是一个跨服务调用),补偿链会级联扩散。

一个真实的场景:电商订单流程涉及下单、扣库存、扣积分、发优惠券、通知物流。如果第5步失败,需要依次补偿前4步。但如果“退优惠券”这个补偿操作本身也失败了(比如优惠券服务不可用),补偿链就卡住了。此时如果第5步的补偿触发了一个新的Saga流程,整个系统可能被补偿请求淹没。

解决方案

方案一:补偿操作加限流。 给补偿操作设置独立的线程池和限流阈值,避免补偿请求挤占正常业务资源。

方案二:补偿操作加退避。 补偿失败后不立即重试,而是等待一段时间后再试。重试间隔逐次递增。

方案三:补偿操作独立幂等。 补偿操作必须幂等——重复执行结果一致。这样即使重试多次,也不会造成数据错误。

方案四:设置补偿超时和人工介入阈值。 补偿操作超过一定次数后,转为人工处理,避免系统被无限重试拖垮。

五、小结

TCC的空回滚、悬挂、幂等三个问题有一个共同的根因:网络延迟导致的时序乱序。空回滚是Cancel先到了,悬挂是Try后到了,幂等是重复请求。解决方案的核心是事务记录表——Try执行前记录,Cancel执行前检查,Confirm/Cancel执行前查询状态。Saga的补偿风暴则源于补偿链的级联效应,需要通过限流、退避、独立幂等和人工介入阈值来控制。

小耶在手,SQL 不愁

还有什么想了解的,欢迎留言!小耶一定知无不言言无不尽……我们下次见~

相关文章
|
26天前
|
存储 Oracle 关系型数据库
双轨并行架构设计:全量迁移的并行策略、增量同步的延迟基准与双向Master实现
传统数据库迁移面临停机窗口长、数据一致性难保证、故障缺乏回退手段三大结构性挑战。双轨并行方案通过“全量迁移+增量同步+双向回切”的技术组合,将迁移从“一次性大爆炸”拆解为“可验证、可回退的渐进过程”。本文从全量迁移的并行策略、增量同步的日志解析与延迟控制、双向切换的决策点设计、一致性校验的触发机制四个层次展开。
|
23天前
|
存储 SQL Linux
时序数据库InfluxDB内存30分钟从40%飙到98%——高基数问题的四步诊断与四种解法
时序数据库InfluxDB的内存使用率突然从40%飙升至98%,OOM Killer强制终止进程——这是高基数问题的典型表现。TSM引擎为每个序列维护独立的内存索引项,当标签组合爆炸时,序列数急剧增长,内存索引膨胀导致OOM。本文拆解从基数爆炸到OOM的完整链路,给出四步诊断法和四种解决方案,并分析如何从内核层面规避高基数陷阱。
|
19天前
|
SQL 算法 关系型数据库
优化器选错执行计划的原因与Hint干预的边界——统计信息、代价模型与版本兼容性
统计信息过期、数据分布倾斜、代价模型局限——优化器选错执行计划的原因有很多。当SQL没改,执行计划却突然变差时,除了更新统计信息,还有一条路:用Hint强制干预优化器的决策。本文拆解MySQL 8.0的Hint语法体系、五类常用Hint的实战场景,以及Hint的局限与替代方案。
|
20天前
|
缓存 监控 关系型数据库
内存只涨不落|拆到组件才发现占的是会话和临时表
从应用报 OOM、调小 Buffer Pool 没用切入,把 MySQL 内存分五层,用 performance_schema 逐层定位,讲清真不释放与高水位不回收两类根因,给出止血与根治。
|
20天前
|
存储 人工智能 关系型数据库
从时间到语义:时序数据库与向量数据库的核心机制、选型与多模融合趋势
时序数据库专门处理“带时间戳的数据”,向量数据库专门处理“高维向量的相似度检索”。两者都不是通用型数据库,而是为特定数据形态和查询模式深度优化。本文拆解两种数据库的核心机制、适用场景,以及2026年多模融合趋势下它们与通用数据库的关系。
|
20天前
|
SQL 固态存储 关系型数据库
SMO无法并发+加锁范围大——InnoDB B-tree索引锁的两个结构性问题
写密集、高并发场景下,MySQL吞吐量上不去,加CPU、换SSD都没用——瓶颈可能不在硬件,在InnoDB的B-tree索引锁上。B-tree的结构修改操作(SMO)一次只能跑一个,索引级锁把所有写操作串行化。本文拆解B-tree索引锁的瓶颈根源、并发SMO的技术原理、Latch Coupling的加锁优化机制,以及热点行事务合并的实践思路。
|
21天前
|
存储 SQL 监控
备份任务绿勾还在|数据已经回不到对的样子
从一次恢复演练里数据是错值切入,把数据可靠性拆成可用性与正确性两层,讲清静默损坏的三类来源、页校验和与双写的检测机制及其边界,说明为什么物理备份会连同坏页一起被带走,给出页校验扫描、字段级指纹比对与恢复演练验收的做法。
|
22天前
|
SQL 中间件 关系型数据库
没人改代码接口却超时|我给数据库加了四道防线
从交易接口零星超时、优化慢 SQL 却不见效切入,讲清数据库是共享资源池,重查询会饿死交易;给出入口、语句、负载、实例四道隔离防线,拆解超时、资源组、中间件路由与熔断做法,并附负载分级表与隔离验证。
|
22天前
|
存储 监控 关系型数据库
PostgreSQL表没变大索引却膨胀了——HOT更新的两个前提条件与三个失效场景
PostgreSQL表没怎么变大,索引却膨胀了几倍——这是频繁更新的表最常见的性能退化模式。HOT(Heap-Only Tuple)更新是PostgreSQL针对这个问题的核心优化:当UPDATE不修改任何索引列,且新行能放在同一页时,可以跳过所有索引更新。但HOT有两个前提条件,任何一个被破坏,索引膨胀就会加速。本文从HOT的机制出发,拆解三个最常见的HOT失效场景,给出fillfactor调优策略和n_tup_hot_upd监控方法。
|
26天前
|
存储 缓存 固态存储
InnoDB Change Buffer:触发条件、Merge时机、SSD时代的价值重估
Buffer Pool是InnoDB最被熟知的内存结构,但Change Buffer是写入路径上另一个关键机制。当二级索引的写入不需要立即读取索引页时,InnoDB会将变更缓存在Change Buffer中,等到索引页被读取时再合并。这个机制让二级索引的随机写入不再产生大量随机I/O,但唯一索引无法使用它,SSD时代它的价值也在被重新评估。本文拆解Change Buffer的触发条件、merge时机、存储结构,以及innodb_change_buffer_max_size的调优逻辑。

热门文章

最新文章