数据存储系统概要

可靠、可扩展与可维护性

现在有很多都属于数据密集型,而不是计算密集型。对于这些类型应用,CPU的处理能力往往不是第一限制性因素,关键在于数据量、数据的复杂度及数据的快速多边形。
数据密集型应用模块:

  • 数据库:存储数据,支持二次访问。
  • 高速缓存:缓存复杂或操作代价昂贵的结果,加速下次访问。
  • 索引:按关键字搜索数据并支持各种过滤。
  • 流式处理:持续发送消息到另一个进程,处理采用异步方式。
  • 批处理:定期处理大量的累计数据。
    我们常见的数据库、队列、高速缓存,这些统称为“数据系统
    下面是一个常见的系统架构:

在这里插入图片描述
但是现实当中程序跑起来,并不是理想态的,会出现各种问题。例如:系统内出现局部失效,如何保证数据的正确性与完整性?发生降级该如何为客户提供一致的服务?负载增加,系统如何扩展?基于这些我们引入下面三个系统特性:

  • 可靠性(Reliability)
    当出现意外(硬件、软件故障、人为失误等),系统应可以正常工作;性能可能降低,但确保功能正常。
  • 可扩展性(Scalability)
    随着数据规模的增长,例如数据量、流量或复杂性,系统应以合理方式匹配这种增长
  • 可维护性(Maintainability)
    随着时间推移,新人加入系统开发和运维,系统都应搞笑运转。

可靠性

对于软件,典型的期望包括:

  • 应用程序执行用户期望的功能。
  • 可以容忍用户出现错误或不正确的使用方法。
  • 性能可以应对典型的场景、合理负载压力和数据量。
  • 系统可防止任何未经授权的访问和滥用。

可扩展性

描述负载

首先看Twitter典型业务操作:

  • 发布tweet消息:用户可以推送消息到所有关注者,平均大约4.6k request/sec,峰值约12k request/sec。
  • 主页时间线浏览:平均300k request/sec 查看关注对象的最新消息。
    细心的人会发现,重点不是要推送的消息太多,而是巨大的扇出(fan-out)结构:每个用户会关注很多人,也会被很多人圈粉。可以做如下处理:
    (1)将发送的新消息插入到全局的tweet集合中。当用户查看时间线时,首先查找所有的关注对象,列出这些人的所有tweet,最后以时间来排序。类似的SQL语句:
SELECT tweets.*, users.* FROM tweets JOIN users ON tweets.sender_id = user.id JOIN follows ON follows.followee_id = users.id WHERE follows.follwer_id = current_user

关系模型支持时间线:
在这里插入图片描述(2)对每个用户的时间线维护一个缓存,类似每个用户一个tweet邮箱。当用户推送新的tweet时,查询其关注者,将tweet插入到每个关注者的时间线缓存中。(加一层缓存机制)

如图:在这里插入图片描述
此方式明显的缺点是增加工作量,假如75个关注者和每秒4.6k的tweet,则需要每秒4.6 X 75 = 354k速率写入缓存。如果一个用户关注者很大,很难实现。
我们可以根据用户使用Tweet频率进行加权,目前Tweet是基于方法2实现的,但是正在转向两种方式相结合的方法。

可维护性

一个很好的软件系统,不能只是停留在刚开始的时刻。也要考虑后期的维护成本,新人的适应成本,可以归结为三个原则:

  • 可运维性:方便运营团队来保持系统的平稳运行。(观测、监控、自动化、可预测)
  • 简单性:简化系统复杂性,使新工程师轻松理解系统。(抽象、易懂、干净、简洁、优雅)
  • 可演化性:工程师能够轻松对系统进行改进,并根据需求变化将其适配到非典型场景,也称为可延伸性、易修改性或可塑性。

数据模型与查询语言

开发任何软件都离不开数据模型,它是基建。复杂的程序可能会有很多中间层,但基本思想相同,每一层通过提供一个简洁的数据模型隐藏下层的复杂性。

