go中使用TCC示例
在 Go 微服务中实现 TCC,通常不会从零开始写所有逻辑,而是使用成熟的分布式事务框架。
目前 Go 社区最流行、功能最完善的 TCC 框架是 dtm (Distributed Transaction Manager)。它支持 Go、Python、Java 等多种语言,并且非常轻量。
下面我将基于 dtm 框架,为你演示如何在 Go 微服务中实现一个经典的 跨服务转账 场景(用户 A 扣款,用户 B 加款)。
🛠️ 核心概念与架构
在 Go 中使用 TCC,主要涉及三个角色:
- dtm 服务端 (Server):负责协调事务状态(Try/Confirm/Cancel)。
- 业务微服务 (Microservice):实现具体的 Try/Confirm/Cancel 逻辑。
- 客户端 (Client):发起分布式事务。
🚀 第一步:环境准备
假设你已经部署了 dtm 服务端(通常运行在 localhost:36789)。
你需要安装 Go 客户端 SDK:
go get github.com/dtm-labs/dtmcli
💻 第二步:实现业务微服务 (TCC 逻辑)
我们需要编写一个 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")
}
📡 第三步:发起分布式事务 (Client 端)
在另一个服务(比如订单服务或 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被调用了两次。 - 解决:在数据库中记录事务状态。
- 在执行 Confirm 前,检查
gid + branch_id是否已经执行过。 - 如果已执行,直接返回成功;如果未执行,执行逻辑并记录状态。
- 在执行 Confirm 前,检查
空回滚
- 问题:
Try请求因为网络原因没到达服务,但dtm超时了,直接调用了Cancel。此时Cancel收到请求,但数据库里没有 Try 的记录。 - 解决:
Cancel逻辑需要判断:如果没有 Try 的记录,也要记录一条“已回滚”的日志,防止后续 Try 请求迟到后无法处理。
业务悬挂
- 问题:
Cancel先于Try到达(同上),执行了空回滚。然后Try请求迟到了。 - 解决:
Try执行时,必须检查是否存在“已回滚”的记录。如果存在,则拒绝执行 Try,直接返回失败。
📌 总结
在 Go 微服务中使用 TCC 的最佳实践:
- 引入
dtm:不要自己写协调器,使用dtm作为事务管理器。 - 业务改造:将业务逻辑拆分为 Try(检查/预留)、Confirm(执行)、Cancel(释放)三个 HTTP 接口。
- 使用屏障:利用
dtmcli提供的屏障功能(dtmcli.Barrier)来处理数据库层面的幂等和空回滚,避免手写复杂的 SQL 逻辑。 - 数据库设计:业务表中通常需要增加状态字段(如
status: frozen/confirmed)来支持 Try 阶段的资源预留。
TCC的Confirm和Cancel如何实现幂等性
在 TCC 模式中,Confirm 和 Cancel 操作的幂等性至关重要。由于网络超时、重试机制或消息重复投递,这两个阶段的方法极有可能被重复调用。如果没有幂等性保障,就会导致资金重复扣减、库存重复释放等严重的数据不一致问题。
实现幂等性的核心思想是:确保同一个事务分支的同一阶段操作,无论被调用多少次,最终的业务状态和结果都是一致的。
以下是两种主流的实现方案:
📝 方案一:基于数据库状态机(推荐)
这是最可靠、最常用的方案。它通过持久化的状态记录来控制操作的执行,能有效应对各种并发和异常场景。
核心步骤:
- 创建事务日志表:创建一张表(如
tcc_transaction_log),用于记录每个全局事务(XID)在每个阶段(Try/Confirm/Cancel)的执行状态。 - 利用唯一索引:在表中为
(xid, stage)字段创建唯一索引。这是防止并发重复执行的关键。 - 执行前检查与状态更新:在执行 Confirm 或 Cancel 的具体业务逻辑前,先查询日志表。
- 如果发现当前阶段(如
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" 即可。
⚡ 方案二:基于分布式锁(如 Redis)
这种方案通过分布式锁来保证同一时刻只有一个请求能执行 Confirm 或 Cancel 操作,从而实现幂等。
核心步骤:
- 获取锁:在执行操作前,使用全局事务ID(XID)和阶段(如
confirm:{xid})作为 Key,尝试从 Redis 获取一个分布式锁。 - 执行业务:如果成功获取锁,则执行业务逻辑。
- 释放锁:业务执行完成后,释放锁。
潜在问题:
- 锁过期:如果业务执行时间过长,超过了锁的过期时间,可能导致锁被提前释放,另一个重试的请求会再次执行,破坏幂等性。
- 状态丢失:单纯的锁只能防止并发,无法记录最终状态。服务重启后,锁信息丢失,无法判断之前是否执行过。
因此,通常不推荐单独使用分布式锁来实现幂等,它更适合作为数据库方案的补充,用于在高并发下减少数据库压力。
📌 方案对比与总结
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 数据库状态机 | 可靠性高,状态持久化,能处理各种异常 | 对数据库有一定压力 | 生产环境首选,对数据一致性要求高的核心业务 |
| 分布式锁 | 性能好,响应快 | 实现复杂,存在锁过期风险,状态不持久 | 可作为数据库方案的缓存层,或用于非核心业务 |
总结
实现 TCC 幂等性的关键在于“先检查,后执行”。通过持久化的方式(如数据库唯一索引)来记录操作的执行状态,是保证最终一致性的最可靠手段。在实际开发中,像 dtm 这样的成熟框架已经内置了“子事务屏障”机制,自动帮你处理了幂等、空回滚和悬挂等问题,你只需要关注业务逻辑本身即可。

浙公网安备 33010602011771号