CSAPP 第 1 & 2 章

系统概述

编译系统

系统硬件

系统抽象

信息存储

基本概念

大多数计算机使用 8 位的块,或者字节(byte),作为最小的可寻址的内存单位,而不是访问内存中单独的位。机器级程序将内存视为一个非常大的字节数组,称为虚拟内存(virtual memory)。内存的每个字节都由一个唯一的数字来标识,称为它的地址(address),所有可能地址的集合就称为虚拟地址空间(virtual address space)。

每台计算机都有一个字长(word size),指明指针数据的标称大小(nominal size)。因为虚拟地址是以这样的一个字来编码的,所以字长决定的最重要的系统参数就是虚拟地址空间的最大大小。也就是说,对于一个字长为 \(w\) 位的机器而言,虚拟地址的范围为 0 ~ \(2^{w}\)-1,程序最多访问 \(2^{w}\) 个字节。

我们将程序称为“32 位程序”或“64 位程序”时,区别在于该程序是如何编译的,而不是其运行的机器类型。

字节顺序

对于跨越多字节的程序对象,我们必须建立两个规则:这个对象的地址是什么,以及在内存中如何排列这些字节。

在几乎所有的机器上,多字节对象都被存储为连续的字节序列,对象的地址为所使用字节中最小的地址。例如,假设一个类型为 int 的变量 × 的地址为 0x100,也就是说,地址表达式 &× 的值为 0x100。那么,(假设数据类型 int 为 32 位表示)x 的 4 个字节将被存储在内存的 0x100、0x101、0x102 和 0x103 位置。

在内存中按照从最低有效字节到最高有效字节的顺序存储对象的方式,称为小端法(little endian)。按照从最高有效字节到最低有效字节的顺序存储对象的方式,称为大端法(big endian)。假设变量 x 的类型为 int,位于地址 0x100 处,它的十六进制值为 0x01234567。地址范围 0x100 ~ 0x103 的字节顺序依赖于机器的类型:

整数表示

编码方式

对向量 \(\vec{x}=[x_{w-1},x_{w-2},\cdots,x_{0}]\),各编码方式的定义如下:

编码方式 定义
无符号数 \(B2U_{w}(\vec{x})\dot =\sum_{i=0}^{w-1}x_{i}2^{i}\)
补码 \(B2T_{w}(\vec{x})\dot =-x_{w-1}2^{w-1}+\sum_{i=0}^{w-2}x_{i}2^{i}\)
反码 \(B2O_{w}(\vec{x})\dot =-x_{w-1}(2^{w-1}-1)+\sum_{i=0}^{w-2}x_{i}2^{i}\)
原码 \(B2S_{w}(\vec{x})\dot =(-1)^{x_{w-1}}\cdot(\sum_{i=0}^{w-2}x_{i}2^{i})\)

转换规则

对于大多数C语言的实现,处理同样字长的有符号数和无符号数之间相互转换的一般规则是:数值可能会改变,但是位模式不变。转换关系如下:

  • 补码转换为无符号数,\(T2U_{w}(x)=x+x_{w-1}2^{w}\)。
  • 无符号数转换为补码,\(U2T_{w}(u)=u-u_{w-1}2^{w}\)。

当执行一个运算时,如果它的一个运算数是有符号的而另一个是无符号的,那么 C 语言会隐式地将有符号参数强制类型转换为无符号数,并假设这两个数都是非负的,来执行这个运算。

扩展规则

要将一个无符号数转换为一个更大的数据类型,我们只要简单地在表示的开头添加 0,这种运算被称为零扩展(zero extension)。要将一个补码数字转换为一个更大的数据类型,可以执行一个符号扩展(sign extension),在表示中添加最高有效位的值。对于扩展大小和转换符号的顺序,C 语言标准要求,先扩展大小,再转换符号。

运算规则

  • 检测无符号数加法中的溢出:对满足 \(0\leq x,y\leq UMax_{w}\) 的 \(x\) 和 \(y\),令 \(s\dot=x+_{w}^{u}y\)。当且仅当 \(s<x\)(或者等价地 \(s<y\))时,计算 \(s\) 发生溢出。
  • 检测补码数字加法中的溢出:对满足 \(TMin_{w}\leq x,y\leq TMax_{w}\) 的 \(x\) 和 \(y\),令 \(s\dot=x+_{w}^{t}y\)。当且仅当 \(x>0\),\(y>0\),但 \(s\leq 0\) 时,计算 \(s\) 发生正溢出。当且仅当 \(x<0\),\(y<0\),但 \(s\geq 0\) 时,计算 \(s\) 发生负溢出。