数据模型

层次模型

它将数据表示为嵌套在记录(树),可以支持一对多关系但是支持多对多关系比较困难。为了解决这个问题,后来出现了关系模型(relational model)和网络模型(network model)。

关系模型

现在最流行的数据模型就是SQL,其目标就是将实现细节隐藏在更简洁的接口后面。而NOSQL位居第二,它可以提供超高速的写入吞吐量,支持一些特定的查询操作。目前很多应用系统是两者相结合实现。

网络模型

它和层次模型的区别在于,一个记录可以有多个父节点,记录之间的链接不是外键,更像是语言中的指针,访问数据的唯一方法是选择一条始于根记录的。(类似图、链表)

文档模型

它主要解决的是局部信息聚合作用,可以方便一次性查出来或表示。但比较明显的缺点是,不能多表联合操作查询。

图状数据模型

图有两种对象组成:顶点(也称为结点/实体)和边(也称为关系/弧)随着数据之间的关系越来越复杂,将数据建模转化为图模型会更自然。

这么多模型,我们该如何选型呢?通过描述大家也能知道每个模型的特点。

  • 应用数据类似文档(一对多关系树,通过一次加载整个树),选用文档模型更合适。
  • 关系模型更倾向于数据分解,把文档模型分解为多个表。
  • 图比较适合社交网络、Web图、道路网
  • 目前大多数数据库都支持文档和关系表。

数据查询语言

有声明式查询(SQL)和命令式查询语言(IMS、CODASYL)。
命令式:

function getSharks() {
    
    
	var sharks = [];
	for (var i = 0; i < animals.length; i++) {
    
    
		if (annimals[i].family === "Sharks") {
    
    
			sharks.push(animals[i]);
		}
	}
}

SQL:

SELECT * FROM animals WHERE family = 'Sharks';

声明式语言是否并行的执行,而命令式语言由于指定特定的执行顺序,很难在多核和多台机器上并行化。

MapReduce查询

它是一种编程模型,用于在许多机器上批处理海量数据。它不是声明式也不是命令式,而是介于两者之间:查询的逻辑用代码片段表示,这些代码片段可以被机器重复的调用。它基于函数式编程语言中map(也称collect)和reduce(也称fold或inject)函数。

图查询

Cypher(以“黑客帝国”中角色命名)是一种用于属性图的声明式查询语言。

三元存储与SPARQL

三元存储中,所有信息都以非常简单的三部分形式存储(主体、谓语、客体),如:(坏小哥,喜欢,玩)中,坏小哥是主体,喜欢是谓语(动词),玩是客体。三元组的主体相当于图的顶点。而客体则是以下两种之一:

  1. 原始数据类型中的值,如字符串或数字。那么三元组的谓语和客体分别相当于主体的(顶点)属性的键和值。如:(hxg, age, 26)就好比顶点hxg,属性{“age”:26}。
  2. 图中的另一个顶点,此时谓语就是图中的边,主体是尾部顶点,而客体是头部的顶点。如:(hxg, love, hxm),主体hxg和hxm都是顶点,并且谓语love是链接二者的边的标签。
    SPARQL(发音:sparkle)是一种采用RDF数据模型的三元存储查询语言。

数据存储与检索

所有的系统都离不开数据交互,数据存储与检索/获取。一个最简单的数据库,它由两个Bash函数实现:

#!/bin/bash
db_set () {
    
    
	echo "$1,$2" >> database
}

db_get () {
    
    
	grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}

这两个函数实现了一个key-value存储。调用db_set key value,直接保存在数据库(文件)中。此方式采用追加的方式,如果多次更新某个建,旧版本不会覆盖,而是直接查最后一次出现的键,找到最新的值(tail -n 1)。但是显而易见,如果文件过大,db_get函数性能非常差,每次获取一个key,需要从头遍历,时间复杂度是O(n)。因此有了索引的概念,但是不管是什么索引,都会增加更新数据的开销。

