支付服务那些坑:从先落库到分表预案,一笔钱背后的设计决策

支付服务:每个"为什么"背后都是真金白银 为什么支付服务和普通业务完全不一样 写业务代码,最常见的是 CRUD。增删改查写熟了,觉得什么服务都差不多——直到被分配写支付服务。 支付和普通业务有本质区别:普通业务操作的是信息,支付操作的是钱。信息写错了能改,钱出去了就是真金白银的损失。更扎心的是,支付服务里每一个看似"怎么做都行"的设计决策,背后都藏着一个"做错了会怎样"的财务事故。 这一篇不讲支付怎么接入第三方(那是另一篇的活),专门讲设计决策:先落库还是先调渠道、支付单和退款单要不要分开、前端该直连支付还是走订单、状态机怎么拆、将来分库分表怎么留预案。这些决策不写明白,代码写对了也是悬的——哪天线上出了对不上账的事故,回头看全是今天的"小事"。 设计决策 1:先落库支付单,还是先调渠道 prepay? 踩坑现场 第一次写支付创建接口的人,几乎都会纠结这个顺序。有人觉得"先调渠道拿参数,再落库,这样能确认渠道成功"——听着有道理,其实是财务黑洞的开端。 为什么必须"先落库、后调渠道" 插入 pay_order 失败?→ 直接抛异常终止,永不调渠道 插入 pay_order 成功?→ 调渠道 prepay → 拿拉起参数 → 返回前端 这笔顺序是强同步串行的,理由有三个: ① pay_order 是"我方要收这笔钱"的唯一凭证。必须先记账,再去碰第三方。渠道 prepay 失败、渠道宕机时,本地已有待支付单——可重试、可追溯、可对账。反过来,渠道调好了本地啥都没有,这笔支付意图就丢了。 ② 反向顺序是财务黑洞。先调渠道拿参数、再落库,万一落库失败(DB 故障、唯一键冲突、事务回滚),渠道侧已经有一笔预支付交易、本地没有单。用户真拿着参数付了款,渠道回调过来本地无单可匹配——用户钱付了,我方账上没收,直接资金风险。 ③ prepay 本身不扣款。 alipay.trade.app.pay 只是"下单拿拉起参数",用户还没付款。所以先落库后调渠道,即使渠道失败也没有资金损失,重试即可。 顺带一个容易踩的长事务坑 创建接口标了 @Transactional ,prepay 这个外部 HTTP 调用被包进了本地事务——prepay 慢(外部网络)会长时间占用数据库连接,高并发下单时是隐患。严谨做法是把 prepay 移出事务: 事务 A:insert pay_order(本地,快) 无事务:调渠道 prepay(外部,慢) 事务 B:prepay 失败则更新 pay_order 状态 顺序本身不变,但别让外部调用拖住数据库事务。 设计决策 2:支付单和退款单,为什么分成两张表? 踩坑现场 看表结构时容易嘀咕:支付单和退款单字段挺像的——都有金额、订单号、渠道、用户、时间。为什么不合成一张表,用个"方向"字段区分? 为什么必须分开 ① 一对多是硬约束。一笔支付可以多次退款:买 399 退一件 99,再退一件 100——支付单只有一笔(399),退款单有两笔(99+100)。退款独立成表才能记录多次退款历史,塞进支付单就毁了。 ② 状态机本质不同。支付单管"收钱":待支付 → 已支付 → 关闭/失败;退款单管"退钱":待处理 → 处理中 → 成功/失败 + 审核流。两个状态机混在一张表必然打架。 ...

十月 29, 2023 · 5 分钟 · 858 字 · yaomingye

补偿机制:分布式系统中出事了我兜底的设计哲学——从本地回滚到Saga编排的完整实践

