Golang微服框架Kratos与它的小伙伴系列 - 分布式事务框架 - DTM

Golang微服框架Kratos与它的小伙伴系列 - 分布式事务框架 - DTM

在 GO 语言生态中,DTM(Distributed Transaction Manager) 是一个开源的分布式事务管理服务,专门用于解决微服务架构下分布式事务的一致性问题。它以轻量、易用、高性能为特点,支持多种分布式事务模式,是 GO 语言开发者在处理跨服务数据一致性时的常用工具。

DTM 的核心功能与特点

1. 支持多种事务模式

DTM 针对不同业务场景,实现了主流的分布式事务协议,包括:

  • TCC(Try-Confirm-Cancel) :适用于核心业务场景,通过拆分业务为 "尝试"、"确认"、"取消" 三个阶段,保证最终一致性。
  • SAGA:适用于长事务场景,将分布式事务拆分为多个本地事务步骤,每个步骤对应一个补偿操作,若某步失败则执行反向补偿。
  • 本地消息表(Local Message Table):基于消息的异步确认机制,通过本地事务与消息发送的原子性,确保跨服务操作的最终一致。
  • 事务消息:结合消息队列实现,通过 "半消息"、"确认发送"、"消费确认" 等机制,保证消息可靠投递与业务操作的一致性。
  • XA:基于数据库的 XA 协议(如 MySQL 的 XA 事务),适用于对强一致性要求高且支持 XA 协议的数据库场景。

2. 轻量易用

  • 作为 GO 语言实现的服务,DTM 部署简单,可独立运行或嵌入应用,对现有系统侵入性低。
  • 提供简洁的 API 接口,开发者无需深入理解复杂的分布式事务理论,即可快速集成(例如,通过几行代码即可定义一个 TCC 事务)。

3. 高可用性与可靠性

  • 支持集群部署,通过分布式锁和数据持久化(如存储到 MySQL、Redis 等)确保事务状态不丢失,即使服务重启也能恢复未完成的事务。
  • 内置重试机制和幂等性处理,解决网络抖动、服务临时故障等问题导致的事务不一致。

4. 跨语言与跨服务支持

虽然 DTM 本身由 GO 语言开发,但通过 HTTP/GRPC 协议,可与其他语言(如 Java、Python)的微服务交互,支持多语言异构系统的分布式事务管理。

DTM 的工作原理

DTM 的核心是事务协调器(Coordinator),它负责:

  • 记录分布式事务的全局状态(如开始、执行中、完成、失败)。
  • 协调参与事务的各个微服务(分支事务),根据事务模式触发相应的操作(如 TCC 的 Confirm/Cancel,SAGA 的补偿步骤)。
  • 当某分支事务失败时,根据预设规则执行回滚或重试,确保最终一致性。

例如,在一个 "下单 - 扣库存 - 支付" 的分布式事务中,DTM 会作为协调器,监控三个服务的本地事务执行情况:若支付成功,则确认库存扣减和订单状态;若支付失败,则触发库存回滚和订单取消。

在微服务框架Kratos下实现各种事务模式

我们要去学习分布式事务,通常有两个典型的场景,可以用于学习:

  • 银行转账
  • 电商订单

银行相关的事务实例对于初学者来说,存在一定的理解门槛,相对而言不太友好。

相比之下,电商场景的实例(如订单创建、库存扣减、支付流程等)更适合初学者:

  • 业务场景贴近生活,初学者更容易理解,比如下单时需要扣减库存、创建订单记录、处理支付等流程,这些都是人们在日常网购中熟悉的环节。
  • 业务逻辑相对清晰,核心事务逻辑(如保证订单、库存、支付数据的一致性)突出,能让初学者更专注于事务模式的实现和原理。

因此,对于初学者来说,从电商实例入手学习事务模式会更轻松高效,等对事务模式有了一定理解后,再去研究银行等复杂领域的实例会更容易上手。

因此,我们将以电商订单场景为例,介绍 DTM 在 Kratos 微服务框架下的应用。

在电商订单场景中,分布式事务的核心目标是保证跨服务操作(如 "下单→扣库存→支付→物流→积分")的一致性。不同分布式事务模式(二阶段消息、SAGA、TCC、XA、Workflow)的设计理念和特性不同,适用的子场景也存在显著差异。

准备工作

在开始之前,请确保已经安装了 DTM 服务,并且 Kratos 框架的环境已经搭建完成。

部署DTM

DTM 服务的核心端口:

端口 协议 功能描述
36789 HTTP DTM 的 HTTP API 服务端口,用于接收 HTTP 协议的事务请求和管理操作。
36790 gRPC DTM 的 gRPC API 服务端口,用于接收 gRPC 协议的事务请求,性能更高。

DTM 可以通过 Docker 容器进行部署:

shell 复制代码
docker run -itd \
  --name dtm \
  -p 36789:36789 \
  -p 36790:36790 \
  -e MICRO_SERVICE_DRIVER="dtm-driver-kratos" \
  -e MICRO_SERVICE_TARGET="etcd://127.0.0.1:2379/dtmservice" \
  -e MICRO_SERVICE_END_POINT="grpc://127.0.0.1:36790" \
  yedf/dtm:latest

如果不想使用 Docker,也可以通过二进制文件安装 DTM。

在MacOS下面可以通过brew安装:

shell 复制代码
brew install dtm

最简单的安装方式是通过 go install 命令:

shell 复制代码
go install github.com/dtm-labs/dtm@main

安装好之后,我们需要对之进行配置,配置文件 conf.yml 如下:

配置为 Etcd 服务发现:

yaml 复制代码
MicroService:
  Driver: 'dtm-driver-kratos' # name of the driver to handle register/discover
  Target: 'etcd://127.0.0.1:2379/dtm-service' # register dtm server to this url
  EndPoint: 'grpc://127.0.0.1:36790'

配置为 Consul 服务发现:

yaml 复制代码
#  dtm: conf.yml
MicroService:
  Driver: 'dtm-driver-kratos' # name of the driver to handle register/discover
  Target: 'consul://127.0.0.1:8500/dtm-service' # register dtm server to this url
  EndPoint: 'grpc://127.0.0.1:36790'

增加Redis数据源的配置:

yaml 复制代码
#  dtm: conf.yml
Store:
  # Redis
  Driver: 'redis'
  Host: 'localhost' # host1:port1,host2:port2 for cluster connection
  Port: 6379
  User: ''
  Password: '*Abcd123456'

最后,我们就可以启动 DTM 的二进制服务了:

shell 复制代码
dtm -c ./conf.yml

它还提供了一个Web Admin,我们可以通过浏览器访问 http://localhost:36789 来查看 DTM 的状态和事务信息。

初始化代码

首先,我们需要安装DTM的Go客户端库:

shell 复制代码
go get github.com/dtm-labs/client
go get github.com/dtm-labs/dtmdriver
go get github.com/dtm-labs/dtmdriver-kratos

接下来,我们需要初始化DTM的Kratos驱动:

go 复制代码
package data

import (
	"github.com/dtm-labs/dtmdriver"

	"github.com/go-kratos/kratos/v2/log"
	"github.com/go-kratos/kratos/v2/registry"

	_ "github.com/dtm-labs/dtmdriver-kratos"
	dtmdriverKratos "github.com/dtm-labs/dtmdriver-kratos"
)

// NewData .
func NewData(logger log.Logger, rr registry.Discovery) (*Data, func(), error) {
	// 激活 Kratos DTM Driver
	_ = dtmdriver.Use(dtmdriverKratos.Name)

	return d, func() {
		l.Info("message", "closing the data resources")
	}, nil
}

这个驱动主要就是提供了服务发现的功能,使用了 Kratos 的服务发现机制。

最后,我们还需要做一个初始化的工作:

go 复制代码
package data

import (
	"github.com/go-kratos/kratos/v2/log"
	"github.com/go-kratos/kratos/v2/registry"
	"github.com/go-kratos/kratos/v2/transport/grpc/resolver/discovery"

	"google.golang.org/grpc/resolver"
)

// NewData .
func NewData(logger log.Logger, rr registry.Discovery) (*Data, func(), error) {
	// 注册Kratos的gRPC解析器的用于动态解析服务地址,用于与Dtm服务通信
	resolver.Register(discovery.NewBuilder(rr, discovery.WithInsecure(true)))

	return d, func() {
		l.Info("message", "closing the data resources")
	}, nil
}

做这个工作是为了让 DTM 的 gRPC 客户端能够正确地解析服务地址,使用 Kratos 的服务发现机制。

现在,我们就可以给DTM传入类似这样的地址:discovery:///dtm-service,解析器的作用就是将这个地址解析为实际的服务地址。如果没有这个解析器,DTM 就无法正确地连接到服务,这时候,我们就只能够硬代码写死服务地址了。

1. 二阶段消息(Two-Phase Message)

二阶段消息(Two-Phase Message)是一种独立于消息队列的分布式事务架构,通过 "Prepare(预准备)→Submit(提交)" 两阶段,结合 DTM 协调器和业务数据库的 barrier 表,实现 "本地事务执行" 与 "跨服务操作触发" 的原子性。其设计不依赖 MQ,目标是替代事务消息和本地消息表,提供更轻量、自动化的一致性方案。

