作者:京东零售 刘慧卿

一 前言

在现代大型分布式软件系统中,有一个绕不过去的课题,那就是如何保证系统的数据一致性。著名的 Paxos 算法(Megastore、Spanner),Raft 协议(ETCD、TiKV、Consul ),ZAB 协议(ZooKeeper)等分布式一致性解决方案,都是在此背景下而诞生的。

数据一致性保障为什么难呢?先来看一下我们熟知的本地数据库事务是如何实现数据一致性的。众所周知,数据库事务有 ACID 四大特性,即原子性 (Atomicity)、 一致性 (Consistency)、隔离性 (Isolation) 和持久性 (Durability)。任何支持数据库事务的存储引擎都需要满足这四大特性。以 Mysql 数据库的 Innodb 存储引擎的设计实现举例,数据一致性过程如下:

  1. 持久性:通过 binlog、redolog 来实现;(用于数据重放,从库备份)

  2. 原子性:通过 undolog 来实现;(用于数据回滚)

  3. 隔离性:通过读写锁 +MVCC(undolog) 来实现;(用于隔离级别的实现)

  4. 一致性:通过原子性,持久性,隔离性最终实现数据一致性;

由此可以见,一致性的实现是在持久性,原子性,隔离性等各种特性基础之上的。从技术实现手段来看,为什么是实现数据一致性的过程需要借助这么多种日志文件呢?

这还得从硬件效率上讲起,我们先来看一组数据(仅做示意,不同硬盘型号指标存在很大差异):

通过测试数据我们可以得出以下几点结论:

  1. 机械硬盘读写效率方面,顺序读写速率是随机读写性能的 100 倍左右;

  2. SSD 硬盘读写效率方面,顺序读写速率是随机读性能的 10 倍左右;

  3. 硬盘的顺序读写速率比内存随机读写还要快;

通过磁盘的读写效率我们可以发现,数据的顺序读写性能要远远高于随机读写,而是数据库的读写场景往往是随机的,为了提高性能效率,就需要尽量将随机读写转换成顺序读写的实现方式。而这些拆分出来的各种日志文件就是其实现方式之一,当然还会有内存缓冲池 (Buffer Pool) 等其它手段一起配合来实现读写效率的提升。

类似的场景,在数据库应用层面也是存在的,比如:推荐使用数据库自增 ID 作为主键。为什么有这条建议呢?

这是因为 B+ 树结构为了维护索引的有序性,在插入新值的时候需要做必要结构维护。如果插入的值比最大值 ID 大,则只需要在最后记录后面插入一个新记录。如果新插入的 ID 值在原先的有序记录中间,就需要挪动后面的数据,空出对应的位置,然后填充新值。如果所在的数据页已经满了,根据 B+ 树算法,这时候需要申请一个新的数据页,然后挪动部分数据过去(页分裂),后面两种情况,性能会受到较大的影响。为了减少位置挪动和页分裂过程中数据的移动,应用层保证新增的索引数据始终是顺序追加模式 (新增数据是索引数据的最大值),就非常有必要了。

以上示例,仅仅是本地数据一致性的简单窥探,如果叠加上集群、网络两个维度,实现分布式数据一致性,就变的更加具有挑战了。

二 分布式系统

为什么分布式系统中数据一致性会更加复杂呢?主要体现在下面几点:

  1. 共享内存:分布式系统没有共享内存,不能像本地系统一样,从共享内存中直接获取整个系统的数据快照。而是需要分别获得各个进程(信道)的本地状态,再组合成全局状态;

  2. 全局时钟:分布式系统没有全局时钟,各个进程无法正确获得事件消息的时序关系,状态的一致性难以保障;

  3. 网络超时:分布式环境下网络超时状态的存在,需要我们找到具有高度容错特性的解决办法;

2.1 CAP 定理

CAP 定理也称为不可能三角约束,是由加州大学伯克利分校 Eric Brewer 教授提出来的,他指出网络服务无法同时满足以下三个特性:

  1. 一致性 (Consistency):在某个写操作完成之后的任何读操作都必须返回该写操作写入的值,或者之后的写操作写入的值。即:各个数据备份的数据内容要保持一致且都为最新数据。

  2. 可用性 (Availability) :任何一个在线的节点收到的请求必须都做出响应。即:不论成功失败,都有回应。

  3. 分区容错性 (Partition tolerance) :允许网络丢失从一个服务节点到另外一个服务节点的任意信息(包括消息的延迟、丢失、重复、乱序,还有网络分区)。即:不同的节点可能会数据不一致,这种情况下我们要保证系统还能正常运行。

