设计数据密集型应用阅读笔记
- 设计数据密集型应用
-
- 可靠性、可伸缩性、可维护性
- 数据模型与查询语言
- 存储与检索
- 编码与演化
- 分布式数据
- 事务
- 分布式系统问题
- 流处理
- 批处理
-
- 分布式批处理框架需要解决的两个主要问题
- [MapReduce 的连接算法](#MapReduce 的连接算法)
设计数据密集型应用
可靠性、可伸缩性、可维护性
数据密集型应用
- 对于大多数应用,CPU 很少成为这类应用的瓶颈,更大的问题通常来自数据量、数据复杂性、以及数据的变更速度
数据系统
-
数据存储工具与数据处理工具
-
类别之间的界限变得越来越模糊
-
数据存储可以被当成消息队列用(Redis),消息队列则带有类似数据库的持久保证(Apache Kafka)
-
-
总体工作被拆分成一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来
设计数据系统要解决的问题
-
可靠性
-
困境下等仍能正常工作
-
硬件故障
- 硬盘崩溃、内存出错、机房断电、有人拔错网线
-
软件故障
-
rpc不可用
-
输入错误,共享资源占用
-
-
人为错误
-
以最小化犯错机会的方式设计系统
-
沙箱,就是预发测试
-
自动化测试,边缘场景测试
-
人为错误中简单快速地恢复,switch开关
-
遥测,就是配置监控预警
-
-
-
相比于预防,更倾向于容忍错误
-
-
可伸缩性
-
有合理的办法应对系统的增长(数据量 流量 复杂性)
-
服务 降级的一个常见原因是负载增加
-
负载的描述
- 取决于系统架构,可以是qps,也可以是wps(数据库写入),活跃用户数量
-
弹性与手动伸缩
-
检测到负载增加时自动增加计算资源
-
手动伸缩
-
-
-
可维护性
-
系统保持现有行为,并适应新的应用场景
-
可操作性
- 便于运维团队保持系统平稳运行。
-
简单性
-
从系统中消除尽可能多的 复杂度,复杂问题简单化,消除 额外的复杂度
-
消除 额外复杂度 的最好工具之一是 抽象
-
-
可演化性
- 使工程师在未来能轻松地对系统进行更改,当需求变化时为新应用场景做适配
-
数据模型与查询语言
前言
-
数据模型影响着我们的解题方式,设计数据模型需考虑模型在场景中的大多应用场景设计。多数应用使用层层叠加的数据模型构建,各有其设计哲学、适用场景和性能特征
-
关系模型
-
文档模型
-
图模型
-
键值模型
-
对象模型
-
列族模型
-
数据模型应用流程
-
建模:采用对象或数据结构,以及操控那些数据结构的 API 来进行建模
-
存储:可以利用通用数据模型来表示它们,如 JSON 或 XML 文档、关系数据库中的表或图模型
- 比如es,数据存储是文档形式存储的
-
表示与操作:如何以内存、磁盘或网络上的字节来表示数据,并去操作查询搜索数据
- 但是es的数据查询是基于倒排索引做的查询
中间层次
-
如数据模型的层层嵌套,api套api,基本思想一致。
-
每个层都通过提供一个明确的数据模型来隐藏更低层次中的复杂性,这些抽象允许不同的人群有效地协作
数据模型
-
关系模型
-
SQL:数据被组织成关系,sql称作表,适用于事务处理,批处理。典型的就是mysql
-
当今大部分应用就是基于关系模型的
-
-
文档模型
-
层次模型:父记录中存储嵌套记录,比如MongoDB的类Json的Bson存储方式
- 优先嵌入,必要时引用
-
NOSQL(不仅是 SQL)
-
需要比关系数据库更好的可伸缩性,包括非常大的数据集或非常高的写入吞吐量
-
关系模型不能很好地支持一些特殊的查询操作,比如搜索引擎
-
受挫于关系模型的限制性
-
-
关系数据库会继续与各种非关系数据库一起使用 - 这种想法有时也被称为 混合持久化
- 比如一件商品,在es存储用于搜索,redis存储是因为是热点商品,mysql作为底层兜底数据
-
阻抗不匹配
-
关系型数据库(RDBMS) 面向对象编程(OOP)之间需要一个笨拙的转化层
-
ORM框架对象关系映射可以减少代码量,但难以完全隐藏
-
-
-
关系型vs文档型
-
在关系数据库中,通过 ID 来引用其他表中的行是正常的,因为连接很容易left join。在文档数据库中,一对多树结构没有必要用连接,对连接的支持通常很弱
-
文档数据库(如 MongoDB、Couchbase)以嵌套的、自包含的文档为基本单位存储数据,其设计初衷是避免跨文档关联。
-
随着功能添加到应用程序中,数据会变得更加互联。公司-简历-学校
-
-
数据类似文档结构,一对多关系树,一次加载整个树,用关系型拆解表会把问题复杂化
- 文档型也有局限性,嵌套过深不合适
-
用到用到多对多关系,就不适合文档型存储了
- 可以通过反规范化来消除对连接的需求,但这需要应用程序代码来做额外的工作以确保数据一致性,复杂化了
-
关系模型和文档模型的混合是未来数据库一条很好的路线。
-
-
网状模型
-
网状模型记录关系的类似于指针,称为访问路径
-
多对多关系中,多条不同路径可以达到相同的记录
-
利用遍历记录列和跟随访问路径表在数据库中移动游标来执行
-
查询和更新数据库的代码变得复杂不灵活
-
-
关系数据库中,查询优化器自动决定查询的哪些部分以哪个顺序执行,以及使用哪些索引。这些选择实际上是 "访问路径",但最大的区别在于它们是由查询优化器自动生成的
-
-
图数据模型
-
关系模型可以处理多对多关系的简单情况,但是随着数据之间的连接变得更加复杂,将数据建模为图形显得更加自然
-
可以在关系数据库中表示图数据
-
在关系数据库中,你通常会事先知道在查询中需要哪些连接。在图查询中,你可能需要在找到待查找的顶点之前,遍历可变数量的边。也就是说,连接的数量事先并不确定。
-
-
图由顶点和变组成
-
可以将那些众所周知的算法运用到这些图上
-
最短路径
-
PageRank
-
-
Cypher 是属性图的声明式查询语言
-
查询
-
声明式 查询语言
-
SQL
-
只需指定所需数据的模式 - 结果必须符合哪些条件,以及如何将数据转换(例如,排序,分组和集合) - 但不是如何实现这一目标
-
更合理,数据库优化器选择如何执行
-
-
命令式 查询语言(类似于for循环找到符合的返回)
-
IMS
-
CODASYL
-
命令式语言告诉计算机以特定顺序执行某些操作。
-
-
MapReduce查询
-
查询的逻辑用代码片段来表示,这些代码片段会被处理框架重复性调用。它基于 map(也称为 collect)和 reduce(也称为 fold 或 inject)函数,两个函数存在于许多函数式编程语言中
-
map 发出的键值对按键来分组。对于具有相同键(即,相同的月份和年份)的所有键值对,调用一次 reduce 函数。
-
reduce 函数将特定月份内所有观测记录中的动物数量相加。
-
-
存储与检索
数据库最基础的两个功能
了解并选择合适的存储引擎
程序员来说,需要从许多可用的存储引擎中选择一个合适的,为了让存储引擎能在你的工作负载类型上运行良好,你也需要大致了解存储引擎在底层究竟做了什么
索引
-
散列索引 (Redis)
-
当你想查找一个值时,使用散列映射来查找数据文件中的偏移量,寻找(seek) 该位置并读取该值即可。
-
散列表必须能放进内存,硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O
-
范围查询效率不高,只能遍历
-
-
SSTables
-
排序字符串表(Sorted String Table)
-
对散列格式中的对段文件的格式修改,要求键值对的序列按键排序。
-
实际的键值对数据,按 key 排序,分块存储
-
布隆过滤器(Bloom Filter),用于快速判断 key 是否不存在
-
每个 Data Block 的起始 key 和偏移量,用于二分查找定位 block
-
-
优点
-
合并段更高效,归并算法
-
可以根据其他同一个段的偏移计算目标数据的偏移量
-
-
典型应用:LevelDB、RocksDB、Cassandra、Bigtable 等 LSM-Tree
-
-
对比
-
Hash KV:追求 O(1) 点查性能,但不支持范围查询;
- graph LR
A[Key="user:1001"] --> B[Hash(key)]
B --> C[定位桶 ID]
C --> D[读取桶所在磁盘页]
D --> E[线性扫描匹配 key]
E --> F{匹配?}
F -- 是 --> G[返回 value]
F -- 否 --> H[返回 not found]
- graph LR
-
SSTable:牺牲一点点查速度,换取有序性、压缩效率、高写吞吐和范围查询能力。
- graph TD
A[Key="user:1001"] --> B{Bloom Filter?}
B -- 不存在 --> C[跳过此 SSTable]
B -- 可能存在 --> D[二分查 Index Block]
D --> E[定位 Data Block]
E --> F[读取 Data Block]
F --> G[在 Block 内查找 key]
G --> H{找到?}
H -- 是 --> I[返回 value]
H -- 否 --> J[查下一层 SSTable]
- graph TD
-
-
B树
-
标准的索引实现
-
查找、更新、新增
-
从根开始,向下根据范围查找,递归下降,达到叶子节点
-
先查找,然后更新叶子页,更新磁盘
-
新增:定位插入位置,若溢出则页分裂
-
-
b+树区别是叶子节点存储全部数据,相对于b树,减少了叶子节点存储索引,然后再根据索引再找一次的流程
- 参考mysql,其中还有各种log日志,wal,锁等机制
-
-
次级索引
- 类似mysql的二级索引
-
多列索引
- 就是类似mysql的多字段联合索引
-
全文搜索和模糊索引
-
Lucene 搜索引擎
-
倒排索引
-
事务性OLTP&分析性OLAP
-
数据仓库
-
OLTP
- 高可用 与 低延迟
-
OLAP
-
抽取 - 转换 - 加载
-
高效地存储和查询
-
表通常超过 100 列,但典型的数据仓库查询一次只会访问其中 4 个或 5 个列
-
不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列,这可以节省大量的工作。
-
可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩。
- 重复率高,使用不同的压缩技术
-
编码与演化
演化
-
应该尽力构建能灵活适应变化的系统
-
关系型数据库通常假定遵循模式,即建表后不再执行ALTER语句,对比之下文档型数据库模式灵活度更好
-
格式或者模式发生变化,需要代码更改
-
服务端:滚动升级
-
客户端:用户更新应用程序
-
向后兼容:新的代码兼容旧代码写入的数据
-
向前兼容:旧代码可以读取新代码写入的数据(忽略新增部分)
-
编码
-
内存中,数据保存在数据结构 文件中
-
讲数据写入文件需要编码从内存中表示到字节序列的转换称为 编码/序列化,反之是反序列化
-
考虑
-
编码通常与特定的编程语言深度绑定
-
实例化任意类 的能力
-
效率:比如java的Serializable效率贼差
-
-
JSON,XML 和 CSV 文本格式
- 对很多需求来说已经足够好了
-
组织内部使用的数据,使用最小公约数式的编码格式压力较小
- 达到 TB 级别,数据格式的选型就会产生巨大的影响
-
由于json xml太占空间,大量二进制编码版本 JSON(MessagePack、BSON、BJSON、UBJSON、BISON 和 Smile 等) 和 XML(例如 WBXML 和 Fast Infoset)的出现。
-
像 Thrift、Protocol Buffers 和 Avro 这样的二进制模式驱动格式允许使用清晰定义的向前和向后兼容性语义进行紧凑、高效的编码。这些模式可以用于静态类型语言的文档和代码生成。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
-
数据流
-
数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码
-
RPC 和 REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码
-
异步消息传递(使用消息代理或参与者),其中节点之间通过发送消息进行通信,消息由发送者编码并由接收者解码
-
分布式数据
复制
-
目的
-
高可用性
- 单机停机的情况下也能保持系统正常运行
-
断开连接的操作
- 允许应用程序在网络中断时继续工作
-
延迟
- 将数据放置在地理上距离用户较近的地方,以便用户能够更快地与其交互
-
可伸缩性(吞吐)
- 通过在副本上读,能够处理比单机更大的读取量
-
-
方法
-
单主复制
-
写入操作发送到单个节点
-
将数据更改事件流发送到其他副本,读取可以在任何副本上执行
-
-
多主复制
- 客户端将每个写入发送到几个主库节点之一,其中任何一个主库都可以接受写入。主库将数据更改事件流发送给彼此以及任何从库节点。
-
无主复制
- 客户端将每个写入发送到几个节点,并从多个节点并行读取,以检测和纠正具有陈旧数据的节点。
-
-
复制可以是同步的,也可以是异步的,这在发生故障时对系统行为有深远的影响。
-
复制延迟引起的奇怪效应
-
用户应该总是能看到自己提交的数据。
-
用户在看到某个时间点的数据后,他们不应该再看到该数据在更早时间点的情况。
-
用户应该看到数据处于一种具有因果意义的状态:例如,按正确的顺序看到一个问题和对应的回答。
-
分区
-
大数据集划分成更小的子集,分区的目标是在多台机器上均匀分布数据和查询负载,避免出现热点(负载不成比例的节点)。这需要选择适合于你的数据的分区方案,并在将节点添加到集群或从集群删除时重新平衡分区。
-
分区方法
-
键范围分区:键是有序的,并且分区拥有从某个最小值到某个最大值的所有键。排序的优势在于可以进行有效的范围查询,应用程序经常访问相邻key存在热点风险
-
散列分区:散列函数应用于每个键,分区拥有一定范围的散列。破坏了排序,不利于范围查询,但是负载好
-
混合策略:复合主键:使用键的一部分来标识分区,而使用另一部分作为排序顺序。
-
-
次级索引和分区
-
基于文档分区(本地索引):次级索引存储在与主键和值相同的分区中,写入只需更新一个分区索引,读取麻烦
- 如果你想通过次级索引查询(比如"找所有 email 为 xxx 的用户"),系统不知道这个 email 在哪个分区,所以必须向所有分区发送查询请求(scatter),然后汇总结果(gather)------这就是"分散/收集"(scatter/gather)操作,开销大、延迟高。
-
基于关键词分区:次级索引本身也独立分区,通常是按次级索引的字段值(关键词)来分区。
-
写入一条新记录时,不仅要写主数据分区,还要更新次级索引所在的另一个分区(甚至多个,如果索引有多个字段)。这涉及跨分区事务或协调,增加写入延迟和复杂性。
-
查询时,根据次级索引字段(如 email)可以直接定位到一个特定的索引分区,只需读一个分区就能拿到结果(可能包含多个主键),再根据主键去对应数据分区取完整记录。
-
-
事务
acid
-
一致性
- 对数据的一组特定约束必须始终成立,即 不变式(invariants)。比如会计系统收支必须时刻保持抵消的状态
-
隔离性
- 同时执行的事务是相互隔离的,比如mysql的四种隔离机制
-
持久性
- 持久性 是一个承诺,即一旦事务成功完成,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失
-
原子性
-
在一系列写操作的中途发生错误,则应中止事务处理,并丢弃当前事务的所有写入(回滚)。
-
原子性与2PC
-
在单个数据库节点执行的事务,原子性通常由存储引擎实现
-
一个事务中涉及多个节点或一个数据库事务中同时操作多个独立的数据项时
-
仅向所有节点发送提交请求并独立提交每个节点的事务是不够的。这样很容易发生违反原子性的情况:提交在某些节点上成功,而在其他节点上失败:
-
两阶段提交是实现跨多个节点的原子事务提交的算法
- 2PC 事务以应用在多个数据库节点上读写数据开始。我们称这些数据库节点为 参与者(participants)。当应用准备提交时,协调者开始阶段 1 :它发送一个 准备(prepare) 请求到每个节点,询问它们是否能够提交。然后协调者会跟踪参与者的响应:
-
-
-
如果所有参与者都回答 "是",表示它们已经准备好提交,那么协调者在阶段 2 发出 提交(commit) 请求,然后提交真正发生。
如果任意一个参与者回复了 "否",则协调者在阶段 2 中向所有节点发送 中止(abort) 请求。
- 更多详见mysql脑图
分布式系统问题
延迟
- 当你尝试通过网络发送数据包时,数据包可能会丢失或任意延迟。同样,答复可能会丢失或延迟,所以如果你没有得到答复,你不知道消息是否发送成功了。
时钟倒转
- 节点的时钟可能会与其他节点显著不同步(尽管你尽最大努力设置 NTP),它可能会突然跳转或跳回,依靠它是很危险的,因为你很可能没有好的方法来测量你的时钟的错误间隔。
节点状态
- 一个进程可能会在其执行的任何时候暂停一段相当长的时间(可能是因为停止所有处理的垃圾收集器),被其他节点宣告死亡,然后再次复活,却没有意识到它被暂停了。
解决
-
检测:大多数分布式算法依靠 超时 来确定远程节点是否仍然可用
-
容忍:重大决策不能由一个节点安全地完成,因此我们需要一个能从其他节点获得帮助的协议,并争取达到法定人数以达成一致。
-
可以避免打开潘多拉的盒子,把东西放在一台机器上,那么通常是值得的。
- 超级计算机
CAP
-
一致性,可用性和分区容错性:三者只能择其二
- 网络分区是一种故障类型,所以它并不是一个选项:不管你喜不喜欢它都会发生
流处理
AMQP/JMS 风格的消息代理
-
代理将单条消息分配给消费者,消费者在成功处理单条消息后确认消息。消息被确认后从代理中删除。这种方法适合作为一种异步形式的 RPC
-
消息处理的确切顺序并不重要,而且消息在处理完之后,不需要回头重新读取旧消息。
基于日志的消息代理
-
代理将一个分区中的所有消息分配给同一个消费者节点,并始终以相同的顺序传递消息。并行是通过分区实现的,消费者通过存档最近处理消息的偏移量来跟踪工作进度。消息代理将消息保留在磁盘上,因此如有必要的话,可以回跳并重新读取旧消息。
-
基于日志的方法与数据库中的复制日志(请参阅 第五章)和日志结构存储引擎(请参阅 第三章)有相似之处。
流的来源
-
用户活动事件,定期读数的传感器,和 Feed 数据(例如,金融中的市场数据)能够自然地表示为流
-
通过消费变更日志并将其应用至衍生系统,你能使诸如搜索索引、缓存以及分析系统这类衍生数据系统不断保持更新。你甚至能从头开始,通过读取从创世至今的所有变更日志,为现有数据创建全新的视图。
流处理连接类型
-
流流连接
- 两个输入流都由活动事件组成,而连接算子在某个时间窗口内搜索相关的事件。例如,它可能会将同一个用户 30 分钟内进行的两个活动联系在一起。如果你想要找出一个流内的相关事件,连接的两侧输入可能实际上都是同一个流(自连接,即 self-join)。
-
流表连接
- 一个输入流由活动事件组成,另一个输入流是数据库变更日志。变更日志保证了数据库的本地副本是最新的。对于每个活动事件,连接算子将查询数据库,并输出一个扩展的活动事件。
-
表表连接
- 两个输入流都是数据库变更日志。在这种情况下,一侧的每一个变化都与另一侧的最新状态相连接。结果是两表连接所得物化视图的变更流。
批处理
分布式批处理框架需要解决的两个主要问题
-
分区
-
在 MapReduce 中,Mapper 根据输入文件块进行分区。
-
Mapper 的输出被重新分区、排序并合并到可配置数量的 Reducer 分区中。这一过程的目的是把所有的 相关 数据(例如带有相同键的所有记录)都放在同一个地方。
-
-
容错
- MapReduce 经常写入磁盘,这使得从单个失败的任务恢复很轻松,无需重新启动整个作业,但在无故障的情况下减慢了执行速度。
MapReduce 的连接算法
-
排序合并连接
- 每个参与连接的输入都通过一个提取连接键的 Mapper。通过分区、排序和合并,具有相同键的所有记录最终都会进入相同的 Reducer 调用。这个函数能输出连接好的记录。
-
广播散列连接
- 两个连接输入之一很小,所以它并没有分区,而且能被完全加载进一个哈希表中。因此,你可以为连接输入大端的每个分区启动一个 Mapper,将输入小端的散列表加载到每个 Mapper 中,然后扫描大端,一次一条记录,并为每条记录查询散列表。
-
分区散列连接
- 如果两个连接输入以相同的方式分区(使用相同的键,相同的散列函数和相同数量的分区),则可以独立地对每个分区应用散列表方法。