1.1 可靠、可扩展与可维护的应用系统
数据密集型应用通常包含了这几个模块:数据库、高速缓存、索引、流式处理以及批处理。
1.1.1 认识数据系统
大多数软件系统都极为关注3个问题:
- 可靠性(reliability):出现意外(软硬件故障和人为失误)时仍然能够正常运转(功能正确,性能可能会降低)。
- 可扩展性(scalability):以合理的方式匹配规模的增长(数据量、流量或复杂性)。
- 可维护性(maintainability):更多人加入到开发和运维中时仍能够高效运转。
1.1.2 可靠性
可能出错的事情称为错误(faults)或故障,系统应对错误(特定类型的)称为容错(fault-tolerant)或者弹性(resilient)。通常会设计容错机制来避免从故障引发系统失效(failure),但有时“预防胜于治疗”(针对不可逆的故障如数据安全问题)。可以通过故意引发故障来检验、测试系统的容错机制。
可以通过为硬件添加冗余来减少系统的硬件故障,软件容错也可作为硬件容错的补充。
软件错误没有快速的解决方法,只能在整个过程中都仔细考虑和检查(尤其是细节处)。
人为失误很难避免,想减少它,要以尽可能少出错的方式来设计系统(但别过于复杂),将容易出错的地方隔离起来使用户接触不到,进行充分的测试,提供出错后快速恢复机制以将损失降到最低,进行详细的监控,规范管理流程(这些对于软件错误依旧适用)。
要尽量保证系统的可靠性(但有时或许会因为成本而牺牲掉一些)。
1.1.3 可扩展性
可扩展性,即系统应对负载增加的能力。
负载可以用称为负载参数的数值来描述,负载参数可以是Web服务器的每秒请求处理次数、数据库中写入的比例、聊天室的活动用户数量、缓存命中率等(可能是平均值,有时是少数峰值)。“Twitter用户浏览其关注的人最新发布的推文”案例值得讨论,到底是发布者主动推送好还是其粉丝自己拉取好,具体问题具体分析,可以将两种结合。
批处理系统更关心吞吐量(throughput),即每秒可处理的记录条数;在线系统更看重服务的响应时间(response time),即客户端从发送请求到接收响应之间的间隔(与延迟(latency)不同,延迟仅仅是处理请求时间(service time),响应时间还包括来回网络延迟和各种排队延迟)。中位数指标比平均值更适合描述用户需要等待多长时间。需要一些高的响应时间阈值和大的百分比来体现异常值有多糟糕(比如95%、99%和99.9%分别表示有95%、99%和99.9%的请求快于相应的响应时间阈值),采用较高的响应时间百分位数能直接影响用户的总体服务体验(tail latencies,尾部延迟或长尾效应)。排队延迟在高百分数响应时间中影响很大。可以将响应时间百分位数添加到服务系统监控中,持续跟踪该指标,一种实现方法是在时间窗口内保留所有请求的响应时间列表然后每分钟做一次排序,或者可以采用一些近似算法如正向衰减等。
要在垂直扩展(即升级到更强大的机器)和水平扩展(即将负载分布到多个更小的机器,即无共享体系结构)之间做取舍。某些系统具有弹性特征,可以自动检测负载增加然后自动添加更多计算资源;另一些系统则是需要手动扩展(或许更低效但是可以减少执行期间的意外情况)。把有状态服务从单个节点扩展到分布式多机环境的复杂性会大大增加(相比无状态服务)。超大规模的系统很难有一种通用的架构,背后取舍因素包括数据读取量、写入量、待存储的数据量、数据的复杂程度、响应时间要求和访问模式等(或是这些因素的叠加,加上其他更复杂的问题)。
1.1.4 可维护性
我们特别关注软件系统的三个设计原则:可运维性、简单性和可演化性。
运营团队对于保持软件系统顺利运行至关重要。数据系统设计可在这些方面提升可操作性:提供系统运行时行为和内部可观测性以便于监控、支持自动化且与标准工具集成、避免绑定特定机器以允许停机维护、提供良好的文档和易于操作的模式、提供良好的默认配置且允许管理员手动控制系统状态和使行为可预测。
数据系统复杂性的表现方式有:状态空间的膨胀、模块紧耦合、令人纠结的相互依赖关系、不一致的命名与术语、为了性能而采取的特殊处理和为解决某特定问题而引入的特殊框架等。降低复杂性可以提高可维护性,但简化系统不等于简化功能,主要要消除意外方面的复杂性,消除意外复杂性的最好手段之一是抽象(隐藏大量实现细节,但很有挑战性),可以将大型系统的一部分抽象为定义明确、可重用的组件。
敏捷开发技术(如测试驱动开发即TDD和重构)多数只是针对小规模、本地模式的环境。目标应该是可以轻松地修改数据系统,使其适应不断变化的需求,与其简单性和抽象性密切相关。“可演化性”用来指代数据系统的敏捷性。
1.2 数据模型与查询语言
大多数应用程序是通过一层层叠加数据模型来构建的,复杂的应用程序可能会有很多中间层。
1.2.1 关系模型与文档模型
SQL数据模型基于关系模型:数据被组织成关系(relations),在SQL中称为表(table),其中每个关系都是元祖(tuples)的无序集合(在SQL中称为行)。关系数据库的核心在于商业数据处理(包括事务处理和批处理)。 关系模型的目标就是将实现细节隐藏在更简洁的接口后面。
NoSQL数据库的几个驱动因素:比关系数据库有更好的扩展性需求包括支持超大数据集或超高写入吞吐量、普遍偏爱免费和开源软件而不是商业数据库产品、关系模型不能很好地支持一些特定的查询操作以及关系模式有一些限制性所以需要更具动态和表达力的数据模型。NoSQL数据库可以和关系数据库一起使用,称作混合持久化。
应用开发采用的面向对象编程语言和SQL数据库之间需要一个转换层,使得应用层代码中的对象和数据库表行列中数据对应,模型之间的脱离被称为阻抗失谐,这个转换层可以是对象-关系映射(ORM)框架。
后来的SQL标准增加了对结构化数据类型和XML数据的支持(允许将多值数据存储在单行内并支持在这些文档中的查询和索引)。而文档型数据库是将数据编码为JSON或XML文档并将其存储在数据库的文本列中,由应用程序解释其结构和内容(JSON比XML更简单)。JSON比多表模式有更好的局部性(相关信息通过一次查询即可获取),多为一对多关系,不需要联结(对其支持通常很弱,需要使用文档引用)。关系型数据库适合多对一关系,因为支持联结。对于不支持联结的数据库,需要在应用层对其进行模拟。
层次模型(如IMS)支持一对多但不支持多对多,具有局限性。网络模型是层次模型的扩展,每个记录可以有多个父结点。关系模型则是定义了所有数据的格式:关系(表)只是元祖(行)的集合(关系数据库中查询优化器自动决定查询执行顺序以及使用哪些索引)。
应该根据应用数据的类型(文档结构还是数据分解)来选择文档模型还是关系型模型。关系型数据库是写时模式,文档型数据库是读时模式。文档模型中模式的灵活性使得模式更新时相比关系模型需要停机更新模式更有优势,且更适合数据异构的情况。文档型数据库在查询时具有数据局部性,在查询时需要访问一整个文档时能够以较少次数的磁盘I/O来完成检索。
一些关系型数据库在支持JSON文档,一些文档型数据库在支持类似于联结的引用功能,关系模型和文档模型融合是趋势。
1.2.2 数据查询语言
首先要了解下关系代数。
SQL是一种声明式查询语言,IMS和CODASYL是命令式查询语言,前者不需要指明如何实现查询(数据库的查询优化器会做决策),且适合于并行执行。
MapReduce是一种用于在许多机器上批量处理海量数据的编程模型,主要基于函数式编程语言中的map(又称collect)和reduce(又称fold或inject)函数。MapReduce相对底层(用于在计算集群上分布执行),而SQL可以通过一些MapReduce操作pipeline来实现。
1.2.3 图状数据模型
图(适用于多对多关系)的组成:顶点(又称结点或实体)和边(又称关系或弧)。图不局限于同构数据。
属性图模型中,主要记录每个顶点和边的属性,可以看作是两个关系表。这具有一些灵活性:任意两顶点间可以连接、从单个顶点可以高效得出其入度和出度、可存储不同类型数据。Cypher是一种用于属性图的声明式查询语言。 若是将图数据存在关系结构中,在查询时需要用SQL中的递归公用表表达式。
三元存储模式几乎等同于属性图模型,所有信息都以三元组(主体、谓语、客体)形式存储。资源描述框架(RDF)机制来源于语义网,让不同网站以一致的格式发布数据。SPARQL是一种采用RDF数据模型的三元存储查询语言。
相比于网络模型,图数据库相对灵活(没有类型嵌套)、可以通过顶点ID引用该顶点而不需要遍历访问路径、顶点和边可以无序、大都使用高级声明式查询语言。
Datalog比Cypher和SPARQL更古老,采用“谓语(主体,客体)”,对于简单的一次性查询不太方便,处理复杂数据比较合适。
1.3 数据存储与检索
本质上,数据库就做两件事:
- 向它插入数据时,它就保存数据;
- 之后查询时,他应该返回那些数据。
1.3.1 数据库核心:数据结构
最简单的数据库(key-value存储)可以用日志追加的方式存储数据,用遍历的方式查找数据,但很低效,所以需要索引来帮助高效查找数据。索引是基于原始数据派生而来的额外数据结构(适当的索引能加速读取查询,但是每个索引都会减慢写速度)。
key-value存储可以采用hash map(或hash table,即哈希表),类似于字典结构。Bitcask:数据以追加形式写入磁盘,哈希索引(key和磁盘文件中数据的offset)必须保存在内存中,可以定期对磁盘中的数据文件进行压缩。数据文件的格式最好是二进制格式。删除记录可以靠追加写删除标志(墓碑)。可以将哈希索引快照存入磁盘中,加速崩溃恢复。数据包含校验值以避免记录部分写入。通常写线程一个,读线程多个。“将新值追加写入”优于“旧值覆盖新值”,因为这样就是顺序写,且利于崩溃恢复,且可避免数据文件碎片化。哈希索引的局限性是:如果在磁盘上维护哈希索引会引发大量磁盘随机I/O,且当哈希变满时增长代价高昂,哈希冲突时也需要复杂的处理逻辑,且区间查询效率很低。
简单改变数据文件的格式——将key-value对的顺序按key排序,简称为排序字符串表(SSTable)。相比哈希索引的日志段,SSTable的优点是:合并段更简单高效(归并),不需要在内存中保存所有key索引就可以查找特定key(区间,使用稀疏索引),写入磁盘前便可初步归并压缩。这类存储引擎(日志结构归并树,即Log Structured Merge-Tree,LSM-tree)的工作流程:写入到内存中的排序数据结构中(如红黑树和跳表,又名内存表),当这个内存表大于某个阈值时作为SSTable文件写入磁盘(因为key-value对都按照key排好序了,所以很容易),与此同时可以添加新的内存表,读的时候依次尝试在内存表、有新到旧的SSTable文件读取直到找到目标或全部遍历完,后台进程会周期性执行SSTable文件的归并与压缩以丢弃已被覆盖和删除的值并对顺序进行全局调整。为了避免崩溃后丢失或者污染数据,每次写入时都应该马上追加日志。LevelDB、RocksDB、Cassandra和HBase都使用了这种LSM-tree算法。Lucene是Elasticsearch和Solr等全文搜索系统的索引引擎使用了类似的想法但更复杂。可以使用布隆过滤器来记录每个SSTable中某个key是否存在,这样可以加速读。归并和压缩方面,LevelDB和RocksDB使用分层压缩,HBase使用大小分级,Cassandra同时支持这两种方式。LSM-tree可以支持很高的写入吞吐量。
B-tree也可以作为数据库的索引结构,它将数据库分解为固定大小的块或页(一般是4KB,是内部读写的最小单元),每个页面可以使用地址或者位置进行标识从而被别的页面引用,它在写时是使用新数据覆盖磁盘上的旧页(原地更新,对该页所有引用保持不变),如果插入导致页溢出则需要分裂页,需要预写日志(write-ahead log,WAL,又称重做日志)来帮助崩溃恢复(每次修改必须先更新WAL)。可以用写时复制来代替覆盖页和WAL来进行崩溃恢复。可以保存key的缩略信息而不是完整的key来节省页空间。要尽量让相邻叶子页按顺序保存在磁盘上。可以通过添加指针让相邻叶子页互相串联以便于遍历(B+tree)。B-tree的一些变体如分形树借鉴了日志结构的想法来减少磁盘寻道。
通常情况下,日志结构索引写更快,B-tree读更快。日志结构索引关注写放大(一次写入引起多次磁盘写,因为归并和压缩)指标。日志结构索引因为不是面向页且定期会进行归并和压缩,所以可以消除碎片化。归并和压缩的过程有时会干扰正在进行的读写操作是日志结构索引的缺点。每个key都恰好唯一对应于索引中的某个位置是B-tree的优点,利于事务隔离中的上锁(直接将锁定义到树中)。
二级索引也易基于key-value来构建,key不是唯一的。B-tree和日志结构索引都可以作为二级索引。聚集索引将行数据保存在索引中(避免查询时额外跳转),非聚集索引仅保存行数据的引用(避免数据冗余)。级联索引是最常见的多列索引,将一列追加到另一列,将几个字段简单地组合为一个key。多维索引也是多列索引。多列索引还有专门的空间索引(如R树)。全文索引通常支持对一个单词的所有同义词进行查询,并忽略单词语法上的变体。内存中的key-value存储(如Memcached)主要用于缓存,因为机器重启的数据丢失可接受。VoltDB、MemSQL和Oracle TimesTen是具有关系模型的内存数据库。RAMCloud是一个具有持久性的内存key-value存储。Redis和CouchBase通过异步写入磁盘提供较弱的持久性。内存数据库块,是因为可以避免使用写磁盘的格式对内存数据结构编码的开销。内存数据库可以通过暂存数据至磁盘中来支持远大于可用内存的数据量。非易失性存储(non-volatile memory,NVM)的普及将改变存储引擎设计。
1.3.2 事务处理与分析处理
事务指组成一个逻辑单元的一组读写操作。在线事务处理(online transaction processing,OLTP)基于key(每次查询反馈少量的记录),多为随机访问(低延迟写入用户的输入),一般的使用场景是终端用户(通过网络应用程序),一般都是最新的数据状态(当前时间点),数据规模是GB到TB。在线分析处理(online analytical processing,OLAP)一般对大量记录进行汇总,写入多为批量导入或事件流,供内部分析师为决策提供支持,数据是随着时间而变化的所有事件历史,数据规模是TB到PB。SQL能够很好地胜任两者。专门的OLAP数据库是数据仓库。
将OLTP和OLAP隔离开可以让业务分析人员在数据库上进行临时分析查询,不影响并发执行事务的性能。将数据导入数据仓库的过程称为提取-转换-加载(Extract-Transform-Load,ETL)。星型模式(又称维度建模),中心是一个事实表(每一行表示在特定时间发生的事件,列是属性),列可能会引用其他表的外键,称为维度表。雪花型模式就是将星型模式的维度表进一步细分为子空间。
1.3.3 列式存储
大部分OLTP数据库使用面向行的方式存储。面向列存储:将每列中所有的值存储在一起,查询只需要读取和解析在查询中使用的那些列(适用于数据仓库,OLAP)。
可以使用位图索引等方法对列进行压缩。对于数据仓库的查询,将数据从磁盘加载到内存的带宽是一大瓶颈,而列式存储,能减少从磁盘加载的数据量;而如何高效地将内存的带宽用于CPU缓存以避免分支错误预测和CPU指令处理流水线中的气泡并利用现代CPU中单指令多数据(SIMD)指令。列式存储也有利于高效利用CPU周期(矢量化处理)。
在列式存储中,排序需要按整行排序。排序有助于进行分组或过滤的查询,也可以进一步帮助压缩列。可以按照多种不同的方式(不同的字段)进行排序,然后在查询时找到最佳的排序数据。
列式存储无法使用原地更新的方式,LSM-tree是一个很好的解决方案。
对于临时分析查询,列式存储性能好得多。可以通过物化视图的方式来缓存一些常用聚合结果,但会影响写入性能(OLTP不经常用)。数据立方体(OLAP立方体)是多维的常用聚合结果,物化数据立方体是物化视图的常见的特殊情况。
1.4 数据编码与演化
当数据格式或模式发生变化时,经常需要对应用程序代码进行相应的调整,为了使系统继续顺利运行需要保持向后兼容(新代码可以读取旧代码编写的数据)和向前兼容(旧代码可以读取新代码编写的数据)。
1.4.1 数据编码格式
在内存中,数据保存在各种数据结构中;将数据写入文件或通过网络发送时,必须将数据编码为某种自包含的字节序列。从内存中的表示到字节序列的转化称为编码(或序列化),相反的过程称为解码(或解析、反序列化)。
许多编程语言都内置支持将内存中的对象编码为字节序列。
数据编码格式中,JSON、XML和CSV都是文本格式的(具有不错可读性),但是对于数字编码可能比较模糊,可能对二进制字符串的支持不是太好(前两者),可能因为一些可选的模式支持而变得复杂(前两者),也或许没有任何模式(后者)。
对于仅在组织内部使用的大数据集,选择更紧凑或更快的解析格式会更好。
二进制数据编码格式有Protocol Buffer、Thrift和Avro,更紧凑或更快。对于Protocol Buffer和Thrift,旧代码读新数据时直接忽视新字段,新代码读旧数据时要求新字段必须是可选的或具有默认值。模式(字段标签和数据类型)不可避免地需要随着时间而不断变化,称之为模式演化。对于Avro(因Thrift不适合Hadoop而生),有读者模式(使用它知道的模式解码数据)和写者模式(应用程序可以使用它知道的任何模式编码数据),二者只需要兼容即可,为了保持兼容性,只能添加或删除具有默认值的字段。
1.4.2 数据流格式
基于数据库的数据流:写入数据库的时候对数据进行编码,读取的时候进行解码,但是旧代码做读取并更新的时候可能会遇到新的字段而造成错误(遇到后不管且不解释),而且通过重写的方式更新以前的数据代价太大(通过新增允许空值的新字段)。
将大型应用程序按照功能区域分解为较小的服务,服务可以调用别的服务,这传统上被称为面向服务的体系结构(service-oriented architecture,SOA),最近更名为微服务体系结构(microservices architecture),它的一个关键设计目标是通过使服务可独立部署和演化让应用程序更易于更改和维护。
当HTTP被用作与服务通信的底层协议时,它被称为Web服务。有两种流行的Web服务方法:REST和SOAP,前者是一种基于HTTP协议的设计理念,强调简单的数据格式,使用URL来标识资源,并能使用一些HTTP的功能,后者是一种基于XML的协议,用于发出网络API请求。
远程过程调用(Remote Procedure Call,RPC)模型试图使用向远程网络服务发出请求看起来与在同一进程中调用编程语言中的函数或方法相同(这种抽象称为位置透明),但是网络请求不可预测(因为网络问题或远程计算机本身的问题),且会因为超时而返回时无结果,因为重试而使得操作被执行多次,比较慢,数据传输前必须进行数据编解码,但是双端可以用不同编程语言编写的代码执行。新的RPC框架会封装可能失败的异步操作,简化了需要并行请求多项服务的情况并将其结果合并,提供了服务发现,允许使用二进制编码格式的自定义RPC协议。
消息代理(消息队列,又称为面向消息的中间件)接受消息并将消息发给接收方,发送方不需要等待结果(不返回结果),可以在接收方不可用或过载时充当缓冲区,可以自动将消息重新发送至崩溃的进程,避免发送方知道接收方IP地址和端口号,可以有多个接收方,在逻辑上将发送方和接收方分离,是单向的,只包含一些元数据的字节序列。
Actor模型是用于单个进程中并发的编程模型,逻辑被封装在Actor中而不是直接处理线程,通过发送和接受异步消息和其他Actor通信,且每个Actor一次处理一条消息(不需要担心线程,每个Actor都可以由框架独立调度)。在分布式Actor框架中,这个编程模型被用来跨越多个节点来扩展应用程序,假定数据会丢失。