Computer System Engineer

Computer System EngineerChapter 0. ConceptsChapter 1. Distributed System1.1 Concepts1.2 Filesystem Intro: Single-node inode-based Filesystem1.2.1 文件的定义1.2.2 实现文件系统的目标:从 Native FS 开始1.2.3 Block Layer1.2.4 File Layer1.2.5 Inode Number Layer1.2.6 File Name Layer1.2.7 Path Name Layer & Absolute Path Name Layer1.2.Ex Quiz1.2.8 Hard Links1.2.9 Symbolic Link Layer1.3 Implement the File System API1.3.1 OPEN1.3.2 READ1.3.3 CREAT1.3.4 WRITE, APPEND, CLOSE1.3.5 SYNC1.3.6 DELETE1.3.7 RENAME1.4 RPC1.4.1 RPC Message1.4.2 RPC Implementation1.4.3 How does RPC Handle Failures?1.5 Distributed File System1.5.1 NFS: Network File SystemDesignsPerformance & ImprovementsDrawback1.5.3 Case Study: Google File SystemDesign Assumptions: environmentsGFS InterfaceGFS ArchitectureComparison between Improved NFS & GFSGFS Interaction ModelReading a file in GFSWriting a file in GFSNaming in GFSHDFSSummary1.6 KVStore: System with a Simpler APIConceptsDesign Natïve KVSImprovements I: Redesign Implementation & Pack DataImprovements II: In-memory / On-Disk Index For Read & SearchImprovements III: Alleviate Write AmplificationImprovements IV: Large Range Query Supports1.7 Consistency Model1.7.1 Intro1.7.2 DefinitionsStrict ConsistencySequential ConsistencyLinearizability1.7.3 Implementation of Linearizability1.8 Eventual Consistency1.8.1 Definitions1.8.2 Converging State: Update ID & Write Ahead Log1.8.3 Causality Preserving: Lamport Clock & Vector Clock1.8.4 Truncate WAL1.8.5 Conclusion1.9 Consistency Under Single-Machine Faults: All-or-Nothing1.9.1 Shadow Copy for Atomicity1.9.2 Logging for Atomicity1.9.3 Conclusion1.10 Consistency for Isolation: Before-or-after Atomicity1.10.1 Definitions & 2PL1.10.2 Case: Salary System1.10.3 Deadlock1.10.4 OCC: Optimistic Concurrent Control1.10.5 Lock Preliminary:锁如何实现1.10.5 OCC & Hardware Transaction Memory (HTM)1.10.6 MVCC1.10.7 Conclusion1.11 Replications & Multi-site Atomicity1.11.1 2-PC1.11.2 Replication1.11.3 Single-Decree Paxos: Distributed Consensus MechanismPhase 1A: PreparePhase 1B: PreparePhase 2A: AcceptPhase 2B: AcceptPhase 3: Learn1.11.4 Multi-PaxosBasic ApproachImprovements1.11.5 RaftChapter 2. Revisit: Network2.1 Layers2.1.1 Link Layer2.2 Network Layer2.2.1 Control Plane: Routing Algorithm2.2.2 Data Plane: Packet Forwarding2.2.3 NAT: Network Address Translation2.2.4 Ethernet Protocol & ARP2.3 End-to-End Layer2.3.1 At Least Once Delivery2.3.2 At Most Once Delivery2.3.3 Data Integrity2.3.4 Segments and Reassembly of Long Messages2.3.5 Jitter Control2.3.6 Authenticity and Privacy2.3.7 Performance Improvement: Sliding Window2.3.8 TCP Congestion Control2.3.9 Weakness & Summary of TCP2.4 Yet Another Protocol in End-to-End Layer: DNS2.4.1 Definitions2.4.2 Look-up Algorithm2.4.3 Design Pattern of DNS2.5 Naming Scheme2.6 CDN2.7 P2P Network (End-to-End Layer Protocol)Chapter 3. Distributed Computing & Programming3.1 Parallelism on Single Chip Device3.2 Distributed Computing Framework: MapReduce3.2.1 Definitions: Batch Processing3.2.2 Overview3.2.3 Implementation3.2.4 Fault Tolerance3.2.5 Optimization3.2.6 Summary3.3 Computation Graph3.3.1 Definitions3.3.2 Application: Distributed TrainingData ParallelismModel ParallelismAsync vs. Sync executionChapter 4. Security4.1 Authentication (Password)4.2 CFI: Control Flow Intergrity4.3 Example: Buffer Overflow - BROP4.4 Data Flow Protection4.4.1 Taint Tracking4.4.2 Defending Malicious Input4.5 Security Channel4.5.1 对称加密4.5.2 非对称加密4.5.3 Replay Attack & Reflection Attack4.5.4 Diffie-Hellman Key Exchange, RSA & MITM4.5.5 证书4.6 Data Privacy4.6.1 OT: Oblivious Transfer (无感知传输)4.6.2 DP: Differential privacy (差分隐私)4.6.3 Secret Sharing4.6.4 Secure MPC (sMPC): Multi-Party Computing4.6.5 Homomorphic Encryption (同态加密)4.6.6 TEE (Trusted Execution Environment)

Chapter 0. Concepts

Chapter 1. Distributed System

1.1 Concepts

CAP: Consistency、Availability、Partition Tolerance(一致性、可用性、分区容忍)

CAP Theorem:一个分布式系统中,最多只能满足上述 3 中特性的其中两种。

并不是说满足了两种后,剩下一种完全没有,而是说剩下的一种无法完全保证。

 

1.2 Filesystem Intro: Single-node inode-based Filesystem

1.2.1 文件的定义

1.2.2 实现文件系统的目标:从 Native FS 开始

规定一个文件系统的 API(抽象文件系统的接口):

然后 data 需要是一个有限随机大小;

如果我们直接用 Hardware 的 API 做一个 Native File System:

  • 每个 sector index 作为文件名;

  • 写文件要么是 append,要么 reallocate;

问题是:

  • 如何找到空闲空间?(暴力扫描,效率低)

  • 如何扩充一个文件?(可能需要移动大量的 sector 内容,效率低)

  • meta-data overhead 比例很大:如果每个文件持有的 sector indices 都要保存(离散式),那么相当于每个 sector(大小由硬件厂商决定,4 ~ 256 bytes 不等)就要额外存放 8 bytes(需要能表示 sector 总大小)的数据,这是不能忍受的;

  • 如果想要以字符串作为用户友好的文件名?

  • 如果想对文件施加认证措施?

  • ……

肯定不行。

我们需要借鉴 UNIX File System 的抽象(Naming Layers):

1.2.3 Block Layer

Block:文件系统最基本的数据单元。把多个连续的 sector 看成一个 block;

注:为了方便起见,在今后讨论 block 时默认一个的大小是 4 KB(现实中可以有其他大小);

总结block layer 把磁盘存储资源抽象成了一个大数组,每个 index 可以访问一个 block;

1.2.4 File Layer

有了 block 的抽象,在其上形成 file 的抽象就合理一些。

目标:我们想要抽象出以下内容:

然后想要抽象文件的思路就很简单了:用 meta-data 声明一个文件所拥有的 block index 的集合

这个声明 block index 的 meta-data 被称为 index node(inode):

理所当然地,inode 信息也应该持久化,那么它应该放在哪个 block 中由谁指定呢?

也很简单,我们单独指定一些 blocks 来专门存放文件的 inode 信息。那么又有一个问题:文件的数量很大导致 inode 的数量也很多,这样一个 inode 能描述的文件 block 数量很有限。

于是我们借鉴页表的思想,让 inode 越靠后的 blocks 指向越深的 indirect blocks,像这样:

但不直接使用页表的设计。因为考虑索引次数和实际大小:磁盘上索引一次的耗时很长,而且一个文件往往没有一整个 address space 那么大;

这样文件靠前位置的数据能很快索引(靠后的数据也可以先 pre-fetch),性能上获得提升。

另外,一个 indirect block 可以放在 data blocks 中,有些实现也会放到 inode table 中,和具体实现有关。

值得注意的是,一个 inode 的大小取决于文件系统的具体实现。它的大小会反过来限制一个文件最大能承载的容量上限(主要是限制了 block number 数,即便采用了多层 indirect 的方式也有影响)。

通过上面的设计,我们现在能够实现这样的映射关系:

总结:为了组织很多离散的 block 为一个文件,引入 inode 数据结构。inode.block_num[N] 中记录了文件的第 i 个 4K 内容放在哪个 block 上(block number)。

但是如果文件很大,那么就需要很多 block 来存放,这样 N 会很大(一个 4KB block 需要 8 bytes 描述 block number,那么一个 16GB 文件需要 4M 个 block,也就是 32MB 空间来记忆所有 block number),不现实。

因此我们给 inode block_num 数组靠后的几个 entry 采用多级 indirect 的方式来索引获得 block number,这样能存放更多的 block number;

1.2.5 Inode Number Layer

现在解决了一个文件的抽象。但是要找一个文件的数据首先就找 inode,我们说要单独为它们准备一个地方,这个 inode 存放的专门的位置就称为 inode table,这些 inode 的索引就是寻找 inode 的唯一标识:inode number。我们将这个抽象层称为 inode number layer;

其中 inode table 由一块块 block 组成,每个 entry 的大小就是一个 inode 的大小;

为了节省空间,防止开辟的 inode table 大小过大,我们可以考虑存放 inode table 的 table,让这个 table 的每个 entry 指向下一级 inode table 的起始地址(indirect)。这也是一种方法,不过性能会差点。

Inode Table 的起始地址是固定在 bitmap for free blocks 之后的位置,它可以放在 super block 中而 inode table 可以指示哪些 inode number(index)是空闲的

这样我们能实现新的映射关系:

到这为止,我们已经实现 “通过一个标识符(inode number)自由访问一个可伸缩文件的内容” 的目标了。但是还有一些需求没有满足:

总结:指定 inode 就需要让 inode 能够描述。这个时候我们将 inode 单独组织在一片 block 中(inode table),并且用索引来标识它们(所以 inode number 和位置有关);

1.2.6 File Name Layer

现在我们为了实现 user-friendly name,我们再加一层 file name layer,将文件名(字符串)和 inode number 联系起来。这是个表的结构,也需要存起来,它应该存放在哪?答案是一种特殊的文件:目录

也就是说,这种文件名 - inode number 的映射关系就用名为 “目录” 的特殊文件来保存(目录内也只包含这样的映射,不包含文件内部的任何信息):

File nameInode number
helloworld.txt12
cse2024.md73

然后 inode 的结构可以更新为:

注:在 EXT4(一个现代的 UNIX FS)中,规定 type:

  • 0x0: Unknown;

  • 0x1: Regular file;

  • 0x2: Directory;

  • 0x3: Character device file;

  • 0x4: Block device file;

  • 0x5: FIFO;

  • 0x6: Socket;

  • 0x7: Symbolic link;

这样我们能建立起新的映射关系:

总结:inode number 是与 inode table 位置相关的标识,直接使用会各种问题,例如文件 inode 用户不友好、不好控制权限等问题。于是我们引入了 “目录” 这种特殊文件,来单独保存这种映射关系。

因此文件名不在文件里、不在文件的 inode 里,而是仅存在于目录文件里

一般情况下 UNIX 目录没法直接看 directory 文件中的 raw content,需要用 /sbin/debugfs 来查看:

注意到 2 个问题:

  1. 上面的目录是按照 block 查找匹配的,如果文件名跨两个 block,应该怎么办?

    两种方法:一种是把算法写复杂一点,考虑边界情况;另一种是限制文件名长度,并且利用数据结构大小做对齐,使得文件名不会跨越两个 block。

    在 UNIX v6 中规定了一个文件名最大长度是 14 bytes,可能就说明了它是使用了后一种方法来处理的。

  2. 上面的 LOOKUP 算法只能针对同一级目录下的文件。如果文件数量很多,我需要分多个目录层级(目录里套目录),这个时候我们要么使用递归查找(效率慢),要么引入一种新的描述方法,直接描述文件在层层嵌套的目录中的位置。这就是 path name;

1.2.7 Path Name Layer & Absolute Path Name Layer

为了解决上面多层目录文件定位的问题,我们引入了新一层抽象:path name layer;

对应新的映射关系如下:

注意到目录结果是否有终极父结点?答案是有的。UNIX FS 的所有文件全部是 /(根目录)的子文件。

而以 / 开头的就是绝对路径。当然 / 目录文件会放在 inode table 的特殊位置(一般 inode number 为 1),方便让系统查找(提供递归终止条件)。

那么一个相对路径和绝对路径是能相互转换的。

1.2.Ex Quiz

如下图的文件系统中,系统如何查找 /programs/pong.c

在 path name layer 的基础上,做一种快捷方式,可以用较短的 path name alias 到很长的 path name,实现快速的解析和访问。

为了实现这个目标,我们想出两种接口:

另外我们需要设计几条规则:

有了 LINK 和 UNLINK 的接口和语义,我们就能在此基础上实现更丰富的功能。

例如重命名 和 移动,它们本质上在 UNIX FS 中是同一种操作。无非是 将一个文件原来记录在目录 A 中的映射关系移动到目录文件 B 中记录,其中 AB 可以相同(重命名操作),并且移动过程中映射的文件名可以改变(移动操作);

那么实现这个功能:move(from_name, to_name),就可以由下面的操作完成:

  1. UNLINK(to_name):如果有 to_name 就会覆盖,相当于先删除;

  2. LINK(from_name, to_name):在 dirname(to_name) 下创建一个 from_name 的 inode number 和 basename(to_name) 的映射;

  3. UNLINK(from_name):最后把原先的文件删除即可;

但这么做有问题:如果 OS 在 1~2 步骤间掉电,那么 to_name 的文件会被删除,但 from_name 还没有移动,这就会产生意料之外的行为。

为了解决 “不在一个文件系统下,却想要创建快捷方式” 的问题,UNIX 提供了更高一层抽象:软链接层。

这个软链接也是一个文件,不过它和硬链接产生的 regular file 不一样,它是一种特殊的文件,里面只包含要链接文件的 pathname

因此,和硬链接不一样的一点是,甚至目标文件不存在时,我也能创建一个软链接(因为这个文件只包含 path name,并且是独立的、特殊文件类型,本身有独立的 inode);

例如:

为什么 tiler-json 软链接的大小是 14 bytes?因为它内部内容只有字符串 "tiler/geojson/"(长度 14)!

我们可以通过 readlink 指令阅读软链接的实际内容。

所以,symbolic link 是以 path name 与目标文件关联,不受 reference count 影响;

hard link 是以 inode number 与目标文件关联,会影响 reference count,并且不能跨文件系统;

总结区别:

sidebar: 在 cd 到一个软链接指向的目录后,如果想显式去往当前目录的真实上级目录(不考虑软链接),则需要指定 cd -P ..,否则大多数 shell(bash / zsh 等)会认为你是想去历史上上一次 context 位于的目录;

 

1.3 Implement the File System API

在了解 FS 基本的设计理念和规约之后,我们可以着手实现一个 file system 了。首先列出 OS system call 需要的 File System API:

在此之前,我们揭晓 inode 数据结构中剩下的数据成员。剩下的数据成员没有体现在 File System 的设计理念中,是因为它们是 OS 管理文件必要的其他元信息,和文件存储本身关系不大:

现在我们再回头看看磁盘文件系统中详细是什么:

提问:2T 硬盘的 data free block bitmap 大约占多大的空间(忽略 inode free block bitmap 和 boot/super block)?

2TB bytes 空间需要 2T / 4K 个 bit 作 data free block bitmap;

2T / 4K (bit) / 8 (bit / bytes) = 64 MB;

1.3.1 OPEN

此操作需要完成以下步骤:

  1. 检查当前用户权限;

  2. 更新文件 atime(也可在 READ 时更新);

  3. 返回临时的、对某个进程而言的,该文件的 short name(file descriptor),这样不必反复进行字符串比较;

