vlambda博客
学习文章列表

分布式一致性,看一遍就懂


背景

互联网时代和环境下,为了快速需求响应和提高系统吞吐,往往进行微服务化改造,将复杂系统和数据进行拆分;

这时候的一致性指分布式服务化系统之间的弱一致性,包括应用系统一致性和数据一致性;

一致性级别 说明
强一致性 要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
弱一致性 系统在写入成功后,不承诺立即可以读到写入的值,也不久承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
最终一致性 最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。最终一致性是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

生活中的一致性例子:

银行处理转账时,扣减你账户上的余额,然后增加别人账户的余额;

如果扣减你的账户余额成功,增加别人账户余额失败,那么你就会损失这笔资金。

反过来,如果扣减你的账户余额失败,增加别人账户余额成功,那么银行就会损失这笔资金,银行需要赔付;

下面通过理论和实际方案的介绍,来学习分布式一致性相关内容!

基础理论

ACID

数据库管理系统(DBMS)在写入或更新资料的过程中,为保证事务是正确可靠的,所必须具备的四个特性:原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持久性(durability)。

在数据库系统中,一个事务是指:由一系列数据库操作组成的一个完整的逻辑过程。

  • 例如银行转帐,从原账户扣除金额,以及向目标账户添加金额,这两个数据库操作的总和,构成一个完整的逻辑过程,不可拆分。

这个过程被称为一个事务,具有ACID特性

ACID特性 说明
原子性(Atomicity) 原子性是指事务包含的所有操作要么全部成功,要么全部失败回滚
一致性(Consistency) 事务必须使数据库从一个一致性状态变换到另一个一致性状态
隔离性(Isolation) 当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离
持久性(Durability) 一个事务一旦被提交了,那么对数据库中的数据的改变就是永久性的,即便是在数据库系统遇到故障的情况下也不会丢失提交事务的操作

CAP理论

一致性(Consistency)

在分布式环境下,一致性是指数据在多个副本之间能否保持一致的特性。

在一致性的需求下,当一个系统在数据一致的状态下执行更新操作后,应该保证系统的数据仍然处于一致的状态

可用性(Availability)

可用性是指系统提供的服务必须一直处于可用的状态,对于用户的每一个操作请求总是能够在有限的时间内返回结果。

  • "有限时间内"是指,对于用户的一个操作请求,系统必须能够在指定的时间内返回对应的处理结果,如果超过了这个时间范围,那么系统就被认为是不可用的

  • "返回结果"是可用性的另一个非常重要的指标,它要求系统在完成对用户请求的处理后,返回一个正常的响应结果

分区容错性(Partition Tolerance)

分布式系统在遇到任何网络分区故障的时候,仍然需要能够保证对外提供满足一致性和可用性的服务,除非是整个网络环境都发生了故障

实际情况

CAP理论证明,任何分布式系统只可同时满足二点,没法三者兼顾

选 择 说 明
CA 放弃分区容错性,加强一致性和可用性,其实就是传统的单机数据库的选择
AP 放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,例如很多NoSQL系统就是如此
CP 放弃可用性,追求一致性和分区容错性,基本不会选择,网络问题会直接让整个系统不可用

满足C和A,那么P能不能满足呢?

满足C需要所有的服务器的数据要一样,也就是说要实现数据的同步,那么同步要不要时间?肯定是要的,并且机器越多,同步的时间肯定越慢,这里问题就来了,我们同时也满足了A,也就是说,我要同步时间短才行。这样的话,机器就不能太多了,也就是说P是满足不了的

满足C和P,那么A能不能满足呢?

满足P需要很多服务器,假设有1000台服务器,同时满足了C,也就是说要保证每台机器的数据都一样,那么同步的时间可就很大,在这种情况下,我们肯定是不能保证用户随时访问每台服务器获取到的数据都是最新的,想要获取最新的,你就得等,等全部同步完了,你就可以获取到了,但是我们的A要求短时间就可以拿到想要的数据啊,这不就是矛盾了,所以说这里A是满足不了了

对于分布式系统而言,网络问题又是一个必定会出现的异常情况,因此分区容错性也就成为了一个分布式系统必然需要面对和解决的问题。

因此往往需要把精力花在如何根据业务特点在C(一致性)和A(可用性)之间寻求平衡

