2021年06月26日09:23:21!Java分布式事务的6种解决方案介绍来啦
详解Java分布式事务的 6 种解决方案

更新时间为2021年6月26日,时间是09:23:21,作者是Java技术栈
在分布式系统、微服务架构广泛流行的当下,服务间相互调用失败已成为常见情况,本文着重于其他几个方面,对于2PC、3PC传统事务,网上已有大量资料,此处不再赘述,本文将通过示例为大家介绍Java分布式事务的6种解决办法,一起来看看吧
介绍
在如今分布式系统、微服务架构广泛流行的情况下,服务间相互调用出现失败已然成为正常状态。怎样处理异常,怎样保证数据一致性,在微服务设计过程中,是一个无法回避的难题。在不同的业务场景里,解决方案会存在差别,常见的方式有:
阻塞式重试,2PC、3PC传统事务,使用队列进行后台异步处理,TCC补偿事务,本地消息表(异步确保),MQ事务。
本文侧重于其他几项,2PC、3PC传统事务的网上资料已经非常多了,这里对此不多做重复。
阻塞式重试
在微服务架构里,阻塞式重试是较为常见的一种方式 。有伪代码示例 。
m := db.Insert(sql)
err := request(B-Service,m)
定义一个函数,函数名为request ,该函数有两个参数,一个参数的类型是string ,参数名为url ,另一个参数的类型是interface{} ,参数名为body 。
for i:=0; i<3; i ++ {
result, err = request.POST(url,body)
if err == nil {
break
}else {
log.Print()
}
}
}
当请求B服务的API失败后,发起最多三次重试 ,若三次重试均失败 ,则打印日志 ,继续执行下一步或向上层抛出错误 ,这种方式会带来以下问题 。
调用 B 服务成功,然而因为网络超时,当前服务认定其失败,于是继续重试,如此一来 B 服务会生成 2 条相同的数据。调用 B 服务失败,鉴于 B 服务不可用,重试 3 次依旧失败,当前服务在前面代码中插入到 DB 的一条记录,便成了脏数据。重试会增加上游对本次调用的延迟,若下游负载较大,重试会放大下游服务的压力 。
第一个问题:通过让 B 服务的 API 支持幂等性来解决。
第二个问题:数据可以通过后台定时脚步来修正,不过这并非是个好办法。
第三个问题:这是一种牺牲,它对于通过阻塞式重试提高一致性、可用性而言,是必不可少的 。
阻塞式重试适用于业务对一致性要求不高的场景。要是对数据一致性有要求,那就得引入额外机制来解决。
异步队列
在解决方案不断演化的进程里,引入队列是一种较为常见且不错的方式。以下是示例:
m := db.Insert(sql) err赋值为mq发布消息到“B-Service-topic”主题,消息内容为m 。
当前服务将数据写入DB后,推送一条消息给MQ,由独立的服务去消费MQ处理业务逻辑。与阻塞式重试相比,MQ在稳定性上远高于普通的业务服务。不过,推送消息到MQ中的调用,仍有失败的可能,比如网络问题、当前服务宕机等。这样依旧会碰到阻塞式重试同样的问题,也就是DB写入成功了,然而推送失败了。
从理论方面来讲,在分布式系统的环境下,那些涉及多个服务调用的代码,都存在着这样一种情况,即在长期运行的过程中,调用失败的情况必定会出现。而这,也是分布式系统设计的难点当中的一个 。
TCC 补偿事务
对事务有要求,不方便进行解耦,在这种情况下,TCC补偿式事务是个较好的选择。
TCC 把调用每个服务都分成 2 个阶段、 3 个操作:
TCC 要求每个服务都实现上述 3 个操作的 API,服务接入 TCC 事务前,原本一次调用就能完成的操作,现在需要分 2 阶段完成,通过三次操作来完成。
一个商城应用需要调用 A 库存服务,需要调用 B 金额服务,需要调用 C 积分服务,如下伪代码:
m := db.Insert(sql)
aResult, aErr := A.Try(m)
bResult, bErr := B.Try(m)
cResult, cErr := C.Try(m)
if cErr != nil {
A.Cancel()
B.Cancel()
C.Cancel()
} else {
A.Confirm()
B.Confirm()
C.Confirm()
}
代码中分别调用 A 服务 API 检查并保留资源,调用 B 服务 API 检查并保留资源,调用 C 服务 API 检查并保留资源,都返回成功了再提交确认(Confirm)操作;如果 C 服务 Try 操作失败后,就分别调用 A 的 Cancel API 释放其保留的资源,调用 B 的 Cancel API 释放其保留的资源,调用 C 的 Cancel API 释放其保留的资源。
TCC在业务方面解决了数据一致性问题,该问题存在于分布式系统下,涉及跨多个服务以及跨多个数据库。不过,TCC方式仍有一些问题,在实际使用时需要留意,其中包括上面章节提及的调用失败的情形。
空释放
上面代码中,若C.Try()真正调用失败,那么下面多余的C.Cancel()调用会出现释放未锁定资源的行为,这是由于当前服务无法判断调用失败是否真的锁定了C资源。若不调用,实际上成功了,但因网络原因返回失败,这会致使C的资源被锁定,一直得不到释放。
空释放在生产环境中经常会出现,服务在实现TCC事务API这个过程时,应该支持空释放的执行。
时序
上面代码中若C.Try()失败,便接着调用C.Cancel()操作。由于网络原因,C.Cancel()请求有可能先到达C服务,C.Try()请求后到达,这会致使空释放问题出现,同时导致C的资源被锁定,一直无法得到释放。
所以C服务应当拒绝在释放资源之后进行的Try()操作。在具体实现方面,可以使用唯一事务ID来区分是第一次的Try()操作还是释放资源之后的Try()操作。
调用失败
在调用Cancel、Confirm的过程中,仍然会出现失败的状况,例如常见的网络方面的原因 。
Cancel操作失败会导致资源被锁定,Confirm操作失败也会导致资源被锁定,资源一直得不到释放。这种情况常见解决方案有:
存在阻塞式重试的情况。不过会出现相同的问题,像是宕机、始终失败的状况。需要写入日志和队列,随后有单独的异步服务进行自动处理或者人工介入处理。然而还是会有问题,在写入日志或队列时,会存在失败的情形。
从理论上来说,非原子性、事务性的二段代码,都会存在中间状态,因为有中间状态,所以就会有失败的可能性。
本地消息表
本地消息表最初由ebay提出,它使本地消息表和业务数据表处于同一个数据库内,如此便能够借助本地事务来满足事务特性。
具体做法是,在本地事务中插入业务数据时,同时插入一条消息数据。之后进行后续操作,若其他操作成功,便删除该消息;若失败,则不删除,而是异步监听这个消息,并不断重试。
本地消息表是一个很好的思路,可以有多种使用方式:
配合MQ
示例伪代码:
创建一个名为“messageTx”的事务,事务类型为“order”,通过“tc”来创建 。
messageTxSql赋值为tx调用TryPlan方法并传入参数"content"的返回值
m赋值为db.InsertTx的结果,sql使用messageTxSql
if err!=nil {
return err
}
将变量 aErr 赋值为,通过 mq 发布消息到 "B-Service-topic" 主题,消息内容为 m ,这一操作的结果 。
if aErr!=nil { // 推送到 MQ 失败
更新消息的状态,使其变为confirm,通过调用messageTx.Confirm()来实现 。
}else {
messageTx.Cancel() // 删除消息
}
// 异步处理 confirm 的消息,继续推送
func OnMessage(task *Task){
err赋值为mq发布消息到主题“B-Service-topic”,消息内容为task的值 。
if err==nil {
messageTx.Cancel()
}
}
上面代码中的messageTxSql,是一段SQL,用于插入本地消息表。
插入到`tcc_async_task`表中,(字段包括)`uid`,`name`,`value`,`status`
values ('?','?','?','?')
它在同一个事务里与业务SQL一同执行,执行结果非成功即失败。
成功后推送到队列,推送成功后,调用messageTx.Cancel()删除本地消息,推送失败则将消息标记为confirm,本地消息表中status有try、confirm两种状态,无论哪种状态在OnMessage都能被监听到,进而发起重试。
本地事务保障消息会写入数据库,本地事务保障业务会写入数据库,此后执行时,无论出现宕机情况,还是网络推送失败,异步监听都能进行后续处理,进而保障消息一定会推到MQ 。
MQ保障一定会到达消费者服务,利用MQ的QOS策略,消费者服务一定能处理,或者继续投递到下一个业务队列中,进而保障了事务的完整性。
配合服务调用
示例伪代码:
messageTx := tc.NewTransaction("order")
messageTxSql := tx.TryPlan("content")
变量 body 与 err,通过执行 db.InsertTx 函数,并传入 sql 和 messageTxSql 作为参数来获取结果 。
if err!=nil {
return err
}
aErr等于通过向“B-Service”发送POST请求并携带body获取的结果。
如果aErr不等于nil,调用B-Service失败。
messageTx.Confirm() // 更新消息的状态为 confirm
}else {
messageTx.Cancel() // 删除消息
}
对confirm的消息进行异步处理,对try的消息进行异步处理,然后继续调用B-Service
func OnMessage(task *Task){
向“B-Service”发送请求,请求体为body 。
}
这是一个本地消息表与调用其他服务的例子,其中未引入MQ。该例子使用异步重试,依靠本地消息表保障消息可靠性,解决了阻塞式重试引发的问题,在日常开发里较为常见。
要是本地不存在需要写DB的操作,那么可以仅仅写入本地消息表,并且同样在OnMessage中进行处理。
messageTx := tc.NewTransaction("order")
messageTx赋值为tx尝试获取"content"的结果。
aErr := request.POST("B-Service",body)
// ....
消息过期
配置本地消息表中Try消息的处理器,配置本地消息表中Confirm消息的处理器。
TCC设置尝试处理程序,该处理程序基于OnTryMessage() 。 设置确认处理程序,调用确认消息函数 。
在消息处理函数里,要判断当前消息任务是否存在过久,比如说一直重试了一小时,然而还是失败了,这种情况下就考虑采用发邮件、短信、日志告警等方式,以此让人工介入。
定义一个名为OnConfirmMessage的函数,该函数接收一个指向tcc.Task类型的指针task作为参数 。
如果当前时间减去任务创建时间大于一小时
err赋值为task.Cancel(),此操作会删除该消息,还会停止重试。
// doSomeThing() 告警,人工介入
return
}
}
在Try处理函数中,要单独判断当前消息任务是否存在过短。因为处于Try状态的消息,可能才刚刚创建,还没被确认提交或删除。这会和正常业务逻辑的执行重复,意味着成功的调用也会被重试。为尽量避免这种情况,可以检测消息的创建时间是否很短,若短则可以跳过。
重试机制必然依赖下游 API 在业务逻辑上的幂等性,即便不处理也能够行得通,不过在设计方面还是要尽可能避免干扰正常的请求。
独立消息服务
独立消息服务是本地消息表的升级版本,它将本地消息表抽离出来成为一个独立的服务。在进行所有操作之前,要先在消息服务中添加一条消息。如果后续操作成功,就删除这条消息。要是操作失败,就提交确认消息。
接着用异步逻辑来监听消息,针对消息做相应的处理,其处理逻辑与本地消息表的处理逻辑大致相同。然而因为向消息服务添加消息时,没办法将其与本地操作置于同一个事务当中,所以会出现这样的情况,即添加消息成功了,可后续却失败了,那么在这个时候,该消息就成了一个没有用处的消息。
如下示例场景:
err赋值为通过向“Message-Service”发送POST请求并携带body得到的结果 。
if err!=nil {
return err
}
aErr := request.POST("B-Service",body)
if aErr!=nil {
return aErr
}
这个无用的消息,要靠消息服务来确认其执行是否成功,若未成功则删除,若成功则继续执行后续逻辑。与本地事务表的try和confirm相比,消息服务在前面多了一种prepare状态。
MQ 事务
有些MQ的实现具备支持事务的能力,像RocketMQ就是这样。MQ的事务能够被视作独立消息服务的一种具体呈现方式,其逻辑是完全相同的。
在进行所有操作之前,先在MQ投递一个消息。如果后续操作成功,就Confirm确认提交消息。要是后续操作失败,就Cancel删除消息。MQ事务会存在prepare状态,这需要MQ的消费处理逻辑来确认业务是否成功。
总结
从分布式系统的实践情况来看,在需要保障数据一致性的场景下,必然要引入额外的机制来进行处理。
TCC 的优点在于作用于业务服务层,它不依赖特定数据库,不与具体框架耦合,资源锁的粒度较为灵活,因而非常适用于微服务场景。其缺点是每个服务都需实现 3 个 API,这对业务的侵入和改动较大,还需处理各种失败异常。由于开发者很难完整处理各种情况,所以找个成熟的框架能够大大降低成本,例如阿里的 Fescar 。
本地消息表具有简单的优点,不依赖于对其他服务进行改造,能够很好地配合服务调用与MQ一同使用,在大多业务场景下都较为实用。其缺点是本地数据库中多了消息表,且与业务表耦合在一起。文中本地消息表方式的示例,源自作者所写的一个库,有兴趣的同学可以参考一下
MQ事务和独立消息服务的优点在于,它抽离出一个公共服务来解决事务问题,避免每个服务的消息表与服务耦合在一起,进而增加服务自身的处理复杂性。其缺点是,支持事务的MQ数量很少,而且每次操作前都要先调用API添加消息,这会增加整体调用的延迟,在绝大多数正常响应的业务场景下,这是一种多余的开销。
TCC 参考:
MQ 事务参考:
到此,这篇介绍Java分布式事务6种解决方案的文章就介绍完了。更多相关Java分布式事务内容,可搜索脚本之家以前的文章,或继续浏览下面的相关文章。希望大家以后多多支持脚本之家!