根据 CAP 原理将数据库分成了满足 CA 原则、满足 CP 原则和满足 AP 原则三大类:

  1. CA - 单点集群,满足一致性,可用性的特性,分区容忍性受到限制。

  2. CP- 满足一致性,分区容忍性的特性,性能收到限制。

  3. AP - 满足可用性,分区容忍性的特性,数据一致性上受到限制。

CAP 定理告诉我们,在网络可能出现分区故障的情况下,一致性和可用性(延迟)之间必须进行权衡。以 Paxos 协议来看,它在 C 和 A 之间选择了前者,即严格的一致性,而 A 则降级为大多数一致性(majority available),这和我们接下来要介绍的 BASE 定理的选择恰恰相反。

2.2 BASE 定理

BASE 定理是对 CAP 中一致性和可用性权衡的结果(网络带来的分区容错性无法忽视),是对大型互联网分布式实践的总结,是基于 CAP 定理逐步演化而来。

  1. 基本可用(Basically Available):指在分布式系统出现不可预知的故障时(网络或存储故障等),允许损失系统的部分特性来换取系统的可用性。比如系统通过断路保护而引发快速失败,在快速失败模式下,支持加载默认显示的内容(静态化的或者被缓存的数据),从而保证服务依然可用。

  2. 软状态(Soft state):指运行系统中的数据存在中间状态,并认为该中间状态不会影响系统的整体可用性和最终一致性,即允许系统在不同节点的数据副本进行数据同步时存在延时。也就是说,如果一个节点接受了数据变更,但是还没有同步到其他备份节点,这个状态是被系统所接受的,也会被标识为数据变更成功。

  3. 最终一致性(Eventuallyconsistent):指系统中所有的数据副本在经过一段时间的同步后,最终状态能达到一致。在分布式环境下,考虑依赖服务和网络的不确定性,传统 ACID 事务会让系统的可用性降低、响应时间变长,这可能达不到系统的要求,因此实际生产中使用柔性事务是一个好的选择。

BASE 理论的核心思想就是:按照实际应用场景,优先满足分区容错性和可用性,采用适当的方式来使系统达到最终一致性(不追求强一致性),这一理论思想对于我们在设计业务系统时,有很大的指导意义。

2.3 事件时序

在分布式系统中,不同的服务分布在不同的机器上,如何确定不同机器上的两个事件发生的先后顺序呢?首先解释下为什么分布式系统需要知道两个事件的先后顺序。举个例子:分布式数据库中不同事务并发执行的时候,需要做事务隔离。隔离的一种做法是使用 MVCC(Multiple Version Concurrent Control) 多版本并发控制,根据数据的版本号来控制该版本数据的可见性。这时候就需要知道数据修改事件发生的先后顺序,才能正确的实现隔离性。

如何识别事件发生的先后顺序?有以下两种思路,

  1. 逻辑时间:只需要一个原子递增的序号标识每一个消息即可。

  2. 物理时间:直接采用时间戳来标识消息顺序。

Linux 将时钟分为系统时钟 (System Clock) 和硬件 (Real Time Clock,简称 RTC) 时钟两种。系统时间是指当前 Linux Kernel 中的时钟,而硬件时钟则是主板上由电池供电的那个主板硬件时钟,这个时钟可以在 BIOS 的 “Standard BIOS Feture” 项中进行设置。 当 Linux 启动时,系统时钟会去读取硬件时钟的设置,然后系统时钟就会独立于硬件运作。

那么我们现实中是如何进行计时的呢?早期使用惠更斯摆钟(擒纵轮),后来发现了有压电效应的石英石,只要施加电场就会震动,石英石加工到一定尺寸,就会达到 32768 次/秒的震动频次,以此来记录时间。但噪声、温度、磁场、湿度等都会影响晶振频率的稳定性,所以石英晶振,每天大约会有秒级单位的误差。

有没有更加精确的计时技术呢?

