目录

OLTP-or-OLAP

属性事务处理系统 OLTP (OnLine Transaction Processing)分析系统 OLAP (OnLine Analytice Processing)
主要读取模式查询少量记录,按键读取在大批量记录上聚合
主要写入模式随机访问,写入要求低延时批量导入(ETL)或者事件流
主要用户终端用户,通过 Web 应用内部数据分析师,用于决策支持
处理的数据数据的最新状态(当前时间点)随时间推移的历史事件
数据集尺寸GB ~ TBTB ~ PB

起初,事务处理和分析查询使用了相同的数据库。 SQL 在这方面已证明是非常灵活的:对于 OLTP 类型的查询以及 OLAP 类型的查询来说效果都很好。尽管如此,在二十世纪八十年代末和九十年代初期,企业有停止使用 OLTP 系统进行分析的趋势,转而在单独的数据库上运行分析。这个单独的数据库被称为 数据仓库(data warehouse)

OLTP 往往对业务至关重要,要求 高可用低延迟

数据仓库是一个独立的数据库,包含公司各种OLTP系统的所有只读副本。

从 OLTP 数据库中提取数据(使用定期的数据转存储或者连续的更新流),转换成适合分析的模式,清理并加载到数据仓库中。这个过程称之为 ”抽取(Extract)-转换(Transform)-加载(Load)“ 简写为: ETL

数据仓库可针对分析类的访问模式进行优化。

数据仓库的数据模型通常是关系型的,因为 SQL 通常适合分析查询。

在分析型业务中,数据模型的多样性很少,许多数据仓库都以相对公式化的方式使用,即 星型模式(也称为维度建模)

image-20230718104133115

模式中心有一个 事实表 。事实表的每行代表在特定时间内发生的事件。例如:分析零售额,每行代表客户购买的产品;分卸网络流量,每行代表一个用户的页面浏览和点击。事实被视为单独的事件,因为这样可以在以后分析中获得最大的灵活性。但是,这意味着事实表可以变得非常大。

事实表中的一些列是属性,例如产品销售的价格和从供应商那里购买的成本(可以用来计算利润率)。事实表中的其他列是对其他表(称为维度表)的外键引用。由于事实表中的每一行都表示一个事件,因此这些维度代表事件发生的对象、内容、地点、时间、方式和原因。

甚至日期和时间也通常使用维度表来表示,因为这允许对日期的附加信息(诸如公共假期)进行编码,从而允许区分假期和非假期的销售查询。

“星型模式” 这个名字来源于这样一个事实,即当我们对表之间的关系进行可视化时,事实表在中间,被维度表包围;与这些表的连接就像星星的光芒。

这个模板的变体被称为 雪花模式,其中维度被进一步分解为子维度。

在典型的数据仓库中,表格通常非常宽:事实表通常有 100 列以上,有时甚至有数百列。维度表也可以是非常宽的,因为它们包括了所有可能与分析相关的元数据。

如果事实表中有万亿行和数 PB 的数据,那么高效地存储和查询它们就成为一个具有挑战性的问题。

在大多数 OLTP 数据库中,存储都是以面向行的方式进行布局的:表格的一行中的所有值都相邻存储。文档数据库也是相似的:整个文档通常存储为一个连续的字节序列。

面向行的存储引擎仍然需要将所有这些行(每个包含超过 100 个属性)从硬盘加载到内存中,解析它们,并过滤掉那些不符合要求的属性。

列式存储背后的想法很简单:不要将所有来自一行的值存储在一起,而是将来自每一列的所有值存储在一起。如果每个列式存储在一个单独的文件中,查询只需要读取和解析查询中使用的那些列(分析时很少 select * …),这可以节省大量的工作。

列式存储布局依赖于每个列文件包含相同顺序的行。 因为,如果你需要重新组装完整的行,你可以从每个单独的列文件中获取第 23 项,并将它们放在一起形成表的第 23 行。

除了仅从硬盘加载查询所需的列以外,我们还可以通过压缩数据来进一步降低对硬盘吞吐量的需求。幸运的是,列式存储通常很适合压缩。

通常情况下,一列中不同值的数量与行数相比要小得多(例如,零售商可能有数十亿的销售交易,但只有 100,000 个不同的产品)。现在我们可以拿一个有 n 个不同值的列,并把它转换成 n 个独立的位图:每个不同值对应一个位图,每行对应一个比特位。如果该行具有该值,则该位为 1,否则为 0。

如果 n 非常小(例如,国家 / 地区列可能有大约 200 个不同的值),则这些位图可以将每行存储成一个比特位。但是,如果 n 更大,大部分位图中将会有很多的零(我们说它们是稀疏的)。在这种情况下,位图可以另外再进行游程编码(run-length encoding,一种无损数据压缩技术)。这可以使列的编码非常紧凑。

