数据密集型系统应用设计

03 Mar 2022 | database, notes

Designing Data-Intensive Applications 笔记

数据模型与查询语言

关系模型、层次模型、网络模型、NoSQL

关系模型对象-关系不匹配:如果数据存储在关系表中,那么应用层代码中的对象与表、行和列的数据库模型之间需要一个转换层,可以使用ORM框架。

在关系模式中,表示一对多的关系(如用户的联系方式)可以有多种方式:

  1. SQL1999之前,数据放入多个单独的表,使用外键进行引用
  2. 后来的SQL增加对结构化数据类型和XML数据的支持,允许将多值数据存储在单行,并支持查询和索引
  3. 多值数据编码为JSON或XML,存储为文本列。通常数据库无法查询文本列中的值

此时用JSON模型更加合适,比多表模式更具局部性,查询也更方便。面向文档的数据库(如MongoDB)都支持该数据模型。

当需要表示多对一的关系时(如用户的国籍ID-国籍表),由于关系数据库支持联结操作,可以很方便地通过ID引用其他表的行。此时文档模型并不合适,通常需要进行多次查询来模拟联结,或者反规范化来减少对联结的需求。

文档模型不会对数据强制执行模式,比较灵活。读时模式的数据结构是隐式的,只有在读取才解释。写时模式是显式,在写入数据时由数据库确保遵守模式。

当需要改变数据格式时(如之前将用户的全名存在一个字段,现在想分别存储名和姓),读时模式只需直接用新字段编写新文档,由应用层来处理读到旧文档的情况。而写时模式则需要进行升级,速度会比较慢。此时可以将新字段设为NULL,等到读取时再修改。

如果集合中的项并不具有相同的结构,使用无模式文档比较自然。

如果多对多关系在数据中很常见,可以考虑图数据库。

总结:选择哪种模式,需要考虑数据项之间的关系(一对多,多对一,多对多)、记录是否有相同的结构、是否增删查改记录的大部分数据

数据存储与检索

哈希索引。Bitcask(Riak的默认存储引擎)采用的核心做法:如果将数据存储追加写入,那么将键hash map保存内存中,每个键映射到数据文件中特定的字节偏移值。

数据文件达到一定大小就写入hash map快照并关闭,后续写入新文件。

旧文件执行压缩,只保留每个键最近的更新。

压缩后的多个文件可以进行合并。

每个文件都有自己的hash map,查找数据按从新到旧的顺序,检查文件的hash map。

删除键可以在数据文件追加一个墓碑记录,在合并的时候丢弃该键。

优点:顺序写很快、合并旧段避免碎片化、并发和崩溃恢复简单 缺点:区间查询效率低、键一多hash map维护麻烦

SSTables。LevelDB和RocksDB采用的做法:相比哈希索引的方法,每个键在文件中唯一且顺序存储。压缩过程确保唯一,顺序存储需要在内存维护一个平衡树(如红黑树)

优点:合并段高效、不需要保存所有键的索引。

以上基于合并和压缩排序文件原理的存储引擎被称为LSM存储引擎。一些细节:查找不存在的值会很慢,可以使用布隆过滤器;不同的压缩合并的策略,如分层压缩、大小分级。

面向页的存储引擎,B树。写操作会覆盖旧页,可能分裂页,假如崩溃比较危险。可以使用WAL预写日志。一些优化:写时复制、保存键的缩略信息、相邻页布局靠近…

LSM-tree优点:通常具有较低的写放大;较低的存储空间

LSM-tree缺点:压缩过程会干扰读写操作,响应延迟有时比较高;可能发生压缩无法匹配写入速率,需要额外的监控措施

聚集索引和非聚集索引

内存数据库写入磁盘仅仅为了持久化,读取完全靠内存服务。性能优势不是因为不需要从磁盘读取,而是避免使用写磁盘的格式对内存数据结构编码的开销。内存够大,基于磁盘的存储引擎也可能不需要从磁盘读取,操作系统能缓存最近使用的磁盘块。

