前言: 本地事务 & 分布式事务
本地事务
在单个数据库上执行的事务,可以利用关系型数据库本身提供的事务特性来实现事务管理。
事务的四大特性 ACID
1. 原子性:事务是一个不可分割的执行单元,要么全部执行成功,要么全部回滚。
2. 一致性:使数据库从一个一致性状态转变到另一个一致性状态。
3. 隔离性:事务的执行是相互独立的,互不干扰。
4. 持久性:事务的执行结果必须是持久化保存的,事务一旦提交,改变是永久的。
事务并发执行的问题
1. 丢失更新:[ 写-写 ]
2. 读脏数据:[ 写-读 ] 读到尚未提交的数据
3. 不可重复读:[ 读-写 ] 读到的是已提交的数据,只不过在两次读的过程中,数据被另一个事务修改了。
事务的四种隔离级别
1.「读未提交」一个事务读取到了另一个事务修改后但未提交的数据。(脏读)
2.「读已提交」使用排他锁X来对数据加锁。读到的是已提交的数据,只不过在两次读的过程中,数据被另一个事务修改了。(不可重复读)
3.「可重复读」使用共享锁S来对数据加锁,读事务结束后才释放锁。即读事务禁止写事务,但允许读事务;写事务禁止一切事务。
4.「串行化」使用排他锁X锁定整个数据表或某个范围的数据,所有事务必须串行执行。
本地事务并发控制的方法
1. 两段锁协议 2PL
2. 乐观并发控制 OCC
3. 时间戳法
4. 多版本并发控制 MVCC
在传统的单体应用中,多个不同的业务逻辑使用的是同一个数据库,可以依靠数据库来保证数据的一致性。
在微服务架构下,将庞大的单体应用分为多个微小的服务,每个服务各自使用不同的数据库,有各自的技术选型和业务边界,通过HTTP协议通信,如何保证不同数据库的数据一致性是微服务场景下分布式事务要关注的问题。
分布式系统会把一个应用拆分成多个可独立部署的服务,服务分别位于不同的节点之上,不同的服务之间通过网络远程协作完成事务操作。
分布式事务
指跨越多个分布式系统的事务,涉及到多个独立的参与者和资源,需要保证多个参与者之间的操作要么全部成功,要么全部失败。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。
本地事务的解决方案为什么不能作用于分布式事务
在分布式环境中,每个独立的服务都有自己的本地事务管理机制。由于网络延迟、节点故障、通信失败等原因,导致分布式事务无法像单个数据库中的事务那样满足ACID。
CAP定理+BASE理论
分布式事务理论:CAP定理 + BASE理论
1.「 CAP定理 」
指在一个分布式系统中不可能同时满足一致性C、可用性A、分区容错性P,三者不可兼得。
「 一致性 consistency 」分布式系统中的所有数据备份在同一时刻值相同。
「 可用性 availability」在部分节点故障后,集群整体仍能响应客户端的读写请求。
「 分区容错性 partition tolerance」系统中的节点之间由于网络问题导致无法相互通信,即在网络分区的情况下,仍能保持正常运行。
现实情况下,我们面对的是一个不可靠的网络、有一定概率宕机的设备,因此分布式事务满足分区容错性是一个必选项,而不是可选项。
对于分布式系统,CAP理论更合适的描述是在满足分区容错性的前提下,没有算法能同时满足数据一致性和服务可用性。因此,需要在C和A之间进行取舍。
CP架构(刚性事务):如果要满足数据的强一致性,就必须在一个服务的数据资源锁定时,对分布式系统中其它服务的数据资源同时锁定,等待全部服务处理完业务,才可以释放资源。此时如果有请求想要操作被锁定的资源就会被阻塞,这样就满足了CP,达到了强一致性和弱可用性。
AP架构(柔性事务):如果要满足服务的强可用性,每个服务就可以各自独立执行本地事务,而无需相互锁定其它服务的资源。在各个服务的事务尚未完全处理完毕时,如果去访问数据库,可能会遇到各个节点数据不一致的情况。需要一些措施,使得经过一段时间后,各个节点的数据最终达到一致性,这样就满足了AP,达到了弱一致性(最终一致性)和强可用性。
2.「 BASE理论 」
BASE是对CAP中一致性和可用性权衡的结果,通过牺牲强一致性来获得高可用性。
核心思想:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采取适当的方式来使系统达到最终一致性。
「 基本可用 Basically Availability」分布式系统在出现故障时,保证核心功能是可用的,允许部分功能适当的降低响应时间,甚至是服务降级。
「 软状态 Soft State」允许系统中的数据存在中间状态,即系统在不同节点的数据副本之间进行数据同步的过程中存在延迟。
「 最终一致性 Eventially Consistent」所有的数据副本在经过一段时间的同步之后,最终都能达到一致性的状态。
满足BASE理论的事务,称之为柔性事务。
分布式事务解决方案分类
分布式事务解决方案比较
XA | AT | TCC | Saga | |
数据一致性 | 强一致性 | 最终一致性 | ||
隔离性 | 完全隔离 | 基于全局锁隔离 | 基于资源预留隔离 | 无隔离 |
代码入侵性 | 无 | 有 | ||
提交方式 | 等到所有的参与者都成功了才去提交 | 本地事务直接提交 | ||
特点 | 基于数据库的XA协议来实现2PC 需要数据库本身支持XA协议 跨数据库调用 A服务mysql B服务Oracel | 增加undo_log表 业务数据和undo_log在同一个本地事务中提交。 生成反向SQL回滚 | 是应用层的2PC。 每个业务操作都要实现try、confirm、cancel 三个接口。 | 基于状态机实现 需要一个json文件用于调度。 本地直接提交事务,无锁。 成功就继续,失败则通过反向补偿来回滚。 |
优点 | 强一致性 在数据库层实现的事务,对业务没有入侵 主流数据库都支持XA | 减少了对业务代码的入侵度。 通过@GlobalTransactional注解来标记需要参与到全局事务的方法。 事务失败会自动回滚。 | 性能提升:根据具体的业务,控制资源锁的粒度,不会锁定整资源。 可靠性:解决了XA模式的协调者单点故障,由业务方发起并控制整个事务活动。 | 异步执行。 长事务解决方案 |
缺点 | 依赖本地事务 同步阻塞,死锁出现的概率大。 性能差,协调者需要等待所有参与者的响应导致延迟。 存在单点故障 | 通过对sql解析来完成的,对sql语法的支持有限。 目前不支持复合主键。 | 对业务代码的入侵性较强。 3个接口需要通过编码实现。 必要时可能会修改数据库。 | 对业务代码的入侵性较强。 需要编写状态机和补偿业务。 不能保证事务隔离性 |
性能 | 差 | 中 | 较好 | 好 |
场景 | 对一致性、隔离性有高要求的业务。 需要数据库本身支持XA协议 | 只支持基于ACID事务的关系型数据库。 Java应用通过JDBC访问数据库。 | 适用于简单业务和高并发场景。 对性能要求较高的短业务流程。 有非关系型数据库参与的事务。 | 长事务解决方案 适用于跨越多个服务的长业务流程。 对可扩展性要求较高的复杂业务场景。 |
分布式事务框架Seata
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。Seata 默认是AT模式。
Seata 术语
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
可以理解为 服务端seata-server
TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
可以理解为 客户端 business
RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
可以理解为 数据库
一个分布式的全局事务,整体是「两阶段提交」模型。全局事务是由若干分支事务组成的,分支事务要满足两阶段提交模型的要求:
-
一阶段 prepare 行为
-
二阶段 commit 或 rollback 行为
根据两阶段行为模式的不同,Seata将分支事务划分为「AT模式」和「TCC模式」。
「AT模式」基于支持本地ACID事务的关系型数据库:
-
一阶段 prepare 行为:业务数据和回滚日志记录在同一个本地事务中提交。
-
二阶段 commit 行为:马上成功结束,自动 异步批量清理回滚日志。
-
二阶段 rollback 行为:通过回滚日志,自动 生成补偿操作,完成数据回滚。
「TCC模式」不依赖于底层数据资源的事务支持:
-
一阶段 prepare 行为:调用 自定义 的try逻辑。
-
二阶段 commit 行为:调用 自定义 的commit逻辑。
-
二阶段 rollback 行为:调用 自定义 的cancel逻辑。
1. seata之「 AT模式」
使用前提
1. 基于支持本地ACID事务的关系型数据库。
2. Java应用,通过JDBC访问数据库。
基本原理
通过在每个参与者的本地事务中实现事务的原子性和隔离性,来保证分布式事务的一致性。
整体机制
两阶段提交协议的演变,以修改products表中的有效期period为例,说明AT机制。
pid | pname | period |
8a57 | 手机 | 2022 |
62t3 | 电脑 | 2025 |
No1. 一阶段
写隔离:业务数据和回滚日志记录在同一个本地事务中。本地事务提交前,先向TC申请全局锁global lock,如果拿不到全局锁,不能提交本地事务。如果能拿到全局锁,提交本地事务,释放本地锁local lock和连接资源,并将本地事务提交的结果上报给TC。
读隔离:在数据库本地事务隔离级别是读已提交或以上的基础上,Seata的AT模式默认全局隔离级别是读未提交。如果在特定场景下,必须要求全局的读已提交,目前seata是通过select for update语句的执行申请全局锁。
如下图是来自官网的解释:tx1在二阶段完成全局提交后,才能释放全局锁,此时tx2拿到全局锁提交本地事务。
具体的步骤
1. 解析SQL:得到sql的类型(update) ,表(products),条件(where pid='8a57')。
2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据,得到前镜像。
select pid,pname,period from products where pid = '8a57';
pid | pname | period |
8a57 | 手机 | 2022 |
3. 执行业务SQL:更新这条记录的period为‘2024’。
4. 查询后镜像:根据前镜像的结果,通过主键定位数据,得到后镜像。
pid | pname | period |
8a57 | 手机 | 2024 |
5. 插入回滚日志:将前后镜像数据、业务SQL信息组成一条记录插入到undo_log表。
6. 本地事务提交前向TC注册分支:申请products表中主键值为8a57的记录的全局锁。
7. 本地事务提交:业务数据的更新和undo_log一并提交。
8. 将本地事务提交的结果上报给TC。
{
"branchId": 641789253,
"undoItems": [{
"afterImage": {
"rows": [{
"fields": [{
"name": "pid",
"type": 4,
"value": "8a57"
}, {
"name": "pname",
"type": 12,
"value": "手机"
}, {
"name": "period",
"type": 12,
"value": "2024"
}]
}],
"tableName": "products"
},
"beforeImage": {
"rows": [{
"fields": [{
"name": "pid",
"type": 4,
"value": "8a57"
}, {
"name": "pname",
"type": 12,
"value": "手机"
}, {
"name": "period",
"type": 12,
"value": "2022"
}]
}],
"tableName": "products"
},
"sqlType": "UPDATE"
}],
"xid": "xid:xxx"
}
No2. 二阶段 — 提交
1. RM收到TC的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给TC,释放全局锁。
2. 异步任务阶段的分支提交请求,将异步和批量的删除相应undo_log记录。
No2. 二阶段 — 回滚
1. RM收到TC的分支回滚请求,开启一个本地事务,重新获取该数据的本地锁,进行反向补偿,实现分支的回滚。
2. 通过 xid 和 branchId 查找到相应的undo_log记录。
3. 拿undo_log中的后镜像与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作修改了,这种情况需要根据配置策略来做处理。
4. 根据undo_log中的前镜像和业务SQL的相关信息生成并执行回滚的语句。
5. 提交本地事务,并把本地事务的回滚结果上报给TC。
2. seata之「 TCC模式」
TCC (Try、Commit、Cancel) 模型是应用层的 2PC,实现的是业务层面的事务。
核心思想是针对每个业务操作都要实现 try、confirm、cancel 三个接口。
TCC的3个操作需要通过业务逻辑编码来实现,对应用的入侵性强,业务耦合度较高。
一. Try阶段
1. 库存服务
检查库存是否能被扣减,假设下单量是15,库存是10,显然不能扣减。
若下单量是2,则在库存冻结表中插入一条扣减记录,冻结库存 = 下单数量。
更新库存表中的库存,库存 = 库存 - 冻结库存
2. 订单服务
将订单数据库中订单的状态更新为“待支付”。 1-待支付 2-已支付 3-已取消
3. 积分服务
检查用户是否满足获得积分的条件,将要增加的积分单独写入积分表的冻结积分字段中。
更新用户当前的积分,积分 = 积分 + 冻结积分,假设用户当前积分为100,需要增加20个积分,则当前积分为120,冻结积分为20。
二. Confirm 阶段
1. 库存服务
根据用户id+商品id去删除冻结表中的扣减记录。
2. 订单服务
将订单数据库中订单的状态更新为“已支付”。
3. 积分服务
修改冻结积分,将冻结积分更新为0
三. Cancel 阶段
1. 库存服务
查询冻结表中的冻结库存,更新库存表中的库存,stock = stock + freeze_stock
根据用户id+商品id去删除冻结表中的扣减记录。
2. 订单服务
将订单数据库中订单的状态更新为“已取消”。
3. 积分服务
更新积分表中的积分,积分 = 积分 - 冻结积分
随后将冻结积分更新为0
需要注意:
1. 幂等问题
在阶段2中由于网络原因或重试机制,可能导致confirm或cancel两个操作的重复执行。
解决思路:分支事务记录表+分布式锁 可以防止资源的重复使用或重复释放造成的业务故障。当你查的时候没执行,查完了也执行完了,需要分布式锁。
2. 空回滚
在未调用 try 操作的情况下,执行了 cancel。
解决思路:需要增加一张分支事务记录表,在 cancel 接口中读取记录,判断 try 是否执行。
3. 资源悬挂
执行 try 时由于网络拥堵造成了超时,TM会通知RM回滚事务,在cancel完成后,try请求才到达RM并执行,导致try阶段预留的资源一直无法释放。
解决思路:需要增加一张分支事务记录表,在 try 接口中读取记录,判断 二阶段 是否执行。
3. seata之「 Saga模式」
Saga模式是长事务解决方案,把分布式事务看作一组本地事务构成的事务链,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败,则补偿前面已经成功的参与者。
-
一阶段:正向服务
-
二阶段:补偿服务
正向服务和反向补偿都需由业务开发实现。
假设一个 Saga 的分布式事务链有 n 个分支事务构成,[T1,T2,...,Tn],那么该分布式事务的执行情况有三种:
「n个事务全部执行成功」T1,T2,...,Tn
「执行到第i(i<n)个事务失败,反向补偿」按逆序依次调用补偿操作,如果补偿失败了,就一直重试 [ T1,T2,..., Ti ] [ Ci,...,C2,C1 ] T1~Tn就是“正向调用”,C1~Cn是“补偿调用”。
「发生失败一直重试」适用于事务必须成功的场景,如果发生失败了就一直重试,不会执行补偿操作,T1,T2,..., Ti (失败),Ti (重试),Ti (重试),...,Tn
目前seata提供的Saga模式是「基于状态机引擎」实现的。
1. 通过状态图来定义服务调用的流程,并生成 json 状态语言定义文件。
2. 状态图中一个节点可以是调用一个服务,节点可以配置它的补偿节点。
3. 状态图 json 由状态机引擎驱动执行,当出现异常时状态引擎反向执行已成功节点对应的补偿节点将事务回滚。
4. 可以实现服务编排需求,支持单项选择、并发、子流程、参数转换、参数映射、服务执行状态判断、异常捕获等功能。
业务场景举例
假设在某网站上有一个长事务流程:
机票预订—> 租车 —> 酒店预订 —> 支付
以上4个事务涉及到4个服务
每一个本地事务在更新完本地数据库之后,会发布一条消息/事件,触发 Saga 中的下一个本地事务的执行。有两种方式实现:
(1) 协同式:基于事件。Saga 的决策和执行顺序逻辑分布在每个 Saga 的参与方,通过交换事件方式来进行沟通。
(2) 编排式:基于命令。Saga 的决策和执行顺序逻辑集中在一个Saga 编排器中,编排器发出命令给 Saga 的参与方,指示应该执行哪一个本地事务。
4. seata之「 XA模式」
XA使用「二阶段提交」(2PC:Two-Phase-Commit)来保证分布式事务的完整性。
XA模式中,分布式事务是构建在 RM 本地事务的基础上,TM 负责协调这些本地事务要么都成功提交、要么都回滚。
「表决阶段」事务管理器向所有参与者发送prepare消息,每个数据库参与者为资源加锁,在本地执行事务,完成undo_log和redo_log的写入,但不提交事务。
「执行阶段」事务管理器收到了参与者执行失败或超时消息,给每个参与者发送回滚消息,否则发送提交commit消息。参与者根据事务管理器的指令执行提交或者回滚,并释放事务处理过程中使用的资源。
redo_log:按照时间顺序,记录了每个事务对数据库的修改操作。
undo_log:存放着修改之前的数据,即数据的历史版本。
2PC 二阶段提交
存在的问题
阻塞:对于任何一次指令必须收到明确的响应,才会继续做下一步,否则处于阻塞状态,占用的资源被一直锁定,不会被释放。
单点故障:如果协调者宕机,参与者没有了协调者指挥,会一直阻塞,尽管可以通过选举新的协调者替代原有协调者,但是如果之前协调者在发送一个提交指令后宕机,而提交指令仅仅被一个参与者接受,并且参与者接收后也宕机,新上任的协调者无法处理这种情况。
脑裂:协调者发送提交指令,有的参与者接收到执行了事务,有的参与者没有接收到事务,就没有执行事务,多个参与者之间是不一致的。
3PC 三阶段提交
三阶段提交协议(3PC 协议)是两阶段提交协议的改进版本。它通过超时机制解决了阻塞的问题,并且把两个阶段增加为三个阶段:询问阶段、准备阶段、提交阶段。
询问阶段:尽可能早的发现无法执行操作而需要中止的行为,但是它并不能发现所有的这种行为,只会减少这种情况的发生。
等待超时:如果在询问阶段等待超时,则自动中止;如果在准备阶段之后等待超时,则自动提交。
通知型事务处理方案
1. 异步确保型
主要适用于内部系统的数据最终一致性保障,因为内部相对比较可控,如订单和购物车、收货与清算、支付与结算等等场景。
2. 最大努力通知
主要用于外部系统,因为外部的网络环境更加复杂和不可信,所以只能尽最大努力去通知实现数据最终一致性,比如充值平台与运营商、支付对接等等跨网络系统级别对接。
1) 业务活动的主动方,在完成业务处理之后,向业务活动的被动方发送消息,允许消息丢失。
2) 主动方可以设置时间阶梯型通知规则,在通知失败后按规则重复通知,直到通知N次后不再通知。
3) 主动方提供校对查询接口,给被动方按需校对查询,用于恢复丢失的业务消息。
4) 业务活动的被动方如果正常接收了数据,就正常返回响应,并结束事务。
5) 如果被动方没有正常接收,根据定时策略,向业务活动主动方查询,恢复丢失的业务消息。
基于本地消息表
将分布式事务拆分成本地事务进行处理。
(1) 事务主动方:额外新建“事务消息表”,在本地事务中完成业务处理,并向本地消息表中写入一个事件。系统中启动一个定时任务,轮询本地消息表,将未完成的事件发布到消息队列MQ,如果发送失败或超时,则一直发送,直至成功。
(2) 事务被动方:订阅消息队列中的消息,并在本地完成业务处理,然后通过消息中间件,通知事务主动方已处理的消息。
(3) 事务主动方:接收消息中间件的消息,更新本地消息表,将该事务的状态标注为已完成