事务消息是"异步消息投递" 模式,而二阶段消息则是"同步消息投递" 模式,二阶段消息的核心在于:

  • Prepare 阶段:业务服务执行本地事务,并将状态记录到 DTM 的 barrier 表,表示准备就绪。
  • Submit 阶段:DTM 协调器检查所有参与方的状态,若都准备就绪,则提交事务;若有失败,则回滚。
  • 幂等性:二阶段消息的设计确保每个操作都是幂等的,即多次执行结果相同,避免重复提交导致数据不一致。

在电商场景中,二阶段消息最适合需要跨服务强一致性、希望减少中间件依赖(无 MQ)、需简化幂等与回查逻辑、支持多下游操作的场景,如订单与库存联动、支付后多服务通知、秒杀下单、退款流程等。其核心价值是用更轻量的架构(仅 DTM + 数据库)替代传统事务消息 + MQ 的方案,降低开发与运维成本。

我们这里用订单与库存联动的场景来说明二阶段消息的使用:

go 复制代码
package service

import "github.com/dtm-labs/client/dtmgrpc"

func (s *ShopService) TestTP(_ context.Context, req *shopV1.BuyRequest) (*shopV1.BuyResponse, error) {
	var requestId string

	gid := dtmgrpc.MustGenGid(dtmServer)

	requestId = gid // 使用 gid 作为 request_id

	// 创建消息事务
	msg := dtmgrpc.NewMsgGrpc(dtmServer, gid).
		Add(
			shopServer+shopV1.StockService_DeductStock_FullMethodName,
			&shopV1.DeductStockRequest{
				ProductId: req.ProductId,
				Quantity:  req.Quantity,
				RequestId: requestId,
			},
		).
		Add(
			shopServer+shopV1.OrderService_CreateOrder_FullMethodName,
			&shopV1.CreateOrderRequest{
				UserId:    req.UserId,
				ProductId: req.ProductId,
				Quantity:  req.Quantity,
				RequestId: requestId,
			},
		)

	msg.WaitResult = true

	// 提交事务
	if err := msg.Submit(); err != nil {
		s.log.Errorf("提交购买事务失败: %v", err)
		return nil, shopV1.ErrorInternalServerError(err.Error())
	}

	s.log.Infof("购买事务提交成功,GID: %s", gid)

	return &shopV1.BuyResponse{Success: true}, nil
}
  1. 建全局事务 ID:使用 dtmgrpc.MustGenGid 生成全局事务 ID,确保事务的唯一性。
  2. 添加分支任务:第一个任务是扣减库存 ,调用 StockService.DeductStock 方法;第二个任务是创建订单 ,调用 OrderService.CreateOrder 方法。
  3. 设置同步等待结果:通过 msg.WaitResult = true,确保事务提交后立即返回结果。
  4. 提交事务:调用 msg.Submit() 提交二阶段消息事务,确保所有分支任务按预期执行。

这种实现方式能够保证订单创建和库存扣减的强一致性,适用于电商场景中的跨服务操作。

该模式很简单,核心要解决的问题是业务的幂等性。Order比较简单,只需要在订单表中记录 request_id 即可。Stock比较复杂一些,因为它需要保证库存扣减的幂等性,我们使用了StockDeductionLog来保证了其幂等性:

go 复制代码
package service

func (s *StockService) DeductStock(_ context.Context, req *shopV1.DeductStockRequest) (*shopV1.StockResponse, error) {
	exist, err := s.stockDeductionLogRepo.ExistLogByRequestID(req.GetRequestId())
	if err != nil {
		s.log.Errorf("failed to check stock deduction log existence for request_id: %s, error: %v", req.GetRequestId(), err)
		return nil, shopV1.ErrorInternalServerError("failed to check stock deduction log existence: %v", err)
	}
	if exist {
		s.log.Infof("stock deduction log already exists for request_id: %s", req.GetRequestId())
		return &shopV1.StockResponse{Success: true}, nil
	}

	if err = s.stockRepo.DeductStock(req.GetProductId(), req.GetQuantity()); err != nil {
		s.log.Errorf("failed to deduct stock for product_id: %d, quantity: %d, error: %v", req.GetProductId(), req.GetQuantity(), err)
		return nil, shopV1.ErrorInternalServerError("failed to deduct stock: %v", err)
	}

	if err = s.stockDeductionLogRepo.CreateLog(&shopV1.StockDeductionLog{
		ProductId: req.GetProductId(),
		RequestId: req.GetRequestId(),
		Quantity:  req.GetQuantity(),
	}); err != nil {
		s.log.Errorf("failed to create stock deduction log for request_id: %s, error: %v", req.GetRequestId(), err)
		return nil, shopV1.ErrorInternalServerError("failed to create stock deduction log: %v", err)
	}

	return &shopV1.StockResponse{}, nil
}

2. TCC 模式(Try-Confirm-Cancel)