内存数据库能提供基于磁盘索引难以实现的数据模型,如Redis为各种数据结构(如集合和队列)提供类似数据库的访问接口。

数据复制

为了容忍节点故障、可扩展性、低延迟,需要采用数据复制。

主从节点模型,只有主节点才能写请求。

同步复制,如果主节点没收到从节点的确认,会阻塞。可以改为半同步:一个从节点同步,其他节点异步复制。如果同步的从节点不可用,提升一个异步的节点为同步模式。

异步复制,主节点崩溃,尚未复制的请求会丢失。

复制日志的实现:

主节点超时时间多长合适?太长意味着恢复时间越长,太短会有不必要的节点切换。TODO

主从异步复制虽然有最终一致性,但是复制滞后也会有问题:

多主节点模型。适用场景:多数据中心(不在一个数据中心内使用多主节点,而是使用常规的主从复制方案。由各个数据中心的主节点进行数据交换)、离线客户端(如手机便签)、协作编辑

写冲突。解决方法:避免冲突(如特定写请求写到同一个主节点)、为写入分配唯一ID,选择ID最高、合并值,由用户解决

无主节点模型。写和读都多个节点,根据版本号技术确定选择哪个值。

局限性:

失效节点重新上线的追赶机制:

宽松的quorum在网络故障时,写入和读取仍然需要w和r个成功的响应,但包含那些并不在先前指定的n个节点。一旦网络问题解决,临时节点需要把接受的写请求发送回原始节点。

无主节点同样有并发写冲突的问题。如果覆盖、丢失数据可以接受,可以采用基于版本号或者时间戳的最后写入者获胜(LWW)来实现最终收敛。否则需要确定写操作的依赖关系(比如使用版本矢量),合并并发写操作。

数据分区

键-值数据的分区

二级索引的分区

分区再平衡策略。取模?不行,节点变化会导致大量的迁移操作。

事务

串行化的隔离级别会带来额外的性能开销,数据库会提供一些弱隔离级别,防止部分并发问题

读-提交。防止脏读、脏写。不能解决例如计数器增量的竞争情况。

数据库通常采用行级锁来防止脏写。可以使用和脏写同一个锁来防止脏读,但是性能太低。数据库通常维护新旧两个值,写事务提交前,读操作都读取旧值;写事务提交后,才读取新值。感觉就是让写事务变原子,类似RCU

这种隔离级别会出现不可重复读或者叫读倾斜的现象(读事务过程中,进行一次写事务,导致读到的数据不一致)。有些场景不能容忍这种暂时的不一致,如备份数据库会出现包含部分旧值新值的镜像、分析查询、完整性检查。

快照级别隔离(可重复读),让事务都从数据库的一致性快照读取,保证看到的是特定时间点的数据。可通过MVCC技术,数据库保留每个对象多个不同的提交版本(不同事务可能在不同时间点查看数据库的状态),来实现快照级别隔离。

PostgreSQL实现MVCC的方法:赋予每个事务唯一递增的ID,每次操作都附上ID…

支持快照级别隔离的存储引擎往往直接采用MVCC来实现读-提交:对读事务中的每个读操作都创建快照。而快照级别隔离用一个快照运行整个读事务。如果只是为了提供读-提交,只保留对象两个版本就够了:已提交的旧版本和未提交的新版本。

上面两种隔离级别都不能解决更新丢失的问题。只有一个最新的数据副本情况下,解决方案

写倾斜。事务开始时数据库的状态,和事务中写操作开始时数据库的状态不同。错误的假设数据库状态导致并发问题。两个事务同时执行读取更新操作,如果更新的是不同对象,则发生写倾斜;更新的是同一个对象,则脏写或更新丢失。

写倾斜一般遵循这样的模式:

  1. 输入匹配条件
  2. 根据查询结果,决定下一步操作
  3. 如果决定继续执行,则发起写操作(增删改)并提交

步骤3会改变步骤2做出决定的前提条件。

