{T}

数据的持久化和一致性

数据的可用性和一致性是很多工程师几乎每天都会挂在嘴边的概念,在存储系统的技术选型上面,一致性将会是一个重要的衡量因素,而在持久层架构设计上面,它也将是最重要的思考维度

概念和背景

数据持久化,本质上就是把内存中的数据给转换并写入指定的存储系统中,这个过程是保证数据不丢失的基本方式,而这个存储系统可以具备很多种形式,可以是网络、硬盘文件,也可以是数据库,还可以是缓存

在谈论数据持久化的时候讲到的一致性,指的就是在存储系统中,客户端对数据的读写行为都是可以预期、符合一定规则的

  • 可以预期和符合规则,而不是说读到的数据是“一致的”“准确的”或是“最新的”,是因为存在不同的一致性模型,数据对一致性遵从的程度和规则都不同
  • 一致性判断的视角要从客户端来看, 存储系统实际存储的数据可以在某些时候不遵从所要求的一致性,而只需要保证存储系统的客户端能读取到一致的数据就可以了。 举例来说,某一个数据更新的过程中,对于存储系统来说,新数据其实已经写入,但由于事务还未提交,这时客户端读到的还是老数据。

服务为了高可用,就要部署多个节点;数据为了高可用,就要存放多个备份。 这里的数据既包括数据本身,又包括数据的读写服务

  • 要让数据不丢失,冗余几乎是唯一的办法,因为再好的存储介质也架不住设备老化和各种原因的破坏
  • 同理,为了数据访问服务能保持可用,包括保证足够的性能,必须要提供多个节点的读写操作服务,于是不得不创建多个数据副本

如果只有一份数据,是不存在一致性问题的

一致性模型

强一致性 Strong Consistency

强一致性要求任意时间下,读操作总是能取得最近一次写操作写入的数据。

注意,这里依然是从存储系统客户端的角度来描述的, 即便如强一致性的限制,也只要求在读取的时候能读到“最新”的数据就可以了,至于这个在上次写操作之后、这次读操作之前,对存储系统内部的数据是否是“最新”的并无要求。

传统关系型数据库,比如 Oracle、MySQL,它们都是符合强一致性的

弱一致性 Weak Consistency

弱一致性和强一致性相反,读操作并不能保证 可以取得最新一次写操作写入的数据,也就是说,客户端可能读到最新的数据,也可能读不到最新的数据

其实它的应用也挺广泛的,最常见的例子就是缓存,比如一个静态资源被浏览器缓存起来,那么这之后只要是从缓存内取得的数据,使用者其实根本不知道这个数据是不是最新的,因为即便它实际有了更新,服务端也不会通知你

最终一致性 Eventual Consistency

最终一致性介于强一致性和弱一致性之间,写操作之后立即进行读操作,可能无法读到更新后的值,但是如果经过了一个指定的时间窗口,就能保证可以读到那个更新后的值

最终一致性可能是日常生活中最常见的一致性模型了。比如搜索引擎,搜索引擎的爬虫会定期爬取数据,并更新搜索数据,因此如果你的网站只是刚刚更新,可能还无法搜到这个更新内容,但只要是过了一定的时间窗口,它就会出现在搜索结果中了

数据高可用的架构技术

简单备份

简单备份(Backup)指的就是定期或按需对存储系统中的数据全量或增量进行复制,并保存为副本,从而降低数据丢失风险的一种方式

image-20240521113913610

实现上最简单的技术,在个人电脑上极其常见,但即便在工业界,依然有大量的应用场景。比方说 Amazon RDS(将关系数据库搬到云上)的 Snapshot 技术,可以定期将所有数据导出为一份副本。在 CPU 和 I/O 等资源不成为瓶颈的情况下,因为是异步进行的,简单备份往往对存储系统读写操作的影响很小。

这种方式存在存储系统的单点故障问题,一旦存储系统挂掉了,服务也就中断了,因此基本没法谈可用性。

你可能会说,可用性的话,可以给访问存储的 Web 服务器做双机备份啊。没错,但那解决的是 Web 服务器可用性的问题,并不是我们这里最关心的数据可用性的问题,数据存储依然是单点的。同时,如果什么时候存储系统挂掉了,那么只能恢复到最近一次的备份点,因此可能丢失大量的数据

Multi-Master

Multi-Master 架构是指存在多个 Master(主)节点,各自都提供完整的读写服务,数据备份之间的互相拷贝为了不影响读写请求的性能,通常是异步进行的