DTM 的 TCC(Try-Confirm-Cancel)模式是一种经典的分布式事务解决方案,适用于对一致性要求较高、业务流程复杂的场景。

它将一个完整的业务操作拆分为"预留资源(Try)→确认执行(Confirm)→取消操作(Cancel)"三个阶段,将分布式事务拆分为业务层的侵入式操作,通过补偿机制确保最终一致性。

TCC 模式由三个操作组成:

操作名 作用 特点
Try(尝试) 预留资源,检查业务条件是否满足,但不执行真正的业务操作。 需保证幂等性(多次调用结果相同),且资源预留需支持回滚(如锁定库存但不扣减)。
Confirm(确认) 执行真正的业务操作,使用 Try 阶段预留的资源完成业务。 需保证幂等性,且操作需满足最终成功的特性(若失败需依赖重试)。
Cancel(取消) 释放 Try 阶段预留的资源,若业务执行失败则回滚。 需保证幂等性,且需完全补偿 Try 阶段的操作。

打一个现实的比喻:

  • Try:顾客在超市冷冻柜取商品(资源预留)。
  • Confirm:收银台结算(正式消费)。
  • Cancel:将商品放回货架(释放预留)。

在开始前,我们需要先搞清楚一个DTM的屏障机制:Barrier 。它将会在TCCSAGA 模式当中使用到。它核心解决的问题是:* 分布式事务场景下的"重入"问题*,但更准确地说,它解决的是"分布式事务分支操作的重复执行"问题,包括重入、重试、空补偿、悬挂等多种场景,本质上是对 "操作重入" 的精细化控制。

首先,我们需要定义3个接口:

protobuf 复制代码
syntax = "proto3";

// 库存服务
service StockService {
  rpc TryDeductStock(TryDeductStockRequest) returns (StockResponse) {
    option (gnostic.openapi.v3.operation) = {
      summary: "尝试减少商品库存",
      description: "预留商品库存,TCC事务 进入 Try 阶段"
    };
  }

  rpc ConfirmDeductStock(ConfirmDeductStockRequest) returns (StockResponse) {
    option (gnostic.openapi.v3.operation) = {
      summary: "确认减少商品库存",
      description: "确认减少库存,TCC事务 进入 Confirm 阶段"
    };
  }

  rpc CancelDeductStock(CancelDeductStockRequest) returns (StockResponse) {
    option (gnostic.openapi.v3.operation) = {
      summary: "取消减少商品库存",
      description: "释放预留库存,TCC事务 进入 Cancel 阶段"
    };
  }
}

实现接口:

go 复制代码
package service

func (s *StockService) TryDeductStock(ctx context.Context, req *shopV1.TryDeductStockRequest) (*shopV1.StockResponse, error) {
	s.log.Infof("尝试扣除库存: %+v", req.RequestId)

	return s.stockRepo.TryDeductStock(ctx, req)
}

func (s *StockService) ConfirmDeductStock(ctx context.Context, req *shopV1.ConfirmDeductStockRequest) (*shopV1.StockResponse, error) {
	s.log.Infof("确认扣除库存: %+v", req.RequestId)

	return s.stockRepo.ConfirmDeductStock(ctx, req)
}

func (s *StockService) CancelDeductStock(ctx context.Context, req *shopV1.CancelDeductStockRequest) (*shopV1.StockResponse, error) {
	s.log.Infof("取消扣除库存: %+v", req.RequestId)

	return s.stockRepo.CancelDeductStock(ctx, req)
}

实现repo:

go 复制代码
package data

func (r *StockRepo) TryDeductStock(ctx context.Context, req *shopV1.TryDeductStockRequest) (*shopV1.StockResponse, error) {
	var err error

	err = dtmgorm.BarrierGorm(ctx, r.data.db, func(tx *gorm.DB) error {
		// 查询当前库存记录
		var stock models.Stock
		if err = tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			First(&stock).Error; err != nil {
			return err
		}

		// 检查库存是否足够
		if stock.Quantity-stock.Locked < req.GetQuantity() {
			return gorm.ErrRecordNotFound
		}

		// 锁定库存
		result := tx.Model(&models.Stock{}).
			Where("product_id = ? AND quantity - locked >= ?", req.GetProductId(), req.GetQuantity()).
			UpdateColumn("locked", gorm.Expr("locked + ?", req.GetQuantity()))
		if result.Error != nil {
			return result.Error
		}

		// 检查是否更新成功
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}

		return nil
	})
	if err != nil {
		return &shopV1.StockResponse{Success: false, Message: err.Error()}, nil
	}

	r.log.Infof("Attempting to deduct stock for product_id: %d, quantity: %d", req.GetProductId(), req.GetQuantity())

	return &shopV1.StockResponse{
		Success: true,
		Message: "Stock deduction initiated successfully",
	}, nil
}

