分布式事务解决方案到底有哪些

在系统是单体架构时,系统是和单个数据库进行交互,所以如果有多表操作的时候,可以使用数据库的事务实现数据的一致性,这种事务可以称之为 本地事务 。随着业务的发展,系统的压力越来越大,单体数据库的性能也达到了瓶颈,不可避免的进行数据库的拆分,还有系统模块的拆分,跨服务、跨数据库的事务场景就越来越多,这样解决分布式的事务的需求就出现了。

有需求就要解决啊!我们程序员就是用来解决问题,实现需求的。之前的文章已经说了,目前已经存在好多种解决分布式事务的方案了,今天我们来说说其中几种比较有代表性的方案。

两阶段提交协议(2PC)

两阶段提交协议(2PC,two phase commit protcol),是基于数据库资源层面的。把分布式事务分为两个阶段,一个是准备阶段,另外一个是提交阶段准备阶段和提交阶段都是由事务管理器(也称作协调者)发起,还有一个角色就是参与者。两阶段提交协议的流程如下:

  • 准备阶段:协调者向参与者发起指令,参与者评估自己的资源,如果参与者评估指令能完成,则会写redo、undo日志,然后锁定资源、执行操作但是不提交。

  • 提交阶段:如果每个参与者都明确的返回成功,也就是意味着资源锁定、执行操作成功,则协调者向各个参与者发起提交指令,参与者提交操作、释放锁定资源;如果有参与者在上述的两个步骤中有明确返回失败,也就是说资源锁定或者执行操作失败,则协调者向各个参与者发布中止指令,参与者执行undo日志,释放锁定的资源。

在最开始的准备阶段就锁定资源,这是一个重量级的操作,可以保证强一致性。但是实现起来却有很多的缺点:

  • 阻塞,没有超时机制。如果在整个流程中,任何一个参与者或者协调者由于可能的网络延迟问题,导致协调者的指令不能发出或者参与者接受不到指令,整个流程也不能继续下去,且资源一直被锁定。

  • 协调者有单点问题。协调者发出指令之后宕机,整个流程无法继续进行。

  • 会有数据不一致的问题。协调者发出提交指令,部分参与者接受到了指令,执行了所有操作,但是有参与者宕机了,无法执行提交操作。

三阶段提交协议(3PC)

针对两阶段提交协议(2PC)的缺点进行改进提出三阶段提交协议(3PC,three phase commit protcol),也是基于数据库资源层面的。增加了一个询问的阶段,增加协调者、参与者超时机制,一旦发生超时则默认提交事务,其他的步骤就和2PC相同。

  • 询问阶段:协调者想参与者询问能否完成指令,参与者只需要回答是和否,无需做其他的操作。

  • 准备阶段:协调者向参与者发起指令,参与者评估自己的资源,如果参与者评估指令能完成,则会写redo、undo日志,然后锁定资源、执行操作但是不提交。如果有参与者在询问阶段回答否,则协调者向参与者发送中止请求。

  • 提交阶段:如果每个参与者都明确的返回成功,也就是意味着资源锁定、执行操作成功,则协调者向各个参与者发起提交指令,参与者提交操作、释放锁定资源;如果有参与者在上述的两个步骤中有明确返回失败,也就是说资源锁定或者执行操作失败,则协调者向各个参与者发布中止指令,参与者执行undo日志,释放锁定的资源。

三阶段提交协议增加了询问阶段,这样可以确保尽可能早的发现参与者无法进行准备操作,但是不能完全避免这种情况,增加了超时机制可以减少资源的锁定时间。但是仍然会有数据不一致的问题。假设在提交阶段,协调者发出中止命令,由于发生网络分区等问题,部分参与者没有接受到命令则按照超时默认提交事务的规则,导致部分参与者回滚了事务,部分参与者提交了事务,数据一致性被破坏。(其实你就是超时默认中止操作还是会发生数据不一致的情况,真是太难啦:smile:)

TCC(Try-Confirm-Cancel)

