分布式系统——分布式事务的使用场景(“恰好”不需要使用它)

341 阅读15分钟

世界上解决一个计算机问题最简单的方法:“恰好”不需要解决它!—— 沈询

案例1:下订单和扣库存

电商系统中有一个经典的案例,即下订单和扣库存如何保持一致。如果先下订单,扣库存失败,那么将会导致超卖;如果下订单不成功,扣库存成功,那么会导致少卖。这两种情况都会导致运营成本增加,在严重情况下需要赔付。

案例2:同步调用超时

服务化的系统间调用常常因为网络问题导致系统间调用超时,即使是网络状况很好的机房,在亿次流量的基数下,同步调用超时也是家常便饭。系统A同步调用系统B超时,系统A可以明确得到超时反馈,但是无法确定系统B是否已经完成了预设的功能。于是,系统A不知道应该继续做什么,如何反馈给使用方。

案例3:异步回调超时

此案例和上一个同步超时的案例类似,不过这是一个受理模式的场景,使用了异步回调返回处理结果,系统A同步调用系统B发起指令,系统B采用受理模式,受理后则返回成功信息,然后系统B处理后异步通知系统A处理结果。在这个过程中,如果系统A由于某种原因迟迟没有收到回调结果,那么这两个系统间的状态就不一致,互相认知的状态不同会导致系统间发生错误,在严重情况下会影响核心链路上的交易的状态准确性,甚至会导致资金损失。

案例4:掉单

在分布式系统中,两个系统协作处理一个流程,分别为对方的上下游,如果一个系统中存在一个请求(通常指订单),另外一个系统不存在,则会导致掉单,掉单的后果很严重,有时也会导致资金损失。

案例5:系统间状态不一致

此案例与上面掉单的案例类似,不同的是两个系统间都存在请求,但是请求的状态不一致。

案例6:缓存和数据库不一致

交易系统基本上离不开关系型数据库,依赖关系型数据库提供的ACID特性,但是在大规模、高并发的互联网系统里,一些特殊的场景对读操作的性能要求极高,服务于交易的数据库难以抗住大规模的读流量,通常需要在数据库前增加一层缓存,那么缓存和数据库之间的数据如何保持一致性?是要保持强一致性还是弱一致性呢?

案例7:本地缓存节点间不一致

一个服务池上的多个节点为了满足较高的性能需求,需要使用本地缓存,这样每个节点都会有一份缓存数据的复制,如果这些数据是静态的、不变的,就永远不会有问题,但是如果这些数据是半静态的或者经常被更新的,则被更新时各个节点的更新是有先后顺序的,在更新的瞬间,在某个时间窗口内各个节点的数据是不一致的,如果这些数据是为某个开关服务的,则想象一下重复的请求进入了不同的节点(在failover重试或者补偿的场景下,重复请求是一定会发生的,也是服务化系统必须处理的),一个请求进入了开关打开的逻辑,同时另外一个请求进入了开关关闭的逻辑,会导致请求被处理两次,最坏的情况是导致资金损失。

案例8:缓存数据结构不一致

这个案例时有发生,某系统需要在缓存中暂存某种类型的数据,该数据由多个数据元素组成,其中,某个数据元素需要从数据库或者服务中获取,如果一部分数据元素获取失败,则由于程序处理不正确,仍然将不完全的数据存入缓存中,在缓存使用者使用时很有可能因为数据的不完全而抛出异常,例如NullPointerException等,然后可能因为没有合理处理异常而导致程序出错。


在大规模、高并发服务化系统中,一个功能被拆分成多个具有单一功能的子功能,一个流程会有多个系统的多个单一功能的服务组合实现,如果使用两阶段提交协议和三阶段提交协议,则确实能解决系统间的一致性问题。除了这两个协议的自身问题,其实现也比较复杂、成本比较高,最重要的是性能不好, 相比来看,TCC协议更简单且更容易实现,但是TCC协议由于每个事务都需要执行Try,再执行Confirm,略显臃肿.

现实系统的底线是仅仅需要达到最终一致性,而不需要实现专业的、复杂的一致性协议。实现最终一致性有一些非常有效、简单的模式,下面就介绍这些模式及其应用场景。

1.查询模式

任何服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。服务操作的使用方可以通过查询接口得知服务操作执行的状态,然后根据不同的状态来做不同的处理操作。