func (r *StockRepo) ConfirmDeductStock(ctx context.Context, req *shopV1.ConfirmDeductStockRequest) (*shopV1.StockResponse, error) {
	var err error

	err = dtmgorm.BarrierGorm(ctx, r.data.db, func(tx *gorm.DB) error {
		// 查询当前库存记录
		var stock models.Stock
		if err = tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			First(&stock).Error; err != nil {
			return err
		}

		// 确认扣减库存,将锁定的库存正式扣减
		result := tx.Model(&models.Stock{}).
			Where("product_id = ? AND locked >= ?", req.GetProductId(), req.GetQuantity()).
			Updates(map[string]interface{}{
				"locked":   gorm.Expr("locked - ?", req.GetQuantity()),
				"quantity": gorm.Expr("quantity - ?", req.GetQuantity()),
			})
		if result.Error != nil {
			return result.Error
		}

		// 检查是否更新成功
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}

		return nil
	})
	if err != nil {
		return &shopV1.StockResponse{Success: false, Message: err.Error()}, nil
	}

	r.log.Infof("Confirming stock deduction for product_id: %d, quantity: %d", req.GetProductId(), req.GetQuantity())

	return &shopV1.StockResponse{
		Success: true,
		Message: "Stock deduction confirmed successfully",
	}, nil
}

func (r *StockRepo) CancelDeductStock(ctx context.Context, req *shopV1.CancelDeductStockRequest) (*shopV1.StockResponse, error) {
	var err error

	err = dtmgorm.BarrierGorm(ctx, r.data.db, func(tx *gorm.DB) error {
		// 查询当前库存记录
		var stock models.Stock
		if err = tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			First(&stock).Error; err != nil {
			return err
		}

		// 恢复库存
		result := tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			UpdateColumn("quantity", gorm.Expr("quantity + ?", req.GetQuantity()))
		if result.Error != nil {
			return result.Error
		}

		// 检查是否更新成功
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}

		return nil
	})
	if err != nil {
		return &shopV1.StockResponse{Success: false, Message: err.Error()}, nil
	}

	r.log.Infof("Cancelling stock deduction for product_id: %d, quantity: %d", req.GetProductId(), req.GetQuantity())

	return &shopV1.StockResponse{
		Success: true,
		Message: "Stock deduction canceled successfully",
	}, nil
}

最后,我们可以在商店服务里面调用:

go 复制代码
package service

import (
	"github.com/dtm-labs/client/dtmgrpc"

	"kratos-dtm-examples/pkg/service"
)

func (s *ShopService) TestTCC(ctx context.Context, req *shopV1.BuyRequest) (*shopV1.BuyResponse, error) {
	var requestId string

	// 生成全局唯一事务 ID (GID)
	gid := dtmgrpc.MustGenGid(service.DtmServerAddress)

	requestId = gid // 使用 gid 作为 request_id

	s.log.Infof("开始 TCC 事务,GID: %s", gid)

	var err error

	err = dtmgrpc.TccGlobalTransaction(service.DtmServerAddress, gid, func(tcc *dtmgrpc.TccGrpc) error {
		// Try 阶段:扣减库存
		err = tcc.CallBranch(
			&shopV1.TryDeductStockRequest{
				ProductId: req.ProductId,
				Quantity:  req.Quantity,
				RequestId: requestId,
			},
			service.ShopServerAddress+shopV1.StockService_TryDeductStock_FullMethodName,
			service.ShopServerAddress+shopV1.StockService_ConfirmDeductStock_FullMethodName,
			service.ShopServerAddress+shopV1.StockService_CancelDeductStock_FullMethodName,
			&shopV1.StockResponse{},
		)
		if err != nil {
			s.log.Errorf("扣减库存失败: %v", err)
			return shopV1.ErrorInternalServerError("扣减库存失败")
		}

		// Try 阶段:创建订单
		err = tcc.CallBranch(
			&shopV1.TryCreateOrderRequest{
				UserId:    req.UserId,
				ProductId: req.ProductId,
				Quantity:  req.Quantity,
				RequestId: requestId,
				OrderNo:   requestId, // 简化使用 requestId 作为订单号
			},
			service.ShopServerAddress+shopV1.OrderService_TryCreateOrder_FullMethodName,
			service.ShopServerAddress+shopV1.OrderService_ConfirmCreateOrder_FullMethodName,
			service.ShopServerAddress+shopV1.OrderService_CancelCreateOrder_FullMethodName,
			&shopV1.OrderResponse{},
		)
		if err != nil {
			s.log.Errorf("TCC创建订单失败: %v", err)
			return shopV1.ErrorInternalServerError("创建订单失败")
		}

		return nil
	})
	if err != nil {
		s.log.Errorf("TCC 事务提交失败: %v", err)
		return nil, shopV1.ErrorInternalServerError(err.Error())
	}

	s.log.Infof("TCC 事务提交成功,GID: %s", gid)

	return &shopV1.BuyResponse{Success: true}, nil
}

