一、分布式事务背景
随着业务的快速发展,网站系统往往由单体架构逐渐演变为分布式、微服务架构,而对于数据库则由单机数据库架构向分布式数据库架构转变。此时,我们会将一个大的应用系统拆分为多个可以独立部署的应用服务,需要各个服务之间进行远程协作才能完成事务操作。
SAGA模式适用场景:
- 业务流程长、业务流程多
- 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口
优势:
- 一阶段提交本地事务,无锁,高性能
- 事件驱动架构,参与者可异步执行,高吞吐
- 补偿服务易于实现
缺点:
- 不保证隔离性(应对方案见后面文档)
二、IOP、seata及zeebe saga模式实现对比
实现方式 | 流程定义 | 服务端 | 数据持久化 | 控制台 | 流程持久化开关 | 流程节点大字段持久化开关 | 服务异步/同步 | 子流程 | 并行 | 异步节点 | 异常重试 | 中断节点(事件驱动) | 异常处理 | 应用宕机后流程恢复能力 | groovy脚本 | 热部署 | 对服务方的要求 | |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
IOP | 状态机 | BPMN2.0 | 无 | 数据库 | console | 支持,默认开启 | 支持,默认全部持久化(更新类节点建议全部持久化) | 异步+同步 | 支持 | 支持 | 支持 | 支持 | 支持 | 支持 | 需要手动恢复 | 支持 | 支持 | 幂等或提供补偿服务及状态查询服务 |
seata-saga | 状态机 | json | seata-server(TC事务协调者) | 数据库 | 支持,默认开启 | 不支持 | 异步+同步 | 支持 | 支持 | 支持 | 支持 | 不支持 | 支持 | seata server 会触发事务恢复 | 支持 | 支持 | 幂等或提供补偿服务及状态查询服务 | |
zeebe | 事件监听 | BPMN2.0 | broker | Elasticsearch | zeebe-camunda-operate | 不支持 | 异步 | 支持 | 支持 | 不支持 | 支持 | operator界面操作 | broker触发 | 不支持 | 支持 | 必须幂等 |
三、seata:
1.seata分为三个角色
1)TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,驱动全局事务提交或回滚。
2)TM (Transaction Manager) - 事务管理器:定义全局事务的范围,开始全局事务、提交或回滚全局事务。
3)RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
2.支持的事务模式
1) Seata AT 模式
2)Seata TCC 模式
3)SEATA Saga 模式
4)Seata XA 模式
3.SEATA Saga模式实现原理
SEATA提供的Saga模式是基于状态机引擎来实现的,机制是:
1)通过状态图来定义服务调用的流程并生成 json 状态语言定义文件
2)状态图中一个节点可以是调用一个服务,节点可以配置它的补偿节点
3)状态图 json 由状态机引擎驱动执行,当出现异常时状态引擎反向执行已成功节点对应的补偿节点将事务回滚
4)可以实现服务编排需求,支持单项选择、并发、子流程、参数转换、参数映射、服务执行状态判断、异常捕获等功能
注意: 异常发生时是否进行补偿也可由用户自定义决定
4.数据库表设计
1)seata_state_machine_def:存储状态机定义文件
CREATE TABLE IF NOT EXISTS `seata_state_machine_def`
(
`id` VARCHAR(32) NOT NULL COMMENT 'id',
`name` VARCHAR(128) NOT NULL COMMENT 'name',
`tenant_id` VARCHAR(32) NOT NULL COMMENT 'tenant id',
`app_name` VARCHAR(32) NOT NULL COMMENT 'application name',
`type` VARCHAR(20) COMMENT 'state language type',
`comment_` VARCHAR(255) COMMENT 'comment',
`ver` VARCHAR(16) NOT NULL COMMENT 'version',
`gmt_create` DATETIME(3) NOT NULL COMMENT 'create time',
`status` VARCHAR(2) NOT NULL COMMENT 'status(AC:active|IN:inactive)',
`content` TEXT COMMENT 'content',
`recover_strategy` VARCHAR(16) COMMENT 'transaction recover strategy(compensate|retry)',
PRIMARY KEY (`id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
2)seata_state_machine_inst:存储状态机运行实例
CREATE TABLE IF NOT EXISTS `seata_state_machine_inst`
(
`id` VARCHAR(128) NOT NULL COMMENT 'id',
`machine_id` VARCHAR(32) NOT NULL COMMENT 'state machine definition id',
`tenant_id` VARCHAR(32) NOT NULL COMMENT 'tenant id',
`parent_id` VARCHAR(128) COMMENT 'parent id',
`gmt_started` DATETIME(3) NOT NULL COMMENT 'start time',
`business_key` VARCHAR(48) COMMENT 'business key',
`start_params` TEXT COMMENT 'start parameters',
`gmt_end` DATETIME(3) COMMENT 'end time',
`excep` BLOB COMMENT 'exception',
`end_params` TEXT COMMENT 'end parameters',
`status` VARCHAR(2) COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`compensation_status` VARCHAR(2) COMMENT 'compensation status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`is_running` TINYINT(1) COMMENT 'is running(0 no|1 yes)',
`gmt_updated` DATETIME(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `unikey_buz_tenant` (`business_key`, `tenant_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
3)seata_state_inst:存储状态图中单个节点
CREATE TABLE IF NOT EXISTS `seata_state_inst`
(
`id` VARCHAR(48) NOT NULL COMMENT 'id',
`machine_inst_id` VARCHAR(128) NOT NULL COMMENT 'state machine instance id',
`name` VARCHAR(128) NOT NULL COMMENT 'state name',
`type` VARCHAR(20) COMMENT 'state type',
`service_name` VARCHAR(128) COMMENT 'service name',
`service_method` VARCHAR(128) COMMENT 'method name',
`service_type` VARCHAR(16) COMMENT 'service type',
`business_key` VARCHAR(48) COMMENT 'business key',
`state_id_compensated_for` VARCHAR(50) COMMENT 'state compensated for',
`state_id_retried_for` VARCHAR(50) COMMENT 'state retried for',
`gmt_started` DATETIME(3) NOT NULL COMMENT 'start time',
`is_for_update` TINYINT(1) COMMENT 'is service for update',
`input_params` TEXT COMMENT 'input parameters',
`output_params` TEXT COMMENT 'output parameters',
`status` VARCHAR(2) NOT NULL COMMENT 'status(SU succeed|FA failed|UN unknown|SK skipped|RU running)',
`excep` BLOB COMMENT 'exception',
`gmt_updated` DATETIME(3) COMMENT 'update time',
`gmt_end` DATETIME(3) COMMENT 'end time',
PRIMARY KEY (`id`, `machine_inst_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;
5.示例
1)我们已商品库存扣减及用户余额扣减为例
2)状态图如下状态图如下
3)流程图对应的json文件如下
{
"Name": "reduceInventoryAndBalance",
"Comment": "reduce inventory then reduce balance in a transaction",
"StartState": "ReduceInventory",
"Version": "0.0.1",
"States": {
"ReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryAction",
"ServiceMethod": "reduce",
"CompensateState": "CompensateReduceInventory",
"Next": "ChoiceState",
"Input": [
"$.[businessKey]",
"$.[count]"
],
"Output": {
"reduceInventoryResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
}
},
"ChoiceState":{
"Type": "Choice",
"Choices":[
{
"Expression":"[reduceInventoryResult] == true",
"Next":"ReduceBalance"
}
],
"Default":"Fail"
},
"ReduceBalance": {
"Type": "ServiceTask",
"ServiceName": "balanceAction",
"ServiceMethod": "reduce",
"CompensateState": "CompensateReduceBalance",
"Input": [
"$.[businessKey]",
"$.[amount]",
{
"throwException" : "$.[mockReduceBalanceFail]"
}
],
"Output": {
"compensateReduceBalanceResult": "$.#root"
},
"Status": {
"#root == true": "SU",
"#root == false": "FA",
"$Exception{java.lang.Throwable}": "UN"
},
"Catch": [
{
"Exceptions": [
"java.lang.Throwable"
],
"Next": "CompensationTrigger"
}
],
"Next": "Succeed"
},
"CompensateReduceInventory": {
"Type": "ServiceTask",
"ServiceName": "inventoryAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
]
},
"CompensateReduceBalance": {
"Type": "ServiceTask",
"ServiceName": "balanceAction",
"ServiceMethod": "compensateReduce",
"Input": [
"$.[businessKey]"
]
},
"CompensationTrigger": {
"Type": "CompensationTrigger",
"Next": "Fail"
},
"Succeed": {
"Type":"Succeed"
},
"Fail": {
"Type":"Fail",
"ErrorCode": "PURCHASE_FAILED",
"Message": "purchase failed"
}
}
}
4)"状态机" 属性简介:
- Comment: 状态机的描述
- Version: 状态机定义版本
- StartState: 启动时运行的第一个"状态"
- States: 状态列表,是一个map结构,key是"状态"的名称,在状态机内必须唯一
5)"状态" 属性简介:
- Type: "状态" 的类型,比如有:
- ServiceTask: 执行调用服务任务
- Choice: 单条件选择路由
- CompensationTrigger: 触发补偿流程
- Succeed: 状态机正常结束
- Fail: 状态机异常结束
- SubStateMachine: 调用子状态机
- CompensateSubMachine: 用于补偿一个子状态机
- ServiceName: 服务名称,通常是服务的beanId
- ServiceMethod: 服务方法名称
- CompensateState: 该"状态"的补偿"状态"
- Input: 调用服务的输入参数列表, 是一个数组, 对应于服务方法的参数列表, $.表示使用表达式从状态机上下文中取参数,表达使用的SpringEL, 如果是常量直接写值即可
- Ouput: 将服务返回的参数赋值到状态机上下文中, 是一个map结构,key为放入到状态机上文时的key(状态机上下文也是一个map),value中$.是表示SpringEL表达式,表示从服务的返回参数中取值,#root表示服务的整个返回参数
- Status: 服务执行状态映射,框架定义了三个状态,SU 成功、FA 失败、UN 未知, 我们需要把服务执行的状态映射成这三个状态,帮助框架判断整个事务的一致性,是一个map结构,key是条件表达式,一般是取服务的返回值或抛出的异常进行判断,默认是SpringEL表达式判断服务返回参数,带$Exception{开头表示判断异常类型。value是当这个条件表达式成立时则将服务执行状态映射成这个值
- Catch: 捕获到异常后的路由
- Next: 服务执行完成后下一个执行的"状态"
- Choices: Choice类型的"状态"里, 可选的分支列表, 分支中的Expression为SpringEL表达式, Next为当表达式成立时执行的下一个"状态"
- ErrorCode: Fail类型"状态"的错误码
- Message: Fail类型"状态"的错误信息
四、zeebe:
1.Zeebe架构主要包含4大组件:client、gateway、broker、exporter
1)client
- 部署工作流(deploy workflows)
- 执行业务逻辑(carry out business logic)
创建工作流实例(start workflow instances)
发布消息(publish messages)
激活任务(activate jobs)
完成任务(complete jobs)
失败任务(fail jobs)
- 处理运维问题(handle operational issues)
- 更新实例流程变量(update workflow instance variables)解决异常(resolve incidents)
客户端程序可以完全独立于Zeebe扩缩容 - Zeebe brokers不执行任何业务逻辑。
客户端是嵌入到应用程序(执行业务逻辑的微服务)的库,用于跟Zeebe集群连接通信。
客户端通过基于HTTP/2协议的gRPC与Zeebe gateway连接。
Zeebe官方提供了Java和Go客户端。社区提供了C#,Ruby,Java客户端实现。gRPC协议很方便生成其他语言的客户端。
Client中,执行单独任务的单元叫JobWorker。
2)gateway
gateway作为Zeebe集群的入口,转发请求到brokers,gateway是无状态和无会话的,可以根据需要添加网关以实现负载平衡和高可用性。
3)broker
broker是分布式的流程引擎,维护运行中流程实例的状态。broker可以分区以实现横向扩容、副本以实现容错。通常情况下,Zeebe部署通常将由多个实例组成。
需要重点强调的是,broker不包含任何业务逻辑,它只负责:
- 处理客户端发送的指令
- 存储和管理运行中流程实例的状态
- 分配任务给job workers
broke形成一个对等网络(peer-to-peer),这样集群不会有单点故障。集群中所有节点都承担相同的职责,所以一个节点不可用后,节点的任务会被透明的重新分配到网络中其他节点。
4)exporter
exporter系统提供zeebe内状态变化的事件流。这些事件流数据有很多潜在用处,包括但不限于:
- 监控当前运行流程实例的状态
- 分析历史的工作流数据以做审计或BI
- 跟踪zeebe抛出的异常(incident)
exporter提供了简洁的API,可以流式导出数据到任何存储系统。zeebe官方提供开箱即用的Elasticsearch exporter,社区也提供了其他exporter。
2.broker集群
Zeebe可以作为broker集群运行,形成对等网络。在这个网络中,所有broker都有相同的职责,没有单点故障。
为了确保容错能力,Zeebe使用Raft协议跨机器复制数据。
数据分为多个分区(碎片)。每个分区都有许多副本。在副本集中,领导者是由筏协议确定的,筏协议接收请求并执行所有处理。所有其他经纪人都是被动的追随者。当领导者不可用时,关注者会透明地选 择一个新的领导者。
对于不同的分区,集群中的每个代理可以同时是领导者和跟随者。在理想的情况下,这会导致客户端流量在所有代理之间平均分配。
但是请注意,跨分区之间没有活动的负载平衡。任何分区的领导者选举都是完全自治的,并且独立于其他分区的领导者选举。在最坏的情况下,这可能导致一个节点成为所有分区的领导者。对于容错来说这 不是问题,因为复制保证仍然有效。但是,由于所有流量都到达一个节点,因此可能会对吞吐量产生负面影响。
3.broker内部流处理模型
Zeebe内部实现,其实就是一系列作用在记录流(record streams)上的流处理器(stream processors)。流处理模型作为一个统一的实现方式,提供:
- 指令协议(command protocol,即请求响应)
- 记录导出(record export / streaming)
- 工作流演算(evaluation, 异步后台任务)
4.处理背压
当代理接收到客户端请求时,它将首先写入事件流,然后由流处理器处理。如果处理速度很慢,或者流中有许多客户端请求,则处理器开始处理命令的时间可能太长。如果代理继续接受来自客户端的新请求,则待办事项日志会增加,并且处理延迟可能会超过可接受的时间。为了避免此类问题,Zeebe采用了背压机制。当代理收到的请求数量超出其可以在可接受的延迟范围内处理的请求时,它将拒绝某些请求。
代理可以处理的最大请求速率取决于计算机的处理能力,网络延迟,系统的当前负载等。因此,在Zeebe中没有为其接收的最大请求速率配置固定限制。相反,Zeebe使用自适应算法来动态确定运行中请求(代理已接受但尚未处理的请求)数量的限制。接受请求时,机上请求计数增加,而将响应发送回客户端时,飞行中请求计数减少。当机上请求计数达到限制时,代理将拒绝请求。
当代理由于背压而拒绝请求时,客户端可以使用适当的重试策略重试它们。如果拒绝率很高,则表明经纪人一直处于高负荷状态。在这种情况下,建议降低请求率。
五、Saga 服务设计的实践经验
允许空补偿
- 空补偿:原服务未执行,补偿服务执行了
- 出现原因:
- 原服务 超时(丢包)
- Saga 事务触发 回滚
- 未收到 原服务请求,先收到 补偿请求
所以服务设计时需要允许空补偿, 即没有找到要补偿的业务主键时返回补偿成功并将原业务主键记录下来
防悬挂控制
- 悬挂:补偿服务 比 原服务 先执行
- 出现原因:
- 原服务 超时(拥堵)
- Saga 事务回滚,触发 回滚
- 拥堵的 原服务 到达
所以要检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝服务的执行
幂等控制
- 原服务与补偿服务都需要保证幂等性, 由于网络可能超时, 可以设置重试策略,重试发生时要通过幂等控制避免业务数据重复更新
缺乏隔离性的应对
- 由于 Saga 事务不保证隔离性, 在极端情况下可能由于脏写无法完成回滚操作, 比如举一个极端的例子, 分布式事务内先给用户A充值, 然后给用户B扣减余额, 如果在给A用户充值成功, 在事务提交以前, A用户把余额消费掉了, 如果事务发生回滚, 这时则没有办法进行补偿了。这就是缺乏隔离性造成的典型的问题, 实践中一般的应对方法是:
- 业务流程设计时遵循“宁可长款, 不可短款”的原则, 长款意思是客户少了钱机构多了钱, 以机构信誉可以给客户退款, 反之则是短款, 少的钱可能追不回来了。所以在业务流程设计上一定是先扣款。
- 有些业务场景可以允许让业务最终成功, 在回滚不了的情况下可以继续重试完成后面的流程, 所以状态机引擎除了提供“回滚”能力还需要提供“向前”恢复上下文继续执行的能力, 让业务最终执行成功, 达到最终一致性的目的。