分布式事务

1 篇文章 0 订阅
1 篇文章 0 订阅

一、事务

1.1、什么是事务?

	事务(transaction)是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit),由开始事务和提交事务之间的所有的语句组成。
	事务的结束有两种,一个是事务中间的所有操作执行成功,提交事务。一个是执行过程中出现异常,那么需要回滚所有操作。就是要么全部成功,要么全部失败。
	事务是恢复和并发控制的基本单位。 	

1.2、事务的特点

事务有四大特点(ACID):
	原子性(Atomicity):
		指的是事务是一个完整的单元,不可分割,要么全部成功,要么全部失败。
	一致性(consistency):
		数据库中的数据在事务操作前后都要保持一致性。例如一个转账业务,A,B两个用户在事务开始前账户总额度是2000元,那事务结束后还是。
	隔离性(Isolation):
		两个事务执行过程中,数据互相隔离,不能互相干扰。
		数据库中的隔离性保证四个隔离级别:
			读未提交(Read Uncommitted)
			读已提交(Read Committed)
			可重复读(Repeatable Read):mysql数据库InnaDB引擎默认的隔离级别
			串行化(Serializable)
		隔离级别越高,并发能力越低。
	持久性(Durability):
		在事务提交后,数据就永久的保存到了数据库中,不管是停电还是宕机,都是如此。

二、本地事务

	在传统的“单服务器、单数据库”的单点架构中,所产生的事务都是本地事务,在这种情况下,数据库本身的事务就能保证ACID原则。

数据库的原子性靠undo_log保证,持久性靠redo_log保证,隔离性靠锁

2.1 undo_log和redo_log

数据库中有很多文件,既有数据文件,也有日志文件。在众多日志文件中,undo_log和redo_log是和事务相关的。
2.1.1 undo_log

原理:

	为了满足事务的原子性,在操作任何数据之前,先将数据保存到undo_log中,再进行数据的修改。如果出现错误或者用户执行了ROLLBACK
语句,就利用undo_log将数据恢复到操作前的状态。

实现的简化流程:

	首先要知道,数据库写入数据到磁盘之前,会先把数据保存到内存中,等事务提交后,再将数据写入到磁盘中。
	用undo实现原子性和持久性的简化过程:
		假设有a,b两个数据,值分别为1,2
		A:事务开始
		B:将a=1存入到undo_log
		C:修改 a=3
		D:将b=2存入到redo_log
		E:修改 b=4
		F:将undo_log写入磁盘
		G:将数据持久化到磁盘
		H:事务提交
	*如何保证持久性?
		在事务提交之前,数据已经保存到了磁盘,也就是说,只要提交了事务,数据就一定持久化了。
	*如何保证原子性?
		每次对数据进行修改,都会把修改之前的数据保存到undo_log,需要恢复时,就读取undo_log。
	*如果系统在G和H之间崩溃
		此时事务并未提交,需要回滚,undo_log已经持久化,可以用来恢复数据

缺点

	每个事务都需要将数据和undo_log持久化到磁盘,需要大量io,就非常慢。因为数据持久化是随机io,undo_log保存到磁盘是顺序io,随机io会多次磁盘寻址,浪费时间。
	怎么解决呢?
		如果能将数据在内存中多存放一些时间,等事务结束后再持久化磁盘,这时我们就引入了redo_log。
2.1.2 redo_log

原理:

和undo_log相反,redo_log保存的是修改之后的数据,事务内只需将redo_log保存到磁盘,不持久化数据,减少了io次数。

实现的简化流程:

和undo_log是一个场景:
	A:事务开始
	B:将a=2记录到undo_log
	C:修改 a=3
	D:将修改后a的值保存到redo_log
	E:将b=4记录到undo_log
	F:修改 b=5
	G:将b=5记录到redo_log
	H:将undo_log保存到磁盘
	I:将redo_log保存到磁盘
	J:事务提交
*如何保证原子性?
	如果事务提交了,那么undo_log已经保存到了磁盘中,可以使用undo_log恢复数据,如果事务还没提交,就不用恢复数据了。
*如何保证持久化?
	上边的过程中没有出现数据的持久化。因为数据已经写入到了redolog中,而redolog持久化到了硬盘中,只要redolog保存进硬盘,就可以提交事务了。
*内存中的数据库数据何时持久化到磁盘?
	因为redolog已经持久化了,因此数据库数据是否写入到了磁盘,影响不大,不过为了避免产生脏数据(内存中的数据和磁盘中的不一样),会在事务提交后刷入到磁盘中(可以设置定时)。
*redolog合适写入到磁盘
	在事务提交之前,或者redolog buffer 满了,就写入到磁盘

问题:

问题1:只用undolog,需要将undolog和数据持久化到硬盘,使用undolog和redolog,需要将undolog和redolog持久化到硬盘,io次数没有减少?
	①数据持久化到磁盘是随机io,效率低;redolog持久化是会开辟一块连续空间,是顺序io,效率高;
	②undolog的内容并不是直接写入到了磁盘中,而是先写入到了redolog bugger中,当redo log 持久化时,undolog就同时持久化到了磁盘。
	③redolog并不是写一次持久化一次,而是先保存在缓冲池中,这也减少了io次数。
问题2:redo log 数据是写入内存buffer中,当buffer满或者事务提交时,将buffer数据写入磁盘。redo log中记录的数据,有可能包含尚未提交事务,如果此时数据库崩溃,那么如何完成数据恢复?
	数据恢复有两种策略:
		恢复时,只重做已经提交了的事务
		恢复时,重做所有事务包括未提交的事务和回滚了的事务。然后通过Undo Log回滚那些未提交的事务
	Inodb引擎采用的是第二种方案,因此undo log要在 redo log前持久化

2.2总结