为什么 short name 不使用 inode number(或者 inode 指针 / block number)?有以下几点考量:

  • Security:file descriptor 相当于一层 indirect layer,让用户态无法直接访问内核态数据;

  • Non-bypassability:任何 FS 操作都由 Kernel 完成;

其中还需要回想一下 file descriptor table、file table、v-node table 三者的关系及其组成。

fd table 保存了:

file table 中保存了:

其中 fork 出的父子进程一开始复制 fd table(但不共享)、共享 file table entry;

所有进程共享一个 file table,但不一定共享 file table entry,如图:

注意 file cursor 可以被 SEEK 操作改变、随着 READ 而不断前进

搞清楚细节后就可以开始实现 OPEN 方法了:

1.3.2 READ

在打开文件后,READ 操作也不难,尤其是我们已经清楚了 fd table 和 file table 的机制:

现在我们综合起来讨论一下,1 次 OPEN、1 次 READ 大量数据需要多少次磁盘的 read 和 write:

 

在 UNIX FS 加载过程中,如果有参数 -noatime,那么 READ 就不会每次 磁盘 read 时写一遍 atime,而是在文件关闭时写一次;

1.3.3 CREAT

如果是创建的话,除了文件(包括目录文件)内自身的数据,还需要注意更改 data free block bitmap、inode free block bitmap 的数据(一般不考虑 super block,因为在 FS 加载时就读完了);

下面的图片是这段代码的 timeline:

 

那么现在有个问题,在写一个新的文件的时候,下面哪个顺序更好?

  1. Update block bitmap, write new data, update inode (size and pointer);

  2. Update block bitmap, update inode (size and pointer), write new data;

  3. Update inode (size and pointer), update block bitmap, write new data;

最好的是第 1 种方案。

第二种坏在如果在第二步~第三步间断电,那么内存中原来被释放的数据可以在下次启动时被完好地读出来,造成信息泄漏的隐私安全问题。

第三种在哪一步断电都有安全危险。

第一种最坏的情况不过是泄漏了一些磁盘空间,而且可以通过磁盘扫描(扫描 free bitmap 和实际 inode 引用)进行纠正。

1.3.4 WRITE, APPEND, CLOSE

上面的操作在中途断电后都会造成 inconsistency 的问题;

1.3.5 SYNC

反复的磁盘 read 和 write 在磁盘看来问题不会那么大,因为磁盘厂商可能会在其中做一些 block cache;

因此可能在写完一组数据后 / 批处理后 / 关机前需要真正落盘(force flush),而不是放在 cache 中。所以产生了这个指令。这条指令会让所有对 file 的更改全部落盘。

这条指令的问题是,在断电后会出现 inconsistency;

1.3.6 DELETE

其实就是我们之前实现的 UNLINK(取消硬链接)接口,和 WRITE 是类似的反操作。

不过需要注意的是,如果一个进程打开一个文件的时候,另一个进程删除了这个文件,虽然这个 file 的 inode reference count 为 0,但在 file table 中如果这个 inode 的 file table entry reference count 不为 0,因此 inode 不会被释放。直到进程关闭了这个 inode 文件后、file table entry 中所有对于 inode 的 entries 全被释放,该文件的 inode 才会被释放(+ free bitmap)。

Window 上删除一个被打开的文件是禁止的,实现方式类似文件锁。

1.3.7 RENAME

实际上我们之前讨论过这个问题,使用 LINK 和 UNLINK 方法实现 RENAME,但是 UNLINK + LINK + UNLINK 的操作在第 1-2 步间掉电时会出现意外的行为(to_name 丢失),所以改成两步:

  1. LINK(from_name, to_name)(weak specification):将 to_name 映射的 inode number 转为映射 from_name 指向的 inode number,并且减小 to_name 指向 inode 的 refcnt;

    Q&A:思考是否有必要增大 from_name 的 inode refcnt?

  2. UNLINK(from_name)(weak specification);

注意到:

 

1.4 RPC

在单个语言层面其实有各自实现。

例如 Java 中有 RMI(Remote Method Invocation,面向对象版本的 RPC);

1.4.1 RPC Message

应该包含:

费时:

  • 系统调用 domain switch;

  • 消息 copy、marshal;

注:最初的 RPC 库安排的 Request Message 有如下构成:

Xid(Transaction ID)、call/reply、rpc version(for compatibility);

program #(Process ID / port)、program version、procedure #(Function ID);

auth stuff、arguments;

而 Reply Message:

Xid(Transaction ID)、call/reply;

accepted? (Yes, or No due to bad RPC version, auth failure, etc.);

