(WIP)读 DDIA 书评与总结

本文是我在读完 DDIA 后的总结和评价感想。

DDIA,22 年初我其实就已经看完了,一直拖到了年底才想到做一些总结,虽迟但到。

在刚刚读完的时候,对这本书整体上的感觉,从知识的角度看,在许多细分的知识点上让我知其然且知其所以然。一些知识点早就听说过,也大概知其原理,但一方面是了解的程度不够深,另一方面这些知识之间很孤立,无法与其他知识形成关联。

因此个人认为 DDIA 所能带给读者的,的确书如其名,是包含了数据密集型应用系统 设计的方方面面,且能帮助读者对 数据密集型应用系统 相关知识形成体系化的概念和理解。

总体架构

数据系统基础

第一部分,主要讨论的是数据系统的基础。

这包括了数据系统作为应用软件系统的一种,需要满足可靠性、可扩展性与可维护性的目标。

其次,数据系统提供的服务便是对数据进行管理,因此采用何种模型来表达数据,怎样对这种数据模型进行查询也是重要的一环。

数据最终都会被存储在内存或是磁盘上,如何高效的存储,兼顾查询,是数据系统需要关注的重点。

最后,介绍了在数据被写入系统之前,以及查询之后的形态是怎样的?

第一章:可靠,可扩展与可维护的应用系统

不论是数据系统(data system)还是其他软件系统,在整个生命周期中都会面临三个问题:可靠性、可扩展性、可维护性。这三大问题都属于软件的跨功能需求(CFR)。能很好的解决这三大问题是成熟软件系统的必要条件。

可靠性 Reliability

简单描述为在遇到故障时仍然可用的能力,亦可称之为容错性(fault-tolerant)或是韧性(resilient)。

对于复杂的大型系统,故障(fault)难以避免,对于容错来说,就是系统能够容忍一部分错误,从而防止系统失效(faillure)。失效便意味着系统不可用了(unavailable)。

软件需要通过可靠性设计来获得容错性或韧性,可以通过故障测试来验证系统的可靠性,如混沌测试(chaos test)。另外,不同的功能优先级不同,因此对故障的容错办法也不同,根据实际情况选择以达到最大投产比。

可扩展性 Scalability

软件的可扩展性描述为,当负载增加时,增加系统资源来满足可接受的系统性能。

负载(load):用参数来描述可以是用户量,请求数等指标。

性能(performance)与资源(resource):性能参数可包括响应时间,吞吐量等,通常用百分位指标来描述如 P90、P95、P99

可扩展性的要求,可细化为

  • 在资源不变的前提下,当负载上升时,性能如何变化
  • 若要求性能稳定,那么如何提供资源来应对负载的变化

对于可扩展性设计,可采取水平扩展(horizontal)和垂直扩展(vertical)的方案。简单来说就是水平扩展加机器,垂直扩展升级机器。

可维护性 Maintainability

可维护性意味着对软件在整个生命周期内的持续投入。

可以说软件系统在发布之后就已经是遗留系统(legacy system)了。随着时间的推移,可维护性差的软件,可能会变成没人愿意维护的遗留系统,糟糕的设计会导致软件变成大泥球(big mud ball)

可维护性细分为三种能力:

可运维:

可运维性要求软件系统可观测、可迁移、可理解。

可观测代表对系统运行状态的洞察能力,可迁移代表易于对系统操作的能力,可理解代表系统知识的整理、归档等等。

简单:

在复杂的需求下还能保持简单的软件,其核心要义在于设计合理的抽象(abstraction)。

通过抽象解决问题分层,在同一抽象层通过合理的边界,构建高内聚低耦合的软件架构。

可演化性:

软件在不断的演进,可演化性要求不论是人员的更迭,或是特性的增减,系统都支持持续的演进而不是停滞不前。

可演化性不仅针对软件本身,也对团队提出了要求,研发效能高的团队,演进的更快、更好。

软件本身良好的设计也是可演化的前提,设计良好则易于修改。