①undo log记录更新前的数据,用于保证事务原子性
②redo log记录更新后的数据,用于保证事务的持久性

三、分布式事务

3.1 什么是分布式事务?

	就是指不在单个服务或单个数据库架构下产生的事务:
		跨服务的分布式事务
		跨数据源的分布式事务
		综合情况
3.1.1 跨数据源
	随着服务的增长,所产生的数据也在不断增加,最后单库单表已经满足不了业务的需要。所以我们把数据库进行拆分,将原
单库单表拆分成数据库分片,也是就产生了跨数据库事务问题。因为每个数据库 都有单独的本地事务,一个数据库出现问题了,
另外两个数据库还会完成自己的本地事务。

在这里插入图片描述

3.1.2 跨服务
	在业务发展初期,一个业务的所有功能都在一个服务中,能满足基本的业务需求。但是随着业务的发展,系统的访问量和业务的复
杂度都在增长,单系统架构逐渐成为业务的发展瓶颈,解决业务系统的高耦合、可伸缩问题的需求越来越强烈。如下图所示,按照面
向服务(soa)架构的设计原则,将单业务系统拆分成多业务系统,降低系统之间的耦合度,使不同的业务系统专注于自身业务。例如
将原本在一个服务中的下单、扣库存、扣款,拆分成三个服务。

在这里插入图片描述

3.1.3 分布式系统的数据一致性问题
	在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。在分布式网络环境下,我们无法保障
所有服务、数据库都百分百可用,一定会出现部分服务、数据库执行成功,另一部分执行失败的问题。当出现部分业务操作成功、部
分业务操作失败时,业务数据就会出现不一致。
	例如电商行业中比较常见的下单付款案例,包括下面几个行为:
		- 创建新订单
		- 扣减商品库存
		- 从用户账户余额扣除金额
	完成上面的操作需要访问三个不同的微服务和三个不同的数据库。

在这里插入图片描述

	在分布式环境下,肯定会出现部分操作成功、部分操作失败的问题,比如:订单生成了,库存也扣减了,但是 用户账户的余额不
足,这就造成数据不一致。
	订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
	但是当我们把三件事情看做一个事情事,要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成
功部分失败的现象,这就是分布式系统下的事务了。此时ACID难以满足,这是分布式事务要解决的问题。

四、解决分布式事务的思路

为什么分布式系统下,事务的ACID原则难以满足?
	这得从CAP定理和BASE理论说起。

4.1 什么是CAP定理?

什么是CAP定理呢?

在这里插入图片描述

1998年,加州大学的计算机科学家Eric Brewer提出,分布式系统有三个指标:
	Consistency(一致性)
	Availability(可用性)
	Partition tolerance (分区容错性)
它们的第一个字母分别是C、A、P。Eric Brewer说,这三个指标不可能同时做到。
这个结论就叫做CAP定理。
4.1.1 Partition tolerance

先看Partition tolerance,中文叫做“分区容错”。
在这里插入图片描述

	大多数分布式系统都分布在多个子网络。每个子网络就叫一个区(partition)。分区容错的意思
是,区间通信可能失败,比如一台服务器在上海,一台在北京,这就是两个区,它们之间可能因为
网络问题无法通信。
	上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设
计的时候,必须考虑到这种情况。
	一般来说,分布式系统,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。根据CAP 定
理,剩下的 C 和 A 无法同时做到。
4.1.2 Consistency
	Consistency中文叫做“一致性”。意思是,写操作之后的读操作,必须返回该值。举例来说,某条
记录是V0,用户向G1服务发起一个写操作,将记录改为v1。

在这里插入图片描述

用户再进行读操作,得到v1,这就叫一致性。

在这里插入图片描述

但是有负载均衡,用户访问的是G2服务器,进行读操作,由于G2的值没有发生变化,因此返回的是V0。
在这里插入图片描述
G1和G2读操作的结果不一致,这就不满足一致性了。
为了让G2也能变成V1,就要在G1写操作的时候,让G1向G2发送一条信息,要求G2也改成V1。
在这里插入图片描述这样向G2发送请求,也能得到v1
在这里插入图片描述

4.1.3 Availability

Availability中文叫“可用性”,意思是只要收到用户的请求,服务器必须做出回应(对错不论)。用户的读请求不论是到G1还是G2,只要服务器收到请求,就必须回应用户,不管是V0还是V1,否则就不满足可用性。

4.1.4 Availability和Consistency的矛盾

一致性和可用性,为什么不能同时满足?
如果保证G2的一致性,那么在G1进行写操作的时候,必须锁定G2的读操作和写操作。只有等数据同步后,才能进行读操作和写操作。锁定期间,G2不能对请求做出回应,就没有可用性保证。

如果保证G2的可用性,那么就不能锁定G2,这就无法保证一致性。

综上所述,G2无法同时保证一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性。如果追求所有节点的可用性,就无法保证一致性。

4.1.4 疑问

①怎样才能同时满足CA?
在单点架构的服务中
②何时要满足CP?
对一致性要求高的场景。例如我们的Zookeeper就是这样的,在服务节点间数据同步时,服务无法对外提供服务
③何时满足AP?
对可用性要求高的场景。例如Eureka,必须保证注册中心随时可用。

4.2 Base理论

4.2.1 什么是Base理论
先看组成Base的三个单词:
	Basically Available (基本可用性)
	Soft state (软状态)
	Eventually consistent (最终一致性)
Base理论是在对CAP定理的实践中,得到的最优解,核心思想是:
	在无法保证强一致性的情况下,那么各个分系统可以根据自身特点,采取合适的方式来使整个系统达到最终一致性。
