数据密集型应用系统设计::数据系统基础

数据系统基础

可靠、可扩展与可维护的应用系统

  • 数据密集型应用通常基于标准模块构建而成,每个模块负责单一的常用功能
    • 数据库:用以存储数据,这样之后应用可以再次访问
    • 高速缓存:缓存那些复杂或操作代价昂贵的结果,以加快下一次访问
    • 索引:用户可以按关键字搜索数据并支持各种过滤
    • 流式处理:持续发送消息至另一个进程,处理采用异步方式
    • 批处理:定期处理大最的累积数据
  • 可靠性
    • 当出现意外情况如硬件、软件故障、人为失误等,系统应可以继续正常运转
    • 对软件典型的期望
      • 应用程序执行用户所期望的功能
      • 可以容忍用户出现错误或者不正确的软件使用方法
      • 性能可以应对典型场景、 合理负载压力和数据量
      • 系统可防止任何未经授权的访问和滥用
    • 可能出错的事情称为错误(faults)或故障
    • 系统可应对错误则称为容错(fault­ tolerant)或者弹性(resilient)
    • 容错总是指特定类型的故障,这样的系统才更有实际意义
    • 故障通常被定义为组件偏离其正常规格
    • 失效意味系统作为一个整体停止,无法向用户提供所需的服务。
    • 通过故意引发故障的方式,来持续检验、测试系统的容错机制,增加对真实发生故障时应对的信心
  • 硬件故障
    • 采用硬件冗余方案对于大多数应用场景还是足够的
    • 多机冗余则只对少最的关键应用更有意义,对于这些应用,高可用性是绝对必要的
    • 通过软件容错的方式来容忍多机失效成为新的手段,或者至少成为硬件容错的有力补充
  • 软件错误
    • 因为节点之间是由软件关联的,因而往往会导致更多的系统故障
    • 避免软件故障需要考虑很多细节
      • 认真检查依赖 的假设条件与系统之间交互
      • 进行全面的测试
      • 进程隔离
      • 允许进程崩溃并自动重启
      • 反复评估,监控并分析生产环节的行为表现
  • 人为失误
    • 人是不可靠的,该如何保证系统的可靠性呢
      • 以最小出错的方式来设计系统。
      • 想办法分离最容易出错的地方、容易引发故障的接口
      • 充分的测试: 从各单元测试到全系统集成测试以及手动测试
      • 当出现人为失误时,提供快速的恢复机制以尽最减少故障影响
      • 设置详细而清晰的监控子系统,包括性能指标和错误率
      • 推行管理流程并加以培训
  • 可靠性的重要性
    • 导致商誉下降,影响效率,营收损失
    • 即使在所谓 “非关键“ 应用中我们也应秉持对用户负责的态度
  • 可扩展性
    • 可扩展性是用来描述系统应对负载增加能力的术语
    • 随着规模的增长, 例如数据量、 流量或复杂性,系统应以合理的方式来匹配这种增长
  • 描述负载
    • 负载可以用称为负载参数的若干数字来描述
      • Web 服务器的每秒请求处理次数
      • 数据库中写入的比例
      • 聊天室的同时活动用户数量
      • 缓存命中率
  • 描述性能
    • 负载增加,但系统资源(如 CPU、内存、网络带宽等)保持不变,系统性能会发生什么变化
    • 负载增加,如果要保持性能不变,需要增加多少资源
    • 延迟与响应时间
      • 响应时间是客户端看到的:除了处理请求时间(服务时间,service time)外,还包括来回网络延迟和各种排队延迟
      • 延迟则是请求花费在处理上的时间
      • 不要将响应时间视为一个固定的数字,而是可度量的一种数值分布
    • 影响响应时间的因素
      • 上下文切换和进程调度
      • 网络数据包丢失和 TCP 重传
      • 垃圾回收暂停
      • 缺页中断和磁盘 I/O
      • 服务器机架的机械振动
    • 我们经常考察的是服务请求的平均响应时间
    • 中位数指标非常适合描述多少用户需要等待多长时间
    • 采用较高的响应时间百分位数(tail latencies, 尾部延迟或长尾效应)很重要, 因为它们直接影响用户的总体服务体验
    • 系统响应时间取决于最慢的那个服务
    • 垂直扩展(升级到更强大的机器)
    • 水平扩展(将负载分布到多个更小的机器)
    • 最近通常的做法一直是,将数据库运行在一个节点上,直到高扩展性或高可用性的要求迫使不得不做水平扩展。
    • 超大规模的系统往往针对特定应用而高度定制,架构取决于多种因素
      • 读取量、写入量
      • 待存储的数据量
      • 数据的复杂程度
      • 响应时间要求
      • 访问模式
    • 对于早期的初创公司或者尚未定型的产品,快速迭代推出产品功能往往比投入精力来应对不可知的扩展性更为重要。
  • 可维护性
    • 软件的大部分成本在于整个生命周期的持续投入
      • 开发阶段
      • 维护与缺陷修复
      • 监控系统来保持正常运行
      • 故障排查
      • 适配新平台
      • 搭配新场景
      • 技术缺陷的完善
      • 增加新功能
    • 可运维性
      • 监视系统的健康状况,并在服务出现异常状态时快速恢复服务
      • 追踪问题的原因,例如系统故障或性能下降
      • 保持软件和平台至最新状态,例如安全补丁方面
      • 了解不同系统如何相互影响,避免执行带有破坏性的操作
      • 预测未来可能的问题,并在问题发生之前即使解决(例如容量规划)
      • 建立用于部署、配置管理等良好的实践规范和工具包
      • 执行复杂的维护任务,例如将应用程序从一个平台迁移到另一个平台
      • 当配置更改时,维护系统的安全稳健
      • 制定流程来规范操作行为,并保持生产环境稳定
      • 保持相关知识的传承
    • 简单性
      • 复杂性有各种各样的表现方式
        • 状态空间的脖胀
        • 模块紧耦合
        • 令入纠结的相互依赖关系
        • 不一致的命名和术语
        • 为了性能而采取的特殊处理
        • 为解决某特定问题而引入的特殊框架
      • 消除意外复杂性最好手段之一是抽象
        • 一个好的设计抽象可用于各种不同的应用程序
        • 也带来更高质量的软件
        • 设计好的抽象还是很有挑战性
    • 可演化性
      • 一成不变的系统需求几乎没有,想法和目标经常在不断变化
      • 组织流程方面,敏捷开发模式为适应变化提供了很好的参考

