分布式系统设计中关于数据一致性的问题

分布式系统设计中关于数据一致性的问题

  • 举例说明
  • CAP
  • 数据一致性的常见的解决方法

举例说明

  在电商等业务中,系统一般由多个独立的服务组成,如何解决分布式调用时候数据的一致性? 具体业务场景如下,比如一个业务操作,如果同时调用服务 A、B、C,需要满足要么同时成功;要么同时失败。A、B、C 可能是多个不同部门开发、部署在不同服务器上的远程服务。
  例如:用户下了一个订单,发现账户里面有余额,然后使用余额支付,支付成功之后,订单状态修改为支付成功,然后通知仓库发货。假设订单系统,支付系统,仓库系统是三个独立的应用,是独立部署的,系统之间通过远程服务调用。订单的有三个状态:I-初始、P-已支付、W-已出库,订单金额100, 帐户余额200。正常情况下,订单的状态会变为I->P->W,帐户余额100,订单出库。但是如果流程不顺利了呢?考虑以下几种情况:
 【1】订单系统调用支付系统支付订单,支付成功,但是返回给订单系统数据超时,订单还是I(初始状态),但是此时帐户余额100。
 【2】订单系统调用支付系统成功,状态也已经更新成功,但是通知仓库发货失败,这个时候订单是P(已支付)状态,帐户余额100,但是仓库不会发货。
 【3】订单系统调用支付系统成功,状态也已经更新成功,然后通知仓库发货,仓库告诉订单系统,没有货了。此时,数据状态与【2】一样。

 对于【1】,能想到的解决方案如下:
  1、假设调用支付系统支付订单的时候先不扣钱,订单状态更新完成之后,再通知支付系统扣钱。如果采用这种设计方案,在同一时刻,这个用户又支付了另外一笔订单,订单价格200,顺利完成了整个订单支付流程,由于当前订单的状态已经变成了支付成功,但是实际用户已经没有钱支付了,这笔订单的状态就不一致了。即使用户在同一个时刻没有进行另外的订单支付行为,通知支付系统扣钱这个动作也有可能完不成,反而增加了系统的复杂性。
  如果支付系统先不扣钱,而是先把钱冻结起来,不让用户给其他订单支付,然后等订单系统把订单状态更新为支付成功的时候,再通知支付系统,这个时候支付系统扣钱,完成后续的操作。假设订单系统在调用支付系统冻结的时候,支付系统冻结成功,但是订单系统超时,返回给用户,告知用户支付失败,如果用户再次支付这笔订单,由于支付系统进行控制,告诉订单系统冻结成功,订单系统更新状态,然后通知支付系统。如果这个时候通知失败,由于钱已经被冻结,用户不能用,只要定时扫描订单和支付状态,进行扣钱而已。但是如果用户重新拍下来一笔订单,100块钱,对新的订单进行支付,这个时候由于先前那一笔订单的钱被冻结了,这个时候用户余额剩余100,冻结100,发现可用的余额足够,直接扣钱。这个时候余额剩余0,冻结100。先前那一笔怎么办,一个办法就是定时扫描,发现订单状态是初始的话,就对用户的支付余额进行解冻处理。这个时候用户的余额变成100,订单数据和支付数据又一致了。假设原先用户余额只有100,被冻结了,用户重新下单,支付的时候就会失败,所以要尽可能的保证在第一次订单结果不明确的情况,尽早解冻用户余额,比如10秒之内。但是不管如何快速,总有数据不一致的时刻,这个是没有办法避免的。
  2、订单系统自动发起重试,多次重试,直到扣款成功为止。假设订单系统第一次调用支付系统成功,但是没有办法收到应答,订单系统又发起调用,重复支付,一次订单支付了200。
  3、在第二种方案的基础上,先解决订单的重复支付行为,需要在支付系统上对订单号进行控制,一笔订单如果已经支付成功,不能再进行支付,返回重复支付标识。订单系统根据返回的标识,更新订单状态。然后解决重试问题,假设应用重试三次,如果三次都失败,先返回给用户提示支付结果未知。假设这个时候用户重新发起支付,订单系统调用支付系统,发现订单已经支付,那么继续下面的流程。如果没有发起支付,系统定时(一分钟一次)去核对订单状态,如果发现已经被支付,则继续后续的流程。这种方案,用户体验非常差,告诉用户支付结果未知。
  上面分析了第一个的问题以及相应的方案,发现在数据分布的环境下,很难绝对的保证数据一致性(任何一段区间),但是有办法通过一种补偿机制,最终保证数据的一致性。

 对于【2】:可以采取重试机制,如果发现通知仓库发货失败,就一直重试。这里面有两种方式:
 1、异步方式:通过类似MQ(消息通知)的机制,这个是异步的通知
 2、同步调用:类似于远程过程调用