Basically Available:基本可用性是指分布式系统在面临故障的时候,允许损失部分的功能,来保证核心功能的可用性。例如当电商平台在访问压力过大时,会引导部分用户到服务降级页面,来保证核心功能的正常运行。
Soft state:软状态是指允许系统间数据存在中间状态,而该中间状态不影响系统的整体可用性。分布式存储中一般一份数据至少会有三个副本,允许不同节点间副本同步的延时就是软状态的体现。
Eventually consistent:最终一致性是指,在经过一段时间后,更新的数据会在整个系统中保持一致。
	我们解决分布式事务,就是根据以上理论来实现的,一般就两种思路:
		CP方式:一致性优先考虑,可用性其次
		AP方式:可用性优先考虑,一致性其次
由以上两种思想,发展出来很多的分布式事务解决方案:
	XA、TCC、可靠消息最终一致、AT	

4.3 分布式事务解决方案

4.3.1 分阶段提交方案
分布式事务的解决方案之一,就是分阶段提交协议(2PC:Two-Phase Commit)
那么什么是分阶段提交协议呢?
	1994 年,X/Open 组织(即现在的 Open Group,官网地址:http://www.opengroup.com )定义
了分布式事务处理的 DTP 模型(全称Distributed Transaction Processing Reference Model)和DTP
 XA协议。DTP模型包括这样几个角色:
		应用程序(AP):我们的微服务
		事务管理器(TM):全局事务管理者。负责分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚等。
		资源管理器(RM):如数据库、文件系统等,并提供访问资源的方式
		通信资源管理器(CRM):通信中间件。控制一个TM域(TM domain)内或者跨TM域的分布式应用之间的通信。
	在该模型中,一个分布式事务(全局事务)可以被分成多个本地事务,分别运行在各自的AP和RM
上。每个本地事务的ACID很好实现,但是分布式事务中必须保证其中包含的每个本地事务都能同时
成功,若有一个本地事务失败,则所有的本地事务都进行回滚。但问题是,在本地事务的运行中,并
不知道其他本地事务的运行状态。因此,就需要CRM来通知各本地事务,同步各本地事务执行的状态。
	因此,各个本地事务的通信必须有统一的标准,否则不同数据库间就无法进行通信。XA规范了
TM与RM之间的通信接口,在TM与多个RM之间形成一个双向通信桥梁,从而在多个数据库资源下
保证ACID四个特性,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须
实现XA协议接口。
	DTP模型文档:http://pubs.opengroup.org/onlinepubs/9294999599/toc.pdf
	DTP XA规范:http://pubs.opengroup.org/onlinepubs/009680699/toc.pdf

具体场景:两阶段提交

两阶段提交协议就是根据这一思想发展出来的,将全局事务拆分为两个阶段来执行:
	阶段一:准备阶段,各个本地事务完成本地事物的准备工作,将执行结果提交给TM。
	阶段二:执行阶段,各个本地事务根据上一阶段执行结果,进行提交或回滚。
这个过程中需要一个协调者(coordinator),还有事务的参与者(voter)。

1)正常情况
在这里插入图片描述

	coordinator(协调者):就是crm
	voter1(参与者):就是我们的应用
	在第一阶段(propose phase)中,协调者向参与者发送事务执行信息,参与者执行事务,将事
务执行结果返回给协调者,如果全部成功,在第二阶段(commit phase)中,协调者向参与者发送
commit指令,参与者都提交事务。

2)异常情况
在这里插入图片描述

	在第一阶段(propose phase)中,协调者向参与者发送事务执行信息,参与者执行事务,将事
务执行结果返回给协调者,只要有一个不成功,在第二阶段(commit phase)中,协调者向参与者发
送abort指令,参与者都回滚事务。

3)缺陷
二阶段提交的问题:
*单点故障问题:
2PC的缺点在于不能处理fail-stop形式的节点failure. 比如下图这种情况
在这里插入图片描述

	假设coordinator和voter3都在Commit这个阶段crash了, 而voter1和voter2没有收到commit消息.
 这时候voter1和voter2就陷入了一个困境. 因为他们并不能判断现在是两个场景中的哪一种:
(1)上轮全票通过然后voter3第一个收到了commit的消息并在commit操作之后crash了
(2)上轮voter3反对所以干脆没有通过.

*阻塞问题:

	在准备阶段、提交阶段,每个事物参与者都会锁定本地资源,并等待其它事务的执行结果,阻塞
时间较长,资源锁定时间太久,因此执行的效率就比较低了。

面对二阶段提交的上述缺点,后来又演变出了三阶段提交,但是依然没有完全解决阻塞和资源锁定的问题,而且引入了一些新的问题,因此实际使用的场景较少。

4)优势

很好了保证了强一致性,对事务执行效率不敏感,并且不希望又太多代码侵入
4.3.2 TCC

TCC模式解决了2PC模式中资源锁定和阻塞的问题,减少了资源锁定时间。

4.3.2.1 基本原理

TCC模式本质是一种补偿机制,包含了三个方法,两个阶段:

三个方法:
	Try:资源的检测和预留。(例如扣库存的时候,先检测库存是否够,并留
出要扣减的量,这一步可以在数据库表中加一个“待扣减字段”)
	Confirm:执行的业务操作提交;要求Try成功Confirm一定要能成功。
	Cancel:预留资源释放
两个阶段:
	准备阶段(try):资源的检测和预留;
	执行阶段(confirm/cancel):根据上一阶段的执行成果,判断下面的执行方法;如
果上一步事务参与者都执行成功,则这里执行Confirm,反之,执行cancel。

在这里插入图片描述这个模式似乎和两阶段提交没什么差别,但其实区别很大:

	try\confirm\cancel都是独立的事务,不受其它参与者的影响,不会阻塞等待它人。