为了能够实现查询,每个服务操作都需要有唯一的流水号标识,也可使用此次服务操作对应的资源ID来标识,例如:请求流水号、订单号等。

首先,单笔查询操作是必须提供的,也鼓励使用单笔订单查询,这是因为每次调用需要占用的负载是可控的。批量查询则根据需求来提供,如果使用了批量查询,则需要有合理的分页机制,并且必须限制分页的大小,以及对批量查询的吞吐量有容量评估、熔断、隔离和限流等措施。

查询模式如图2-4所示。

对于案例2~案例5,我们都需要使用查询模式来了解被调用服务的处理情况,决定下一步做什么,例如是补偿未完成的操作还是回滚已经完成的操作。

2.补偿模式

有了上面的查询模式,在任何情况下,我们都能得知具体的操作所处的状态,如果整个操作都处于不正常的状态,则我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,或者取消已经完成的子操作,通过修复使整个分布式系统达到一致。为了让系统最终达到一致状态而做的努力都叫作补偿。

对于服务化系统中同步调用的操作,若业务操作发起方还没有收到业务操作执行方的明确返回或者调用超时,则可参考案例2,这时业务发起方需要及时地调用业务执行方来获得操作执行的状态,这里使用在前面学习的查询模式。在获得业务操作执行方的状态后,如果业务执行方已经完成预设工作,则业务发起方向业务的使用方返回成功;如果业务操作执行方的状态为失败或者未知,则会立即告诉业务使用方失败,也叫作快速失败策略,然后调用业务操作的逆向操作,保证操作不被执行或者回滚已经执行的操作,让业务使用方、业务操作发起方和业务操作执行方最终达到一致状态。

补偿模式如图2-5所示。

补偿操作根据发起形式分为以下几种。

  • 自动恢复:程序根据发生不一致的环境,通过继续进行未完成的操作,或者回滚已经完成的操作,来自动达到一致状态。
  • 通知运营:如果程序无法自动恢复,并且设计时考虑到了不一致的场景,则可以提供运营功能,通过运营手工进行补偿。
  • 技术运营:如果很不巧,系统无法自动恢复,又没有运营功能,那么必须通过技术手段来解决,技术手段包括进行数据库变更或者代码变更,这是最糟的一种场景,也是我们在生产中尽量避免的场景。

3.异步确保模式 (mq思想的源头)

异步确保模式是补偿模式的一个典型案例,经常应用到使用方对响应时间要求不太高的场景中,通常把这类操作从主流程中摘除,通过异步的方式进行处理,处理后把结果通过通知系统通知给使用方。这个方案的最大好处是能够对高并发流量进行消峰,例如:电商系统中的物流、配送,以及支付系统中的计费、入账等。

在实践中将要执行的异步操作封装后持久入库,然后通过定时捞取未完成的任务进行补偿操作来实现异步确保模式,只要定时系统足够健壮,则任何任务最终都会被成功执行。

异步确保模式如图2-6所示。

对于2.2节中的案例3,若对某个操作迟迟没有收到响应,则通过查询模式、补偿模式和异步确保模式来继续未完成的操作。

4.定期校对模式

系统在没有达到一致之前,系统间的状态是不一致的,甚至是混乱的,需要通过补偿操作来达到最终一致性的目的,但是如何来发现需要补偿的操作呢?

在操作主流程中的系统间执行校对操作,可以在事后异步地批量校对操作的状态,如果发现不一致的操作,则进行补偿,补偿操作与补偿模式中的补偿操作是一致的。

另外,实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,生成全局唯一ID有以下两种方法。

  • 持久型:使用数据库表自增字段或者Sequence生成,为了提高效率,每个应用节点可以缓存一个批次的ID,如果机器重启则可能会损失一部分ID,但是这并不会产生任何问题。
  • 时间型:一般由机器号、业务号、时间、单节点内自增ID组成,由于时间一般精确到秒或者毫秒,因此不需要持久就能保证在分布式系统中全局唯一、粗略递增等。

分布式系统中的调用链跟踪如图2-8所示。

全局的唯一流水 ID 可以将一个请求在分布式系统中的流转路径聚合,而调用链中的SpanID可以将聚合的请求路径通过树形结构进行展示,让技术支持工作人员轻松地发现系统出现的问题,能够快速定位出现问题的服务节点,提高应急效率。笔者会在第5章中介绍基于调用链的服务治理系统的设计与实现。