对于同步的调用的方式,比较简单,能够及时获取结果;对于异步的通知,就必须采用请求,应答的方式进行。

 对于【3】:存在以下几种解决的方案:
 1、在用户下单的时刻,告诉仓库把货物留下来。在用户下单的时候,相当于库存减1,当用户恶意下单,没有去支付,就影响到了其他用户的购买。可以设置一个订单超时时间,如果这段时间内没有支付,就自动取消订单。
 2、支付订单时候,在支付之前检查仓库有没有货,如果没有货,就告知用户没有货物了。对于这种方案,用户体验不好。
 3、如果用户支付成功,这个时候没有货了,退款给用户或者等待有货的时候再发货,用户体验同样不好。
  正常情况,电商的仓库一般都是有货的,所以影响到的用户很少,但是在秒杀或者营销的时候,这个时候就不一定了。对于秒杀和促销商品,可以考虑第一种方案,大多数人都会直接付款,这样可以照顾大多数用户的体验。对于一般的订单,可以采用第二种或者第三种方式,这种情况下,发生付款之后仓库没有货的情况会比较少,这样就可以实现自己的利益最大化而最低程度的减少用户体验。而铁道部对于这个问题采用的是第一种方案,因为这里更注重用户体验。

CAP

  分布式环境下(数据分布)要任何时刻保证数据一致性是不可能的,只能采取妥协的方案来保证数据最终一致性,即CAP定理。在分布式的环境下设计和部署系统时,有3个核心的需求,以一种特殊的关系存在。这里的分布式系统说的是在物理上分布的系统,比如我们常见的web系统。其中3个核心的需求是:Consistency,Availability和Partition Tolerance(CAP)。
  Consistency(一致性):与数据库ACID的一致性类似,但这里关注的是所有数据节点上的数据一致性和正确性,而数据库的ACID关注的是在一个事务内,对数据的一些约束。
  Availability(可用性):关注某个结点的数据是否可用,可以认为某一个节点的系统是否可用,通信故障除外。
  Partition Tolerance(分区容忍性):是否可以对数据进行分区。这是考虑到性能和可伸缩性。
  这三点为什么不能完全保证?因为一旦进行数据分区就说明了节点之间必须进行通信,涉及到通信,就无法确保在有限的时间内完成指定的行文。如果要求两个操作之间要完整的进行,因为涉及到通信,肯定存在某一个时刻只完成一部分的业务操作,在通信完成的这一段时间内,数据就是不一致性的。如果要求保证一致性,那么就必须在通信完成这一段时间内保护数据,使得任何访问这些数据的操作不可用。如果想保证一致性和可用性,那么数据就不能够分区。一个简单的理解就是所有的数据就必须存放在一个数据库里面,不能进行数据库拆分。这个对于大数据量,高并发的互联网应用来说,是不可接受的。
  例如:一个购物系统,卖家A和卖家B做了一笔交易100元,交易成功,买家把钱给卖家。这里面存在两张表的数据:Trade表Account表 ,涉及到三条数据Trade(100),Account A ,Account B。假设Trade表和Account表在一个数据库,那么只需要使用数据库的事务,就可以保证一致性,同时不会影响可用性。但是随着交易量越来越大,可以考虑按照业务分库,把Trade库和Account库单独分开,这样就涉及到Trade库和Account库进行通信,即分区,也就不可能同时保证可用性和一致性。假设初始状态:
 trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,I)
 account(accountNo,balance) = account(A,300)
 account(accountNo,balance) = account(B,10)
