数据一致性(一)
transaction
Published: 2018-10-20

MySQL的事务是数据一致性的典范,事务内的执行要么都成功,要么都失败。但业务系统涉及系统间的相互调用,涉及的数据库也不尽相同,所以实现数据一致性还是有挑战的。

首先了解强一致性和弱一致性。在微服务中,系统间通过HTTP的方式相互调用,很难实现数据的强一致。我们这里主要说弱一致性,也就是数据最终一致性。

数据一致性还有个重要的前提:支持幂等。也就是说,只要请求参数不变,那么无论重复请求多少次,结果都一样。在对接第三方支付时,这个词出现的频率还是老高的。

基础理论

ACID

  • 原子性Atomicity

  • 一致性Consistency

  • 隔离性Isolation

  • 持久性Durability

如果涉及业务逻辑的数据被设计到同一个数据库中,借助事务的ACID可以很方便地解决。但现实情况是,要保证数据一致性的数据被分布在多个不同的数据库里。

CAP

CAP理论说明任何分布式系统只可同时满足两点,无法三点都满足。

  • 一致性Consistency

  • 可用性Avaliability

阮一峰老师的解释说:只要收到用户的请求,服务器就必须给出回应。

  • 分区容错Partition tolerance

BASE

BASE模型实现的系统不保证强一致性,在处理请求的过程中,允许存在短暂的数据不一致。系统在处理流程的各个阶段,会更新记录的对应状态,后续通过状态来修复数据,最终达到数据一致。

  • 基本可用Basically Available
  • 软状态Soft State
  • 最终一致Eventually Consistency

购买业务

蜗牛要在一家电商网站买电子书,整个购买流程和涉及的系统虚构如下图。过程涉及检查它是否已经买过,然后是生成订单号、支付、交付(实际上订单系统不包含支付功能,这里简化处理)。

image

交付涉及三个系统,在任何一个系统内,数据库的事务都只能保证它服务内的数据一致。而且,如果在事务过程中引入了调用第三方的HTTP请求,数据库的事务执行结果甚至有可能会被污染。比如,HTTP请求超时返回失败,但实际上请求却执行成功的场景。

代码设计

参考之前写的 Saga Pattern模式,对任何一个外部服务的调用都引入两个行为:执行补偿。补偿是对执行结果的修正。比如对于用户支付失败的场景,补偿行为可以是接口重试、可以是直接退款、还可以推送MQ异步修复等。

统一使用interface来定义一套规范。每一种支付方式以及购买产品所调用的外部服务可能不尽相同,用interface来达到统一调用的目的。补偿的行为都基于执行动作返回的错误,所以我们需要实现自己的错误码。

type DeliverPattern interface {
	//是否需要执行交付流程
	Check(ctx *context.Context) (bool, error)

	//支付及支付补偿
	DoPay(ctx *context.Context) error
	PayCompensate(ctx *context.Context, doErr error) error

	//交付及对应的补偿
	DoDeliver(ctx *context.Context)
	DeliverCompensate(ctx *context.Context, doErr error) error
}

如何补偿

对于如何补偿,不同的业务有不同的补偿方式,当让不能一概而论。但整体的思想,我觉得还是不外乎两种。当然,下面的两种描述是自己这样称呼的。

事务类

首先便是数据库事务类,任何一个流程失败,整个事务内的操作全部反向回滚。沿着这样的思路,接口定义中PayCompensate应该实现DoPay的回滚操作,而DeliverCompensate应该实现DoPay以及DoDeliver的回滚操作。

我们需要在操作的同时维护一个回滚操作的队列,任何一个Do行为的完成,都需要在回滚队列中插入对应的回滚方法。当后面任何一个Do操作失败,统一执行回滚队列的方法。

这样的困境在于你不能完全保证回滚方法一定成功执行。而且出于性能考虑,还需要结合异步队列,通过后台重试来保证整个业务流程彻底回滚成功或回滚失败。

状态类

每个业务都会拆分成各个更小的块,就跟写代码空行一样,这里的DeliverPattern也是根据业务流程拆分成更小的执行粒度。我们可以为每个Do行为都设置一个状态码,类似于状态机,记录每一次购买的各个状态。

const (
	StatusDoPaySuccess           = 1
	StatusDoPayCompensateSuccess = 2
	StatusDoPayCompensateFailure = 3
)

这样我们补偿方法中执行的不再是回滚操作,而是Do方法的重试。如果补偿成功,继续执行后续的操作,如果补偿失败,记录下该状态,后续看看怎么补偿。