在分布式系统中构建了唯一ID、调用链等基础设施后,我们很容易对系统间的不一致进行核对。通常我们需要构建第三方的定期核对系统,从第三方的角度来监控服务执行的健康程度。

定期核对系统如图2-9所示。

对于2.2节的案例4和案例5,通常通过定期校对模式发现问题,并通过补偿模式来修复,最后达到系统间的最终一致性。

定期校对模式多应用于金融系统中。金融系统由于涉及资金安全,需要保证准确性,所以需要多重的一致性保证机制,包括商户交易对账、系统间的一致性对账、现金对账、账务对账、手续费对账等,这些都属于定期校对模式。顺便说一下,金融系统与社交应用在技术上的本质区别为:社交应用在于量大,而金融系统在于数据的准确性。

到现在为止,我们看到通过查询模式、补偿模式和定期核对模式可以解决2.2节中案例2~案例5的所有问题:对于案例2,如果同步超时发生,则需要查询状态进行补偿;对于案例3,如果迟迟没有收到回调响应,则也会通过查询状态进行补偿;对于案例4和案例5,通过定期核对模式可以保证系统间操作的一致性,避免因为掉单和状态不一致而导致出现问题,可实时止损。

5.可靠消息模式

在分布式系统中,对于主流程中优先级比较低的操作,大多采用异步的方式执行,也就是前面提到的异步确保模型,为了让异步操作的调用方和被调用方充分解耦,也由于专业的消息队列本身具有可伸缩、可分片、可持久等功能,我们通常通过消息队列实现异步化。对于消息队列,我们需要建立特殊的设施来保证可靠的消息发送及处理机的幂等性。

1)消息的可靠发送

消息的可靠发送可以认为是尽最大努力发送消息通知,有以下两种实现方法。

  • 第1种,在发送消息之前将消息持久到数据库,状态标记为待发送,然后发送消息,如果发送成功,则将消息改为发送成功。定时任务定时从数据库捞取在一定时间内未发送的消息并将消息发送。可靠消息发送模式1如图2-10所示。

  • 第2种,该实现方式与第1种类似,不同的是持久消息的数据库是独立的,并不耦合在业务系统中。发送消息前,先发送一个预消息给某个第三方的消息管理器,消息管理器将其持久到数据库,并标记状态为待发送,在发送成功后,标记消息为发送成功。定时任务定时从数据库中捞取一定时间内未发送的消息,查询业务系统是否要继续发送,根据查询结果来确定消息的状态。可靠消息发送模式2如图2-11所示。

一些公司把消息的可靠发送实现在了中间件里,通过Spring的注入,在消息发送时自动持久消息记录,如果有消息记录没有发送成功,则定时补偿发送。

2)消息处理器的幂等性

如果我们要保证可靠地发送消息,简单来说就是要保证消息一定发送出去,那么需要有重试机制。有了重试机制后,消息就一定会重复,那么我们需要对重复的问题进行处理。

处理重复问题的最佳方式是保证操作的幂等性,幂等性的数学公式为:

f(f(x))=f(x)f(f(x))= f(x)

保证操作的幂等性的常用方法如下。

  • 使用数据库表的唯一键进行滤重,拒绝重复的请求。
  • 使用分布式表对请求进行滤重。
  • 使用状态流转的方向性来滤重,通常使用数据库的行级锁来实现。
  • 根据业务的特点,操作本身就是幂等的,例如:删除一个资源、增加一个资源、获得一个资源等。

6.缓存一致性模式

在大规模、高并发系统中的一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存来抗住读流量。下面是使用缓存来保证一致性的最佳实践。

  • 如果性能要求不是非常高,则尽量使用分布式缓存,而不要使用本地缓存。
  • 写缓存时数据一定要完整,如果缓存数据的一部分有效,另一部分无效,则宁可在需要时回源数据库,也不要把部分数据放入缓存中。
  • 使用缓存牺牲了一致性,为了提高性能,数据库与缓存只需要保持弱一致性,而不需要保持强一致性,否则违背了使用缓存的初衷。
  • 读的顺序是先读缓存,后读数据库,写的顺序要先写数据库,后写缓存。 这里的最佳实践能够避免案例6、案例7和案例8中的问题。