《数据密集型应用系统设计》非常好的一本书。全书分为三部分:
这里是第一部分。
可靠性
可扩展性
可维护性
问题来源:
当今应用都属于数据密集型(data-intensive
),而不是计算密集型(compute-intensive
)
数据应用系统常用模块:
本书将这个些都归类于:数据系统(data system
)
Redis既可以用户数据存储,也适用于消息队列
ApacheKafka可作为消息队列,同时也具备持久化存储保证。
缓存层Memcached
全文索引服务器Elasticsearch/Solr
Reliability
简单说:即使发生某些错误,系统仍然可以正常工作。
硬件故障
添加冗余来减少系统故障率。云平台(AmazonWebServices,AWS
)强调总体灵活性与弹性,不是单台机器的可靠性,所以云服务器虚拟机实例的可靠性相对较差。
软件错误
没有什么好的解决办法。
人为错误
假定人是不可靠的来设计系统.设计接口原则:使做正确的事情很简单,使做错误的事情很难。
Scalability
描述系统应对负载增加能力的术语。
描述负载
有一个twitter的例子很好。负载多是由系统的关键业务参数决定。
描述性能
批处理系统如Hadoop
,通常关心吞吐量throughout
。在线系统更看重响应时间responsetime
。
采用百分位数(percentiles
)描述响应时间。如中位数是50ms,则说明有50%的请求响应时间大于50ms。通常使用p95,p99,p999.亚马逊采用p999.描述、定义服务质量目标(ServiceLevelObjectives,SLO
),服务质量协议(ServiceLevelAgreements,SLA
)。
应对负载
垂直扩展:升级到更强大的机器
水平扩展:负载分布到多个更小的机器,无共享体系结构。
Maintainability
许多人不愿意维护遗留系统。
问题来源
数据模型可能是开发软件最重要的部分,它决定了软件的编写方式,包含业务逻辑知识。应用程序通常通过一层一层叠加数据模型来构建。每一层面临的关键问题是:如何将其用下一层来表示。
构建每一层的基本思想:每层都通过提供一个简洁的数据模型来隐藏复杂性。
这一章的内容就是数据存储和查询的通用数据模型。关系模型、文档模型、图状模型。还有数据查询语言。
NoSQL
:最初是为了吸引眼球创造的。现意为"不仅仅是SQL".采用NoSql的驱动因素:
混合持久化:结合关系数据库和各种非关系数据库一起使用。
对象-关系不匹配
是指业务代码与数据库模型之间可能存在的不匹配问题。
ActiveRecord,Hibernate
这样的对象-关系映射(ORM)框架减少了转换层的样板代码,但不能消除不匹配的问题。
传统SQL模型是规范化表示关系数据。之后SQL标准增加了对结构化数据/XML数据的支持。将多值数据存储在单行内,并支持在这些文档中查询和索引。例如MySQL支持JSON数据类型。
JSON的优势是JSON模型是一种“没有模式”的结构类型,能够减少阻抗失配的问题。
使用id而不是原始字符串
有很多好处,例如城市名字变更,只用更改id上值。id对人类没有任何直接意义,所以永远不需要改变。任何对人类有意义的东西都可能在将来某个时刻发生变更。
文档模型处理多对一的数据关系很弱
模型演化
文档数据库时某种方式的层次模型,但并未遵循CODASYL标准。
data locality
:数据视为整体存储):文档数据通常存储为JSON/XML/变体(MongoDB的BSON)连续字符串。如果频繁读取整个文档,则文档型数据库非常适用。文档数据库更新记录时,只有修改量不改变原文档大小时,才能采用原地覆盖更新,这些说明文档数据库不适用频繁更新记录的场景。Bigtable数据模型:用于Cassandra/HBase
SQL是一种声明式的查询语言。相对的是命令式语言。声明式查询语言只需要指定所需的数据模式,而命令式语言要指定哪些操作(过程),类似于编程语言。声明式查询语言API比命令式简单,数据库引擎可自行优化查询性能。例如HTML当中指定样式,使用Style是一种声明式的,而DOM对象则是命令式的。
MapReduce
查询
MapReduce是一种编程模型,用于在许多机器上批处理海量数据。一些NoSQL存储系统(MongoDB,CouchDB)支持有限的MapReduce方式在大量文档上执行只读查询。它介于声明式和命令式之间。它基于两个操作:map,reduce。map用于收集/过滤 数据集,reduce执行在数据集上的各种操作。所以通常需要手动实现map/reduce函数来表达查询需求。
图状数据模型非常适合处理 多对多关系。文档数据库几乎不能处理多对多关系,关系数据库能处理简单的多对多关系。
图的构成:顶点 , 边。顶点时实体,边是实体之间的关系。例如社交网络中顶点是人,边则表示人之间的关系。
属性图:Property Graph
,Neo4j/Titan/InfiniteGraph.这种图的顶点上具有属性字段,边除了有进出顶点外也有属性字段。查询时通常需要选定一个入口顶点。
三元存储模型:TripleStore
,Datomic,AllegroGraph。三元存储中,所有信息都以非常简单的三部分形式存储:主谓宾。三元组(Jim,like,banana),主体Jim,谓语like,客体banana.主体相当于顶点,而客体可以是另外一个顶点,此时三元组表示一个边。
图的查询语言:声明式Cypher/SPARQL/Datalog,命令式Germlin,图处理框架Pergel
图数据库不是与前述"网络模型CODASYL模型"具有很大的不同。
这一章的内容是数据库的存储与检索。
日志(log
)是一个仅支持追加式更新的数据文件。日志结构写入非常快,只需要追加写在文件末尾,但是直接查询较慢,所以需要新的数据结构:索引。索引式基于原始数据派生而来的额外数据结构,作为路标,帮助定位数据记录。但是索引显然会影响写入性能,因为每次写入时都需要更新索引文件。
哈希索引
哈希索引的原理是将记录的key映射成数据文件中特定的字节偏移量。这样读取记录就只需要o(1)。索引文件通常是较小的,所以可将索引文件加载到内存中。
合并压缩
为防止用尽磁盘,可规定每一个数据文件的大小,达到限制后,新纪录写入新的文件(段文件)中。旧的段是不会改变的,所以可以对旧段进行合并,只保留每个记录最新的值。这就是日志式结构数据库的合并压缩的过程。
删除记录
删除记录采用墓碑机制,即追加一个删除记录,在合并时发现key的墓碑时,直接删除key的所有记录。
并发控制
可以使用一个写线程。
好处
日志式为什么不采用原地更新文件呢?
缺陷
哈希索引在某些方面有缺陷。现在介绍其他索引结构。
在存储段文件的时候,要求key-value按key的顺序排序。这种格式为排序字符串表(SSTable)。
优点:
构建维护SSTables
磁盘上维护排序结构(B-trees
).内存排序有树状数据结构(红黑树/AVL树)。
于是构建SSTables的过程为:
SSTables实例:LevelDB,RocksDB.用于嵌入到其他应用程序的key-value存储引擎库。类似的存储引擎海被用于Cassandra/HBase。
LSM-Tree:Log-Structured Merge-Tree 日志结构的合并树
基于合并和压缩排序文件原理的存储引擎通常都被称为LSM存储引擎。
Lucene是Elasticsearch/Solr等全文搜索系统所使用的索引引擎,采用的是类似的方法保存词典。
存储引擎使用额外的布隆过滤器优化“查找不存在的记录”
布隆过滤器:内存高效的数据结构,用于近似计算集合的内容,如果数据库中不存在某个key,它能够很快得出结果,从而节省大量不必要的磁盘读取。
B-tree是目前使用最广泛的索引结构。
B-tree将数据库分解成固定大小的块/页,一般大小为4kb,页是内部读/写最小单元。每个页内可以使用地址或位置表识另外一个页,类似指针,指向的是磁盘地址。
查找
从根(某一页)开始,页内的内容是按键排序的,如果页内某位置是引用,则引用两边的key说明了引用的key范围。就这样不断缩小查询范围。查询效率很高。一个页内包含的子页引用数量称为分支因子(一般为几百)。树深度是log(n),n是记录条数。
更新
首先搜索包含该key的叶子页,更改该页的值,写回磁盘。
写入
搜索包含其key范围的页,添加到该页。如果容量不足,则该页分裂成2个半满页。并且父页页同时更新,如果父页也容量不足,重复分裂过程。
分支因子为500的4KB页的四级树存储高达256TB
B-tree写操作是使用新数据覆盖磁盘上的旧页,而日志结构索引仅追加更新文件,删除过时文件,但不修改文件。磁盘上执行这一过程是:磁头移动到正确的位置,用新的数据覆盖相应的扇区。SSD执行:SSD随机读取更快,但SSD执行重写是 擦除并重写非常大的存储芯片块(比本身要更新的块更大),也就是SSD的操作粒度比磁盘大。(想象一下用create_time/update_time作索引的区别)
由于Btree插入操作可能发生分裂,即需要覆盖多个不同的页,这就加大了崩溃以后恢复的难度。常见的机制是:预写日志write-ahead log,WAL
,重做日志。WAL是一个仅支持追加修改的文件。btree必须先更新WAL。
原地更新的另一个复杂因素:如果多线程同时访问btree,需要并发控制,否则可能看到不一致的状态。而日志结构不涉及更新文件操作,无此问题。
优化
简单说(都是相对而言且只考虑一般情况):B-Tree读取快,写入慢。LSM-Tree写入快,读取慢。
LSM-Tree优点
B-Tree索引必须至少写两次数据:一次WAL,一次写入页本身(还可能页分裂)。即使只更改一个字节,也要重写整个页。
写放大:由于一次数据库写入请求导致多次磁盘写。SSD只能承受有限次的擦除覆盖。
LSM-Tree写吞吐量高,有时有较低写放大。磁盘对于顺序写要比随机写快得多。
LSM-Tree更好地支持压缩,磁盘文件比B-tree小很多。B-tree有碎片化的问题。
许多SSD内部使用日志结构化算法将随机写入转换为底层存储芯片上的顺序写入(容易想见是把原数据设为墓碑,然后顺序写入)。
LSM-Tree缺点
压缩过程昂贵,容易发生读写请求等待(极有可能是发生在读取大量段文件时IO资源竞争)。B-tree相应延迟更具确定性。
如果压缩未仔细配置,而写入量很大,此时可能会产生大量未压缩合并的段文件,容易造成磁盘空间不足。比如一条记录,由于程序bug无限写入,LSM-Tree会快速扩大,而B-tree只占用一个位置。
对于事务语义:B-tree容易实现高效的事务隔离(可以使用范围锁)。
LSM-Tree/B-tree都是key-value索引(value就是实际行数据/文档/顶点)。
索引中的key是查询搜索的对象,而value则可以是实际数据或者 其他地方存储的引用(堆文件
)。当存在多个二级索引时,堆文件可以避免复制(二级索引的value就是堆文件的地址)。只更新值时,堆文件的方法非常高效。
但是堆文件多了一次磁盘IO,所以有了索引分类。
问题:要查询2019-05-22日"充值"订单。
单列索引:先按日期索引缩小范围,再按订单类型索引。这里面日期、类型就属于单列索引。
级联索引:最常见的多列索引。将日期+类型作为索引,称级联索引。一列追加到另一列…缺点是顺序是完全指定的,不能从中间某列开始查询
多维索引:将多列数据合并成一个多维向量索引。例如经纬度场景下空间索引(R树)。多维索引可以同时缩小查询范围,而不是按索引一个一个缩小。
全文索引/模糊索引是全文搜索引擎例如Lucene支持的一种搜索技术,作用是可以搜索相近的key。Lucene基本原理是利用key字符串序列的有限状态自动机,搜索一定距离的所有记录。
前面的数据结构都是为了适应磁盘限制。内存数据库是新东西。
VoltDB,MemSQL,Oracle TimesTen是具有关系模型的内存数据库。RAMCLOUD开源具有持久性key-value存储。Redis/Couchbase通过异步写入磁盘提供较弱的持久性。
内存数据库的性能
内存数据库的性能优势并不是因为它们不需要从磁盘读取(因为基于磁盘的存储引擎也会充分利用内存),而是因为它们避免使用写磁盘的格式对内存数据结构编码的开销。
内存数据库还可以提供基于磁盘索引难以实现的某些数据模型。例如:redis的优先级队列/集合。
最新发展是将最少使用的数据写入磁盘,将来访问时再写回内存。这样可以支持超过内存大小的数据量。
事务:是指一个逻辑单元的一组读写操作。事务不一定具有ACID属性,而是事务通常需要ACID属性。
OLTP:online transaction processing ,在线事务处理。通常是与用户交互有关的业务逻辑。
OATP:online analytic procsssing 这是与数据分析相关的逻辑。OATP数据量可能比OLTP大3个数量级(GB->TB)。
数据库通常可以同时用于OLTP和OATP,也可能单独建立数据库用于OATP,这是叫数据仓库。
ETL:Extract-Transform-Load,将数据导入数据仓库的过程 提取-转换-加载
数据仓库常见数据模型是 关系型。数据仓库通常都具有SQL查询接口,内部实现则差异很大,会针对不同的查询模式进行优化。
大量基于Hadoop的SQL项目,如:Apache Hive,Spark SQL,Cloudera Impala,Facebook Presto,Apache Tajo,Apache Drill,其中一些基于Google Dremel构建。
数据仓库的数据模型较少,常见的是“星型模式(维度建模)”:以一个事实表(fact_table,可理解为交易记录)为中心,对事实表的每一列都能联系到一个另外的表(维度),如产品id-产品表。这种模式产生出的就是一个中心,环绕多个表。像星型。
星型模式的数据仓库中。维度表通常较小,而事实表则大得多。如何高效低存储和查询这些数据成为一个挑战,所以我们需要"列式存储"。
优势
分析问题通常只需要数据的某几列。
问题:有一个交易表,现在要分析一年内人们购买水果/糖果是否取决于一周中某几天。
如果是通常的OLTP数据库,则需要加载所有行记录文件。列式存储则不同,只需要加载日期、产品、数量三个文件。每个列文件以相同的顺序保存记录,位于同一索引位置的数据就是一条交易记录的数据。
列压缩
列式存储的另一个好处,例如产品交易记录可以有数亿条,而产品ID则可能只有数万种。有很多重复的内容就有了压缩的前提条件。
例如使用‘位图索引’:建立产品ID的位图索引,苹果:0000001000000…,香蕉:10000000000…,
其中每一位代表一行,0/1代表交易是否有该产品。虽然原始位图可能长达数亿位,但每一个位图都会有非常多的0,很容易压缩。
由于列数据压缩,可以高效地利用CPU L1缓存,以便进行一大段数据的操作,这种技术被称为矢量化处理。
列排序
列排序是按照某个key排序,显然这导致的问题是其他列也按照该key排序,所以在查询/压缩时可能只有第一列有很好的优化效果。C-Store引进了一种改进想法(Vertica采用):考虑到不同的查询从不同的排序中获益,那么在建议数据冗余时,可以以不同的排序方式存储数据。
写操作
由于列存储多是面向查询优化,所以列存储的写操作很困难。例如压缩的列,要想原地更新的方式插入一个数据,需要先解压缩列,插入数据,最后再压缩。解决方案是将列数据整体读到内存中维护,积累到足够的更新以后,批量写入新文件,这就避免了原地更新的诸多不便。
聚合操作:sum,count,avg,min,max.
创建聚合操作结果的缓存的一种方式是物化视图(显然它会影响OLTP数据库的写入性能)。
物化视图可以加速某些查询操作,缺点也很明显,灵活性不高。
这一章讨论的问题是数据如何应对变化。关系型数据库通过ALTER语句改变模式,而文档型数据库存储json数据时,无模式,应对变化无需改变。
变化时通常需要考虑双向的兼容性:
向后兼容
新代码可以读取旧代码写入的数据
向前兼容
旧代码可以读取新代码写入的数据
向后兼容通常比较容易,只需要在编写新代码时注意即可。而向前兼容则比较棘手,编写旧代码时不会知道将来的变化,通常能做的事尽量考虑到数据模式的演变,让旧代码能够有一定的适应变化的能力。
程序通常的使用的两种不同的数据表示形式:
1.在内存中,数据保存在对象,结构,列表,数组等数据结构中(使用指针寻址)
2.将数据写入文件/通过网络发送时,必须将其编码为某种自包含的字节序列(如JSON文档).
因此数据需要在1,2两种情形之间转化,1->2称为编码(序列化),2->1解码(解析,反序列化).请注意加密不是编码,属于不同的范畴。
许多编程语言内置支持了序列化工具,如java.io.serializable,第三方库Java Kryo.
问题:
这里方案具有一定的通用性,如JSON/XML/CSV,但也有一些问题:
二进制编码较适合在组织内部使用。二进制的解析是一个问题,因此出现了一些二进制编码工具包,用以支持JSON(如MessagePack,BSON,BJSON,UBJSON,BISON,Smile)和XML(如WBXML,FastInfoset)。
这类工具包的原理都是将JSON以一定的格式(模式)编码成二进制,解析端需要使用同样的工具解析,因此限制颇多只适合在组织内部使用。
Apache Thrift/protocol Buffers是基于相同原理的两种二进制编码库。他们都需要模式来编码任意数据:在读取数据时,将按照模式规定的顺序遍历每一个字段,这意味着读取模式和写入模式必须完全一致,否则将会导致读取失败。
Apache Avro是另一种二进制编码格式,它是Hadoop的子项目。Avro也需要指定数据的模式。但Avro不是严格依赖静态模式,Avro同时支持动态模式(类似用map/json来表示对象,map/json具有高度的通用性)
Avro支持模式演变:Avro在读取时,不必要求读模式与写时模式完全一致。关键点在于Avro内部在读取时提供了模式转换,Avro会比较读时模式和写时模式,忽略两者的差异字段;按照字段名字匹配,这样字段的顺序也不要求严格一致。实现的一个关键点是Avro会将写模式直接记录在数据文件开头。
Avro对特定问题进行了优化,特别是记录了数据所用的上下文,例如:
为什么要使用二进制编码?
二进制编码相比JSON/XML及其变体的最大优势: 数据更紧凑,由于模式的存在,编码时可以省略字段名称。
探讨最常见的进程间数据流动方式(数据库,服务调用REST/RPC,异步消息传递)涉及的数据编码解码问题。
有两种流行的WEB服务方法:REST/SOAP.
REST/SOAP
REST不是一种协议,而是一种基于HTTP原则的设计理念,使用URL标示资源,使用Http功能进行缓存控制、身份验证等。SOAP是基于XML的协议,最常用HTTP但避免使用大多数HTTP功能,SOAP自身实现这些功能,因此SOAP协议非常复杂。
REST比较简单,可以利用工具如swagger生成代码和文档
RPC(remote procedure call远程过程调用)是一种思想,该思想试图向远程网络服务发出请求看起来与在同一进程中调用方法相同(这种抽象称为位置透明
).JAVA的远程方法调用(RMI)和JavaBeans(EJB)是该思想的实现。
RPC的思想是有根本缺陷的,与本地调用有区别:
这意味着RPC和本地调用是根本不同的东西,强行使之看起来像是无意义的。因此RPC的优点不是像本地调用,RPC的优点在于自定义的RPC协议比通用的JSON协议有更好的性能。
REST公共API的主流风格,而RPC框架则侧重于组织内多项服务之间的请求,通常发生在同一数据中心。
可以认为异步消息传递系统
是位于RPC和数据库之间的消息传递系统。它与RPC相似之处:客户端请求(消息)以低延迟传递到另一个进程。与数据库相似之处:不是通过直接网络连接发送消息,而是以消息代理为中介,中介会暂存消息。消息代理只负责传递数据,而不是像RPC在整个网络过程中还执行业务逻辑。
相比RPC,消息代理的优点:
消息代理是异步处理模式,RPC是同步。
常见消息代理:RabbitMQ,ActiveMQ,HornetQ,NATS,Apache Kafka
消息代理常见的使用方式:一个业务进程向指定的队列或主题发送消息,代理确保消息被传递给队列或主题的一个(或多个)消费者(订阅者)。
消息是包含一些元数据的字节序列,因此可以使用任何编码格式,只要消费者和生产者能够协商好。
Actor模型是用于单进程中并发的编程模型。逻辑封装在Actor中,而不是直接处理线程(及竞争、锁等问题)。Actor与Actor之间只通过消息代理通信。
现在将Actor模型扩展为分布式Actor模型,能够跨越多个节点来扩展应用程序,其实质就是将消息代理和Actor编程模型集成到单个框架中。
三种流行的Actor框架处理消息编码的方式如下:
总结:编码解码的关键问题是向后/向前兼容性和滚动升级。结论是这两点都是可以实现的。