比如扣款和下订单,这是两个独立的方法,只要独立执行完就行了,会有单独的逻辑
判断他们的执行结果,都成功,整个流程就成功,如果有不成功的,就将数据进行恢
复。
	try、confirm、cancel由程序员在业务层编写,锁粒度有代码控制
4.3.2.2 实例

我们以之前的下单业务中的扣减余额为例来看下三个不同的方法要怎么编写,假设账户A原来余额是100,需要余额扣减30元。如图:
在这里插入图片描述

  • 一阶段(Try):余额检查,并冻结用户部分金额,此阶段执行完毕,事务已经提交
    • 检查用户余额是否充足,如果充足,冻结部分余额
    • 在账户表中添加冻结金额字段,值为30,余额不变
  • 二阶段
    • 提交(Confirm):真正的扣款,把冻结金额从余额中扣除,冻结金额清空
      • 修改冻结金额为0,修改余额为100-30 = 70元
    • 补偿(Cancel):释放之前冻结的金额,并非回滚
      • 余额不变,修改账户冻结金额为0
4.3.2.3 优势和缺点
  • 优势
    TCC执行的每一个阶段都会提交本地事务并释放锁,并不需要等待其它事务的执行结果。而如果其它事务执行失败,最后不是回滚,而是执行补偿操作。这样就避免了资源的长期锁定和阻塞等待,执行效率比较高,属于性能比较好的分布式事务方式。
  • 缺点
    • 代码侵入:需要人为编写代码实现try、confirm、cancel,代码侵入较多
    • 开发成本高:一个业务需要拆分成3个步骤,分别编写业务实现,业务编写比较复杂
    • 安全性考虑:cancel动作如果执行失败,资源就无法释放,需要引入重试机制,而重试可能导致重复执行,还要考虑重试时的幂等问题
4.3.2.4 使用场景
  • 对事务有一定的一致性要求(最终一致)
  • 对性能要求较高
  • 开发人员具备较高的编码能力和幂等处理经验
4.3.3 可靠消息服务
其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。
4.3.3.1 基本原理

一般分为事务的发起者A和事务的其它参与者B:

  • 事务发起者A执行本地事务

  • 事务发起者A通过MQ将需要执行的事务信息发送给事务参与者B

  • 事务参与者B接收到消息后执行本地事务
    在这里插入图片描述这个过程有点像你去学校食堂吃饭:

    • 拿着钱去收银处,点一份红烧牛肉面,付钱
    • 收银处给你发一个小票,还有一个号牌,你别把票弄丢!
    • 你凭小票和号牌一定能领到一份红烧牛肉面,不管需要多久

    几个注意事项:

    • 事务发起者A必须确保本地事务成功后,消息一定发送成功
    • MQ必须保证消息正确投递和持久化保存
    • 事务参与者B必须确保消息最终一定能消费,如果失败需要多次重试
    • 事务B执行失败,会重试,但不会导致事务A回滚

那么问题来了,我们如何保证消息发送一定成功?如何保证消费者一定能收到消息?

4.3.3.2 本地消息表
	为了避免消息发送失败或丢失,我们可以把消息持久化到数据库中。实现时有简化
版本和解耦合版本两种方式。

1)简化版本
原理图:
在这里插入图片描述

  • 事务发起者:
    • 开启本地事务
    • 执行事务相关业务
    • 发送消息到MQ
    • 把消息持久化到数据库,标记为已发送
    • 提交本地事务
  • 事务接收者:
    • 接收消息
    • 开启本地事务
    • 处理事务相关业务
    • 修改数据库消息状态为已消费
    • 提交本地事务
  • 额外的定时任务
    • 定时扫描表中超时未消费消息,重新发送

优点:

  • 与tcc相比,实现方式较为简单,开发成本低。

缺点:

  • 数据一致性完全依赖于消息服务,因此消息服务必须是可靠的。
  • 需要处理被动业务方的幂等问题
  • 被动业务失败不会导致主动业务的回滚,而是重试被动的业务
  • 事务业务与消息发送业务耦合、业务数据与消息表要在一起

2)独立消息服务

	为了解决上述问题,我们会引入一个独立的消息服务,来完成对消息的持久化、发
送、确认、失败重试等一系列行为,大概的模型如下:

在这里插入图片描述

一次消息发送的时序图:

在这里插入图片描述事务发起者A的基本执行步骤:

  • 开启本地事务
  • 通知消息服务,准备发送消息(消息服务将消息持久化,标记为准备发送)
  • 执行本地业务,
    • 执行失败则终止,通知消息服务,取消发送(消息服务修改订单状态)
    • 执行成功则继续,通知消息服务,确认发送(消息服务发送消息、修改订单状态)
  • 提交本地事务

消息服务本身提供下面的接口:

  • 准备发送:把消息持久化到数据库,并标记状态为准备发送
  • 取消发送:把数据库消息状态修改为取消
  • 确认发送:把数据库消息状态修改为确认发送。尝试发送消息,成功后修改状态为已发送
  • 确认消费:消费者已经接收并处理消息,把数据库消息状态修改为已消费
  • 定时任务:定时扫描数据库中状态为确认发送的消息,然后询问对应的事务发起者,事务业务执行是否成功,结果:
    • 业务执行成功:尝试发送消息,成功后修改状态为已发送
    • 业务执行失败:把数据库消息状态修改为取消

事务参与者B的基本步骤:

  • 接收消息
  • 开启本地事务
  • 执行业务
  • 通知消息服务,消息已经接收和处理
  • 提交事务

优点:

  • 解除了事务业务与消息相关业务的耦合

缺点:

  • 实现起来比较复杂
4.3.3.3 RocketMQ事务消息
	RocketMQ本身自带了事务消息,可以保证消息的可靠性,原理其实就是自带了本