3. SAGA模式

SAGA 将分布式事务拆分为一系列本地事务 ,每个本地事务对应一个补偿事务(若前序事务失败,通过补偿事务回滚),最终实现 "最终一致性"。

首先,我们需要定义两个接口,一个正向,一个补偿的接口:

protobuf 复制代码
syntax = "proto3";

package shop.service.v1;

// 库存服务
service StockService {
  rpc DeductStock(DeductStockRequest) returns (StockResponse) {
    option (gnostic.openapi.v3.operation) = {
      summary: "减少商品库存"
    };
  }

  rpc RefundStock(RefundStockRequest) returns (StockResponse) {
    option (gnostic.openapi.v3.operation) = {
      summary: "退款并恢复商品库存",
      description: "SAGA事务 退款并恢复商品库存"
    };
  }
}

实现接口:

go 复制代码
package service

func (s *StockService) DeductStock(_ context.Context, req *shopV1.DeductStockRequest) (*shopV1.StockResponse, error) {
	exist, err := s.stockDeductionLogRepo.ExistLogByRequestID(req.GetRequestId())
	if err != nil {
		s.log.Errorf("failed to check stock deduction log existence for request_id: %s, error: %v", req.GetRequestId(), err)
		return nil, shopV1.ErrorInternalServerError("failed to check stock deduction log existence: %v", err)
	}
	if exist {
		s.log.Infof("stock deduction log already exists for request_id: %s", req.GetRequestId())
		return &shopV1.StockResponse{Success: true}, nil
	}

	if err = s.stockRepo.DeductStock(req.GetProductId(), req.GetQuantity()); err != nil {
		s.log.Errorf("failed to deduct stock for product_id: %d, quantity: %d, error: %v", req.GetProductId(), req.GetQuantity(), err)
		return nil, shopV1.ErrorInternalServerError("failed to deduct stock: %v", err)
	}

	if err = s.stockDeductionLogRepo.CreateLog(&shopV1.StockDeductionLog{
		ProductId: req.GetProductId(),
		//UserId:,
		RequestId: req.GetRequestId(),
		Quantity:  req.GetQuantity(),
	}); err != nil {
		s.log.Errorf("failed to create stock deduction log for request_id: %s, error: %v", req.GetRequestId(), err)
		return nil, shopV1.ErrorInternalServerError("failed to create stock deduction log: %v", err)
	}

	return &shopV1.StockResponse{
		Success: true,
		Message: "Stock deducted successfully",
	}, nil
}

func (s *StockService) RefundStock(ctx context.Context, req *shopV1.RefundStockRequest) (*shopV1.StockResponse, error) {
	s.log.Infof("RefundStock called with request: %+v", req)

	return s.stockRepo.RefundStock(ctx, req)
}

实现repo:

go 复制代码
package data

import (
	"gorm.io/gorm"
	"kratos-dtm-examples/app/shop/service/internal/data/models"
	"kratos-dtm-examples/pkg/dtmgorm"
)

func (r *StockRepo) DeductStock(productID uint32, quantity int32) error {
	return r.DeductStockWithTx(r.data.db, productID, quantity)
}

func (r *StockRepo) DeductStockWithTx(tx *gorm.DB, productID uint32, quantity int32) error {
	// 使用事务来确保数据一致性
	return tx.Transaction(func(tx *gorm.DB) error {
		// 查询当前库存
		var stock models.Stock
		if err := tx.Model(&models.Stock{}).
			Select("quantity", "locked").
			Where("product_id = ?", productID).
			First(&stock).Error; err != nil {
			return err
		}

		// 检查库存是否足够
		if stock.Quantity-stock.Locked < quantity {
			return gorm.ErrRecordNotFound
		}

		// 扣减库存
		result := tx.Model(&models.Stock{}).
			Where("product_id = ? AND quantity >= ?", productID, quantity).
			UpdateColumn("quantity", gorm.Expr("quantity - ?", quantity))

		if result.Error != nil {
			return result.Error
		}

		// 如果没有更新任何记录,说明库存不足
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}

		return nil
	})
}

