分布式事务实现 | 青训营

139 阅读9分钟

1 前言

同样在实现业务过程中遇到这么一个场景,我需要将视频上传到OSS(对象存储中),同时我也要将对象的连接存储到我的数据当中,这里涉及到了对象存储和数据库存储两个系统,现在可能会遇到上传视频失败,或者遇到数据存储失败。现在要保证两个系统的数据保证一致性,也就是说上传视频和存储数据两个操作同时成功,要么就同时失败,也就是所谓的分布式事务,要解决这么一个问题,所以有了这一篇文章,介绍分布式事务应该如何实现。

本文会结合此次的场景具体实现,也会介绍一些常见的解决分布式事务的方法。

2 事务

说起事务,就不得不提起MySQL数据库的事务,也就是我们平时遇到的最常见的事务 。事务是访问并可能更新数据库中各项数据项的一个执行单元, 事务是由事务开始和事务结束之间执行的全体操作组成的。简单来说,事务当中可能包含多个操作,但是这些操作要么全部同时成功,要么全部同时失败。

事务结束有两种情况:①事务中的全部操作都执行成功,提交事务 ② 事务中有操作失败,那么将会发生回滚操作,并且撤销之前的所有操作。

事务具有四个特征(ACID):原子性、一致性、隔离性和持久性

  • 原子性(Atomicity):一个事务的所有操作要么全部完成,要么全部不完。
  • 一致性(Consistency):操作前后,数据库保证一致性状态。
  • 隔离性(Isolation):数据库允许多个事务并发的执行,事务和事务之间相互隔离,互不影响。
  • 持久性(Durability):事务结束后,对数据的修改是永久的。

3 分布式事务

image.png

分布式事务是指事务得参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的节点之上,且属于不同的应用,分布式事务需要保证这些操作要么全部操作成功,要么全部失败。简单来说,就是将数据分散存储在分布式系统中。

提到分布式事务,就说一下CAP原子(在课堂上遇到过,只记得了服务无法满足三个属性):

  • 一致性(Consistency):更新操作返回客户端完成后,所有节点在同一时间的数据完全一致,不能存在中间状态。分布式环境中,一致性是指多个副本之间能否保持一致的特性。
  • 可用性(Availability):系统提供的服务必须处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。
  • 分区容错行(Partition tolerance):分布式系统在遇到任何网络分区故障时,仍然需要能够保证对外提供满足一致性和可用性的服务,除非整个网络环境都发生了故障。

为什么CAP原理在分布式系统中只能三选二呢?

在一个分布式系统中,分区故障是无法避免的。如果不考虑分区容忍性,一旦发生了故障。整个系统就无法使用了,这不符合实际的开发需要。所有所谓的三选二,其实P已经是必选的了,只能在AP,CP之间选择。 为了满足数据的一致性,到有写请求的时候,数据需要同步到全部的节点,请求必须等到所有的节点数据都更新一致后,才能响应,再次之前所有的请求都会阻塞。就很可能导致响应超时,破坏了可用性。 为了提高可用性,系统部署节点越多,但是又要保证响应的时间,所有就无法达到高一致性。

下面将介绍几种常见的分布式事务保证一致性的方法

4 异步回滚策略

4.1 简介

异步任务回滚是一种处理在执行某个主要任务过程中发生错误时,通过启动异步任务来执行回滚操作的机制。通常,在复杂的业务逻辑中,一个主要任务可能包含多个步骤,涉及多个资源或数据的修改。如果在主要任务的某个步骤出现错误,为了保持数据的一致性和完整性,我们可能需要撤销之前已经执行的操作,即执行回滚操作。

异步任务回滚的主要思想是将回滚操作封装成一个异步任务,当主要任务发生错误时,启动这个异步任务来执行回滚操作。这样可以确保回滚操作在发生错误后能够被执行,从而保持数据的一致性和完整性。

4.2 具体实现

func storeVideoLinkToDB(videoLink string) error {
	// 模拟链接存储到数据库
	if err := insertLinkToDatabase(videoLink); err != nil {
		// 链接存储失败,创建异步回滚任务
		go func() {
			if err := deleteUploadedVideo(videoLink); err != nil {
				// 处理删除视频失败的情况
			}
		}()
		return err
	}
	return nil
}

func deleteUploadedVideo(videoLink string) error {
	// 模拟删除已上传的视频
	if err := deleteFromOSS(videoLink); err != nil {
		// 处理删除失败的情况
		return err
	}
	return nil
}

如果其中一个操作失败了,就会回滚到上一个事务中去,我在实际的实现中也是采用这样方法。我利用两个协程,一个存储数据库,另外一个完成视频上传,如果其中一个任务失败了,就会回滚另外一个任务,这样来达到数据的一致性:

	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		// 上传视频
	}()
	go func() {
		defer wg.Done()
		// 保存到数据库
	}()
	wg.Wait()

	// 异步回滚
	if updataErr != nil || creatErr != nil {
		go func() {
			// 存入数据库失败,删除上传
			if creatErr != nil {
			}
			// 上传失败,删除数据库
			if updataErr != nil {
			}
		}()
	}

