共计 5561 个字符,预计需要花费 14 分钟才能阅读完成。
作者:蘑菇学生
出处:www.cnblogs.com/mushroom/p/13788039.html
介绍
在分布式系统、微服务架构大行其道的明天,服务间相互调用呈现失败曾经成为常态。如何解决异样,如何保证数据一致性,成为微服务设计过程中,绕不开的一个难题。在不同的业务场景下,解决方案会有所差别,常见的形式有:
- 阻塞式重试;
- 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 提出的,它让本地音讯表与业务数据表处于同一个数据库中,这样就能利用本地事务来满足事务个性。
具体做法是在本地事务中插入业务数据时,也插入一条音讯数据。而后在做后续操作,如果其余操作胜利,则删除该音讯;如果失败则不删除,异步监听这个音讯,一直重试。
本地音讯表是一个很好的思路,能够有多种应用形式:
配合 MQ
示例伪代码:
messageTx := tc.NewTransaction("order")
messageTxSql := tx.TryPlan("content")
m,err := db.InsertTx(sql,messageTxSql)
if err!=nil {return err}
aErr := mq.Publish("B-Service-topic",m)
if aErr!=nil { // 推送到 MQ 失败
messageTx.Confirm() // 更新音讯的状态为 confirm}else {messageTx.Cancel() // 删除音讯
}
// 异步解决 confirm 的音讯,持续推送
func OnMessage(task *Task){err := mq.Publish("B-Service-topic", task.Value())
if err==nil {messageTx.Cancel()
}
}
下面代码中其 messageTxSql 是插入本地音讯表的一段 SQL:
insert into `tcc_async_task` (`uid`,`name`,`value`,`status`)
values ('?','?','?','?')
它和业务 SQL 在同一个事务中去执行,要么胜利,要么失败。
胜利则推送到队列,推送胜利,则调用 messageTx.Cancel() 删除本地音讯;推送失败则标记音讯为 confirm
。本地音讯表中 status
有 2 种状态 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 := request.POST("B-Service",body)
if aErr!=nil { // 调用 B-Service 失败
messageTx.Confirm() // 更新音讯的状态为 confirm}else {messageTx.Cancel() // 删除音讯
}
// 异步解决 confirm 或 try 的音讯,持续调用 B-Service
func OnMessage(task *Task){// request.POST("B-Service",body)
}
这是本地音讯表 + 调用其余服务的例子,没有 MQ 的引入。这种应用异步重试,并用本地音讯表保障音讯的可靠性,解决了阻塞式重试带来的问题,在日常开发中比拟常见。
如果本地没有要写 DB 的操作,能够只写入本地音讯表,同样在 OnMessage
中解决:
messageTx := tc.NewTransaction("order")
messageTx := tx.Try("content")
aErr := request.POST("B-Service",body)
// ....
音讯过期
配置本地音讯表的 Try
和 Confirm
音讯的处理器:
TCC.SetTryHandler(OnTryMessage())
TCC.SetConfirmHandler(OnConfirmMessage())
在音讯处理函数中要判断以后音讯工作是否存在过久,比方始终重试了一小时,还是失败,就思考发邮件、短信、日志告警等形式,让人工染指。
func OnConfirmMessage(task *tcc.Task) {if time.Now().Sub(task.CreatedAt) > time.Hour {err := task.Cancel() // 删除该音讯,进行重试。// doSomeThing() 告警,人工染指
return
}
}
在 Try
处理函数中,还要独自判断以后音讯工作是否存在过短,因为 Try
状态的音讯,可能才刚刚创立,还没被确认提交或删除。这会和失常业务逻辑的执行反复,意味着胜利的调用,也会被重试;为尽量避免这种状况,能够检测音讯的创立工夫是否很短,短的话能够跳过。
重试机制必然依赖上游 API 在业务逻辑上的幂等性,尽管不解决也可行,但设计上还是要尽量避免烦扰失常的申请。
独立音讯服务
独立音讯服务是本地音讯表的升级版,把本地音讯表抽离成一个独立的服务。所有操作之前先在音讯服务增加个音讯,后续操作胜利则删除音讯,失败则提交确认音讯。
而后用异步逻辑去监听音讯,做对应的解决,和本地音讯表的解决逻辑基本一致。但因为向音讯服务增加音讯,无奈和本地操作放到一个事务里,所以会存在增加音讯胜利,后续失败,则此时的音讯就是个无用音讯。
如下示例场景:
err := request.POST("Message-Service",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 一起应用,在大多业务场景下都比拟实用。毛病是本地数据库多了音讯表,和业务表耦合在一起。文中本地音讯表形式的示例,来源于作者写的一个库,有趣味的同学能够参考下 https://github.com/mushroomsi…
MQ 事务和独立音讯服务的长处是抽离出一个公共的服务来解决事务问题,防止每个服务都有音讯表和服务耦合在一起,减少服务本身的解决复杂性。毛病是反对事务的 MQ 很少;且每次操作前都先调用 API 增加个音讯,会减少整体调用的提早,在绝大多数失常响应的业务场景下,是一种多余的开销。
TCC 参考:https://www.sofastack.tech/bl…
MQ 事务参考:https://www.jianshu.com/p/eb5…
近期热文举荐:
1.1,000+ 道 Java 面试题及答案整顿(2021 最新版)
2. 终于靠开源我的项目弄到 IntelliJ IDEA 激活码了,真香!
3. 阿里 Mock 工具正式开源,干掉市面上所有 Mock 工具!
4.Spring Cloud 2020.0.0 正式公布,全新颠覆性版本!
5.《Java 开发手册(嵩山版)》最新公布,速速下载!
感觉不错,别忘了顺手点赞 + 转发哦!