go中使用TCC示例

在 Go 微服务中实现 TCC,通常不会从零开始写所有逻辑,而是使用成熟的分布式事务框架。

目前 Go 社区最流行、功能最完善的 TCC 框架是 dtm (Distributed Transaction Manager)。它支持 Go、Python、Java 等多种语言,并且非常轻量。

下面我将基于 dtm 框架,为你演示如何在 Go 微服务中实现一个经典的 跨服务转账 场景(用户 A 扣款,用户 B 加款)。


🛠️ 核心概念与架构

在 Go 中使用 TCC,主要涉及三个角色:

  1. dtm 服务端 (Server):负责协调事务状态(Try/Confirm/Cancel)。
  2. 业务微服务 (Microservice):实现具体的 Try/Confirm/Cancel 逻辑。
  3. 客户端 (Client):发起分布式事务。

🚀 第一步:环境准备

假设你已经部署了 dtm 服务端(通常运行在 localhost:36789)。
你需要安装 Go 客户端 SDK:

go get github.com/dtm-labs/dtmcli

💻 第二步:实现业务微服务 (TCC 逻辑)

我们需要编写一个 HTTP 服务,暴露出 TCC 所需的三个接口:TryConfirmCancel

场景:账户服务,处理扣款。

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 是否已经执行过。
    • 如果已执行,直接返回成功;如果未执行,执行逻辑并记录状态。

空回滚

  • 问题Try 请求因为网络原因没到达服务,但 dtm 超时了,直接调用了 Cancel。此时 Cancel 收到请求,但数据库里没有 Try 的记录。
  • 解决Cancel 逻辑需要判断:如果没有 Try 的记录,也要记录一条“已回滚”的日志,防止后续 Try 请求迟到后无法处理。

业务悬挂

  • 问题Cancel 先于 Try 到达(同上),执行了空回滚。然后 Try 请求迟到了。
  • 解决Try 执行时,必须检查是否存在“已回滚”的记录。如果存在,则拒绝执行 Try,直接返回失败。

📌 总结

在 Go 微服务中使用 TCC 的最佳实践:

  1. 引入 dtm:不要自己写协调器,使用 dtm 作为事务管理器。
  2. 业务改造:将业务逻辑拆分为 Try(检查/预留)、Confirm(执行)、Cancel(释放)三个 HTTP 接口。
  3. 使用屏障:利用 dtmcli 提供的屏障功能(dtmcli.Barrier)来处理数据库层面的幂等和空回滚,避免手写复杂的 SQL 逻辑。
  4. 数据库设计:业务表中通常需要增加状态字段(如 status: frozen/confirmed)来支持 Try 阶段的资源预留。

TCC的Confirm和Cancel如何实现幂等性

在 TCC 模式中,Confirm 和 Cancel 操作的幂等性至关重要。由于网络超时、重试机制或消息重复投递,这两个阶段的方法极有可能被重复调用。如果没有幂等性保障,就会导致资金重复扣减、库存重复释放等严重的数据不一致问题。

实现幂等性的核心思想是:确保同一个事务分支的同一阶段操作,无论被调用多少次,最终的业务状态和结果都是一致的

以下是两种主流的实现方案:

📝 方案一:基于数据库状态机(推荐)

这是最可靠、最常用的方案。它通过持久化的状态记录来控制操作的执行,能有效应对各种并发和异常场景。

核心步骤:

  1. 创建事务日志表:创建一张表(如 tcc_transaction_log),用于记录每个全局事务(XID)在每个阶段(Try/Confirm/Cancel)的执行状态。
  2. 利用唯一索引:在表中为 (xid, stage) 字段创建唯一索引。这是防止并发重复执行的关键。
  3. 执行前检查与状态更新:在执行 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 操作,从而实现幂等。

核心步骤:

  1. 获取锁:在执行操作前,使用全局事务ID(XID)和阶段(如 confirm:{xid})作为 Key,尝试从 Redis 获取一个分布式锁。
  2. 执行业务:如果成功获取锁,则执行业务逻辑。
  3. 释放锁:业务执行完成后,释放锁。

潜在问题:

  • 锁过期:如果业务执行时间过长,超过了锁的过期时间,可能导致锁被提前释放,另一个重试的请求会再次执行,破坏幂等性。
  • 状态丢失:单纯的锁只能防止并发,无法记录最终状态。服务重启后,锁信息丢失,无法判断之前是否执行过。

因此,通常不推荐单独使用分布式锁来实现幂等,它更适合作为数据库方案的补充,用于在高并发下减少数据库压力。

📌 方案对比与总结

方案 优点 缺点 适用场景
数据库状态机 可靠性高,状态持久化,能处理各种异常 对数据库有一定压力 生产环境首选,对数据一致性要求高的核心业务
分布式锁 性能好,响应快 实现复杂,存在锁过期风险,状态不持久 可作为数据库方案的缓存层,或用于非核心业务

总结

实现 TCC 幂等性的关键在于“先检查,后执行”。通过持久化的方式(如数据库唯一索引)来记录操作的执行状态,是保证最终一致性的最可靠手段。在实际开发中,像 dtm 这样的成熟框架已经内置了“子事务屏障”机制,自动帮你处理了幂等、空回滚和悬挂等问题,你只需要关注业务逻辑本身即可。

posted @ 2026-04-21 11:27  干炸小黄鱼  阅读(8)  评论(0)    收藏  举报