哈希索引

通过hash map(hash表)实现索引,假如是上面采用追加的方式文件组成。可以保存内存中的hash map,把每一个key一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。
在这里插入图片描述
Bitcask(Riak中的默认存储引擎)采用的核心做法就是上面例子。可以提供高性能的的读和写,只要所有的key可以放入内存,只需要一次磁盘寻址,就可以将value加载到内存。当然,只追加一个文件不行,需要日志切割。还可以进行压缩处理,剔除重复的key,也可以压缩的时候,写入新的段文件中,同时将多个段进行合并。在真正的实现中,有以下重要问题:

  • 文件格式:日志追加应该使用二进制格式,首先以字节为单位记录字符串的长度,之后跟上字符串原始长度
  • 删除记录:想要删除键,必须在数据文件中追加一个特殊的删除标识(也称墓碑)。当合并文件时,一旦发现墓碑标记,丢弃这个key。
  • 崩溃恢复:如果数据库重新启动,内存hash map丢失,如果全部扫描文件加载,时间很长。Bitcask通过将每段hash map快照存储到磁盘,可以更快加载到内存。
  • 部分写入的记录:数据库随时崩溃,包括记录追加到日志的过程中。Bitcask文件包括校验值,可以发现部分损坏并丢弃。
  • 并发控制:由于数据文件是追加,不可变的,不是顺序写入,因此可以被多个线程同时读取。
  • 局限性:必须在内存,如果key的数据量过大,成本就很大。区间范围查询效率极低。
    有人提问,为啥不原地刷新文件,非要追加?
  • 首先追加和分段合并主要是顺序写,比随机写快得多。
  • 假如段文件是追加的或不可变的,则并发和崩溃恢复要简单得多,如:不必担心在重写的值时发生崩溃,留下既有旧值又有新值的文件。
  • 合并旧段可以避免随着时间的推移数据文件出现碎片化的问题。

SSTables&LSM-Tree

如果要求写入的key有序排列,这种格式成为排序字符串表,简称:SSTable。内存排序通常有红黑树或AVL树。
构建过程(LevelDB和RocksDB):

  • 写入数据,添加内存中的平衡树(如红黑树)中。这个内存中的树有时被称为内存表。
  • 当内存大于某个阈值(通常是几兆字节),将其作为SSTable文件写入磁盘,与此同时可以继续添加到一个新的内存表实例。
  • 处理读请求,尝试内存获取,然后是最新的磁盘段文件,最后一次遍历其他文件,知道找到目标。
  • 后台进程周期性的执行段合并与压缩,丢弃被覆盖或者删除的值。
    上述方案存在一个问题:如果数据库崩溃,最近写入的数据(在内存表中但没落盘)将会丢失,为此,可以先在磁盘保留单独的文件,每个写入都会追加该日志,崩溃后可以恢复内存表。每当将内存表写入SSTabl时,相应的日志可以丢弃。
    基于合并和压缩排序的文件原理的存储引擎通常都被称为LSM存储引擎

性能优化

  • 如果查找某个不存在的key,一直回溯访问旧段文件(多次磁盘IO)。基于这一点,存储引擎通常使用布隆过滤器。
  • LevelDB和RocksDB使用分层压缩,HBase使用大小分级,Cassabdra则同时支持这两种压缩。大小分级:较新的和较小的SSTable被连续合并到较旧的和较大的SSTable。分层:键的范围分裂成多个更小的SSTables,旧的被移动到单独的“层级”。

B-trees

它是几乎所有的关系数据库的标准索引实现,许多非关系型数据库也会经常用到。具体的实现细节可以我之前写的这篇博客:MySQL内部实现和优化的原理
为了解决操作需要覆盖不同的页,插入导致页溢出,需要分裂。此时如果数据库在完成部分页写入之后发生崩溃,最终导致索引破坏。因为需要加一个预写日志(write-ahead log,WAL),也称重做日志。可以支持追加修改,B-tree先更新WAL,再去修改树本身,当发生崩溃可以恢复到最近的一致状态。