有,那就是原子钟,通过原子能级跃迁之间的辐射震荡时间,来确定时间长度,由于微波震荡频率,受到太阳和地球的影响很小,能够做到 5400 万年误差不超过 1 秒,缺点是成本昂贵。

在卫星定位系统上,就使用了原子钟。这是因为卫星定位场景中,对时间误差的容忍度很低。举个例子:如果时间上有 100 毫秒的误差,那么引起的等效误差就有 30km(通过卫星和接收端的时间差乘以光速来确定两者之间的距离),这对于位置定位来说,已经是不能接受了。另外,理论上只需要三颗就能确定位置,为了保证时间准确性,还会搭载一颗额外的卫星,来进行时间差的纠正。

考虑到成本问题,目前计算机普遍使用的是石英晶振,每天会有秒级的误差。为了解决这个误差,NTP(Network Time Protocol)被提了出来,使计算机对其服务器或时钟源(如石英钟,GPS 等) 做同步化,提供高精准度的时间校正能力。NTP 的同步频率是可以自己设置的,Linux 默认最小时间间隔为 64s,默认最大时间间隔是 1024s(17 分钟左右)。

为什么在分布式系统中大部分都不直接使用物理时钟,而是使用逻辑时钟呢?

这是因为分布式系统中,从各自机器上获取物理时钟的时间戳,而各台机器的物理时钟是很难完全同步的,即使有 NTP(Network Time Protocol),精度也是有限的。这对于依赖时钟构造的系统来说往往是难以承受了。常见的分布式 ID 生成算法:雪花算法(SnowFlake),为了防止 ID 重复,在设计的时候就不得不考虑时钟回拨的场景。

因此分布式系统通常使用逻辑时间来记录事件的顺序关系。逻辑时钟实现方案有以下几种:

Lamport 时钟(LC)是 Leslie Lamport 在 1978 年的论文《Time, Clocks, and the Ordering of Events in a Distributed System》提出的,Lamport 逻辑时钟保证了因果关系(偏序)的正确性,但不保证绝对时序的正确性。

向量时钟(VC)是 LC 的一种延伸,能够提供全序关系的同时区分其中的并发事件和因果事件。其思想是不同进程之间同步时钟的时候 不仅同步自己的时钟,还同步自己知道的其他进程的时钟。如果进程数过多,这也会导致向量维护难度(不直观)和成本(网络通信)增加。结合 LC 和 VC 两种时钟的优点,之后又出现了混合逻辑时钟 (Hybrid Logical Clocks) 。为了进一步降低网络通信开销,Google 反其道而行,采用物理时钟 + 算法来实现记录事件顺序,TrueTime 方案又应运而生,对这方面感兴趣的同学可以拓展学习。

三 常用解决方案

前面介绍了分布式数据一致性的难点和理论知识,接下来我们一起来了解一下当前实现分布式数据一致性的几种落地方案。

3.1 两阶段事务

XA 是由 X/Open 国际联盟提出的 Distributed Transaction Processing(DTP)模型。模型的基础就是两阶段提交协议。XA 被许多数据库(如 Oracle、DB2、SQL Server、MySQL)和中间件工具 (如 CICS 和 Tuxedo) 本地支持 。协议定义了交易中间件与数据库之间的接口规范(即接口函数),交易中间件用它来通知数据库事务的开始、结束以及提交、回滚等。XA 接口函数由数据库厂商提供。通常情况下,交易中间件与数据库通过 XA 接口规范,使用两阶段提交来完成一个全局事务。

两阶段(2PC)的组成:

  1. 提交事务请求(投票);

  2. 执行事务请求(提交或中断);

由于两阶段的执行在数据一致性 (协调者 commit 请求出现网络故障时) 和单点故障(协调者出现故障)方面都存在着很大的问题,所以又引入了【预提交】这一新阶段,被称为三阶段,如下:

  1. 提交事务请求(投票);

  2. 预提交请求(网络超时认为执行失败);

  3. 执行事务请求(提交或中断);

三阶段(3PC)事务是将【执行事务请求】一分为二,新增的【预提交】阶段,这解决了两类问题:

◦ 初步解决了请求超时的问题(如果超时,自动失败);

◦ 参与者超时没有收到到协调者的反馈(出现单点故障时),则自动认为成功,开始执行事务;

