DTM分布式事务使用总结
1、为什么需要分布式事务
一次下单可能需要完成三件事:创建订单、扣减库存、扣减账户余额。
如果这些数据都在一个数据库中,并且能够通过同一个数据库事务操作,通常先使用本地事务即可。出现错误时,数据库负责回滚本次事务中的修改。
当订单、库存和账户拆成独立服务,各自维护数据库后,订单服务中的一次 Rollback 无法撤销另一个服务已经提交的数据。此时需要设计跨服务的一致性方案。
DTM 是一个用 Go 编写的分布式事务管理器,通过事务协调、分支调用和失败重试,帮助多个服务按选定的事务模式完成业务。它支持 HTTP、gRPC 等接入方式;本文围绕 HTTP 和 Go SDK 展开。DTM 官方文档、通信协议。
本文记录学习与接入思路,示例不代表本站已经引入 DTM。示例中的服务地址、业务路由和数据库连接都需要根据实际项目配置。
2、先认识几个角色
| 角色或标识 | 含义 | 下单示例 |
|---|---|---|
| 事务发起方 | 组织本次业务流程并提交事务 | 接收下单请求的应用服务 |
| DTM Server | 持久化协调状态,调度分支和重试 | 独立部署的协调服务 |
| 事务分支 | 参与全局事务的一项业务操作 | 扣库存、扣余额 |
| 本地事务 | 每个分支内部的数据原子操作 | 同时写库存流水与更新库存 |
| GID | 一次全局事务的标识 | 本次下单流程的事务编号 |
| Branch ID | 全局事务内的分支标识 | 库存分支、账户分支的编号 |
| 业务单号 | 业务系统自身的唯一标识 | 订单号 order_id |
GID 和订单号的职责不同。GID 用于协调事务,订单号用于查询订单、业务去重和对账。建议建立两者的持久化关联,以便在调用超时后仍能定位原来的事务。
DTM 协调的是各个服务的操作流程,各个服务仍然负责自己的本地事务、库存约束、余额约束和业务状态流转。DTM 架构。
3、常见事务模式怎么选择
| 模式 | 基本做法 | 适合考虑的场景 | 主要设计成本 |
|---|---|---|---|
| Saga | 执行正向操作,失败后执行补偿 | 能接受中间状态、能够定义补偿的业务 | 编写补偿逻辑 |
| TCC | Try 预留,Confirm 确认,Cancel 释放 | 需要先冻结库存或额度的业务 | 维护资源预留及三阶段接口 |
| XA | 使用数据库的分布式事务能力 | 数据库支持相应协议、锁竞争可控的业务 | 控制锁持有时间,核对数据库支持 |
| 二阶段消息 | 协调本地事务结果和后续分支执行 | 本地成功后,后续操作需要持续向前完成 | 设计可靠回查与幂等分支 |
实际选择时,先问业务三个问题:是否需要撤销已经完成的动作,是否允许处理中状态对外可见,是否能够提前预留资源。再结合并发量和数据库条件选择模式。官方模式选择说明。
这些模式对数据可见性的处理方式不同,不能仅凭“用了分布式事务”就假设任意跨服务查询都获得同一个一致性快照。读接口与业务状态也需要配合设计。分布式事务基础理论。
4、Saga:先执行,失败后补偿
4.1、正常流程与补偿流程
下面以两个分支说明过程:
1 | 正常流程: |
每个正向操作都需要明确对应的补偿语义:库存扣减对应恢复本次扣减数量,账户扣款对应撤销本次扣款。补偿通常需要参考原业务流水,不能简单把数据恢复成某个旧快照,否则可能覆盖其他并发业务的正常修改。
Saga 的补偿是新的业务操作。 正向操作的本地事务可能早已提交,因此它与数据库撤销未提交事务的 Rollback 不同。Saga 官方说明。
4.2、超时不代表没有执行
假设账户服务已经扣款并提交,但返回响应时网络断开。协调方看到的是超时,账户服务实际已经完成了业务。
因此,分支可能收到重复请求;结果不确定的分支也需要具备相应的补偿处理能力。业务接口必须围绕“请求可能重复、结果可能暂时未知”来设计,而不能依赖一次 HTTP 请求只发生一次。
对于发短信、发邮件、调用外部支付平台等动作,需要单独分析可撤销性和对方的幂等机制。不要把它们当成能够跟本地 SQL 一起回滚的操作。
5、TCC:先预留,再确认或释放
TCC 将业务拆成三个阶段:
| 阶段 | 库存示例 | 账户示例 |
|---|---|---|
| Try | 检查可用库存并冻结本次购买数量 | 检查可用余额并冻结本次金额 |
| Confirm | 将冻结库存转为正式销售消耗 | 将冻结金额转为实际扣款 |
| Cancel | 解除本次库存冻结 | 解除本次资金冻结 |
Try 阶段应完成必要的约束检查和资源预留。Confirm、Cancel 则围绕已经预留的资源完成收尾;如果到 Confirm 才检查是否还有库存,预留就失去了意义。
例如可以将资源分为“可用”和“冻结”两部分,另外保存每笔业务的预留记录。读取可用余额、取消预留、执行确认时,都应遵循同一套状态规则,避免其他接口绕过冻结限制。
TCC 的 Confirm、Cancel,以及 Saga 的补偿操作,都需要设计为在临时故障消除后能够最终完成。出现持续业务冲突时,应修复数据或逻辑,单纯增加重试次数并不能解决问题。最终成功的设计要求。
6、二阶段消息:保证后续操作继续完成
有些业务不适合“失败后撤销”,而是希望在本地操作成功后,让后续任务可靠地执行下去。
例如本地确认订单后,异步生成一份可重建的统计记录。统计服务临时不可用时,可以稍后补做,无须取消已经确认的订单。
这里的难点是两个动作之间的空隙:
1 | 提交本地数据库事务 → 进程退出 → 没有通知下游 |
把代码写成“先提交数据库,再调用下游”无法消除这个空隙。DTM 的二阶段消息通过准备、提交和本地事务结果回查等机制协调这两个阶段;接入时应使用对应 SDK 流程,并持久化可供回查的本地结果。二阶段消息示例。
回查应依据本地事务的可靠记录判断结果,不能因为临时查询失败就判断“业务没有发生”,也不能仅凭进程内存中的一个变量判断是否已经提交。
同样可以考虑本地消息表(Outbox):把业务数据和待发送事件放进同一个本地事务,再由后台任务投递,消费者负责去重。两者都需要完整设计失败恢复路径,具体选择取决于系统已有设施与维护成本。其他事务模式。
7、子事务屏障解决什么问题
7.1、重复、空补偿和悬挂
| 问题 | 发生过程 | 未处理时的影响 |
|---|---|---|
| 重复调用 | 操作成功后响应丢失,协调方再次请求 | 重复扣款或重复恢复库存 |
| 空补偿 | 正向操作没有执行,补偿先到达 | 凭空增加库存或余额 |
| 悬挂 | 补偿已处理,延迟的正向请求随后到达 | 已取消的业务重新生效 |
DTM 的子事务屏障利用本地持久化记录和唯一约束,对同一事务分支的重复、乱序操作进行控制。接入时将业务写操作放入屏障回调,并与屏障记录使用同一个本地数据库事务。异常与子事务屏障。
屏障表应使用与所选 DTM、SDK 版本匹配的官方建表脚本,并部署在能够与对应业务数据共同提交的数据库中。不要把协调服务的事务表与业务侧的屏障表混为一谈。
7.2、屏障不替代业务幂等
用户连续点击两次下单,如果每次都创建一个新的 GID,对 DTM 来说就是两次不同的事务。
业务侧仍需使用订单号、请求幂等键或唯一索引,保证同一次业务请求映射到已有订单及事务。幂等键命中后,还要校验请求参数一致,避免同一个键被用于不同商品或金额。
屏障也不会自动约束不经过它的写入路径。库存、账户等关键数据应统一写入规则,并通过数据库约束和业务流水保留可核对的依据。
8、Go 接入 Saga 的基本写法
8.1、依赖与地址
本文使用 github.com/dtm-labs/client/dtmcli 的 HTTP 客户端接口。官方客户端还提供 dtmgrpc 和 workflow;旧文章可能使用其他仓库路径,复制代码时需要先核对项目自己的 go.mod。官方 Go 客户端。
DTM 默认 HTTP 端口为 36789,gRPC 端口为 36790。HTTP SDK 常用的协调地址形如 http://127.0.0.1:36789/api/dtmsvr。协议说明。
服务端和客户端依赖应选择兼容的版本并固定,不在文章中把某个版本号当成永久适用的“最新版”。可先参考官方快速示例,再移入自己的项目。
8.2、提交两个分支
下面是应用服务中的提交函数,展示 Saga 的组织方式。前提是 DTM Server 和库存、账户回调接口已经部署,调用方已经持久化订单号与 GID 的关联;它不包含完整的业务服务实现。
1 | package order |
金额使用整数分传递,价格应由服务端根据商品和订单计算。示例中的 AmountCents 是内部服务参数,不应直接信任浏览器传入的金额。
Add 声明正向与补偿地址,Submit 向协调服务提交事务。上述代码没有设置等待全局完成的选项,返回 nil 后应先向用户展示“处理中”,再通过业务状态查询获取结果。Saga 客户端实现。
如果提交请求超时,不能立刻生成新 GID 再扣一次款。应保留原订单和 GID,按状态查询及恢复流程处理,避免把结果未知当成确定失败。
8.3、业务分支接入屏障
分支接口收到 DTM 的回调参数后,可以使用以下封装进入本地事务。示例针对 PostgreSQL;db 是已经初始化的 *sql.DB,apply 是使用传入 tx 执行本次业务 SQL 的函数。
1 | package branch |
接口层先校验请求,再传入回调 URL 的查询参数及业务函数。数据库驱动、屏障表、业务表和 HTTP 响应映射仍需在项目中配置。屏障负责提交或回滚该本地事务,业务回调不要自行调用 tx.Commit()。屏障 SDK 实现。
回调里的业务 SQL 必须使用参数 tx。如果在回调中转而调用另一个独立连接上的 db.Exec,屏障记录和业务修改就不再属于同一次提交。
接入 GoFrame 等 ORM 时,也需要确认数据库方言与事务适配方式,确保所有写入复用屏障管理的事务连接。仅仅在外面包一层函数,内部继续使用默认数据库连接,并不能获得正确的事务边界。
9、HTTP 分支响应不能只看 JSON 字段
按 DTM 的 HTTP 分支协议,下面几类结果有不同含义:
| HTTP 状态码 | 含义 | 业务处理示例 |
|---|---|---|
200 |
操作成功 | 扣库存已提交,或重复请求已被正确处理 |
409 |
确定的业务失败 | Saga 正向扣款时确认余额不足,且未扣款 |
425 |
操作仍在处理中 | 已受理的外部流程尚无最终结果 |
其他错误,如 500 |
临时故障 | 数据库暂时不可用,需要重试 |
因此不能让全局响应中间件把所有分支结果都包装成 HTTP 200,再仅在 JSON 中写 code: 500。这可能让协调方误判业务已经成功。具体映射需与所使用的服务端版本保持一致。DTM 分支结果协议。
同样不要把数据库超时、连接失败都映射成 409。这些情况不一定是确定的业务拒绝,应保留重试或核对结果的空间。
补偿、TCC Confirm/Cancel、二阶段消息的后续分支属于需要最终完成的操作。它们遇到临时故障时应返回可重试结果;若存在永久无法完成的业务条件,需要重新设计流程,不能简单用 409 结束处理。
10、事务状态如何展示给用户
推荐在业务表中维护自己的处理状态,例如订单的 processing、completed、cancelled,并保留 order_id、GID 和必要的失败原因。这些名称是业务设计示例,不是 DTM 内部状态枚举。
客户端提交请求后可以获得订单号,展示“正在处理”,再查询业务状态。页面刷新后仍然可以通过订单号恢复展示,不依赖某次 HTTP 连接一直存在。
业务的最终成功状态必须在核对全局事务结果及业务数据后更新。不要因为库存分支完成就提前标记整笔订单成功,也不要仅凭浏览器超时就把订单取消。
查询或对账读取到过渡状态时,使用状态条件更新防止覆盖较新的终态。对于长时间未完成的事务,应提供可追踪的记录和人工处理入口。
11、部署与故障恢复要点
11.1、区分两类数据库
协调服务的存储用于保存全局事务及调度状态;业务数据库保存订单、库存、账户和相应屏障记录。两者承担的职责不同,不能只创建协调服务的表,就认为业务分支已经获得幂等保障。
实际部署应按所选版本的官方配置初始化存储、设置持久化和备份,并验证协调服务重启后的恢复流程。DTM 服务端仓库。
11.2、回调地址必须从 DTM 所在网络可达
提交给 DTM 的分支地址由协调服务访问。如果 DTM 运行在容器内,127.0.0.1 通常指向该容器自身;开发机上能访问的地址,不一定是容器或另一台服务器能访问的地址。
建议把协调地址与各业务回调地址分别配置。生产环境还应限制回调接口的访问来源,配置服务间认证,避免普通公网请求直接触发扣款或退款分支。
11.3、重试需要配合监控
网络短暂中断可以靠重试恢复,但代码错误、错误的补偿金额或业务数据损坏不会因为重试就自动消失。
可以重点监控全局事务处理时长、分支重试次数、持续失败的补偿操作,以及业务账目是否一致。告警阈值按实际业务时效设置,日志中保留订单号、GID、Branch ID 和操作类型,便于跨服务追踪。
恢复或人工修复前先确认原事务状态。不要直接删除屏障记录来“放开重试”,否则曾经成功的操作可能再次修改数据。
12、接入时容易踩的坑
| 现象 | 常见原因 | 改进方向 |
|---|---|---|
| 同一订单重复扣款 | 每次重试都创建新的事务 | 持久化业务幂等键、订单号与 GID 的关联 |
| 补偿多次增加库存 | 补偿接口缺少重复与空补偿控制 | 正向、补偿都接入一致的屏障机制 |
| 屏障记录成功但业务没完成 | 业务写入使用了独立事务 | 统一使用屏障回调中的事务连接 |
| 服务报告成功,协调结果异常 | 全局中间件覆盖了 HTTP 状态 | 为 DTM 分支单独配置协议响应 |
| 一直重试但无法结束 | 临时错误和永久业务冲突未区分 | 修复业务条件,完善告警与对账 |
| 容器内回调失败 | 分支 URL 使用了错误的本机地址 | 从协调服务所在网络核对连通性 |
| 通知、外部扣款重复执行 | 将外部调用当成本地 SQL 的一部分 | 使用外部幂等键、可靠事件及结果查询 |
这些问题需要结合数据库约束、业务状态机和恢复流程一起处理。DTM 能承担协调和重试,正确的资源变更与补偿语义仍由业务代码保证。
13、在自己的项目中如何逐步使用
学习时可以先构造两个独立业务服务,实现 Saga 正向操作和补偿,再接入屏障,逐步观察重复调用、明确失败、响应丢失和服务重启后的行为。
进入实际项目之前,应专门验证提交超时后使用原 GID 恢复、补偿先于正向到达、同一分支并发重复请求,以及业务事务提交后进程退出等场景。本文代码用于说明接入结构,未进行服务部署或端到端运行验证。
对于当前博客,评论与留言主要是单库创建和分页查询,优先使用本地事务即可。数据库写入后让缓存失效的逻辑,也应先按访问规模完善重试或可靠事件机制;等出现明确的跨服务一致性需求,再评估引入 DTM 的收益和维护成本。



文章评论
分享你的观点,也欢迎提出问题。
写下你的想法
所有评论
正在加载评论…