性能优化

  • 写时复制:一些数据库不采用覆盖和维护WAL进行崩溃恢复,而是使用修改的页写入不同的位置,树中父页的新版本被创建,并指向新的位置。
  • 保存键的缩略信息,可以节省空间,可以将更多的键压入页中,让树具有更高的分支因子,减少层数(B+树)。
  • 有些B-tree实现对树进行布局,以便相邻的叶子页可以顺序保存在磁盘,随着树增加,维护这个顺序越来越困难。
  • 添加额外的指针到树中,每个叶子页面可能会向左和向右引用其他同级的兄弟页,这样可以顺序扫描(B+树)。

对比

  • LSM-tree对于写入更快,B-tree对读更快。
  • B-tree至少写两次数据,一次写预写日志,一次写树的页本身(还可能发生分页)。
  • 日志存储的缺点是压缩会干扰进行的读写操作,即使是增量的压缩,由于磁盘并发的资源有限,当压缩时,容易发生读写请求等待。
  • 如果写入量很大,但压缩无法匹配新数据的写入速率,磁盘会不断有未合并的段文件,直到磁盘空间不足。

其他索引

主键索引、二级索引、多列索引、全文索引、模糊索引在此不再赘述。

事务处理与分析处理

事务不一定具有ACID,事务处理只是意味着允许客户端低延迟读取和写入。
像博客评论、游戏动作、通讯录联系人等被称为在线事务处理(online transaction processing,OLTP)。
像数据分析,扫描大量记录,称为在线分析处理(online analytic processing,OLAP)。

数据仓库

数据仓库是单独的数据库,分析人员可以在不影响OLTP操作的情况下使用,存储的都是OLTP的副本。大型企业都会有数据仓库。
数据仓库和简化的ETL(Extract-Transform-Load)过程:
在这里插入图片描述

星型和雪花型分析模式

星型模式来源于当表可视化时,事实表位于中间,被一系列维度表包围,举个例子:个人博客,评论、点赞、浏览、文章类型等都围绕文章表展开存储,关联。该模板的一个变体成为雪花模式,其中维度进一步细分为子空间。

列式存储

有时候存储表的数据量很大,并且列很多。我们一次访问查询其中某几列,但是在大多数数据库中,存储以面向行的方式布局,**因此需要将所有行从磁盘里加载到内存,解析它们,并过滤不符合条件的行。**这需要很长时间。
如果采用列式存储,将每列中的所有值存储在一起,每一列存储在一个单独的文件中,查询只需要读取和解析在该查询中使用的那些列,可以节省大量的工作。

列压缩

一般列的压缩是根据位图进行压缩。
在这里插入图片描述

内存带宽和矢量化处理

对于扫描数百万/千万级别的数据仓库查询,将数据从磁盘加载到内存的带宽是一大瓶颈。我们需要考虑如何高效的奖内存带宽用于CPU缓存,列压缩可以使得列中更多的行加载到L1缓存。

列存储的写操作

目前采用LSM-tree。所有写入的首先进入内存存储区,将其添加到已排序的结构中,接着准备写入磁盘,当累计到足够写入时,它们将与磁盘上的列文件合并,并批量写入新文件。(商业数据仓库Vertica采用这种方式)

数据编码与演化

开发项目中,经常遇到上下游,甚至自己的代码逻辑数据格式/模式发生变化,这个时候需要对程序进行相应的调整(例如:向记录加字段,程序读取)。对于一个成熟庞大的系统,想要更新迭代并非易事。

  • 对于服务端程序,可能需要滚动升级(分阶段部署),部署到少数节点(或者称为放量),检查是否正常,逐步全量部署。
  • 对于客户端,只能依靠用户升级。