在现实中很少会选择两(三)阶段事务的方案来解决分布式事务问题,主要有三个方面的原因:

◦ 在有些故障条件下(协调者宕机),会造成所有参与者占有读锁、写锁堵塞在第二阶段,需要人工干预才能继续,存在可用性隐患;

◦ 增加了协调者中间件,系统变得复杂化;

◦ XA 事务的性能不高,很多业务场景难以承受;

如何解决 2PC 和 3PC 的存在的问题呢?

那就是引入多个协调者,同时引入主协调者,并以主协调者的命令为基准,这就是一种最简单的 Paxos 算法。Paxos 的版本有: Basic Paxos 、Multi Paxos、Fast-Paxos,具体落地有 Raft 和 Zookeeper 的 ZAB 协议 。

小结一下,基于 XA 协议实现的分布式事务对业务侵入很小。它最大的优势就是对使用方透明,用户可以像使用本地事务一样使用基于 XA 协议的分布式事务。 XA 协议能够严格保障事务 ACID 特性。严格保障事务 ACID 特性是一把双刃剑,事务执行在过程中需要将所需资源全部锁定,它更加适用于执行时间确定的短事务。对于长事务来说,整个事务进行期间对数据的独占,将导致对热点数据依赖的业务系统并发性能衰退明显。 因此,在高并发的性能至上场景中,基于 XA 协议的分布式事务并不是最佳选择。除了 两(三)阶段事务外,还有 TCC(Try Confirm Cancel:应用层的两阶段提交模型),Saga(大事务分解成多个独立的子事务)等分布式事务模型,这里就不再展开探讨了。

3.2 本地消息表

本地消息表这个方案最初是 ebay 提出的,此方案的核心是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到数据库、本地文件或消息队列,再通过业务规则自动或手动发起重试。下面我就以消息存储到数据库为例,借助数据库本地事务来实现消息的可靠投递。

假设我们有一个服务,需要跨网络更新两个数据库 A 和 B,由于网络调用结果除了返回成功,失败两种结果之外,还有一种状态那就是超时。超时这种状态就比较让人头疼了,它到底是成功了还是失败了呢?都有可能,具体结果无法确定,数据一致性得到了挑战。如何解决这个问题呢?具体方案这样的:

  1. 在数据库 A 中创建一张消息表,在进行业务处理时,将业务数据和消息数据通过数据库事务一起持久化;(保证消息的可靠存储)

  2. 启动一个消息定时投递任务,将消息表中【待投递状态】的记录投递到 MQ 中去,直至投递成功,修改消息状态为【已投递】;(保证消息的可靠投递)

  3. MQ 消费方进行消息处理(ack+ 重试机制 + 幂等设计),执行业务逻辑,写业务数据到数据库 B,并将执行结果同步给 MQ 生产方,MQ 生产方得到回传结果,成功则更新消息状态为【已完成】,失败则更新消息状态为【待投递状态】(业务上的失败则执行事务的回滚逻辑,消息状态更新为【已取消】);

  4. MQ 生产方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再投递一遍;

小结一下,本地消息表模式,是实现柔性事务的一种实现方案,核心是将一个分布式事务拆分为多个本地事务,事务之间通过事件消息衔接,事件消息和上个事务共用一个本地事务存储到本地消息表,再通过定时任务轮询本地消息表进行消息投递,下游业务订阅消息进行消费,本质上是依靠消息的重试机制达到最终一致性。

3.3 MQ 消息

本地消息表实现数据一致性的方案在某些场景下非常有用,但整个实现逻辑比较复杂;在一些不是特别重要核心的业务场景中,为了降低使用成本,很多时候就把消息表给去掉了,直接在本地事务之外发送 MQ 消息,消息消费方执行完业务逻辑后,再回传执行状态(甚至允许不回传)。通过损失一部分确定性,来轻量级的实现数据同步逻辑。使用这种方案的前提是 MQ 服务的稳定性保障要做到位,否则出现问题的概率将大大提高。