数据模型与查询语言

  • 数据模型可能是开发软件最重要的部分
  • 复杂的应用程序可能会有更多的中间层
  • 每层都通过提供一个简洁的数据模型来隐藏下层的复杂性
  • 关系模型
    • 数据被组织成关系
      • 每个关系都是元组(tuples)的无序集合(在 SQL 中称为行)
      • 如果数据存储在关系表中,那么应用层代码中的对象与表、行和列的数据库模型之间需要一个笨拙的转换层
    • 查询优化器自动决定以何种顺序执行查询,以及使用哪些索引
    • 只需构建一次查询优化器,然后使用该数据库的所有应用程序都可以从中受益
  • 网络模型
    • 它也被称为 CODASYL 模型
    • 网络模型中,一个记录可能有多个父结点
    • 在网络模型中,记录之间的链接不是外键,而更像是编程语言中的指针
    • 访问记录的唯一方法是选择一条始于根记录的路径,并沿着相关链接依次访问。
    • 查询和更新数据库变得异常复杂而没有灵活性
  • 文档模型
    • 无强制模式
    • 数据的结构是隐式的,只有在读取时才解释
    • 文档通常存储为编码为 JSON、XML 或其二进制变体的连续字符串
    • 存储局部性具有性能优势
    • 局部性优势仅适用需要同时访问文档大部分内容的场景
  • NoSQL
    • Not Only SQL
    • 比关系数据库更好的扩展性需求,包括支持超大数据集或超高写入吞吐量
    • 普遍偏爱免费和开源软件而不是商业数据库产品
    • 关系模型不能很好地支持一些特定的查询操作
    • 对关系模式一些限制性感到沮丧,渴望更具动态和表达力的数据模型
  • 在可预见的将来,关系数据库可能仍将继续与各种非关系数据存储一起使用,这种思路有时也被称为混合持久化
  • 文档数据库的比较
    • 在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同
    • 相关项都由唯一的标识符引用
      • 在关系模型中被称为外键
      • 文档模型中被称为文档引用
    • 支持文档数据模型的主要论点是模式灵活性, 由于局部性而带来较好的性能
    • 关系模型则强在联结操作、多对一和多对多关系更简洁的表达上,与文档模型抗衡
    • 对于高度关联的数据,文档模型不太适合,关系模型可以胜任,而图模型则是最为自然的
  • 融合关系模型与文档模型是未来数据库发展的一条很好的途径