同时意味着新旧版本的代码,以及新旧数据格式,可能会同时在系统内共存。因此设计的时候需要保持双向兼容性:
向后兼容:较新的代码可以读取旧的代码编写数据。
向前兼容:交旧的代码可以读取由新的代码编写的数据。
向后兼容很容易,向前兼容需要旧代码忽略新版本的代码所做的添加。

数据编码格式

程序一般至少有两种不同的数据表示形式:

  • 在内存中,数据保存在对象、结构体、列表、数组、哈希表和树等结构中。
  • 将数据写入文件或者网络发送时,必须将其编码为某种自包含的字节序列。
    从内存的表示到字节序列的转化成为编码(序列化),相反的过程成为解码(反序列化)

语言的特定格式

往往每个编程语言都有自己的编码函数方法,另一个语言无法直接访问。解码也会带来安全问题。

JSON、XML与二进制变体

JSON、XML包括CSV都是文本格式,可读性很强。JSON区分字符串和数字,不区分整数和浮点数,并且不指定精度。大于2的53次方的整数在IEEEE 754双精度浮点数中不能精确表示。Twitter使用API返回JSON包含两次推特ID,一次是JSON数字,一次是十进制字符串,来解决JS没有正确解析数字的问题。缺点就是占用空间大。
二进制编码体积小,网络传输快,非常适合数据量多的场景。

Thrift与Protocol Buffers

Thrift定义数据格式:

struct Person {
    
    
	1: required string  userName,
	2: optional i64 age, 
}

Protocol Buffers 定义数据格式:

message Person {
    
    
	required string  user_name  = 1;
	optional int64  age         = 2, 
}

如果字段设置了required,但是字段如果没有被填充,运行的时候就会失败。

Avro

它是另一种二进制编码格式,它与Protocol Buffers和Thrift有着一些有趣的差异。Avro在2009年作为Hadoop子项目启动。
Avro IDL(人工编辑):

record Persion {
    
    
	string userName;
	union {
    
    null, long}  favorite = null;
	array<string> interests;
}

Avro JSON形式:

{
	"type":"record",
	"name":"Person",
	"fields": [
		{"name":"userName",    "type": "sting"},
		{"name":"favorite",    "type": ["null", "sting"], "default": null},
		{"name":"interests",    "type": {"type", "array", "items": "string"}},
	]
}

定义数据模式的优点:

  • 它们比各种“二进制JSON”变体更紧凑,可以省略编码数据中的字段名称。
  • 模式是一种有价值的文档形式,因为模式是解码所必须的,所以可以确定它是最新的。
  • 模式数据库允许在部署任何内容前,检查模式更改的向前和向后兼容性。
  • 对于静态类型的语言,从模式生成代码的能力是有用的,它能够在编译时进行类型检查。

数据流模式

数据是可以通过多种方式从一个进程流向另一个进程的。常见的数据流动方式:

  • 数据库
  • 服务调用(REST和RPC)
  • 异步消息传递

REST

REST不是一种协议,而是一种基于HTTP原则的设计理念。它强调简单的数据格式,使用URL来标识资源,并使用HTTP功能进行缓存控制,身份验证和内容类型协商。

SOAP

它是基于XML协议发送网络API请求。

消息传递(RabbitMQ、ActiveMQ、HornetQ、NATS、Kafka)

简单讲就是客户端以低延迟传递到另一个进程。
优点:

  • 它可以充当缓存区,削除峰值,提高系统可用性。
  • 它可以自动将消息发送到崩溃的进程,防止消息丢失。
  • 它避免发送方需要知道接收方的IP地址和端口号
  • 它支持将一条消息发送给多个接收方。
  • 实现了发送方和接收方分离。

以上是存储系统的简要总结。

猜你喜欢

转载自blog.csdn.net/weixin_43885417/article/details/130471848