在理想情况下,期望的状态为:
 trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
 account(accountNo,balance) = account(A,200)
 account(accountNo,balance) = account(B,110)
但是考虑到一些异常情况,假设在trade(20121001,S)更新完成之后,帐户A进行扣款之前,帐户A进行了另外一笔300款钱的交易,把钱消费了,那么就存在一个状态:
 trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
 account(accountNo,balance) = account(A,0)
 account(accountNo,balance) = account(B,10)
产生了数据不一致的状态。由于这个涉及到资金上的问题,对资金要求比较高,因此必须保证一致,只能在进行trade(A,B,20121001)交易的时候,对于任何A的后续交易请求trade(A,X,X),必须等到A完成之后,才能够进行处理,即trade(A,B,20121001)的时候,Account(A)的数据是不可用的。
  任何架构师在设计分布式的系统的时候,都必须在这三者之间进行取舍。首先就是选择是否分区,由于在一个数据分区内,根据数据库的ACID特性,是可以保证一致性的,不会存在可用性和一致性的问题,唯一需要考虑的就是性能问题。对于可用性和一致性,大多数应用就必须保证可用性,毕竟牺牲了可用性,相当于间接的影响了用户体验,而唯一可以考虑就是一致性了。
牺牲一致性
  对于牺牲一致性,最多的就是缓存和数据库的数据同步问题。把缓存看做一个数据分区节点,数据库看作另外一个节点,这两个节点之间的数据在任何时刻都无法保证一致性的。访问一个用户的信息的时候,可以先访问缓存的数据,但是如果用户修改了自己的一些信息,首先修改的是数据库,然后再通知缓存进行更新,这段期间内就会导致的数据不一致,用户可能访问的是一个过期的缓存,而不是最新的数据。
  另外一种牺牲一致性的方法是通过一种错误补偿机制来进行,拿之前的例子来说,假设将业务逻辑顺序调整一下,先扣买家钱,然后更新交易状态,再把钱打给卖家。假设初始状态:
 account(accountNo,balance) = account(A,300)
 account(accountNo,balance) = account(B,10)
 trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,I)
那么有可能出现:
 account(accountNo,balance) = account(A,200)
 trade(buyer,seller,tradeNo,status) = trade(A,B,20121001,S)
 account(accountNo,balance) = account(B,10)
即出现A扣款成功,交易状态也成功,但是钱没有打给B,此时可以通过一个异常恢复机制,把钱打给B,最终的情况保证了一致性,在一定时间内数据可能是不一致的,但是不会影响太大。上面的异常检测恢复机制(事后补偿),这种机制其实还是有限制,首先对于分区检测操作,不同的业务涉及到的分区操作可能不一样。

数据一致性的常见的解决方法

  在分布式系统来说,如果不想牺牲一致性,根据CAP 理论告诉只能放弃可用性,这显然不能接。数据一致性的基础理论:
强一致
  当更新操作完成之后,任何多个后续进程或者线程的访问都会返回最新的更新过的值。这种是对用户最友好的,即用户上一次写什么,下一次就保证能读到什么。根据 CAP 理论,这种实现需要牺牲可用性。
弱一致性
  系统并不保证后续进程或者线程的访问都会返回最新的更新过的值。系统在数据写入成功之后,不承诺立即可以读到最新写入的值,也不会具体的承诺多久之后可以读到。