func (r *StockRepo) RefundStock(ctx context.Context, req *shopV1.RefundStockRequest) (*shopV1.StockResponse, error) {
	var err error

	err = dtmgorm.BarrierGorm(ctx, r.data.db, func(tx *gorm.DB) error {
		// 查询当前库存记录
		var stock models.Stock
		if err = tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			First(&stock).Error; err != nil {
			return err
		}

		// 增加库存
		result := tx.Model(&models.Stock{}).
			Where("product_id = ?", req.GetProductId()).
			UpdateColumn("quantity", gorm.Expr("quantity + ?", req.GetQuantity()))
		if result.Error != nil {
			return result.Error
		}

		// 检查是否更新成功
		if result.RowsAffected == 0 {
			return gorm.ErrRecordNotFound
		}

		return nil
	})
	if err != nil {
		return &shopV1.StockResponse{Success: false, Message: err.Error()}, nil
	}

	r.log.Infof("Refunding stock for product_id: %d, quantity: %d", req.GetProductId(), req.GetQuantity())

	return &shopV1.StockResponse{
		Success: true,
		Message: "Stock refunded successfully",
	}, nil
}

最后,我们可以在商店服务里面调用:

go 复制代码
package service

import (
   "github.com/dtm-labs/client/dtmgrpc"
   "kratos-dtm-examples/pkg/service"
)

func (s *ShopService) TestSAGA(ctx context.Context, req *shopV1.BuyRequest) (*shopV1.BuyResponse, error) {
   var requestId string

   // 生成全局唯一事务 ID (GID)
   gid := dtmgrpc.MustGenGid(service.DtmServerAddress)

   requestId = gid // 使用 gid 作为 request_id

   s.log.Infof("开始 SAGA 事务,GID: %s", gid)

   saga := dtmgrpc.NewSagaGrpc(service.DtmServerAddress, gid).
           // 扣减库存
           Add(
              service.ShopServerAddress+shopV1.StockService_DeductStock_FullMethodName,
              service.ShopServerAddress+shopV1.StockService_RefundStock_FullMethodName,
              &shopV1.DeductStockRequest{
                 ProductId: req.ProductId,
                 Quantity:  req.Quantity,
                 RequestId: requestId,
              },
           ).
           // 创建订单
           Add(
              service.ShopServerAddress+shopV1.OrderService_CreateOrder_FullMethodName,
              service.ShopServerAddress+shopV1.OrderService_RefundOrder_FullMethodName,
              &shopV1.CreateOrderRequest{
                 UserId:    req.UserId,
                 ProductId: req.ProductId,
                 Quantity:  req.Quantity,
                 RequestId: requestId,
                 OrderNo:   requestId, // 简化使用 requestId 作为订单号
              },
           )

   if err := saga.Submit(); err != nil {
      s.log.Errorf("SAGA 事务提交失败: %v", err)
      return nil, shopV1.ErrorInternalServerError(err.Error())
   }

   s.log.Infof("SAGA 事务提交成功,GID: %s", gid)
   return &shopV1.BuyResponse{Success: true}, nil
}

4. XA 模式(两阶段提交)

XA 是基于数据库层的分布式事务协议,通过 "准备阶段(Prepare)" 和 "提交阶段(Commit)" 实现强一致性,要求所有参与方(数据库)支持 XA 协议(如 MySQL、Postgresql)。

5. Workflow 模式(工作流事务)

Workflow 模式是DTM首创推出的模式,在这个模式下,可以混合使用XASAGATCC ,也可以混合使用HTTP、gRPC、本地操作,用户可以对分布式事务里面的绝大部分内容进行定制,具备极大的灵活性。

使用Workflow模式,总共有三个步骤,在这里我们以gRPC为例来进行具体的讲解:

  1. 初始化;
  2. 注册工作流;
  3. 执行工作流。
1. 初始化
go 复制代码
package server

import (
	"github.com/dtm-labs/client/workflow"

	"kratos-dtm-examples/app/shop/service/internal/service"
)

// NewGRPCServer new a gRPC server.
func NewGRPCServer(
	cfg *conf.Bootstrap, logger log.Logger,
	stockService *service.StockService,
	orderService *service.OrderService,
	paymentService *service.PaymentService,
) *grpc.Server {
	//...

	// 注册操作需要在业务服务启动之后执行,因为当进程crash,dtm会回调业务服务器,继续未完成的任务
	workflow.InitGrpc(serviceName.DtmServerAddress, serviceName.ShopServerAddress, srv.Server)

	return srv
}

之所以需要做这样的一个初始化的工作,那是因为 DTM 的工作流模式需要在业务服务启动后进行注册,以便 DTM 能够正确地回调业务服务,继续未完成的任务。

2. 注册工作流

工作流,我们以比较简单的SAGA事务为例来进行说明:

go 复制代码
package service

const (
	WorkflowShopServiceOrderSAGA  = "test_workflow_shop_order_saga"
	WorkflowShopServiceOrderTCC   = "test_workflow_shop_order_tcc"
	WorkflowShopServiceOrderXA    = "test_workflow_shop_order_xa"
	WorkflowShopServiceOrderMixed = "test_workflow_shop_order_mixed"
)

