进入数字化时代,企业在基础架构选型上早已经不局限于传统的PC服务器、刀片服务器以及小型机等,而是倾向于使用基于云服务提供的开箱即用的组件或者自建私有云等。云服务是针对底层物理硬件资源进行虚拟化,能够让用户快速构建更稳定、更安全的应用,降低开发运维的难度和整体IT成本。但它在提供便利的同时,也会导致整体数据存储链路、通信链路复杂度的提升,例如基于云服务器的Kubernetes集群构建的微服务集群。
因此在具体实践中,在定位问题时,从业者需要对底层硬件及其之间的关系有更加深刻的认知,进而选择更适合当前应用场景的架构。
计算机组成原理是计算机专业的学生必学的课程之一,但是笔者经多年观察发现,似乎很多从业者并不是很清楚计算机核心组件及其之间的关系,且无法将这些核心组件与不同的应用软件的特性建立有效的联系。例如计算机内部L1、L2、L3缓存的目的是什么?数据库为什么会存在锁的概念?为什么零拷贝技术(Zero-Copy)可以提高数据交互的效率?这些特性本质都依赖于当前计算机组成的架构。
冯·诺伊曼是现代计算机之父,他提出计算机应采用二进制、将程序指令存储器与数据存储器合并在一起存储,并由五个部分构成(运算器、控制器、存储器、输入设备、输出设备)。这也是冯·诺依曼体系结构的三个基本准则。其中运算器、控制器构成了CPU的主要功能;而内部存储器按照实际需要分为内存存储器以及磁盘;输入输出设备便是计算机相关交互设备,例如显示器、鼠标、键盘等。其中不同的组件对应不同的访问时间及容量,如表3-1所示。
表3-1 不同的组件对应不同的访问时间及容量
CPU(中央处理器),是电子计算机的主要设备之一。它的主要功能是解释计算机指令以及处理计算机软件中的数据,包括处理指令、执行操作、控制时间、处理数据。CPU是计算机中负责读取指令,对指令译码并执行指令的核心部件。它主要包括两个部分,即控制器、运算器,还包括高速缓冲存储器及实现它们之间联系的数据、控制的总线。
在计算机体系结构中,CPU 是对计算机的所有硬件资源(如存储器、输入输出单元)进行控制调配、执行通用运算的核心硬件单元,是计算机的运算和控制核心。计算机系统中所有软件层的操作最终都将通过指令集映射为CPU的操作。
在单核时代,CPU都是串行执行的,不存在数据一致性问题。但是随着技术的进步,进入多核时代,为了保证指令的执行效率,不同的CPU可能会在同一时刻操作相同的记录,出现数据一致性的问题,进而引出锁的概念。此外,现在多核CPU多采用L1、L2、L3等多级缓存,这虽然带来性能的提升,但是也带来数据在并发情况下数据一致性的问题。CPU多级缓存结构如图3-1所示。
图3-1 CPU多级缓存结构
从图3-1可以看出,CPU之间交互的便是RAM-主存,也就是我们常说的内存。
内存(Memory)是计算机的重要部件之一,也称内存储器和主存储器,用于暂时存放CPU中的运算数据以及与硬盘等外部存储器交换的数据。它是外部存储器与CPU进行沟通的桥梁,计算机中的所有程序都在内存中进行,内存性能的强弱会直接影响计算机的整体性能水平。只要计算机开始运行,操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成,CPU会将结果传送出来。
应用程序启动时,需要向操作系统申请一部分内存资源来处理应用相关数据,这部分内存空间称作用户空间;然而操作系统也需要内存资源完成自身正常的功能,这部分空间称作内核空间。访问应用时需要进行用户空间与内核空间的交互,如图3-2所示。
图3-2 用户空间与内核空间交互示意图
应用程序在访问磁盘文件时,会先利用DMA技术把文件内容读取到内核缓冲区,然后再把内容从内核缓冲区拷贝到用户缓冲区中。如果程序要输出,则会把用户缓冲区的内容再拷贝到内核的Socket缓冲区中,利用DMA输出。
操作系统为了减少甚至完全避免操作系统与应用程序之间不必要的CPU拷贝,减少内存带宽的占用以及用户空间与操作系统内核空间之间的上下文切换,提出了零拷贝技术。
Tips 零拷贝只是表示用户空间与内核之间未发生数据的拷贝,内核空间内部依然发生数据拷贝。
磁盘是计算机主要的存储介质,可以持久化存储数据。早期计算机使用的磁盘是软磁盘(简称软盘),现今常用的磁盘是硬磁盘(Hard Disk,简称硬盘)。硬盘又分为现在的机械硬盘与性能更好的固态硬盘。
磁盘存储的价格其实与数据模型的发展有一定的关系。例如在数据存储价格较为昂贵的时代,IT 从业者考虑的是如何在满足应用需求的前提下尽量减少数据的冗余。这个时候范式建模(现在不理解也没关系,后续会详细介绍)相对比较流行,因为范式建模可以消除数据冗余,减少数据存储的空间。随着存储价格逐步降低,维度建模逐步流行,它可以通过一定的数据冗余提高查询的效率。
Tips 任何技术或者方法论的流行如果离开其所在的时代背景或者技术限制都是缺乏意义的。
CPU、内存以及磁盘构成计算机组成的基础。对于数据存储来说,它基于计算机资源并且结合具体的应用程序提供相应的服务,必然也会延伸出一些核心的概念。
总的来看,数据存储的核心概念有3个,分别是事务、索引以及锁。事务可以实现操作的并发执行,索引可以提高数据的查询效率,锁可以提高数据的一致性。
事务就是一个对数据库操作的序列,是一个不可分割的工作单位,要么这个序列里面的操作全部执行,要么全部不执行。事务包含ACID特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)及持久性(Durability)。
❑原子性:事务中的所有操作作为一个整体像原子一样不可分割,要么全部成功,要么全部失败。需要注意的是,原子性的特点是指数据库操作层面的,而非所在服务器层面的。
❑一致性:事务的执行结果必须使数据库从一个一致性状态到另一个一致性状态。
❑隔离性:并发执行的事务不会相互影响,并发执行对数据库的影响和串行执行时一样。在数据库中通常存在四种不同的隔离,分别是读未提交(read-uncommitted)、不可重复读(read-committed)、可重复读(repeatable-read)及串行化(serializable)。事务隔离性是由数据库层面与CPU进行交互的策略不同导致的。
❑持久性:事务一旦提交,它对数据库的更新就是持久的。任何事务或系统故障都不会导致数据丢失。
索引是定义在表(Table)基础之上,快速定位所需记录而无须检查所有记录的一种辅助存储结构,它由一系列存储在磁盘上的索引项组成。索引与数据表一样,是需要存储空间的,并且索引的更新是依赖于被索引的数据表的字段的,所以在实际使用过程中索引的新增或者删除需要与业务场景进行结合。
数据库的索引类似于图书的索引。图书的索引允许用户不必翻阅完整本书就能迅速地找到所需要的信息。在数据库中,索引也允许程序迅速地找到表中的数据,而不必扫描整个数据表。在关系型数据库中,常见的索引是采用B+树的方式进行构建的。
从逻辑上来看索引主要分为主键索引、唯一索引、组合索引以及全文索引。主键索引表示该字段不为空且唯一,在数据库中一个表如果没有主键,则数据库会默认创建一个主键。唯一索引表示该字段值唯一,但是可以为空。组合索引表示这个索引的索引列可以有多个,但是在使用时存在最左前缀原则,即以最左边的索引列为起点,任何连续的索引都能匹配上。全文索引是一种特殊类型的,基于标记的功能性索引,利用创建倒排索引满足对于文档的检索。
锁是保证数据库数据一致性的基石。总的来看,锁分为悲观锁(Pessimistic Lock)和乐观锁(Optimistic Lock)两种形式。悲观锁实际上使用的是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。乐观锁并不会使用数据库提供的锁机制。一般,实现乐观锁的方式就是记录数据版本。锁分类详情如图3-3所示,图中基本涵盖了主流数据库里面涉及的锁类型。
图3-3 锁分类详情
悲观锁(又名“悲观并发控制”,Pessimistic Concurrency Control,缩写为“PCC”)是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。
悲观锁主要用于数据争用激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本的环境中。按照不同的分类方式,可以将悲观锁分为多种类型。
1)按性质来分,可将悲观锁分为共享锁、排他锁以及更新锁。
共享(S)锁允许并发事务读取一个资源。资源上存在共享锁时,任何其他事务都不能修改数据。一旦已经读取数据,则立即释放资源上的共享锁,除非将事务隔离级别设置为可重复读或更高级别,或者在事务生存周期内用锁定提示保留共享锁。
排他(X)锁可以防止并发事务对资源进行访问,其他事务不能读取或修改排他锁锁定的数据。
更新(U)锁用于可更新的资源中,防止当多个会话在读取、锁定以及随后可能进行的资源更新时发生常见形式的死锁。从共享锁到排他锁的转换必须等待一段时间,这是因为一个事务的排他锁与其他事务的共享锁不兼容,发生锁等待。当第二个事务试图获取排他锁以进行更新时,由于两个事务都要转换为排他锁,并且每个事务都需要等待另一个事务释放共享锁,因此发生死锁。
2)按作用域来分,可将悲观锁分为行锁、表锁以及页锁。
行锁仅对指定的记录进行加锁,这样其他进程还是可以对同一个表中的其他记录进行操作。行锁开销大,加锁慢;会出现死锁;锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
表锁对整张表加锁,在锁定期间,其他进程无法对该表进行写操作;如果你是写锁,则其他进程也不允许进行读操作;表锁开销小,加锁快;不会出现死锁;锁定粒度大,发生锁冲突的概率最高,并发度最低。
页锁介于行锁以及表锁之间,一次锁定相邻的一组记录。页锁的开销和加锁时间界于表锁和行锁之间;会出现死锁;锁定粒度界于表锁和行锁之间,并发度一般。
3)按锁的行为来分,可将悲观锁分为读锁、意向锁以及写锁。
读锁,即共享锁(S锁),若事务T对数据对象A加上S锁,则事务T可以读A但不能修改A,其他事务只能再对A加S锁,而不能加X锁,直到T释放A上的S 锁。这保证了其他事务可以读A,但在T释放A上的S锁之前不能对A做任何修改。
意向锁解决表锁与之前可能存在的行锁冲突,避免为了判断表是否存在行锁而去扫描全表的系统消耗。
写锁又称排他锁(X锁)。若事务T对数据对象A加上X锁,则事务T可以读A也可以修改A,其他事务不能再对A加任何锁,直到T释放A上的锁。这保证了其他事务在T释放A上的锁之前不能再读取和修改A。
Tips 意向锁是表锁,而非行锁;意向锁与读写锁构成了S、X、IS、IX四种类型的锁。
乐观锁假设数据一般情况下不会造成冲突,所以只会在数据进行更新的时候,正式对数据冲突与否进行检测,如果数据冲突了,则返回用户错误的信息,让用户决定如何处理。
从数据处理的角度来看,日常应用主要可以分为两大类:一是以实时事务为主的OLTP(On-Line Transaction Processing,联机事务处理)型应用,例如电商的购物数据、银行的交易数据等;二是以分析为主的OLAP(On-Line Analytical Processing,联机分析处理)型应用,其中最为典型的就是数据仓库、数据中心等大型系统,通过抽取并整合不同源系统的数据之后进行分析以及后续的可视化分析等。
OLTP型的应用系统往往是OLAP型系统的数据源。OLTP侧重于写,适合处理较小的事务,且对于并发具有较高的要求。OLAP则侧重于读而写相对较少,它的业务逻辑相对复杂,读取的数据量较多,并且涉及较多聚合函数的处理。因为在OLAP系统中读相对写较多,所以在某种程度上数据一致性的要求往往显得不是那么重要,这也是NoSQL可以发展的前提。
在OLAP中,数据分析的结果往往依赖数据同步工具推送到下游中,近些年也出现了以数据API的形式提供对外的数据源服务(例如数据中台)。OLAP型的应用系统中往往存在很多数据指标,这些指标是经过复杂的运算而得出(往往是基于不同数据表进行关联并聚合)的。在传统的关系型数据库中,数据是以行为单位读取的,而数据聚合中往往只需要部分列值,为了减少整个查询过程中的资源消耗(例如磁盘I/O,内存消耗、网络I/O等),很多列式存储数据库应运而生,例如HBase、ClickHouse等。
随着企业业务的发展,一些OLTP型系统被要求支持具有分析型特征的业务需求。例如在某些2C营销场景中,实时统计不同渠道最近一段时间(不固定)的消费金额以动态调整相应的营销策略。在这种类型的系统设计过程中需要考虑数据源的对接(如果需要的数据不在该系统中)、数据聚合粒度的设计(实时统计对于资源的要求)、数据存储架构的设计、应用架构的设计等。
在介绍了数据存储相关基础知识之后,我们从整个数据存储的发展阶段来介绍整个数据存储技术的发展。