image-20240521114108292

如果某一主节点对应的存储服务挂掉了,还有另一个主节点可以提供对应服务,因此这种方式是可以提供高可用服务的。图中只放了两个主节点,但是其实是可以放置多个的。

关于一致性,通常情况下节点之间的数据互拷贝是异步进行的,因此是最终一致性。需要说明的是,这个数据互拷贝理论上也是可以做到同步进行的,即将数据拷贝到所有其它的主节点以后再将响应返回给用户,而且那种情况下就可以做到强一致性,不过实际却很少有这样做的:

第一个原因,显而易见,同步的数据拷贝会导致整体请求响应的时延增加。

第二个,也是更重要的原因,如果有节点异常,这个拷贝操作就可能会超时或失败,这种情况下,你觉得存储系统应该怎样对待这个错误?显然,存储系统会陷入两难的境地。

  • 如果系统容许错误发生,不返回错误给用户,那么强一致性就无法保证,既然无法保证,那么这个拷贝过程就完全可以设计成异步的,因为既然无论如何也无法保证强一致性,这个同步除了增加时延以外,并未带来任何明显的好处。
  • 如果系统不容许错误发生,即返回错误给用户,一致性就被严格保证了,但是这样的话,整个存储系统就不再是高可用了,因为任何一个主节点的不可用,就会导致其它任意主节点向其拷贝数据的失败,进而导致整个系统都变得不可用。我们使用多个主节点的目的就是要提高可用性,而现在这样的设计和高可用性的目的就自相矛盾了。

其实对待这个节点间的数据拷贝错误,还有第三种方式,它结合了上述二者的优点。在下面 Master-Slave 的部分会谈到。

Multi-Master 最大的缺陷是关于事务处理的,本地事务(即单个存储节点)可以提交成功,但是全局事务(所有存储节点)却可能失败

它包含这样两种典型的产生问题的场景:

  • 由于是最终一致性,那么数据丢失也是可能发生的,即在写操作成功而节点间数据拷贝还没完成的时刻,如果主节点挂掉了,那么数据丢失也就发生了,只不过丢失的数据可能相对较少,但是全局事务的完整性就无从谈起了。
  • 如果没有节点异常,主节点 A 的事务提交成功,主节点 B 的事务也提交成功,它们是做到了对本地数据库中事务操作的原子性。可是当进行节点间数据互拷贝时,一旦这两个提交的事务发生冲突(例如修改同一条记录),它们就傻眼了,到底应该以 A 还是以 B 的事务为准?这种冲突的解决会比较复杂,而且由于发生在异步的拷贝环节,这时候用户的请求都已经返回响应了,就没法告知用户事务冲突了。

因此当要实现全局事务的时候,Multi-Master 往往不是一个好的选择

3. Master-Slave

Master-Slave 架构是指存在一个可读可写(或者只写)的 Master 节点,而存在多个只读的 Slave 节点,每当有通过 Master 的更新出现,数据会以异步的方式单向拷贝到所有的 Slave 节点上去。

这种方式比较适用互联网较常见的业务,即读远大于写的场景,而且读的可扩展性(Scalability)较强(即增加一个 Slave 节点的代价较小),而且不存在 Multi-Master 的事务冲突问题。

Master-Slave 的缺点也很明显。既然 只有一个可写的节点,那么写的可扩展性就很差了;而且和 Multi-Master 一样,数据从 Master 到 Slave 的拷贝是异步进行的,因此数据存在丢失的可能。

当然也可以让数据拷贝变成同步进行的,但是这又存在着上文讨论过的同样的缺陷。但是,有一种介于全同步和全异步之间的缓解这个问题的方法,即“最小副本数量”,比如可能存在 5 个 Slave 节点,但是从 Master 到 Slave 的数据拷贝一旦在 2 个节点成功了,就不用等另外 3 个返回,直接返回用户操作成功。即便那 3 个中存在失败,系统也可以标记失败节点,并按照既定策略自动处理掉,而不影响用户感知。

可以说这种数据拷贝的方法是“部分同步”“部分异步”的,既降低了数据丢失的可能,又避免了因为某个 Slave 问题而导致Master“死等”的情况发生。

其它

还有其它更为复杂的方法,一种是 2PC 或 3PC,即两阶段提交或三阶段提交,甚至采用高容错的分布式的共识算法 Paxos。这些方法能够保证强一致性,但是在实现上都要复杂许多,我在今天的扩展阅读中会介绍它们。