地消息表,与我们上面讲的思路类似。
4.3.3.4 RabbitMQ的消息确认

RabbitMQ确保消息不丢失的思路比较奇特,并没有使用传统的本地表,而是利用了消息的确认机制:

  • 生产者确认机制:确保消息从生产者到达MQ不会有问题
    • 消息生产者发送消息到RabbitMQ时,可以设置一个异步的监听器,监听来自MQ的ACK
    • MQ接收到消息后,会返回一个回执给生产者:
      • 消息到达交换机后路由失败,会返回失败ACK
      • 消息路由成功,持久化失败,会返回失败ACK
      • 消息路由成功,持久化成功,会返回成功ACK
    • 生产者提前编写好不同回执的处理方式
      • 失败回执:等待一定时间后重新发送
      • 成功回执:记录日志等行为
  • 消费者确认机制:确保消息能够被消费者正确消费
    • 消费者需要在监听队列的时候指定手动ACK模式
    • RabbitMQ把消息投递给消费者后,会等待消费者ACK,接收到ACK后才删除消息,如果没有接收到ACK消息会一直保留在服务端,如果消费者断开连接或异常后,消息会投递给其它消费者。
    • 消费者处理完消息,提交事务后,手动ACK。如果执行过程中抛出异常,则不会ACK,业务处理失败,等待下一条消息

经过上面的两种确认机制,可以确保从消息生产者到消费者的消息安全,再结合生产者和消费者两端的本地事务,即可保证一个分布式事务的最终一致性。

4.3.3.4 消息事务的优缺点

总结上面的几种模型,消息事务的优缺点如下:

  • 优点:
    • 业务相对简单,不需要编写三个阶段业务
    • 是多个本地事务的结合,因此资源锁定周期短,性能好
  • 缺点:
    • 代码侵入
    • 依赖于MQ的可靠性
    • 消息发起者可以回滚,但是消息参与者无法引起事务回滚
    • 事务时效性差,取决于MQ消息发送是否及时,还有消息参与者的执行情况

针对事务无法回滚的问题,有人提出说可以再事务参与者执行失败后,再次利用MQ通知消息服务,然后由消息服务通知其他参与者回滚。那么,恭喜你,你利用MQ和自定义的消息服务再次实现了2PC 模型,又造了一个大轮子

4.3.4 AT模式
	2019年1月份,Seata开源了AT模式。AT模式是一种无侵入的分布式事务解决方
案。可以看作是对TCC或二阶段提交模型的一种优化,解决了TCC模式中的代码侵
入、编码复杂等问题。
	seata的官方文档:https://seata.io/zh-cn/docs/dev/mode/at-mode.html
4.3.4.1 基本原理

先来看一张流程图:
在这里插入图片描述

有没有感觉跟TCC的执行很像,都是分两个阶段:
	- 一阶段:执行本地事务,并返回执行结果
	- 二阶段:根据一阶段的结果,判断二阶段做法:提交或回滚
	但AT模式底层做的事情可完全不同,而且第二阶段根本不需要我们编写,全部有
Seata自己实现了。也就是说:我们写的代码与本地事务时代码一样,无需手动处理
分布式事务。

那么,AT模式如何实现无代码侵入,如何帮我们自动实现二阶段代码的呢?
一阶段:

	在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL”要更
新的业务数据,在业务数据被更新前,将其保存成“before image”,然后执行“业务 
SQL”更新业务数据,在业务数据更新之后,再将其保存成“after image”,最后获取全
局行锁,提交事务。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作
的原子性。
	这里的before image和after image类似于数据库的undo和redo日志,但其实是用数据库模拟的。

在这里插入图片描述二阶段提交:

	二阶段如果是提交的话,因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata
 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。

二阶段回滚:

	二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL”,还原业
务数据。回滚方式便是用“before image”还原业务数据;但在还原前要首先要校验脏
写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有
脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。

在这里插入图片描述

	不过因为有全局锁机制,所以可以降低出现脏写的概率。
	AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业
务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务
解决方案。
4.3.4.2 详细架构和流程

Seata中的几个基本概念:

  • TC(Transaction Coordinator) - 事务协调者
    维护全局和分支事务的状态,驱动全局事务提交或回滚(TM之间的协调者)。
  • TM(Transaction Manager) - 事务管理器
    定义全局事务的范围:开始全局事务、提交或回滚全局事务。
  • RM(Resource Manager) - 资源管理器
    管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

我们看下面的一个架构图
在这里插入图片描述 ·

TC是一个单独的服务,TM和RM是运行在一个个微服务中,由jar包导入
  • TM:业务模块中全局事务的开启者
    • 向TC开启一个全局事务
    • 调用其它微服务
  • RM:业务模块执行者中,包含RM部分,负责向TC汇报事务执行状态
    • 执行本地事务
    • 向TC注册分支事务,并提交本地事务执行结果
  • TM:结束对微服务的调用,通知TC,全局事务执行完毕,事务一阶段结束
  • TC:汇总各个分支事务执行结果,决定分布式事务是提交还是回滚;
  • TC 通知所有 RM 提交/回滚 资源,事务二阶段结束。

流程
一阶段:

  • TM开启全局事务,并向TC声明全局事务,包括全局事务XID信息
  • TM所在服务调用其它微服务
  • 微服务,主要有RM来执行
    • 查询before_image
    • 执行本地事务
    • 查询after_image
    • 生成undo_log并写入数据库
    • 向TC注册分支事务,告知事务执行结果
    • 获取全局锁(阻止其它全局事务并发修改当前数据)
    • 释放本地锁(不影响其它业务对数据的操作)
  • 待所有业务执行完毕,事务发起者(TM)会尝试向TC提交全局事务