BASE理论

BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的缩写。

BASE理论是对CAP中一致性和可用性权衡的结果,其来源于对大规模互联网系统分布式实践的总结,是基于CAP定理逐步演化而来的。

BASE理论的核心思想是:

  • 即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性

接下来看一下BASE中的三要素:

基本可用(Basically Available)

基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性(注意,这绝不等价于系统不可用)。

比如:

  • 响应时间上的损失。正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒

  • 系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面

软状态(Soft State)

软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时

最终一致性(Eventually Consistent)

最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。

因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

总的来说,BASE理论面向的是大型高可用可扩展的分布式系统,和传统的事务ACID特性是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态

但同时,在实际的分布式场景中,不同业务和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性和BASE理论往往又会结合在一起。

分布式一致性协议

两阶段提交协议(2PC)

第一阶段(投票阶段):

  1. 协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应
  2. 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志(注意:若成功这里其实每个参与者已经执行了事务操作)
  3. 各参与者节点响应协调者节点发起的询问,如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;
  4. 如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息

第二阶段(提交执行阶段):

当协调者节点从所有参与者节点获得的相应消息都为"同意"时:

  1. 协调者节点向所有参与者节点发出"正式提交"的请求
  2. 参与者节点正式完成操作,并释放在整个事务期间内占用的资源
  3. 参与者节点向协调者节点发送"完成"消息
  4. 协调者节点受到所有参与者节点反馈的"完成"消息后,完成事务

存在的问题:

资源被同步阻塞

在数据提交的过程中,所有参与处理的服务器都处于阻塞状态,如果其他线程想访问临界区的资源,需要等待该条会话请求在本地执行完成后释放临界区资源。

因此,采用二阶段提交算法也会降低程序并发执行的效率。

单点问题

此外,还会发生单点问题。单点问题也叫作单点服务器故障问题,它指的是当作为分布式集群系统的调度服务器发生故障时,整个集群因为缺少协调者而无法进行二阶段提交算法。

单点问题也是二阶段提交最大的缺点,因此使用二阶段提交算法的时候通常都会进行一些改良,以满足对系统稳定性的要求。

在Commit 阶段出现数据不一致

当统计集群中的服务器可以进行事务操作时,协调服务器会向这些处理事务操作的服务器发送 commit 提交请求。

如果在这个过程中,其中的一台或几台服务器发生网络故障,无法接收到来自协调服务器的提交请求,导致这些服务器无法完成最终的数据变更,就会造成整个分布式集群出现数据不一致的情况。

三阶段提交协议(3PC)

三阶段提交其实是在二阶段算法的基础上进行了优化和改进。

为了解决二阶段协议中的同步阻塞等问题,三阶段提交协议在协调者和参与者中都引入了超时机制,并且把两阶段提交协议的第一个阶段拆分成了两步:询问,然后再锁资源,最后真正提交。

分布式一致性,看一遍就懂
阶段 说明
CanCommit 事务询问,协调者向所有的参与者发送一个包含事务内容的 CanCommit 请求,询问是否可以执行事务提交操作,并开始等待各参与者的响应。各参与者向协调者反馈事务询问的响应 参与者在接收到来自协调者的 CanCommit 请求后,正常情况下,如果其自身认为可以顺利执行事务,那么会反馈 Yes 响应,并进入预备状态,否则反馈 No 响应。
PreCommit 如果在询问阶段所有的参与者都返回可以执行操作,协调者向参与者发送预执行请求,然后参与者写redo和undo日志,执行操作,但是不提交操作;如果在询问阶段任何参与者返回不能执行操作的结果,则协调者向参与者发送中止请求
DoCommit 如果每个参与者在准备阶段返回准备成功,也就是预留资源和执行操作成功,协调者向参与者发起提交指令,参与者提交资源变更的事务,释放锁定的资源;如果任何一个参与者返回准备失败,也就是预留资源或者执行操作失败,协调者向参与者发起中止指令,参与者取消已经变更的事务,执行undo日志,释放锁定的资源

注意事项

一旦进入阶段3,发生 协调者出现问题 或 协调者和参与者之间的网络出现故障,即参与者无法及时接收到来自协调者的 DoCommit 或 abort 请求,针对这种异常情况,参与者都会在等待超时之后,继续进行事务提交。