下面这张比较的表格来自 Transactions Across Datacenters 这个著名的演讲,这张图在互联网上流传很广。

简单说明一下,从上到下每行的含义依次为:一致性、事务支持、延迟、吞吐量、数据丢失和故障转移(指的是节点出现故障以后,其它节点可以自动顶替上来的能力)。

从中我们可以看到,没有一列能够做到全绿色,这正如我们所知道的那样,软件工程上的问题都“没有银弹”。特别是,Backups、M/S 和 MM 得益于异步的副本拷贝,能够做到低延迟,这就无法做到强一致性;而 2PC 和 Paxos 通过同步操作可以做到强一致性,却带来了高延迟

选修课堂:一致性哈希

哈希算法,又被称为散列算法,就是通过某种确定的键值函数,将源数据映射成为一个简短的新数据串,这个串叫做哈希值。 如果两个源数据的 hash 值不同,那么它们一定不相同;如果两个源数据的hash值相同,那么这两个源数据可能相同,也可能不相同。

有时候会使用一个特殊的哈希算法,来将每项数据都映射到某一个“位置”,从而将大量的数据分散存储到不同的位置中。哈希算法在数据量大,且单个节点(单台机器)无法处理的时候尤为有用。比如说要将从 0 到 9999 这 10000 个连续自然数分散到 5 个数据存储的节点上,那我就可以设计一个基于取余数的哈希算法,做到均匀分布:

code
f(x) = x % 5

可以看到,这个函数的结果,也就是哈希值,只有 0、1、2、3、4 这 5 个,对应这 5 个节点,那么我可以根据其结果把这个 x 放到相应的节点上去。这样,每个节点就只需要存储 2000 个数

由于业务的扩张,现在需要处理从 0 到 11999 这 12000 个数了,也就是说多了 2000 个数。可是节点承载的数据量已经基本到达了极限,没法再加入那么多数据了。加机器吧,现在有 6 个节点了就得修改这个算法:

code
f(x) = x % 6

又带来了一个问题,就是这些已经在节点上的数据,必须要调整位置了,毕竟算法变了嘛,因此这些数所在的节点可能要改变,这个过程叫做 Rehashing。只有同为 5 和 6 的倍数的数(即只有为 30 倍数的数),不用调整位置,其它全部都要调整。也就是说,就因为加了这一台机器,29/30 = 96.7% 的数据全部都要调整位置!

一致性哈希,就是一种尽可能减少 Rehashing 过程中进行数据迁移的算法。 且看下面这张图:

总共有 12 格(0 点到 12 点),通过上面类似的哈希算法,把数据映射到时钟的每个格子上,这次取 12 的余数:

code
f(x) = x % 12

同时系统中总共有三台服务器,那么每台服务器就可以负责管理其中的“4 个小时”的数据。比如哈希值是 14 的数据(范围 B)存储在右下角的节点,58 的数据(范围 C)存储在左下角的节点,而 9~12 的数据(范围 A)存储到正上方的节点。

当我们添加新硬件,有一个新节点加入的时候,情况就不同了,请看下图:

在这种情况下,正下方有一台机器被加入,原本 58 点的数据被分成两部分,78 点的数据(C2)依然存储在左下角的原节点,而 5~6 点的数据(C1)则需要迁移到新的,也就是正下方的节点上。添加一个节点,只需要移动其中的一部分数据,也就是 2/12 = 1/6 的数据就行,是不是对整个系统影响就小了很多?

但是添加了一台服务器,如果原始数据哈希计算后的分布是均匀的, 经过 了添加机器的操作,节点上承载数据分布却是不均匀的——正上方、右下角的服务器分别承载了总共 1/3 的数据,而左下角、正下方的服务器却各自只需要承载 1/6 的数据。

解决方法就是引入“ 虚拟节点”,根据时钟的 12 个数字,把它均匀分成 12 个区域,分别由 12 个虚拟节点负责,并且顺时针按照 Ax-Bx-Cx 这样命名。在添加机器以前,每台机器需要负责4块数据(例如某台机器 A 需要承载 A1、A2、A3 和 A4 的数据),并且它们均匀地散布在圆环上:

现在添加新机器,只需要把 A1、B2、C3 这三个虚拟节点的数据,搬迁到新机器 D 上:

同样搬迁了最少量的数据,且元盘上的数据还是均匀分布的,只是从均匀分布在 3 台机器,变成了均匀分布到 4 台机器上。当然,作为示意,我这里是把圆环分成了 12 份,实际可以分成更多的 2^n