func (s *ShopService) init() {
	var err error

	// SAGA工作流注册
	err = workflow.Register(WorkflowShopServiceOrderSAGA, func(wf *workflow.Workflow, data []byte) error {

		var codec = encoding.GetCodec("proto")

		var req shopV1.BuyRequest
		if len(data) > 0 {
			if err = codec.Unmarshal(data, &req); err != nil {
				s.log.Errorf("工作流数据反序列化失败: %v", err)
				return shopV1.ErrorInternalServerError("工作流数据反序列化失败")
			}
		}

		// 扣减库存步骤
		wf.NewBranch().OnRollback(func(bb *dtmcli.BranchBarrier) error {
			if _, err = s.stockService.RefundStock(wf.Context, &shopV1.RefundStockRequest{
				ProductId: req.ProductId,
				Quantity:  req.Quantity,
			}); err != nil {
				s.log.Errorf("工作流回滚扣减库存失败: %v", err)
				return shopV1.ErrorInternalServerError("工作流回滚扣减库存失败")
			}

			return nil
		})
		if _, err = s.stockService.DeductStock(wf.Context, &shopV1.DeductStockRequest{
			ProductId: req.ProductId,
			Quantity:  req.Quantity,
			RequestId: wf.Gid,
		}); err != nil {
			s.log.Errorf("工作流扣减库存失败: %v", err)
			return shopV1.ErrorInternalServerError("工作流扣减库存失败")
		}

		// 创建订单步骤
		wf.NewBranch().OnRollback(func(bb *dtmcli.BranchBarrier) error {
			if _, err = s.orderService.RefundOrder(wf.Context, &shopV1.RefundOrderRequest{
				OrderNo: wf.Gid,
			}); err != nil {
				s.log.Errorf("工作流回滚创建订单失败: %v", err)
				return shopV1.ErrorInternalServerError("工作流回滚创建订单失败")
			}
			return nil
		})
		if _, err = s.orderService.CreateOrder(wf.Context, &shopV1.CreateOrderRequest{
			UserId:    req.UserId,
			ProductId: req.ProductId,
			Quantity:  req.Quantity,
			RequestId: wf.Gid,
			OrderNo:   wf.Gid,
		}); err != nil {
			s.log.Errorf("工作流创建订单失败: %v", err)
			return shopV1.ErrorInternalServerError("工作流创建订单失败")
		}

		return nil
	})
	if err != nil {
		s.log.Errorf("工作流[%s] 注册失败: %v", WorkflowShopServiceOrderSAGA, err)
		return
	}
}

注册工作流的工作只需要做一次,通常在服务启动时进行。

3. 执行工作流

上面注册工作流的时候,我们使用了一个 WorkflowShopServiceOrderSAGA 的工作流名称。现在我们可以使用这个工作流名称来执行工作流了:

go 复制代码
package service

import (
	"github.com/dtm-labs/client/dtmgrpc"
	"github.com/dtm-labs/client/workflow"
)

func (s *ShopService) TestWorkFlowSAGA(ctx context.Context, req *shopV1.BuyRequest) (*shopV1.BuyResponse, error) {
	// 生成全局唯一事务 ID (GID)
	gid := dtmgrpc.MustGenGid(service.DtmServerAddress)

	s.log.Infof("开始SAGA工作流事务,GID: %s", gid)

	// 提交工作流
	if err := workflow.Execute(WorkflowShopServiceOrderSAGA, gid, dtmgimp.MustProtoMarshal(req)); err != nil {
		s.log.Errorf("SAGA工作流事务提交失败: %v", err)
		return nil, shopV1.ErrorInternalServerError("SAGA工作流事务提交失败")
	}

	s.log.Infof("SAGA工作流事务提交成功,GID: %s", gid)

	return &shopV1.BuyResponse{Success: true}, nil
}

实例代码项目

参考资料

相关推荐
序安InToo20 分钟前
第6课|注释与代码风格
后端·操作系统·嵌入式
xyy12320 分钟前
C#: Newtonsoft.Json 到 System.Text.Json 迁移避坑指南
后端
洋洋技术笔记23 分钟前
Spring Boot Web MVC配置详解
spring boot·后端
JxWang0523 分钟前
VS Code 配置 Markdown 环境
后端
navms26 分钟前
搞懂线程池,先把 Worker 机制啃明白
后端
JxWang0526 分钟前
离线数仓的优化及重构
后端
Nyarlathotep011328 分钟前
gin01:初探gin的启动
后端·go
JxWang0528 分钟前
安卓手机配置通用多屏协同及自动化脚本
后端
JxWang0529 分钟前
Windows Terminal 配置 oh-my-posh
后端
SimonKing1 小时前
OpenCode AI编程助手如何添加Skills,优化项目!
java·后端·程序员