位图索引非常适合数据仓库中常见的各种查询。按位与,按位或 即可满足 sql 中的 andin 查询。

对于需要扫描数百万行的数据仓库查询来说,一个巨大的瓶颈是从硬盘获取数据到内存的带宽。但是这并不是唯一的瓶颈,分析型数据库的开发人员还关注如何高效地利用从主内存CPU缓存的带宽,避免分支预测错误和CPU指令处理流水线中的停顿,并利用现代CPU中的单指令多数据(SIMD single-instruction-multi-data)指令。

列式存储:可以减小从硬盘加载的数据量;也可以高效的利用CPU周期。如:查询引擎可以将一整块压缩好的列数据放进 CPU 的 L1 缓存中,然后在紧密的循环(即没有函数调用)中遍历。相比于每条记录的处理都需要大量函数调用和条件判断的代码,CPU 执行这样一个循环要快得多。列压缩允许列中的更多行被同时放进容量有限的 L1 缓存。前面描述的按位 “与” 和 “或” 运算符可以被设计为直接在这样的压缩列数据块上操作。这种技术被称为向量化处理(vectorized processing)。

列式存储中,存储列的顺序并不关键,按插入顺序存储它们最简单,因为插入一个新行只需要追加到每个列文件。但是,我们也可以选择按某种顺序来排列数据,就像我们之前对 SSTables 所做的那样,并将其用作索引机制。

注意,对每列分别执行排序也是无意义的,因为那样就没法知道不同列中的哪些项属于同一行。我们只能在明确一列中的第 k 项与另一列中的第 k 项属于同一行的情况下,才能重建出完整的行。

对数据的排序需要对整行进行统一操作,即使它们的存储方式是按列的。数据库管理员可以根据他们对常用查询的了解,来选择表格中用来排序的列。例如,如果查询通常以日期范围为目标,例如“上个月”,则可以将 date_key 作为第一个排序键。这样查询优化器就可以只扫描近1个月范围的行了,这比扫描所有行要快得多。

对于第一排序列中具有相同值的行,可以用第二排序列来进一步排序。例如,如果 date_key 是中的第一个排序关键字,那么 product_sk 可能是第二个排序关键字,以便同一天的同一产品的所有销售数据都被存储在相邻位置。这将有助于需要在特定日期范围内按产品对销售进行分组或过滤的查询。

按顺序排序的另一个好处是它可以帮助压缩列。如果主要排序列没有太多个不同的值,那么在排序之后,将会得到一个相同的值连续重复多次的序列。一个简单的游程编码可以将该列压缩到几 KB —— 即使表中有数十亿行。

第一个排序键的压缩效果最强。第二和第三个排序键会更混乱,因此不会有这么长的连续的重复值。排序优先级更低的列以几乎随机的顺序出现,所以可能不会被压缩。但对前几列做排序在整体上仍然是有好处的。

对这个想法,有一个巧妙的扩展被 C-Store 发现,并在商业数据仓库 Vertica 中被采用:既然不同的查询受益于不同的排序顺序,为什么不以几种不同的方式来存储相同的数据呢?反正数据都需要做备份,以防单点故障时丢失数据。因此你可以用不同排序方式来存储冗余数据,以便在处理查询时,调用最适合查询模式的版本。

在一个列式存储中有多个排序顺序有点类似于在一个面向行的存储中有多个次级索引。但最大的区别在于

  • 面向行的存储将每一行保存在一个地方(在堆文件或聚集索引中),次级索引只包含指向匹配行的指针。
  • 在列式存储中,通常在其他地方没有任何指向数据的指针,只有包含值的列。

这些优化在数据仓库中是有意义的,因为其负载主要由分析人员运行的大型只读查询组成。列式存储、压缩和排序都有助于更快地读取这些查询。然而,他们的缺点是写入更加困难。

使用 B 树的就地更新方法对于压缩的列是不可能的。如果你想在排序表的中间插入一行,你很可能不得不重写所有的列文件。由于行由列中的位置标识,因此插入必须对所有列进行一致地更新。

幸运的是,本章前面已经看到了一个很好的解决方案:LSM 树。所有的写操作首先进入一个内存中的存储,在这里它们被添加到一个已排序的结构中,并准备写入硬盘。内存中的存储是面向行还是列的并不重要。当已经积累了足够的写入数据时,它们将与硬盘上的列文件合并,并批量写入新文件。这基本上是 Vertica 所做的。

查询操作需要检查硬盘上的列数据和内存中的最近写入,并将两者的结果合并起来。但是,查询优化器对用户隐藏了这个细节。从分析师的角度来看,通过插入、更新或删除操作进行修改的数据会立即反映在后续的查询中。