第二章:数据模型与查询语言

数据模型是数据系统表现数据的逻辑形态,从数据对象的角度看,数据模型保存了数据对象的自身属性以及与其他数据对象之间的关联关系

有三种数据模型,基于数据对象的自身属性和关联关系,分别从不同的关注点来表示数据:

  1. 文档模型:更关注数据对象的自身属性
    • 结构简单,易于理解
    • 底层存储更紧凑,空间局部性好
    • 弱模式要求,同一个数据对象不要求模式完全一致
    • 不易处理多对一,多对多的关系,数据间的 join 可能会依赖应用层逻辑
  2. 图模型:更关注数据对象间的关系
    • 专门用于处理复杂的多对多关系
    • 通过节点和边描述数据
  3. 关系模型:折中的数据模型
    • 通过 table 的形式描述数据
    • 能设计出严格符合数据规范化的数据模型
    • 通过查询优化器代理了上层应用的 join 动作

针对不同的数据模型,也存在多种查询语言。

查询语言可分为命令式和声明式的。对于数据库系统而言,大多数查询语言都是声明式的,因为声明式语言的抽象层次更高,对细节的描述更少。命令式语言更贴近代码,由代码来控制如何进行数据的写入和查询。

第三章: 数据存储与检索

两种数据处理业务:

  1. OLTP:在线事务处理
    • 面向用户,为了快速响应,每次查询只涉及少量数据
    • 应用程序基于 key 来做查询,查询瓶颈在磁盘寻道时间
  2. OLAP:在线分析处理
    • 查询请求数量很少,但每个查询都可能需要在短时间内扫描数百万条记录
    • 范围检索,磁盘带宽是瓶颈

数据结构是数据库的核心,实际存储的数据结构可能与数据模型大相径庭。

索引是由原始数据派生出的数据结构,索引用于更高效的查询数据,两种常见的索引数据结构:

B-Tree(详细),LSM-Tree(详细)。