其实大家看了2PC和3PC的执行流程,就可以感觉到他们的实现,会出现资源阻塞、数据不一致的问题,性能效率也不高。在实际项目中也少有使用2PC和3PC实现分布式事务。后来又有大神提出TCC(Try-Confirm-Cancel)协议,协议是基于业务层面实现。这个协议将任务拆分成Try、Confirm、Cancel三个阶段,每个阶段都要保证各自操作幂等。

  • Try(预处理阶段):参与者完成所有业务检查(一致性),预留业务资源(准隔离性),所有参与者预留都成功,try阶段才算成功。此阶段仅是一个初步操作,它和后续的Confirm 一起才能真正构成一个完整的业务逻辑。

这个预留就是说用户在下订单使用了50积分抵扣金额,我们给积分增加一种冻结的状态,直接把使用的50积分状态置为冻结状态,在订单未完成支付之前用户查看自己的总积分没有减少,但是可用来支付的积分少了50,这样就不会一直占用资源,更新完50积分的状态就是释放了资源。

  • Confirm(确认阶段):确认执行业务操作,不做任何业务检查,只使用Try阶段预留的业务资源。通常情况下,采用TCC则认为 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。若Confirm阶段真的出错了,需引入重试机制或人工处理。

  • Cancel(取消阶段):取消Try阶段预留的业务资源。如果某个业务资源没有预留成功,则取消所有业务资源预留请求。通常情况下,采用TCC则认为Cancel阶段也是一定成功的。若Cancel阶段真的出错了,需引入重试机制或人工处理。

2PC(3PC)是追求的数据的强一致性,是一种强一致性事务,而TCC在Confirm、Cancel阶段允许重试,这就意味着数据在一段时间内一致性被破坏,TCC符合BASE理论则可称作一种柔性事务。

如果拿TCC事务的处理流程与2PC两阶段提交做比较,2PC通常都是在跨库的DB层面实现,而TCC在应用层处理,通过业务逻辑来实现。这种分布式事务的实现方式的优势可以让应用自己定义数据操作的粒度,使得降低资源锁冲突、提高系统吞吐量成为可能。TCC的不足之处则在于对应用的代码侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作,增大了开发工作量。

目前业界已经有很多开源的TCC协议的分布式事务框架,例如Hmily、ByteTCC、TCC-transaction。使用这些框架就可以很大程度上节约时间,将更多的时间和注意力放到到具体业务中。

可靠消息最终一致性方案

可靠消息方案通过消息生产、消息存储、消息投递三个阶段的可靠性,实现最终数据一致性,这种方案又有两种实现方式,一种是基于本地消息表实现,另外一种是基于事务消息实现。其实这种本地消息表和消息队列方案不冲突,因为消息队列的消息能够100%投递不丢失也可以用本地消息表实现。

本地消息表方案

通过本地消息表实现分布式事务,是一种 最简单、最简便 的实现方式。它的 核心思想就是讲分布式事务拆分成本地事务进行处理,用数据库的事务ACID特性保证数据一致性 。还有再提一句这种实现思想是eBay里的大牛提出来的,前文说到的BASE理论也是同一家公司的人提出的,不得不赞叹eBay公司里大牛可真是多啊!

简版本地消息表方案

在这里我解释我所说的本地消息表方案中,是可以没有消息队列中间件的,这个可能和网上很多的说法不一样。因为考虑实际的情况,两个系统之间交互如果不存在高并发、大流量,以后也不会出现太多的业务耦合,引入消息中间件就会太过了,不仅提升了系统的复杂度,也增加了额外的中间件维护成功。所以解决这种情况最简单的方式就是使用http通信、异常用定时任务补偿即可。

这里我先举例说明我上面说的最简单的方式,之前我们的系统要从飞猪上进行会员引流,其中有这样一个需求,如果一个用户是我们系统的会员同时也是飞猪的会员,若这个用户在我们系统的等级发生了改变,则通知飞猪此用户的等级发生变动,然后用户在飞猪APP上查看会员信息的时候,飞猪重新调用一下我们系统的会员信息查询接口。你说对于这种需求用消息队列实现是不是有点大材小用了,最终的开发方案如下:

业务本地消息表设计

CREATE TABLE `biz_local_message` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`biz_module` tinyint(4) unsigned NOT NULL COMMENT '业务模块 1订单  2支付  4等等',
`biz_no` char(64) NOT NULL COMMENT '业务单号,唯一标识',
`biz_type` tinyint(4) unsigned NOT NULL COMMENT '业务类型',
`msg` varchar(512) NOT NULL DEFAULT '' COMMENT '消息内容',
`msg_desc` varchar(64) DEFAULT NULL COMMENT '消息描述',
`backoff_second` int(10) unsigned NOT NULL DEFAULT '180' COMMENT '退避秒数,默认3分钟,用来计算next_handle_time时间',
`handle_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '已经尝试处理次数',
`max_handle_count` tinyint(4) NOT NULL DEFAULT '5' COMMENT '最大尝试处理次数',
`next_handle_time` datetime DEFAULT NULL COMMENT '下一次处理时间',
`handle_status` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '记录处理状态 1待处理  2处理中  4处理成功  8处理失败',
`create_time` datetime NOT NULL COMMENT '创建时间',
`create_user` varchar(32) NOT NULL DEFAULT 'admin' COMMENT '创建人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`update_user` varchar(32) NOT NULL DEFAULT 'admin' COMMENT '更新人',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_bizNoBizMoudleBizType` (`biz_no`,`biz_module`,`biz_type`) USING BTREE,
KEY `idx_nextHandleTime` (`next_handle_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务本地消息'
  • 保证biz_local_message表数据跟业务流程在一个事务中,一起成功写入数据库。

这里的业务模块biz_module就是会员,业务单号biz_no可以用uuid,消息内容就是要推送的消息json保存。开启一个异步线程进行消息推送,推送前将消息的状态handle_status从1待处理设置成2处理中、处理次数 handle_count 值加1、由backoff_second字段和当前时间计算出下次推送时间设置next_handle_time字段,SQL更新记录影响行数affectRow 返回1才继续处理,更新失败则直接return。

  • 定时任务补偿,处理出现推送异常的消息。定时任务的执行频率可以根据自己的业务需要自行设定,我这里当时设定的是每5分钟执行一次。

总体上是先进行异常处理,然后再处理异步线程可能没有推送的消息。

第一步将 where handle_status=2 AND handle_count = max_handle_count AND next_handle_time <= now() 的记录状态handle_status更新为8处理失败 、next_handle_time字段清空,然后进入人工处理流程。

第二步将 where handle_status=2 AND handle_count < max_handle_count AND next_handle_time <= now() 则直接把这些记录的 handle_status 状态更新成 1待处理 、next_handle_time字段清空。

第三步将 where handle_status= 1 的记录分页处理。

  1. 处理前将消息的状态从1待处理 更新成 2处理中、处理次数 handle_count 值加1、由backoff_second字段和当前时间计算出下次推送时间设置next_handle_time字段、更新update_time字段,SQL更新记录影响行数affectRow 返回1才继续处理,更新失败则处理下一条记录。

  2. 前一步更新成功则继续处理;请求返回成功将记录的的状态由2处理中更新成4处理成功、清空 next_handle_time 字段,更新 update_time字段 。如果请求返回失败则更新将满足 handle_status='2' AND handle_count < max_handle_count 的记录更新 handle_status=1 、update_time字段,记录达到最大处理次数的将记录handle_status更新成 8处理失败,清空 next_handle_time 字段,更新update_time字段,然后进入人工处理流程。

在被调用的服务发生异常或者网络问题,短时间内的频繁重试所得到的结果也大致都是失败,这样的重试不仅没有效果,反而还会增服务的负担。所以在计算下一次处理时间next_handle_time除了加上backoff_second退避秒数之后也可以加上随机数,或者更高大上一点使用退避算法来计算。

简版本地消息表方案流程图

实际本地消息表方案

上面那种是简版的本地消息表方案,但是采用分布式架构的系统由于业务解耦、高并发大流量异步削峰等需要会引入消息队列,则消息就会直接发送到消息队列MQ中,不再通过http请求的方式进行通讯,所以方案的流程就会和上面的不一样,具体流程如下图:

由于消息队列(我这里以RocketMQ为例)的引入,业务本地消息表的设计稍稍改动了一下:

CREATE TABLE `biz_local_message` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`biz_module` tinyint(4) unsigned NOT NULL COMMENT '业务模块 1订单  2支付  4等等',
`biz_no` char(64) NOT NULL COMMENT '业务单号,唯一标识',
`biz_type` tinyint(4) unsigned NOT NULL COMMENT '业务类型',
`topic_name` varchar(64) NOT NULL COMMENT '主题名称',
`group_name` varchar(128) NOT NULL COMMENT '分组名称',
`message_tag` varchar(128) NOT NULL COMMENT '消息tag',
`msg` varchar(512) NOT NULL DEFAULT '' COMMENT '消息内容',
`msg_desc` varchar(64) DEFAULT NULL COMMENT '消息描述',
`backoff_second` int(10) unsigned NOT NULL DEFAULT '180' COMMENT '退避秒数,默认3分钟,用来计算next_handle_time时间',
`handle_count` tinyint(4) NOT NULL DEFAULT '0' COMMENT '已经尝试处理次数',
`max_handle_count` tinyint(4) NOT NULL DEFAULT '5' COMMENT '最大尝试处理次数',
`next_handle_time` datetime DEFAULT NULL COMMENT '下一次处理时间',
`handle_status` tinyint(4) unsigned NOT NULL DEFAULT '1' COMMENT '记录处理状态 1待处理  2处理中  4处理成功  8处理失败',
`create_time` datetime NOT NULL COMMENT '创建时间',
`create_user` varchar(32) NOT NULL DEFAULT 'admin' COMMENT '创建人',
`update_time` datetime NOT NULL COMMENT '更新时间',
`update_user` varchar(32) NOT NULL DEFAULT 'admin' COMMENT '更新人',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_bizNoBizMoudleBizType` (`biz_no`,`biz_module`,`biz_type`) USING BTREE,
KEY `idx_nextHandleTime` (`next_handle_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务本地消息'

如果你的消息内容msg内容比较大使用的是text类型,为了提高数据库的效率,或许你可以将这个消息内容msg字段拿出来单独存一张表;

CREATE TABLE `biz_local_message_content` (
`id` bigint(20) unsigned NOT NULL COMMENT '主键',
`local_message_id` bigint(20) unsigned NOT NULL COMMENT '消息主键',
`msg` text NOT NULL DEFAULT '' COMMENT '消息内容'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务本地消息内容表'

biz_local_message 表中处理成功的消息可以直接统一删除,或者挪到一个新的表中备份。

这种和上面的方案相比就是把http通信的方式换成了把消息发送到消息队列中,然后下游系统从消息队列中消费消息。

基于RocketMQ事务消息方案

数据库的本地事务无法解决业务逻辑和消息发送的一致性,因为消息发送是一个网络通信过程,发送消息可能出现发送失败或者超时情况。超时的情况也有可能消息已经发送成功了,也有可能发送失败,但消息发送方是无法确定的,所以这时消息发送方是提交事务还是回滚事务,都是会有可能出现数据不一致的地方。

要解决这个问题,可以采用上面的本地消息表方案,业务逻辑和消息记录在一个事务中一起提交,然后再发送消息。那就也可以采用事务消息(half消息)的办法,事务消息和普通消息的区别在于,事务消息发送到消息队列中后处于prepared状态,是对消费者不可见的,等到事务消息的状态更改为可消费状态后,下游系统的消费者才可以消费到消息。

Sam的个人博客
我还没有学会写个人说明!
上一篇

面试官一上来就问我 Chrome 底层原理和 HTTP 协议(万字长文)

下一篇

《海贼王》全球人气角色投票中期排名揭晓 路飞暂时登顶

你也可能喜欢

评论已经被关闭。

插入图片