数据存储与检索

  • 哈希索引
    • Bitcask 默认存储引擎
    • 提供高性能的读和写,只要所有的 key 可以放入内存
    • 只需一次磁盘寻址
    • 只追加到文件末尾,不做原地更新
    • 适合每个键的值频繁更新的场景
    • 执行压缩的同时将多个段合并在一起以节省空间
    • 优点
      • 追加和分段合并主要是顺序写,它通常比随机写入快得多
      • 如果段文件是追加的或不可变的,则并发和崩溃恢复要简单得多
      • 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题
    • 局限性
      • hash 表必须全部放入内存,磁盘表现难以良好
      • 区间查询查询效率低
  • SSTables
    • 要求 key-value 对按照 key 排序
    • 每个键在每个合并的段文件中只能出现一次
    • 合并段更加简单高效
    • 在文件中查找特定的键时,不再需要在内存中保存所有键的索引
    • 在压缩块开头保存稀疏索引
    • 构建和维护 SSTables
      • 当写入时,将其添加到内存中的平衡树数据结构中(例如如红黑树)。这个内存中的树有时被称为内存表。
      • 当内存表大于某个闹值(通常为几兆字节)时,将其作为 SSTable 文件写入磁盘。由于树已经维护了按键排序的 key-value 对,写磁盘可以比较高效。新的 SSTable 文件成为数据库的最新部分。当 SSTable 写磁盘的同时 ,写入可以继续添加到一个新的内存表实例
      • 为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)
      • 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值
    • 崩溃处理 - 在磁盘上保留单独的日志,每个写入都会立即追加到该日志,每当将内存表写入 SSTable 时,相应的日志可以被丢弃
  • LSM-tree
    • Log-Structured Merge-Tree
    • 确定键不存在之前,必须先检查内存表,然后将段一直回溯访问到最旧的段文件
      • 为了优化这种访问,存储引擎通常使用额外的布隆过滤器
    • 可以支持非常高的写入吞吐量。
  • B-trees
    • 经受了长久的时间考验
    • 是几乎所有关系数据库中的标准索引实现
    • B-tree 将数据库分解成固定大小的块或页, 这种设计更接近底层硬件,因为磁盘也是以固定大小的块排列
    • 查找索引中的一个键时, 从根开始。
    • 孩子都负责一个连续范围内的键,相邻引用之间的键可以指示这些范围之间的边界。
    • 大多数数据库可以适合 3~4 层的 B-tree
    • 使 B-tree 可靠
      • B-tree 底层的基本写操作是使用新数据覆盖磁盘上的旧页, 对该页的所有引用保持不变
      • 从崩溃中恢复, 预写日志(write-ahead log, WAL),也称为重做日志
      • 每个 B-tree 的修改必 须先更新 WAL 然后再修改树本身的页
      • 多个线程要同时访问 B-tree , 注意并发控制 ,否则线程可能会看到树处于不一致的状态。通常使用锁存器(轻量级的锁)保护树的数据结构来完成
    • 优化 B-tree
      • 利用 COW 来做并发控制
      • 保存键的缩略信息,而不是完整的键,这样可以节省页空间
      • 对树进行布局,以便相邻叶子页可以按顺序保存在磁盘上
      • 添加额外的指针到树中。 例如,每个叶子页面可能会向左和向右引用其同级的兄弟页,这样可以顺序扫描键,而不用跳回到 父页
      • 分形树
  • 对比 B-tree 和 LSM-tree
    • B-tree 的 实现比 LSM-tree 的实现更为成熟
    • LSM-tree 通常对于写快
    • 而 B-tree 被认为对于读取更快。读取通常在 LSM-tree 上较慢
    • LSM-tree 的优点
      • LSM-tree 通常能够承受比 B-tree 更高的写入吞吐量
      • 它们有时具有较低的写放大
      • 它们以顺序方式写入紧凑的 SSTable 文件
      • 磁盘的顺序写比随机写要快得多
      • LSM-tree 可以支持更好地压缩,因此通常磁盘上的文件比 B-tree 小很多
      • 更少的碎片
    • LSM-tree 的缺点
      • 压缩过程有时会干扰正在进行的读写操作
      • 压缩和写入共享带宽, 数据库的数据量越大,压缩所需的磁盘带宽就越多
      • 写入高并且压缩没有仔细配置,随着未合并段的不断增加,读取会变慢
  • 其他索引结构
    • 二级索引
      • 索引中的键是查询搜索的对象
        • 实际存储的行
        • 对其他地方存储的行的引用
        • 存储行的具体文件被称为堆文件
          • 避免数据复制,实际数据只存在一个地方
          • 当新值大于旧值时,需要将数据移动到新空间,在原地保存一个指向新地址的指针
        • 将索引行直接存储在索引中,聚簇索引
          • 在某些数据库中,表的主键始终是聚簇索引,表的二级索引引用主键索引
        • 索引覆盖
          • 索引中保存了一些表的列值,刚好满足查询条件
          • 加快读取速度,更大的写开销和事物开销
    • 多列索引
      • 将几个字段按照顺序组成一个键
      • 专门的空间索引,R 树
    • 全文索引
      • Lucene
      • 采用了类似 SSTable 的索引结构
      • 内存中的索引是键中的字符序列的有限状态自动机
    • 在内存中保存所有内容
      • 用于缓存的内存数据库可以容忍丢失
      • 不能丢失的可以持久化到磁盘或者冗余到其他机器
      • 关系型数据库的数据也可以完全存在数据库
      • 使用磁盘格式的编码开销大于 KV 结构的数据库
      • 基于内存的数据库可以提供更多的数据结构
      • 更容易水平扩展
      • NVM 技术的发展
  • 事务处理与分析处理
    • ACID(原子性、一致性、隔离性和持久性)
    • OLTP 和 OLAP 对比
      属性OLTPOLAP
      主要读特征基于键,每次查询返回少量的记录对大量记录进行汇总
      主要写特征随机访问,低延迟写入用户的输入批量导入( ETL)或事件流
      典型使用场景终端用户,通过网络应用程序内部分析师,为决策提供支持
      数据表征最新的数据状态(当前时间点)随着时间而变化的所有事件历史
      数据规模GB 到 TBTB 到 PB
    • OLTP 存储引擎
      • 日志结构
      • 原地更新
    • SQL 可以同时胜任 OLAP 和 OLTP
    • 数据仓库
      • 在线的数据分析影响 LATP 性能
      • 数据仓库可以针对分析访问模式进行优化
    • 星型和雪花型分析模式
      • 星型模型
        • 模式的中心是一个所谓的事实表,事实表的每一行表示特定时间发生的事件
        • 其他列可能会引用其他表的外键,称为维度表
        • 事实表中的每一行都代表一个事件,维度通常代表事件的对象(who)、什么(what)、地点(where)、时间(when)、方法(how)以及原因(why)
      • 雪花模型
        • 在星型模型的基础上维度进一步细分为子空间
      • 在典型的数仓中,表的列非常宽,有时有几百列
    • 列式存储
      • 访问的数据通常只有少数列
      • 来自表的一列的所有值相邻存储
      • 列压缩
      • 位图编码
    • 内存带宽和矢量化处理
      • CPU 缓存
      • SIMD
    • 列存储中的排序
      • 行的存储顺序并不太重要
      • 第一列排序出现相同值时,可以指定第二列继续进行排序
      • 面向列的存储具有多个排序顺序,这有些类似在面向行的存储中具有多个二级索引
    • 列存储的写操作
      • LSM-Tree
    • 物化聚合
      • 物化视图,内容是一些查询的结果
      • 从虚拟视图查询时,SQL 引擎将其动态扩展到视图的底层查询,然后处理扩展查询
      • OLAP 立方体,由不同唯独分组的聚合网格
      • 数据立方体缺乏像查询原始数据那样的灵活性

