最终一致性:BASE论文笔记
简述
Base论文是ebay的架构师于2008年提交的一篇论文。主要用来阐述在分布式架构设计下,基于BASE的设计思想和方案。所谓BASE就是basically available(基本的可用性),soft state(软状态,所谓的软状态,指的是暂时的不一致,后文会详细展开),eventually consistent(最终一致性)。在分布式领域,有著名的CAP理论,也就是一致性,可用性,分区容错性即可靠性,这三者无法同时获得。而base理论就是在牺牲部分一致性的基础上,来达到可用性的大幅提升的一种方案理念。
欢迎加入技术交流群186233599讨论交流,也欢迎关注技术公众号:风火说。
分区容错性
这是BASE里相对简单好操作的一个地方。比如我们需要存储用户数据,通过部署多个物理实例,将用户数据均匀的分散在其上。即使其中的一个服务器发生宕机,也不会影响到其他的数据。容忍了局部失败而提供了整体上一定的容错性。
容错的问题通过将数据部署多个节点来保证。那就带来下面的问题,数据的一致性如何保证。传统的业务解决方式就是2PC。依赖于数据库提供的XA事务来实现分布式下数据一致性。
传统的数据库事务方式在分布式领域的问题
考虑一个这样的场景,A给B进行转账。2个用户在不同的银行,他们的数据库部署在不同的物理节点。而转账是一个事务操作。传统意义上有所谓的分布式事务,也就是2PC这种协议来保证分布式情况下的事务。但是2PC协议的开销很大,不利于在大规模的情况下的性能表现。并且XA只是数据库层面的协议,如果应用本身是分布式的,还需要额外的落地支持。在实现上也不简单。
BASE方式来解决
这个转账例子有两个数据库,如果A成功了,B失败了,此时就需要回滚A。如果我们不回滚,而是重试直到B成功也是一种可行的方案。对于同一个数据库实例,在一个连接中可以使用事务操作不同的表。基于以上的两点,我们给A增加一个消息表,用来存储需要别的库异步配合的执行消息。在这个例子中,用于存储向B发送的消息。那么我们的操作如下
- 在A中开启事务,对用户执行扣钱sql,并且往消息表中新增一个消息,消息的内容是要求B执行加钱的操作。
- 提交事务。如果事务失败则回滚,该业务失败。此时B完全无感知。
- 如果事务提交成功,则向B发出调用,执行增加钱的操作。如果B回复成功,将消息表中的该消息删除。
- 如果B回复失败,则发起重试,或者依靠定时任务不断重试,直到成功。
- 成功后删除消息表中的该消息。
我们来分析下上面的做法,首先通过A中的事务保证了在A上转账的操作落地完成并且有记录可以查询(消息表中的就是未完成的记录)。在事务提交成功后再执行和B相关的操作,返回成功才删除消息表,这样就保证操作最后总是可以成功(因为B返回了成功消息)。可以看到,在A事务成功到B调用成功这之间,数据在两个数据库上存在不一致的情况,这也是BASE理论中牺牲的强一致性的地方,但是通过这样的做法,数据在两个系统中最终是可以达到一致的,也即是所谓的最终一致性。通过牺牲强一致性,提高了系统的吞吐。并且这个不一致的时间窗口实际上对于一般的用户是无感知的。可能就是在几十毫秒到两三秒之间,用户是可以允许也是理解这样的延迟的。
那么上面的方案是否就已经可以解决问题了呢,答案是不。
幂等
在上面的流程可以看到,在A事务成功以后要调用B的接口,如果调用失败是需要重复调用直到成功的。问题在于,由于需要网络传输调用结果,有可能B调用实际上是成功了,但是网络中断导致A无法收到消息。那么A就会认为是调用失败,从而再次发起调用。那么B就将一个加钱的动作执行了2次。此时两边的数据处于不一致的状态,并且无法修复。为了解决这个问题,我们引入幂等约束。所谓幂等操作也就是说对于该操作,一次或多次的调用产生的结果是相同的。上面的问题就是由于调用B加钱的操作不是幂等,而A在理论上必然存在重复调用的情况(因为网络是不可靠的),进而导致数据不一致错误。那么怎么让B的加钱操作是幂等的呢?A中存在一个消息表,用于存放需要执行操作的消息,那么给B中也增加一个更新表。对于A中的每一个消息都存在一个全局唯一的id。那么调用B的加钱操作的流程修改为如下
- B开启事务。检查更新表中是否存在消息id。如果存在消息id,直接忽略该操作。并且返回A成功消息。
- 如果不存在消息id,执行用户的加钱操作,并且往更新表中插入该消息。提交事务。提交事务成功返回A成功消息,提交失败则返回失败消息。
使用这样的逻辑,则B的加钱操作就成为了一个幂等操作,可以承受多次调用。
简单的幂等
上面方案的幂等依靠本地的更新表记录了所有的消息id进行比对进而防止多次的重复调用。这样需要一个更新表并且要存储所有的消息,比较重一些。如果我们给于消息一个不断递增的序号,并且b的数据表中新增一个序号字段。b只要执行消息前会比对消息的序号和自身数据的序号。如果消息序号大于自身序号才可以执行。也就是执行如下的伪代码
begin transaction
update b set money=message.money+b.money,version=message.version where b.id = message.userid and b.version > message.version
commit
end transaction
通过这样的方式,就不需要启用一个单独的更新表。然后对于B的业务表有侵入性的修改。
中间总结
在有了上面的例子,现在我们来稍微总结。通过将一致性要求从强一致性降低到最终一致性,我们可以避免2PC这样的高成本协议,并且让业务具有更强的伸缩性。将上面具体的方案抽象下,其中的思路还是比较清晰的。
- 将一个分布式的事务拆分成多个本地事务,引入消息概念。
- 将本地数据更新和消息的新增绑定为一个事务,作为整体进行提交。
- 在在一个本地事务成功的情况下,进行下一个远端的事务操作。并且要求该远端事务操作具备幂等性,可以承受重复的多次调用而不会导致数据错误。
- 消息可以被本地被多次尝试或者在异步组件中尝试直到消息送达并且操作成功。最终让所有系统的数据达到一致。
上面的思路重点就是在异步消息这个概念的引入。而幂等的保证方式可以有两种,一种是被调用方,也就是接口提供方,自身保存一份执行过的消息表,用于在执行操作前进行比对,避免执行重复操作。一种是将消息引入序号改变,被调用方只执行比自身序号大的消息。
TCC类型的幂等
上面的基于存储消息方式的幂等由于需要存储执行过的消息会带来额外的存储开销。并且执行过的消息理论上已经失去了其意义(假设调用方执行成功,后续就不会再去调用,那么就没有判重的需求了)。这种方式中,消息的序号是由消息的发送方来生成的,并且被调用方始终需要存储着所有的操作历史,历史数据会越来越多,并且都是无用的历史数据。那我们换个思路,消息的序号是由消息的收取方来生成如何。具体的操作如下
- A开启事务,执行本地业务更新,调用B的try接口传递业务参数。B的try接口调用成功则返回一个全局唯一的消息id
- A将这个消息id写入到消息表中。提交事务。
- 如果事务提交成功,调用B的confirm接口。接口的参数只有消息id。表明该业务确认需要执行。B将真实执行该业务,并且将confirm执行结果返回给予A。如果成功则删除消息表中的该消息。如果失败则通过异步组件发生重试。
- 如果事务提交失败,则调用B的cancel接口,接口参数只有消息id。表明取消该业务的执行。
在这种方式中,消息的id是由被调用方被调用try接口时产生。此时被调用方存储该消息id。在被调用confirm或者cancel接口时,首先检查该id是否存在,存在才执行下一步的操作。对于cancel接口而言,操作只是简单的删除该消息即可。而对于confirm操作,则需要开启事务,并且在事务中执行对应的操作,并且删除消息。最终提交事务。如果事务成功提交则返回成功消息,否则返回失败。如果消息id不存在,有两种可能。
- 消息id是错误的
- 该消息已经被执行了。
在内部可信的系统内,排除第一种情况,只有第二种情况。故而在这种情况,confirm接口的调用也是返回成功消息。这种方式,被调用方只需要存储一定规模的消息id,因为被成功执行的消息都会被删除。再给所有存储的消息一个过期时间。由后台定时组件定期扫描删除即可。这样,消息的堆积大小也是可控的了。