success? (Yes, or No due to bad prog/proc #, etc.);

auth stuff;

results;

其中消息传递的挑战性很大:

我们对于消息传递的目标如下:

如果是 Texture Format:

如果是 Binary Format:

1.4.2 RPC Implementation

框架通过声明自动化生成代码,实现 参数编解码、收发消息、消息传输 等动作;

Generate stubs from an interface specification;

消息传递需要确保:正确性、固实性、兼容性;

RPC 传输协议:

1.4.3 How does RPC Handle Failures?

A user sends an RPC but the server does not reply, possible reasons:

处理方式要根据:

 

1.5 Distributed File System

在 RPC 的帮助下可以建立一个分布式文件系统。

目的:file system scalability;

访问远程文件的设计办法:

如果我们使用显式访问的方式来访问文件行不行?有问题:

1.5.1 NFS: Network File System

Designs

设计目标:

NFS 对外界发布的 API:

注意到几点:

 

当一个应用尝试读一个远程文件时:

 

Performance & Improvements

分析:有时候 Network File System 会比本地磁盘还要快(取决于网速和文件服务器性能);

优化:在 client 端做 cache;

系统优化 3 大件:Cache、Hash、Batch;

注:在 Server 端,OS 会自动地 cache;

除了 cache,还可以从 read-ahead(顺序读文件时,预加载)、消息网络传输压缩等思路考虑。

 

Drawback

NFS 目前还是基于单机的远程文件系统。

想法:把很多台机器放在一起组成一个超大的文件系统。

具体实现思路是做手动的分区和备份——但不好,又回到了 FTP 的样子。

也许我们没法完全重用 inode-based file system,需要对它做一些改进:

 

这样的改进也只能改进 capacity,没法提升:

想要进一步解决上面的问题,还需要更强的武器(如 data replication,但又会引入数据一致性问题);

 

1.5.3 Case Study: Google File System

Design Assumptions: environments

GFS Interface

GFS Architecture

这很像我们之前尝试改进的 NFS 的做法:

为什么文件使用 64 MB 的 large chunks 作为文件存储基本单位?

  • 减小网络交流频率:网络通信开销大,通过增大 chunks 牺牲一部分 utilizations 换取更少次数的请求(而且在 design assumptions 中说了 workload 的大部分文件都很大);

  • 提升可连接的 TCP 数量上限:更小的 blocks 分散在更多的机器上,需要维持的 TCP 连接更多,实际能连接的能力就下降了;

  • 减小 metadata 的大小,以便 master node 可以将信息存在内存中,加快访问速率;

GFS 还有更多的机制:

Comparison between Improved NFS & GFS

GFS Interaction Model

Reading a file in GFS

Writing a file in GFS

特点:

设计目标:

 

为了保证一致性、消除并发写冲突,需要在同一组 replicas 中选一个 chunk server 作为 a single primary(leader)来统一协调写操作。有两个问题:

master 如何选择一个 primary?这个 primary 不能持久,因为每台机器都有可能故障,而是定期随机地在每组 replicas 中通过给予 “a chunk lease”(租约)来选 primary,在这些 replicas 中只有这个 chunk server 才能修改 chunk(并且心跳连接);

允许允许续租机制;

更改 primary 后,master node 会通过更新(增加)chunk version 并通知 replicas 来完成。

因此写操作分为以下几个阶段:

GFS 的写操作对 atomic append 非常友好:

 

Naming in GFS

GFS 采用 simple flat naming,也就是不存在常见文件系统的 “目录” 结构、不存在 alias(即软硬链接)。因此 files namespace 就是一个查找表(lookup table),直接将 pathnames 映射到 file metadata(就像 KV Store);

 

HDFS

HDFS(Hadoop Distributed File System):一个 Apache 开源的分布式文件系统实现,受到 GFS 的启发(架构几乎一样,只是改了术语);

 

Summary

 

1.6 KVStore: System with a Simpler API

Concepts

首先进行存储抽象,来定义讨论的范围:

我们定义针对这种数据的存储系统为 “Key-Value Store System”(KVStore / KVS);

然后定义 KVS 的 API:

Design Natïve KVS

那么如何实现 KVStore 系统呢?有个 naïve 解决方案:

这样 GET 就是 OPEN + READINSERT 就是 CREATE + WRITE 等等,我们就可以利用类似文件系统的方法来处理 KV Store 的问题啦!

但有问题:

综上:

Improvements I: Redesign Implementation & Pack Data

我们想出一个主意:

让一个/多个文件放多个的 kv data,让不同的 kv 打包进同一个 disk block 中;

这样缓解了上面系统调用开销大、空间膨胀等问题;

接下来,我们只需要在文件系统的基础上来设计 KV Store 系统就行了!

为什么要借鉴文件系统?

  • We still need a system to interact with the disk hardware

  • (还是要依赖于 file system)Though modern KVS may also bypass the file system, but is uncommon;

第一个目标:如何在 natïve KVS 上实现 UPDATE(K, V)

两种策略:

根据 ICS / 存储介质基本知识,顺序写远远快于随机写,因此我们选择第二种方案。

这样 INSERT 轻松完成,DELETE 可以通过插入 (K, NULL) 来完成(最后需要进行定期 GC 来避免写放大);

这种 Append-Only 的文件格式被称为 Log-structured file(an append-only sequence of records);

最终我们获得如下好处:

但是!我们还有最后一个 API 没考虑:GET(K)(或者 SCAN(K1, K2))。

结果因为追加更新的性质,我们需要最坏 O(n) 的查找力度(这在大型文件中相当讨厌!);

为了解决这个问题,我们可以借鉴数据库的 “索引” 来加速查找工作。

像 B+Tree, HashTable 都可以作为索引。其实我们发现之前已经用过类似的索引结构了:文件系统中的 inode table 就是对 inode 的索引(不过太过简单);

我们可以先试一试用一个 in-memory hash map 来将 key 与 KV 数据所在 log file 的偏移量关联起来:

这种方法被一些商用系统采用(例如 Bitcask in riakKV);

优点:读写高性能;

局限性:INDEX 只在 RAM 中。而 RAM 大小有限,太多的插入操作(新建 key)会导致 INDEX 在内存中溢出;因此它仅仅在一些 workload 中适用:

适用场景:when workloads have many updates but not insertions

 

因此,我们需要把 index 周期性搬到磁盘上。那么这个时候需要好好选择数据结构了,主要因为:

总之,我们需要基于某类 hash 数据结构作出针对磁盘存储介质的优化。

我们看看哪些数据结构符合我们的要求:

我们发现选取这样满足条件的数据结构并不是一件简单的事,需要考虑很多问题,做些 trade-off:

 

Improvements III: Alleviate Write Amplification

事实上目前除了前面提到的问题,还有一个问题是,KVS 中的 Log file 会不断增长,总有一天会超过磁盘容量。

除了分出多个 log files 以外,我们还想到之前设计实现 API 的时候,写和删除会出现多个重复、没用的 entries,因此我们的思路是:

Compaction with segmentation + Merge

 

Improvements IV: Large Range Query Supports

我们可以使用 B+ Tree(或者用 B Tree)来存放 Key & Log File,以起到 Range Query 的作用。

这种数据结构针对磁盘进行了优化:

所以我们需要用 B+ Tree 来索引日志吗?不需要!如果我们用的话,应该直接把日志内容放在叶结点上。因为如果叶结点还要索引的话又会引入 merge & compaction,平白无故地多出了一次 disk random access;

综上,B/B+ Tree 对于存储 KVS 方面:

 

在综合考虑上面的几个问题后:

我们打算使用 SSTable(String Sorted Table)来索引 Log File 的方法来做 KV Store System:

这么做有一些好处:

我们还可以作出一些优化:

最终还要考虑:

 

1.7 Consistency Model

1.7.1 Intro

假设我们正在建构一个 Chat App,消息结构如下:

我们想部署一个 KV Store System 来存储消息,想了一种方法:将 KVS 放在中心化的服务器中;

不过这么做有问题:

那么改进一下:在中心化 KVS server 的基础上,每个端侧设备有一个 KVS 做数据备份;

这样就有一个 Naïve solution:

还是有两个问题:

Fix 为 Naïve solution++(这就是 WeChat 应用使用的):

但是这里存在一些 consistency issue(unexpected behavior):

所以在网络上,对 KVS 的操作请求可能没法及时同步其他设备看到的信息,从而导致数据不一致性。因此我们需要建立一套 Consistency Model。

1.7.2 Definitions

Consistency Model 描述了一个数据存储系统在并发 / 分布式 / 出现错误(failure)时的应对行为

例如,in GFS, the consistency model is that all the chunk will eventually be the same(最终一致性);

而一个 Strong Consistency Model:可以精确的保证哪些事会发生、哪些事不会发生,不会出现 unexpected behavior;

这是一个 Weak Consistency Model 所没法达到的。所以下面我们先考虑 Strong Consistency Model;

注意,没有正不正确的说法总是在:是否容易实现、效率高低、数据一致性之间做 trade-off;

一个 Consistency Model 的光谱如下:

我们本章关注的是可线性化、严格的 Consistency。

那么直观上什么是 Strong Consistency Model 呢?

而一个正确的 strong consistency model,需要对一连串的并发请求,deduce 出一个正确的 serial behavior,就像数据库的并发调度。

从这个 deduce/调度策略,我们可以区分出 3 种比较强的 consistency model:

Strict Consistency

Strict Consistency:需要根据 global issuing order(global wall clock time)来决定这个 serial behavior!

优点:

缺点:

如下图:

Sequential Consistency

Sequential Consistency:只有每个终端处理的一系列事务需要有序,Per-process issuing/completion order;

也就是说,只需要一个物理结点上的连续发生的事务间保持顺序就行

优点:效率高;

主要缺点:Missing Update(Write Done but read old data)。像上面的例子,实际物理时间 GET(X)PUT(X) 结束后,但是没法获得修改完成后的信息,因为分布式系统使用的是 sequential consistency,上述真实物理时间排布会最终同步成下面的线序。

Linearizability

Linearizability:除了保证每个终端处理的一系列事务需要有序,还要保证 Done-to-Issuing Order;

也就是说,所有的 done event 和 issuing event 的时间需要确保顺序。如下图:

蓝色标注说明,在使用 Linearizability Consistency 的分布式系统中,串行化时会考虑到 所有操作的 done time 和 issue time 在真实物理时钟下的先后关系,确保最终 Put(X, 1)Get(Y) 操作之前完成。这种程度的同步会比 Sequential 的更严格,但比 strict consistency 轻松。

例如上面的例子我们就能发现,如果使用 Linearizability,那么 GET(X) 能获得 PUT(X) 的最新值,缓解了 Sequential Consistency 的 Missing Updates 的问题;

另外,可以反证法证明:

If each object’s op is linearizable, then overall system is linearizable.

综上,真实应用中总是倾向于 Linearizability;

1.7.3 Implementation of Linearizability

如何实现 Linearizability?

第一种方法:Primary-backup approach,即:对每个对象,Clients 都会向某个指定的结点发送读/写的请求;

对读:返回本地关于 primary(M0)数据的缓存;

对写,在保证先后写有序(in-order)的同时:

  1. M0 向所有 replicas 发送写数据指令;

  2. 完成后,M0 在本地执行写指令;

  3. Respond OK;

如下图:

注:“in-order” 是指,两次写操作的顺序不会因为先后到达 node 而受到影响

如何实现?有序性这个点不必要用 global wall time,使用 sequence number 给每个写操作计数就行(确保不会因为一些原因导致到达两个物理结点的写的信号不同)。

但有很严重的性能问题:

此外还有可靠性(可用性)问题:primary 具有脆弱性,一旦某块数据对应的 replicas 中,primary 挂了,系统应该如何应对?

我们先看 performance 问题。能不能读一个随机的 replica 中的数据(不总是读 primary machine),就像 GFS Read 一样?

不行,因为这存在一个 linearizable 的问题。如果尝试读一个不是 primary node 的结点,可能会读到系统的中间值,出现数据不一致性。例如下面的场景:

所以,为了防止 primary 成为性能瓶颈(fallback 到中心化 KVS 系统的性能),我们定义了 partition

将所有数据对象存储在 KVS 时分为不同的 partition,不同的 partition 可以有不同的 primary,这样可以分散掉读写的请求;

Different objects have different primaries ,如下图:

现在落到实处思考一下:

这种 Primary-backup model 实现 Linearizability 的方式对于一般 Mobile Chat Application 来说,显然是不合适的:

所以,对于分布式 Chat Application 而言,数据不一致性时时刻刻都可能存在,只不过是一致性的要求、同步的方式随着业务逻辑调整,更加灵活。因此我们需要了解一种对一致性要求更宽松的一致性模型,它在上面这种 Mobile Chat App 的情况下更为适用。

 

1.8 Eventual Consistency

最终一致性模型是一个相当宽松的数据一致性模型之一,其地位如下图所示:

1.8.1 Definitions

这种应用场景下,我们更关心性能、fault tolerance,而不是 consistency。这是一种 weak consistency model,只要:

这样我们可以如此定义实现最终一致性:

但是,对于 “如何实现最终一致性” 的相同问题,在不同的实际场景下的回答(解决方案)是不一样的。

考虑这种对 mobile chat app 的方案是否有问题?

是的,会出现 write-write conflict: 也就是说不同的 client 可能同时(concurrently)向最近的 server replica 更新同一个数据,而相互不知晓。

这样在数据传播后,最终会导致 data diverges,也就是出现同一时刻的两个数据版本(在一个 server 上先 X 再 Y,而另一个 server 上相反),永远没法达成最终一致性,如下图:

回想我们在 Linearizability 中的解决方案,它采取 “pessimistic conflict handling”(类似悲观锁),认为冲突是常见的,除非交给 primary node 的请求是 serialized,否则不应该生效(也就是说,先到 primary 的请求先生效)。

而在 eventual consistency 中采取 “optimistic conflict handling”(类似乐观锁),认为写冲突不常见(所以不设置 primary,每个结点自己都可以写),但希望修改最终会 propagate 到所有的设备中。

也就是说,选择 linearizability 和 eventual consistency,实质上是在 consistency 和 performance 间做 trade-off:

但是说到底,eventual consistency 还是需要 cope with anomalies:

1.8.2 Converging State: Update ID & Write Ahead Log

先看如何合并不同结点间的不同状态(解决 write-write conflict)。我们可以像 git 处理冲突一样,只有必要才解决冲突,否则 auto-merge。也就是在传播途中:

例如,对于两个 client 向两个 KVS 中同时用 PUT 的方法更新一个数据记录 chat[cid] = [...chat[cid], new_sid]

这样如果不加处理,可能会出现数据丢失(因为 PUT API 的语义,最终只有一方的修改存在);

于是解决方案是引入 “Append Update” 的语义:chat[cid].append(sid),这样保证数据不会丢失。

丢失问题解决了,那么合并顺序呢?我们需要两个 KVS 所在的 server 对全局的顺序达成一致共识。如果一开始都更改 KVS,那么最后肯定分不清了(没有排序依据了)。

所以我们应该使用 Write Ahead Log,为操作打上时间戳(先不考虑两个 server 的 timestamp 不一致的问题)。如果不巧时间戳也相同呢?那么可以使用 server node ID 来决定,也就是 update ID 组:<time T, node ID>,这样一定能区分两个操作的先后(因为 timestamp 在 server 上不同的问题,不保证绝对全局顺序)。

注:肯定不能反过来 <node ID, time T>(先判断 Node ID 再判断时间),这样 “某些 node 的更新操作总是在另一些 node 之前” 的现象会很明显。

但是,还有一个问题,为了尽可能确保状态成功合并,应该先 merge(achieve state convergence),再将写的数据 update 到 KVS(如果先 update 的话,不同的 server 原始数据就不同了,更没法合并了)。但是如果先等 merge 完成再 update,会导致更新延迟,用户无法在提交后立即看见,这是在 mobile app 中不可容忍的。

我们再想一种解决方案,是否可以先 update,再处理 merge?

答案是,可以。我们从单机中的数据库事务处理策略中获取灵感,可以采用回滚(rollback/undo)和重做(replay/redo)来管理不恰当的 update,让状态在后续过程中合并、再次修正并达到一致:

 

1.8.3 Causality Preserving: Lamport Clock & Vector Clock

好。现在假设我们通过上面的方法解决了 write-write conflict 的问题,那么 loss of causality 呢?

我们回想之前的做法,我们使用 <time T, node ID> 作为操作时间戳,但是两个 server 的 timestamp 是不同步的。

就像我们之前说的,这的确不影响 write-write conflict 的解决(sort & converging state),但是它会影响用户体验的因果性(loss of causality),例如:

假设在全局时钟(global wall time,应用并不知道)下,client 对 SRV-1 写了 X(其中 SRV-1 产生的时间戳为 10),而在此后的很短一段时间内,SRV-1 和 SRV-2 间传播了一次数据(sync),SRV2 没有冲突立即写入 KVS 并在 WAL 中记录;

但是在这之后又是很短的一段时间内,iPhone 向 SRV-2 删除了 X,这个时候由于 SRV-2 和 SRV-1 时间戳不同步,SRV-2 此时才生成时间戳 9,并写入 KVS;如果这个时候,SRV-2 又向 SRV-1 传播一次数据(sync),那么 SRV-1 看见时间戳 9,会以为 Delete 事件发生在写入 X 前,在 resolve conflict & replay 时会导致 delete 在 add 前操作,X 实际并没有在 SRV-1 上删除,因此 SRV-1 和 SRV-2 数据又一次不同步了。

这就说明没有 preserve causality(没有保证前后依赖的事务的因果顺序)。为了解决这个问题,我们就需要指定好事件间的因果。

或者说,我们需要 clock 真实地反映因果(Clock should reflect the causal order)。但是不同机器时钟是不同步的!怎么办?那么就不用不准确的绝对时钟了。

人们想出了 “逻辑时钟”(相当于事件驱动时钟),这就是著名的 Lamport clock;思路如下:

注意比较 tricky 的一点:同步后的两个有因果事件的 Lamport clock 值才有明显可比性。因为它展示的是 global order,例如:

  • E1 事件在机器上发生后,同步到另一台机器发生 E2,那这个时候保证 TL(E1)<TL(E2)

但是从 TL(E1)<TL(E2) 没法推出下列的情况:

  • E1,E2 是否经过一次数据传播(各机器 timestamp 并不准确和同步);

  • E1,E2 的 global wall time 的关系;

  • E1 事件导致机器向 E2 所在机器 sync 时,E1 仍然不一定在 E2 的 global wall time 前发生。因为并没有阐述 E1,E2 的因果关系;

现在,我们使用 <Logical Time, Node ID> 作为 Update ID,就既能解决 write-write conflict 的顺序问题,又能解决 loss of causality 的问题了!

但是很不幸,这样还有一个问题:这种测定方法过强了(对于已经沟通的两个 servers 来说,事件关系要么前要么后)。如果我需要做两件不相干的事,它们实际上可以(或者需要)在同一时间进行,也就是需要 incomparable timestamps!

在离散数学上说,就是我们不一定需要强的线序关系,而是需要偏序关系(partial order)!

这个时候,人们基于 Lamport clock,设计出了 vector clock。思路如下:

我们根据不相干事务数量 n,设定 n 个 Lamport clock [c1,c2,,cn],每个 Lamport clock ci 的管理方法同上。

这样我们就可以比较多种不相干事务间的先后关系,在某些应用场景中比较有效。

总而言之,大部分场景下 sort & converging state + Lamport clock 已经能实现 eventual consistency 并解决大部分问题了,少部分场景下还要借助 vector clock

1.8.4 Truncate WAL

最终还有一个问题:我们在 sort & converging state 中在本地写了 Write Ahead Log(WAL)。它不仅可以决定 merge order,还可以让我们在发生 write-write conflict 时进行 sync replay,但这并不高效。因此我们需要从两个方面下手:

  1. 尽量不要全部回到 empty state 并重做所有的 update(只做部分 replay);

  2. 减小 Log 文件大小;

先来看如何只做一部分的 update。

我们把那些不确定后面会不会有数据传播造成顺序重写(例如分布式网络中存在正在传播中的、在此之前的更改操作)的数据写操作称为 “tentative writes”(unstable writes),这些写操作可能在后续的传播同步的过程中出现 write-write conflict 需要 replay;

确定不会有其他传播对这个数据造成影响的写称为 “stable writes”;

于是整个 WAL 中由上下两部分构成:stable writes 和 tentative writes。借鉴了单机数据库的事务日志(redo/undo log),这里也相当于有个 checkpoint 的概念,不过是用 stable 和 tentative 来区分的。

在发现 write-write conflict 需要 replay 时,server 可以按照 WAL 回滚到 tentative 记录之前,不需要回滚 stable writes 的部分,初步提升了回滚效率。

 

那么我们如何判断一个写操作是 stable 的还是 tentative 的呢?

方法 1(de-centralized approach)是利用 Lamport clock:

举一个例子:对 UPDATE <10, A> 操作,如果分布式系统中所有的 nodes 都发现了不少于 10 的 UPDATE 操作,那么这个操作就是 stable write;

但是显而易见,这种方法有个缺陷,就是分布式集群中有任意一个 node 掉线了,那么就无法继续判断 stable write 了,仍然会有很多 tentative writes 需要在 conflicts 时回滚。

方法 2(centralized approach)是借助 primary node:

设定一个 server 作为 primary,它不作为专门写数据的结点,而是专门生产一个特殊信息的结点:

这样,只要 primary node 是存活的,分布式系统就能一直判断 stable writes;

 

还有一个问题,向 primary 申请 CSN 的 server 可能 Local Timestamp 并不是当前最小的(M1 机器事件 E1 触发 M2E2 事件,这样 TL(E1)<TL(E2),但如果 M2 先向 primary 申请 CSN),应该怎么办?

这比较简单:我们可以让一个机器向 primary 申请 CSN 时,带着前面所有依赖的(因事件)一起申请 CSN,这样一定能保证 CSN 也能反映 Causality;

考虑这个问题:两个没有因果关系的事件在传递时,由于没有互相通信,因此 TL 的大小关系是不一定的;这个时候如果 TL 大的(不一定是 global wall time 靠后的)先向 primary 申请 CSN,primary 同步到剩余结点时就会出现顺序调换的现象:

不过因为没有改变 causality(主要是 Srv1Srv2 无关),并且发生的次数不多,不影响总体用户体验,因此也是满足 eventual consistency 的。

 

1.8.5 Conclusion

总而言之,Eventual Consistency 的 Anomalies 到底是否重要(发生的频率和相关的后果),取决于应用的场景。

对于一个 Mobile Chat App 而言,可能 Eventual Consistency 就足够了,但对于银行系统甚至 Linearizability 可能都不足够。

 

1.9 Consistency Under Single-Machine Faults: All-or-Nothing

回忆 strong consistency model 的定义:

我们前面几节想要尽量通过保证第二条来实现较强的一致性。现在我们考虑第三条,如果系统出现 fails,分布式系统应该如何处理才不至于直接崩溃?

1.9.1 Shadow Copy for Atomicity

首先,我们要确保某一类操作要么不做,要么全部做完(all-or-nothing atomicity),例如对文件 / 数据库的写操作。

注:在数据库领域,all-or-nothing 就被称为 atomicity(原子性);

我们在学习单机数据库事务执行时讨论过这个原子性,那么在分布式系统中,如何实现 all-or-nothing 呢?

同样,仿照之前的做法,我们首先想到 shadow copy:

先把要修改的文件 / 数据库 copy 一份,操作时直接在上面修改;等到修改完成 + fsync 后,再将 copy rename 到使用的文件 / 数据库上。这样无论在操作的哪个步骤故障,总不会出现事情做到一半的情况(最多出现多出一个有问题的 shadow copy,这个在下次恢复时清除即可)。

在单机数据库的事务保证时,shadow copy 就是一种 No-STEAL/FORCE 性质的算法。

但是这种方法执行效率低,没法支持事务的大规模并发。为什么?我们后面讨论。

 

好,shadow copy 把 atomicity 的问题交给了 file system;

我们分析 file system 的 RENAME 究竟有几步、出错的可能性。rename(temp, final)最简单的情况是,rename 的两个文件在同一目录下:

  1. directory data block 中,final inode number 改为 temp 的 inode number;

  2. final inode 中的 refcnt 减 1;

  3. temp inode 中的 refcnt 加 1;

  4. 在 directory data block 中移除 temp 的 inode number 和名称映射;

  5. 原先 temp、现在 final 的 inode 的 refcnt 减 1;

如何保证上述步骤执行的没问题?我们在 OS file system 中先想到的是 “journal”(相当于逻辑日志);

但是问题是所有操作都要重复到磁盘上写两次(尤其是文件比较大的时候,这种方法不可取)。如果解决这个问题?

首先观察:

因此作出缓解问题的方案:在 journaling 中只保护重要的 metadata。这样真正的数据只会被写一次

但如果 data 也很重要呢?具体的 workout:

那么写 journal 本身的时候挂了呢?

好,那如果仅仅 journal 的大小仍然超过一个 sector 大小呢?那么我们就需要更 generalized 的方案了(我们后面讨论)。

 

上面讨论,如果使用 shadow copy 会导致并发性问题。如果两个 client 同时对一个资源进行修改,这个时候 shadow copy 需要确保第二个 client 不要基于原来的数据直接创建新的文件(因为可能出现覆盖的问题)。但是如果共享一个新文件,那么其中一个 client 写完后 fsync 可能会把另一个的 intermediate 脏数据刷盘,在断点时会造成数据不一致。

因此还要保证只要有一个 client 在写,就暂时不要写回,以免出现数据不一致的现象。

所以我们需要:

这在客观上就限制了 shadow copy 操作的并发性。

 

如何改进 shadow copy?我们借鉴单机数据库的事务处理方案:Logging;

1.9.2 Logging for Atomicity

我们引入 undo log 和 redo log、checkpoints 来实现单机上的 all-or-nothing atomicity;

STEAL + NO-FORCE:基于 redo/undo log 的恢复算法;

  • 在故障恢复时,需要:

    1. 反向扫描 undo log、正向扫描 redo log,出现没有闭合的 <T start> 则判定为未完成事务、反之是已完成事务;

    2. 重做阶段:在正向扫描 redo log 后按序将已完成(标注重做)的部分再次执行(重放历史)、未完成部分插入 <T abort>

    3. 撤销阶段:在反向扫描 undo log 后将未完成(标注回滚)的部分撤销执行,并插入 <T abort>

  • 补偿日志机制:为了防止在恢复过程中再次崩溃而不知晓恢复的进度,人们设立 “补偿日志”,每次执行 undo 日志记录后,数据库需要向日志中写入一条补偿日志记录(compensation log record,CLR),记录撤销的动作,也就是实现了 undo 日志的 redo,记录已经 undo 的日志,保证 undo 不被重复执行;

  • 检查点机制:数据库的日志会随着事务的执行不断变长,这会使恢复时间也相应地变长,需要压缩日志大小来降低恢复的时间。人们因此设计了一种检查点(checkpoint)机制,检查点定义了一个脏页刷盘的时刻,要求检查点之前的日志记录对应的缓冲区数据页面修改已经刷新到磁盘。这样:

    • 在检查点之前完成(commit/abort)的事务不需要处理;

    • 在检查点之后 commit/abort 的事务需要重做;

    • 所有未完成的事务(不含commit/abort)需要回滚;

这同时也回答了之前 file system 中 journal sector 大小不足的问题。这个 generalized 的方案就是用专门的 logging file 来存储多出来的数据。

注意,在 OS 中,我们将 undo-log 和 redo-log 结合起来,一个 entry 包含:

然后恢复过程也有变化:

为什么 redo 在 undo 之后?因为 undo 可能会擦除 redo 的修改,即一个未提交的事务把另一个提交的事务回滚了。

为什么 undo 要从 end to start?因为后续的事务可能会依赖于前序的事务;

checkpoints 标记的方法:

观察:

所以优化后的方法(basic approach):

  1. 等待所有事务全部完成;

  2. 刷新 page cache;

  3. 丢弃所有的 redo & undo log;

但这种方案是有问题的:如果一个事务进行的时间很长,怎么办?因为有些应用场景下一个事务可能需要执行 1~2 hours。

我们能接受在有正在执行事务的情况下,进行 checkpoint 标记吗?

现在改进一下 basic approach:

定义一个 action(粒度细于 transaction,例如转账事务中一个 action 就是 deposit),然后:

  1. 等待所有的 action 全部完成;

  2. 向 log 中写入 CKPT(checkpoint)记录;

    • Contains a list of all transaction in process and their logs;

  3. flush page cache;

  4. 丢弃所有除了 checkpoint 记录的其他记录;

这样含有 checkpoints 的恢复情况如下:

注意:

  • 每个 action 都会让日志落盘;

  • 在 Checkpoint 前,无论是否是脏页,肯定刷盘;

  • 在 Checkpoint 后、Crash 前的 action,因为是 STEAL 策略,因此可能已经刷盘(state modification),需要 undo 这部分以防万一。就像数据库的 STEAL + FORCE/NO-FORCE 策略;

1.9.3 Conclusion

对比 redo(only)-logging 和 redo-undo logging,redo-only 的优势:

实际:除了那些在内存中状态很大的事务,Redo-Only Logging 都是首选(NO-STEAL + NO-FORCE)

我们发现,高并发数据库其实会存在很多 “内存中状态很大” 的事务,它如果使用 redo-only logging,那么内存缓冲区小的弊端就会显现,因此像 MySQL 数据库就用 redo-undo logging。

而操作系统和其他需要保证 all-or-nothing atomicity 的应用只需要 redo-only logging 就足够了。

为什么很少用 undo(only)-logging(STEAL + FORCE)?

因为它既需要 STEAL(提交前的各个时刻都可落盘),又需要 FORCE(提交前一定需要落盘),因此 disk I/O 更大,比其他所有情况的时延更大(即便 undo-logging 可能恢复更快一点);

 

1.10 Consistency for Isolation: Before-or-after Atomicity

1.10.1 Definitions & 2PL

我们再讨论另一种情况。现在我们实现了 all-or-nothing atomicity,也有 linearizability 保证数据一致性,那么一定就没问题了吗?

非也,因为还会存在并发访问共享数据的问题。

分布式系统下,如果出现多线程共享数据时,可能出现 race condition。这也许可以用一致性模型解决?

我们回顾一下前面讨论的好实现的 Linearizability 一致性模型。这样行吗?不一定行,因为:

正常情况下,Linearizability 这么做是没问题的,但是如果 X,Y 需要是原子变化的呢(一个对象的不同属性,共享资源)?我们不希望其他 clients 读到中间状态,这就不能避免 race condition 了。

所以现在我们还需要再引入一种 consistency model,这种 model 可以有效避免多线程程序下出现共享资源的 race condition 的问题,也就是实现了 before-or-after atomicity(在数据库领域被称为 Isolation,在 OS 领域还被称为 Serializability Model)。

我们详细定义一下 before-or-after atomicity:

Concurrent transactions have the before-or-after property if their effect from the point of view of their invokers is as if the transactions occurred either completely before or completely after one another

用数据库领域的话说,就是并发执行的两个事务能够等价于事务的串行执行(可串行化)。

 

回忆一下,在数据库领域我们如何实现事务的 isolation?

我们采用的方法不就是 调度策略 + 锁吗?这里也是!

如果是分布式数据库事务呢?Two-Phase Commit,这里也是!

和数据库处理事务的 isolation 一样,我们要实现 before-or-after atomicity 就可以用锁来完成。解决办法:

对比一下 Global Lock 和 Fine-Grained Lock:

 

Serializability Model 中有几类并发顺序,恰好和数据库领域的 “调度” 的概念对应:

 

我们定义:交换事务相邻两操作(action)的顺序,如果不改变最终结果相同,则称这是一次等价交换(两个调度是等价的)。并且,如果调度 S 仅通过等价交换,就能变成串行调度,则称 S冲突可串行化调度

也就是存在一个这样的调度就行!

根据交换等价以及 冲突可串行化调度的定义,我们直接有结论(通过等价类理解):若冲突可串行化调度 S 中调度的分别属于两事务 Ti,Tj 的某两个操作 Om,On 间存在冲突(不可等价交换),则在 S 等价的串行调度 S 中,Ti,Tj 中的 Om,On 一定和 STi,Tj 中的 Om,On 的顺序相同(保序性)。

因为这个保序性,我们可以借助拓扑排序描述等价类间互不可等价交换的关系,称 “优先级图”。若调度 Si 与调度 Sj 是进行一次不等价交换后的两类调度,且 SiSj 中事务 Ti,Tj 交换顺序的冲突操作对 OmTi,OnTj 中,实际执行顺序 OmOn 之前,则构建一条 (Ti,Tj) 的有向边。最终会在两事务所有操作顺序的等价类间形成一张图,称 “Conflict Graph”;

 

我们再定义:一个调度是视图可串行化的,当且仅当最终写状态,以及中间的读状态和对应的串行调度是相同的。

也就是说,终态可串行化只关心事务调度的最终状态与串行调度一致(也是我们的目标),视图串行化除了关注最终状态,还关注中间读的状态;冲突可串行化不仅关注最终状态,还关注了数据依赖。因此严格性依次上升,但判断难度逐级下降。

 

如何证明 2PL 协议是 Conflict Serializability 的?我们假设一个前提,所有共享资源冲突都可以用锁来管理

反证:假设不是这样的,因此 Conflict Graph G 中存在一个环 T1T2TkT1

TiTi+1 代表的冲突操作为 xi,因此:

紧接着:

发现前两步骤违背了 2PL 的定义,因此假设不成立。

 

1.10.2 Case: Salary System

那么如果没有 “所有共享资源冲突都可以用锁来管理” 的前提,2PL 还能保证 conflict serializability 吗?

我们假设在一个工资数据库中,每一个雇员的记录都有一把 Fine-Grained Lock。

如果 T1 是全表扫描并更新工资;T2 是插入新雇员,那么会发现问题:

如果 T2T1 间执行,突然发现多出了一些记录。这就是 幻读(Phantom Read)。核心原因就是全表扫描和记录的增删间没法用锁来管理(仅仅锁住 action 访问的记录是不行的),这种冲突没法用 2PL 来控制。

解决方案有:

但一般不会处理它,因为代价很高。

 

1.10.3 Deadlock

对于 2PL (悲观的)锁,极有可能因为拿锁顺序的问题而持续地相互等待。

解决方案是:

因此我们需要反思,悲观的 2PL 协议可能引发死锁,而且不得不付出代价解决。

那么我们能不能使用乐观的方式来实现 Before-and-after Atomicity?

 

1.10.4 OCC: Optimistic Concurrent Control

乐观的并发控制机制主要有 3 个流程:

  1. Concurrent local processing:读、写都在本地 buffer 进行并记录在 read/write set 中;

  2. Validation serializability in critical section

    检测 serializability 是否能保证(也就是 read set 中是否被修改过);

  3. Commit the results in critical section or abort:

    • 如果 validation 失败了,abort 这个事务;

    • 如果 validation 成功了,刷入 write set 指定的 buffer 并提交事务;

也就是说:

当事务开始时:tx.begin(),初始化 read set 和 write set;

当事务提交时:tx.commit(),同时包含 phase 2 和 3;

注意 phase 2 和 3 需要在 critical section 中(事务互斥),有两个原因:

  1. 因为可能出现 ABA problem

    如果 T1A=A0,然后 T2 更改 A=BT3 又改回来 A=A0,这个时候 T1 只检查 A 可能无法发现修改。

    解决方案是在数据基础上加上版本号(64 bytes)。我们在 read set / write set 中就可以用这种方法。

  2. 并发事务同时执行 phase 2 时,都对 A 写,但都只看了 read set 发现没改,同时提交会导致其中一个写被覆盖;

 

那么如何实现 phase 2 和 3 的 critical section?

 

OCC 的优势:

OCC (in the optimal case, i.e., no abort):

2PL:

综上:Locking is costly especially compared to reads

 

OCC 的劣势:显然,在 serializable 情况下,即便没有出现 conflict cycle 也可能判断需要 aborts,如下:

注:这种情况下可以串行化为 T1 先执行,T2 后执行;

这种情况就是 False Aborts;尤其是在很多读的情况下(read set 很大);

更严重的情况时,当大量事务并发时,两个 aborts 的事务在重试时又再次 aborts,结果可能造成 live locks

这样 2PL 和 OCC 在不同并发数量情况下的吞吐量关系如下:

 

1.10.5 Lock Preliminary:锁如何实现

底层主要借助硬件实现。只是软件上的话不是很充分。互斥锁的硬件原语如下:

最终编译器遇到上述指令后使用 Lock Prefix 确保在内存地址中原子执行;

Lock prefix to ensure an instruction is atomically executed on a memory address;

也就是提供了 CAS 的语义,软件可以在 CAS 的基础上实现对应的原子语义。

但是这 CAS 会极大地影响性能(比 L1 cache 慢 10 倍以上);

 

1.10.5 OCC & Hardware Transaction Memory (HTM)

硬件厂商,例如 Intel、ARM 会提供对内存的 Before-or-After Atomicity 的读写,这样就不需要软件层面的 2PL 和 OCC,进而提升并发性能。

Intel 推出 Restricted Transaction Memory (RTM),ARM 推出 Transactional Memory Extension (TME)。

以 Intel 的 RTM 为例,它提供了两个新的汇编指令:xbeginxend,相当于事务的开始和结束。

在 OS 及以上层次,可以这么使用:

优点:

缺点:不保证成功(可能多次 abort),因此处理 abort case 时较为麻烦;

 

为什么 RTM 不保证成功?

因为 RTM 底层是采用 OCC 的思想实现的:

问题是 CPU cache 比较小(取决于硬件),一旦用完了就会导致无条件 abort

一般情况下 RTM 使用 L1 Cache 跟踪 writes,L2/L3 Cache 跟踪 reads;

为什么 L2/L3 Cache 不会同时跟踪读写?

RTM 除了受到 CPU Cache 的限制,还有 Limited Execution Time:事务的执行时间越长,transactions abort 的可能性更大:

 

如何处理不成功的情况?

就是因为 RTM 是基于 OCC 的硬件实现,因此会因为 false validations 或者硬件上的限制(cache 不够大)而出现频繁的 Aborts。

所以在使用 RTM 时,在尝试一定次数(counter)后就需要切换到 fallback path 上(pessimistic sync),例如:

 

简单总结一下:

 

1.10.6 MVCC

我们知道,无论是 2PL 还是 OCC,在大量的只读事务情况下性能很差。OCC 是因为 read validation fails,2PL 是因为读的时候锁住了其他线程。

当问题是大多数实际应用场景(例如淘宝页面)都是以读为主的,因此不得不针对读的性能优化。

我们先从 OCC 下手。OCC 多数的 False Aborts 主要是因为一下两种情况:

能否让读的情况不发生 false aborts(也就是能不能不 validate read)?

于是我们引入 “多版本” 的概念,每个事务操作的数据都有多个版本(multiple-versions),于是一个事务操作的一组数据就被称为 snapshot:

带有版本的数据的结构如下:

因此减少 false aborts 的目标就是:尽量避免在读 snapshot 时 race condition(如何让 read 总是读到符合顺序的 snapshot 上的数据)。

如何确定版本?可以使用时间戳来表示。我们需要它反映事务的串行执行顺序:

最简单的解决方案的就是使用 global counter:

但有两个问题:

 

现在我们用 MV 改进一下 OCC(incomplete):

  1. 获取 start time;

  2. Phase 1: Concurrent local processing

    • Reads data belongs to the snapshot closest to the start time(总是从最接近当前 start time 但 commit time 不晚于这个时间的 snapshot 中读);

    • Buffers writes into a write set;

  3. 获取 commit time;

  4. Phase 2: Commit the results in critical section;

    • Commits: installs the write set with the commit time;

这样我们就不需要 validation 了!

但是仍然需要锁来锁住从获取 commit time 到提交完成的区间,因为可能出现 partial updated snapshot;

我们看一个读过程(一个 start,另一个准备 commit):

T1 先获取到 start time 的情况下,确实读是正确的,不需要 read validations。但是出现了 partial snapshot(也就是中间状态对外可见)的问题。试想如果 T1T2 的顺序交换了呢?

这个时候就会出现没法跟踪到另一个串行顺序在 T1 之前的 T2 的更新,从而导致 T1 只能读到 T2 修改一半的内容;

综上,我们需要确保 T2 的一个 snapshot 完整的更改过程(一般短于这个 transaction)是 atomic 的,也就是对 snapshot 的更改过程上细粒度锁:

这样在完全修改 snapshot 的状态前,未修改的部分不会被读到:

考虑一个问题,能否交换 commit 阶段的 “获取 commit time” 和 “上锁” 的顺序?不行:

相当于 T1 最终还是读到了 partial snapshot!所以不行。

 

最终,我们可以使用改进的 incomplete MVCC 完成 read 过程的 isolation!

但还是要 validate writes,因为两个并发写冲突通常不可串行化,难以避免。

validate writes 的方式就是在 commit 阶段,其他事务针对当前事务已经写的 write set 中的数据,commit time 是否晚于当前事务的 start time(是否有更新的版本),如果有就 abort;

 

总结一下:

  1. 事务开始阶段,先从 global time 获得事务的 start time;

  2. 读数据 X 时检查最大的 X 的版本,并且早于当前事务的 start time(保证串行顺序):也就是到数据库读 snapshot;

  3. 写数据 X 时向 buffer 中写,并且加入 write set;

  4. 最后事务提交时,先从 global time 获得事务的 commit time,然后逐一检查 write sets 中每一个数据是否有比当前事务 start time 更晚的 commit time。如果有则 abort,如果无,则更新所有 write set 中的数据,并将数据对应的 commit time 更新为当前事务的 commit time;

 

现在这个 incomplete MVCC 就没问题了吗?大部分情况都符合 Before-or-After Atomicity 了,但现在还有一个问题需要解决:Write Skew

可能会出现这种情况:

T1T2 在先后读取共享资源后,分别修改对方之前读的资源。这种情况实际上是应该 abort 的,但在 incomplete MVCC 下都能正常提交事务。

 

主要是只 validate writes 而没有 validate reads 造成了这个问题。

解决这个问题很简单:

这个 incomplete MVCC 就被称为 Snapshot Isolation(SI)

其实,除了在 snapshot isolation 中首先实现了 multiple-versions,现在的 2PL/OCC 的变种也用到了 multiple-versions(以后讨论)。

 

1.10.7 Conclusion

总结一下事务中的 consistency。

我们在 OS 系统中讨论 “事务”,其实是一种管理数据行为的抽象。这个数据可以是 KVStore entries、文件系统的 meta-data、处理器 meta-data 等等;

我们要让事务保证 ACID 的性质:

之所以需要这些性质,就是想要实现 failure atomicity 并避免 race condition,最终简化管理数据的抽象。

我们通过 Logging 和 Recovery 策略,实现了 Atomicity 和 Durability;通过 Concurrent Control Methods(2PL/OCC/SI)实现了 Isolation;最终数据库约束系统和编程人员共同确保 Consistency(这里不讨论)。

 

1.11 Replications & Multi-site Atomicity

如何让 1000 台机器像 1 台机器一样工作?

1.11.1 2-PC

回忆一下,OCC 和 2PL 在大量读的情况下性能不佳,因此我们引入了 MVCC,利用 Snapshot Isolation 实现一个更高效的读,规避了 read validation;

假设一台机器的物理资源已经不足,现在我们希望分布式存储,将同类数据存在不同机器上;

这个时候显然单机的数据库的通过 logging 是不能实现整个系统的 all-or-nothing atomicity 的!

这是因为事务分布在不同物理节点上,于是,事务会被切分为两种:

问题转化为,如何使 high-layer 事务协调 low-layer 事务,保证所有 low-layer 事务的 all-or-nothing atomicity

我们需要知道,在分布式场景下,仅仅有 commit 和 abort 的状态是不行的,还需要 tentative commit 状态,表示已经准备好 commit,需要等到 coordinator 判断让其他所有 servers 都写入时再进行

于是人们基于这个思想,引入一种新的协议:Two-Phase Commit

Phase 1: Preparation / Voting

Phase 2: Commitment

 

看起来很美好,对吧?我们讨论一个 conner case 来考虑系统的 fault tolerance:假设分布式系统中任一台机器挂了 / 任一次请求通信挂了(不可靠)怎么办?

我们回忆单机的 logging 会写 redo-undo log,如果事务应该提交,但是准备 commit 时挂掉,那么单机会在重启时重做,而如果全局决定了 abort 那么数据就会不一致!因此我们需要更改记录 log 的方式。

解决方案是:

总而言之,我们有原则:

举个例子,一个 high-layer 事务有两个 low-layer 事务:

现在我们在新的解决方案下讨论所有故障的情况:

Remaining challenge:我们最终发现,这个解决方案看起来实现了 all-or-nothing atomicity,但是可用性不强,尤其是 coordinator 挂了后,事务就需要一直等待它重启了。

此外,我们还需要知道:

最终,Two-Phase Commit 需要在 2PL 和 OCC 上应用:

 

1.11.2 Replication

那么 2PC 实现了 CAP 的哪些特性?答案是只保证了 Consistency。因为:

因此我们需要 replication 来确保高可用性。

实现 high availability 的优点:

For performance:

  • Higher throughput: replicas can serve concurrently;

  • Lower latency: cache is also a form of replication;

For fault tolerance: Maintain availability even if some replicas fail;

这里我们主要讨论如何备份多份数据,对 coordinator 的备份是类似的。

有两种备份方法:

 

有些情况下应该采取 悲观备份方法,例如 2PC 的 coordinator(需要 commit 决定是统一的),也就是要求 “Single-copy Consistency”(多台机器和一台机器是一样的),需要从外界看起来只有一份数据。强一致性的代价就是牺牲一部分性能和可用性。

我们可以用 Replicated State Machine(RSM)这个模型来做 pessimistic replication(尽管 RSM 中会有很多 replicas):

  1. (初状态相同)Start with the same initial state on each server;

  2. (完全相同的输入)Provide each replica with the same input operations, in the same order;

  3. (保证操作不随机)Ensure all operations are deterministic

    E.g., no randomness(把随机数生成也作为统一输入), no reading of current time, etc.

理想情况下 RSM 能实现 single-copy consistency;

问题是,clients 的请求到达不同 servers 的顺序可能不一致(这是物理情况决定的)。由于我们现在要实现 pessimistic replication,因此不能之后再 re-order,而是需要立即 re-order。我们后面讨论这个问题。

 

现在来看 RSM 模型的实现。一般来说类似 GFS 的 primary backup(主从备份)就能解决问题:

也就是:使用 view server(可以是独立物理节点/独立进程),来确保只有一个 server(primary)从 clients 接收请求。并且 primary 应该:

 

这个 primary-backup 为什么这么构建?

首先,我们需要 primary、backup 来做 replication;谁做 primary、谁做 backup 可以事先指定。

如果 backup 挂了,primary 来检查并建立新的 backup;

如果 primary 挂了,则依赖于 coordinator(知晓 primary/backup 的状态),用它来选举新的 primary。

这种解决方案还是有问题:

如果 coordinator 挂了?那么我们建立多个 coordinator 来确保容错性。

如果两个 coordinator 间 “split-brain”(network partition)了,怎么办(Partition Tolerance)?这可能会选举出多个 primary,在 network 恢复后出现问题!

因此我们引入唯一一个 view server,将 coordinator 中判断、任命 primary 的工作分离出来,与所有 primary 和 backup 维持心跳,并告知 coordinator 谁是 primary。

View Server 的具体任务是:

 

此外,primary 和 backup 需要遵循下列规则:

 

考虑一个 corner case:

如果此时 View Server 因为无法和 S1 建立心跳连接(但 S1 是存活的,和 S2 和 coordinator 都能联系),而决定选 S2 为 primary 时,它会先更新一轮 view 并且向 S2 发送身份变更信息:

此时无论 coordinator 联系谁都会错误:

这个中间状态称为 “repair time”,我们一般允许系统短时间地处于该状态。

 

有了上述规则,即便 coordinator 也有 replicas,整个系统也能有 partition tolerance 了。

现在还有一个重要问题:如果唯一的 view server 挂了怎么办?我们知道,为了保证 partition tolerance,我们不应该再对 view server 继续 replication 了。

不过幸运的是,view server 的任务足够简单、本身足够轻量,我们可以使用分布式协调服务来确保多个 view server 的对外完全一致性。

这个分布式协调服务像 ZooKeeper、Raft 都可以胜任,它们的思想都起源于 Paxos(经典,但晦涩),下面介绍 Paxos 的基本原理。

 

1.11.3 Single-Decree Paxos: Distributed Consensus Mechanism

Paxos 这样的分布式协调机制需要解决的问题就是,如何在系统存在大量并发读写、延迟、network partition、每个 server 随时可能宕机的情况下确保数据的写操作能够以正确和唯一的顺序最终传达给所有分布式 servers 并持久化。

而 single-decree Paxos 则是用来解决上述问题的子问题:如何确保系统不受上述因素影响 agree on one value。

也就是,实现 RSM 的某个状态在所有 replications 上都能保证最终顺序一致,不论现在的情况如何;

或者说,让 RSM 的单个 Log Entry 维持一致;

Single-decree Paxos 机制如下:

在 Paxos 这个独立的 “岛” 中,有 3 类角色:Proposers(提议者)、Acceptors(选举者)、Learner(记录者)。

这 3 类角色都是逻辑角色,实际上可以一个物理节点分饰两角。

其中:

注意,提出的 proposal 有一个 ID 和对应的 Value,Value 是什么不重要,只要知道一个 proposal 中有一个 ID 和对应的 proposal value 能携带信息就行。

此外,在 paxos 协议中,存在决议轮数(round),每一轮有唯一的 ID 标识。每轮之间不需要维持同步关系。可以使用超时等待的机制。

并且当一个位于 j 轮或更早 server(不论是谁)因为延迟原因接收到了来自 j+1 轮的信息,那么 abort 当前的 j 轮的信息,转到 j+1 轮(后面详细阐述);

而每一轮可以分为不同的 phases。下面详细阐述不同 phases 的内容:

Phase 1A: Prepare

Leader 视角:

Proposers 中的 leader(optional,也可以不选举,每个 proposer 都行)创建一个 proposal N 向 acceptors 提议;

并且要求 N 比当前 proposer 看见的(包括不是自己提出的)proposal ID Nh 更大。

注意:这里的 proposal 不含有 proposal value,只是为了确认是否能获得多数同意。

具体为什么,后面介绍。

Phase 1B: Prepare

Acceptors 视角:

接收来自 leader 的 proposal(s),并且判断:

如果这个 proposal ID N 比之前见过的所有 proposal ID 都大,那么 promise(同意),并且:

  1. 回复之前同意过的 proposal ID 最大的 proposal ID Na(但是对应的 proposal value Va 留空到 Phase 2B 记录);

  2. 保证从这之后拒绝所有 ID 小于 N 的 proposals;

  3. 更新 “见过最大的 proposal” 的 ID Nh(见过和同意过不一样,参见 Phase 2B 的解释);

否则直接忽略(不予回复);

Phase 2A: Accept

Leader 视角:

如果一段时间后没有收集到 Acceptors 的多数同意(超时或者发现 proposal N+1 被同意了),则 abort 掉这个 proposal;如果 clients 还有请求,这个 proposer 就回到 phase 1A 继续;

反之,如果 Leader 收到了 acceptors 的“多数同意”(定义参见上文,下面不再赘述),则说明自己的 proposal ID 极有可能(为什么是可能?因为可能存在 network partition)是目前最新的,因此判断:

然后,在上述判断确定好 proposal value 后,向 acceptors 声明:proposal N、proposal value V 获得多数同意。这一步的作用是:

Phase 2B: Accept

Acceptors 视角:

如果 leader 发来的 “声明获得多数同意” 的信息 Accept(N,V)N 仍然是见过的最大 ID,那么:

  1. 补充记录 “同意过最大的 Proposal ID” 对应的 proposal value Va

  2. 向 Proposers 广播 proposal ID N 已经达成一致的信息;

    发给 proposers 是为了:

    • 让处于 phase 2A 等待的未拿到多数同意的 proposers abort 更旧的 proposal,回到 phase 1A;

    • 让所有 proposers 更新 “看到的最大的 proposal ID” Nh

否则(期间有 proposal ID 更大的 leader 声明 “获得多数同意”)会忽略这个声明。这种情况可能是 leader 遭遇了延迟,或者其他原因。

这也是为什么 acceptors 存放的 “同意过的最大的 Proposal IDNa 和 “见过的最大的 Proposal IDNh 不一定一样。

Phase 3: Learn

如果 leader 获得了多数 acceptors 的 “达成一致” 的响应,则说明真正确定了 Proposal Value(因为确保多数 acceptors 已经在 Phase 2B 记录了这个 value 作为 Va,后面的 proposal 只能 “继承遗志”),就向 Learner 广播决定消息(decide);

否则 leader 重新回到 Phase 1A,带着这个值重新提出一次 proposal。

最终获得消息的 Learner 持久化决定(decide)的 proposal value,并且向 clients 回复。

最终,无论 clients 输入什么请求,所有 learners 总是会保持这个 proposal value 不变

 

现在我们最终考虑几个问题:

思考 1:如果 “获得多数同意” 的声明 Accept(N,V) 被忽略,会对 paxos 的正确性有影响吗?

答:不会有影响。此时多数 acceptors 已经记录了 Na,其中 Va 要么是前些轮的 proposal value,要么是 NULL;如果是前些轮的 proposal value,则由于 Phase 2A 的性质,这个获得多数同意的 V 一定与 Va 相同,因此完全等价;

如果 V 是 NULL,那么就和 “leader 刚准备 propose 就挂了” 的情况一样,也不会对正确性有影响。

思考 2:可能出现 NaVa 不匹配的情况吗(例如 Na 是一轮 proposal ID,但 Va 是和这一轮不同的 proposal value,不包括 NULL)?

答:不会的。因为如果 Phase 1B 同意并记录了 Na,在到 Phase 2B 前其他更新的 proposal 被同意了,那么说明此时更新的 proposal 也已经通过 Phase 1B 刷掉了旧的 Na,此时 Va 只有可能是 NULL 或者与本轮相同的 proposal value(由 Phase 2A 决定);

思考 3:如果多数 acceptors 虽然达成一致,但因为某些原因未能向 proposers 发送达成一致的信息,会不会造成出现多个 “达成一致” 的 Proposal Value V

答:不会的。不用担心这个多数达成一致的 V 被丢失,因为多数达成一致的 V 的 acceptors 一定已经在 Phase 1B 中记录了 “同意过的最大 Proposal ID” Na 以及 Phase 2B 中记录对应的 Va

所以,之后即便有 proposal ID 更大的 leader 声明获得了多数同意,也会 “继承它的遗志”(原因参见 Phase 2A,“总是取最大 proposal ID 的非空 proposal value”),选这个最早达成一致并广播的 V 作为它的 proposal value。

因此无论怎样,最终总会保持最先多数达成一致的 proposal value

思考 4:最终达成一致的 Proposal Value 最早在什么时候被确定的?

答:

思考 5:如果 Phase 1B 中 acceptors 在发送完成同意后立即挂掉,是否应该保存一些信息?

答:应该利用日志等手段持久化 Nh;防止重启后状态丢失对 paxos 正确性的影响(例如其他 leader 并不是最大的 proposal ID 却被同意);

思考 6:如果 Phase 2B 中 acceptors 在接收到 “声明获得多数同意” 的消息后挂掉,是否应该保存一些信息?

答:应该持久化 Nh,Na,Va,防止重启后状态丢失对 paxos 确定唯一决定值造成影响;

思考 7:如果 leader 在发出 “声明获得多数同意” 的消息后挂掉,应该怎么办?

答:应该持久化 Mh(比 Nh 更大的、决定作为 proposal ID 的值),并且重启后重新 propose;

但是 Mh 也不必要(不会影响正确性),只是如果没有的话 proposers 需要一轮一轮尝试,会增大 conflicts 和 restart 的次数,降低性能;

思考 8:从第一次 propose,到 learners 最终得到决定的值,最少需要几个 Phase 的通信?最多呢?

答:不考虑其他干扰因素,完整需要 2 个 phase(两个来回),最坏情况一直没法选出决定的值

思考 9:如果没有 Learner,能否知道 paxos 最终决定的值?

答:可以,但是过程会更复杂,性能会下降。proposers 需要 2 次请求并对比结果才能知道最终决定的值;

 

1.11.4 Multi-Paxos

Basic Approach

我们注意到,single-decree Paxos 只是解决统一一个值的子问题(确定 RSM 中的一个状态在所有 replications 中统一,也就是让 RSM 单个 Log Entry 统一),如果希望统一一系列值(RSM 继续转移状态),就没法办到了(因为 single-decree Paxos 总会保持最先多数达成一致的 proposal value)。

永远只投出单个值的 single-decree paxos 在实际场景中几乎不可用。

例如我们没法用 single-decree paxos 来实现 View Server(对于不同 view 的变化没法应对)。

最简单的解决方案是,我们可以为每个 Entry 创建一个独立的 single-decree paxos 实例,让它们各自沉淀出唯一确定的值。

例如对于一个正常情况,我要向 RSM Log 中插入一个 Entry yyy(状态转移),正常情况初状态如下:

然后接收到 client 请求的 S1 会选择第 3 个 Single-Decree Paxos 实例并进行 propose,经过最少 2 轮的协议,Learner 成功得到决定的值,填写到 Log 中,如下:

如果之前是有问题的呢?例如假设有一个 server S1 因为 network partition / 延迟等等原因迟迟不知道上一轮决定的 value(部分 learner 不知道),如下:

现在 client 想向 S1 发送插入 yyy 的请求,这个时候 S1 会选中(旧的)第 3 个 Single-Decree Paxos 实例,然后进行 propose,我们知道,yyy 肯定无法获得多数同意(因为在旧的实例中,多数 acceptors 已经对 zzz 达成一致了),因此最多只会补上第 3 轮的 zzz

还需要重新再请求一轮才能真正写入 yyy

依此类推,如果 client 请求的是比最新决议晚 x 轮的 server,那么至少需要重复 x+1 次请求才能达成插入的目标。

Improvements

我们知道这样是不高效的,原因有几点:

  1. 每轮 Single-Decree Paxos 可能有多个 proposers 并发 propose,会频繁出现 conflicts 和 restart 的情况(例如它们都没能拿到多数同意);

  2. 不同 Server 间的 Learners 情况不一,可能造成需要多轮请求才能写入的情况;

我们可以作出如下改进(以下改进不必要,但可以提升 multi-paxos 的性能):

不过除了性能问题,multi-paxos 还可能出现日志空洞的问题:

有些 server 有、有些没有决定值的情况我们前面就讨论过,可能是因为延迟或者 partition 或者宕机等等原因。一般需要再过一段时间才能最终一致;

而对一个 paxos 实例所有 server 都没有决定,则可能是一个 server 在提出 propose 时立即挂了,或者决定前立即挂了,则需要 client 单独重新请求(但不影响分布式协调的特性,RSM 可以最终统一)。

总的来说,paxos 从数学理论层面描述了分布式协调系统的可行性,但是对于工程上如何实现 RSM 而言并不友好。因此人们提出了 Raft 算法来在工程实现层面支持分布式协调机制。

 

1.11.5 Raft

Raft 作为一个与 Paxos 等价的分布式协调系统,它在工程实现方面非常友好。

其中,Raft 与 Paxos 最重要的一点不同就是,它们的目标不同。Paxos 确保在分布式环境中选取出唯一稳定、一致的 proposed value,需要 multi-paxos 多实例实现一系列稳定的值(也就是日志),并且它不保证日志的 entries 全部填满;

Raft 目标就是在分布式系统下,为 Replicated State Machine 维护 replicated log,确保这些 log 的顺序一致(前缀内容相同),最终一定能相同。

Raft 的实现方法从较高的层次上看,有如下几点:

  1. Leader Election:仍然坚持 One-Write Principle,选举其中一个 server 作为 leader;当 leader 挂了后重选;

  2. Log Replication: Leader 负责接受来自 Clients 的请求,将它们按照某种顺序 append 到日志中,并且通过 overwrite inconsistencies 来 replicates 到其他所有 servers 中;

  3. Safety:为了保证 logs 的 consistent 特性,只有 log 相对更新的 servers 才能成为 leader;

对于 Leader Election,Raft 设计了和 Paxos 类似的 “身份系统”:

在任意时刻,Raft 中的每个 server 都是以下身份之一:

上图的过程解释两点:

其中 term(任期)代表的选举周期符合:

因此每个 leader 与唯一一个 term ID 相关联。这样做可以避免过时的信息传递。

此外,只有当 leader 与 followers 的 heart beat 断开后才发起一次选举。heart beat 的 timeout 太大,那么 leader 挂了后很久才能发现,降低了系统可用性;反之太小会造成频繁选举降低性能。

一般情况下 timeout 在 100~500 ms 内,需要根据网络情况微调。

选举过程:

  1. 将自身 follower 身份切换为 candidate;

  2. 增加当前 term ID;

  3. 为自己投票,并且向其他所有 servers 发送请求投票的 RPC,重试直到下列之一满足:

    • 收到绝大多数票,则自身变为 leader;

    • 收到当前更大 term 的 leader 的信息,则自身变为 follower;

    • election timeout/draw,重新开启一轮新的 term 进行选举;

值得注意的是,follower 收到投票的请求后,只有在请求的 term ID 比之前投过的都大,大于自身的 term ID,才能进行投票。确保 follower 在一个 term 中只投一次

还有一个有趣的点,选举开始通常意味着 leader 挂了,因此如果所有 follower 同时开始成为 candidate,那么同时选举为 leader 会造成性能问题(大量冲突)。

因此我们可以对不同的 follower 使用不同的 timeout,保证它们大概率不会同时成为 candidate(有点像随机 TTL 防止缓存雪崩);

 

日志内容:

注意到我们对每个 entry 保留了 term 信息,主要用作 overwrite inconsistencies(后面介绍)。

并且 log entries 分为两类:committed 和 uncommitted,前者的 entries 不会再出现 overwrite,可以放心写入 RSM Log。

 

现在考虑出现 crash(network partition)的情况,说明为什么会出现 inconsistencies,以及何时 commit、何时 overwrite consistencies。下面的情况就会造成 S1xxx 指令并不是 committed 的。

因此我们说,inconsistencies 是不可避免的,只不过 raft 降低了后续恢复的成本(维护 prefix 的 consistency)。

下面我们阐述 Raft 在 consistency check 的过程中如何进行 overwrite inconsistencies、确认 committed entries 以及 entries replication;

我们首先以 Leader 的 Log 为 “真理”,follower 和 leader 不同时,认为 follower 是无效的。也就是说:

在 append entry 时,检查当前 prefix 与 followers 是否完全相同:

 

这种方法非常 “粗暴”,也会导致一个 entry 即便被多数 followers 写,也不见得就是 committed 的状态。如下:

但是,我们希望一个 entry 被多数 followers 写就是 committed 的,怎么办?

这就是 Raft 的 Safety,我们需要同时确保:

也就是说,在上面的问题中,S5 prefix 缺少了 committed entries,只要不把它选为 leader,就不会有 safety 问题的出现。

也就是选取 leader 的依据:

这样上面的问题中,如果 S1 挂了,则可能选上的只有 S2,S3,大部分 follower 就会拒绝 S5 的选举请求。

那么,我们通过限制 leader 标准、划分 committed entries,实现了 safety 吗?可惜还没有。再考虑一个特例:

因此我们判断 committed 依据是,只有在处理当前 term N 的 replication(包括进行 overwrite)时,才认为 N1 的 term 是满足 safety 特性的。

 

总结,选举 leader 需要检查 2 个方面:

然后,上面两条仍然不能确保 raft 在 term N(最近一轮)的 safety(多数 followers 有相同的 entry 是 committed 的),只能确保 term N-1(本轮的上一轮)是 committed 的、可以刷盘的;

也就是说,只有当 term N 的 entry 获得多数接受并且进行 replica 时,term N-1 以及之前的 entry 才能视作 committed。

 

Chapter 2. Revisit: Network

 

2.1 Layers

另外有些共识:

如何将数据从一个节点传给直接相邻的节点?考虑解决几个问题:

我们考虑借鉴一些例子:

能否用这个不借助 shared clock 的方案:

但是如果 propagation time 设为 Δt,那么需要多于 2Δt 的时间才能传递一个 bit(最大传输率 12Δt);

因此人们为了提升传输效率,使用 parallel transmission 的思路:SCSI 接口(模仿硬盘数据传输)、printer 并口等等。

但是 parallel trans 有问题:

因此人们考虑 serial trans(如 USB/SATA):

 

现在又有两个传递过程的问题:

 

现在看如何让一个线路上尽可能多地负载数据流(共享一根线)。

 

现在我们考虑错误处理(Bit Flip Error Handling):

首先,容错最本质的做法就是 “冗余”。如果有效的编码稀疏地落在巨大的编码空间中,只错 1 次或 2 次大概率就能发现是非法的。

因此我们引入编码数据的 “海明距离”(Hamming Distance)来衡量两个数据表示的相似程度,并且表示发生 bit flip 错误后可能落到的位置。

只要让有效的编码方式间距离的海明距离大一点,能发现更多次 bit flip 错误的可能性就更大。例如如果希望发现 2 次 bit flip 错误,那么可以让每两个有效编码的海明距离为 5 以上。

举例,一种最简单的 Simple Parity Check(奇偶校验)方法如下:

我们将 00 -> 000, 11 -> 110, 10 -> 101, 01 -> 011,也就是将原本编码空间扩大一倍,使得每两个有效编码间的海明距离由 1 变为 2(在编码空间中相距最远),这样新的编码就能识别 1-bit 的 flip error(但不能纠正);

另一种编码校验方式更强大一些,4B/7B 编码:

让每 4 bit 数据中的 3 bit 是异或计算出来的。只要某些地方发生了错误,就能利用等式信息反推出哪位出错(纠正能力)。

为什么规定如上的计算方法?因为可以发现,“Not-Match” 和 “Error” 的下标可以直接用加法计算,简化了硬件实现。

总结一下,这种方法能够:必定能发现、纠正 1-bit flip error、必定能发现 2-bit flip error、有概率发现 3-bit flip error

缺点是数据利用率比较低(数据量扩大了 1 倍);

 

2.2 Network Layer

IP 协议使用的是 Best Effort Network 设计模式。除了 Best Effort,还有 Guaranteed-delivery:

现实生活中,很难满足完全 guaranteed,而且可能也不需要。

所以在 IP 协议中,定义了一些概念:

并定义了一些接口:

如何让 packet 在两个 network address 的设备间传递?这就是 network layer 需要完成的任务:Routing(路由)。

由于实际网口数量很多,因此我们需要一张表来记录指定的 IP Address 应该从哪个网口出去。

而且我们希望这张表可以不用手动维护。

因此 IP 协议分为两个部分:

2.2.1 Control Plane: Routing Algorithm

目标:

算法内容:

  1. 新加入的节点使用 Hello Protocol 告知邻居;

  2. 新加入的节点使用广播(advertisement)告知可达节点自己的相邻节点;

  3. 每个节点计算并决定到已知的、可达节点的最短路径和距离;

如何知晓两个可达节点间的最短路?两种算法:

综上,两种方法都没法应用在大规模、变化频繁的网络中。我们现在讨论如何进行 scaling。三种方法:

 

2.2.2 Data Plane: Packet Forwarding

一个快点的网络包从查表到发出大约 100 cycles,而访存快的 100 多 cycles,慢的 200~300 cycles,这对系统有很大挑战。

4 条路:

corner case:

 

2.2.3 NAT: Network Address Translation

私网(内网)和公网:随着网络规模扩大,上面介绍的 IPv4 协议给出的 IP Address 已经不够用了。这应该怎么办!

人们的解决方案是接一个网关(Netgate):

这是在利用 NAT 网关自己的 IP 和 Port 作为 Proxy,并在 NAT 网关中存放映射关系(<内网 IP, 内网 Port> -> <NAT port>);

Quiz:计算一个 NAT 网关最多能承载内网中的多少台机器?

NAT 网关的承载上限就是 port 数量。

但这种设计破坏了网络层次化设计。因为在网络层需要知道上层的协议(Transfer Layer,Port),并且修改它(修改了 payload)。

所以有些协议就没法支持,例如:

 

2.2.4 Ethernet Protocol & ARP

以太网,全称 载波监听冲突监测(Carrier Sense Multiple Access with Collision Detection,CSMA/CD)。

它提供了两种接口:

Ethernet 有两种类型:

以及两种拓扑构建方式:

Ethernet 是如何广播的?

由于 MAC 地址(Link Layer)实际上管控不严格,不同 Ethernet 中的 MAC 地址可以相同,甚至可以人为永久刷入新的 MAC;

例如 E 设备的 MAC 可以标识成 15(和 M 一样);

同样,和 Network Layer 一样,同样需要把 router 另一端接入的其他设备映射过来,这就是 ARP/RARP 协议。它在 Link Layer 中,将 IP Address 和 MAC 地址联系起来

这个 ARP 表一般需要广播(Broadcast)建立;

举个例子:

如果一个包需要从 C 传给 Baidu,过程如何?数据包头信息如何变化?

如上图过程,首先应用层给出目标 IP 地址(也可以是 hostname 然后通过一次 DNS lookup 得到),以及需要传输的数据;

然后 OS 无法在本地查询到 MAC 地址,使用 ARP 表找到应该交给局域网配置的 gateway(即 router-1),于是将 Target MAC 设为 gateway 并且 Source MAC 填成自己,方便后续消息回传工作;

数据包在 Link Layer 传给 gateway 后,它发现虽然 MAC 地址是给自己的,但 IP 地址并不是,意味着需要再次进行转发。gateway 查看它的 ARP Table,应该向 router-2 转发,因此将 Source MAC 改成自己的 MAC,Target MAC 改成 router-2 的 MAC;

因为 gateway 还是内网网关,因此通过 NAT 协议向外请求,此时还会修改 Source IP 为自身 IP、Port 为新的 gateway port,并将映射关系记录在 gateway 的 NAT 表中;

最终 router-2 收到 packet 后发现能够找到 Baidu MAC 地址了,因此最后将 Source MAC 改为自己的 MAC,Target MAC 改为 Baidu 的 MAC,成功将数据发送给 Baidu 了(不存在 NAT,因此没有更换 Source IP,还是 router-1);

如果是 A 传给 Google 呢?

上述例子中,我们发现两个 MAC 地址就能成功描述在 Ethernet 中的点到点的数据传输路径,比较方便。

 

ARP Spoofing(一种 Man-In-The-Middle Attack):

防御方法:

但由于 ARP 协议本身的问题(任何人都能进行 broadcast),ARP Spoofing 本身没有违反协议的规则,因此上述防御方案治标不治本(例如通过减缓 ARP poisoning 的速度、只污染特定某个设备的 ARP,来规避第二种防御方案)。

 

2.3 End-to-End Layer

由于网络层不确保 Delay、Order of arrival、Certainty of arrival、Accuracy of content、Right place to deliver;

因此在 End-to-End Layer(Transfer Layer + Application Layer)可能需要处理上述部分或全部问题(因上层应用需求而定);

不过无论是 UDP/TCP/RTP,都没法完成一个方法通吃所有实际应用情况(有些实时应用需要速度而不需要保证丢包率,另一些则相反)。

因此 End-to-End Layer 需要确保:

2.3.1 At Least Once Delivery

为了保证 “最少发到一次” 的性质,策略是:

但我们需要解决以下问题:

  1. 没收到怎么办?

  2. 对方收到了,但 ACK 没传回来怎么办?

这两种问题没法区分。这个问题我们在确保 At Mose Once 的性质时就会解决。

在上述策略中,我们怎么知道丢包了?或者说,sender 如何发现 timeout?

 

2.3.2 At Most Once Delivery

上面提到,现在的单次 ACK + timeout resend 的策略没法区分 “没收到” 和 “收到后 ACK 没传回” 的情况,这在某些情况下不允许的,因为有些应用不能容忍重复的网络包。这时我们需要让 End-to-End Layer 来选择性支持 at most once 的性质。

解决方案:

还有几种解决方案:

此外,还可能存在一些问题:

总之,de-duplication 会给系统带来比较大的复杂度!

 

2.3.3 Data Integrity

如何确保 receiver 收到和 sender 相同的信息?

答案是 sender 在头部维护一个 checksum 信息校验,receiver 接收到后再次计算校验一下;

link layer 中也有 checksum,请问这里是否多余?

答案是不多余。因为可能会有其他错误,例如在 message copying 中出错;

但也不绝对保证数据完整性。例如,如果数据包 mis-delivery(送错或者丢包没发现问题),并且 receiver 仍然 ACK 了,那么可能出现问题;

 

2.3.4 Segments and Reassembly of Long Messages

End-to-End Layer 这个部分需要处理 “网络长数据分段和组装”,其中消息长度由 Application 决定,MTU 则由网络本身决定。

这样 message 本身的 ID,可以用在 at-least-once 和 at-most-once 中;

这里可能出现一些问题:

对于乱序问题,我们的解决方案有很多:

此外,如果想要加速,可以引入 NAK 来避免 timeout 情况;如果发现 NAK 导致 duplication,则停止这种策略;

 

2.3.5 Jitter Control

可以类比观看在线视频的情况,假设存在一个最长的时延,Dlong 长于 99% 的请求情况,Dshort 又非常短,那么我们可以计算 segment buffer(用于缓存一些 packets 防止网络时延尖峰对用户造成比较显著的影响):N=DlongDshortDheadway;其中 Dheadway 表示两次收到 segments 间的平均时延;

但是某些实时场景,例如股票交易/游戏渲染,就没法使用这种方法了。

 

2.3.6 Authenticity and Privacy

我们还要考虑到 Internet 上的大多数资源是不可信的,可能存在恶意的 robot 或者 hacker 窃听或更改包的数据。

为此的解决方案是非对称加密。

 

2.3.7 Performance Improvement: Sliding Window

我们考虑现在我们已经设计的发包方式(segments):

但是这种方式会引起性能问题,因为 Segment/ACK 发送、和接收到存在时延,如果每次都等待 ACK 再继续发送 segment,那么性能是不行的。

因此采用 overlapping transmission(pipeline style):

 

但不能真的不管了,因为这么做可能出现包丢失的问题。并且最好考虑到 receiver 的接受能力。因此人们想到了 fixed window 的设计:

如果 N 个包任一个 ACK 超时,则 resend。

不过我们发现空闲时间还是很长,于是改进为 sliding window,充分利用等待时的资源:

接受一个最前面的 packet,滑动窗口立即向前移动一位,这样能减小空闲时间。如果最前面的 packet 迟迟没有 ACK,则哪怕窗口后面的 packet 确认了,也等待在原地(保守的特性,因为一个网络故障可能意味这有 serious problems):

还有一个问题,滑动窗口的大小设为多少比较合适?

如果 window 太大,会导致网络拥塞(sender 一口气发送超过网络带宽能承受的上限的数据);如果 window 太小,会导致更长的空闲时间,没有充分利用网络资源。

如何科学地确定 window size 的大小,是其既不会出现网络拥塞,也不会出现资源浪费?这时候,就需要进行 TCP 的拥塞控制(congestion control)了。

2.3.8 TCP Congestion Control

拥塞的定义是,过多的 packet 同时出现在网络的一部分后导致延迟增大/丢包的现象,或导致 performance degraded;

要想处理网络拥塞,需要 network layer 和 end-to-end layer 协作处理:

发生的原因,无非是 sender 发送过多的 packet 导致等待队列延长、时延增大。超过队列大小后发生丢包。

因此,我们从网络拥塞的原因中讨论 window size 的合理范围:

window sizeRTT×bottleneck data ratewindow sizemin(RTT×bottleneck data rate×receiver buffer size)

遵循 TCP 保守原则,我们设定 window size 为动态计算,并遵循以下原则:

  1. Additive Increase:不存在丢包时,线性增加 window size(+=);

  2. Multiplicative Decrease:认为丢包是一种很严重的事情,出现一次丢包,立即指数形式减小 window size(/=);

上面的策略统称 AIMD

针对这个策略进行改进,为了防止初始阶段(从 window size = 0 增长)过慢,在从 0 开始增长时以指数形式增长(*=)。这个改进被称为 slow start

于是,在 AIMD + slow start 的改进策略下,一个网络的可能 window size 的变化如下:

注意到:

在另一个层面,我们可以从 sender 和 receiver 的 window sizes 两个维度更全面地检视一下 AIMD 策略。或者说,在多个 user 共享一个 router bottleneck 时如何确保网络资源占有的公平性(fairness):

这就说明了 AIMD 强大的稳定性:即便 sender 和 receiver 最开始设定的 window size 不一样,也能通过多次调整来匹配它们的 window size,实现资源最大化,降低 receiver 丢包概率;

这也解释了为什么不用 Additive Decrease,因为 additive decrease 没法让双方 window size 较为稳定地保持在同样大小范围内;

 

2.3.9 Weakness & Summary of TCP

现在一个基础版本的 TCP 内容就介绍完全了,但不是在所有情况都适用的:

总结:

 

2.4 Yet Another Protocol in End-to-End Layer: DNS

2.4.1 Definitions

DNS(域名解析服务),不存在它,网络也能 work。不过它也有作用:

特性:

2.4.2 Look-up Algorithm

最开始,人们使用 hosts.txt 来保存 address binding(domain name 到 IP address 的映射);

注:现在还在同时使用这个文件,具体做法参见后面的算法。

但是 scalable 不能适应网络的发展,于是出现了一个沿用至今的系统:Berkeley Internet Name Domain(BIND);

还有,由于域名太多,因此人们采用了分级架构(顶级/二级/三级 域名),每级的特定域名可以由特定组织/个人来负责。

显然,DNS 可以 cache 下来,减小查询时延。我们现在讨论一台机器没有 cache 任何 DNS 记录的情况下,查询 ipads.se.sjtu.edu.cn 的情况:

因此一个新的机器只需要记住 root 节点所在的 DNS server 的 IP 地址就行了。

现在我们来看 DNS 有 cache 的情况,它是如何设计的?

  1. 在本地机器上保留的 /etc/hosts(windows 上请自行搜索),直接定义当前机器范围内可以定义的 domain name - IP 的映射;

    在本地机器上存在 /etc/resolv.conf 指定可以查找的 DNS server(例如 8.8.8.8 是 Google 提供的 DNS server,可以让机器查询 root);

    除了用本机写的 DNS server,在连上某些无线网时,可能会自动给机器推一个 DNS server(由这个无线网管理员配置的);

  2. 本地机器的 OS、甚至浏览器自己会进行 cache;

  3. DNS server cache:在委托请求(recursion query)的 DNS server 中也存在 cache;

    为什么不用 non-recursion 的方式?因为 client 的网络效率可能没有 server 的好。例如 client 本身的 packet 还需要出层层的局域网才能到公网;

上述 cache(浏览器/OS/DNS server cache)存在过期时间,称为 TTL(time-to-live),通常管理 DNS server 的人 / OS 或浏览器的配置可以控制各自的 DNS 解析 cache 的 TTL;

2.4.3 Design Pattern of DNS

是什么使得,在网络即便扩大几十万倍的情况下,仍然只需通过 replicas 就能维持可用性?

但是也有一些问题:

2.5 Naming Scheme

讨论比较抽象的理论。人们是如何通过命名机制建立起模块化系统的?

我们回想,email address name/phone number、之前的提到的 hostname、x86 register name、language function name、file path name、URL/URI、IP address,等等;

我们以磁盘为例,Linux 是如何将文件和磁盘联系起来?就是通过 naming 的层次实现:

总结一下 Naming 如何支持模块化(在什么时候使用):

其中,我们注意一种特殊的 name: address,它们是软件用于表示有关物理信息的标识,它通常不仅仅作为 “name”,还有实际的含义(作为 “value”);

于是,naming 的抽象模型如下:

Context:例如 CSE 的老师说 lab-1 一般是指 CSE 的 lab-1 而非 Compiler 的 lab-1;

但 Context 不必要,例如 URL/URI,它具有全球统一的性质;

我们将上面 “只有一种可能的 context” 的情况称为 universal name spaces(例如 UUID、信用卡号、email address 、URI/URL等等);

但有些 naming 就需要,例如 file name,我们在 OS 中使用 $PATH 来指定 context;

naming terminology 也对应了一组标准操作:

API:

value <- RESOLVE(name, context):Return the mapping of name in the context;

status <- BIND(name, value, context):Establish a name to value mapping in the context;

status <- UNBIND(name, context):Delete name from context;

list <- ENUMERATE(context):Return a list of all bindings;

result <- COMPARE(name1, name2):Check if name1 and name2 are equal;

Q&A:

What is the syntax of names?

What are the possible value?

What context is used to resolve names?

Who specifies the context?

Is a particular name global (context-free) or local?

Does every name have a value? Or, can you have “dangling” names? (a name without any value)

Can a single name have multiple values?

Does every value have a name? Or, can you name everything?

Can a single value have multiple names? Or, are there synonyms?

Can the value corresponding to a name change over time?

2.6 CDN

Server Selection Mechanism:

2.7 P2P Network (End-to-End Layer Protocol)

P2P 网络面临的挑战:

  1. 整个 P2P 网络中到底有多少节点存了多少对象?

  2. 怎么找到其他人呢?

  3. 不同数据在节点之间怎么 spilt 呢?

  4. 数据有没有可能放在某个节点,某个节点挂了就丢了呢?

  5. 保证 consistency;

  6. 保证 security;

为了避免中心化网络的弊端:

人们使用了 Cooperative 的设计思路:

区分 3 个角色:

然后需要一个中心化的 server(至少域名是中心化的)发布 tracker 的位置;

这个发布方法可以通过 .torrent 种子文件完成,其中就会包含 tracker URL、文件 (块) 的元数据(例如文件大小、名称、Hash 等等);

下载不同文件块的顺序由什么决定?

Strict? Rarest first? Random? Parallel?

BitTorrent: Random for the first one, Rarest first for the rest, Parallel for the last one;

于是 P2P 的缺点显而易见:Tracker 作为中心化组件,torrents 没法大规模 scale;

不过可以进行 scalable lookup: DHT(distributed hash table),让 bindings 存在于不同的物理节点上(因为一台 server 保存不高效,也无法实现高可用),并且实现下面的接口:

P2P 如何实现 DHT?我们需要在找到两个机器间通路的同时(某个资源的 peer 到 seeder 的通路)确保 load balance 和 O(logn) 跳的复杂度!

答案是一致性 hash(Chord 算法)。

总结一下:

consistent hashing 可以这么实现:

这样的方案能在分布式场景下尽可能减少缓存失效和变动的比例;

但这种方案仍然存在问题:当集群中的节点数量较少时,可能会出现节点在哈希空间中分布不平衡的问题(hash 环的倾斜和负载不均),甚至引发雪崩问题(最多数据的 A 故障,全转移给 B,然后 B 故障,并重复下去,造成整个分布式集群崩溃)。

解决 hash 环倾斜的问题的方案之一就是引入 “虚拟节点”(相当于给机器 hash 点创建 “软链接”),将 virtual nodes 和 real nodes 的映射关系记录在 Hash Ring 中;

 

Chapter 3. Distributed Computing & Programming

现在我们回顾一下,我们已经讨论过的在一个云 OS 可能用到的分布式机制:

现在,我们想要考虑 “分布式计算和任务调度”,也就是说,我们在分布式环境上如何实现并行的、高效的调度和计算

这就是 MapReduce 需要考虑的事,本章我们将讨论 MapReduce 的实现案例。

先关注一些基本概念。

3.1 Parallelism on Single Chip Device

如何 scale 一个 Single Chip Device 的计算能力?答案是并行。

单机并行的 3 种方案:

如何实现 multiple core?

 

为了提升并行计算能力,除了上面对于多核并行的探索以外,还有一个重要的方向需要优化和挖掘:访存。

因为除了单机的核数,并行计算的性能还可能被 memory stall 所限制(100 cycles ~)。你可能会说,我们可以根据 locality 进行指令 prefetch。但是在内存读写是主要瓶颈的时候,指令 prefetch 并不总是可行的。

为了刻画访存这个方面的瓶颈,我们引入一些 metrics:

因此数据加载时间为:latency + payload / bandwidth

注意几个方面:

  • 这是最理想的情况。实际上 latency 和 bandwidth 会被其他物理因素影响;

  • 如果是 CPU load/store 场景,瓶颈可能在 latency,而在大数据量传输的场景,瓶颈可能在 bandwidth 上;

Roofline Model(刻画应用对访存和算力的需求):

这样我们可以表示设备带宽:slope=bandwidth=peak flopsOI

于是我们有了优化的方向:只要给定一个应用在每次内存读取时的 FLOPs 次数,我们就能根据硬件的 roofline model 来判断应用是 computation bound 还是 memory bound;

另外,仅仅有 SIMD 是不足够的,因为太过 low-level 了,如果我们想要对向量计算 ReLU 函数,就需要分支预测,并且每个 SIMD ALU 会共享同一个 Program Counter,这就没法充分利用 SIMD 的优势。

那么我们能否让 SIMD 指令表达 conditional branch 的策略(例如 conditional vector add)?

解决方案是 masked instruction:

缺点是存在分支时,没法使用全部的 ALUs(每个分支间串行),浪费计算资源。并且分支很多时,性能不好。

基于上面的 CPU 缺陷,人们在 GPU 上引入了一个编程模式(Nvidia)SIMT(Single Instruction Multiple Thread);

这样 SIMT 可以帮助我们实现:

例如 CUDA 就是使用这种思想。

 

general-purpose vs specification calculation (Domain Specific Language): Trade Off

我们还注意到 GPU 实际上不需要关心 cache coherence 的问题,因此它们可以有很多核、很多的 ALU。此外 GPU 还没有 branch predictor,实际上在 general-purpose computing 和其他功能方面作出了权衡。

在考虑 general-purpose 和 specification 计算的上限时,最终会落到单机芯片的主频(物理限制)。

也就是说,有很多计算任务只由一台机器是没法解决的。我们需要模仿应用层面的做法:建立计算集群;

 

3.2 Distributed Computing Framework: MapReduce

3.2.1 Definitions: Batch Processing

为了应对不同类型的应用场景(complex queries),人们发展了不同类型的分布式计算框架。现在我们先聚焦于 batch process system;

考虑一个场景:我希望处理一个超大级别文件的文本内容,例如:

除了第一个 cat 指令可以用分布式文件系统进行 scale,其他几个顺序的指令都会被单机单线程计算能力和 DRAM 承载能力所限制。

这可以用分布式计算的思想进行优化,利用 RPC 把数据分散到多台机器(map),计算完成后在将结果汇总到一台机器(reduce);

这个实现会面临一些挑战(或者说编程者需要额外考虑的):

  1. Sending data to/from nodes:发送和接收 RPC;

  2. Coordinating among nodes:结点间 reduce 时需要考虑同步机制等等;

  3. Recovering from node failure:需要保证 failure tolerance;

  4. Optimizing for locality:在网络间传输数据经常慢于本地数据的访问;

  5. Partition data to to enable more parallelism:如何对任务进行合理的 partition 来充分利用计算资源,以突破串行执行的性能瓶颈?

在以前一个分布式计算的应用通常是需要手动处理,就算上述问题得到解决,那么这些代码也会和具体的业务逻辑紧耦合,缺乏可维护性和可扩展性。

于是人们开发了 MapReduce 框架,用以应对:

  1. 将具体计算的业务逻辑和并行计算的上述挑战和要求解耦;

  2. Map 和 Reduce 间自动 handle 数据传输;

  3. MapReduce 本身的语义能确保同步(schedule reducers after the mappers);

  4. node failure 通过 re-execution 来恢复;

  5. 为了充分利用 locality,通常有策略把 map 和 reduce 调度到同一机器上;

  6. 将输入数据分区(partition)提升数据并行性;

常见的使用场景有:

3.2.2 Overview

MapReduce 的接口语义是:

用户只需要关心分布式计算的业务逻辑就行,其他的全部交给 map reduce 框架!

3.2.3 Implementation

  1. (Client/Master) split input files into chunks (shards)

    • 通常情况是 64 MB(可以 fit GFS chunk size,方便 RPC 读 chunk);

  2. (remote) fork processes:

    • 1 master: scheduler & coordinator;

    • lots of workers:每个空闲 workers 被分配 map tasks(each work on a shard)或者 reduce tasks(each work on intermediate files);

  3. (mappers) map task:

    • 从被分配的 input shard 中读取内容;

    • parse key-value pairs;

    • 执行用户指定的 map function,生成中间 key-value pairs(buffered in memory);

  4. (mappers) create intermediate files:

    • 将 buffered in memory 的 intermediate key-value pairs 定期落盘

    • 其中每个 key-value pair 被 partition function 分到 R 个分区(默认 hash + mod R);

      R 由实际 reducers 的数量决定;

  5. (reducers) sorting intermediate data:

    • 接收 master 关于 intermediate files 的不同分区位置的提醒;

    • 使用 RPC 从 map workers 中读取文件内容;

    • 将从不同 intermediate files 中 sort keys(类似 merge sort,因为 mappers 和 reducers 都会做排序);

  6. (reducers) reduce tasks

    • 按 unique intermediate key 来为数据分组;

    • 执行用户指定的 reduce function,按指定的 key 对多个数据进行 reduce;

    • 输出到一个 output file 中;

  7. (Client/Master) return to user

    • 当所有 map/reduce 任务完成后,master 唤醒用户程序;

    • 结果将存放在 R 个输出文件中;

 

3.2.4 Fault Tolerance

总之我们现在已经完成的部分:

考虑我们如何解决 fault tolerance 的问题(machine failure 很常见的问题);

我们分别看 worker 和 master 的情况。

还有一类错误的情况不是集群本身的问题,而是第三方程序 / 数据的问题:

 

3.2.5 Optimization

 

3.2.6 Summary

优点:

用户不再需要关心 parallelization / data distribution / load balancing / fault tolerance;

缺点: Limited programming abstraction ;

但 MapReduce 也不能解决所有问题。

例如 Google 爬虫使用 MapReduce 统计页面,一般需要 8 小时左右,但网页更新速度更快。

也就是说,特性如下:

上面的最后一个问题很严重。这意味着稍微复杂一点的任务,MapReduce 都没法胜任,例如 “find the five most popular pages in web logs”,我们需要 chain multiple MapReduce tasks together!这个时候 MapReduce 的缺陷就显现出来了:

也就是说,MapReduce 并没有对于 multi-stage execution 这种场景做出优化。

于是更新一点的概念抽象:Computing Graph 就被提出来了。

 

3.3 Computation Graph

3.3.1 Definitions

对某些比较复杂的计算场景,仅仅使用 MapReduce 的计算抽象已经不足以完成任务了。于是人们提出了另一种计算抽象:计算图。

也就是说,计算会被表示为一个有向无环图(DAG):

DAG 的好处:总是可以找到一个前序结点,进行 re-execution 将故障结点的数据恢复回来。

一个典型的计算图的运行时抽象如下:

计算图的调度规则:

计算图的 fault tolerance(和 MapReduce 很类似):

思考:如果计算过程并不是幂等的呢?和 MapReduce 类似,还是可以通过日志等手段恢复。

 

人们对于 computing graph 的实现之一就是 Dryad(一种通用的分布式执行引擎,面向粗粒度数据并行应用),它和 MapReduce 的目标都是相似的。

Dryad lets developers easily create large-scale distributed apps without requiring them to master any concurrency techniques beyond being able to draw a graph of the data dependencies of their algorithms.

 

3.3.2 Application: Distributed Training

这是个在 AI 中实际应用 computing graph 的例子:分布式训练。回顾一下,我们为什么需要分布式训练?

本章我们不讨论 AI 训练中的收敛问题(机器学习应该看的),我们将着重讨论训练的 throughput 性质(系统的性质);

以 Stochastic Gradient Descent 算法为例:

ωiter+1=ωiterαBi=1BJ(ωiter)ω

有两种让它并行分布式计算的方法:

Data Parallelism

我们考虑同步的 data parallelism 方式:

具体来说,我们可以通过 Bulk Synchronous Parallel (BSP) Execution 的方式来实现上述思想(同步数据):

注意到我们需要优化 allReduce 算子,目标就是降低网络开销(单机上的运算开销已经优化到位,不是性能瓶颈所在)。这里不妨记 N 表示模型参数量大小,P 表示并行处理的 workers 数量(处理器数量):

注意到这里浮点数的运算顺序有不同,所以这种方法的 assumption 是数据运算是可交换的;

总的来说,ring allreduce 的开销:2×(P1)×N/PO(1) fan-in;

总体信息交流轮数:O(P),比 Parameter Server Approach 的 O(1) 显著增大;A trade-off for reducing network contention due to high fan-in;

缺陷:由于有额外 rounds 的通信开销,可能 latency 比较大;

这样还能继续优化!让每个 server 处理两个 shard(Double the fan-in),就能让 fan-in 下降到 O(logn),这就是 double Binary Tree All-Reduce。通过构建一个 reduction tree,double fan-in 的量,来构成仅 log(P) 轮数据交换。

总结一下:

 

Model Parallelism

Data Parallelism 的缺陷:replicated model;

既然一个 GPU 放不下这么多数据(data forward),我们可以考虑放到多台机器:

不过划分也有不同方法:

一般真正的使用场景中,我们常常把 pipeline parallelism 和 tensor parallelism 的策略结合起来。它们的优缺点:

Pipeline parallelism: Partition the computation graph by layers;

Tensor parallelism: Partition the parameters of a layer (or a set of layers);

 

Async vs. Sync execution

回忆我们在同步执行时讨论的 BSP 实现,它给每个节点带来了大量的空闲时间,因此我们可以采取异步方法,让节点间不再相互等待。这就是 Asynchronous execution;

Async

Sync

Which is better? Hard to tell. But, as a system designer, preserving the algorithms property is very important. Any design that alter the training property should be justified.

因此大多数时候人们都选择同步执行的训练方法。

 

Chapter 4. Security

系统安全的目标

Information security goals:

Liveness goals:

系统安全的假设:Threat Model(威胁模型)

 

4.1 Authentication (Password)

目标:认证用户、让攻击者猜测困难。

算法 1(tenex):

侧信道攻击。密码判断的正确长度和运行时间有关。测试每一位,可以使用 Page Fault 增大运行时间的差异。

算法 2:于是我们不能在 server 上保存明文。现在考虑保存 Hash Value。

注意到 Hash 运算的性质 Hash(x) = a && Hash(y) = a => x = y,我们可以构造彩虹表(常用密码的 Hash),进行撞库。

算法 3:保存 Hash Value 也不安全。需要在 Hash 密码前,对 Hash 加盐(随机数),Hash(pwd | salt)

问题上这个方法没有从根本上解决泄漏的安全隐患,只不过是增大来攻击者的攻击成本:需要为每一个人的 salt 都要计算一个彩虹表,这样想批量攻击就很困难了。

而且很严重的问题是,总会存在首次验证的情况。以 Web Application 为例,如果是钓鱼网站,密码还是会丢。即便发送端事先和接受端商议固定的 salt,并且加盐 hash,攻击者还是可以通过重放攻击(replay attack)来非法获取权限。

算法 4:不把密码放在网络上,使用 nonce(server 给的随机数),然后计算 Hash(pwd | nonce) 传给 server;

 

还有一些小问题,首先防止攻击者能够 offline attack,需要对尝试的请求进行监控。

也可以使用 site key(输入 username 后交换一些独特的信息,例如历史产生的数据,验证是否是钓鱼网站);

也可以让密码只能使用一次。

 

4.2 CFI: Control Flow Intergrity

很多二进制攻击的本质是针对程序正常控制流的破坏(例如 Buffer-Overflow Attack、ROP 等等)。因此,我们考虑防御这些问题,就可以从保证控制流完整性的思路上入手。

注意到,在编写程序时,程序语义中实际上是包含 CFG(Control Flow Graph)的。问题是编译为二进制文件后,机器代码中的 CFG 的额外的信息限制丢失了。我们可以考虑在编译时,把源码中的 CFG 信息限制加入二进制中吗?

有调查表明,程序在静态编译后有 90% 以上都是 Direct Jump/Call,并且运行时几乎 95% 以上的 Indirect Jump/Call 都只有一个确定的 Target。也就是说,程序的 Control Flow 没有想象中的那么灵活。

 

方法:在编译器编写时,我们可以在在跳转的 CFG 路径上硬编码随机数(patch),调用时检查跳转的位置是否匹配。

如果是对外提供的库,担心硬编码的兼容性差,可以利用 prefetchnta 指令(quiet failure,错误静默)。

但是这么约束粒度很粗,不能防止所有的异常跳转。例如:

除了上面的方法(unique IDs),一些措施例如 Non-writable code、Non-executable data、Enforcement: hardware support + prohibit system calls that change protection state + verification at load-time;

使用 CFI 方法能防御所有:stack-based buffer overflow、return-to-libc exploits、pointer subterfuge(指针诡计,通常利用了 buffer 不安全的库函数)这些类型的攻击。

CFI 不能防御的攻击有:

这些攻击都没有违反程序正常的 CFG;

不过也没关系,因为这已经极大的增大了 ROP 等攻击的难度。

 

4.3 Example: Buffer Overflow - BROP

BROP Study Case

 

4.4 Data Flow Protection

机密性、完整性、可用性(CIA);

4.4.1 Taint Tracking

敏感数据段的 lifetime 应该越短越好,这个时段被称为 “data exposure”:

依赖数据的流动(例如赋值)。

Taint 的数据应该存放在哪里?大概率是放在整个内存的最末尾位置(不是直接放在栈上的)。

弱点:利用控制流可以洗掉 taint,例如 b = a 的语句使用全分类讨论赋值 b 为常数。

4.4.2 Defending Malicious Input

使用 taint analysis 来预测可能出现的 malicious input 对系统产生的不安全影响。

4.5 Security Channel

4.5.1 对称加密

所以可以采用一种简单的做法来尝试防止 Eve 和 Ida 的行为:

形象理解就是一把暴露在外的锁(e(x),d(x))和钥匙 r

Sidebar:按位异或 与 AES 算法

按位异或(bitwise exclusive-or)就是一种选择。

dr(x)=er(x)=xr

dr(er(x))=(xr)r=x0=x

因为它满足一些优秀性质:

  • 是一个双射函数,其逆函数就是自身;

  • 能保证 er(x)x 的关系不大。只要密钥 r 是随机的,那么 x 的每个二进制位有 50% 概率不变,有 50% 概率相反;

  • 硬件计算很快,性能好;

但这个函数的 r 参数不能重复使用相同的随机数。为什么?因为重复的 r 会让窃听者获取和明文相关的消息:窃听者第一次拿到 xr,第二次拿到 yr,那么就知道 xy=xr(yr);这个 xy 就会间接地泄漏信息。

假设通信传递的是某个重要选举的票选信息,而每一位代表一个票选意见(比如是 1 就选 A,是 0 就选 B)。那么 xy 中的 0 越多,说明选举群众的共识度越高,有更多的人选择类似。

为了解决这个问题,人们基于按位异或设计了一种更强的对称加密算法:AES(Advance Encryption Standard),它的好处是:

  • 虽然算法公开,但可以重复使用密钥;

  • 计算效率合适;

  • 目前没有比 brute-force 更好的办法来破解(但也没有证明绝对不可破解);

这种方式被称为 “对称加密”(Symmetric Cryptography),即加密、解密用的密钥相同。

er(x)dr(x)r 参数就是加密用的密钥,而 erdr 本身是公开的算法

对称加密特点

但对称加密也有问题:在 Alice 和 Bob 第一次交流的时候,肯定需要约定一个密钥 r,这样 Eve 直接在信道上监听就能拿到解密函数。

也就是说,对称加密使用的密钥目前还没有办法保证不被网络上不可信任的设备截获。

因此需要更强大的措施来防止窃听者、中间人获取密钥。

4.5.2 非对称加密

假设我们通过某种方法获得了一个神奇的算法:

对,就这一个更改就让 Eve 没办法了:

因为虽然 b 解密密钥是公开的,但是,一旦有中间人 / 窃听者要用 b 解密后,由于 a 永远不在网络上传播(从生成开始一直保管在 Alice 那),所以他们将无法把消息加密还原回去,这样 Bob 发现消息不对就可以及时发现问题。

这个公开的 b 就被称为公钥(Public Key),私有的 a 就被称为私钥(Private Key),这种加密方式被称为 “非对称加密”(Asymmetric Cryptography);

而私钥加密、公钥解密的过程被称为 签名(signing)

Sidebar: RSA 算法

那么上面的 “神奇算法” 是如何实现的呢?数学家想出一个方法:

  1. 任取两个相当大的素数 p,q,计算 N=pq

  2. 找到一个和 (p1)(q1) 互素的数 e,则 f:xxemodNZN 上的双射,作为我们的加密函数;

  3. 解密相当简单:找到 e 关于 mod(p1)(q1) 的乘法逆元,记为 d,则对 ZN 上的任一元素 x,都有:(xe)dxmodN

证明:由于第 3 条蕴含第 2 条,所以下面仅证明第 2 条:

ed1mod(p1)(q1) ed=1+k(p1)(q1) (xe)dx=xedx=x(xk(p1)(q1)1)

注意到 xk(p1)(q1)1 可以进行因式分解:xk(p1)(q1)1=(xp11)(xq11)Pk

再由费马小定理:xp11modpp 是素数),因此对于素数 p,qxk(p1)(q1)1modpq=0;即:xedxmodN=0,或者说 xedxmodN 得证;