三阶段提交协议存在的问题

参与者接收到 PreCommit 消息后,如果网络出现分区,此时协调者和部分参与者无法进行正常的网络通信,该部分参与者依然会进行事务的提交,必然出现数据的不一致性。

TCC

无论是 2PC 还是 3PC,都存在一个大粒度资源锁定的问题。

我们先来想象这样一种场景,用户在电商网站购买商品1000元,使用余额支付800元,使用红包支付200元。

我们看一下在 2PC 中的流程:

prepare 阶段:

  • 下单系统插入一条订单记录,不提交

  • 余额系统减 800 元,给记录加锁,写 redo 和 undo 日志,不提交

  • 红包系统减 200 元,给记录加锁,写 redo 和 undo 日志,不提交

commit 阶段:

  • 下单系统提交订单记录

  • 余额系统提交,释放锁

  • 红包系统提交,释放锁

为什么说这是一种大粒度的资源锁定呢?

是因为在 prepare 阶段,当数据库给用户余额减 800 元之后,为了维持隔离性,会给该条记录加锁,在事务提交前,其它事务无法再访问该条记录。

但实际上,我们只需要预留其中的 800 元,不需要锁定整个用户余额。这是 2PC 和 3PC 的局限,因为这两者是资源层的协议,无法提供更灵活的资源锁定操作。

为了解决这个问题,TCC 应运而生。TCC 本质上也是一个二阶段提交协议,但和 JTA 中的二阶段协议不同的是,它是一个服务层的协议,因此开发者可以根据业务自由控制资源锁定的粒度。

我们先来看一下 TCC 协议的运行过程。

TCC 将事务的提交过程分为 try-confirm-cancel(实际上 TCC 就是 try、confirm、cancel 的简称) 三个阶段:

  • try:完成业务检查、预留业务资源

  • confirm:使用预留的资源执行业务操作(需要保证幂等性)

  • cancel:取消执行业务操作,释放预留的资源(需要保证幂等性)

流程如下:

  1. 事务发起方向事务协调器发起事务请求,事务协调器调用所有事务参与者的 try 方法完成资源的预留,这时候并没有真正执行业务,而是为后面具体要执行的业务预留资源,这里完成了一阶段。
  2. 如果事务协调器发现有参与者的 try 方法预留资源时候发现资源不够,则调用参与方的 cancel 方法回滚预留的资源,需要注意 cancel 方法需要实现业务幂等,因为有可能调用失败(比如网络原因参与者接受到了请求,但是由于网络原因事务协调器没有接受到回执)会重试。
  3. 如果事务协调器发现所有参与者的 try 方法返回都 OK,则事务协调器调用所有参与者的 confirm 方法,不做资源检查,直接进行具体的业务操作。
  4. 如果协调器发现所有参与者的 confirm 方法都 OK 了,则分布式事务结束。
  5. 如果协调器发现有些参与者的 confirm 方法失败了,或者由于网络原因没有收到回执,则协调器会进行重试。这里如果重试一定次数后还是失败,常见的是做事务补偿。

通过一个支付场景看看 TCC 在该场景中的流程:

Try操作

  • tryX 下单系统创建待支付订单

  • tryY 冻结账户红包 200 元

  • tryZ 冻结资金账户 800 元

Confirm操作

  • confirmX 订单更新为支付成功

  • confirmY 扣减账户红包 200 元

  • confirmZ 扣减资金账户 800 元

Cancel操作

  • cancelX 订单处理异常,资金红包退回,订单支付失败

  • cancelY 冻结红包失败,账户余额退回,订单支付失败

  • cancelZ 冻结余额失败,账户红包退回,订单支付失败

可以看到,我们使用了冻结代替了原先的账号锁定(实际操作中,冻结操作可以用数据库减操作+日志实现),这样在冻结操作之后,事务提交之前,其它事务也能使用账户余额,提高了并发性。

总结一下,相比于二阶段提交协议,TCC 主要有以下区别:

  • 2PC 位于资源层而 TCC 位于服务层。

  • 2PC 的接口由第三方厂商实现,TCC 的接口由开发人员实现。

  • TCC 可以更灵活地控制资源锁定的粒度。

  • TCC 对应用的侵入性强。业务逻辑的每个分支都需要实现 try、confirm、cancel 三个操作,应用侵入性较强,改造成本高。