4.3 补偿事务

在查阅资料的过程中,看到了补偿事务这一说话,感觉两者的思路是一样的,在这里将两者进行区分一下,以免混淆,简单总结一下,异步回滚就是补偿事务这种思想下的一种具体实现:

补偿事务: 补偿事务是一种设计模式,用于处理分布式事务失败的情况。当事务的某个步骤失败时,可以通过执行一系列的补偿操作来保持数据的一致性。这些补偿操作不一定要与原始操作完全相反,而是根据业务需求进行设计,以确保数据状态的一致性。

总的来说,异步回滚和补偿事务都是处理分布式事务失败的方法,但补偿事务更侧重于设计模式和业务逻辑,而异步回滚更强调在后台异步执行回滚操作以提高性能。

5 两阶段提交

5.1 简介

2PC 是一种协议,确保在多个参与者之间执行的事务要么全部提交,要么全部回滚。它涉及预提交、提交和回滚三个阶段,协调者与参与者之间进行交互,但因为其同步阻塞特性,可能导致性能问题和单点故障。

两阶段分为准备阶段和提交阶段,流程如下:

  • 协调者通知所有的参与者,然后等待参与者的回执
  • 参与者收到通知开始执行操作,参与者给协调者返回响应,参与事务操作返回"同意",否者返回“中止”
  • 当收到所有参与者都是“同意”进入提交阶段,协调者想参与者发送“提交请求”
  • 参与者完成提交,释放占用的资源,给协调者发送“完成”消息
  • 协调者收到所有参与者返回的“完成”消息后,事务完成
  • 协调者收到“中止”,则会发生回滚操作

5.2 具体实现

type Participant interface {
	Prepare() error
	Commit() error
	Abort() error
}

type DatabaseParticipant struct{}

func (dp *DatabaseParticipant) Prepare() error {
	fmt.Println("Database: Prepare")
	// 执行数据库的预提交操作
	return nil
}

func (dp *DatabaseParticipant) Commit() error {
	fmt.Println("Database: Commit")
	// 执行数据库的提交操作
	return nil
}

func (dp *DatabaseParticipant) Abort() error {
	fmt.Println("Database: Abort")
	// 执行数据库的回滚操作
	return nil
}

type OSSParticipant struct{}

func (op *OSSParticipant) Prepare() error {
	fmt.Println("OSS: Prepare")
	// 执行视频上传的预提交操作
	return nil
}

func (op *OSSParticipant) Commit() error {
	fmt.Println("OSS: Commit")
	// 执行视频上传的提交操作
	return nil
}

func (op *OSSParticipant) Abort() error {
	fmt.Println("OSS: Abort")
	// 执行视频上传的回滚操作
	return nil
}

func main() {
	database := &DatabaseParticipant{}
	oss := &OSSParticipant{}

	// 预提交阶段
	if err := preparePhase([]Participant{database, oss}); err != nil {
		log.Fatal("Prepare phase failed:", err)
	}

	// 提交阶段
	if err := commitPhase([]Participant{database, oss}); err != nil {
		log.Fatal("Commit phase failed:", err)
	} else {
		fmt.Println("Transaction committed successfully")
	}
}

func preparePhase(participants []Participant) error {
	fmt.Println("=== Prepare Phase ===")
	for _, participant := range participants {
		if err := participant.Prepare(); err != nil {
			abortPhase(participants)
			return err
		}
	}
	return nil
}

func commitPhase(participants []Participant) error {
	fmt.Println("=== Commit Phase ===")
	for _, participant := range participants {
		if err := participant.Commit(); err != nil {
			abortPhase(participants)
			return err
		}
	}
	return nil
}

func abortPhase(participants []Participant) {
	fmt.Println("=== Abort Phase ===")
	for _, participant := range participants {
		participant.Abort()
	}
}

6 其余方法

6.1 消息中间件

使用消息中间件(如Kafka、RabbitMQ)来发布和订阅事件,通过事件驱动模型实现分布式事务的一致性。事件发布者将事务的操作作为事件发布,订阅者执行相应的操作。

6.2 三阶段提交

3PC 是对2PC的改进,增加了超时机制,降低了阻塞风险。它包括CanCommit、PreCommit和DoCommit三个阶段,每个阶段都有超时机制,但仍然需要解决某些不确定性情况。

三个阶段是指:

询问阶段:询问参与者是否可以执行事务操作
预提交阶段:除了没有提交事务,其它事务操作都执行了
提交阶段:提交事务或者回滚事务

6.3 其它

除了上面介绍的方法还可以利用第三方组件,其中还有很多知识在里面,比如柔性事务、刚性事务,刚刚提到的利用消息中间件和事务补偿都是柔性事务,而二阶段提交和三阶段提交属于刚性事务。还有TTC模型等等,也有很多的知识点,先只实现了两种