也就是说,(e,N) 是公钥,d=e1mod(p1)(q1) 是私钥,f(x)=er,N(x)=dr,N(x)=xrmodN 是加/解密函数。

由我们前面讨论的知识,随机生成两个素数时间复杂度 O(n),加密 / 解密是 ZN 指数 O(n3)

这个算法安全的前提是,没有人能快速地从 N 获得 p,q(即:素因数分解是难问题)

但是非对称加密也有缺陷:无论是生成公私钥,还是加密数据,计算都相当复杂、性能不佳,不适合用来传递大规模数据。

于是,一般可以采用这种措施:非对称加密 先用来传递对称加密的密钥,然后之后的通讯就使用对称加密来通信。这样既利用了对称加密的特性(计算量小、不知道密钥的情况下基本没法破解),又利用了非对称加密的强大安全性。

4.5.3 Replay Attack & Reflection Attack

4.5.4 Diffie-Hellman Key Exchange, RSA & MITM

除了 RSA,DH key exchange 是一种安全生成和传递对称密钥的算法。

但是它们都可能受到中间人攻击。

现在 Eve(窃听者)彻底没法窃听到任何有效数据了,但是 Ida 还有办法。

Ida 可以拦截 Alice 和 Bob 间的所有流量,然后在 Alice 向 Bob 第一次发送密钥时:

这下,Alice 和 Bob 在不知情的情况下,把消息全都发给了 Ida,Ida 既掌握解密的公钥、所有消息,还能不被 Alice 和 Bob 发现。

Ida 在这里被称为 “中间人”,而这种行为被称为 “中间人攻击”(Middle-in-the-man Attack,MITM);

这样是不行的。不过往好处想,还是之前的说法,只要我们保证第一次交换对称加密密钥的过程是安全的不就行了?这里的问题在于 Ida 可能会篡改公钥

那么有什么办法是在暴露公钥的情况下,还能防止公钥被篡改的?

4.5.5 证书

这理论上肯定没法只由 Bob 和 Alice 解决,还需要外界的帮助。于是人们引入了 “第三方公证” 的机制:

人们需要设立一个第三方机构,确保它不会与中间人勾结。第三方机构本身事先生成一对公私钥,然后:

由于 Ida 无法伪造第三方机构,因此最外层的密钥没法突破,因此也没法得到里面的数据进行中间人攻击了。

这里,第三方机构(证书签发机构)提供的公钥被称为 “证书”(Certificate)。Alice 使用第三方公钥加密、Bob 将加密信息给第三方解密的过程,就称为 “证书签名”;

目前,这套机制能够完全防御 Eve(窃听者)、Ida(中间人 / 攻击者)对信息的窃听和篡改。当然,证书 + 非对称加密 + 对称加密的整套机制被应用在了 SSL/TLS 当中,为 HTTPS、SSH 等协议重要通信场合提供全面的保护。

