vlambda博客
学习文章列表

分布式系统链路一致性踩坑录

作 者 | 飞捷

文章来源 | 阿里巴巴淘系技术团队


本文把问题聚焦在一个用户请求从入口开始在分布式系统这个链路上如何调用来保证一致。


说到分布式一定跑不掉一致性。一致性涉及的面域很广。什么ACID事务咯,CAP咯,2PC咯,BASE咯这些概念大家肯定也都懂,网上这样的介绍文章也是一大把。但是是否会用,是否用得上又是另一个问题。本篇文章不讲这些高大上的理论。我们把问题聚焦在一个用户请求从入口开始在分布式系统这个链路上如何调用来保证一致。这也是日常最常见的需求场景,结合案例讲讲它的实现以及可能存在的坑


什么是一致性:简单说就是一个请求过来,上游看到的结果和下游看到的结果一样(系统角度)。或者说用户看到的和实际发生的结果一样(业务角度)


小王接到一个需求,做一个积分兑换页面,输入兑换的奖品数,只需要消耗10个积分,就可以兑换指定个数的奖品,先到先得。产品经理告诉小王,兑换奖品的接口是现成的,你只需要做个积分的记录,然后关联用户消耗积分完了兑换奖品即可。



看来需求很简单,写个db记录一下积分,然后调用一下兑换接口,带上输入的兑换奖品参数就好了嘛,于是小王快速写好代码,提测了。


结果遇到了大量的问题。让我们依次分析一下。


为了简化模型,我们假设这里就只有3个节点:


前端页面节点A:和用户交互的节点,用于用户通过页面触发积分兑换的功能,页面会调后端接口

应用后端节点B:实现这次互动的节点,用于检查用户情况,判断用户安全性,扣减用户积分,并最终调用兑换奖品接口

平台后端节点C:用于提供兑换奖品的接口,做实际兑换奖品的操作


第一章:幂等控制


问题1:重试异常


当用户点击领取的时候,有的时候可能因为手抖了一下点了两次,导致用户认为我只操作了一次兑换,结果却操作了2次甚至多次的情况。


有人说,那还不简单,前端改一下,当点击兑换按钮时,将按钮置灰不就完了。


实际上除了手抖之外,还有其他地方抖,比如网络抖动,浏览器收不到回复,触发重试机制,或者网关内部抖动导致重试,甚至路由器收不到回复包自动重试,总之重试机制遍布整个链路。


为了保证你的一次请求是「一次请求」,往往都会加一个幂等ID,用来表示这是一次请求。每次请求过来的时候都会记录这个幂等ID,如果发生抖动导致重试,后端也可立马知道这是一次重复调用,来最终判断如何处理。


幂等ID需要满足以下要求:


1.唯一性:新生成的幂等ID可以不是序列的,但一定是全局唯一的,比如UUID


2.来自头部请求:从前端发起的第一个请求到服务端,这个幂等ID就应该带过来了,而不是等到服务端再生成幂等ID


3.幂等的唯一映射:当请求从一个节点到另一个节点时,这个幂等ID可以是相同的值,也可以是不同的值,但是在替换值的时候,一定要保证他们也是一对一的关系的,且同时也要满足幂等ID的特性。比如前端请求后端的时候带了uuid的幂等值过来,你可以将这个uuid存到db的一条记录中,然后取这条记录的id作为幂等id往下游接口调用


小王在前端加了幂等ID,请求后端奖品发放接口的时候会带上幂等ID,后端收到请求后,判断这个幂等的请求是否在db存在过,如果未存在,则是新请求,否则就是旧请求的重试。新请求从起始状态全新程走一遍,旧请求就看当前状态,状态是什么就从这个状态的后一个状态开始继续推进,前面走过的就不用再走了,避免重复操作。


问题2:关键参数问题


测试同学看了一下小王写的这个逻辑。灵机一动,说我可以破解你。


你不是要消耗10个积分才能够领用奖品吗?那用11个积分可以领取两个奖品吗?试试看。他先把用户的剩余积分设置为11(用于假设用户只剩下11个积分了),然后选择兑换1个商品,触发第一次接口调用,他在「平台节点C」设置了一个断点。当请求到这个断点时故意将该节点挂掉(模拟服务挂掉场景),然后重启。成功后,他带着相同的幂等id再次去请求接口,不同的是,他这次请求偷偷将兑换1个商品的参数1变成2。因为此时服务器判断该接口是重复调用(幂等id相同)故直接跳过参数校验(因为当前服务器从单据的状态上看认为前面验证已经做过了),就不判断了,直接拿着新参数去调用「平台后端节点C」去兑换2个商品,成功了。


测试抛了一个缺陷给小王...