最终一致性
  弱一致性的特定形式。系统保证在没有后续更新的前提下,系统最终返回上一次更新操作的值。在没有故障发生的前提下,不一致窗口的时间主要受通信延迟,系统负载和复制副本的个数影响。DNS 是一个典型的最终一致性系统。
  在工程实践上,为了保障系统的可用性,互联网系统大多将强一致性需求转换成最终一致性的需求,并通过系统执行幂等性的保证,保证数据的最终一致性。但在电商等场景中,对于数据一致性的解决方法和常见的互联网系统(如 MySQL 主从同步)又有一定区别,分成以下 6 种解决方案:

  1. 规避分布式事务——业务整合
      业务整合方案主要采用将接口整合到本地执行的方法。拿问题场景来说,则可以将服务 A、B、C 整合为一个服务 D 给业务,这个服务 D 再通过转换为本地事务的方式,比如服务 D 包含本地服务和服务 E,而服务 E 是本地服务 A ~ C 的整合。
    优点:解决(规避)了分布式事务。
    缺点:显而易见,把本来规划拆分好的业务,又耦合到了一起,业务职责不清晰,不利于维护。
  2. 经典方案——eBay 模式
      此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文本、数据库或消息队列,再通过业务规则自动或人工发起重试。人工重试更多的是应用于支付场景,通过对账系统对事后问题的处理。消息日志方案的核心是保证服务接口的幂等性。考虑到网络通讯失败、数据丢包等原因,如果接口不能保证幂等性,数据的唯一性将很难保证。
    eBay 方式的主要思路如下:
      Base:一种 ACID 的替代方案。BASE 与 ACID 原则在保证数据一致性的基本差异。如果 ACID 为分区的数据库提供一致性的选择,那么如何实现可用性呢?答案是BASE (basically available, soft state, eventually consistent)。BASE 的可用性是通过支持局部故障而不是系统全局故障来实现的。如果将用户分区在 5 个数据库服务器上,BASE 设计鼓励类似的处理方式,一个用户数据库的故障只影响这台特定主机那 20% 的用户。如果产生了一笔交易,需要在交易表增加记录,同时还要修改用户表的金额。这两个表属于不同的远程服务,所以就涉及到分布式事务一致性的问题。一个经典的解决方法,将主要修改操作以及更新用户表的消息放在一个本地事务来完成。同时为了避免重复消费用户表消息带来的问题,达到多次重试的幂等性,增加一个更新记录表 updates_applied 来记录已经处理过的消息。基于以上方法,在第一阶段,通过本地的数据库的事务保障,增加了 transaction 表及消息队列 。在第二阶段,分别读出消息队列(但不删除),通过判断更新记录表 updates_applied 来检测相关记录是否被执行,未被执行的记录会修改 user 表,然后增加一条操作记录到 updates_applied,事务执行成功之后再删除队列。通过以上方法,达到了分布式系统的最终一致性。
  3. 去哪儿网分布式事务方案
      随着业务规模不断地扩大,电商网站一般都要面临拆分之路。就是将原来一个单体应用拆分成多个不同职责的子系统。比如以前可能将面向用户、客户和运营的功能都放在一个系统里,现在拆分为订单中心、代理商管理、运营系统、报价中心、库存管理等多个子系统。最开始的单体应用所有功能都在一起,存储也在一起。比如运营要取消某个订单,直接去更新订单表状态,然后更新库存表。因为是单体应用,库在一起,这些都可以在一个事务里,由关系数据库来保证一致性。但拆分之后就不同了,不同的子系统都有自己的存储。比如订单中心就只管理订单库,而库存管理也有库存库,那么运营系统取消订单的时候就是通过接口调用等方式来调用订单中心和库存管理的服务了,而不是直接去操作库。这就涉及一个分布式事务的问题。分布式事务有两种解决方式:
    1、优先使用异步消息
      使用异步消息 Consumer 端需要实现幂等。幂等有两种方式,一种方式是业务逻辑保证幂等。比如接到支付成功的消息订单状态变成支付完成,如果当前状态是支付完成,则再收到一个支付成功的消息则说明消息重复了,直接作为消息成功处理。另外一种方式如果业务逻辑无法保证幂等,则要增加一个去重表或者类似的实现。对于 producer 端在业务数据库的同实例上放一个消息库,发消息和业务操作在同一个本地事务里。发消息的时候消息并不立即发出,而是向消息库插入一条消息记录,然后在事务提交的时候再异步将消息发出,发送消息如果成功则将消息库里的消息删除,如果遇到消息队列服务异常或网络问题,消息没有成功发出那么消息就留在这里了,会有另外一个服务不断地将这些消息扫出重新发送。
    2.有的业务不适合异步消息的方式
      事务的各个参与方都需要同步的得到结果。这种情况的实现方式其实和上面类似,每个参与方的本地业务库的同实例上面放一个事务记录库。比如 A 同步调用 B,C。A 本地事务成功的时候更新本地事务记录状态,B 和 C 同样。如果有一次 A 调用 B 失败了,这个失败可能是 B 真的失败了,也可能是调用超时,实际 B 成功。则由一个中心服务对比三方的事务记录表,做一个最终决定。假设现在三方的事务记录是 A 成功,B 失败,C 成功。那么最终决定有两种方式,根据具体场景:
     1、重试 B,直到 B 成功,事务记录表里记录了各项调用参数等信息;
     2、执行 A 和 B 的补偿操作(一种可行的补偿方式是回滚)。
    对于场景1:比如 B 是扣库存服务,在第一次调用的时候因为某种原因失败了,但是重试的时候库存已经变为 0,无法重试成功,这个时候只有回滚 A 和 C 了。那么在业务库的同实例里放消息库或事务记录库,会对业务侵入,业务还要关心这个库,是否一个合理的设计?实际上可以依靠运维的手段来简化开发的侵入,让 DBA 在公司所有 MySQL 实例上预初始化这个库,通过框架层(消息的客户端或事务 RPC 框架)透明的在背后操作这个库,业务开发人员只需要关心自己的业务逻辑,不需要直接访问这个库。
      总结起来,其实两种方式的根本原理是类似的,也就是将分布式事务转换为多个本地事务,然后依靠重试等方式达到最终一致性。
  4. 蘑菇街交易创建过程中的分布式一致性方案
      交易创建的一般性流程:将交易创建流程抽象出一系列可扩展的功能点,每个功能点都可以有多个实现(具体的实现之间有组合/互斥关系)。把各个功能点按照一定流程串起来,就完成了交易创建的过程。 面临的问题:每个功能点的实现都可能会依赖外部服务。那么如何保证各个服务之间的数据是一致的呢?比如锁定优惠券服务调用超时了,不能确定到底有没有锁券成功,该如何处理?再比如锁券成功了,但是扣减库存失败了,该如何处理?
    方案选型
      服务依赖过多,会带来管理复杂性增加和稳定性风险增大的问题。试想如果我们强依赖 10 个服务,9 个都执行成功了,最后一个执行失败了,那么是不是前面 9 个都要回滚掉?这个成本还是非常高的。所以在拆分大的流程为多个小的本地事务的前提下,对于非实时、非强一致性的关联业务写入,在本地事务执行成功后,我们选择发消息通知、关联事务异步化执行的方案。消息通知往往不能保证 100% 成功;且消息通知后,接收方业务是否能执行成功还是未知数。前者问题可以通过重试解决;后者可以选用事务消息来保证。
      但是事务消息框架本身会给业务代码带来侵入性和复杂性,所以选择基于 DB 事件变化通知到 MQ 的方式做系统间解耦,通过订阅方消费 MQ 消息时的 ACK 机制,保证消息一定消费成功,达到最终一致性。由于消息可能会被重发,消息订阅方业务逻辑处理要做好幂等保证。
      所以目前只剩下需要实时同步做、有强一致性要求的业务场景了。在交易创建过程中,锁券和扣减库存是这样的两个典型场景。要保证多个系统间数据一致,必须要引入分布式事务框架才能解决。但引入非常重的类似二阶段提交分布式事务框架会带来复杂性的急剧上升;在电商领域,绝对的强一致是过于理想化的,可以选择准实时的最终一致性。在交易创建流程中,首先创建一个不可见订单,然后在同步调用锁券和扣减库存时,针对调用异常(失败或者超时),发出废单消息到MQ。如果消息发送失败,本地会做时间阶梯式的异步重试;优惠券系统和库存系统收到消息后,会进行判断是否需要做业务回滚,这样就准实时地保证了多个本地事务的最终一致性。
  5. 支付宝及蚂蚁金融云的分布式服务 DTS 方案
      其主要思路如下:分布式事务服务 (Distributed Transaction Service, DTS) 是一个分布式事务框架,用来保障在大规模分布式环境下事务的最终一致性。DTS 从架构上分为 xts-client 和 xts-server 两部分,前者是一个嵌入客户端应用的 JAR 包,主要负责事务数据的写入和处理;后者是一个独立的系统,主要负责异常事务的恢复。
    核心特性
      传统关系型数据库的事务模型必须遵守 ACID 原则。在单数据库模式下,ACID 模型能有效保障数据的完整性,但是在大规模分布式环境下,一个业务往往会跨越多个数据库,如何保证这多个数据库之间的数据一致性,需要其他行之有效的策略。在 JavaEE 规范中使用 2PC (2 Phase Commit, 两阶段提交) 来处理跨 DB 环境下的事务问题,但是 2PC 是反可伸缩模式,也就是说,在事务处理过程中,参与者需要一直持有资源直到整个分布式事务结束。这样,当业务规模达到千万级以上时,2PC 的局限性就越来越明显,系统可伸缩性会变得很差。基于此,我们采用 BASE 的思想实现了一套类似 2PC 的分布式事务方案,这就是 DTS。DTS在充分保障分布式环境下高可用性、高可靠性的同时兼顾数据一致性的要求,其最大的特点是保证数据最终一致 (Eventually consistent)。
    DTS 框架有如下特性:
    最终一致:事务处理过程中,会有短暂不一致的情况,但通过恢复系统,可以让事务的数据达到最终一致的目标。
    协议简单:DTS 定义了类似 2PC 的标准两阶段接口,业务系统只需要实现对应的接口就可以使用 DTS 的事务功能。
    与 RPC 服务协议无关:在 SOA 架构下,一个或多个 DB 操作往往被包装成一个一个的 Service,Service 与 Service 之间通过 RPC 协议通信。DTS 框架构建在 SOA 架构上,与底层协议无关。
    与底层事务实现无关: DTS 是一个抽象的基于 Service 层的概念,与底层事务实现无关,也就是说在 DTS 的范围内,无论是关系型数据库 MySQL,Oracle,还是 KV 存储 MemCache,或者列存数据库 HBase,只要将对其的操作包装成 DTS 的参与者,就可以接入到 DTS 事务范围内。
    实现
    一个完整的业务活动由一个主业务服务与若干从业务服务组成。主业务服务负责发起并完成整个业务活动。从业务服务提供 TCC 型业务操作。业务活动管理器控制业务活动的一致性,它登记业务活动中的操作,并在活动提交时确认所有的两阶段事务的 confirm 操作,在业务活动取消时调用所有两阶段事务的 cancel 操作。”与 2PC 协议比较,没有单独的 Prepare 阶段,降低协议成本,系统故障容忍度高,恢复简单。
  6. 农信网数据一致性方案
    1.电商业务
      公司的支付部门,通过接入其它第三方支付系统来提供支付服务给业务部门,支付服务是一个基于 Dubbo 的 RPC 服务。对于业务部门来说,电商部门的订单支付,需要调用支付平台的支付接口来处理订单;同时需要调用积分中心的接口,按照业务规则,给用户增加积分。从业务规则上需要同时保证业务数据的实时性和一致性,也就是支付成功必须加积分。我们采用的方式是同步调用,首先处理本地事务业务。考虑到积分业务比较单一且业务影响低于支付,由积分平台提供增加与回撤接口。具体的流程是先调用积分平台增加用户积分,再调用支付平台进行支付处理,如果处理失败,catch 方法调用积分平台的回撤方法,将本次处理的积分订单回撤。
    2.用户信息变更
      公司的用户信息,统一由用户中心维护,而用户信息的变更需要同步给各业务子系统,业务子系统再根据变更内容,处理各自业务。用户中心作为 MQ 的producer,添加通知给 MQ。APP Server 订阅该消息,同步本地数据信息,再处理相关业务比如 APP 退出下线等。我们采用异步消息通知机制,目前主要使用 ActiveMQ,基于 Virtual Topic 的订阅方式,保证单个业务集群订阅的单次消费。

总结
  分布式服务对衍生的配套系统要求比较多,特别是基于消息、日志的最终一致性方案,需要考虑消息的积压、消费情况、监控、报警等。

你可能感兴趣的:(大数据,分布式系统)