《数据密集型应用系统设计》笔记一——数据系统基础
《数据密集型应用系统设计》笔记一——数据系统基础
第一章:可靠、可扩展与可维护的应用系统
认识数据系统
单一工具难以满足复杂应用系统的需求,因此整体工作被拆解为一系列能被单个工具高效完成的任务,并通过应用代码将它们缝合起来。比如一个缓存、索引、数据库协作的例子: 一个应用被称为数据密集型的,如果数据是其主要挑战(数据量,数据复杂度、数据变化速度)——与之相对的是计算密集型,即处理器速度是其瓶颈。 软件系统中很重要的三个问题:
- 可靠性(Reliability):系统面临各种错误(硬件故障、软件故障、人为错误),仍可正常工作。
- 可扩展性(Scalability):有合理的办法应对系统的增长(数据量、流量、复杂性)。
- 可维护性(Maintainability):许多不同的人在不同的生命周期,都能高效地在系统上工作。
可靠性
可靠性意味着:即时发生了某些错误,系统仍然可以继续正常工作。
可能出错的事情称为错误(fault)或故障,系统可应对错误则称为容错(fault tolerant)或者弹性(resilient)。
故障与失效(failure)不完全一致。故障通常被定义为组件偏离其正常规格,而失效意味着系统作为一个整体,停止对外提供服务。
常见的故障分类:
- 硬件故障
- 故障场景:硬盘崩溃、内存故障、停电、断网等。
- 应对策略:添加冗余硬件以备用;软件容错(如:负载均衡)。
- 软件故障
- 故障场景:各种难以预料的 Bug。
- 应对策略:仔细考虑细节;全面测试;监控、告警;系统/数据隔离机制;自动化部署、回滚机制等。
- 人为失误
- 故障场景:操作不当、配置错误等。
- 应对策略:快速恢复机制;监控、告警等。
可扩展性
可扩展性(Scalability)是用来描述系统应对负载增长能力的术语。
描述负载
负载可以用称为负载参数的若干数字来描述。参数的最佳选择取决于系统的体系结构。它可能是 QPS、数据库中写入的比例、日活用户量、缓存命中率等。
推特发送推文的设计变迁:
推文放在全局推文集合中,查询的时候做 join
推文插入到每个关注者的时间线中,「扇出」比较大,当有千万粉丝的大 V 发推压力大
推特从方案一变成了方案二,然后变成了两者结合的方式
描述性能
负责增加将会发生什么:
- 负载增加,但系统资源保持不变时,系统性能将受到什么影响?
- 负载增加,如果希望性能保持不变时,需要增加多少系统资源?
批处理系统,通常关心吞吐量(throughput);在线系统,通常更关心响应时间(response time)。
度量场景的响应时间,平均响应时间并不是一个合适的指标,因为它无法告诉有多少用户实际经历了多少延迟。最好使用百分位数,比如中位数(P50)、P95、P99、P999 等标识。
测量客户端的响应时间非常重要(而不是服务端),比如会出现头部阻塞、网络延迟等。
实践中的百分位点,可以用一个滑动的时间窗口(比如 10 分钟)进行统计。可以对列表进行排序,效率低的话,考虑一下正向衰减,t-digest 等近似计算方法。
响应时间:中位数指标比平均响应时间更适合描述等待时间。
如何应对负载:垂直扩展(升级硬件)和水平扩展(集群、分布式)
应对负载的方法
- 垂直扩展:升级硬件
- 水平扩展:将负载分布到多台小机器上
- 弹性设计:自动检测负载增加,然后自动添加计算资源
- 无状态服务可以组成集群进行扩展;有状态服务从单点到分布式,复杂性会大大增加,因此,应该尽量将数据库放在单节点上。
可维护性
三个设计原则:
- 可运维性:运维更轻松。应对:监控、链路追踪、CI/CD、规范流程等。
- 简单性:简化复杂度。应对:良好的抽象。
- 可演化性:易于改变。应对:DDD、TDD、重构、敏捷。
第二章:数据模型与查询语言
关系模型与文档模型
关系模型 - 数据被组织成关系(SQL 中称作表),其中每个关系是元组(SQL 中称作行) 的无序集合。
NoSql - 不仅是 SQL(Not Only SQL)
相比于关系型数据库,为什么用 NoSql?
- 需要更好的扩展性,以应对非常大的数据集或高并发。
- 关系模型不能很好地支持一些特殊的查询。
- 关系模型有很多限制,不够灵活。
当前以及未来很长一段时间,关系型数据库和 NoSql 并存的混合持久化是一种常态。
复杂的应用程序可能会有更多的中间层,每层都通过提供一个简洁的数据模型来隐藏下层的复杂性。
如果数据大多是一对多关系(树结构数据)或者记录之间没有关系,那么文档模型是最合适的。
关系模型能够处理简单的多对多关系,但是随着数据之间的关联越来越复杂,将数据建模转化为图模型会更加自然。
对象关系不匹配
使用面向对象语言,需要一个转换层,才能转成 SQL 数据模型。模型之间的脱离有时被称为阻抗失谐。
Hibernate 这样的 对象关系映射(ORM) 框架则减少这个转换层所需的样板代码量,但是它们不能完全隐藏这两个模型之间的差异。
对于一份简历而言,关系型模型描述一对多的关系需要多张表。
对于简历这样的数据结构,主要是一个自包含的文档,用 JSON 表示非常合适。JSON 相比于多表模式,有更好的局部性,可以一次查询出一个用户的所有信息。JSON 其实是树形层级结构。
多对一和多对多的关系
使用 ID 的好处是,因为它对人类没有任何直接意义,所以永远不需要直接改变:即使 ID 标识的信息发生了变化,它也可以保持不变。
文档模型不适合表达多对一的关系。对于关系数据库,由于支持联结操作,可以更方便地通过 ID 来引用其他表的行。而在文档数据库中,一对多的树状结构不需要联结,即使支持联结通常也比较弱。
如果数据库本身不支持联结,则必须通过对数据库进行多次查询来模拟联结。
考虑以下可能对简历进行的修改或补充:
- 组织和学校作为实体:组织、学校有各自的主页。
- 推荐:用户可以推荐其他用户在自己的简历上。
文档数据库是否在重演历史?
20 世纪 70 年代,最受欢迎的是层次模型(hierarchical model),它与文档数据库使用的 JSON 模型有很多相似之处。它将所有数据表示为嵌套在记录中的记录树。层次模型能很好地支持一对多的关系,但是很难支持多对多的关系,而且不支持联结。
为解决层次模型的局限性而提出的方案:
- 关系模型(relational model) - 后来,演变成了 SQL,并被广泛接受
- 网络模型(network model) - 最初很受关注,但最终被淡忘
网络模型
每个记录可能有多个父节点。
网络模型中,记录之间的链接不是外键,而更像编程语言中的指针(会存储在磁盘上)。访问记录的唯一方法是选择一条始于根记录的路径,并沿着相关链接一次访问,这条链接链条也被称为访问路径(access path)。
最简单的情况下,访问路径类似遍历链表:从链表头开始,每次查看一条记录,直到找到所需的记录。但在多对多关系的情况中,存在多条不同的路径可以通向相同的记录,网络模型的程序员必须跟踪这些不同的访问路径。
缺点:查询和更新数据库非常麻烦。
关系模型
关系模型定义了所有数据的格式:关系(表) 只是 元组(行) 的集合,仅此而已。
在关系数据库中,查询优化器自动决定以何种顺序执行查询,以及使用哪些索引。
文档数据库的比较
文档数据库是某种方式的层次模型:即在其负记录中保存了嵌套记录,而不是存储在单独的表中。
但是,在表示多对一和多对多的关系时,关系数据库和文档数据库并没有根本的不同:在这两种情况下,相关项目都由唯一的标识符引用,该标识符在关系模型中被称为外键,在文档模型中被称为**文档引用。**标识符可以查询时通过联结操作或相关后续查询来解析。
关系数据库与文档数据库现状
支持文档数据模型的主要论据是模式灵活性,由于局部性而带来较好的性能。关系模型则强在联结操作、多对一和多对多关系更简洁的表达上。
哪种数据模型的应用代码更简单
文档模型:
- 优点:
- 如果应用程序中的数据具有类似文档的结构(即一对多关系树,通常一次性加载整个树),那么使用文档模型更为合适。而关系模型则倾向于数据分解,把文档结构分解为多个表。
- 缺点:
- 不能直接引用文档中的嵌套的项目,而是需要说“用户 251 的位置列表中的第二项”(很像分层模型中的访问路径)。但是,只要文件嵌套不太深,这通常不是问题。
- 文档数据库对联结的支持不足。这是否是问题取决于应用,如果应用程序使用多对多关系,那么文档模型就没不合适了。
对于高度关联的数据,文档模型不太适合,关系模型更适合。
文档模型中的模式灵活性
文档模型是「读时模式」
- 文档数据库有时称为无模式(schemaless),但这具有误导性,因为读取数据的代码通常假定某种结构——即存在隐式模式,但不由数据库强制执行。
- 一个更精确的术语是读时模式(schema-on-read)(数据的结构是隐含的,只有在数据被读取时才被解释),相应的是写时模式(schema-on-write)(传统的关系数据库方法中,模式明确,且数据库确保数据写入时都必须遵循)。
- 读时模式类似于编程语言中的动态(运行时)类型检查,而写时模式类似于静态(编译时)类型检查。
模式变更
- 读时模式变更字段很容易,只用改应用代码
- 写时模式变更字段速度很慢,而且要求停运。它的这种坏名誉并不是完全应得的:大多数关系数据库系统可在几毫秒内执行 ALTER TABLE 语句。MySQL 是一个值得注意的例外,它执行 ALTER TABLE 时会复制整个表,这可能意味着在更改一个大型表时会花费几分钟甚至几个小时的停机时间,尽管存在各种工具来解决这个限制。
查询的数据局部性
文档通常存储为编码为 JSON、XML 或其二进制变体(如 MongoDB 的 BSON)的连续字符串。
读文档:
- 如果应用需要频繁访问整个文档,则存储局部性具有性能优势。
- 局部性优势仅适用于需要同时访问文档大部分内容的场景。
写文档:
- 更新文档时,通常需要重写整个文档。
- 通常建议文档应该尽量小且避免写入时增加文档大小。
文档数据库与关系数据库的融合
- MySQL 等逐步增加了对 JSON 和 XML 的支持
- 融合关系模型与文档模型是未来数据库发展的一条很好的途径。
数据查询语言
关系模型包含了一种查询数据的新方法:SQL 是一种 声明式 查询语言,而 IMS 和 CODASYL 使用 命令式 代码来查询数据库。
命令式语言告诉计算机以特定顺序执行某些操作,比如常见的编程语言。
声明式查询语言只需指定所需的数据模式,结果需要满足哪些条件,以及如何转换数据(例如,排序,分组和集合) ,而不需指明如何实现这一目标
Web 上的声明式查询(略)
MapReduce 查询
MapReduce 是一种编程模型,用于在许多机器上批量处理海量数据。一些 NoSQL 支持有限的 MapReduce 方式在大量文档上执行只读查询。
图数据模型(略)
本章小结
历史上,数据最初被表示为一棵大树(层次模型),但是这不利于表示多对多的关系,所以发明了关系模型来解决这个问题。 最近,开发人员发现一些应用程序也不适合采用关系模型。新的非关系型“NoSQL”数据存储在两个主要方向上存在分歧:
- 文档数据库的应用场景是:数据来自于自包含文档,且文档之间的关联很少。
- 图数据库则的应用场景是:所有数据都可能会相互关联。
文档模型、关系模型和图模型,都应用广泛。不同模型之间可以相互模拟,但是处理起来比较笨拙。
文档数据库和图数据库有一个共同点,那就是它们通常不会对存储的数据强加某个模式,这样比较灵活。
第三章:存储与检索
从最基本的层面看,数据库只需做两件事情:存储和检索。
数据库核心:数据结构
为了高效地查找数据库中特定键的值, 需要新的数据结构: 索引。
存储系统的设计权衡:适当的索引可以加速读取查询,但每个索引都会减慢写速度。数据库通常不会对所有内容进行索引。
索引类型:
- 哈希索引
- B+ 树
- LSM 树
- 等等
扩展阅读:检索技术核心 20 讲
事务处理与分析处理
列式存储
如果表中有数以万亿行、PB 大小的数据,则适合用于存储在列式存储中。
第四章:数据编码与演化
本章节主要介绍各种序列化、反序列化方式。略
数据编码格式
数据流模式
向前和向后的兼容对于可演化性来说非常重要。
基于数据库的数据流
在不同的时间写入不同的值
数据库通常支持在不同的时间写入不同的值。
在集群中部署新版本是一个逐一的过程,必然存在这样的时间段:集群中部分是新机器,部分是老机器。
当旧版本的应用视图更新新版本的应用所写入的数据时,可能会丢失数据。
归档数据
生成数据库快照时,数据转储通常使用最新的模式进行编码。
基于服务的数据流:REST 和 RPC
- 最常见的网络通信方式:C/S 架构(客户端+服务端)。
- Web 服务:收、发 GET 和 POST 请求。
- 将大型应用分而治之:微服务架构。
- 微服务架构的一个关键设计目标:服务可以独立部署和演化。
Web 服务
- 当 HTTP 被用作与服务通信的底层协议时,它被称为 Web 服务
- 有两种流行的 Web 服务方法:REST 和 SOAP。
REST 不是一种协议,而是一个基于 HTTP 原则的设计理念。它强调简单的数据格式,使用 URL 来标识资源,并使用 HTTP 功能进行缓存控制,身份验证和内容类型协商。与 SOAP 相比,REST 已经越来越受欢迎,至少在跨组织服务集成的背景下,并经常与微服务相关。根据 REST 原则设计的 API 称为 RESTful。
SOAP 是一种基于 XML 的协议,用于发送网络 API 请求。虽然,它最常用于 HTTP,但其目的是独立于 HTTP,并避免使用大多数 HTTP 功能。SOAP Web 服务的 API 使用 WSDL 语言来描述。 WSDL 支持代码生成,客户端可以使用本地类和方法调用(编码为 XML 消息并由框架再次解码)访问远程服务。尽管 SOAP 及其各种扩展表面上是标准化的,但是不同厂商的实现之间的互操作性往往会造成问题。
远程过程调用(RPC)的问题
RPC 模型试图向远程网络服务发出请求,看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明)。
RPC 的缺陷:
- 本地函数调用是可预测的,并且成功或失败仅取决于控制的参数。而网络请求是不可预知的。
- 本地函数调用要么返回结果,要么抛出异常,或者永远不返回(因为进入无限循环或进程崩溃)。网络请求有另一个可能的结果:由于超时,它可能会没有返回结果。这种情况下,无法得知发生了什么。
- 如果重试失败的网络请求,可能会发生请求实际上已经完成,只有响应丢失的情况。在这种情况下,重试将导致该操作被执行多次,除非在协议中建立重复数据消除( 幂等(idempotence))机制。本地函数调用没有这个问题。
- 每次调用本地功能时,通常需要大致相同的时间来执行。网络请求慢得多,不可预知。
- 调用本地函数时,可以高效地将引用(指针)传递给本地内存中的对象。当发出网络请求时,所有这些参数都需要被编码成可以通过网络发送的字节序列。如果参数是像数字或字符串这样的基本类型倒是没关系,但是对于较大的对象很快就会变成问题。
- 客户端和服务端可以用不同的编程语言实现。所以,RPC 框架必须将数据类型从一种语言转换成另一种语言。
RPC 比 REST 性能好。但是,REST 更加方便,不限定特定的语言,有更好的通用性。因此,REST 是公共 API 的主流;RPC 框架则侧重于同一组织内多个服务间的请求,且通常在同一数据中心。
基于消息传递的数据流
消息代理
通常,消息代理的使用方式如下:
生产者向指定的队列或主题发消息;消息代理确保消息被传递给队列或主题的一个或多个消费者或订阅者。同一主题上,可以有多个生产者和多个消费者。
分布式 Actor 框架
Actor 模型是用于单个进程中并发的编程模型。每个 Actor 通常代表一个客户端或实体,它可能具有某些本地状态,并且它通过发送和接受异步消息与其他 Actor 通信。
分布式的 Actor 框架实质上时将消息代理和 Actor 编程模型集成到单个框架中。
三种流行的分布式 Actor 框架:
- Akka 使用 Java 的内置序列化,它不提供向前或向后兼容性。但是,可以用类似 Protocol Buffer 替代;
- Orleans 不支持滚动升级部署的自定义数据编码格式;
- Erlang OTP,很难对记录模式进行更改。
小结
许多服务需要支持滚动升级:向前、向后兼容性。
我们讨论了几种数据编码格式及其兼容性属性:
- 编程语言特定的编码仅限于单一编程语言,往往无法提供前向和后向兼容性。
- JSON,XML 和 CSV 等文本格式非常普遍,其兼容性取决于您如何使用它们。它们有可选的模式语言,这有时是有用的,有时却是一个障碍。这些格式对某些数据类型的支持有些模糊,必须小心数字和二进制字符串等问题。
- 像 Thrift,Protocol Buffers 和 Avro 这样的二进制模式驱动格式,支持使用清晰定义的前向和后向兼容性语义进行紧凑,高效的编码。这些模式对于静态类型语言中的文档和非常有用。但是,他们有一个缺点,就是在数据可读之前需要对数据进行解码。
我们还讨论了数据流的几种模式,说明了数据编码重要性的不同场景:
- 数据库,写入数据库的进程对数据进行编码,并从数据库读取进程对其进行解码。
- RPC 和 REST API,客户端对请求进行编码,服务器对请求进行解码并对响应进行编码,客户端最终对响应进行解码。
- 异步消息传递(使用消息代理或 Actor),节点之间通过互发消息进行通信,消息由发送者编码并由接收者解码。
结论:前向兼容性和滚动升级在某种程度上是可以实现的。
参考资料
- 数据密集型应用系统设计 - 这可能是目前最好的分布式存储书籍,强力推荐【进阶】