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
2
3
4
5
6
正常流程:
提交 Saga → 扣减库存成功 → 扣减余额成功 → 全局事务成功

业务失败:
提交 Saga → 扣减库存成功 → 扣减余额明确失败
→ 协调补偿 → 恢复库存 → 全局事务回滚完成

每个正向操作都需要明确对应的补偿语义:库存扣减对应恢复本次扣减数量,账户扣款对应撤销本次扣款。补偿通常需要参考原业务流水,不能简单把数据恢复成某个旧快照,否则可能覆盖其他并发业务的正常修改。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package order

import (
"fmt"
"strings"

"github.com/dtm-labs/client/dtmcli"
)

type SagaConfig struct {
DTMServer string
InventoryBase string
AccountBase string
}

type OrderPayload struct {
OrderID string `json:"order_id"`
UserID int64 `json:"user_id"`
ProductID int64 `json:"product_id"`
Quantity int64 `json:"quantity"`
AmountCents int64 `json:"amount_cents"`
}

func SubmitOrder(cfg SagaConfig, gid string, req OrderPayload) error {
if gid == "" || req.OrderID == "" || req.UserID <= 0 ||
req.ProductID <= 0 || req.Quantity <= 0 || req.AmountCents <= 0 {
return fmt.Errorf("事务标识或订单参数不合法")
}
if cfg.DTMServer == "" || cfg.InventoryBase == "" || cfg.AccountBase == "" {
return fmt.Errorf("DTM 或业务回调地址未配置")
}

inventory := strings.TrimRight(cfg.InventoryBase, "/")
account := strings.TrimRight(cfg.AccountBase, "/")
saga := dtmcli.NewSaga(cfg.DTMServer, gid)
saga.Add(inventory+"/deduct", inventory+"/restore", req)
saga.Add(account+"/debit", account+"/refund", req)
if err := saga.Submit(); err != nil {
return fmt.Errorf("提交订单事务未获得成功响应,请核对原 GID %s: %w", gid, err)
}
return nil
}

金额使用整数分传递,价格应由服务端根据商品和订单计算。示例中的 AmountCents 是内部服务参数,不应直接信任浏览器传入的金额。

Add 声明正向与补偿地址,Submit 向协调服务提交事务。上述代码没有设置等待全局完成的选项,返回 nil 后应先向用户展示“处理中”,再通过业务状态查询获取结果。Saga 客户端实现。

如果提交请求超时,不能立刻生成新 GID 再扣一次款。应保留原订单和 GID,按状态查询及恢复流程处理,避免把结果未知当成确定失败。

8.3、业务分支接入屏障

分支接口收到 DTM 的回调参数后,可以使用以下封装进入本地事务。示例针对 PostgreSQL;db 是已经初始化的 *sql.DB,apply 是使用传入 tx 执行本次业务 SQL 的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package branch

import (
"database/sql"
"net/url"

"github.com/dtm-labs/client/dtmcli"
)

func ApplyPostgresBranch(
db *sql.DB,
query url.Values,
apply func(tx *sql.Tx) error,
) error {
barrier, err := dtmcli.BarrierFromQuery(query)
if err != nil {
return err
}
barrier.DBType = "postgres"
return barrier.CallWithDB(db, apply)
}

接口层先校验请求,再传入回调 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 的收益和维护成本。