背景
- 使用Golang和GRPC实现TCC分布式事务(上)
- 这是整个项目的代码 tcc-toy,如果你觉得这个项目对你有帮助,欢迎给项目点个star,你的支持是我前进的动力!👏
在上篇文章中,介绍了TCC分布式事务的基本流程,并实现了RM(resource manager)的基本结构,同时利用redis进行子事务屏蔽。
接下来,我们将进一步深入,继续实现APP(Application)
和TM(Transaction Manager)
。
前言
让我们再次回顾一下我们的项目tcc-toy的结构图
因为采用了Brokerless的网络模型,接下来的APP和TM将被整合为一个单一的实体进行实现,以便于简化我们的实现过程。
实现
首先是我们整个框架的时序图:
定义用户的基本操作方法:
go
transaction_manager.TCCCall(func(tx *transaction_manager.Transaction) transaction_manager.Operation {
_, err := tx.CallTry("localhost:9210", &pb.TryRequest{Param: "tx1"})
if err != nil {
log.Printf("tx1 try failed: %v", err)
return tx.Cancel()
}
_, err = tx.CallTry("localhost:9211", &pb.TryRequest{Param: "tx2"})
if err != nil {
log.Printf("tx2 try failed: %v", err)
return tx.Cancel()
}
log.Printf("tx1 and tx2 try success")
return tx.Commit()
})
在这里,用户通过调用TCCCall
函数来启动事务,并传入一个函数变量txFunc
。在txFunc
函数中,用户会调用Transaction
的CallTry
方法。当CallTry
被调用时,Transaction
会自动记录下参与事务的RM,这些信息将在后续的Commit或Cancel操作中使用。
在函数的最后,用户需要根据CallTry
的结果以及自身的业务逻辑,决定是执行Commit操作还是Cancel操作。
实现Transaction
结构体和TCCCall
函数:
go
type Transaction struct {
xid string # 事务ID
ctx context.Context
resources []pb.ResourceManagerClient # 连接RM的GRPC客户端
}
func TCCCall(txFunc func(*Transaction) Operation) (err error) {
t := &Transaction{
xid: uuid.New().String(),
ctx: context.Background(),
resources: make([]pb.ResourceManagerClient, 0),
}
o := txFunc(t)
if o == commit {
t.commit()
} else if o == cancel {
t.cancel()
} else {
t.cancel()
err = fmt.Errorf("unknown operation %v", o)
}
return
}
TCCCall函数的工作流程如下:首先,它会初始化一个Transaction
实例并生成一个事务ID。接着,它会调用用户传入的txFunc
函数。根据txFunc
的返回值,TCCCall函数将决定是执行回滚还是提交操作。
我们可以看到,txFunc
的返回值是一个我们自定义的类型Operation
,它有两个可能的值:
go
type Operation int
const (
commit Operation = iota
cancel
)
func (t *Transaction) Commit() Operation {
return commit
}
func (t *Transaction) Cancel() Operation {
return cancel
}
这样使用的时候,只需要return tx.Commit()
或者return tx.Cancel()
,尽可能地减少返回错误的概率,也更加的直观。
接下来,我们来开发调用Try接口的实现方法:
go
func (t *Transaction) CallTry(host string, req *pb.TryRequest) (*pb.TryReply, error) {
conn, err := grpc.Dial(host, grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
return nil, err
}
resourceClient := pb.NewResourceManagerClient(conn)
t.resources = append(t.resources, resourceClient)
req.Xid = t.xid
return resourceClient.Try(t.ctx, req)
}
这个函数功能相对比较简单,首先根据用户提供的host参数创建一个grpc客户端,然后将这个客户端记录到Transaction实例中,最后调用Try接口。
然后是commit方法:
go
func (t *Transaction) commit() {
wg := sync.WaitGroup{}
retryResources := make([]pb.ResourceManagerClient, 0)
resources := t.resources
for {
rwLock := sync.RWMutex{}
for i := range resources { // 遍历所有的RM,调用Commit方法
r := t.resources[i]
wg.Add(1)
go func(r pb.ResourceManagerClient) {
defer wg.Done()
_, err := r.Commit(t.ctx, &pb.CommitRequest{Xid: t.xid})
rwLock.Lock()
if err != nil {
retryResources = append(retryResources, r)
}
rwLock.Unlock()
}(r)
}
wg.Wait()
if len(retryResources) > 0 {
resources = retryResources
retryResources = make([]pb.ResourceManagerClient, 0)
t.backoff()
} else {
break
}
}
}
使用协程同时调用所有RM
的Commit
接口,然后等待所有的结构返回结果后再结束函数。需要注意的是,到了这一步,所有的事务都必须完成Commit
或者Cancel
,否则的话就会出现数据不一致的问题。所以这里在调用Commit
失败之后,会将失败的RM存入一个切片中,在短暂暂停之后,会继续调用这些RM的Commit
接口。
cancel方法和commit类似,只是将Commit接口改成了Cancel接口。这里就不贴出来了,详情看tcc-toy
到这里我们整个项目就完成了,接下来看一下整个项目是怎样运行的。
例子
首先是我们的RM,RM需要先定义我们事务的3个接口,Try/Commit/Cancel(在Try里面随机选择返回成功或者失败),然后在main函数里面启动两个RM服务,分别在9210和9211端口:
go
type MockResourceManager struct {
pb.UnimplementedResourceManagerServer
}
func (manager *MockResourceManager) Try(_ context.Context, req *pb.TryRequest) (*pb.TryReply, error) {
log.Printf("try -- xid: %s, param: %s\n", req.Xid, req.Param)
if rand.Intn(10)%2 == 0 { // 随机选择返回错误或者成功
return &pb.TryReply{}, errors.New("random error")
}
return &pb.TryReply{}, nil
}
func (manager *MockResourceManager) Commit(_ context.Context, req *pb.CommitRequest) (*pb.CommitReply, error) {
log.Printf("commit -- xid: %s\n", req.Xid)
return &pb.CommitReply{}, nil
}
func (manager *MockResourceManager) Cancel(_ context.Context, req *pb.CancelRequest) (*pb.CancelReply, error) {
log.Printf("cancel -- xid: %s\n", req.Xid)
return &pb.CancelReply{}, nil
}
func main() {
redisCli := redis.NewClient(&redis.Options{Addr: "mydomain.com:6379", Password: "123456"})
wg := sync.WaitGroup{}
resourceManager := func(port int) {
defer wg.Done()
// 启动一个resource manager
mock := &MockResourceManager{}
rm := resource_manager.New(mock, redisCli, resource_manager.WithPort(port))
if err := rm.Run(); err != nil {
log.Printf("resource manager failed: %v", err)
return
}
}
wg.Add(2)
go resourceManager(9210)
go resourceManager(9211)
wg.Wait()
}
然后是我们的APP,使用TCCCall
开启事务,然后使用CallTry
方法调用对应的RM服务的接口,最后选择Commit或者Cancel:
go
func main() {
err := transaction_manager.TCCCall(func(tx *transaction_manager.Transaction) transaction_manager.Operation {
_, err := tx.CallTry("localhost:9210", &pb.TryRequest{Param: "tx1"})
if err != nil {
log.Printf("tx1 try failed: %v", err)
return tx.Cancel()
}
_, err = tx.CallTry("localhost:9211", &pb.TryRequest{Param: "tx2"})
if err != nil {
log.Printf("tx2 try failed: %v", err)
return tx.Cancel()
}
log.Printf("tx1 and tx2 try success")
return tx.Commit()
})
if err != nil {
log.Fatalf("transaction failed: %v", err)
}
}
成功执行了APP之后,可以看到APP的日志
log
2023/12/12 22:40:22 tx1 and tx2 try success
# 或者
2023/12/12 22:40:07 tx2 try failed: rpc error: code = Unknown desc = random error
总结
至此,《使用Golang和GRPC实现TCC分布式事务》系列已圆满结束。尽管这个项目相对简单,更像是一个玩具项目,但它对我来说是一次很好的学习和实践机会。我们通过这个项目,深入了解了TCC分布式事务的基本流程,并探索了如何结合Golang和GRPC来构建一个轻量级的分布式事务管理系统。
这个项目虽小,但实践中我们涉及了事务管理器(Transaction Manager)和资源管理器(Resource Manager)的实现,以及使用Redis作为子事务屏蔽的关键组件。希望通过这个项目,读者能够更深入地理解TCC事务的机制和设计原则。
欢迎读者在练习过程中尝试使用不同的编程语言来实现类似的分布式事务系统。这不仅有助于加深对TCC事务的理解,还能够锻炼读者在分布式系统设计和开发方面的能力。
最后,期待大家分享自己的实现成果和经验,共同学习、讨论!🎉