数据编码与演化

  • 双向的兼容性
    • 较新的代码可以读取由旧代码编写的数据
    • 较旧的代码可以读取由新代码编写的数据
  • 数据编码格式
    • 在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。这些数据结构针对 CPU 的高效访问和操作进行了优化
    • 将数据写入文件或通过网络发送时,必须将其编码为某种自包含的字节序列
  • 语言特定的格式
    • 语言绑定
    • 安全问题
    • 兼容性问题
    • 性能问题
  • JSON,XML,CSV
    • 数字编码有很多模糊之处。在 XML 和 csv 中,无怯区分数字和碰巧由数字组成的字符串
    • JSON 区分字符串和数字,但不区分整数和浮点数,并且不指定精度。
    • JSON 和 XML 对 Unicode 字符串(即人类可读文本)有很好的支持,但是它们不支持二进制字符串(没有字符编码的字节序列)
    • XML 和 JSON 都有可选的模式支持
    • CSV 没有任何模式,因此应用程序需要定义每行和每列的含义
  • 二进制变体
    • 大数据集收益明显
    • MessagePack
      • 一种 JSON 的二进制编码
    • Thrift 与 Protocol Buffers
      • 需要模式来编码任意的数据
      • Thrift 与使用 Thrift 接口定义语言来描述模式
      • Protocol Buffers 使用类似模式
      • 没有字段名
      • 如果字段设置了 required,但字段未填充,则运行时检查将出现失败
    • 字段标签和模式演化
      • 字段标签(field tag)对编码数据的含义至关重要。编码永远不直接引用字段名称
      • 可以添加新的字段到模式,只要给每个字段一个新的标记号码。如果旧的代码(不知道添加的新标记号码)试图读取新代码写入的数据,包括一个它不能识别的标记号码中新的字段,则它可以简单地忽略该字段
      • 只要每个字段都有一个唯一的标记号码,新的代码总是可以读取旧的数据,因为标记号码仍然具有相同的含义
      • 为了保持向后兼容性,在模式的初始部署之后添加的每个字段都必须是可选的或具有默认值
    • Avro
      • 二进制编码格式
      • Avro IDL 用于人工编辑
      • 另一种(基于 JSON)更易于机器读取
      • 只有当读取数据的代码使用与写入数据的代码完全相同的模式肘,才能正确解码二进制数据。读和写的模式如果有任何不匹配 都将无法解码数据
      • 模式演化
        • 在不同的上下文环境中保存单一的模式
    • 模式的优点
      • 它们可以比各种“二进制 JSON”变体更紧凑,可以省略编码数据中的宇段名称。
      • 模式是一种有价值的文档形式,因为模式是解码所必需的,所以可以确定它是最新的
      • 模式数据库允许在部署任何内容之前检查模式更改的向前和向后兼容
      • 对于静态类型编程语言的用户来说,从模式生成代码的能力是有用的,它能够在编译时进行类型检查
  • 数据流模式
    • 进程间数据流动的方式
      • 通过数据库
      • 通过服务调用
      • 通过异步消息传递
  • 基于数据库的数据流
    • 服务版本不一致
    • 向前兼容,旧版本的代码不处理新版本加入的值
    • 不同时间写入不同的值导致字段丢失
    • 创建归档时使用统一的编码
  • 基于服务的数据流
    • REST 和 RPC
    • 服务器公开的 API 称为服务
    • 服务器和客户端使用的数据编码必须在不同版本的服务 API 之间兼容
    • 网络服务
      • 运行在用户设备上的客户端应用程序,通过 HTTP 向服务发出请求, 这些请求通常通过公共互联网进行
      • 一种服务向同一组织拥有的另一项服务提出请求,这些服务通常位于同一数据中心内 ,作为面向服务/微型架构的一部分。支持这种用例的软件有时被称为中间件
      • 一种服务向不同组织所拥有的服务提出请求,经常需通过互联网 。这用于不同组织后端系统之间的数据交换。此类别包括由在线服务(如信用卡处理系统)提供的公共 API,或用于共享访问用户数据的 OAuth
      • 有两种流行的 Web 服务方方法 : REST 和 SOAP
        • REST
          • 它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制、身份验证和内容类型协商
        • SOAP
          • 基于 XML 的协议,用于发出网络 API 请求
          • SOAP Web 服务的 API 使用被称为 WSDL
          • 过于复杂, 无法手动构建,SOAP 用户严重依赖工具支持、代码生成和 IDE
      • 远程过程调用(RPC)的问题
        • 结果不可预测
        • 服务幂等
        • 网络波动
        • 大对象编码解析
        • 不同的语言的支持问题
      • RPC 的发展方向
        • 封装可能失败的异步操作
        • 并行请求多项服务
        • 服务发现
      • RPC 方案的向后和向前兼容性属性取决于它所使用的具体编码技术
  • 基于消息传递的数据流
    • 如果接收方不可用或过载,它可以充当缓冲区,从而提高系统的可靠性。
    • 它可以自动将消息重新发送到崩溃的进程,从而防止消息丢失。
    • 它支持将一条消息发送给多个接收方
    • 它在逻辑上将发送方与接收方分离
    • 消息代理
      • 一个进程向指定的队列或主题发送消息,并且代理确保消息被传递给队列或主题的一个或多个消费者或订阅者
      • 在同一主题上可以有许多生产者和许多消费者
      • 主题只提供单向数据流
      • 消息代理通常不会强制任何特定的数据模型
  • 分布式 Actor 框架
    • Actor 模型是用于单个进程中并发的编程模型
    • 逻辑被封装在 Actor 中,而不是直接处理线程
    • 每个 Actor 通常代表一个客户端或实体,它可能具有某些本地状态(不与其他任何 Actor 共享)
    • 它通过发送和接收异步消息与其他 Actor 通信。
    • 不保证消息传送: 在某些错误情况下,消息将丢失。
    • 由于每个 Actor 一次只处理一条消息,因此不需要担心线程,每个 Actor 都可以由框架独立调度。
    • 三种流行的分布式 Actor 框架处理消息编码的方式
      • 默认情况下,Akka 使用 Java 的内置序列化,它不提供向前或向后兼容性。但是,可以用类似 Protocol Buffers 的东西替代它,从而获得滚动升级的能力
      • 默认情况下, Orleans 使用不支持滚动升级部署的自定义数据编码格式:要部署新版本的应用程序,需要建立一个新的集群,将流量从旧集群导入到新集群,然后关闭旧集群。像 Akka 一样,也可以使用自定义序列化插件。
      • 在 Erlang OTP 中,很难对记录模式进行更改, 滚动升级在技术上是可能的,但要求仔细规划。