生成幂等ID的同时,一定要确认当前请求的关键参数,并将这些参数保留到db中存为快照。当下次重试请求过来时,我们需要校验重试请求的关键参数是否等于第一次请求的参数,不等的不用往后请求了,直接报错返回


小王改呀改,好不容易修复了代码,提交了。


第二章:状态机


问题3:喜欢乐观锁


小王约测试同学一起吃饭,边吃饭边聊天,他跟测试说,你知道吗,我在这次需求里面使用了乐观锁。当积分兑换奖品请求过来后,我先从用户的积分里面拿到积分和当前记录的版本号,在兑换奖品并更新扣减积分的时候我会带上这个版本号,只要版本对上并更新成功就说明没有并发,可以推进,如果没有更新成功就说明出现了并发,积分发生了变化,返回失败即可。这样即使高并发过来了也不会出现问题,是不是很机智?


饭后,测试同学测都没测直接给小王提交了一个bug。


针对这个场景,小王虽然解决了积分的校验问题,确保积分扣减不会出现bug。但是假如网络抖动,触发了重试,你没有状态判断,就可能导致幂等不起作用,出现重复兑换的情况。那怎么解决这个问题呢?


状态机是从系统上确定当前业务在执行过程中间的中间状态,我们记录了状态机,就可以知道当前操作进行到了哪个阶段,有没有成功等。


针对刚才这个场景,如果在更新积分的同时更新用户在当前任务的状态机,是不是就可以解决重复的问题了?


我们先不说是否解决问题,至少这个时候,你发现乐观锁搞不定了。你不可能一次性更新多个领域多个参数的状态。这个时候,就要回归传统,使用悲观锁(db事务机制,注意不是分布式事务)。


为了配合悲观锁,更新状态,这里还需要用到一个工具,那就是「单据」。单据可以理解为一次请求的凭证,当然也可以记录这个请求在当前节点的各种状态,当一个请求过来了我们首先会进行落单,更新状态为已接收,当我们去调用下游接口的时候需要记录调用成功与否的状态。当然下游的接口也一定要保持幂等的。这样记录了状态后,即使重试,也不会出现重复调用的情况了。


所以请求到你的节点时为了记录状态你会落单,你再请求下游别的节点时他们也会记录状态也会落单。这样的数据就叫做流水型数据。流水型数据使一次请求在往系统中调用时像流水一样在各个节点间传递,并通过幂等确保节点间的状态一致。与之对应的还有一个叫状态型数据,这种就是只需要变更某一个状态即可,不需要协同,比如我们更新一下用户名称。


问题4:更新状态的偏见


小王设置了悲观锁,加了状态,他觉得既然更新是关键,那我是不是只要更新才变更状态,其他时候不更新状态是不是就没问题了?比如我做查询的时候,需要更新状态到db吗?


在上面积分兑换这个需求场景下,小王在调用下游兑换接口前,还需要做各种判断,比如,是否正常用户,活动是否结束等。正常调用一般没问题,但是在某种极端场景就会出现问题。比如当用户从前端发起兑换请求后,到小王的「应用后端节点」,小王做了判断发现都没问题,触发调用下游的兑换奖品接口,下游请求成功了。但是在响应成功的时候网络抖动,小王的应用后端节点连接超时,于是同步报给上游,但此时上游网络也发生了抖动,断掉了。用户的浏览器接收不到消息,于是触发重试机制,用相同的幂等id再次请求接口到后端。小王的应用后端收到请求后开始检查,这时发现该用户状态有问题(风控),所以这次他不直接向下游请求兑换接口了,而是直接返回错误给到上游前端,前端返回客户兑换失败。但是实际情况是此次兑换是成功的,不一致。


当然这在兑换奖品这样的场景下,业务勉强可以接受,因为这至少不会产生资损,大不了错误误报,但是假如把用户换成支付宝,把下游兑换接口换成银行网关呢,支付宝正在飞速的做各种转账操作呢?这种情况可以接受吗?


所以即使是查询,我们也需要记录最终的状态结果,表示至少在当前的请求这个时间点,查询状态是这样的。至于后面怎么重试也好,我只关注当前状态即可。


那能不能先做一下初步的业务判断再起事务,录入幂等ID及状态呢,答案是不行,会不一致,如果第一次请求过来成功并落单,但是返回失败,第二次重试的时候却因为初步业务判断发生变化导致不通过,返回校验失败,那就会存在下游成功而上游失败的不一致情况。


总之要保证一致性,一定要满足:一锁二判三更新。每个节点状态都需要记录状态。


有些业务涉及到的状态有很多,比如冻结,解冻,额度等多种状态,相互之间还有依赖关系。也就是说状态是有顺序的(解冻一定要先冻结,取消一定要先扣减)如果通过控制状态机对象实现严格的状态变更关系,能够较好的避规一些因迭代导致可能存在的各种问题。