比如,你的订单服务中本来只有一个接口

//修改代码状态
orderClient.updateStatus();

都要拆为三个接口,即:

orderClient.tryUpateStatus();
orderClient.confirmUpateStatus();
orderClient.cancelUpateStatus();

目前TCC的实现有如下几个

  • tcc-transaction:

  • ByteTCC

  • spring-cloud-rest-tcc

最终一致性模式

缓存一致性模式

高并发系统中一个常见的核心需求就是亿级的读需求,显然,关系型数据库并不是解决高并发读需求的最佳方案,互联网的经典做法就是使用缓存

常用缓存方式分为本地缓存和分布式缓存两种;如果对性能要求不是非常的高,优先使用分布式缓存;对于数据实时性和分布式一直性要求不高的可以使用本地缓存,比如某些人员的配置,即使不同机器的配置短时间不相同也不影响正常业务流转

数据库与缓存只需要保持弱一致性,而不需要强一致性,常用的缓存方案参考:

查询模式

服务操作都需要提供一个查询接口,用来向外部输出操作执行的状态。

服务操作的使用方可以通过查询接口,得知服务操作执行的状态,然后根据不同状态来做不同的处理操作

举个例子:

定时任务监听生成中的订单、单发送群消息,RD收到群消息查询订单的具体状态判断系统是否有问题,是否需要人工修复

补偿模式

如果整个操作处于不正常的状态,我们需要修正操作中有问题的子操作,这可能需要重新执行未完成的子操作,后者取消已经完成的子操作,通过修复使整个分布式系统达到一致,为了让系统最终一致而做的努力都叫做补偿

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

举个例子:

监听到生成中订单后,系统自动重新推送入库消息重新生成入库单进行重试,如果系统没法自动恢复需要RD接入定位修复问题

异步确保模式

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

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

举个例子:

采购系统进行预算释放和耗用,会同步记录日志,后期通过异步和定时任务重试保证释放和耗用成功

定期校对模式

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

实现定期校对的一个关键就是分布式系统中需要有一个自始至终唯一的ID,常用的唯一id生成方案

举个例子:

财务那边的对账系统定期校对结算数据和业务单据数据的一致性

可靠消息模式

对于异步的操作可以使用消息队列,通过消息队列将调用方和被调用方进行解耦,提高系统响应速度,同时能够达到消峰目的;

对于消息队列,我们需要建立特殊的设施保证可靠的消息发送以及处理机的幂等

消息的可靠发送

发送消息之前,把消息持久到数据库,状态标记为待发送,然后发送消息,如果发送成功,将消息改为发送成功。定时任务定时从数据库捞取一定时间内未发送的消息,将消息发送

使用第三方消息管理器,发送消息之前,先发送一个预消息给第三方消息管理器,消息管理器将其持久到数据库,并标记状态为待发送,发送成功后,标记消息为发送成功。定时任务定时从数据库捞取一定时间内未发送的消息,回查业务系统是否要继续发送,根据查询结果来确定消息的状态

消息处理器的幂等性

保证消息一定要发送出去,那么就需要有重试机制,有了重试机制,消息一定会重复,那么我们需要对重复做处,常用几种方案

  • 使用数据库表的唯一索引进行防重,拒绝重复的请求

  • 使用分布式中间件Redis进行防重

  • 使用状态机防重,单据相关的业务会涉及到状态机,状态在不同情况下会发生变更,如果状态机已经处于下一个状态,这时候来一个上一个状态的变更,理论上是不能够变更的,保证了有限状态机的幂等

  • 使用乐观锁防重,数据更新带条件,这也是在系统设计的时候,合理的选择乐观锁,通过version或者其他条件来做乐观锁,这样保证更新及时在并发的情况下,也不会有太大的问题

举个例子:

  1. 单据保存的http接口,前端提交时增加唯一id,通过Redis进行防重提交,防止重复建单
  2. 订单对接出入库系统进行mq异步交互,通过订单的中间态状态进行重试,下游做防重

参考

  • 书籍:分布式服务架构:原理、设计与实战