本篇来聊聊数据存储的内容,看看程序世界里数据是以什么形式存在的?为了描述数据并把它们和这个现实世界关联起来我们一般都是如何去进行表达的?最后通过我们习惯的表达方式再结合数据结构是如何存储下来的?
在进行技术方案设计的时候,第一步大多都是围绕当前的业务模型先进行抽象建模,通过数据模型先表述清楚业务形态,因为万事万物都是数据各种形态的集合。一言以蔽之,数据建模就是在设计合理的数据关系、使用合理的存储方式让程序世界能够高效、灵活地支持业务世界的运转。
数据有哪些特点
站在不同的视角可以给数据进行多种分类,这里我们使用从原子到组合、从简单到复杂逐层递进剖析。
简简单单的原子性
拿找工作时填写简历作为例子,每个人的名字、性别、年龄等都是最原子的数据表达,因为每个字段它都没有二义性,即使填写内容的名字可能重复、性别相同、年龄相仿也不影响它表达的就是你叫什么、是男还是女、多大岁数。
优缺点 |
描述 |
举例 |
优点 |
原子唯一性,理解成本低,数据表达的原子基石 |
数据库中符合三范式设计的字段; |
缺点 |
表达单一,承载能力不丰富 |
- |
凡事都有两面性,如果只是在导出一个学校班级人员的姓名、成绩等简单数据就可以搞定,数据最本质的工作就是记录、传递、存储信息,而现实世界是丰富多彩错综复杂的,需要数据有更多的描绘能力以适配,但是无论多复杂的数据结构,都来源于最朴实无华的一个个小原子体。
无限可能的组合性
再回到写简历的例子上,工作经历信息就是原子要素组合而来,因为工作经历里常见的会包含工作类型(实习/兼职/正式)、公司名称、工作起始时间等等,它是需要其他字段来做支持的,所以这里的工作经历是一个组合性的概念。
优缺点 |
描述 |
举例 |
优点 |
支持复杂多样的数据结构,描绘表达能力丰富 |
Redis中有序集合、字典表等; |
缺点 |
结构复杂,有理解成本,对存储、查询、变更有性能诉求 |
- |
数据的组合性更具有现实意义,通过组合我们可以建立起形态各异的模型关系,根据每一种模型关系的特点应用在不同的具体场景中,它拥有自配的数据存储方式和数据查询方式,是日常开发中涉及使用最多的。
数据有哪些模型
数据结构如何分类?
在数据结构中根据数据关系、数据集合的不同一般划分四类:集合结构、线性结构、树形结构、图状结构,它是认识数据关系的基石,是进行数据建模的前提。
数据分类 |
数据特点 |
数据结构 |
举例 |
集合结构 |
数据同属一个类型,与其他数据无任何关系 |
集合 |
仓库中的商品 |
线性结构 |
数据之间存在一对一关系 |
数组、链表、队列、栈 |
编组的火车、叠放的盘子、买票的人群 |
树形结构 |
数据之间存在一对多关系 |
二叉树、红黑树、多叉树 |
家族谱、河流脉络、组织机构关系 |
图状结构 |
数据之间存在多对多关系 |
图 |
网络链路、社交关系 |
数据模型
数据模型一般有层次模型、文档模型、键值模型、图状模型、关系模型等,而关系模型是业务开发中涉及使用最多的,又可以分为一对一、一对多、多对多的逻辑关系。梳理清楚业务数据之间的关系,才能进行更上游的架构设计与流程驱动。
层次模型
现实生活是一个充满秩序的社会,秩序需要约束,而约束来源于规则,维护规则的原则之一就是要有层次。
技术中的层次之美
做久了或者见多了技术架构设计的朋友可能都听过这么一句话,“实在不行就加一层”。加一层的意义是在于让边界更清晰、职责更明确,数据和功能更聚焦和内聚,根本目的就是怕“乱”,像TCP的分层架构、应用开发的MVC设计、各端开发的工作分层,处处都在实践着层次之美。
层次的重要性
数据的存在不可能“独善其身”,或在构建更复杂的组合体,或在维系着错综的关系。分清主次、边界、组合逻辑才能更好地去进行有的放矢。
文档模型
自述特性,突破约束
文档模型主要是存储以松散、自适性结构的数据为主,不需要提前定义好存储结构和形式,数据通过特定组织结构可以自述逻辑关系含义,同类多条数据存储支持差异化按需存储,没有格式约束,具有较强的自述性,对变更友好。
举例一个场景,京东商城的商品种类覆盖非常全面,根据商品种类的不同会衍生出千差万别的商品属性,除了通用的商品名称、颜色、价格、生产日期等,还需要根据种类进行领域精分,比如食品类专属的口味、配料表、制作工艺、烹饪方式等,家用电器类专属的机电型号、电器配置参数、适配电压等......
如果使用传统关系型数据库先定义表结构再存储数据的理念,想必程序设计者会崩溃,因为属性标签实在过于庞大了,我们不能无穷尽地增加字段来支持一个个属性字段,而且即使维护了也会面临两个核心问题,一个是表字段过于多,线性增长到百千级别存储一行数据简直是天方夜谭,而且DBMS也有字段数量限制,另一方面,即便没有突破DBMS限制也会存在大量数据空间浪费,因为并不是所有属性字段都会被用到,数据表定义是面向属性相同数据结构进行建模的,很明显它对这种差异化场景是不友好的,也正因此,文档模型的松散性、对数据定义的开放特点才特别适合。
从XML到JSON,再到BSON
一般而言我们会使用JSON
、XML
等作为文档模型数据的存储的组织方式,因为JSON
、XML
支持常用的数值、字符串数据类型,还支持MAP
、ARRAY
、LIST
等组合数据结构的表达能力,它突破了传统关系型数据表结构定义的局限性。相比JSON
而言,XML
结构表达上约束更强一些,可以配合其他业务层逻辑进行更多对数据能力的辅助和增强,比如可以给字段定义增加属性,如限制类型、定义长度、数量等,但是在空间占用上因为提供更多描述能力也耗费更多,显得更为“笨重”。
在RESTful
还没有兴起之前,像WebService
这种侵入性、定制化的服务交互是完全依赖XML
的,某种程度上它很像时下微服务架构中RPC
服务交互的“远程版”。在对接一些传统行业或者不太赶新潮的外部业务时,XML
依然生命力旺盛,而在互联网业务中几乎不会看到使用XML
来进行数据传输的组织方式,更多的是以服务配置文件的形式存在,如Spring
、Mybatis
等框架中的各种配置,而一种更为轻便的数据组织方式YAML
正在逐步甚至在某些团队已经完全替代它。
MongoDB
是文档模型存储的佼佼者,在数据格式上主要使用到的是在传统JSON
的基础上发展出的Binary JSON
,即BSON
,支持如Date
、BinData
等更多数据类型,提供了效率可观的遍历能力。既要保持松散自述性的特点来打破数据预定义约束的初衷,又要在此基础上获得更多数据存储、检索效率的平衡,这是文档模型数据库努力的方向。
键值模型
<K,V>结构,神奇的字典表
键值模型的存在最熟悉的莫过于编程语言中的字典表(Map)
,它是由Key-Value
结构组成的。生活中最常见的字典表应用如书籍的目录,通过短小精简的排列来指定与其相关内容的所属页码范围,通过书籍目录就可以快速检索到内容进行查阅。之所以可通过目录索引快速检索到内容,正是因为索引与内容的唯一映射性,可做到快递地“指哪打哪”,因此字典表对于Key
是有唯一性要求的,一般可以支持数值、字符串类型,对于Value
支持的数据类型或结构则可以增加复杂性进行扩展。
关于Value
的扩展和应用在Redis
中体现得是最为淋漓尽致的。Redis
是一个标准的<K,V>
结构数据库存储模型,常见数据类型String
、List
、Set
、Zset
、Hash
都是基于Value
在进行拓展。因此,Key
体现的是数据检索的“快”,而数据存储的多样化与丰富性全部要在Value
进行。
“上瘾”的套娃机
不难发现我们的使用姿势都是依赖Key
的唯一特性获取O(1)
的检索效率拿到Value
目标数据。有意思的是,这种唯一映射的检索特性支持无限“套娃”会让我们上瘾,但切不可“贪杯”,适当的使用1到2层是可以接受的,单纯的线性依赖也会退化成O(N)
效率,需要考虑设计的合理性,最简单的思考路径就是把这些套娃的Key
直接拼接成一个组合Key
是不是就依然保持键值对的特性了。
适当“套娃”在数据持久化上并不常见也并不推荐,但是在应用层使用得相对广泛,数据层的存储和用户层的展示存在较大差异,需要应用层的数据改造转换,这时候便是字典表
大展身手的时刻了,相信你也编码过从不同服务拿来默认的List
结构的数据源进行数据组合的代码片段,如果这里有三个List
集合如何拍平到一个大的List
中给到前端小伙伴进行展示呢?如果你写三层For
循环你的组长会疯掉,但相信聪明的你已经学会了“套娃”,用两个Map
兜住需要匹配的数据作为Key
遍历后暂存,然后再遍历一次整合出来就完成,时间复杂度从O(N³)
降低到了O(N)
。
类型 |
结构 |
举例 |
简单键值模型 |
<K,V> |
<姓名,性别> |
复杂键值模型 |
<K,Object> |
<姓名,工作经历[{公司名称A、起止时间A、工作类型A},{公司名称B、起止时间B、工作类型B}]>; |
|
<K,<K,<K,V>>> |
<工作经历,<年,<项目,工作内容>>> |
关系模型
关系模型算得上最为熟悉的模型,因为在编程构建中离不开“对象”,特别是在程序设计领域,我们被动灌输或主动发觉到,生硬的0和1更面向硬件机器,而“万物皆对象”的世界描述让我们有了从抽象层到现实转换的桥梁,让编程充满温度。
提到对象与数据的模型映射,这里不得不提到ORM
(Object Relational Mapping),即对象关系映射。简单而言,它在做的就是把数据层的表字段定义与应用层的对象实体打通,像操作对象逻辑那样简单地控制数据变化而不需要过多去处理数据层的复杂性,像Java
中的Hibernate
、Mybatis
、Golang
中的gorm
等。
关系模型我们按照实体与实体之间的串联度分为一对一、一对多、多对多三类。
一对一
一对一模型非常简单,就像一个人对应只能有一个妈妈。
一对多
一对多模型会更加扩散,就像一个人对应可以有多个孩子。
多对多
多对多模型最为复杂,就像一个人的不同朋友之间会对应复杂的多对多的相识关系。
图状模型
关于图状模型,可以说是生活中最广泛的,尤其在地图导航、社会关系中体现的最为明显。在数据结构世界中,图相比数组、链表、队列、栈等是较为基础成员来说更为复杂,相关问题的求解过程更艰难,也真因如此才会显得愈发迷人。
复杂的网络关系
生活中处处体现图状模型,让我们互通互联的网络,与人相处的关系圈,交织复杂的道路,等等。我们常说点、线、面组合三维一体形成几何变化万千的世界,从某一个侧面或整体俯瞰就是一个个图状模型的建模。滴滴打车、高德地图、京东物流、顺丰快递、微博、朋友圈,无论形态是聚焦在出行、物流、快递、社交、新闻,都离不开图状模型的构建来支持业务运转,所谓术业有专攻,对于图状模型关系,特定领域才会更多涉及并提高对它的关注度,甚至投入资源进行相关图数据库的开发来满足业务支持。当下较为流行的图数据库有- Neo4j、Microsoft Azure Cosmos DB、 ArangoDB、Virtuoso、OrientDB。
图数据库,异军突起
在过去乃至今天的很长一段时间里,关系型数据库一直是主力军,在DB-Engines的数据库排名中,TopN的主旋律一直是Oracle
、MySQL
、SQL Server
、DB2
这些Relational Model
老朋友的身影。
随着分布式、大数据的兴起,对于数据存储、使用诉求的差异化、性能追求,我们又迎来了在K-V
存储基础上扩展多种数据结构、高性能Memory DB
—— Redis,为非结构化数据存储而生的Document DB
—— MongoDB,高性能搜索引擎 —— ElasticSearch等等,以上列举是足够通用性的数据存储和中间件,无论何种业务都可以使用,基本成为了流行的开发数据层标配组件。
关系模型库 vs 图数据库
模型 |
存储模型 |
数据查询模型 |
查询性能 |
关系型数据库 |
行式存储,二维 |
扫描行(scan/select)、连接行(join)、过滤行(filter) |
以表为单元,独立存储,联表查询性能差 |
图数据库 |
集合存储,点、边 |
从一系列的起点开始,通过多步遍历图形,每一步从当前的节点开始,遵守一定的关联关系(边)到达相邻点 |
数据可以怎么存储
数据怎么存储最合适,需要看拿数据来做什么,如果只是记录存档后续不再理会,我想怎么存储都可以,大部分业务场景存储之后还需要进行查询、修改甚至删除,基于成本考虑可能要考虑下尽量节省空间,还有一个基本诉求就是尽量快速地查询到数据,再丰富下场景还会有维度条件筛选、多数据连接等......基于此,基于合理的存储结构进行设计和适配,让正确的东西做正确的事情就是一个大方向不会偏离很远、对于后续实践也有很好引导作用的出发点。
大部分业务的现实情况基本都是读多写少,这个比例最夸张的时候可能可以达到99:1甚至更多,下面我们主要从读的角度来剖析下在数据读取上有哪些挑战,结合具体的存储结构看看各自有哪些强项与不足。
读取数据都有哪些姿势
读类型 |
描述 |
单点读 |
只查询一条数据 |
范围读 |
查询范围内逻辑连续存储紧密的数据 |
离散读 |
查询多个不连续存储分散的数据 |
全量读 |
查询集合中全部数据 |
连接读 |
查询多个存储集合单元并带有一定逻辑联系 |
过滤读 |
根据条件进行过滤查询,剔除掉不符合条件的 |
聚合读 |
按一到多个条件进行聚合,统计其他数据项的结果 |
计算读 |
对数据进行计算加工,如转换、拼接、替换等 |
行式存储
行式存储,可谓是“老牌劲旅”,传统的关系型数据库大部分都选择行式存储作为基础,一个数据集合单元存储在一个Record
中,比如有一张User
存储用户姓名、年龄、职业,存储结构如下:
- |
Column(姓名) |
Column(年龄) |
Column(职业) |
[Store] |
张三 |
25 |
程序员 |
[Store] |
李四 |
35 |
个体 |
[Store] |
王五 |
45 |
设计师 |
- 『单点查询』 单条数据存储紧凑,对
I/O
友好,一次查询可以Load
出所需数据。快速检索和定位到数据行的方法可以参考Innodb
中B+Tree
的实现,所有数据都会有一个用来做聚簇组合的核心主键(Primary Key)
,将其按照默认排序方式挂载到B+Tree
上保持有序性,依赖树Log(N)
的时间复杂度保证一个相对稳定的数据检索效率,找到数据行的方法就是通过维护这颗索引树挂载一个个主键来找到对应数据,而数据行的数据内容也会绑定并存储在该索引树上。大部分场景下主键(Primary Key)
是没有业务含义的,且也不应该具有业务含义,它单一又确定的职责就是作为串联节点挂载在索引树上,一边串联数据整体的有序性,一边关联自己代表的数据行,想通过其他字段来获取数据或快速定位到数据行需要借助新的索引,一般可以称为二级索引
,这个索引的作用和主键索引类似,都是维护索引树有序性,不同点在于它并不存储数据行内容,也不直接串联数据行,而是通过串联主键进行数据行的间接串联。 - 『范围查询』 标准的范围读是不会跨越太多数据行的,对于行式存储来说,一定逻辑关系的数据一定会按照给定顺序紧密依序存储,因此它对
I/O
也是友好的,一次查询Load
出的数据行都是迎合诉求的,不需要做过多筛选,避免了频繁、多余读取带来的额外性能损耗。 - 『离散查询』 由于行式存储在单点查询上拥有较高性能,离散查询在一定程度上只是单点查询的条件组织,单点查询的时间复杂度如果是
Log(N)
,那么离散查询可以笼统表示为N*Log(N)
。 - 『过滤查询』 所谓过滤查询即条件查询,需要按照条件也就是数据行中的列进行筛选后将数据返回,最快捷的做法是先根据输入条件将不需要的数据筛选掉,剩下的就是真实诉求的数据行,关于这部分的查询最常见的问题就是单表的慢SQL现象,最熟悉的优化手段就是配置合理索引也就是增加符合条件的辅助索引加速数据的甄别和筛选。
- 『聚合查询』 聚合查询都是围绕行式存储中某个字段展开的,而数据的读取是基于行,并不能基于列,比如现在有10条数据行,我要按照年龄聚合,需要把所有行数据都查出来然后这时只需要聚合统计的列数据,其他列虽然也被
Load
出来但是毫无用处,造成I/O
损耗,增加了读盘压力。
可以看到,行式存储在单点、离散、范围查询的处理上是相对友好的,条件查询也可以通过添加辅助索引来进行数据筛选保持较高效率,不足之处在于聚合查询并不是它的长处,虽然支持但并不是强项,而且在数据存储达到一定体量后劣势凸显就会愈加明显。
列式存储
列式存储在迎合数据分析的场景下应运而生,它从列计算的统计诉求出发,在数据存储的“根儿”上先人一步进行治理。下面我们使用行式存储的数据来进行列式存储变换,如下:
[Store] Column(姓名) |
[Store] Column(年龄) |
[Store] Column(职业) |
张三 |
25 |
程序员 |
李四 |
35 |
个体 |
王五 |
45 |
设计师 |
可以看到过去列组合存储的形式变成了列聚合的存储形式,数据紧凑型上从A1B1C1
、A2B2C2
的形态变成了A1A2
、B1B2
、C1C2
。这样单独读取A
字段数据时候只需要读取A
字段存储即可,减少了不必要的数据内容Load
,对于I/O
是友好的,在OLAP
场景下做数据统计、聚合非常有帮助,存储结构的天然优势带来了性能上的巨大收益。
键值存储
K-V
的数据结构的特点是拥有O(1)
时间复杂度的数据查询定位能力,具体的数据装载及表达的丰富性依赖于Value
的选择和加持。
快到极致的数据索引能力并不是没有任何约束和成本的,一般而言在同一个数据区域或容器内,Key
是不允许重复的,如果允许重复或不可避免地出现所谓的“碰撞”,也可以通过解决方案进行支持,但可能在一定程度上降低查询效率,感兴趣的朋友可以探究下哈希表
的实现。对于Key
的定位就如同B+Tree
中的主键(Primary Key)
的设计思路,核心作用还是起到数据索引的能力,一边串联数据排序,一边串联数据内容。
Value类型 |
举例 |
字符串 |
单字段,"张三" |
字符串 |
序列化如JSON,{"name":"张三","age":"25"} |
列表 |
[<用户A>,<用户B>] |
哈希表 |
<UID,张三>,<UID,李四> |
而对于Value
的定位,我们把它可以等同于关系型数据库中的记录行Record
,只不过这个数据记录可以灵活的支持各种形态的存储。我们不难发现,与关系型数据库相比之下,K-V
存储中数据内容的形式不光光是千篇一律规矩摆放的数据内容,以往是只要借助索引定位到数据就可以Load
出,而对于键值存储来说,只有简单的字符串可以勉强做到,而对于复杂结构的Value
来说,具体的数据定位工作可能还需要在数据内容中再做一层查询检索或剖析才可以拿到等同于关系型数据库中的Record
,正因为关系型数据可以在数据内容中直接拿到目标数据,因此它才能支持sql语言进行数据检索,而所谓的nosql语言在一定程度上因为数据内容存储的灵活性并不能第一时间开箱即用得到目标数据,如果把sql比作是一把钥匙,那么sql系存储会按照规矩把数据都一个个放好在门里,拿着钥匙找到门牌号就能打开门拿走数据直接用了,而nosql系存储的也会把数据放在一个个门里,但是存放的太过"随意",即使拿钥匙打开了门,但是发现最终想要的目标数据可能还在一个个箱子里,箱子里可能还有盒子......
对比之下,键值存储适合存储那些需要第一时间定位到数据内容,而不需要关心是否拆解数据内容的用数场景,由于数据定位依赖简易几乎不可重复的Key
来导航,导致在连接读、聚合度、过滤读、计算读等场景并不适合,它的核心应用场景在于数据内容较小、需要快速定位的单点读、离散读。
小结
以上提到的数据存储方式只是逻辑顺序上的,并没有提到数据内容本身的存储方式,这里简单扩展下,如果数据内容是一个人的基本信息,那么你可以选择套一层xml或者json封装下也可以选择类似占位式、二进制方式摆放,如下:
方式 |
举例 |
特点 |
顺序排列 |
|
空间占用少,可读性一般,需要记录每个含义字段的范围索引来进行读取或者需要借助额外分隔符占用空间辅助 |
xml |
|
序列化方式,占用空间大,表达能力好,也可以支持基于字段基础上拓展 |
json |
|
同xml特点,但是空间占用更小一些 |
二进制 |
|
如果数据内容是积木,存储方式讲的是我们如何去“摆放”它,是横着放还是竖着放,是平躺着放还是倒立着放,不同的摆放方式对应着不同的能力,而数据内容自身的存储可以从可读性、空间占用多个角度来考虑选择。