计算一个位级表示的值的在补码加法下的逆元:第一种方法是对每一位取反,再对结果加 1,即 -x == ~x + 1;第二种方法是将最低位的 1 左侧的所有位取反。

无符号乘法和补码乘法在截断之后的位级表示是相同的,该结论可以通过公式推导得出。

整数乘法运算指令比其他整数运算(例如加法、减法、移位)更慢,因此编译器会尝试使用移位和加减法的组合来代替乘以常数因子的乘法。是否实际替换,取决于两种方案的相对速度。

整数除法总是向零取整,即对于正数向下取整,对于负数向上取整。当使用移位运算替换除以 2 的幂的除法时,需要注意补码移位是向下取整。也就是说,对于负数需要添加“偏置(biasing)”值,从而使得其向零取整。如下所示:

1
x / (1 << k) == (x < 0 ? x + (1 << k) - 1 : x) >> k

浮点标准

IEEE 浮点标准用 \(V=(-1)^{s}\times M\times 2^{E}\) 的形式来表示一个数:

  • 符号(sign) \(s\) 决定这数是负数(\(s=1\))还是正数(\(s=0\)),而对于数值 0 的符号位解释作为特殊情况处理。

  • 尾数(significand) \(M\) 是一个二进制小数,它的范围是1 ~ \(2-\varepsilon\),或者是 0 ~ \(1-\varepsilon\)。

  • 阶码(exponent) \(E\) 的作用是对浮点数加权,这个权重是 2 的 \(E\) 次幂(可能是负数)。

将浮点数的位表示划分为三个字段,分别对这些值进行编码:

  • 一个单独的符号位 \(s\) 直接编码符号 \(s\)。
  • \(k\) 位的阶码字段 \(exp=e_{k-1}\cdots e_{1}e_{0}\) 编码阶码 \(E\)。
  • \(n\) 位小数字段 \(frac=f_{n-1}\cdots f_{1}f_{0}\),编码尾数 \(M\),但是编码出来的值也依赖于阶码字段的值是否等于 0。

图 2-32 给出了将这三个字段装进字中两种最常见的格式。在单精度浮点格式中,s、exp 和 frac 字段分别为 1 位、k = 8 位和 n = 23 位,得到一个 32 位的表示。在双精度浮点格式中,s、exp 和 frac 字段分别为 1 位、k = 11 位和 n = 52 位,得到一个 64 位的表示。

给定位表示,根据 exp 的值,被编码的值可以分成三种不同的情况。

分类 阶码 尾数
规格化的值 \(E=e-Bias\),\(Bias=2^{k-1}-1\) \(M=1+f\)
非规格化的值 \(E=1-Bias\) \(M=f\)

这种表示具有一个有趣的属性,假如我们将图 2-35 中的值的位表达式解释为无符号整数,它们就是按升序排列的,就像它们表示的浮点数一样。IEEE 格式如此设计就是为了浮点数能够使用整数排序函数来进行排序。当处理负数时,有一个小的难点,因为它们有开头的 1,并且它们是按照降序出现的,但是不需要浮点运算来进行比较也能解决这个问题。

IEEE 浮点格式定义了四种不同的舍入方式:向偶数舍入(默认),向零舍入,向下舍入,向上舍入。向偶数舍入指的是,将正中间的值向偶数舍入。IEEE 标准中的浮点加法和乘法是可交换的,具有单调性,但是不可结合,不具有分配性。

分布式系统的挑战

不可靠的网络

网络分区故障,指网络的一部分由于网络故障而与其他部分断开,其实可以直接称为网络故障。作者推荐,可以通过故意触发网络问题,来测试系统的反应。

在分布式系统中,需要设置响应的超时时间,从而判断节点是否失效。如果时间设置得太长,则需要等待更长的时间。如果时间设置得太短,可能节点只是负载过高而响应缓慢,此时判断节点失效并将负载转移到其他节点会进一步增加系统负载,从而可能导致失效扩散(还可能会导致其他异常)。比较好的做法是,持续测量响应时间及其变化,然后根据测量结果自动调整超时时间。

从广义上讲,网络延迟的波动可以视为资源动态分配的结果。传统的电话网络(非 VoIP)使用电路交换技术,为每个通话分配一条固定带宽的通信链路,网络的延迟是确定的;而互联网使用分组交换技术,所有用户共享网络带宽,用户之间的数据存在排队的情况,该方法可以增加带宽的利用率,但是理论上的延迟是无限大的。