二阶段:

  • TC统计分支事务执行情况,根据结果判断下一步行为
    • 分支都成功:通知分支事务,提交事务
    • 有分支执行失败:通知执行成功的分支事务,回滚数据
  • 分支事务的RM
    • 提交事务:直接清空before_image和after_image信息,释放全局锁
    • 回滚事务:
      • 校验after_image,判断是否有脏写
      • 如果没有脏写,回滚数据到before_image,清除before_image和after_image
      • 如果有脏写,请求人工介入
4.3.4.3 工作机制

详见Seata的官方文档:Seata文档

4.3.4.4 优缺点

优点:

  • 与2PC相比:每个分支事务都是独立提交,不互相等待,减少了资源锁定和阻塞时间
  • 与TCC相比:二阶段的执行操作全部自动化生成,无代码侵入,开发成本低

缺点:

  • 与TCC相比,需要动态生成二阶段的反向补偿操作,执行性能略低于TCC

4.4 Seata框架

4.4.1 介绍
	Seata(Simple Extensible Autonomous Transaction Architecture,简单可扩展自治事务框架)
是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。Seata 开源半年左右,目
前已经有接近一万 star,社区非常活跃。我们热忱欢迎大家参与到 Seata 社区建设中,一同将 
Seata 打造成开源分布式事务标杆产品。
	Seata:https://github.com/seata/seata
4.4.1.1 Seata 产品模块
	如下图所示,Seata 中有三大模块,分别是 TM、RM 和 TC。 其中 TM 和 RM 是作为 Seata 的
客户端与业务系统集成在一起,TC 作为 Seata 的服务端独立部署。

在这里插入图片描述

4.4.1.2 Seata支持的事务模型
Seata 会有 4 种分布式事务解决方案,分别是 AT 模式、TCC 模式、Saga 模式和 XA 模式
4.4.2 AT模式实战

Seata中比较常用的是AT模式,这里我们拿AT模式来做演示,看看如何在SpringCloud微服务中集成Seata.

我们假定一个用户购买商品的业务逻辑。整个业务逻辑由3个微服务提供支持:

  • 仓储服务:对给定的商品扣除仓储数量。
  • 订单服务:根据采购需求创建订单。
  • 帐户服务:从用户帐户中扣除余额。

流程图:
在这里插入图片描述订单服务在下单时,同时调用库存服务和用户服务,此时就会发生跨服务和跨数据源的分布式事务问题。

4.4.2.1 准备数据

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for account_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `money` int(11) UNSIGNED NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of account_tbl
-- ----------------------------
INSERT INTO `account_tbl` VALUES (1, 'user202003032042012', 1000);

-- ----------------------------
-- Table structure for order_tbl
-- ----------------------------
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `count` int(11) NULL DEFAULT 0,
  `money` int(11) NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Table structure for storage_tbl
-- ----------------------------
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `count` int(11) UNSIGNED NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

-- ----------------------------
-- Records of storage_tbl
-- ----------------------------
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);

-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime(0) NOT NULL,
  `log_modified` datetime(0) NOT NULL,
  `ext` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;

SET FOREIGN_KEY_CHECKS = 1;

4.4.2.2 引入Demo工程

我们先准备基本的项目环境,实现下单的业务代码,大家就正常搭建符合逻辑的微服务就行。
在这里插入图片描述

结构说明:

  • account-service:用户服务,提供操作用户账号余额的功能,端口8083
  • eureka-server:注册中心,端口8761
  • order-service:订单服务,提供根据数据创建订单的功能,端口8082
  • storage-service:仓储服务,提供扣减商品库存功能,端口8081

搭建完之后,要测试正常情况下项目时候能逻辑圆满

4.4.2.3 准备TC服务
在之前讲解Seata原理的时候,我们就聊过,其中包含重要的3个角色:
	TC:事务协调器
	TM:事务管理器
	RM:资源管理器
	其中,TC是一个独立的服务,负责协调各个分支事务,而TM和RM通过jar包的方式,集成在各个事务参与者中。
	因此,首先我们需要搭建一个独立的TC服务。

1)安装

首先去官网下载TC的服务端安装包,GitHub的地址:https://github.com/seata/seata/releases
然后解压即可,其目录结构如下:

在这里插入图片描述

包括:
	bin:启动脚本
	conf:配置文件
	lib:依赖项

2)配置

我们需要修改Seata的两个配置文件:registry.conf 和 file.conf
注册中心的配置:在${seata_home}/conf/目录中,一般是registry.conf文件
当前服务的配置,两种配置方式:
	通过分布式服务的统一配置中心,例如Zookeeper
	通过本地文件

我们先看registry.conf,内容是JSON风格

registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa,指定注册中心的类型,这里使用eureka
  type = "file"

  #原解压出来的有多种注册中心配置,这里保留两个
  eureka {
    serviceUrl = "http://localhost:8761/eureka"
    application = "seata_tc_server"
    weight = "1"
  }
  zk {
    cluster = "default"
    serverAddr = "127.0.0.1:2181"
    session.timeout = 6000
    connect.timeout = 2000
  }
}

config {
  # file、nacos 、apollo、zk、consul、etcd3,配置文件的方式
  type = "file"

  nacos {
    serverAddr = "localhost"
    namespace = ""
    group = "SEATA_GROUP"
  }
  zk {
    serverAddr = "127.0.0.1:2181"
    session.timeout = 6000
    connect.timeout = 2000
  }
  file {
    name = "file.conf"
  }
}

这个文件主要配置两个内容:

注册中心(registry)的类型及地址,本例我们选择eureka做注册中心
	eureka.serviceUrl:是eureka的地址,例如http://localhost:8761/eureka
	application:是TC注册到eureka时的服务名称,例如seata_tc_server
配置中心(config)的类型及地址,本例我们选择本地文件做配置,就是当前目录的file.conf文件