补偿:别等炸了才想兜底 某一天凌晨,运维群里弹出一条告警:订单服务返回码全是 500,错误日志里赫然写着 库存扣减失败,事务已提交。排查一圈发现——库存服务超时了,但订单服务的本地事务已经提交,用户钱扣了,货没发出去。 这不是什么玄幻剧情。只要你的系统一次操作涉及两个以上的外部依赖,它就一定会发生。 问题的根儿不在于某个服务挂了,而在于挂了之后没人善后。这就是补偿机制要解决的事。 📌 前置知识:本文假设读者已经知道数据库事务 ACID 的基本概念、分布式系统中"网络不可靠"的前提。如果对分布式事务的 2PC / TCC / Saga 还没概念,建议先翻一下本站的《分布式事务基础》和《TCC + Saga》两篇。 什么是补偿机制 先给个直接的定义: 补偿(Compensation) 是一系列操作,用于撤销一个已经部分执行或完全执行的业务流程,使系统回到业务上可接受的一致状态。 注意两个关键词: 撤销——不是 “取消”,是"对已经产生的副作用进行逆操作"。扣掉的库存加回去,冻结的额度解冻,发的优惠券标记作废。 业务上可接受——补偿之后的状态不一定等于执行之前的状态。比如退款流水里多了一条退款记录,这不是脏数据,这是业务可审计的中间态,本来就是设计的一部分。 补偿 ≠ 回滚(Rollback)。回滚是数据库层的物理操作,依赖 undo log,对业务透明;补偿是业务层的逻辑操作,需要开发者显式编写逆操作代码。 > ⚠️ 新手提示:把补偿理解成 Ctrl+Z 不准确。Ctrl+Z 是"回到上一步",补偿是"把已经造成的后果消弭掉"——相当于打翻了水杯,Ctrl+Z 是水自动回到杯子里(物理回滚),补偿是拿抹布擦干净桌子然后重新倒一杯(业务补救)。 补偿思维从本地就开始了 很多人觉得补偿是"分布式事务"才碰的东西,其实本地代码里到处都是补偿的影子,只是你没把它当成一个专门的概念。 场景一:文件操作的"撤销三部曲" public void processFile(String srcPath, String destPath) { File backupFile = null; File tempFile = null; try { // 步骤1:创建备份 backupFile = new File(srcPath + ".bak"); Files.copy(Path.of(srcPath), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); // 步骤2:处理并写入临时文件 tempFile = new File(destPath + ".tmp"); try (var reader = new BufferedReader(new FileReader(srcPath)); var writer = new BufferedWriter(new FileWriter(tempFile))) { String line; while ((line = reader.readLine()) != null) { writer.write(transform(line)); writer.newLine(); } } // 步骤3:原子替换目标文件 Files.move(tempFile.toPath(), Path.of(destPath), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } catch (Exception e) { // 补偿逻辑:清掉所有中间产物 if (tempFile != null && tempFile.exists()) { tempFile.delete(); // 撤销步骤2 } if (backupFile != null && backupFile.exists()) { try { Files.move(backupFile.toPath(), Path.of(srcPath), StandardCopyOption.REPLACE_EXISTING); // 撤销步骤1 } catch (IOException ex) { log.error("连备份恢复都失败了,手动处理吧...", ex); } } throw new ProcessingException("文件处理失败,已尽力回滚", e); } } 这段代码没什么高深的,但仔细看它的结构——每执行一步,catch 里就有对应的逆操作。这就是补偿机制的最朴素形态: ...

二月 20, 2023 · 9 分钟 · 1854 字 · yaomingye

微服务支付系统全景:从接入第三方到对账结算,一笔钱走完的九九八十一难

一笔钱在微服务里到底怎么走的 为什么支付是微服务里最难啃的骨头 做业务开发,碰到的最常见代码可能就是 CRUD。增删改查写熟了,觉得微服务也不过如此——直到某天被分配了支付模块。 支付和普通业务有本质区别:普通业务操作的是"信息",支付操作的是"钱"。写错一行代码,信息可以修,钱出去了就是真金白银的损失。更麻烦的是,支付不是自己一个服务就能搞定的事——要接微信、要接支付宝、可能还要接银联、接 Stripe。每家渠道的接口风格不同,回调机制不同,对账方式也不同。上游还有订单系统在等支付结果,下游有会计系统等着入账。 flowchart LR %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca,font-weight:bold; RISK1["[钱出去了\n回不来]"] RISK2["[重复支付\n多扣款]"] RISK3["[回调丢失\n订单卡死]"] RISK4["[对账不平\n财务追杀]"] RISK5["[渠道故障\n全站瘫痪]"] CORE["支付系统\n核心矛盾:\n复杂 × 高风险 × 强一致性"] RISK1 --> CORE RISK2 --> CORE RISK3 --> CORE RISK4 --> CORE RISK5 --> CORE class RISK1,RISK2,RISK3,RISK4,RISK5 reject; class CORE highlight; 把这些复杂度拆开来看,一个支付系统本质上要解决五个问题: 怎么收——对接各种支付渠道,屏蔽渠道差异 怎么记——每笔钱的来龙去脉都要有据可查 怎么验——回调确认钱真的到了,不是"用户说付了就算付了" 怎么对——自己的账和渠道的账对得上 怎么退——钱能收就能退,但不能退多了,也不能重复退 下面逐个拆解。 支付系统的"五脏六腑":模块全景图 在动手写代码之前,先搞清楚一笔钱在系统里要经过哪些模块。 flowchart TD %% 半暗底色 + 高亮描边:完美适配博客深色/浅色双主题 %% classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; classDef highlight fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa,font-weight:bold; subgraph FRONT["接入层"] GATE["[支付网关\n路由/验签/限流/协议转换]"] end subgraph CORE_MOD["核心支付域"] ORDER["[支付订单服务\n订单创建/查询/状态流转]"] CHANNEL["[支付渠道服务\n渠道抽象/路由/适配器]"] CALLBACK["[回调处理服务\n异步通知/幂等/重试]"] REFUND["[退款服务\n退款申请/审核/执行]"] end subgraph BILLING["清算对账域"] RECON["[对账服务\nT+1对账/差异处理/长款短款]"] SETTLE["[结算服务\n分账/手续费/入账]"] end subgraph INFRA["基础设施"] MQ["[消息队列\n异步解耦/重试]"] IDEM["[幂等表\n防重支付/防重回调]"] LGR["[流水表\n不可变审计日志]"] end FRONT --> CORE_MOD CORE_MOD --> BILLING INFRA -.-> CORE_MOD INFRA -.-> BILLING class GATE highlight; class ORDER,CHANNEL,CALLBACK,REFUND process; class RECON,SETTLE data; class MQ,IDEM,LGR process; 每个模块解决一类问题: ...

二月 14, 2023 · 6 分钟 · 1167 字 · yaomingye

事务消息:半消息与回查

事务消息 本文是分布式算法科普系列第五篇。上一篇讲了 2PC 和 TCC——处理"同步调用"场景下的分布式事务。这一篇换一个跑道——当业务逻辑和消息发送需要原子化,但发消息本身是异步的,怎么保证一致性? 一、故事:“先写数据库还是先发消息"的终极难题 在消息队列成为微服务通信标配之后,开发者很快撞上了一个死结。一个极其常见的场景:订单创建成功 → 需要发一条消息通知下游(发优惠券、发短信、记录日志)。代码看起来人畜无害: // 伪代码——演示问题——不要在生产里这么写 BEGIN TRANSACTION INSERT INTO orders (...) COMMIT // ↓ 事务已经提交了 mq.send("order_created", order) // 如果这里执行之前——进程突然挂了? 数据库写入了,消息没发出去——下游永远不知道这笔订单。 那把发消息放进事务里? BEGIN TRANSACTION INSERT INTO orders (...) mq.send("order_created", order) // 消息队列有自己的事务吗? COMMIT 数据库事务和消息队列是两套独立的系统——没有"联合事务"这种东西。数据库的 ROLLBACK 不会撤回已经发到 Broker 的消息。 那反过来——先发消息再写数据库? mq.send("order_created", order) // 消息发出去了 // ↓ 然后写数据库时——数据库挂了 INSERT INTO orders (...) // 失败! 消息发出去了,数据库没写入——下游收到消息后来查订单——发现根本没有这笔订单。 写过的都懂——这个"先有鸡还是先有蛋"的问题在异步场景下几乎无解。早期方案是在数据库里建一张"消息发件箱"表(outbox),把消息和业务数据在同一个事务里写入,再用一个独立的进程轮询这张表来真正发送。但这个方案太重了——需要额外的轮询进程、需要处理重复投递、需要清理已发送的消息。 2016 年前后,RocketMQ 的团队给出了一个更优雅的方案——让 Broker 自己承担"协调者"的角色,引入"半消息"和"回查"两个机制,一举解决了这个难题。这就是事务消息(Transactional Message)。 二、前置:同步事务 vs 异步事务 在深入事务消息之前,先理清它和上一篇讲的 2PC/TCC 之间的分工: 场景 用哪种方案 特点 服务 A 同步调用服务 B——需要 B 的操作和 A 的操作一起成功或回滚 2PC / TCC 同步——A 等 B 的返回结果 服务 A 发消息给服务 B——需要消息的发送和 A 的本地事务原子化 事务消息 异步——A 不关心 B 什么时候消费 2PC/TCC 处理的是"请求-响应"模式下的分布式事务,事务消息处理的是"发布-订阅"模式下的分布式事务。它们解决的是同一个问题(一致性)的两个不同侧面。 ...

一月 22, 2023 · 3 分钟 · 510 字 · yaomingye

分布式事务:两阶段提交与 TCC

分布式事务 本文是分布式算法科普系列第四篇。前三篇讲了服务发现、共识算法、流控——都是"怎么把活分下去"和"怎么保护自己不被冲垮"。这一篇回到一个老问题:一笔业务操作跨越了多个服务,怎么保证数据要么全成功、要么全回滚? 一、故事:数据库拆了,事务怎么办 1970 年代,随着数据库从单机走向网络化,一个此前不存在的问题浮现出来——一笔业务需要同时修改两台机器上的数据,怎么保证原子性? 在单机数据库上,事务是再自然不过的事情——BEGIN → 改 A 表 → 改 B 表 → COMMIT。数据库内部用 undo log 和 redo log 保证崩溃恢复后数据的一致性。但如果 A 表在机器 1 上,B 表在机器 2 上——COMMIT 只对机器 1 生效,机器 2 没收到,或者收到了但执行到一半宕机了——怎么办? Jim Gray 在 1978 年的《Notes on Data Base Operating Systems》中首次系统描述了两阶段提交(2PC,Two-Phase Commit)——用一个"协调者"站在所有参与者中间,分两步确认:第一步问所有人"准备好了没",第二步根据所有人的答复决定"一起提交"还是"一起回滚"。 这个设计的影响延续至今。XA 规范(1991 年由 X/Open 组织发布)将 2PC 标准化为分布式事务处理的工业协议。几乎所有关系型数据库(MySQL、Oracle、PostgreSQL)都支持 XA 事务。 但 2PC 有一个众所周知的痛点——同步阻塞。协调者挂了,参与者只能干等。于是在微服务时代,一种更灵活的方案出现了——TCC(Try-Confirm-Cancel),把二阶段的"锁资源"升级为"预留资源 + 确认或释放"。 二、前置:单机事务不够用了 先从业务场景开始。一个典型的电商下单流程: 下单(Order 服务)→ 扣库存(Inventory 服务)→ 扣余额(Account 服务) 三个操作跨了三个服务、三套数据库。如果在"扣库存"成功后、“扣余额"之前——Account 服务宕机了——库存扣了,但余额没扣,钱没收,货没了。 这就是分布式事务要解决的问题——跨多个服务(多个数据库)的一组操作,要么全部成功,要么全部回滚。 单机事务靠 ACID 保证——原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。分布式事务的目标也是 ACID,但实现手段完全不同——它不靠数据库内部的 undo/redo log,而是靠多个参与者之间的协调协议。 ...

一月 21, 2023 · 4 分钟 · 694 字 · yaomingye

事务消息 + 本地消息表 + 生产踩坑

事务消息 + 本地消息表 📖 前置阅读:本文是分布式事务系列的第四篇——假设你已经理解了 CAP/BASE 理论、Seata AT 的 undo_log 机制、TCC 的 Try/Confirm/Cancel 三阶段和 Saga 的补偿链。如果这些概念还陌生——先读 分布式事务本质——CAP、BASE 与四大方案、Seata AT 模式——undo_log 与二阶段原理 和 TCC + Saga——补偿型分布式事务。 一、⚡ 同步方案的瓶颈——为什么还需要异步方案 先回顾前面三篇文章我们做了什么: Seata AT:下单 → 扣库存 → 扣余额——三个操作在一个 @GlobalTransactional 中——同步执行 TCC:Try 预留 → Confirm 确认 → Cancel 回滚——三个阶段——同步执行 Saga:正向执行 → 失败逆补偿——协调者串联——同步执行 它们有一个共同特征:调用方要等所有分支都执行完——才返回结果。 order-service 调用 product-service 扣库存: → 发起 RPC 调用 → 等待 product-service 处理 → 等待 product-service 返回结果 → 拿到结果——继续下一步 如果 product-service 很慢——比如库存要查 3 个 Redis + 2 个 DB: → order-service 的线程就等着 → 线程池撑爆 → 整个链路超时 同步方案的根本矛盾:事务参与方的响应时间——直接影响调用方的吞吐量。 ...

十二月 30, 2022 · 17 分钟 · 3414 字 · yaomingye

TCC + Saga——补偿型分布式事务

TCC + Saga 📖 前置阅读:本文假设读者已理解 Seata AT 模式的原理和局限。如果还不熟悉,建议先阅读 Seata AT 模式——undo_log 与二阶段原理。 一、⚡ AT 能回滚库存——但能回滚一条"已发出的短信"吗? AT 模式的局限——上一篇说了: AT 的自动回滚依赖 undo_log——生成反向 SQL INSERT → DELETE(undo_log 记录自增 ID——反向就是 DELETE) UPDATE → UPDATE(undo_log 记录前置镜像——反向就是把值改回去) 但以下操作——数据库回滚不了: ① 发了优惠券——HTTP POST 到营销系统的 API——数据库回滚不了 HTTP 调用 ② 发了短信——调了阿里云短信 API——阿里云不会因为你的"反向 SQL"就收回短信 ③ 调了第三方支付——Payment API 已经扣了钱——不能"生成反向 HTTP"退钱 ④ 给 Redis 写了一个计数器——Redis 没有 undo_log——AT 管不了 TCC 和 Saga 就是为这而生的——手动补偿——操作本身和撤回操作都由你写代码实现。 二、🔄 TCC——Try / Confirm / Cancel——你自己管理回滚 2.1 TCC 的本质——每个操作配一个"撤销操作" TCC 把每个业务操作拆成三个方法: Try(尝试) —— 预留资源——但不真正执行 Confirm(确认) —— 真正执行——Try 预留的资源生效 Cancel(取消) —— 释放 Try 预留的资源——回滚 和 AT 的区别: AT:你写一套代码——Seata 自动生成"撤销操作"(反向 SQL) TCC:你写三套代码——Try(正向)、Confirm(确认)、Cancel(撤销) → 写了三套代码——能处理任何类型的操作——不再局限于数据库 2.2 示例——“创建订单 + 发优惠券 + 扣积分”——用 TCC // ===== 场景:下单时——创建订单 + 发优惠券 + 扣积分 ===== // 订单是 DB 操作——但发优惠券是 HTTP API——扣积分也是 HTTP API // AT 回滚不了 HTTP API——用 TCC // ===== 订单服务——TCC 接口 ===== public interface OrderTccAction { /** * Try:预创建订单——状态为 PENDING——库存还没扣——订单还不能支付 * @param businessContext 在 TM 端传入的参数——和 @BusinessActionContextParameter 对应 */ @TwoPhaseBusinessAction( name = "order-create", // TCC 资源名 commitMethod = "confirmCreateOrder", // Confirm 方法 rollbackMethod = "cancelCreateOrder" // Cancel 方法 ) boolean tryCreateOrder( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "items") List<OrderItemDto> items, @BusinessActionContextParameter(paramName = "totalAmount") BigDecimal totalAmount ); /** * Confirm:把订单从 PENDING 变为 CREATED——正式生效 */ boolean confirmCreateOrder(BusinessActionContext context); /** * Cancel:把 PENDING 的订单变为 CANCELLED——释放预占 */ boolean cancelCreateOrder(BusinessActionContext context); } // ===== 订单服务——TCC 实现 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private OrderMapper orderMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { // ① 预创建订单——状态为 PENDING——不是正式订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setTotalAmount(totalAmount); order.setStatus(OrderStatus.PENDING); // ← PENDING——不是正式订单——不可支付 order.setCreatedAt(LocalDateTime.now()); orderMapper.insert(order); // ② 把 orderId 存入 BusinessActionContext——Confirm/Cancel 会用到 // Seata 自动把方法返回值之外的参数存入 Context // 这里通过 RootContext 手动放 RootContext.bind("orderId_" + RootContext.getXID(), order.getId()); return true; // Try 成功——等待 TC 通知 Confirm 或 Cancel } @Override @Transactional public boolean confirmCreateOrder(BusinessActionContext context) { // ① 从 Context 中取出 orderId Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); // ② 把订单状态从 PENDING → CREATED——正式生效 Order order = orderMapper.selectById(orderId); if (order == null || order.getStatus() != OrderStatus.PENDING) { // 幂等——如果已经 Confirm 过了——不再处理 return true; } order.setStatus(OrderStatus.CREATED); orderMapper.updateById(order); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { Long orderId = (Long) context.getActionContext() .get("orderId_" + context.getXid()); Order order = orderMapper.selectById(orderId); if (order == null) { // 空回滚——Try 还没执行——Cancel 先到了——不做处理 return true; } if (order.getStatus() == OrderStatus.CANCELLED) { // 幂等——已经取消过了 return true; } order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } // ===== 优惠券服务——TCC 接口(HTTP API——AT 回滚不了)===== public interface CouponTccAction { @TwoPhaseBusinessAction( name = "coupon-grant", commitMethod = "confirmGrantCoupon", rollbackMethod = "cancelGrantCoupon" ) boolean tryGrantCoupon( @BusinessActionContextParameter(paramName = "userId") Long userId, @BusinessActionContextParameter(paramName = "couponType") String couponType ); boolean confirmGrantCoupon(BusinessActionContext context); boolean cancelGrantCoupon(BusinessActionContext context); } @Service public class CouponTccActionImpl implements CouponTccAction { @Autowired private CouponService couponService; // 这个 Service 调外部营销 API @Override public boolean tryGrantCoupon(Long userId, String couponType) { // Try:预占优惠券——调营销 API——标记为用户——但未激活 Coupon coupon = couponService.reserveCoupon(userId, couponType); // 外部 API 返回了 couponId RootContext.bind("couponId_" + RootContext.getXID(), coupon.getId()); return true; } @Override public boolean confirmGrantCoupon(BusinessActionContext context) { // Confirm:激活优惠券——用户可用 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); couponService.activateCoupon(couponId); // HTTP PUT /coupons/{id}/activate return true; } @Override public boolean cancelGrantCoupon(BusinessActionContext context) { // Cancel:回收优惠券——把预留的优惠券放回库存 Long couponId = (Long) context.getActionContext() .get("couponId_" + context.getXid()); if (couponId == null) { return true; // 空回滚——Try 还没执行完 } couponService.recycleCoupon(couponId); // HTTP DELETE /coupons/{id} return true; } } // ===== TM——全局事务发起方——调各个 TCC 接口 ===== @Service public class OrderApplicationService { @Autowired private OrderTccAction orderTccAction; @Autowired private CouponTccAction couponTccAction; @Autowired private PointTccAction pointTccAction; @GlobalTransactional public Order createOrderWithCoupon(CreateOrderRequest request) { // ① Try:预创建订单 boolean orderTry = orderTccAction.tryCreateOrder( request.getUserId(), request.getItems(), request.getTotalAmount()); if (!orderTry) throw new BusinessException("预创建订单失败"); // ② Try:预发优惠券——不是数据库操作——是 HTTP 调外部 API boolean couponTry = couponTccAction.tryGrantCoupon( request.getUserId(), "FIRST_ORDER"); if (!couponTry) throw new BusinessException("预发优惠券失败"); // ③ Try:预扣积分——也是 HTTP 调外部 API boolean pointTry = pointTccAction.tryDeductPoints( request.getUserId(), 100); if (!pointTry) throw new BusinessException("预扣积分失败"); // ④ 所有 Try 成功——TM 通知 TC 进 Confirm // TC 依次调每个 RM 的 confirmXxx() // → orderTccAction.confirmCreateOrder() ——订单 PENDING→CREATED // → couponTccAction.confirmGrantCoupon() ——优惠券激活 // → pointTccAction.confirmDeductPoints() ——积分确认扣除 return ...; // 返回订单信息 } // 如果任何一个 Try 抛异常——TC 依次调每个 RM 的 cancelXxx() // → orderTccAction.cancelCreateOrder() ——订单 PENDING→CANCELLED // → couponTccAction.cancelGrantCoupon() ——优惠券回收 // → pointTccAction.cancelDeductPoints() ——积分退回 } 2.3 TCC 的两个致命陷阱——空回滚与悬挂 陷阱一:空回滚——Try 没执行——Cancel 先到了 时间线: ① TM 调 Order TCC 的 Try——网络超时——TM 不知道 Try 成功了没有 ② TM 决定回滚——发起 Cancel ③ Cancel 到达 order-service——但此时 Try 还没收到(网络延迟)——或者 Try 正在执行 ④ Cancel 执行时——订单不存在(Try 还没创建)——Cancel 失败 这叫"空回滚"——Cancel 先于 Try 到达 解决——控制记录表: 在 Cancel 中——如果查不到订单——不能报错——记录一条"Cancel 已执行"的空记录 当 Try 终于到达时——先查"Cancel 是否已执行"——如果是——Try 不再执行 陷阱二:悬挂——Try 超时后——Cancel 执行了——Try 又到了 时间线: ① TM 调 Try——Try 执行中——卡住了(GC 停顿——网络延迟) ② TM 等 10 秒超时——发起 Cancel ③ Cancel 到达——顺利执行——订单状态改为 CANCELLED ④ 第 30 秒——Try 终于执行完了——订单 INSERT 进去了——状态是 PENDING ⑤ 结果:Cancel 已经执行了——但 Try 把数据又写进去了——这个 Try"悬挂"了 这叫"悬挂"——Try 在 Cancel 之后到达——Cancel 的撤销效果被 Try 覆盖了 解决——同样用控制记录表: Cancel 执行时——记录一条"xid=xxx 已 Cancel" Try 执行前——先查"xid=xxx 是否已 Cancel"——如果是——拒绝执行 -- ===== TCC 防悬挂 + 空回滚控制表——每个参与 TCC 的服务都建一张 ===== CREATE TABLE tcc_operation_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, xid VARCHAR(128) NOT NULL COMMENT '全局事务 ID', branch_id BIGINT NOT NULL COMMENT '分支事务 ID', action_name VARCHAR(64) NOT NULL COMMENT 'TCC 资源名——order-create/coupon-grant', status TINYINT NOT NULL COMMENT '1-Try 2-Confirm 3-Cancel', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_xid_branch_action (xid, branch_id, action_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; // ===== 改进后的 TCC 实现——带防悬挂 + 空回滚 ===== @Service public class OrderTccActionImpl implements OrderTccAction { @Autowired private TccOperationRecordMapper recordMapper; @Override @Transactional public boolean tryCreateOrder(Long userId, List<OrderItemDto> items, BigDecimal totalAmount) { String xid = RootContext.getXID(); Long branchId = RootContext.getBranchId(); // ① 防悬挂——检查 Cancel 是否已执行 TccOperationRecord cancelRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); // status=3 = Cancel if (cancelRecord != null) { // Cancel 先到了——Try 不能再执行——这就是"悬挂"——拒绝 return false; } // ② 记录 Try TccOperationRecord tryRecord = new TccOperationRecord(); tryRecord.setXid(xid); tryRecord.setBranchId(branchId); tryRecord.setActionName("order-create"); tryRecord.setStatus(1); // Try recordMapper.insert(tryRecord); // ③ 执行业务逻辑 Order order = new Order(); // ... 创建订单——状态 PENDING orderMapper.insert(order); RootContext.bind("orderId_" + xid, order.getId()); return true; } @Override @Transactional public boolean cancelCreateOrder(BusinessActionContext context) { String xid = context.getXid(); Long branchId = context.getBranchId(); // ① 幂等——检查 Cancel 是否已执行 TccOperationRecord existingRecord = recordMapper.selectOne( xid, branchId, "order-create", 3); if (existingRecord != null) { return true; // Cancel 已经执行过了——幂等——直接返回 } // ② 记录 Cancel——在查订单之前——防止空回滚 TccOperationRecord cancelRecord = new TccOperationRecord(); cancelRecord.setXid(xid); cancelRecord.setBranchId(branchId); cancelRecord.setActionName("order-create"); cancelRecord.setStatus(3); // Cancel recordMapper.insert(cancelRecord); // ③ 空回滚处理——查不到订单——不能报错 Long orderId = (Long) context.getActionContext().get("orderId_" + xid); if (orderId == null) { return true; // Try 没执行——空回滚——正常 } Order order = orderMapper.selectById(orderId); if (order == null) { return true; // Try 没执行完——空回滚——正常 } if (order.getStatus() == OrderStatus.CANCELLED) { return true; // 幂等 } // ④ 执行业务撤销 order.setStatus(OrderStatus.CANCELLED); orderMapper.updateById(order); return true; } } ⚠️ 新手提示:空回滚和悬挂是 TCC 的两个经典坑——90% 的 TCC 实现都有这两个问题。解决方案就是一张操作记录表——在 Cancel 执行前先记一笔"Cancel 已执行"——在 Try 执行前先查"Cancel 是否已执行"。记录表的唯一键 (xid, branch_id, action_name) 天然防并发——并发的 Try 和 Cancel 只有一个能插入成功。 ...

十二月 29, 2022 · 8 分钟 · 1671 字 · yaomingye

Seata AT 模式——undo_log 与二阶段原理

Seata AT 模式 📖 前置阅读:本文假设读者已理解分布式事务的核心问题(多数据库操作一致性)和 BASE 最终一致性概念。如果还不熟悉,建议先阅读 分布式事务本质——CAP、BASE 与四大方案。 一、⚡ Seata AT 一句话——你写你的 SQL——它自动生成反向 SQL 回想 XA 2PC 的问题——锁住数据库行等协调者——性能黑洞。Seata AT 是怎么解决的? XA 2PC 的做法(性能黑洞): ① Prepare:执行 SQL——不提交——锁住行 ② 等协调者——这期间这些行都是锁着的——其他事务不能动 ③ Commit/Rollback:提交或回滚——释放锁 Seata AT 的做法(攒反向 SQL——事后再补): ① 一阶段:执行 SQL——立即提交——释放锁——同时记录 undo_log(反向 SQL) ② 如果全局事务成功:删掉 undo_log——完事 ③ 如果全局事务失败:根据 undo_log 执行反向 SQL——把数据改回去 核心区别:XA 是锁住行等结果——Seata 是先把活干了——记下 undo_log——失败了逆向执行。 二、🏗️ Seata 架构——TC / TM / RM 三角 flowchart LR TM["TM(Transaction Manager)\n全局事务管理者\n-- 标注 @GlobalTransactional"] RM1["RM(Resource Manager)\norder-service\n-- 操作 order 数据库"] RM2["RM(Resource Manager)\nproduct-service\n-- 操作 product 数据库"] RM3["RM(Resource Manager)\naccount-service\n-- 操作 account 数据库"] TC["TC(Transaction Coordinator)\nSeata Server\n-- 协调全局事务——管理全局锁"] TM -->|"① 开启全局事务"| TC TM -->|"② 调用 order-service"| RM1 RM1 -->|"③ 一阶段:执行业务 SQL + 记录 undo_log + 向 TC 注册分支事务"| TC TM -->|"④ 调用 product-service"| RM2 RM2 -->|"⑤ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑥ 调用 account-service"| RM3 RM3 -->|"⑦ 一阶段:执行业务 SQL + 记录 undo_log + 注册分支事务"| TC TM -->|"⑧ 全局事务成功 → 通知 TC 提交"| TC TC -->|"⑨ 二阶段:通知所有 RM 删除 undo_log"| RM1 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM2 TC -->|"⑨ 通知所有 RM 删除 undo_log"| RM3 classDef style_TM fill:#431407,stroke:#ea580c,stroke-width:2px,color:#fed7aa; classDef style_TC fill:#450a0a,stroke:#dc2626,stroke-width:2px,color:#fecaca; class TM style_TM; class TC style_TC;``` | 角色 | 全称 | 作用 | 在哪里 | |------|------|------|------| | TC | Transaction Coordinator | 协调全局事务——管理全局锁——决定提交还是回滚 | Seata Server——独立部署 | | TM | Transaction Manager | 定义全局事务边界——标 `@GlobalTransactional` 的方法 | 发起方服务(order-service) | | RM | Resource Manager | 管理分支事务——执行 undo_log 记录——向 TC 注册 | 每个参与方服务(product/account) | ## 三、🔍 undo_log 的核心原理——Seata AT 的灵魂 ### 3.1 undo_log 表结构 ```sql -- 每个参与分布式事务的数据库都需要一张 undo_log 表 -- Seata 提供了建表 SQL——直接执行即可 CREATE TABLE undo_log ( id BIGINT(20) NOT NULL AUTO_INCREMENT, branch_id BIGINT(20) NOT NULL COMMENT '分支事务 ID', xid VARCHAR(100) NOT NULL COMMENT '全局事务 ID', context VARCHAR(128) NOT NULL COMMENT '上下文', rollback_info LONGBLOB NOT NULL COMMENT '回滚信息——记录前置镜像和后置镜像', log_status INT(11) NOT NULL COMMENT '状态:0-正常 1-全局事务已完成', log_created DATETIME NOT NULL, log_modified DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid, branch_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; 3.2 undo_log 的工作原理——前置镜像 + 后置镜像 以"扣库存"为例——product 服务执行:UPDATE product SET stock = stock - 5 WHERE id = 1 一阶段——执行 SQL + 记录 undo_log: ① Seata 拦截 SQL——先查一下当前数据: SELECT stock FROM product WHERE id = 1 → stock = 10 ② 执行你的业务 SQL: UPDATE product SET stock = stock - 5 WHERE id = 1 → stock = 5 (后置镜像) ③ 立即提交——不锁行——释放数据库锁 ④ 记录 undo_log: 前置镜像:stock = 10 (SQL 执行前的值) 后置镜像:stock = 5 (SQL 执行后的值) 反向 SQL:UPDATE product SET stock = 10 WHERE id = 1 ⑤ 向 TC 注册:我的分支事务完成了——xid=xxx——undo_log 已记录 二阶段——提交: 全局事务成功 → TC 通知所有 RM 提交 → 删掉 undo_log 记录 → 完事 二阶段——回滚: 全局事务失败 → TC 通知所有 RM 回滚 → 读 undo_log 中的反向 SQL → 执行: UPDATE product SET stock = 10 WHERE id = 1 然后把数据改回去了 → 删掉 undo_log 记录 关键——为什么 AT 比 XA 快: ...

十二月 28, 2022 · 8 分钟 · 1567 字 · yaomingye

分布式事务本质——CAP、BASE 与四大方案

分布式事务本质 一、⚡ @Transactional 在生产中失效——不是代码写错了——是底层就不是一回事 先看一个场景——最经典的"下单扣库存": // 单体应用——一个 @Transactional 搞定 @Service public class OrderService { @Transactional public void createOrder(CreateOrderRequest request) { // ① 创建订单 orderMapper.insert(order); // ② 扣库存 product.setStock(product.getStock() - quantity); productMapper.updateById(product); // ③ 扣余额 account.setBalance(account.getBalance().subtract(totalAmount)); accountMapper.updateById(account); // 这三个操作在同一个数据库中——同一个事务——要么全成功——要么全回滚 } } 拆成微服务后——同样的流程——@Transactional 失效: order-service ──→ 创建订单(自己的数据库) product-service ──→ 扣库存(product 数据库) account-service ──→ 扣余额(account 数据库) 每个服务有独立的数据库——三个 @Transactional 是三个独立的事务 → 订单创建成功——库存扣减成功——但扣余额失败 → 订单已创建——库存已扣——余额没变——钱还在——但东西已经扣了 → 数据不一致——用户赚了——公司亏了 分布式事务的本质问题:多个数据库(或服务)的操作——怎么保证"要么全成功、要么全回滚"? ...

十二月 27, 2022 · 4 分钟 · 706 字 · yaomingye
Cat Radio