不可靠的时钟

网络上的每台机器都有独立的时钟硬件设备,通常是石英晶体振荡器,用于维护机器的本地时间,该时间可能与其他机器上的时间不同。通常使用网络时间协议(Network Time Protocol,NTP)来同步机器之间的时间,该协议根据一组专门的时间服务器来调整本地时间(调整石英的振动频率),时间服务器则从精确度更高的时间源(例如 GPS 接收器)获取高精度时间。

墙上时钟和单调时钟

现代计算机内部至少有两种时钟:墙上时钟和单调时钟。

墙上时钟

墙上时钟根据某个日期返回当前的日期与时间,例如 Linux 的 clock_gettime(CLOCK_REALTIME) 和 Java 的 System.currentTimeMillis() 会返回自 1970 年 1月 1 日(UTC)以来的秒数和毫秒数,不含润秒。有些系统则使用其他日期作为参考点。

墙上时钟需要使用 NTP 进行同步,但是存在很多问题。特别是,如果本地时钟远远快于 NTP 服务器,则同步之后会发生时间倒退的现象,以及墙上时钟经常忽略润秒,导致其不太适合用于测量时间间隔。

单调时钟

单调时钟不需要和 NTP 服务器时钟进行同步,适合测量时间间隔。例如 Linux 的 clock_gettime(CLOCK_MONOTONIC) 和 Java 中的 System.nanoTime() 返回的都是单调时钟。单调时钟的单个值没有任何意义,它可能是电脑启动后经过的纳秒数或者其他含义,不同节点上的单调时钟没有相同的基准,不能相互比较。

时钟同步和准确性

硬件时钟和 NTP 服务器可能会出现各种问题,例如:计算机中的石英钟存在漂移现象(运行速度会加快或减慢,取决于机器的温度);如果本地时钟和 NTP 服务器时钟相差太大,应用程序可能会看到时间倒退或跳跃的现象;同步的准确性受限于网络延迟,以及 NTP 服务器是否正常工作;各种其他情况,包括下面提到的润秒。

润秒(Leap second)就是对协调世界时(Coordinated Universal Time,UTC)增加或减少 1 秒,以使协调世界时和世界时(UT,通常指 UT1)之间的差异不超过 0.9 秒。2022 年 11 月,国际计量大会决定在 2035 年之前取消润秒。润秒曾经使许多大型系统崩溃,根本原因是许多系统没有正确适配润秒,软件存在 BUG 从而引发各种问题。可以看下 The Inside Story of the Extra Second That Crashed the Web 这篇文章,讲述了现实中发生过的问题。Google 处理润秒方式是,在一天内逐步调整时间,而不是在一天结束时直接改变 1 秒。PS:一个显示各个时钟目前时间的网站

如果投入大量资源,可以达到非常高的时钟精度,例如交易系统的时钟就要求很小的时钟误差。高精度的时钟可以使用 GPS 接收器,精确时间协议(PTP)并辅以细致的部署和监测来实现。

依赖同步的时钟

如果应用需要精确同步的时钟,最好仔细监控所有节点上的时钟偏差。如果某个节点的时钟漂移超出上限,则将其视为失效节点并从集群中移除。这样监控的目的是确保在造成重大影响(例如隐式的数据丢失)之前尽早发现并处理问题。

时间戳和事件顺序

在无主复制的检测并发写中提到过,最后写入者获胜(LWW)冲突解决策略由于时钟偏差,可能会覆盖非并发写入。

在上述例子中,时钟同步机制稳定工作,节点 1 和节点 3 之间的时钟偏差小于 3ms,但是时间戳却不能正确排序事件,从而导致客户端 B 的增量操作被覆盖。解决方案就是之前提到过的,使用版本向量技术跟踪因果关系。PS:因果关系其实就是非并发写操作的前后关系,版本向量不仅可以跟踪因果关系,还可以判断写操作是否并发。

时钟的置信区间

或许墙上时钟会返回微秒甚至纳秒级别的信息,但是这种精度的测量值其实并不可信,因为存在石英漂移和网络延迟等不确定性因素。所以,我们不应该将时钟读数视为一个精确的时间点,而应该视为带有置信区间的时间范围。例如,系统可能有 95% 的置信度认为目前时间介于 10.3~10.5 秒之间。

可以根据具体的时间源来推算出时钟误差的上限。如果节点上直接装有 GPS 接收器或原子(铯)时钟,那它的误差范围通常可查询制造商的手册。如果节点是从服务器获取时间,则不确定性取决于上次同步以来的石英漂移范围,加上 NTP 服务器的不确定性,再加上节点和服务器之间的往返时间。