再来看file.conf文件:


## transaction log store, only used in seata-server
store {
  ## store mode: file、db
  mode = "file"

  ## file store property
  file {
    ## store location dir
    dir = "sessionStore"
    # branch session size , if exceeded first try compress lockkey, still exceeded throws exceptions
    maxBranchSessionSize = 16384
    # globe session size , if exceeded throws exceptions
    maxGlobalSessionSize = 512
    # file buffer size , if exceeded allocate new buffer
    fileWriteBufferCacheSize = 16384
    # when recover batch read size
    sessionReloadReadSize = 100
    # async, sync
    flushDiskMode = async
  }

  ## database store property
  db {
    ## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp) etc.
    datasource = "dbcp"
    ## mysql/oracle/h2/oceanbase etc.
    dbType = "mysql"
    driverClassName = "com.mysql.jdbc.Driver"
    url = "jdbc:mysql://127.0.0.1:3306/seata"
    user = "mysql"
    password = "mysql"
    minConn = 1
    maxConn = 10
    globalTable = "global_table"
    branchTable = "branch_table"
    lockTable = "lock_table"
    queryLimit = 100
  }
}
关键配置:
	store:TC的服务端数据存储配置
		mode:数据存储方式,支持两种:file和db
			file:将数据存储在本地文件中,性能比较好,但不支持水平扩展
			db:将数据保存在指定的数据库中,需要指定数据库连接信息
如果用文件作为存储介质,不需要其它配置了,直接运行即可。
但是如果使用db作为存储介质,还需要在数据库中创建3张表:
CREATE TABLE IF NOT EXISTS 'global table(
`xid` VARCHAR(128) NOT NULL, 
transaction id BIGINTstatus TINYINT NOT NULL, 
`application id` VARCHAR(32)transaction service group VARCHAR(32),
transaction name VARCHAR(128), 
timeout` INT, 
`begin_time BIGINT, 
`application data VARCHAR(2000), 
gmt_create DATETIME, 
gmt_modified DATETIME, 
PRIMARY KEY (`xid`),
KEYidx gmt modified status`(`gmt modified`, status`), KEY `idx transaction id` (`transaction id`))ENGINE =InnoDB
DEFAULT CHARSET = utf8;
-- the table to store Branchsession data CREATE TABLE IF NOT EXISTS `branch table(
branch id BIGINT NOT NULL, 
`xid` VARCHAR(128)NOT NULL, 
transaction id BIGINT, 
`resource group id` VARCHAR(32),
`resource id` VARCHAR(256), 
`branch_type VARCHAR(8), 
`status TINYINT, 
`client id` VARCHAR(64), 
application data` VARCHAR(2000), 
gmt_create DATETIME, 
gmt_modified` DATETIMEPRIMARY KEY (`branch id`), KEY'idx xid`(`xid`))ENGINE =InnoDB
DEFAULT CHARSET =utf8;
-- the table to store lock data
CREATE TABLE IF NOT EXISTS'lock table(
`row_key` VARCHAR(128) NOT NULL, 
`xid` VARCHAR(96), 
transaction id` BIGINT,
`branch id` BIGINT NOT NULL, 
resource id VARCHAR(256), 
table name VARCHAR(32), 
`pk VARCHAR(36), 
gmt_create` DATETIME, 
gmt modified` DATETIMEPRIMARY KEY (`row_key`),
KEY `idx branch id` (`branch id`))ENGINE =InnoDB
DEFAULT CHARSET = utf8;

3)启动

进入${seata_home}/bin/目录中:
	如果是linux环境(要有JRE),执行seata-server.sh
	如果是windows环境,执行seata-server.bat
4.4.2.4 改造Order服务
接下来是微服务的改造,不管是哪一个微服务,只要是事务的参与者,步骤基本一致。

1)引入依赖
我们在父工程seata-demo中已经对依赖做了管理:

<alibaba.seata.version>2.1.0.RELEASE</alibaba.seata.version>
<seata.version>1.1.0<seata.version>

因此,我们在项目order-service的pom文件中,引入依赖坐标即可:

<dependency>
	<groupId>com.alibaba.cloud</groupId>
	<artifactId>spring-cloud-alibaba-seata</artifactId>				 
	<version>${alibaba.seata.version}</version>
	<exclusions>
		<exclusion>
			<artifactId>seata-all</artifactid>
			<groupId>io.seata</groupId>
		</exclusion>	
	</exclusions>
</dependency>
<dependency>
	<artifactId>seata-all</artifactid>
	<groupId>io.seata</groupId>
	<version>${seata.version}</version>
</dependency>

这是在服务中引入了TM 和 RM

2)添加配置文件
首先在application.yml中添加一行配置:

spring: 
	cloud:
		alibaba: 
			seata:
				tx-service-group:test_tx_group

这里是定义事务组的名称,接下来会用到。
然后是在resources目录下放两个配置文件:file.conf和registry.conf
其中,registry.conf与TC服务端的一样,此处不再讲解。
我们来看下file.conf