LSM-Tree 对比 B-Tree

  1. 写入吞吐量高
    • LSM-Tree 相较于 B-Tree 在合理的配置下有较低的写放大(详细
    • LSM-Tree 的顺序磁盘写入,速度更快
    • LSM-Tree 数据文件可压缩,碎片更少
  2. 压缩工作影响读写业务
    • 磁盘并发资源有限,后台压缩可能导致读写等待
    • 磁盘带宽资源有限,后台压缩会占用大量带宽

数据仓库

单独的数据库,包含的数据通常是 OLTP 数据库的副本,供分析人员进行分析。

分析型业务下数据模型通常采用事实表+维度表的形式:

  • 事实表:每一行代表发生的一个事件,事件包含于所有维度相关的 id
  • 维度表:记录不同维度的数据,通过 id 与事实表关联

事实表+维度表构成”星形模型“,对维度表的进一步规范化后,可得到”雪花模型“。

列式存储

基于分析性业务的数据模型,通常一次分析可能只关注事实表中有限的几列,因此其他成百上千的列并没有分析价值。

因此通过列式存储,每一列单独存储后,分析时只加载特定列的数据即可。

此外,列式存储中,每一列存放的数据都高度相似,因此压缩比很高,节约磁盘空间。

第四章:数据编码与演化

程序之间通过网络或磁盘等手段传输数据时,需要将内存中的数据结构通过某种形式进行编码后作为字节序列进行传输。

解决数据编码问题需要考虑的问题有很多,包括编码效率,空间占用,可读性以及版本兼容等等。

语言特定格式

编程语言原生支持的数据编码格式,如 Java 的 Serializable,Ruby 的 Marshal 等等。

语言特定格式最大的优势在于简单,开箱即用。但在效率、兼容性与可读性上都不佳。由于是特定的编程语言所提供的编码形式,因此异构的程序之间通常无法识别。

文本格式编码

类似于 JSON、XML 等文本格式的编码方案,由于符合人类可读的格式,因此理解起来很简单,使用也简单。它们不与任何一种语言强绑定,因此也能作为通用的数据传输编码。

文本编码格式,在处理数字时可能遇到挑战,如 JSON 的数字精度问题。此外,文本编码格式,在需要传输二进制数据时也比较困难,通常需要采用类似Base64 编码的办法将二进制数据编码为文本后再传输。

基于模式(Schema)的二进制编码

Thrift 和 Protocol buffers 是两种基于模式的编码格式。基于模式代表在编码前需要指定具体的模式信息(包含数据结构中的字段及其类型)。

模式的优点在于,数据结构中的字段名和类型可以被更紧凑的编码,并且易于支持向前或向后兼容(向前兼容只能添加字段而不能删除,旧版本程序可简单忽略新增字段,而向后兼容只能添加可选字段以防校验失败)。

但基于模式也会导致数据必须解码后才支持人类可读。

进程间数据流动方式

  • 通过数据库:数据提供方将数据写入数据库,数据使用方从数据库中读取
  • RPC 和 REST API:客户端和服务端分别对请求和响应进行编解码
  • 异步消息:发送方编码,接收方解码

分布式数据系统

第五章:数据复制

在分布式系统中,通过节点间的数据复制,可以实现:

  • 降低延迟:用户从距离自己更近的节点上获取数据
  • 提升可靠性:数据有多份副本,因此部分节点失效不影响整体服务
  • 提升吞吐量:多个节点可以同时提供服务

主从复制

主从复制型分布式系统,节点(副本)以一主多从的形态分布,从副本只负责服务读请求,所有写请求都需要由主副本服务,主副本的数据经由网络复制到从副本。

  1. 同步复制 / 异步复制

    同步与异步复制的主要区别在于是否在复制完成后才向用户响应成功。

    异步复制主要存在的问题是滞后。即由于网络延迟等原因,有的从副本先拿到数据,有的滞后很久。会产生一定时间段内的不一致。因此异步复制的数据可以称为“最终一致性”。

    同步复制通常并不现实,因为任意节点失效会导致整个系统不可写,也会导致用户等待时间过久。

    只要求同步复制到一部分从副本的方式,称为半同步,是一种折中的方法。

  2. 新加入从节点

    如果要新加入从节点,则涉及到大量的数据同步问题。可选的办法是:

    定期生成数据快照,在节点加入前先应用该快照。之后基于快照所在时间点,复制之后的数据(形象的称为“追赶”)。

  3. 节点失效

    • 从节点失效:由于多副本,少量从节点失效一般不会影响整体服务,只要在恢复后根据数据进行“追赶”即可。
    • 主节点失效:
      • 选举一个从节点提升为主节点:需要使用到一些选举算法
      • 数据一致性:晋升的从节点不一定持有最新的数据,可能造成数据丢失
      • 脑裂:原主节点恢复后仍旧认为自己是主节点,需要使其自主降级
      • 确认失效:如果无法准确判断主节点失效,就容易发生脑裂
  4. 复制日志

    • 基于语句:在主节点上执行的语句,复制到从节点执行。简单,但难以处理非确定性语句(如时间戳,自增主键,触发器等等)
    • 基于 WAL:从 WAL 中获取数据并复制,更底层、更快,但难以支持异构系统。
    • 基于逻辑行:以逻辑行的数据进行复制,可以与底层存储引擎解耦
    • 基于触发器:允许应用层通过触发器逻辑来控制具体复制哪些数据

复制滞后

最终一致性的异步数据复制,由于可能的滞后性,会导致用户在从节点上读取到旧的数据,从而产生如下问题:

  1. 写后读:

    当用户完成数据写入并响应成功后,立即读取该数据,如果数据来自某个滞后从节点,就会产生写入成功但读不到的问题。

    • 可能会被修改的数据,只从主节点读取(例如用户 Profile 只能自己修改,因此自己的 Profile 只从主节点读)
    • 可以在读取时包含一个已知最新的时间戳,如果节点自身的数据不够新,则代理给其他节点处理
  2. 单调读:

    两次读取分别被路由到了不同的节点,新一次读的节点其数据比上一次的旧,用户看来数据似乎消失了。

    • 可以通过用户 ID 来修正路由策略,使同一用户的请求都落在同一节点
  3. 前缀一致读:

    用户在不同的节点先后读到的数据,实际顺序相反,从而破坏了因果性。

    • 需要在节点之间维护某些数据的因果性,例如”Happens-before 关系“

多主复制

在相距较远的数据中心之间,由于只有一个主节点,跨数据中心的数据同步与数据访问延迟高,同时如果数据中心之间网络故障,会导致只有从节点的数据中心服务瘫痪。

在每个数据中心都设置一个主节点可以缓解上述问题。但又引入了主节点之间的数据冲突问题。

  1. 冲突检测:
    • 同步的处理冲突,如果数据同步发现了冲突,则返回错误。但这就失去了多主的优势。
    • 异步的处理冲突,当发现时请求已经成功返回,为时已晚
  2. 避免冲突:由于冲突解决不易,最好能避免冲突,例如将用户绑定在某个数据中心上避免在多个中心产生修改。
  3. 冲突状态收敛:
    • 根据时间戳或 ID,谁大谁赢:会产生数据丢失
    • 值合并:如同时写入了 B 和 C,则数据呈现为 “B / C”
    • 应用层解决:发生冲突后告知应用层

无主复制

与有主复制的形式不同,无主复制模式下,并没有某一个主节点,任意节点皆可接收写请求,而复制的过程由客户端控制,向多个节点发送。

  1. 节点失效:

    在无主模式下,节点失效并不需要做故障转移。向正常节点的写入成功返回即可认为写入成功,当故障节点恢复后,由于存在数据滞后,因此:

    • 读取数据时需要并发从多节点读取数据,以防恰好读到刚刚恢复的节点导致读到旧数据
    • 故障恢复的节点需要考虑“赶上”进度,这有两种方法:
      • 读修复:客户端读多节点发现不一致后,帮助旧节点追赶数据。此方法下不一致的时间依赖读取频率。
      • 反熵:通过后台程序扫描节点间的不一致并尝试解决。此方法下后台程序不能保证写入顺序。

    设置读写 quorum 来保证能读到新值,在 n 个副本下,写入 quorum 为 w,读取 quorum 为 r,当 w+r>n 时,一定能读取到新值,因为写入节点和读取节点之间一定有重合。

  2. 如下情况 quorum 不一定能保证读到新值:

    • 在 sloppy quorum (即为了提高可用性,在写入节点数不满足 w 时,仍然记录数据并在之后同步)下,写入和读取节点没有重合
    • 对写入冲突选择按时间戳丢弃,如果时间戳存在偏差,可能丢弃好值
    • 读和写同时发生时
    • 由于写入未能达到 w 而失败时,写入成功的节点,数据并不会回滚,因此可能读到错误的新值
    • 若节点失效后,追赶的过程中新值被旧值覆盖,则拥有新值的节点数量低于 w

    正因为上述可能的情况,读写 quorum 并不能确保“复制滞后”中的写后读、单调读、前缀一致读。如果需要更强的保证,则涉及事务和共识的问题。

  3. 检测并发写:

    在发生并发写后,Last write wins,最后写入者获胜的方案一定会丢弃一部分数据。在部分场景如缓存系统中是可以的,但若不可接受数据丢失时,则无法使用。

    • Happens-before 关系与并发:对于两操作 A 和 B,若 B 在操作时知道 A,或是依赖 A,则不是并发,否则属于并发关系
    • 通过版本确定并发关系:服务端在写入后返回所有客户端基于冲突主键的写入数据以及一个版本号,客户端下一次操作时合并所有数据,并携带版本号,实现在服务端汇集所有数据而不丢失。客户端合并操作可依赖如 CRDT 等自动合并的数据结构。
    • 版本矢量:对每一个副本的每一种冲突主键都设定唯一版本号

派生数据