但是,大多数系统并不提供这种误差查询接口,通常只会返回某个确定的时间,而没有任何误差信息。Google Spanner 中的 TrueTime API 提供误差查询,它会返回时间的上下界。

全局快照的同步时钟

该节主要是讲如何在分布式场景下,生成全局单调递增的事务 ID,有点不明白这个标题是什么意思。如果是单节点数据库,使用一个计数器就可以实现正确的事务 ID。但是,如果是多节点数据库,则更加复杂并且开销更大。

Twitter 使用雪花(Snowflake)算法来生成近似单调递增的唯一 ID。如果节点之间的墙上时钟完全同步,则也可以将其作为事务 ID,但是实际上是不可能的。Google Spanner 使用 TrueTime API 返回的时钟置信区间作为事务 ID,如果两个置信区间没有重叠,则可以知道两个事务的先后顺序。

进程暂停

在使用主从复制的数据库中,只有主节点可以接受写入,如果主节点失效则需要将某个从节点提升为主节点。判断节点是否失效可以使用租约来实现:如果某个节点持有租约,那么它就是主节点;如果租约过期,则该节点失效。我们可以使用单调时钟来判断租约是否过期,但是可能由于垃圾收集、上下文切换或磁盘 I/O 等原因导致进程暂停,从而使得暂停之前判断租约没有过期,暂停之后发送请求时租约已经过期。

上图是 HBase 曾经遇到的问题,不正确的分布式锁实现,导致未持有锁的客户端修改数据。解决方案是,锁服务为每个锁维护一个单调递增的 fencing 令牌(实际上就是版本号),在锁服务授予客户端租约和客户端向存储服务发送写请求时会包含该令牌,存储服务也会维护数据最后一次修改对应的令牌。如果存储服务收到的写请求包含旧令牌,则会拒绝该请求。如果使用 ZooKeeper 作为锁服务,则事务标识 zxid 或节点版本 cversion 可以充当 fencing 令牌。

数据分区

分区/分片(动词)就是将数据拆分为多个子集,一个子集被称为一个分区[1](名词)。使用数据分区的目的是提高可扩展性,不同分区可以存储在不同节点上,查询负载也随之分散到多个节点。在面对海量数据集或者非常高的查询压力,使用数据复制还不够,这时就需要使用数据分区。当分区和复制结合使用时(假设为主从复制),每个分区都会有自己的主节点和从节点,这种情况下单个节点也会存储多个分区的数据(作为某个分区的主节点和其他分区的从节点)。

分区方式

最好的情况是将数据和查询负载均匀分布到所有节点。如果分区不均匀,就会出现负载的倾斜,这会导致分区的效率下降。极端情况下,所有负载都集中在单个节点上,其他节点都处于空闲状态,此时这个节点被称为系统的热点。避免热点的最简单的方法是将记录随机分配给所有节点,但是由于不知道数据的分布情况,在查询时需要访问所有节点。下面将会介绍对键值数据和二级索引进行分区的方式。

这节的内容看得有点懵。按我的理解,键(key)也被称为关键字(keyword),它是一列或多列属性的集合,可以是唯一的或者不唯一的。但是按照书中的描述以及我在网上看到的定义来说,在“键值数据”这样的词中,似乎要求键必须是唯一的。书中在介绍完键值数据之后,又紧接着介绍二级索引,然后说二级索引在分区时的复杂性在于索引键的不唯一。但是书中接下来的讨论让我感觉,二级索引在分区时的复杂性在于如何创建二级索引,即是创建本地索引还是全局索引。

键值数据

基于区间的分区

解释:可以为每个分区分配一个关键字区间,区间可以由管理员手动选择,或者由数据库自动选择。

优点:分区内的数据可以按照关键字排序存储,从而支持区间查询。

缺点:如果查询集中访问某个范围内的数据,则会导致热点问题,解决方案是使用额外的内容作为关键字的第一列。

基于哈希的分区

解释:可以为每个分区分配一个哈希值区间,关键字根据哈希值进行分区,用于分区的哈希函数不需要很强的加密性。

分析:可以将关键字均匀的分配到多个分区,但是不能很好地支持关键字上的区间查询(查询时需要将请求发送给所有分区)。如果使用的是联合关键字,则可以只将关键字的第一列用于哈希分区,然后将其他列用作联合索引来对分区内的数据排序,从而可以在关键字的其他列上实现区间查询。

