简介在分布式系统和微服务架构流行的今天,服务之间无法相互调用已经成为常态。如何处理异常,如何保证数据的一致性,成为微服务设计过程中无法回避的问题。在不同的业务场景下,解决方案会有所不同,常用的方法有:阻塞重试;2PC、3PC传统交易;使用队列,后台异步处理;TCC补偿交易;本地消息表(异步保证);MQ事务。本文重点介绍其他几个项目。关于2PC和3PC传统事务网上已经有很多资料,这里不再赘述。阻塞重试在微服务架构中,阻塞重试是一种比较常见的方式。伪代码示例:m:=db.Insert(sql)err:=request(B-Service,m)funcrequest(urlstring,bodyinterface{}){fori:=0;i<3;i++{result,err=request.POST(url,body)iferr==nil{break}else{log.Print()}}}如上,当请求服务B的API失败时,最多会发起3次重试。如果3次还是失败,打印日志继续执行或者向上层抛错。这种方式会造成如下问题:调用B服务成功,但是由于网络超时,当前服务认为失败,继续重试,这样B服务会产生2条相同的数据.调用服务B失败。因为服务B不可用,重试3次还是失败。前面代码中当前服务插入DB的一条记录就变成了脏数据。重试会增加本次调用的上游调用延迟。如果下游负载较重,重试会放大下游服务的压力。第一个问题:通过让服务B的API支持幂等来解决。第二个问题:可以在后台通过计时步骤来修正数据,但这不是一个好办法。第三个问题:这是通过阻塞重试来提高一致性和可用性的本质牺牲。阻塞重试适用于业务对一致性要求不敏感的场景。如果有数据一致性的需求,就必须引入额外的机制来解决。异步队列在解决??方案演进的过程中,引入队列是一种比较常见也是比较好的方式。下面的例子:m:=db.Insert(sql)err:=mq.Publish("B-Service-topic",m)当前服务向DB写入数据后,向MQ推送一条消息,独立服务会转到ConsumeMQ处理业务逻辑。与阻塞重试相比,虽然MQ的稳定性远高于普通业务服务,但是在向MQ推送消息时仍然存在失败的可能,比如网络问题、当前服务宕机等。这样还是会遇到同样阻塞重试的问题,就是DB写成功了,但是push失败了。从理论上讲,在分布式系统中,这种情况存在于涉及多个服务调用的代码中。长此以往,肯定会出现调用失败的情况。这也是分布式系统设计的难点之一。另外,MQ系列面试题和答案都整理好了。微信搜索Java技术栈,后台发送:面试,网上可以看。TCC补偿事务TCC补偿事务在对事务有要求且不方便解耦时是更好的选择。TCC将每个服务的调用分为2个阶段和3个操作:Phase1,Try操作:检查业务资源和预留资源,如检查库存、扣款等。Phase2,Confirm操作:提交并确认Try操作的资源预留。例如,将库存预扣更新为扣除。Phase2,Cancel操作:Try操作失败后,释放被扣留的资源。例如,加回库存预扣税。TCC要求每个服务都实现以上三个操作的API。之前调用服务访问TCC事务完成的操作,现在需要分两个阶段,三个操作完成。比如一个商城应用需要调用A库存服务,B金额服务,C积分服务,伪代码如下:m:=db.Insert(sql)aResult,aErr:=A.Try(m)bResult,bErr:=B.Try(m)cResult,cErr:=C.Try(m)ifcErr!=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接口释放其预留的资源。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()。FailedtocallCancel,Confirm在调用过程中,还是会出现失败的情况,比如常见的网络原因。Cancel()或Confirm()操作失败将导致资源被锁定,永远不会被释放。这种情况的常见解决方案是:阻塞重试。但是也有同样的问题,比如宕机,一直故障。写入日志、队列,然后有单独的异步服务自动或手动介入处理。但也存在问题。在写日志或者队列的时候,都会出现故障。从理论上讲,非原子和事务性的两段代码都会有一个中间状态,如果有中间状态就会有失败的可能。本地消息表本地消息表最初是由ebay提出的。它允许本地消息表和业务数据表在同一个数据库中,这样就可以使用本地事务来满足事务特性。具体方法是在本地事务中插入业务数据时插入一条消息数据。然后进行后续操作,如果其他操作成功,则删除该消息;失败则不删除,异步监听消息,不断重试。本地消息表是个好主意,可以用在很多方面:配合MQ示例伪代码:messageTx:=tc.NewTransaction("order")messageTxSql:=tx.TryPlan("content")m,err:=db.InsertTx(sql,messageTxSql)iferr!=nil{returnerr}aErr:=mq.Publish("B-Service-topic",m)ifaErr!=nil{//无法推送到MQmessageTx.Confirm()//更新消息状态为confirm}else{messageTx.Cancel()//删除消息}//异步处理confirm消息,继续推送funcOnMessage(task*Task){err:=mq.Publish("B-Service-topic",task.Value())iferr==nil{messageTx.Cancel()}}上面代码中,messageTxSql是插入本地消息表的一段SQL:insertinto`tcc_async_task`(`uid`,`name`,`value`,`status`)values('?','?','?','?')和业务SQL在同一个事务中执行,要么成功,要么失败。如果推送成功,则将其推送到队列中。如果推送成功,会调用messageTx.Cancel()删除本地消息;如果推送失败,消息将被标记为确认。本地消息表中的状态有两种状态:try和confirm,无论哪种状态都可以在OnMessage中监听到发起重试。本地事务保证将消息和服务写入数据库。之后无论是执行down还是网络推送失败,异步监听都可以进行后续处理,从而保证消息会被推送到MQ。而MQ保证会到达消费者服务。使用MQ的QOS策略,消费服务必须能够处理,或者继续投递到下一个业务队列,从而保证交易的完整性。服务调用示例的伪代码:messageTx:=tc.NewTransaction("order")messageTxSql:=tx.TryPlan("content")body,err:=db.InsertTx(sql,messageTxSql)iferr!=nil{returnerr}aErr:=request.POST("B-Service",body)ifaErr!=nil{//调用B-Service失败messageTx.Confirm()//更新消息状态确认}else{messageTx.Cancel()//DeleteMessage}//异步处理confirm或try消息,继续调用B-ServicefuncOnMessage(task*Task){//request.POST("B-Service",body)}这是本地消息表的例子+调用其他服务,没有引入MQ。这种通过异步重试和使用本地消息表保证消息可靠性解决了阻塞重试带来的问题,在日常开发中比较常见。如果本地没有写入DB的操作,只能写入本地消息表,同样在OnMessage中处理:messageTx:=tc.NewTransaction("order")messageTx:=tx.Try("content")aErr:=请求。POST("B-Service",body)//...消息过期在本地消息表中配置Try和Confirm消息的处理器:TCC.SetTryHandler(OnTryMessage())TCC.SetConfirmHandler(OnConfirmMessage())inmessageprocessing函数中需要判断当前消息任务是否存在时间过长,比如重试一个小时仍然失败,考虑发送邮件、短信、日志告警等方式允许人工干预。funcOnConfirmMessage(task*tcc.Task){iftime.Now().Sub(task.CreatedAt)>time.Hour{err:=task.Cancel()//删除消息,停止重试。//doSomeThing()报警,人工干预返回}}在Try处理函数中,还需要单独判断当前消息任务是否太短,因为Try状态的消息可能刚刚创建,还没有确认提交或删除。这将重复执行正常的业务逻辑,这意味着成功的调用也会被重试;为了尽量避免这种情况,可以检查消息的创建时间是否很短,如果很短可以跳过。重试机制必须依赖下游API在业务逻辑上的幂等性。虽然不处理也是可行的,但是设计上要尽量避免干扰正常的请求。另外,推荐Java核心技术教程和示例源码:https://github.com/javastacks/javastack独立消息服务独立消息服务是本地消息表的升级版,将本地消息表分离成一个独立的服务。在所有操作之前向消息服务添加一条消息,如果后续操作成功则删除该消息,如果失败则提交确认消息。然后使用异步逻辑监听消息并做相应的处理,与本地消息表的处理逻辑基本一致。但是由于在消息服务中添加消息无法与本地操作放入事务中,因此可能会出现消息添加成功后出现后续失败的情况,这时的消息就没有用了。以下示例场景:err:=request.POST("Message-Service",body)iferr!=nil{returnerr}aErr:=request.POST("B-Service",body)ifaErr!=nil{returnerr}这个对于无用的消息,消息服务需要确认消息是否执行成功,没有则删除,继续执行后续逻辑。与本地事务表try和confirm相比,消息服务在前面多了一个状态prepare。MQ事务某些MQ实现支持事务,例如RocketMQ。MQ事务可以看作是独立消息服务的具体实现,逻辑是完全一致的。在所有操作之前,先在MQ中发布一条消息。如果后续操作成功,Confirm将确认提交消息,如果失败,Cancel将删除该消息。MQ事务也有prepare状态,需要MQ消费处理逻辑来确认业务是否成功。总结从分布式系统实践来看,为了保证数据的一致性,必须引入额外的机制进行处理。TCC的优点是作用于业务服务层,不依赖于特定的数据库,不与特定的框架耦合,资源锁的粒度比较灵活,非常适合微服务场景。缺点是每个服务要实现3个API,需要处理业务入侵和大变更的各种失败异常。开发人员很难完全处理各种情况。找一个成熟的框架可以大大降低成本,比如阿里的Fescar。本地消息表的优点是简单,不依赖于其他服务的改造,可以结合服务调用和MQ使用。在大多数业务场景下比较实用。缺点是本地数据库消息表比较多,再加上业务表。本文中的本地消息表方法示例来自作者编写的一个库。有兴趣的同学可以参考https://github.com/mushroomsir/tccMQ事务和独立消息服务的好处是提取一个公共服务来解决事务问题,避免每个服务都有一个消息表和服务耦合在一起,增加服务本身的处理复杂度。缺点是支持事务的MQ很少;并且在每次操作前调用API添加消息会增加整体调用的延迟,这在大多数正常响应业务场景下是不必要的开销。