最后再总结一下,乐观锁是把双刃剑,有利有弊,是否使用乐观锁需要看具体场景:


1.是否需要去重


2.是否只关注一种状态


3.是否能够容忍返回失败,实际调用成功这种不一致情况。 


第三章:快照



问题5:参数变化


小王在调用下游兑换接口时,会给接口带上各种参数,除了用户ID,兑换商品ID,兑换数量,还有一个兑换时间


有一次测试在测试接口的重试机制时发现返回失败了,怎么查也查不出原因,找小王,小王看了一下日志反馈说是下游返回的错误。于是找到下游奖品兑换应用的owner说,你接口挂了查一下啥原因?对方心想这个接口一直都是好的啊,大家都在调用,别人的都没问题,怎么你的就有问题?


于是他查了一下日志,发现小王相同幂等id下发来了两次请求,第一次兑换时系统成功了,但是返回超时,触发了重试,第二次请求过来的时候发现接口传的兑换时间这个关键参数不对,两次传的时间不一致。导致错误返回。因为小王每次调用接口都更新了时间,所以只要第一次失败,后面都失败了。于是他把小王骂了一顿。


所有接口,无论是查询接口还是下游服务的更新接口,都需要记录接口的参数,这个信息就是快照。在相同幂等id的情况下,要保证所有的请求和查询结果都是一致的。有的时候在调用下游二方接口的同时,需要传入的参数可能又来自于另一个二方接口的查询,如果不做快照,两次查询不一致,可能导致下游处理逻辑发生变化,甚至导致资损。


那有没有可以不落单的参数呢?有,如果这个参数是确定性的,中间不用发生变化的,就可以不用落单,比如用户性别


退而求其次,记录快照也是一种凭证,避免以后遇到问题了出现扯不清的皮。


第四章:推进机制


问题6:查询超时


保持一致性的三个抓手无外乎就是「重试」,「查询」,「消息」三种机制。


小王在调用下游接口时,为了保证一致性,除了允许重试之外,还和下游的同学协商增加了消息和查询能力。也就是说一旦请求发生超时,下游如果处理完成,可以异步发消息给小王,告诉小王最终处理是成功还是失败,如果没有收到消息,小王还会拿着幂等ID调用下游的查询接口去查询结果


下游的负责同学听了觉得靠谱,就同意了,和小王一起干了起来。很快功能完成了,提测。小王心想,现在有重试、消息、反查三种机制保障,一定没问题了。


结果有一天测试同学找到小王说你的系统又出问题了。接口调用失败,但是实际成功了,出现了不一致情况。小王跑去查了一下日志,发现非常奇怪的现象:


1.看接口请求日志显示的业务状态是超时。


2.再看消息接收情况,返回的业务状态是成功。


3.最后看反查的情况,返回的业务状态是失败。


三种做法,三种结果!小王懵了


小王联系到下游方:“为什么三种调用方式出现了三种不同的结果?”

下游方:“什么结果?”


小王:“我通过直接接口调用推进返回未知错误?”


下游方:“这个请求到我们时应用正好出现了fullgc 系统卡顿了一下,导致第一次的请求是超时!,你可以重试或者等待消息回执或者主动查询”


小王:“我主动查新了啊,返回给我的为啥又是错误?”


下游方:“看了一下,你主动查询间隔时间太短了,任务还在执行过程中,并没有落单,所以返查是查不到结果的,返回错误,最后成功我们有发异步消息的,你有收到吗?”


小王:“...”


原来在第一次请求失败后,小王便开始等待异步消息,结果异步消息却迟迟没有收到,于是小王同时又发起了查询操作,但此时下游系统还在卡顿中挣扎,虽然请求任务收到了,但还在执行过程中,并没有落单,所以返查是查不到结果的,返回错误。小王就将结果就同步落了单并返回给了前端。但是过了一会下游又执行完了,最终成功。这时候小王又从下游收到了一个成功的异步消息。(虽然流程看起来比较长,但是整个过程仅用了几秒钟),最终出现了上面奇怪的现象。


问题找到后解决办法就简单了,小王觉得查询操作不靠谱,关闭了反查机制。并告诉了下游,下游告诉他说,你应该是延长反查机制,而不是关闭,小王问为啥呢?


问题7:扯皮


在推进的这三种机制中,最靠谱的就是重试,其次是查询,最后才是消息。但是在论和第三方的对接中,如果要确认扯清关系的,最靠谱的办法反而是「查询」。因为「重试」只能证明我请求是否成功,在有些场景下,无法证明对方的单据是否生成。而「消息」先不考虑对接的难度,消息延迟,丢消息,消息超时的情况都是有可能发生的。反观查询,只要确定好请求和查询的时间间隔问题,确保查询一定能够拿到终态。那么查询我就能够任意请求多少次都没关系,查询失败也好,超时也好我都可以决定是否再次查询。查询的结果我可以落单作为凭证,如果有结果也一定说明对方系统一定是有结果了,而不是别的什么情况。拿着凭证再和对方交流就会避免很多扯不清的问题。


