写在前面:对DDIA这本书慕名已久,粗看书里的一些知识都或多或少了解,但仔细阅读下来,还是缺少对细节的认识。目前看了四个章节,这本书一直在围绕两个问题:是什么和为什么,来做阐述,针对工业界已有的技术和存在的问题分析的非常细致,让我时常有种恍然大悟的感觉,对各种知识之间的关联讲述的非常到位。所以写下每章要点的笔记,时常回顾时常新
第1章 可靠、可扩展与可维护的应用系统
Redis既可以用于数据存储也适用于消息队列,Apache Kafka作为消息队列也具备了持久化存储保证
需要将任务分解,每个组件负责高效完成其中一部分,多个组件依靠应用层代码驱动有机衔接起来。
关注对大多数软件系统都极为重要的三个问题: 可靠性、可扩展性、可维护性
- 可靠性:
- 容错总是指特定类型的故障
- 通过随机杀死某个进程,这种故意引发故障的方式,来持续检验、测试系统的容错机制
- 硬件故障:硬件冗余方案、软件容错(当需要重启计算机时为操作系统打安全补丁,可以每次给一个节点打补丁然后重启,而不需要同时下线整个系统(即滚动升级))
- 软件错误:节点之间是由软件关联的,因而往往会导致更多的系统故障。认真检查依赖的假设条件与系统之间交互
- 人为失误:抽象层、沙箱、测试、快速恢复、监控子系统、管理培训
- 可扩展性:
- 系统应对负载增加的能力
- 什么是负载?QPS、数据库写入比例、聊天室的同时活动用户数量、缓存命中率。具体系统瓶颈取决于平均值,或者峰值
- TP50、TP99、TP999:满足百分之**的网络请求得到响应所需要的最低耗时值是多少
- 考虑每增加一个数量级的负载,架构应如何设计
- 如何在垂直扩展(即升级到更强大的机器)和水平扩展(即将负载分布到多个更小的机器)之间做取舍
- 把无状态服务分布然后扩展至多台机器相对比较容易,而有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加
- 可维护性:
- 可运维性、简单性、可演化性
- 抽象、可操作性
第2章 数据模型与查询语言
每层都通过提供一个简洁的数据模型来隐藏下层的复杂性
- 关系模型与文档模型
- SQL:数据被组织成关系,在SQL中称为表,其中每个关系都是元组的无序集合
- 关系模型的目标就是将实现细节隐藏在更简洁的接口后面。
- NoSQL:不仅仅是SQL,支持超大数据集,支持一些特定的查询操作,混合持久化。
- 对象-关系映射(ORM):应用层中的对象与数据库模型之间的转换层。模型之间的脱离有时称为阻抗失谐
- 使用ID的好处:ID对人类没有意义,ID的具体内容改变不影响其他副本
- 网络模型:记录之间的链接像指针,访问记录的方法是选择一条始于根记录的路径,沿着链接依次访问。像在一个n维数据空间中进行遍历
- 关系模型:定义了所有数据的格式:关系(表)只是元组(行)的集合,没有访问路径。解决多对多关系。
- 文档模型:是某种方式的层次模型,即在其父记录中保存了嵌套记录(一对多关系),而不是存储在单独的表中。多对一和多对多关系与关系模型没区别,相关项由唯一标识符引用:外键或文档引用。
- 数据查询语言
- 每个数据模型都有自己的查询语言或框架
- 声明式查询语言(SQL)很有吸引力,它比命令式API更加简洁和容易使用。但更重要的是,它对外隐藏了数据库引擎的很多实现细节
- SQL不保证顺序,而数据库无法确定命令式代码是否依赖于排序
- SQL适合于并行执行,主要通过增加核数。命令式代码由于指定了特定的执行顺序,很难在多核和多台机器上并行化
- MapReduce:SQL的分布式实现,如MongoDB
- 图状数据模型
- 属性图模型:可以将图存储看作由两个关系表组成,一个用于顶点,另一个用于边。图有利于演化:向应用程序添加功能时,图可以容易地扩展以适应数据结构的不断变化
- Cypher查询语言:用于属性图的声明式查询语言,对于声明式查询语言,查询优化器会自动选择效率最高的执行策略
- SQL中的图查询:采用递归公用表表达式
- 三元存储模型:所有信息都以非常简单的三部分形式存储(主体,谓语,客体)。主体相当于图中的顶点,客体是原始数据类型中的value(谓语是key),或图中的另一个顶点(谓语是边)
- RDF资源描述框架:它让不同网站以一致的格式发布数据,这样来自不同网站的数据自动合并成一个数据网络,一种互联网级别包含所有数据的数据库。三元组的主体、谓语和客体通常是URI。
- SPARQL是一种采用RDF数据模型的三元存储查询语言。
- Datalog的数据模型类似于三元存储模式,但更为通用一些。它采用“谓语 (主体,客体) ”的表达方式,而不是三元组
- 属性图模型:可以将图存储看作由两个关系表组成,一个用于顶点,另一个用于边。图有利于演化:向应用程序添加功能时,图可以容易地扩展以适应数据结构的不断变化
第3章 数据存储与检索
- 数据库核心:数据结构。简单的做法是用log记录,set方法通过追加实现,get方法则需要O(n)。而索引可以加速读取查询,但每个索引都会减慢写速度(写数据要更新索引)。
- 哈希索引:哈希表实现
- 只追加到一个文件,那么如何避免最终用尽磁盘空间?一个好的解决方案是将日志分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中。然后可以在这些段上执行压缩,压缩意味着在日志中丢弃重复的键,并且只保留每个键最近的更新。
- 每个段现在都有自己的内存哈希表,将键映射到文件的偏移量。为了找到键的值,首先检查最新的段的hash map;如果键不存在,检查第二最新的段,以此类推。由于合并过程可以维持较少的段数量,因此查找通常不需要检查很多hash map。
- 实现细节:
- 文件格式:使用二进制格式,以字节为单位记录字符串长度,之后再跟上原始字符串
- 删除记录:通过墓碑标记,合并日志段时丢失已删除的kv
- 崩溃恢复:将每个段的hash map的快照存储在磁盘上,可以更快地加载到内存中,以此加快恢复速度
- 部分写入的记录:通过校验值将损坏部分丢弃
- 并发控制:一个写线程,多个读线程
- 追加而不是原地更新:
- 追加和分段合并是顺序写,比随机写快
- 在并发和崩溃恢复时要简单,不用担心重写值时发生崩溃
- 合并旧段可以避免数据文件久而久之出现碎片化
- 局限:
- 哈希表必须放入内存,如果key很多会有问题,若放入磁盘,则需要大量的随机访问I/O
- 区间查询效率不高
- SSTable排序字符串表:kv按照key排序
- 合并段更高效:类似合并排序
- 稀疏地保存所有key的索引
- 将稀疏索引指向的一个块放入同一个段文件中,并在写磁盘前压缩
- 既然是排序的key,那就可以通过红黑树或AVL树来维护
- 写入时添加到内存表(树)中,当内存表大于某个阈值时,将其作为SSTable文件写入磁盘,此时的新数据可以添加到新的内存表中。
- 读取时先查找内存表中的key,然后是最新的段文件,再是次新,直到找到目标
- 后台进程执行合并与压缩
- 问题:如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。
- 解决:在磁盘上保留单独的日志,每个写入都会立即追加到该日志,不需要排序,唯一的目的是崩溃恢复。
- LSM日志结构合并树:基于合并和压缩排序文件原理的存储引擎,本质就是SSTables
- 性能优化:
- 查找某个不存在key:一直访问到最旧的段文件,导致多次磁盘IO,可以使用布隆过滤器解决
- 大小分级:较新的和较小的SSTables被连续合并到较旧和较大的SSTables
- 分层压缩:键的范围分裂成多个更小的SSTables, 旧数据被移动到单独的“层级”,这样压缩可以逐步进行并节省磁盘空间
- 优点:
- 数据集可以远大于内存
- 区间查询:key是顺序的
- 顺序写入:支持非常高的写入吞吐量
- 性能优化:
- B-trees:与LSM是将数据库分解成可变大小的段不同,B-tree将数据库分解成固定大小的块或页,一般为4KB
- 分支因子为500的4 KB页的四级树可以存储高达256 TB
- 写操作:原地更新覆盖旧页,LSM是追加更新
- WAL(write-ahead log,预写日志):追加修改,每个B-tree的修改必须先更新WAL然后再修改树本身的页,用于崩溃恢复
- 并发控制:使用latch(轻量级锁),而LSM则在后台执行合并,用新段原子地替换旧段
- 优化:
- 写时复制:修改的页被写入不同的位置,树中父页的新版本被创建,并指向新的位置。而不是使用覆盖页和WAL来进行崩溃恢复
- 保存key的缩略信息:节省页空间
- 对树布局,以便相邻子页按顺序保存在磁盘上:减少磁盘IO。而LSM在合并过程中一次重写大量存储段,更容易让连续的key在磁盘上靠近
- 叶子页面对兄弟页添加额外的指针
- 变体如分形树:借鉴了一些日志结构的想法来减少磁盘寻道
- LSM-tree通常对于写入更快,而B-tree被认为对于读取更快。读取通常在LSM-tree上较慢,因为它们必须在不同的压缩阶段检查多个不同的数据结构和SSTable。
- LSM-tree优点:
- (写放大):一次数据库写入请求导致的多次磁盘写,如B-tree一次写WAL,一次写树的页本身;LSM-tree由于反复压缩和SSTable的合并,日志结构索引也会重写数据多次。性能瓶颈很可能在于数据库写入磁盘的速率。
- LSM-tree更高的写入吞吐量:因为具有较低的写放大、且顺序写快于随机写
- LSM-tree支持更好地压缩:定期重写SSTables以消除碎片化
- LSM-tree缺点:
- 压缩过程干扰正在进行的读写操作:磁盘并发资源有限
- 磁盘的有限写入带宽需要在初始写入(记录并刷新内存表到磁盘)和后台运行的压缩线程之间所共享
- B-tree的优点则是每个键都恰好唯一对应于索引中的某个位置,而日志结构的存储引擎可能在不同的段中具有相同键的多个副本。在许多关系数据库中,事务隔离是通过键范围上的锁来实现的,并且在B-tree索引中,这些锁可以直接定义到树中
- 内存kv存储:主要用于缓存,通过电池供电的内存、将更改记录写入磁盘、将定期快照写入磁盘、同步内存状态到其他机器
- 反缓存:当没有足够的内存时,通过将**最近最少使用(LRU)**的数据从内存写到磁盘,并在将来再次被访问时将其加载到内存。
- 哈希索引:哈希表实现
- 事务处理与分析处理
- 将数据导入数据仓库的过程称为提取-转换-加载(Extract-Transform-Load, ETL)
- 使用单独的数据仓库而不是直接查询OLTP系统进行分析,很大的优势在于数据仓库可以针对分析访问模式进行优化
- 星型模式:当表关系可视化时,事实表位于中间,被一系列维度表包围
- 雪花模式:其中维度进一步细分为子空间。
- 列式存储
- 将每列中的所有值存储在一起,如果每个列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列,这可以节省大量的工作
- 列压缩:位图->游程编码
- 列存储排序:指定第一个排序键、第二个排序键···。排序后便于压缩,如游程编码
- 写操作:LSM-tree
- 物化视图:缓存查询最常使用的一些计数或总和
第4章 数据编码与演化
- 数据编码格式:
- 内存中,通常保存在各种数据结构中;文件或网络中,通常编码为自包含的字节序列。从内存到字节序列的转化称为序列化,相反则是反序列化
- 语言内置编码方案的问题:
- 编码通常与特定的编程语言绑定在一起
- 解码过程需要能够实例化任意的类,攻击者可以让应用程序解码任意的字节序列,那么它们可以实例化任意的类,这通常意味着可以远程执行任意代码
- 经常忽略向前和向后兼容性等问题
- 次要的效率问题
- JSON、XML、CSV的问题:
- 数字编码:精确度、无法区分
- 不支持二进制字符串
- 硬编码适当的编解码逻辑
- Thrift和Protocol Buffers
- 有相应的代码生成工具,应用程序可以直接调用生成的代码来编解码
- Thrift:BinaryProtocol、CompactProtocol
- 向前兼容性:旧代码读取新代码写入的数据,可以忽略不能识别的标记号码(field tag)
- 向后兼容性:新代码读取旧代码写入的数据,因为标记号码仍然具有相同的含义,在模式的初始部署之后添加的每个字段都必须是可选的或具有默认值
- PB字段有三种标记:required、optional、repeated,没有列表或数组类型,如图4-4所示,对于重复字段,表示同一个字段标签只是简单地多次出现在记录中
- Thrift有列表类型,支持嵌套
- Avro:支持Hadoop
- 省略,待补,P118
- 模式(PB、Thrift、Avro)
- 模式演化,或者理解为版本更新,可以通过字段的field tag来维护
- 支持更详细的验证规则
- 数据流模式
- 当通过网络发送数据或者把它写入文件时,都需要将数据编码为字节序列。
- 基于数据库的数据流
- 不同的时间写入不同的值,大多数关系数据库允许进行简单的模式更改,例如添加具有默认值为空的新列,而不重写现有数据,除了MySQL
- 归档存储,即快照
- 基于服务的数据流:REST和RPC(其中一个进程通过网络向另一个进程发送请求,并期望尽快得到响应)
- 客户端和服务端
- 微服务体系结构:服务器本身可以是另一项服务的客户端(例如,典型的Web应用服务器作为数据库的客户端)。这种方法通常用于将大型应用程序按照功能区域分解为较小的服务,这样当一个服务需要另一个服务的某些功能或数据时,就会向另一个服务发出请求
- 设计目标:通过使服务可独立部署和演化,让应用程序更易于更改和维护。
- Web服务:REST(基于HTTP)和SOAP(基于XML)
- RPC模型试图使向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)
- 网络请求不可预测,如网络问题
- 可能由于超时无法知道请求是否成功
- 重复请求:建立重复数据消除(幂等性)机制
- 时延不同
- 序列化过程中对于较大的对象可能出现问题
- 客户端和服务端的语言gap
- 新一代的RPC框架更加明确了远程请求与本地函数调用不同的事实,gRPC支持流,其中调用不仅包括一个请求和一个响应,还包括一段时间内一系列的请求和响应;支持服务发现
- REST利于调试,支持所有语言平台,RPC框架主要侧重于同一组织内多项服务之间的请求,通常发生在同一数据中心内。
- 假定所有的服务器都先被更新,其次是所有的客户端。因此,只需要在请求上具有向后兼容性,而在响应上具有向前兼容性
- Thrift、gRPC、Avro RPC根据自己的编码格式规则进行演化
- SOAP,请求和响应都基于XML来演化
- REST使用JSON,添加可选的请求参数、在响应中添加新的字段
- 基于消息传递的数据流(单向,响应是在独立的通道上完成,是异步的)
- RPC和数据库之间的异步消息传递系统
- 消息代理的优点:
- 充当缓冲区
- 自动重发,防止丢失
- 避免发送方知道接收方IP和port
- 支持发送给多个接收方
- 将发送方和接收方分离
- 一个进程向指定的队列或主题发送消息,并且代理确保消息被传递给队列或主题的一个或多个消费者或订阅者。在同一主题上可以有许多生产者和许多消费者
- Actor模型:用于单个进程中并发的编程模型
- 每个Actor通常代表一个客户端或实体,它可能具有某些本地状态(不与其他任何Actor共享),并且它通过发送和接收异步消息与其他Actor通信。每个Actor一次只处理一条消息,假定消息可能丢失
- 分布式Actor框架:用来跨越多个节点来扩展应用程序,实质上是将消息代理和Actor编程模型集成到单个框架中
- 仍需担心向前和向后兼容性问题(滚动升级),因为消息可能会从运行新版本的节点发送到运行旧版本的节点