在电商等业务中,系统一般由多个独立的服务组成,如何解决分布式调用时候数据的一致性? 具体业务场景如下,比如一个业务操作,如果同时调用服务 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定理。在分布式的环境下设计和部署系统时,有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 种解决方案:
总结
分布式服务对衍生的配套系统要求比较多,特别是基于消息、日志的最终一致性方案,需要考虑消息的积压、消费情况、监控、报警等。