数据仓库的另一个值得一提的方面是物化聚合(materialized aggregates)。如前所述,数据仓库查询通常涉及一个聚合函数,如 SQL 中的 COUNT、SUM、AVG、MIN 或 MAX。如果相同的聚合被许多不同的查询使用,那么每次都通过原始数据来处理可能太浪费了。为什么不将一些查询使用最频繁的计数或总和缓存起来?

创建这种缓存的一种方式是物化视图(Materialized View)。在关系数据模型中,它通常被定义为一个标准(虚拟)视图:一个类似于表的对象,其内容是一些查询的结果。不同的是,物化视图是查询结果的实际副本,会被写入硬盘,而虚拟视图只是编写查询的一个捷径。从虚拟视图读取时,SQL 引擎会将其展开到视图的底层查询中,然后再处理展开的查询。

当底层数据发生变化时,物化视图需要更新,因为它是数据的非规范化副本。数据库可以自动完成该操作,但是这样的更新使得写入成本更高,这就是在 OLTP 数据库中不经常使用物化视图的原因。在读取繁重的数据仓库中,它们可能更有意义(它们是否实际上改善了读取性能取决于使用场景)。

物化视图的常见特例称为数据立方体或 OLAP 立方。它是按不同维度分组的聚合网格。显示了一个例子。

image-20230718103751084

想象一下,现在每个事实都只有两个维度表的外键 —— 在上图中分别是日期和产品。你现在可以绘制一个二维表格,一个轴线上是日期,另一个轴线上是产品。每个单元格包含具有该日期 - 产品组合的所有事实的属性(例如 net_price)的聚合(例如 SUM)。然后,你可以沿着每行或每列应用相同的汇总,并获得减少了一个维度的汇总(按产品的销售额,无论日期,或者按日期的销售额,无论产品)。

一般来说,事实往往有两个以上的维度。在中有五个维度:日期、产品、商店、促销和客户。要想象一个五维超立方体是什么样子是很困难的,但是原理是一样的:每个单元格都包含特定日期 - 产品 - 商店 - 促销 - 客户组合的销售额。这些值可以在每个维度上求和汇总。

物化数据立方体的优点是可以让某些查询变得非常快,因为它们已经被有效地预先计算了。例如,如果你想知道每个商店的总销售额,则只需查看合适维度的总计,而无需扫描数百万行的原始数据。

数据立方体的缺点是不具有查询原始数据的灵活性。例如,没有办法计算有多少比例的销售来自成本超过 100 美元的项目,因为价格不是其中的一个维度。因此,大多数数据仓库试图保留尽可能多的原始数据,并将聚合数据(如数据立方体)仅用作某些查询的性能提升手段

在高层次上,我们看到存储引擎分为两大类:针对 事务处理(OLTP) 优化的存储引擎和针对 在线分析(OLAP) 优化的存储引擎。这两类使用场景的访问模式之间有很大的区别:

  • OLTP 系统通常面向最终用户,这意味着系统可能会收到大量的请求。为了处理负载,应用程序在每个查询中通常只访问少量的记录。应用程序使用某种键来请求记录,存储引擎使用索引来查找所请求的键的数据。硬盘查找时间往往是这里的瓶颈。
  • 数据仓库和类似的分析系统会少见一些,因为它们主要由业务分析人员使用,而不是最终用户。它们的查询量要比 OLTP 系统少得多,但通常每个查询开销高昂,需要在短时间内扫描数百万条记录。硬盘带宽(而不是查找时间)往往是瓶颈,列式存储是针对这种工作负载的日益流行的解决方案。

在 OLTP 这一边,我们能看到两派主流的存储引擎:

  • 日志结构学派:只允许追加到文件和删除过时的文件,但不会更新已经写入的文件。Bitcask、SSTables、LSM 树、LevelDB、Cassandra、HBase、Lucene 等都属于这个类别。
  • 就地更新学派:将硬盘视为一组可以覆写的固定大小的页面。 B 树是这种理念的典范,用在所有主要的关系数据库和许多非关系型数据库中。

日志结构的存储引擎是相对较新的技术。他们的主要想法是,通过系统性地将随机访问写入转换为硬盘上的顺序写入,由于硬盘驱动器和固态硬盘的性能特点,可以实现更高的写入吞吐量。

关于 OLTP,我们最后还介绍了一些更复杂的索引结构,以及针对所有数据都放在内存里而优化的数据库。

然后,我们暂时放下了存储引擎的内部细节,查看了典型数据仓库的高级架构,并说明了为什么分析工作负载与 OLTP 差别很大:当你的查询需要在大量行中顺序扫描时,索引的重要性就会降低很多。相反,非常紧凑地编码数据变得非常重要,以最大限度地减少查询需要从硬盘读取的数据量。我们讨论了列式存储如何帮助实现这一目标。