更新丢失的解决方案中,单对象的原子操作在这里不起作用;自动检测更新丢失不支持写倾斜问题。解决写倾斜可选的方案:显式加锁、使用串行化隔离级别。对于某些例子,如果步骤2检查的是不满足给定条件的行,预期结果为空(比如同时创建没被用过的用户名),那SELECT FOR UPDATE也无从加锁。

这种一个事务的写入改变另一个事务查询结果的现象,叫做幻读。注意与不可重复读的区别,一个是只对一个对象,一个是一个范围。不过感觉幻读更多的是写倾斜中的不能加锁的情况

串行化隔离。大多数都使用以下技术实现:

以下都在单节点背景下讨论。

严格串行化把事务都放在单线程执行,这是可行的。为充分利用单线程,这种数据库往往不支持交互式的多语句事务(人为交互、网络通信费时),需要提交整个事务代码作为存储过程打包发送到数据库。同时写入吞吐量必须比较低,才能在单个CPU上处理,否则需要分区,最好没有跨分区事务。

两阶段加锁的一个对象都有读写锁。多个事务用共享模式可以同时读取同一对象,但是出现写操作,必须以独占模式获得锁。仅仅这样,幻读问题还存在,需要引入谓词锁。谓词锁与读写锁相似,不过不属于特定对象,而是作用于满足某些搜索条件的所有查询对象。

不过谓词锁性能不佳,大多数数据库使用的是索引区间锁,它将谓词锁保护的对象扩大化(如谓词锁是房间123时间段下午1-2点,索引区间锁是房间123的所有时间段或下午1-2点的所有房间),然后在保护的对象上创建索引。

可串行化的快照隔离提供完整的可串行化保证,性能相比于快照隔离损失很小。两阶段加锁是悲观并发控制机制,可串行化的快照隔离相反。当可能发生潜在冲突,事务继续执行,提交时数据库会检查是否确实发生冲突,是的话中止事务并重试。

分布式系统的挑战

与单机系统不同,分布式系统中会出现部分失效以及不确定性。在这种情况下,超算更像是单节点计算机:如果一个节点出现故障,通常是简单地停止整个集群的工作。

但是互联网相关的服务却有很大不同:

因此需要接受部分故障,并建立容错机制。

网络不可靠,处理这个问题的常用方法是超时机制。长时间的超时不可取,过早宣布节点超时也是有问题的。比如该节点只是由于负载峰值而暂时变慢了。如果该节点正在执行任务而职责却被其他节点接管,会导致任务被执行两次。当系统已经处于高负荷,过早宣告节点死亡会导致级联失效。

有时一个节点可能处于残废状态:例如,由于驱动程序错误,千兆网卡可能突然下降到1Kb/s 的吞吐量。这样一个残废而不是死掉的节点可能比一个干净的失效节点更难处理。

网络的延迟通常出现在排队上:

固定电话网络和数据中心网络的比较。电话网络采用电路交换网络,当拨打电话时会建立固定带宽量的电路,直至通话结束。这种网络是同步的,延迟是有限的固定的。而互联网采用分组协议,这是为了充分利用带宽。如果要使用电路交换网络,预测合理大小的带宽分配也是个问题。

现代计算机至少有两种时钟。日历时钟,也称为挂钟时间,通常与NTP同步,这意味着可能会跳回先前的时间。而单调时钟保证总是往前走,NTP通过调节单调钟的频率来调整时间,而不是直接跳转。

时钟不可靠:

不正确的时钟很容易被忽略,依赖于精确同步时钟的软件,在时钟同步有问题情况下,可能不会有惊天动地的崩溃。

考虑我们依赖物理时钟编写程序: 在节点2上会错误地推断出x=1是最近的值,而丢弃写入x=2。这种冲突解决策略是最后写入胜利(LWW)。有些实现会在客户端而不是服务器上生成时间戳,但这并不能改变LWW的基本问题:

物理时钟是不可靠的,我们需要使用逻辑时钟

分布式系统中的节点,必须假定其执行可能在任意时刻暂停相当长的时间:

分布式系统与运行在单台计算机上的程序的不同之处:没有共享内存,只有通过可变延迟的不可靠网络传递的消息,系统可能遭受部分失效,不可靠的时钟和处理暂停。在本书讨论的那些系统中,我们通常可以安全地假设没有拜占庭式的错误。在大多数服务器端数据系统中,部署拜占庭容错解决方案的成本使其变得不切实际。

一致性与共识

我们希望像事务机制一样,建立一套通用的抽象机制以及对应的技术保证,这样应用程序就可以安全地信赖底层的保证,屏蔽系统内部复杂的问题。分布式系统最重要的抽象之一就是共识,因此建立了分布式一致性模型。

最终一致性,非常弱的保证。使用只提供弱保证的数据库,会使应用开发特别困难。因此需要更强一致性模型。

分布式一致性模型与事务隔离级别相似,但是事务隔离主要是为了避免由于同时执行事务而导致的竞争状态,而分布式一致性主要关于在面对延迟和故障时如何协调副本间的状态。

最强一致性模型有线性一致性。可线性化对于应用程序来说,数据库看起来好像只有一个数据副本,每个读操作返回最近写操作所设置的值。可线性化和可串行化是不同的概念,可串行化是事务的隔离属性,可线性化是读写单个对象最新值保证,它不要求将操作组合为事务,无法避免写倾斜等问题。

有些场景并不一定要线性化(如查询比赛比分),但有些场景下,线性化至关重要。如用户名唯一、账户余额不能负值、不出售没有库存的商品…

线性化的实现方式

只要有不可靠的网络,就会发生违背线性化的风险。不要求线性化的应用更能容忍网络故障,这种思路也成为CAP定理。线性化对性能影响巨大,目前还没能找到有效的线性化方案,可以选择弱一致性模型。

因果一致性保证有因果关系的操作顺序一致。如何确定因果关系?数据库需要跟踪程序读的是哪个版本的数据。

显性地跟踪所有的因果关系不切实际,我们可以用序列号或时间戳来排序事件。对于主从复制数据库,可以在主节点简单地为每个操作递增计数器,就能实现因果关系一致的序列号。如果不存在唯一的主节点(如多主、无主、分区),要产生因果关系一致的序列号,可以使用Lamport时间戳。

仅仅有Lamport时间戳来确定操作的全序关系是不够的,还需要这些操作是否发生、何时确定等(如两个用户创建同名账户,但节点刚收到创建请求,它不知道是否有另一个节点也在创建同名账户,因此无法立刻决定该请求时成功还是失败)。

因此我们发现按时间戳或序列号排序,不如主从复制那么直接有效。主从复制中,为了扩展系统的吞吐量和故障切换,我们使用全序关系广播:在主节点上对所有操作进行排序,并将操作顺序进行广播。

全序广播通常被描述为在节点间交换消息的协议,它要满足两个安全属性:可靠交付和全序交付。全序广播的顺序在消息送达时被固化,因此节点就不会将先前消息插入顺序中的较早位置。这个事实使得全序广播比时间戳排序更强。

全序关系广播与线性化不完全相同,因为全序广播是异步,接收者可能明显落后其他接收者,与线性化要求读取时保证看到最新值相违背。但是全序关系广播可以用来实现线性化存储,线性化存储也可以实现全序关系广播。

比如要确保用户名唯一。要用全序广播构建线性一致存储的话,可以假设主节点有一个CAS操作的线性一致寄存器,这样如果有多个用户试图设置相同用户名,只有一个能成功。通过将全序广播当成仅追加日志的方式来实现用户名唯一:

  1. 在日志中追加一条消息,指明要声明的用户名
  2. 读日志,等待刚才追加的消息被读回
  3. 检查是否有任何消息声称目标用户名的所有权。如果这些消息中的第一条就是你自己的消息,那么你就成功了:你可以提交声称的用户名(也许是通过向日志追加另一条消息)并向客户端确认。如果所需用户名的第一条消息来自其他用户,则中止操作。

日志项是以相同顺序送达至所有节点,这样就能确定对某个写入是提交还是中止达成一致。这里写入是线性一致的,但读取不一定线性一致,可能读到还没更新的从节点。这里的过程提供了顺序一致性,是比线性一致性稍微弱一些的保证。

