架构师日记-为什么数据一致性那么难
作者:京东零售 刘慧卿
一 前言
在现代大型分布式软件系统中,有一个绕不过去的课题,那就是如何保证系统的数据一致性。著名的Paxos算法(Megastore、Spanner),Raft协议(ETCD、TiKV、Consul ),ZAB协议(ZooKeeper)等分布式一致性解决方案,都是在此背景下而诞生的。
数据一致性保障为什么难呢?先来看一下我们熟知的本地数据库事务是如何实现数据一致性的。众所周知,数据库事务有ACID四大特性,即原子性 (Atomicity)、 一致性(Consistency)、隔离性(Isolation) 和持久性(Durability)。任何支持数据库事务的存储引擎都需要满足这四大特性。以Mysql数据库的Innodb存储引擎的设计实现举例,数据一致性过程如下:
由此可以见,一致性的实现是在持久性,原子性,隔离性等各种特性基础之上的。从技术实现手段来看,为什么是实现数据一致性的过程需要借助这么多种日志文件呢?
这还得从硬件效率上讲起,我们先来看一组数据(仅做示意,不同硬盘型号指标存在很大差异):
通过测试数据我们可以得出以下几点结论:
通过磁盘的读写效率我们可以发现,数据的顺序读写性能要远远高于随机读写,而是数据库的读写场景往往是随机的,为了提高性能效率,就需要尽量将随机读写转换成顺序读写的实现方式。而这些拆分出来的各种日志文件就是其实现方式之一,当然还会有内存缓冲池(Buffer Pool)等其它手段一起配合来实现读写效率的提升。
类似的场景,在数据库应用层面也是存在的,比如:推荐使用数据库自增ID作为主键。为什么有这条建议呢?
这是因为B+ 树结构为了维护索引的有序性,在插入新值的时候需要做必要结构维护。如果插入的值比最大值ID大,则只需要在最后记录后面插入一个新记录。如果新插入的ID值在原先的有序记录中间,就需要挪动后面的数据,空出对应的位置,然后填充新值。如果所在的数据页已经满了,根据 B+ 树算法,这时候需要申请一个新的数据页,然后挪动部分数据过去(页分裂),后面两种情况,性能会受到较大的影响。为了减少位置挪动和页分裂过程中数据的移动,应用层保证新增的索引数据始终是顺序追加模式(新增数据是索引数据的最大值),就非常有必要了。
以上示例,仅仅是本地数据一致性的简单窥探,如果叠加上集群、网络两个维度,实现分布式数据一致性,就变的更加具有挑战了。
二 分布式系统
为什么分布式系统中数据一致性会更加复杂呢?主要体现在下面几点:
2.1 CAP定理
CAP定理也称为不可能三角约束,是由加州大学伯克利分校Eric Brewer教授提出来的,他指出网络服务无法同时满足以下三个特性:
根据CAP原理将数据库分成了满足CA原则、满足CP原则和满足AP原则三大类:
CAP定理告诉我们,在网络可能出现分区故障的情况下,一致性和可用性(延迟)之间必须进行权衡。以Paxos协议来看,它在C和A之间选择了前者,即严格的一致性,而A则降级为大多数一致性(majority available),这和我们接下来要介绍的BASE定理的选择恰恰相反。
2.2 BASE定理
BASE定理是对CAP中一致性和可用性权衡的结果(网络带来的分区容错性无法忽视),是对大型互联网分布式实践的总结,是基于CAP定理逐步演化而来。
BASE理论的核心思想就是:按照实际应用场景,优先满足分区容错性和可用性,采用适当的方式来使系统达到最终一致性(不追求强一致性),这一理论思想对于我们在设计业务系统时,有很大的指导意义。
2.3 事件时序
在分布式系统中,不同的服务分布在不同的机器上,如何确定不同机器上的两个事件发生的先后顺序呢?首先解释下为什么分布式系统需要知道两个事件的先后顺序。举个例子:分布式数据库中不同事务并发执行的时候,需要做事务隔离。隔离的一种做法是使用 MVCC(Multiple Version Concurrent Control)多版本并发控制,根据数据的版本号来控制该版本数据的可见性。这时候就需要知道数据修改事件发生的先后顺序,才能正确的实现隔离性。
如何识别事件发生的先后顺序?有以下两种思路,
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)的组成:
由于两阶段的执行在数据一致性(协调者commit请求出现网络故障时)和单点故障(协调者出现故障)方面都存在着很大的问题,所以又引入了【预提交】这一新阶段,被称为三阶段,如下:
三阶段(3PC)事务是将【执行事务请求】一分为二,新增的【预提交】阶段,这解决了两类问题:
在现实中很少会选择两(三)阶段事务的方案来解决分布式事务问题,主要有三个方面的原因:
如何解决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,由于网络调用结果除了返回成功,失败两种结果之外,还有一种状态那就是超时。超时这种状态就比较让人头疼了,它到底是成功了还是失败了呢?都有可能,具体结果无法确定,数据一致性得到了挑战。如何解决这个问题呢?具体方案这样的:
小结一下,本地消息表模式,是实现柔性事务的一种实现方案,核心是将一个分布式事务拆分为多个本地事务,事务之间通过事件消息衔接,事件消息和上个事务共用一个本地事务存储到本地消息表,再通过定时任务轮询本地消息表进行消息投递,下游业务订阅消息进行消费,本质上是依靠消息的重试机制达到最终一致性。
3.3 MQ消息
本地消息表实现数据一致性的方案在某些场景下非常有用,但整个实现逻辑比较复杂;在一些不是特别重要核心的业务场景中,为了降低使用成本,很多时候就把消息表给去掉了,直接在本地事务之外发送MQ消息,消息消费方执行完业务逻辑后,再回传执行状态(甚至允许不回传)。通过损失一部分确定性,来轻量级的实现数据同步逻辑。使用这种方案的前提是MQ服务的稳定性保障要做到位,否则出现问题的概率将大大提高。
如果用ACID来衡量该方案,基于可靠消息服务的分布式事务方案能保证事务的最终原子性和持久性,但无法保证一致性和隔离性。数据库的隔离性是通过锁机制来保证的,同样的思路,要想遵守隔离性原则,往往还需要在事务发起方采用分布式锁机制来实现。总体来说,基于可靠消息服务的分布式方案适用于对业务的实时一致性以及事务的隔离性要求都不高的内部系统。
3.4 事务消息
有一些MQ是支持事务消息的,比如JMQ,RocketMQ,它们支持事务消息的方式类似于采用的二阶段提交。
以RocketMQ中间件为例,其思路大致为:
具体流程可参照下图:
也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
四 并发控制
4.1 背景
我们经常需要在业务处理服务之上加一个缓存层,一方面为了提高了响应效率,另一方面也能节省了下游算力,是一种比较常见的服务优化手段。然而,在缓存层的实现方案上,却有很多种实现方式,其中有些实现方案却存在着很多坑,需要注意规避。
4.2 常见问题
如何解决上面提到的“先发后至”的问题呢?针对这种使用场景,这里提供一些方案设计思路。
4.3 解决方案
无论是先写数据库再写缓存,还是先写缓存再写数据库都会存在数据不一致的问题。换一种思路,不再寻求写数据的一致,而是在读数据的时候能够保持一致也可以。核心流程如下:
虽然这种方式不能100%保证数据一致性,但不一致的概率大大降低了。
这种方案的核心是:将写缓存的操作从主业务逻辑中独立出来,比如通过发送一个变更消息或者订阅数据库binlog日志,通过变更消息查询数据库的最新数据同步到缓存中去。如下图(其中步骤4和5为可选项):
方案小结,方案一比较简单,容易实现。但由于存在大概率的缓存穿透的场景,在有频繁修改,高并发的场景下,数据库承压比较大,服务的高可用很难得到保障。方案二实现了读写职责分离(CQRS架构设计),实现上复杂一些。读操作基本上靠缓存,比较适用于并发量高,时效敏感度低的应用场景。
五 总结
目前,分布式数据一致性问题还没有普世通用的解决方案,它需要从业务需求的角度出发,确定对各种一致性模型的接受程度,再通过具体场景来选择解决方案。从应用角度看,分布式事务的现实场景常常无法规避,特别是对涉及金融类的业务,数据一致性是底线,业务需要对数据有百分之百的掌控力。而一般的电商交易场景,使用基于消息队列的柔性事务框架是不错的选择。最后,附几种事务模型的功能对比表:
关注点 | 本地事务 | 两(三)阶段事务 | 柔性事务 |
---|---|---|---|
业务改造 | 无 | 无 | 实现协议接口 |
一致性 | 不支持 | 支持 | 最终一致 |
隔离性 | 不支持 | 支持 | 应用层保证 |
并发性能 | 无影响 | 严重衰退 | 略微衰退 |
适合场景 | 单一数据源 | 短事务 & 低并发 | 长事务 & 高并发 |
注:文中部分图片来自于互联网

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
iOS代码覆盖率(一)-全量覆盖率自动化实践
作者:京东零售 邓立兵 简介 这是一个统计基于Swift & Objective-C工程的代码覆盖率的自动化脚本。之所以做成 Pod ,是便于更好的复用,该 Pod 只包含了收集生成代码覆盖率的脚本。整体比较简单方便。 这里只将流程,咱不讲原理。后续另外介绍 这里只将流程,咱不讲原理。后续另外介绍 使用 1、安装: 通过CocoaPods进行安装,在你的 Podfile 文件添加如下代码: pod 'HDCoverage' 复制代码 然后pod install安装下载相关脚本文件。 2、关联脚本: 在项目的Xcode的Build Phases添加新的脚本(New Run Script Phase)(App在Build会执行该脚本): "${PODS_ROOT}/HDCoverage/HDCoverage/hd_coverage_env.sh" 复制代码 3、工程配置代码覆盖率参数: 这里本来是在HDCoverage有脚本支持的,但是基于对哪些模块(Pod作为独立模版)进行代码覆盖率,所以建议在Podfile自主添加如下代码灵活管理,详细说明如下: # 实现p...
- 下一篇
Flutter 异步编程指南
作者:京东物流王志明 1 Dart 中的事件循环模型 在 App 开发中,经常会遇到处理异步任务的场景,如网络请求、读写文件等。Android、iOS 使用的是多线程,而在 Flutter 中为单线程事件循环,如下图所示 Dart 中有两个任务队列,分别为 microtask 队列和 event 队列,队列中的任务按照先进先出的顺序执行,而 microtask 队列的执行优先级高于 event 队列。在 main 方法执行完毕后,会启动事件循环,首先将 microtask 队列中的任务逐个执行完毕,再去执行 event 队列中的任务,每一个 event 队列中的任务在执行完成后,会再去优先执行 microtask 队列中的任务,如此反复,直到清空所有队列,这个过程就是 Dart 事件循环的处理机制。这种机制可以让我们更简单的处理异步任务,不用担心锁的问题。我们可以很容易的预测任务执行的顺序,但无法准确的预测到事件循环何时会处理到你期望执行的任务。例如创建了一个延时任务,但排在前面的任务结束前是不会处理这个延时任务的,也就说这个任务的等待时间可能会大于指定的延迟时间。 Dart 中的方法...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Docker快速安装Oracle11G,搭建oracle11g学习环境
- CentOS7安装Docker,走上虚拟化容器引擎之路
- Docker安装Oracle12C,快速搭建Oracle学习环境
- SpringBoot2全家桶,快速入门学习开发网站教程
- CentOS7设置SWAP分区,小内存服务器的救世主
- CentOS7,CentOS8安装Elasticsearch6.8.6
- Hadoop3单机部署,实现最简伪集群
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- SpringBoot2配置默认Tomcat设置,开启更多高级功能