如果用 ACID 来衡量该方案,基于可靠消息服务的分布式事务方案能保证事务的最终原子性和持久性,但无法保证一致性和隔离性。数据库的隔离性是通过锁机制来保证的,同样的思路,要想遵守隔离性原则,往往还需要在事务发起方采用分布式锁机制来实现。总体来说,基于可靠消息服务的分布式方案适用于对业务的实时一致性以及事务的隔离性要求都不高的内部系统。

3.4 事务消息

有一些 MQ 是支持事务消息的,比如 JMQ,RocketMQ,它们支持事务消息的方式类似于采用的二阶段提交。

以 RocketMQ 中间件为例,其思路大致为:

• 第一阶段 Prepared 消息,会拿到消息的地址;

• 第二阶段执行本地事务;

• 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态;

具体流程可参照下图:

也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了 RocketMQ 会定期扫描消息集群中的事务消息,这时候发现了 Prepared 消息,它会向消息发送者确认,所以生产方需要实现一个 check 接口,RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

四 并发控制

4.1 背景

我们经常需要在业务处理服务之上加一个缓存层,一方面为了提高了响应效率,另一方面也能节省了下游算力,是一种比较常见的服务优化手段。然而,在缓存层的实现方案上,却有很多种实现方式,其中有些实现方案却存在着很多坑,需要注意规避。

4.2 常见问题

  1. 坑一,将写缓存、发送 MQ、调用 RPC 服务与数据库事务绑定在一起了。涉及到跨网络交互的操作,服务质量严重依赖于各端网络质量,如果网络出现抖动或者中间件服务器出现故障,就会引起本地数据库事务的响应时长的升级,短时事务变成了长时事务,数据库链接资源被长时间占用,得不到释放,引起吞吐量瞬时下降,进而影响其它业务的正常数据库操作;

  2. 坑二,并发场景下的数据一致性问题,典型场景如下:

如何解决上面提到的 “先发后至” 的问题呢?针对这种使用场景,这里提供一些方案设计思路。

4.3 解决方案

• 方案一,主动让缓存穿透,触发重新从数据源读取最新数据;

无论是先写数据库再写缓存,还是先写缓存再写数据库都会存在数据不一致的问题。换一种思路,不再寻求写数据的一致,而是在读数据的时候能够保持一致也可以。核心流程如下:

◦ 写完数据库动作后,主动将缓存中的老数据进行删除;

◦ 在使用数据的时候,发现缓存数据为空(未命中),则主动触发读数据库中的最新数据;

◦ 缓存穿透的场景,再将最新数据同步写入缓存即可;

虽然这种方式不能 100% 保证数据一致性,但不一致的概率大大降低了。

• 方案二,读写分离,通过消息或文件触发同步更新缓存数据;

这种方案的核心是:将写缓存的操作从主业务逻辑中独立出来,比如通过发送一个变更消息或者订阅数据库 binlog 日志,通过变更消息查询数据库的最新数据同步到缓存中去。如下图(其中步骤 4 和 5 为可选项):

◦ 写请求操作数据库;

◦ 异步任务订阅数据库 binlog 日志(MQ 消息也可以),并触发写缓存操作;

◦ 读请求直读取缓存数据;

方案小结,方案一比较简单,容易实现。但由于存在大概率的缓存穿透的场景,在有频繁修改,高并发的场景下,数据库承压比较大,服务的高可用很难得到保障。方案二实现了读写职责分离(CQRS 架构设计),实现上复杂一些。读操作基本上靠缓存,比较适用于并发量高,时效敏感度低的应用场景。

五 总结

目前,分布式数据一致性问题还没有普世通用的解决方案,它需要从业务需求的角度出发,确定对各种一致性模型的接受程度,再通过具体场景来选择解决方案。从应用角度看,分布式事务的现实场景常常无法规避,特别是对涉及金融类的业务,数据一致性是底线,业务需要对数据有百分之百的掌控力。而一般的电商交易场景,使用基于消息队列的柔性事务框架是不错的选择。最后,附几种事务模型的功能对比表:

关注点 本地事务 两(三)阶段事务 柔性事务
业务改造 实现协议接口
一致性 不支持 支持 最终一致
隔离性 不支持 支持 应用层保证
并发性能 无影响 严重衰退 略微衰退
适合场景 单一数据源 短事务 & 低并发 长事务 & 高并发

注:文中部分图片来自于互联网


↙↙↙阅读原文可查看相关链接,并与作者交流