关于证书机构 CA 的 Q&A:

How does the browser get this list of CAs?

How does the CA build its table of names <-> public keys?

What if a CA makes a mistake?

 

4.6 Data Privacy

如何在使用数据的前提下,防止它们被偷走?

4.6.1 OT: Oblivious Transfer (无感知传输)

考虑一个场景,我需要从 server 的数据列表 {d1,d2,,dn} 中获得其中一个数据,但是为了隐私起见,我不希望 server 知道我取了哪个!注意,server 不能把全部数据都给 client。

这看起来很荒谬,我不告诉 server 我想拿哪个数据,却想要 server 发给我指定的数据。

这个问题就是 1-out-of-n OT 问题;

实现方法:

4.6.2 DP: Differential privacy (差分隐私)

考虑一个场景,薪资查询的时候,不能让询问者知道其他人的精确薪资,只能知道一些统计学信息(例如总和、平均值等等)。

因此根据信息熵可知,我们需要返回的信息只能是模糊的(如果是精确的一定最终会泄漏。例如如果 client 问总数 + 除了某个人的总数)!

并且最好限制 client 的访问次数。lock 后下次修改才会重置次数。

安全性质:

现存机制:

4.6.3 Secret Sharing

考虑一个场景,N 个人保存机密开启的密钥的一部分,只有至少 K 个人提供信息,才能解开机密数据的锁。这样保证了数据共享的安全性,有点像多个人保管多份钥匙。

优缺点:

4.6.4 Secure MPC (sMPC): Multi-Party Computing

定义:Multiple parties (at least 2) work together to calculate a function;

Enforce the data privacy for each party;

Semi-honest adversary: Each party must follow the protocol;

Generic protocol: Can securely compute any functionality;

Multi-party computation: Secret sharing;

2-party computation: GC(Garbled Circuits) + OT(Oblivious Transfer)

4.6.5 Homomorphic Encryption (同态加密)

4.6.6 TEE (Trusted Execution Environment)