分布式事务的 6 种解决方案,写得非常好!

在分布式系统、微服务架构大行其道的今天,服务间互相调用出现失败已经成为常态。如何处理异常,如何保证数据一致性,成为微服务设计过程中,绕不开的一个难题。 在不同的业务场景下,解决方案会有所差异,常见的方式有:

阻塞式重试;

2PC、3PC 传统事务;

使用队列,后台异步处理;

TCC 补偿事务;

本地消息表(异步确保);

MQ 事务。

本文侧重于其他几项,关于 2PC、3PC 传统事务,网上资料已经非常多了,这里不多做重复。

阻塞式重试

在微服务架构中,阻塞式重试是比较常见的一种方式。伪代码示例:

m := db.Insert(sql) err := request(B-Service,m) func request(url string,body interface{}){ 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.Publish("B-Service-topic",m)

在当前服务将数据写入 DB 后,推送一条消息给 MQ,由独立的服务去消费 MQ 处理业务逻辑。和阻塞式重试相比,虽然 MQ 在稳定性上远高于普通的业务服务,但在推送消息到 MQ 中的调用,还是会有失败的可能性,比如网络问题、当前服务宕机等。这样还是会遇到阻塞式重试相同的问题,即 DB 写入成功了,但推送失败了。

理论上来讲,分布式系统下,涉及多个服务调用的代码都存在这样的情况,在长期运行中,调用失败的情况一定会出现。这也是分布式系统设计的难点之一。

TCC 补偿事务

在对事务有要求,且不方便解耦的情况下,TCC 补偿式事务是个较好的选择。

TCC 把调用每个服务都分成 2 个阶段、 3 个操作:

阶段一、Try 操作:对业务资源做检测、资源预留,比如对库存的检查、预扣。

阶段二、Confirm 操作:提交确认 Try 操作的资源预留。比如把库存预扣更新为扣除。

阶段二、Cancel 操作:Try 操作失败后,释放其预扣的资源。比如把库存预扣的加回去。

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、B、C 服务 API 检查并保留资源,都返回成功了再提交确认(Confirm)操作;如果 C 服务 Try 操作失败后,则分别调用 A、B、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 提出的,它让本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来满足事务特性。

内容版权声明:除非注明,否则皆为本站原创文章。

转载注明出处:https://www.heiqu.com/zzpyzd.html