
























在 Go 微服务中实现 TCC,通常不会从零开始写所有逻辑,而是使用成熟的分布式事务框架。
目前 Go 社区最流行、功能最完善的 TCC 框架是 dtm (Distributed Transaction Manager)。它支持 Go、Python、Java 等多种语言,并且非常轻量。
下面我将基于 dtm 框架,为你演示如何在 Go 微服务中实现一个经典的 跨服务转账 场景(用户 A 扣款,用户 B 加款)。
在 Go 中使用 TCC,主要涉及三个角色:
假设你已经部署了 dtm 服务端(通常运行在 localhost:36789)。
你需要安装 Go 客户端 SDK:
go get github.com/dtm-labs/dtmcli
我们需要编写一个 HTTP 服务,暴露出 TCC 所需的三个接口:Try、Confirm、Cancel。
场景:账户服务,处理扣款。
package main
import (
"fmt"
"log"
"net/http"
"github.com/dtm-labs/dtmcli"
"github.com/gin-gonic/gin"
)
// 模拟数据库操作
func updateAccountBalance(uid string, amount int) error {
// 这里应该连接真实的数据库 (MySQL/PostgreSQL)
// 1. 开启本地事务
// 2. 执行 SQL: UPDATE accounts SET balance = balance + ? WHERE uid = ?
// 3. 提交事务
fmt.Printf("数据库操作: 用户 %s 余额变动 %d\n", uid, amount)
return nil
}
// 1. Try 阶段:尝试扣款(冻结资源)
func tccTry(c *gin.Context) {
var req struct {
UID string `json:"uid"`
Amount int `json:"amount"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 业务逻辑:检查余额是否充足,如果充足则冻结金额
// 注意:这里通常需要记录一个“冻结”状态,或者使用数据库的乐观锁
fmt.Printf("Try: 准备冻结用户 %s 的金额 %d\n", req.UID, req.Amount)
// 如果检查失败(例如余额不足),返回错误,dtm 会收到失败并触发 Cancel
if req.Amount > 10000 { // 模拟余额不足
c.JSON(400, gin.H{"message": "余额不足"})
return
}
// 成功则返回 200
c.JSON(200, gin.H{"message": "Try success"})
}
// 2. Confirm 阶段:确认扣款(正式执行)
func tccConfirm(c *gin.Context) {
var req struct {
UID string `json:"uid"`
Amount int `json:"amount"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 业务逻辑:将 Try 阶段冻结的金额正式扣除
fmt.Printf("Confirm: 正式扣除用户 %s 的金额 %d\n", req.UID, req.Amount)
// 必须实现幂等性:如果之前已经扣过了,直接返回成功
c.JSON(200, gin.H{"message": "Confirm success"})
}
// 3. Cancel 阶段:取消操作(回滚/解冻)
func tccCancel(c *gin.Context) {
var req struct {
UID string `json:"uid"`
Amount int `json:"amount"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// 业务逻辑:解冻 Try 阶段冻结的金额
fmt.Printf("Cancel: 解冻用户 %s 的金额 %d\n", req.UID, req.Amount)
// 必须实现幂等性
c.JSON(200, gin.H{"message": "Cancel success"})
}
func main() {
r := gin.Default()
// 注册 TCC 接口
// 实际生产中,这些接口通常通过 dtm 的屏障或者专门的路由暴露
r.POST("/api/account/try", tccTry)
r.POST("/api/account/confirm", tccConfirm)
r.POST("/api/account/cancel", tccCancel)
log.Println("Account Service starting on :8080")
r.Run(":8080")
}
在另一个服务(比如订单服务或 API 网关)中,调用 dtm 来管理整个流程。
package main
import (
"errors"
"fmt"
"github.com/dtm-labs/dtmcli"
)
const (
// dtm 服务器地址
DtmServer = "http://localhost:36789/dtmsvr"
// 业务微服务地址
AccountService = "http://localhost:8080/api/account"
)
func main() {
// 1. 创建一个 TCC 事务对象
// gid 是全局事务ID,必须全局唯一,通常使用 UUID
gid := "your-unique-gid-12345"
tcc, err := dtmcli.NewTccGid(DtmServer, gid)
if err != nil {
fmt.Println("创建事务失败:", err)
return
}
// 2. 定义 Try/Confirm/Cancel 的请求数据
reqData := map[string]interface{}{
"uid": "user_A",
"amount": 100,
}
// 3. 添加 TCC 分支
// 这里告诉 dtm:如果有子事务,请依次调用这些 URL
err = tcc.AddBranches(
// 第一个分支:用户 A 扣款
[]string{
AccountService + "/try", // Try URL
AccountService + "/confirm", // Confirm URL
AccountService + "/cancel", // Cancel URL
},
reqData,
)
// 可以添加更多分支,例如用户 B 加款
// tcc.AddBranches(...)
if err != nil {
fmt.Println("添加分支失败:", err)
return
}
// 4. 提交事务
// 这一步是同步阻塞的(也可以异步),dtm 会负责协调 Try -> Confirm/Cancel
err = tcc.DoAndSubmit()
if err != nil {
// 事务失败(Try 阶段有报错,或者网络超时等)
fmt.Println("分布式事务执行失败:", err)
} else {
// 事务成功(所有 Try 成功,且 Confirm 成功)
fmt.Println("分布式事务执行成功!")
}
}
在 Go 中手写 TCC 时,必须处理以下三个“坑”,dtm 框架内部通过 子事务屏障 很好地解决了这些问题,但你需要理解原理:
Confirm 被调用了两次。gid + branch_id 是否已经执行过。Try 请求因为网络原因没到达服务,但 dtm 超时了,直接调用了 Cancel。此时 Cancel 收到请求,但数据库里没有 Try 的记录。Cancel 逻辑需要判断:如果没有 Try 的记录,也要记录一条“已回滚”的日志,防止后续 Try 请求迟到后无法处理。Cancel 先于 Try 到达(同上),执行了空回滚。然后 Try 请求迟到了。Try 执行时,必须检查是否存在“已回滚”的记录。如果存在,则拒绝执行 Try,直接返回失败。在 Go 微服务中使用 TCC 的最佳实践:
dtm:不要自己写协调器,使用 dtm 作为事务管理器。dtmcli 提供的屏障功能(dtmcli.Barrier)来处理数据库层面的幂等和空回滚,避免手写复杂的 SQL 逻辑。status: frozen/confirmed)来支持 Try 阶段的资源预留。在 TCC 模式中,Confirm 和 Cancel 操作的幂等性至关重要。由于网络超时、重试机制或消息重复投递,这两个阶段的方法极有可能被重复调用。如果没有幂等性保障,就会导致资金重复扣减、库存重复释放等严重的数据不一致问题。
实现幂等性的核心思想是:确保同一个事务分支的同一阶段操作,无论被调用多少次,最终的业务状态和结果都是一致的。
以下是两种主流的实现方案:
这是最可靠、最常用的方案。它通过持久化的状态记录来控制操作的执行,能有效应对各种并发和异常场景。
核心步骤:
tcc_transaction_log),用于记录每个全局事务(XID)在每个阶段(Try/Confirm/Cancel)的执行状态。(xid, stage) 字段创建唯一索引。这是防止并发重复执行的关键。CONFIRM)的记录已存在且状态为成功,则直接返回成功,不再执行业务逻辑。代码示例 (Go + GORM):
// TccTxLog 对应数据库表 tcc_transaction_log
type TccTxLog struct {
ID uint `gorm:"primaryKey"`
Xid string `gorm:"uniqueIndex:idx_xid_stage;not null"` // 全局事务ID
Stage string `gorm:"uniqueIndex:idx_xid_stage;not null"` // 阶段: TRY, CONFIRM, CANCEL
}
// Confirm 阶段实现幂等性
func (s *StockService) Confirm(xid string) error {
// 1. 开启一个数据库事务
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 2. 检查是否已执行过 Confirm
var log TccTxLog
err := tx.Where("xid = ? AND stage = ?", xid, "CONFIRM").First(&log).Error
if err == nil {
// 记录存在,说明 Confirm 已执行,直接提交事务并返回成功
tx.Commit()
return nil
}
// 3. 记录不存在,执行真正的业务逻辑 (例如:扣减冻结库存)
// ... 执行具体的数据库更新操作 ...
// err = tx.Exec("UPDATE stock SET ... WHERE ...").Error
// if err != nil { tx.Rollback(); return err }
// 4. 业务逻辑成功后,插入一条 Confirm 执行记录
log = TccTxLog{Xid: xid, Stage: "CONFIRM"}
err = tx.Create(&log).Error
if err != nil {
tx.Rollback()
return err // 插入失败,可能是唯一索引冲突,说明有其他请求抢先执行了
}
// 5. 提交事务
return tx.Commit().Error
}
Cancel 阶段的实现逻辑与 Confirm 完全相同,只需将 stage 参数改为 "CANCEL" 即可。
这种方案通过分布式锁来保证同一时刻只有一个请求能执行 Confirm 或 Cancel 操作,从而实现幂等。
核心步骤:
confirm:{xid})作为 Key,尝试从 Redis 获取一个分布式锁。潜在问题:
因此,通常不推荐单独使用分布式锁来实现幂等,它更适合作为数据库方案的补充,用于在高并发下减少数据库压力。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库状态机 | 可靠性高,状态持久化,能处理各种异常 | 对数据库有一定压力 | 生产环境首选,对数据一致性要求高的核心业务 |
| 分布式锁 | 性能好,响应快 | 实现复杂,存在锁过期风险,状态不持久 | 可作为数据库方案的缓存层,或用于非核心业务 |
总结
实现 TCC 幂等性的关键在于“先检查,后执行”。通过持久化的方式(如数据库唯一索引)来记录操作的执行状态,是保证最终一致性的最可靠手段。在实际开发中,像 dtm 这样的成熟框架已经内置了“子事务屏障”机制,自动帮你处理了幂等、空回滚和悬挂等问题,你只需要关注业务逻辑本身即可。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。