transport {
   #tcp udt unix-domain-socket 
    type ="TCP"
   #NIO NATIVE 
    server = "NIO"
   #enable heartbeat 
    heartbeat = true
   # the client batch send request enable 
    enableclientBatchSendRequest = true
   #thread factory for netty 
    threadFactory{
        bossThreadPrefix ="NettyBoss"
        workerThreadPrefix ="NettyServerNIOWorker"
        serverExecutorThread-prefix = "NettyServerBizHandler" 
        shareBossWorker = false
        clientSelectorThreadPrefix ="NettyclientSelector' 
        clientselectorThreadsize =1
        clientWorkerThreadPrefix ="NettyclientWorkerThread"
       # netty boss thread size,will not be used for udt 
        bossThreadsize =1
       #auto default pin or 8
        workerThreadsize = "default"
    }
    shutdown{
        # when destroy server, wait seconds
         wait =3
    }
    serialization ="seata" 
    compressor = "none"
}
service {
    vgroup_mapping.test_tx_group = "seata_tc_server"
   #only support when registry.type=file, please don't set multiple addresses 
    seata tc server.grouplist = "127.0.0.1:8091"
   #degrade, current not support 
    enableDegrade = false
   #disable seata
    disableGlobalTransaction = false

client{ 
    rm {
        asyncCommitBufferLimit =10000 
        lock{
            retryInterval =10 
            retryTimes =30
            retryPolicyBranchRollbackOnConflict = true
        }
        reportRetryCount =5
        tableMetaCheckEnable = false 
        reportSuccessEnable = false
    }
    tm {
        commitRetryCount =5 
        rollbackRetryCount =5
    }
    undo {
        dataValidation = true
        logSerialization ="jackson" 
        logTable = "undo log"
    }
    log {
        exceptionRate =100
    }
}

配置解读:

transport:与TC交互的一些配置
	heartbeat:client和server通信心跳检测开关
	enableClientBatchSendRequest:客户端事务消息请求是否批量合并发送
service:TC的地址配置,用于获取TC的地址
	vgroup_mapping.test_tx_group = "seata_tc_server":
		test_tx_group:是事务组名称,要与application.yml中配置一致,
		seata_tc_server:是TC服务端在注册中心的id,将来通过注册中心获取TC地址
		enableDegrade:服务降级开关,默认关闭。如果开启,当业务重试多次失败后会放弃全局事务
		disableGlobalTransaction:全局事务开关,默认false。false为开启,true为关闭
	default.grouplist:这个当注册中心为file的时候,才用到
client:客户端配置
	rm:资源管理器配
		asynCommitBufferLimit:二阶段提交默认是异步执行,这里指定异步队列的大小
		lock:全局锁配置
			retryInterval:校验或占用全局锁重试间隔,默认10,单位毫秒
			retryTimes:校验或占用全局锁重试次数,默认30次
			retryPolicyBranchRollbackOnConflict:分支事务与其它全局回滚事务冲突时锁策略,默认true,优先释放本地锁让回滚成功
		reportRetryCount:一阶段结果上报TC失败后重试次数,默认5次
	tm:事务管理器配置
		commitRetryCount:一阶段全局提交结果上报TC重试次数,默认1
		rollbackRetryCount:一阶段全局回滚结果上报TC重试次数,默认1
	undo:undo_log的配置
		dataValidation:是否开启二阶段回滚镜像校验,默认true
		logSerialization:undo序列化方式,默认Jackson
		logTable:自定义undo表名,默认是undo_log
	log:日志配置
	exceptionRate:出现回滚异常时的日志记录频率,默认100,百分之一概率。回滚失败基本是脏
					数据,无需输出堆栈占用硬盘空间

3)代理DataSource
Seata的二阶段执行是通过拦截sql语句,分析语义来指定回滚策略,因此需要对DataSource做代理。我们在项目的cn.itcast.order.config包中,添加一个配置类:

package cn.itcast.order.config;

importcom.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean: import io.seata.rm.datasource.DataSourceProxy;
import org.apache.ibatis.session.salsessionFactory; import org.springframework.context.annotation.Bean;
importorg.springframework.context.annotation.Configuration;

import javax.sgl.DataSource;

@Configuration
public class DataSourceProxyConfig {
	@Bean
	public SqlsessionFactorysalsessionFactoryBean(DataSource dataSource)throws Exception {
		//订单服务中引入了mybatis-plus,所以要使用特殊的sqlsessionFactoryBean MybatisSqlsessionFactoryBean sqlsessionFactoryBean =new
		MybatisSalSessionFactoryBean();
		// 代理数据源
		sglsessionFactoryBean.setDataSource(newDataSourceProxy(dataSource))// 生成sq1sessionFactory
		return sglsessionFactoryBean.getObject()
}
	注意,这里因为订单服务使用了mybatis-plus这个框架(这是一个mybatis集成框架,自动生成单
表Sql),因此我们需要用mybatis-plus的MybatisSqlSessionFactoryBean代替SqlSessionFactoryBean
	如果用的是原生的mybatis,请使用SqlSessionFactoryBean。

4)添加事务注解

	给事务发起者order_service的OrderServiceImpl中的createOrder()方法添加@GlobalTransactional注解,开启全局事务:

在这里插入图片描述重启项目。

4.4.2.5 改造Storage、Account服务
与OrderService类似,这里也要经过下面的步骤:
	引入依赖:与order-service一致,略
	添加配置文件:与order-service一致,略
	代理DataSource,我们的storage-service和account-service都没有用mybatis-plus,所以配置要使用SqlSessionFactory:
package cn.itcast.order.config;

import io.seata.rm.datasource.DataSourceProxy;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.solSessionFactoryBean:
import org.springframework.context.annotation.Bean;
importorg.springframework.context.annotation.Configuration;

import javax.sal.DataSource;

@Configuration
public class DataSourceProxyConfig {

@Bean
publicsqlsessionFactorysqlsessionFactoryBean(DataSource dataSource)throws Exception {
	//因为使用的是mybatis,这里定义sqlsessionFactoryBean
	SqlsessionFactoryBeansq1SessionFactoryBean =new SqlSessionFactoryBean()// 配置数据源代理
	sglsessionFactoryBean.setDataSource(new DataSourceProxy(dataSource)); 
	return salsessionFactoryBean.getobject();}
}
另外,事务注解可以使用@Transactionnal,而不是@GlobalTransactional,事务发起者才需要添加@GlobalTransactional。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值