当然这还要建立在对方返回是有确定性状态的基础之上。


所谓确定性状态,就是对方返回给你的状态一共就3类:失败类,成功类,未知类


1.失败类:是一种确定性的结果。失败可能是账户不对,额度不够或者其他什么原因导致,这种属于业务逻辑,正常返回给到上游即可


2.成功类:这也是一种确定性的结果,当然也是大家希望看到的结果


3.未知类:这是一种不确定性的结果,这类结果往往是系统未知异常,这类结果返回,一般需要上游不断重试给下游,直到返回确定性的结果为止。这样的异常如超时、系统资源不足、其他人工处理等都属于这类异常。


确定好这些关键异常类型,就可以确保和二方三方做好正确的协同,会避免各种扯皮,也会避免出现各种不一致了


第五章:容灾


到这里前方战线基本上就算是结束了。但是这只是完成了线上应用基本部署。一个好的系统各种监控报警以及应急预案是不可缺少的。这里再最后讲讲后方需要做的事情。


问题8:读写分离之痛


一致性强依赖db,但是db容灾方式一般是搞主备,做读写分离。这种方式的好处是一方面有灾备,如果出现问题导致主库丢失,备库也能够迅速接替上。另一方面将读操作放到备库中,也能够缓解主库的压力。


那在小王这个场景中是否就可直接用呢?


答案是可以搞灾备,但是不可以做读写分离。因为在业务流程中会存在大量的校验和事务操作,搞读写分离会出现数据丢失,读脏数据等各种问题。


如果要做好容灾,当主库出现问题时,目前最好的办法是走failover机制。


failover就是两套AB库,但是他们不是主备关系,也不走数据同步。而是相同的两套数据库。当一个请求过来时访问主库失败了。系统会根据实际情况,决定是否将后面的请求切换到FO库执行,这样至少能保证后面的用户请求不出现问题。


那有同学就会问了,那这样切到一个新库的话,前面的用户流程不就丢失了


这其实正好是流水型数据的特点,流水型数据不关心不同请求的关系。他只关心在一次请求发起后,在整个系统链路中是保证一致的就好。对于单据这类的流水型数据,前面是否有历史单据其实并不重要。但是像用户个人数据,比如账单啊之类的,这个是不放到流水型数据中的,这个是状态型数据。所以从用户角度,他还是能够看到自己的历史账单。


那这样是不是就可以不用灾备了?


也不是,两者功能不一样,光有failover没有灾备也不行,假如主库真的因为硬件故障导致恢复不了数据了呢?这个时候不就丢数据了?


第六章:对账


问题9:错误报警


除了上面的db容灾之外,我们还需要考虑流程的正确性,确保不会因为bug或者其他原因导致数据不一致。在节点与节点之间,我们还需要做对账的处理,以便实现二次检查。


对账一般分为实时对账和离线对账,实时对账就是去取两个节点的db,拿binlog作为消息触发一次数据的对比逻辑一般由开发或者测试提供方案。离线对账就是将两边节点的数据通过odps放到离线库中,然后每天起一个任务跑一下看数据是否对得上。一般比较严谨的业务这两个对账都存在


但是有对账就有误报的情况。最典型的误报就是上游数据状态显示失败,下游数据状态显示成功这类情况。这是怎么发生的呢?


这个案例其实前面已经提到过了,类似小王这个案例,假如小王在向下游接口请求做奖品发放的时候,下游执行成功,但是在返回给到小王结果时因为网络抖动导致超时,小王这边收到未知异常的结果,但是下游确实成功的,两边不一致。虽然小王可以通过上游触发重试机制,通过幂等二次推进来查看最终结果,但是有时候上游也不一定会触发重试机制的。一般这种情况再对账监控时就会出现报警。


所以一般情况下一个节点都要自己搞个定时任务,遍历检查所有非最终态的单据,构建主动触发重试机制,以确保两边的链路保持一致。


小王通过各种努力,不断与测试同学「磨合」,并最终完成了所有前方战线及后方战线的开发。觉得收益颇多,沉淀了文档,他给测试同学看了一下自己的总结沉淀。测试同学看了后也夸了夸小王做的不错,转身回去,又给小王提了一个bug


这里系统上虽然没问题了,但是在某些场景上还有个业务逻辑问题,你知道这个bug是什么吗?




云栖号的伙伴群开启了,欢迎大家入群聊起来!

大家想看什么内容,我们可以一起聊聊~

👇👇👇


 ✨ 精彩推荐✨  


 技 术 好 文 


 企 业 案 例 



↓ 直通阿里云 ☁️