问答:为什么要将哈希值拆分为区间,而不直接使用取模操作?因为如果添加/删除节点,取模会导致大量的数据迁移。

负载倾斜与热点

虽然哈希分区可以减轻热点,但是无法完全避免。极端情况下,所有读/写操作都是针对一个关键字,则最终所有请求都会被路由到同一个分区。例如,发生热点事件时,会产生大量对相同关键字的读/写操作,此时哈希分区起不到作用。大多数系统至今仍无法自动消除这种高度倾斜的负载,而只能通过应用层来减轻倾斜程度。例如,如果某个关键字被认为是热点,则可以通过在关键字的开头或结尾添加随机数(有点像密码学中的盐值),从而将请求路由到不同分区。但是,此时读操作必须将多个分区中的数据合并,开销较大。

二级索引

基于文档的分区

解释:每个分区独自创建和维护二级索引,创建的是本地索引,而非全局索引。

缺点:如果要使用索引查询满足某个条件的数据,则需要将查询请求发送给所有分区,然后合并返回的结果。

基于词条的分区

解释:对所有数据创建全局索引,然后对索引进行分区,可以使用区间或哈希分区。

优点:进行单关键字查询时,只需要读取单个分区,因为相同的索引键都会被分配到相同的节点。

缺点:即使更新的是单个节点上的数据,可能也需要更新多个节点上的索引。如果选择同步更新,那么需要分布式事务的支持,写请求会被阻塞;如果选择异步更新,就意味着更新的滞后。

分区再平衡

在某些情况下,可能需要为数据库添加/删除节点,我们希望在添加/删除节点的过程中平衡所有节点的负载,这个迁移负载的过程被称为再平衡或者动态平衡。

再平衡的策略

固定数量的分区

解释:创建远超实际节点数的固定数量的分区,然后为每个节点分配多个分区。如果添加节点,则从每个现有节点中移动几个分区到新节点;如果删除节点,则将其中的分区均匀分配给剩余节点。也可以将硬件配置考虑进来,为性能更强的节点分配更多的分区。

分析:如果数据的规模不确定,就很难确定合适的分区数量。每个分区包含的数据量的上限是固定的,实际大小应该和集群中的数据总量成正比。如果分区数量太大,则每个分区包含的数据量太小,徒增管理开销;如果分区数量太小,则每个分区包含的数据量太大,再平衡和故障恢复的开销就更大(不是很懂为什么)。

动态分区

解释:为每个分区设置阈值,如果分区中的数据量太大或太小,就会进行分裂或合并(类似 B+ 树)。每个节点可以包含多个分区,当某个分区分裂时,可以将其中一半的数据转移到其他节点,以平衡负载。

分析:优点是分区的数量可以通过分裂和合并自动适配数据总量。对于空的数据库来说,需要进行预分裂,从而避免开始时只存在一个分区,导致负载不均衡的情况。

按节点比例分区

解释:为每个节点分配固定数量的分区。如果添加节点,则随机选择固定数量的现有分区进行分裂。

分析:随机选择可能会带来不公平的分裂,但是当每个节点包含的分区数量较大时,可以减少不公平的概率。

疑问:为什么书上说随机选择分区边界的前提是使用哈希分区,以及为什么说该方法符合一致性哈希。

请求路由

我们已经知道如何将数据分区,以及如何平衡节点上的分区,现在还有一个问题是,如何将请求路由到对应分区所在的节点。如果发生分区再平衡,分区和节点的对应关系还会随之变化,我们需要能够跟踪这些变化。有如下三种处理策略:

  • 客户端将请求发送给任意节点,如果当前节点没有对应的分区,则将请求转发给其他节点,直到找到对应节点。
  • 客户端将请求发送给路由层(负载均衡器),路由层负责将请求转发给对应节点。
  • 客户端跟踪分区和节点之间的关系,直接将请求发送给对应节点。

不管使用哪种方法,核心问题是:作出路由决策的组件(节点、路由器、客户端)如何跟踪分区和节点的对应关系。有的分布式系统依靠独立的协调服务(例如 ZooKeeper)跟踪对应关系,有的使用 gossip 协议在节点之间同步对应关系,等等。

PS:数据分区这章看得有点痛苦,感觉书上的表述很乱,包括多个同义词混用,以及前后表述不一致。一些部分也讲得很模糊,没有一个实际的例子,单是看某句话感觉会有歧义,不知道实际上想表达的是什么。先这样吧,总感觉笔记上有很多问题。


  1. 这里讨论的数据分区和网络分区问题(一种节点间的网络故障)中的分区是不同的概念。 ↩︎