为了让读取也线性一致,可以:

如果有了线性一致性存储,要实现全序广播,可以用一个线性一致的寄存器来做CAS操作。每个要通过全序广播发送的消息都要对寄存器做自增CAS操作,以获得的值作为序列号来确定全序关系。这里的序列号与兰伯特时间戳不同,是一个没有间隙的序列。

问题是如何实现带有原子性自增并返回操作的线性一致寄存器?关键在于如何处理该寄存器所在节点出现网络中断的情况,并在该节点失效时能恢复寄存器的值到其他节点。这不可避免得出一个共识算法。可以证明,线性一致的CAS寄存器与全序广播都都等价于共识问题。

节点能达成一致非常重要,比如说领导选举、原子提交(要么全部提交,要么全部中止回滚)。

向所有节点发送提交请求并独立提交每个节点的事务是不够的,这样容易导致部分成功部分失败,而成功的事务是不能撤回的。

两阶段提交是一种跨多节点实现原子提交的共识算法,即确保所有节点提交或所有节点中止。2PC会有一个协调者来负责提交,它先发送一个准备请求到每个节点,如果所有节点都回应可以提交,那么协调者会发出提交请求,然后才真正提交。

一旦协调者做出决定,提交或放弃请求会发送给所有参与者。如果这个请求失败或超时,协调者必须永远保持重试。

两次请求之间,协调者可能崩溃或网络故障,此时参与者只能等待,此时处于存疑状态。原则上参与者可以相互沟通并达成一致,但这不是2PC协议的一部分。完成2PC的唯一方法是等待协调者恢复,因此协调者在向参与者发送第二次之前,必须将决定写入磁盘上的事务。协调者恢复后,在日志中没有提交记录的事务都会中止。

三阶段提交(3PC)是为了解决等待协调者恢复而阻塞的问题。然而它假定网络延迟有界,因此让它在实践中工作比较难。

我们应该精确说明分布式事务的含义,有两种截然不同的分布式事务类型:数据库内部的分布式事务、异构分布式事务。数据库内部事务不必与其他系统兼容,所有节点运行同样的软件,可以使用任何协议,进行特定优化。异构事务的系统可能完全不同,更具挑战。

如果异构系统不能抛弃部分完成事务所导致的任何副作用,则难以做到分布式事务。需要所有受事务影响的系统都使用同样的原子提交协议。X/Open XA是跨异构技术实现两阶段提交的标准。

数据库在事务提交或中止之前,都会持有锁,如果在两阶段提交中,由于协调者崩溃而长期处于存疑状态,会阻塞其他事务。理论上,协调者从崩溃中恢复,应该从日志中恢复状态并解决存疑事务。但实践中,会出现一直存在的孤立的存疑事务,这时重启数据库也没办法,因为2PC要求重启也必须保留存疑事务的锁。这样只能让管理员手动操作。

许多XA都会有一种启发式的解决方法,允许参与者单方面对存疑事务进行处理,无须协调者做出决定。这里会破坏原子性,只是为了出现紧急情况准备的。

XA事务有很多限制:

共识必须满足以下性质:三个安全属性(一致统一、完整性、有效性)、一个活性属性(终止)。如果不关心容错,满足安全属性很容易,就像2PC所做的,由一个独裁者来做决定。需要等待节点恢复的算法都不满足终止属性。

最著名的容错共识算法有Paxos、Raft、Zab等。这些共识协议,在内部都有一个领导者,不保证领导者是独一无二的,而是做出更弱的保证,每个纪元编号中的领导者唯一。

共识算法有很多好处,但也有代价。

ZooKeeper和etcd被设计为容纳少量完全可以放在内存中的数据,通过容错的全序广播算法复制到所有节点上,还提供一些方便构建分布式系统的服务:

在这些功能中,只有线性一致的原子操作才需要共识。

一些zookeeper的使用例子:分配工作给节点、服务发现、成员资格服务


Older · View Archive (37)

又是第一篇日志

Newer

DCR 框架中 Message 类的分析