Lec 11 分析数据库的布局
阅读材料
C-Store: A Column Oriented DBMS, 2005
Column-Stores vs. Row-Stores: How Different Are They Really?, SIGMOD'08
我们将讨论面向列的数据库,它代表了构建关系数据库的不同方式,该数据库针对大规模的读密集型操作进行了优化,而非对事务处理的优化。
数据库的工作负载可以分为以下3种类型:
OLTP, Online Transactional Processing,联机事务处理。OLTP 负载的特点是操作快速、运行时间短、重复性高,查询通常很简单,并且每次只作用于一个实体。 这类负载通常写多读少,每次只读取或更新数据库中的少量数据。
- 一个 OLTP 负载的例子是 亚马逊的前台商店系统:用户可以将商品加入购物车并进行购买,但这些操作只影响他们自己的账户。
OLAP, Online Analytical Processing,连接分析处理。OLAP 负载的特点是运行时间长、查询复杂,通常会读取数据库中很大一部分数据。这类负载通常用于分析或从 OLTP 系统收集的数据中推导出新信息。一个 OLAP 负载的例子是:亚马逊统计某个下雨天,匹兹堡地区最畅销的商品是什么。
HTAP , Hybrid Transactional and Analytical Processing(HTAP),混合事务与事务处理。HTAP 是一种新型的数据库负载模式(近年来越来越流行),它将 OLTP 和 OLAP 的负载整合在同一个数据库系统中。
1. 回顾优化器
- Selinger 优化器是现代基于成本优化器的基石
- 简单的统计
- 几种启发式,比如left-deep
- 连接顺序采用动态规划
- 很容易扩展
- 通过更复杂的统计
- 减少启发式
优化器执行步骤
Cost = CPU + IO(磁盘+网络)
CPU time ~ 1GHz = 十亿条指令/sec = 1ns/每指令
磁盘I/O = 100MB/sec = 10ns/byes
随机I/O = 10ms = 100seeks / sec
但是我们主要关注磁盘I/O
|部门(dept)| = 100条记录 = 10 KB = 1物理页
|员工(employ)| = 10K记录 = 100物理页 = 100MB
|小孩(kids)|= 30K记录 = 300 物理页 = 3MB
在一个物理页上可以有100个元组(类似B树或者文件)
内存只有10页, 每页10KB
- sql
-- 执行 SELECT * FROM e, d, k WHERE salary > 10k, e.dno = d.dno e.kid = k.id
需要做多少次迭代?
3000万
2. 存储模型
N-ary 存储模型
N-ary存储模型(N-Ary Storage Model, NSM)在页面中存储元组有多种方式。到目前为止,我们一直假设使用的是 N-ary 存储模型(N-Ary Storage Model, NSM)。在 N-ary 存储模型中,DBMS 会将一个元组的所有属性连续地存储在同一页面中。这种方式非常适合 OLTP 类型的负载,因为此类负载以写入为主,并且事务往往只操作一个个体。由于一个元组的所有数据都在一起,只需一次页面读取(fetch)即可获取整个元组的所有属性,效率很高。
优点:
- 插入、更新和删除操作速度快;
- 适用于需要访问完整元组的查询;
缺点:
- 如果要扫描表中的大部分数据,或只需要部分属性(列),效率较低;
分解存储模型
分解存储模型(Decomposition Storage Model, DSM),DBNS会将所有元祖的某一属性(列)连续地存储在一个数据块中。因此这种模型叫做列存储。非常适合OLAP类型的负载,尤其是大量的只读查询,这类查询只访问表中的部分列,并对数据进行大范围扫描。
拼接元组的两种方式:
- 固定长度偏移(Fixed-Length Offsets)(最常见):每列中每个值的位置(偏移量)相同,因此在不同列中,相同偏移位置的值属于同一个元组。为了支持这种方式,列中的值必须都是固定长度。
- 嵌入式元组 ID(Embedded Tuple IDs)(较少使用):每个属性值都存储一个对应的元组 ID(如主键),并维护一个映射,用于告诉系统如何在各列中找到属于同一元组的值。但这种方式的缺点是:每个属性值都需要存一个 ID,存储开销较大。
优点:
- 减少 I/O 浪费:DBMS 只读取查询所需的列,而非整个元组;
- 更高效的查询处理:由于更好的局部性(locality)和缓存重用;
- 更好的数据压缩率:相似数据连续存储,便于压缩
缺点:
- 对于点查找、插入、更新和删除操作较慢,因为元组在列中被“拆开”,需要重新“拼接”;
跨属性划分
跨属性划分(Partition Attributes Across, PAX)是一种混合的存储模型,它在每个数据页面内部对属性进行垂直划分(即列式划分)。 目标是:在保持行存储的空间局部性优点的同时,也能获得列存储在处理查询上的性能优势。
在 PAX 中:
- 所有的行会被水平划分为一组组“行组(row groups)”;
- 每个行组内部,将属性进行垂直划分,类似列存;
- 每个行组就像是一个“迷你列存”,但只针对该组内的行。
一个 PAX 文件包含:
- 全局头部(global header):包含指向每个行组的偏移信息;
- 每个行组自己的头部信息:用于记录该组中的元数据。
3. 数据库压缩
压缩在基于磁盘的 DBMS 中被广泛使用,因为磁盘 I/O 几乎总是系统性能的主要瓶颈。特别是在只读的分析型工作负载(OLAP)中更为常见。通过对数据预先压缩,DBMS 能在一次读取中获取更多有用的元组,代价是增加了压缩和解压的计算开销。
对于内存数据库(in-memory DBMS)来说,情况更加复杂: 它们不需要从磁盘读取数据来执行查询,因为内存(DRAM)的访问速度远远快于磁盘。但压缩数据库仍然可以 降低内存占用 并 减少处理数据时的工作量,从而提高整体效率。因此,内存数据库必须在“访问速度”和“压缩率”之间做出权衡。 压缩既可以节省 DRAM 空间,又可能在执行查询时减少 CPU 负担。
如果一个数据集是完全随机的二进制比特流,那就无法进行有效压缩。 但真实世界中的数据往往具备一些适合压缩的特性:
- 属性值的分布高度偏斜skewed(例如:布朗语料库中遵循 Zipf 分布的词频);
- 同一元组内属性之间存在高度相关性correlation(例如:邮政编码与城市、订单日期与发货日期)
因此,一个好的数据库压缩方案应满足以下要求:
- 必须生成固定长度的值。唯一的例外是变量长度数据,它们通常被存储在独立的池中。这样做是为了遵守内存对齐(word-alignment),并允许 DBMS 通过偏移量快速访问数据。
- 应允许DBMS在查询过程中尽可能延迟解压缩操作(称之为延迟物化、Late Materialization)
- 必须是无损压缩,因为用户通常不能接受数据丢失
压缩粒度
我们希望压缩的数据类型会极大地影响可以采用哪种压缩方案。压缩通常分为以下四种粒度:
- 块级压缩(Block Level):对同一张表中的一整块元组(block of tuples)进行压缩。
- 元组级压缩(Tuple Level): 对整个元组的内容进行压缩(仅适用于 NSM 模型,即行存储模型)。
- 属性级压缩(Attribute Level): 对一个元组中的单个属性值进行压缩。也可以对同一个元组中的多个属性分别压缩。
- 列级压缩(Columnar Level): 对多个元组中一个或多个属性的值进行压缩(仅适用于 DSM 模型,即列存储模型)。 这种方式可以使用更复杂、更高效的压缩方案。
原生压缩
DBMS 可以使用通用的压缩算法(如 gzip、LZO、LZ4、Snappy、Brotli、Oracle OZIP、Zstd)来压缩数据。虽然可选的压缩算法有很多,但工程师们通常会选择那些压缩/解压速度较快、压缩率稍低的算法,以获得更好的整体性能。
一个典型的例子是 MySQL 的 InnoDB 存储引擎: InnoDB 会对磁盘页进行压缩,将它们填充为 2KB 的整数幂大小后存入缓冲池(buffer pool)。 但每次 DBMS 读取或修改数据时,必须先对缓冲池中的压缩数据进行解压。
由于访问数据前必须先解压缩,这限制了压缩策略的使用范围: 如果目标是将整张表压缩为一个巨大的块,那么这种“原生的压缩”方案无法实现,因为每次访问都需要将整个表解压——这会导致非常大的开销。
这些原生压缩算法并不了解数据的语义或结构。 压缩算法对数据的格式、结构以及查询如何访问这些数据都是一无所知的。 这也意味着,DBMS 无法利用延迟物化(Late Materialization),因为它无法判断在什么时候可以推迟解压。
列式压缩方案
列式压缩方案最适合读取密集型工作负载,写入操作可能需要额外支持
运行长度编码:RLE 通过压缩连续重复值(runs)来表示一列中的相同值段,格式为三元组:
- 属性值
- 在该列段中的起始位置
- 该值重复的次数(元素个数)
DBMS 应该在压缩前对列进行智能排序,以最大化重复值的聚集,提升压缩率。 RLE 的压缩效果高度依赖于数据特性,比如属性值的数量和频率。
位打包编码(Bit-Packing Encoding): 当某一属性的所有值都小于其声明的最大范围时,可以用更少的位数来存储这些值,从而节省空间。
Mostly 编码:位打包编码的一个变体。当某些值超过预设范围时,使用特殊标记表示这些异常值,并通过查找表记录这些例外值。
位图编码(Bitmap Encoding) DBMS 为某个属性的每个唯一值分别创建一个位图。位图中的每个位置表示一个元组:第 i 位为 1 表示第 i 个元组具有该值,为 0 表示没有。为了避免分配过大的连续内存,位图通常按段(chunk)划分存储。
注意:位图编码只在属性值基数较低(low cardinality)时有效。 如果唯一值太多,生成的位图甚至可能比原始数据还要大
增量编码(Delta Encoding) 不是存储具体值,而是存储相邻值之间的差值。 基值(base value)可以直接内联存储,也可以存在一个查找表中。 还能在这些“差值”上再做 RLE 压缩,进一步提升效果
增量前缀编码(Incremental Encoding) 增量编码的一个变种:记录相同前缀/后缀及其长度,避免重复存储。 适用于已排序数据。
字典压缩(Dictionary Compression): 最常见的数据库压缩方式。 DBMS 使用较小的编码(codes)替换频繁出现的值,并维护一个字典数据结构来实现编码值和原始值之间的映射。
论文阅读:C-Store
摘要
介绍了一种以读操作为优化目标的关系型数据库管理系统的设计,其设计上的主要区别包括:通过列而非行存储数据,在查询处理过程中对对象进行精细编码和压缩并存储于主存内,存储重叠的列式投影集合而不是传统的表和索引,采用非传统的事务实现方式,包括为只读事务提供高可用性和快照隔离,以及广泛使用位图索引来补充B树结构。论文还展示了基于TPC-H子集的初步性能数据,表明该系统(名为C-Store)的性能远远超过了当前流行的商业产品。
1. 介绍
面向记录的存储系统,即元祖的所有的属性连续存储在介质中,单次磁盘写操作即可将记录的所有字段输出到磁盘,我们称行存储架构的DBMS 为 写入优化系统。相比之下海量的数据即时查询的系统应道采用读优化设计。数据仓库就是典型的代表,还包括客户关系管理(CRM)、电子图书馆目录检索系统等。本文提出C-Store列存储系统,在此架构下,DBMS只需要查询所涉及的列值,可避免将无关属性加载到内存。在数据仓库场景中,典型查询往往需要对海量数据项进行聚合运算。
当前DBMS通常采用字节/字边界对齐设计,以原生数据格式存储值。传统观点认为,在贮存中对齐数据边界需要付出过高代价。 需权衡CPU换磁盘带宽。列存储通过两种方式利用CPU资源节省带宽,一种是数据编码压缩,另一种是密集存储,将N个K位长的值紧密打包为N×K位存储。
商业DBMS采用完整元祖存储模式,并为表属性建立辅助B树索引。这类索引有两类:主索引和二级索引,前者使表行尽可能按指定属性排序、后者不要求底层记录按索引属性排序。此类索引在写入优化的OLTP负载中表现优异,但在读优化场景下效率不足,更适合采用位图索引、跨表索引和物化视图等数据结构。
因此C-Store以列集合为物理存储单元、每列按照特定属性排序。具有相同排序列的列组称为"投影"(projections),同一列可存在于多个不同排序属性的投影中。通过创新的压缩技术,系统能在不显著增加存储空间的前提下支持多列排序方案。
即便在读取密集型环境中,事务性更新仍不可或缺。数据仓库需要在线修正错误,实时数据仓库更要求数据可见延迟趋近于零。CRM等读取为主的应用同样需要通用在线更新能力。但更新需求与读取优化数据结构存在天然矛盾。
NOTE
“即席查询”(Ad-hoc Query)是数据库领域的一个术语,指的是非预先定义、临时发起的灵活查询
C-Store以一种全新的视角应对这一难题。具体而言,我们将面向读取优化的列式存储与支持插入/更新的可写存储整合到同一系统软件中,并通过图1所示的元组移动器(tuple mover)进行连接。系统顶层设计了一个小型可写存储组件(Writeable Store, WS),其架构专为支持高性能插入和更新操作而设计。另一主体是规模更大的读取优化存储(Read-optimized Store, RS),该组件能够支持海量数据存储。正如其名,RS针对读取操作进行深度优化,仅支持一种受限的插入形式——即通过元组移动器将记录从WS批量迁移至RS的过程(如图1所示)。RS中被删除的记录会进行标记,后续由元组移动器统一清除。更新操作则通过"插入新记录+删除旧记录"的方式实现。
为实现高效的元组移动,我们采用LSM树(Log-Structured Merge-tree)的变体结构。该技术通过合并有序WS数据对象与大型RS数据块的方式,以批量处理形式将元组从WS迁移至RS,并在操作完成后生成RS的新副本进行替换。图1所示架构需要在一个包含大规模即席查询、小型更新事务以及可能持续插入的环境中支持事务处理。显然,若简单采用动态锁机制会导致严重的读写冲突,并因阻塞和死锁造成性能下降。

为此,我们规定只读查询必须在历史模式(historical mode)下运行。该模式下,查询需选定一个时间戳T(不超过最近已提交事务的时间戳),系统会语义保证查询结果与该历史时间点完全一致。为实现这种快照隔离(snapshot isolation)[BERE95],C-Store需为插入的数据元素打时间戳标记,并要求运行时系统精确忽略时间戳晚于T的数据元素。
最后需要指出,当前主流商业优化器和执行器均为行导向设计,这显然是为了适配市场上普遍的行式存储。由于RS和WS均采用列式存储结构,构建列式优化的查询优化器与执行器势在必行。后文将揭示,这套软件的设计与当下传统方案存在根本性差异。
C-Store作为面向列的数据库管理系统,其架构设计核心在于减少每次查询的磁盘访问次数。该系统具有以下创新特性:
- 混合架构设计:包含针对高频插入/更新优化的可写存储组件(WS)与专注查询性能的读取优化存储组件(RS)
- 冗余存储策略:通过以不同顺序存储表的多个重叠投影(projection),使查询总能利用最优投影路径执行
- 深度列压缩技术:采用多种编码方案实现列数据的高效压缩
- 列式优化器与执行器:其基础操作原语完全不同于行式系统
- 高可用保障:通过K-safety机制维护足够数量的重叠投影,既提升性能又确保可用性
- 快照隔离机制:避免查询时使用两阶段提交(2PC)和锁协议
需要强调的是,虽然上述部分技术在过去研究中已有独立探讨,但正是这些技术在真实系统中的有机融合,使得C-Store具有独特价值。
本文后续结构如下:第二节阐述C-Store的数据模型;第三节解析RS组件的设计;第四节探讨WS组件实现;第五节研究C-Store数据结构在网格节点中的分配策略;第六节说明系统更新与事务处理机制;第七节专述元组移动器组件;第八节展示查询优化器与执行器设计;第九节将C-Store与主流商业行式存储及列式存储进行性能对比——在TPC-H类查询中,C-Store显著优于两类对比系统。但需说明当前性能对比尚未完全完成,我们尚未充分整合WS与元组移动器组件,其开销可能影响最终结果。最后,第十节与第十一节分别讨论相关前人工作和本文结论。
2. 数据模型
用户视角看,跟标准DBMS差不多。C-Store支持标准的逻辑数据模型,即一个数据库包含一组命名表,每个命名表有元祖命名属性,并且支持主键和外键属性,支持标准SQL语法。但是物理存储并不是按照逻辑数据模型,传统数据库通常直接物理上存储整张表的行数据,然后通过各种索引(如 B+树)加速查询, 而C-Store通过投影(projections)实现
具体而言,它基于某个逻辑表(称为锚表 anchor table)而建立,包含这个表的一些属性(列),不一定包含全部列。此外,只要从锚定表到包含某个属性的其他表之间存在一系列的 n:1(即外键)关系,投影中就可以包含来自其他表的任意数量的属性。当然,我们可以允许更复杂的投影结构,但我们认为这种简单的方案已经能够满足我们的需求,同时还能保证高性能。
为了构建一个投影(projection),我们会从锚定表 T 中选出感兴趣的属性(即对这些属性做投影),保留所有重复的行,并执行一系列基于值的外键连接,从非锚定表中获取其它需要的属性。因此,一个 projection 拥有与其锚定表相同数量的行。
我们将表t的第i个投影记为
EMP1 (name, age)
EMP2 (dept, age, DEPT.floor)
EMP3 (name, salary)
DEPT1(dname, floor)投影中的元组按列存储。因此,若某投影包含 K 个属性,系统会维护 K 个独立的数据结构(每个存储一列),且所有列均按相同的排序键(sort key)排序。排序键可以是投影中的任意单列或多列组合,元组严格遵循从左到右的键顺序排列。我们在投影名称后追加竖线(|)及排序键来标注其排序规则。上述投影的一种可能排序方式如下:
EMP1(name, age | age)
EMP2(dept, age, DEPT.floor | DEPT.floor)
EMP3(name, salary | salary)
DEPT1(dname, floor | floor)每个投影会被水平分片( horizontally partitioned)成1个或多个段(segments),并通过段标识符Sid(Sid > 0)进行管理。C-Store仅支持基于排序键的值范围分区,因此每个段关联投影排序键的特定键范围,且所有段的键范围构成完整的键空间。显然,要在 C-Store 中回答任意 SQL 查询,数据库中的每张表都必须有一组覆盖性投影(covering set of projections),也就是说,每张表中的每一列至少要被存储在某一个投影中。然而,C-Store 还必须能够从存储的各个段(segments)中重建出完整的表行。为实现这一点,它需要对来自不同投影的段进行连接,这就是通过存储键(storage keys)和连接索引(join indexes)来完成的。
存储键(Storage Keys)。每个segment会将其每一列中的每个数据值与一个存储键(SK)关联起来。在同一个段中,来自不同列但拥有相同存储键的值被认为属于同一个逻辑行(logical row)。我们将段中的一行称为一条“记录(record)”或“元组(tuple)”。在 RS(只读段,Read-Only Segment)中,存储键的编号是 1、2、3,依此类推,并不会被物理存储下来,而是根据数据在列中的物理位置推断得出(相关细节见第 3 节)。而在 WS(可写段,Writeable Segment)中,存储键是物理存在的整数,其值大于任何一个 RS 段中存储键的最大值。
连接索引(Join Indices)。为了从一个表T的多个投影中重构出完整记录,C-Store使用了连接索引,如果T1和T2是表T的两个投影,那么从 T1 的 M 个段到 T2 的 N 个段的连接索引,本质上是一个包含 M 张表的集合,每张表对应 T1 中的一个段 S,其行的形式如下:
(s: T2 中的段 ID, k: 该段中的存储键)也就是说,T1 某段中的一个元组,在连接索引中对应的是:T2 中对应的SID,以及该段中的存储键(k)。由于所有的连接索引都发生在以相同表为锚定表(anchor table)的投影之间,因此这种映射始终是一对一的。
换种说法,连接索引可以看作是:它将按某个顺序 O 排序的投影 T1,逻辑上重新排序为按另一个顺序 O′ 排序的投影 T2。
为了从多个投影 T1,…,Tk 中重构出原始表 T,我们需要能够找到一条路径,穿过一系列连接索引,从而把 T 的所有属性映射到一个共同的排序方式 O* 上。 路径(path) 是一组连接索引的集合,起点是某个投影 Ti 所指定的排序方式,终点是一个排序方式为 O* 的投影,中间可以经过 0 个或多个投影。
例如,若想从 EMP 表的多个投影中重建 EMP 表,可以至少使用两个连接索引。如果我们选择按 age 排序作为共同顺序,那么可以建立两个连接索引,分别将 EMP2 和 EMP3 映射到 EMP1 的排序方式。也可以使用另一种方式:先将 EMP2 映射到 EMP3,再将 EMP3 映射到 EMP1。
图 2 展示了一个简单的连接索引示例,它将 EMP3 映射到 EMP1,假设每个投影只有一个段(SID = 1)。比如,EMP3 的第一条记录 (Bob, 10K) 对应 EMP1 的第二条记录,因此连接索引的第一条记录中的存储键为 2。

数据库中每个投影的数据段(segments)及其关联的连接索引(join indexes)必须被分配到 C-Store 系统中的不同节点上。C-Store 管理员可以选择性地指定数据库中的表必须满足 K-安全性(K-safe)。所谓 K-安全,指的是:即使集群中有 K 个节点发生故障,系统仍然能够重建数据库中的所有表(也就是说,尽管有 K 个节点失效,仍然必须存在一组“覆盖性投影”(covering set of projections)和一组连接索引(join indices),它们之间映射到了某种共同的排序方式上)。当故障发生时,C-Store 会以 K-1 安全性继续运行,直到故障修复并将失效节点恢复为止。目前我们正在研究快速算法以实现这一点。
因此,C-Store 的物理数据库设计问题,就是要确定一组投影、数据段、排序键和连接索引,以对应数据库中的逻辑表。这个物理模式(schema)需要同时满足::
- K-安全性;
- 在给定的“训练工作负载”(training workload,通常由管理员提供)的前提下实现最佳整体性能;
- 且不超过给定的空间预算 B。
接下来我们转向投影、段、storage keys、连接索引的表示。
3. RS
RS(Read-Optimized Store,读优化存储) 是一种为读取操作优化的列式存储系统。因此,每个投影(projection)中的段(segment)都会被拆分为其各自的列,并且每一列的数据会按照该投影的排序键(sort key)顺序进行存储。
在 RS 中,每个元组的 存储键(Storage Key)就是该记录在段中的序号。这个存储键 不会被物理存储,而是按需计算得出。
3.1 编码方案(Encoding Schemes)
RS 中的列使用以下四种编码方式之一进行压缩。编码的选择取决于两点:
- 列的排序方式(是按自身值排序“self-order”,还是按投影中另一列的值排序“foreign-order”);
- 列中不同值的比例(distinct value proportion)。
下面是四种编码方式的说明:
类型 1:自排序(self-order),值较少
这种编码将一列表示为一系列三元组 (v, f, n),其中:
v是该列中的一个值;f是v首次出现的位置;n是v出现的次数。
例如,如果值 4 出现在第 12 到 18 位,这将被表示为 (4, 12, 7)。
对于自排序的列,这种编码方式每个不同的值只需存储一个三元组。为了支持按值查询,该类型的列配备了 基于值字段的簇式 B 树索引(clustered B-tree)。
由于 RS 不支持在线更新,所以索引可以密集打包(densepack),没有空位。而且在使用较大的磁盘块(如 64–128KB)时,B 树的高度通常很小(不超过 2 层)。
类型 2:外排序(foreign-order),值较少
这类列编码为一系列二元组 (v, b):
v是值;b是 bitmap(位图),指明v出现的位置。
举个例子,对于整数列:0, 0, 1, 1, 2, 1, 0, 2, 1,可以编码为:
(0, 110000100)(1, 001101001)(2, 000010010)
这些 bitmap 通常是稀疏的,因此使用游程编码(run-length encoding)来节省空间。
为了快速定位某个位置上的值,还会构建 offset index(偏移索引):即将列的位置映射到实际值的 B 树结构。
类型 3:自排序,值很多
该方案的思想是:将每个值表示为相对于上一个值的“增量(delta)”。
例如,列:1, 4, 7, 7, 8, 12,将编码为:1, 3, 3, 0, 1, 4。 其中:
- 第一个值是列中第一个元素;
- 之后的每个值是与前一个值的差值。
这种编码是以 块(block)为单位进行的:每个块的首个值为原始值及其存储键,之后的值是增量。
这个方法类似于 VSAM 编码 B 树索引键(VSAM04) 的方式。可以使用 块级别的密集 B 树索引(densepack B-tree) 对编码对象建立索引。
类型 4:外排序,值很多
当一个列中有大量不同的值时,可能更适合不进行压缩。
但目前作者还在探索适用于这种情况的压缩方案。不过,即使不压缩,也可以通过 密集 B 树(densepack B-tree) 来支持索引。
3.2 连接索引(Join Indexes)
连接索引用于将同一锚定表上的不同投影连接起来(以重建完整行)。如前所述,连接索引由若干 (sid, storage_key) 二元组组成,其中:
sid表示目标段的标识;storage_key表示目标段中元组的存储键。
这两个字段都可以作为普通的列进行存储。
但连接索引的物理设计(即存放位置)对数据库系统设计有重要影响,作者将在下一节进一步探讨。同时,连接索引还必须整合 RS 和 WS,因此它们的设计会在下节重新展开说明。
4. WS
为了避免开发两个优化器,WS(可写段)也是一个列式存储系统,并实现了与 RS(只读段)完全相同的物理 DBMS 设计。因此,WS 中也存在相同的投影(projections)和连接索引(join indexes)。不过,由于 WS 必须能够支持高效的事务性更新,其存储结构与 RS 存在巨大差异。
在 WS 中,每条记录的存储键(SK)是显式存储在每个段中的。每当向某个表 T 插入一个逻辑元组时,系统会为其分配一个唯一的 SK。执行引擎必须确保这个 SK 会被记录在每一个存储该逻辑元组数据的投影中。这个 SK 是一个整数,它的值总是大于数据库中最大段的记录数。
为了实现简单性和可扩展性,WS 采用与 RS 完全相同的水平分区方式,因此 WS 段与 RS 段之间是一一对应的。一个 (sid, storage_key) 的二元组就能唯一标识一个记录,不管它存在于 RS 还是 WS 中。
由于我们假设 WS 的规模相比 RS 很小,因此不会尝试压缩数据值,而是直接存储所有数据。因此,WS 中的每个投影都使用 B 树索引来维护逻辑排序键(sort-key)的顺序。
WS 投影中的每一列都表示为一组二元组 (v, sk),其中 v 是列中的数据值,sk 是其对应的存储键。这些 (v, sk) 对会用传统 B 树按 sk 组织。
每个投影的排序键也会额外表示为一组二元组 (s, sk),其中 s 是排序键的值,sk 表示该排序键首次出现的位置。这一结构也通过传统 B 树按排序键字段组织。如果要用排序键进行搜索,系统会先用后者的 B 树找到目标存储键,再用前者的 B 树集合查找记录的其他字段。
现在可以完整地描述连接索引(join index)了: 每个投影都表示为一组段对(二元组),每对中一个属于 WS,一个属于 RS。对于“发送方”中的每条记录,我们必须记录其在“接收方”中对应记录的 sid 和 storage_key。
通常,将连接索引采用与“发送方”投影相同的水平分区方式进行划分,并将连接索引的每个分区与其相关联的发送段共址是非常有用的。实际上,每个 (sid, storage_key) 对都可以视为指向一条记录的指针,而这条记录可能存在于 RS 或 WS 中
5. 存储管理
存储管理的问题在于如何在网格系统(grid system)中将各个段分配到不同的节点上。C-Store会使用一个 存储分配器(storage allocator)自动完成这项工作。很显然,一个投影的所有列,只要属于同一个段,他们就应该被存放在同一节点上(即共址co-located)。连接索引也应该和他们发送方段(sender segments)共址。此外,每个WS(可写段)也会与包含相同存储键范围的RS(只读段)共址。
基于这些约束条件,我们正在开发一个存储分配器。这个系统不仅会进行初始分配,也会在负载不均衡时进行重新分配(reallocation)。由于C-Store中一切都是列,存储的本质就是”列的持久化“。相比于现代文件系统,使用原始设备(raw device)并没有太大优势。因此,较大的列(比如以兆字节计)就直接以单独的文件形式存储在底层操作系统中。
6. 更新与事务
7. 元祖迁移
8. C-Store查询优化器
9. 性能对比
论文阅读:列存储 vs 行存储
概要
核心观点是:列存储在处理只读查询时性能更高,因为它们只需从磁盘(或内存)读取查询所需的那些属性。这使得列存储在 I/O 方面更加高效。一个很自然的观点,能否通过行存储来获得列存储的优势,通过垂直拆分,或者为每个列建立索引,使得可以单独访问列。本文的最终结论,虽然并非不可能,但必须对存储层和查询执行器都做出改变才行
1. 介绍
对于列式存储,很多学者宣称有数量级的读速度提升,这些性能提升源于列式DBMS内部架构的根本特点,还是说列式的物理设计的传统系统中也可能实现这种性能提升?
这篇论文就是系统地回答这问题的。通过尽可能探索用“列式”的设计,包括:
- 将表垂直分区成一组由(表键,属性)对组成的双列表,这样只需要读取必要的列即可回答查询。
- 使用index-only计划,通过创建一组覆盖查询中所有列的索引,DBMS可以直接回答查询而无需访问底层表
- 使用物化视图,每个查询都有一个包含所需列的视图。虽然占用大量空间,但这是行存储的最佳情况,可以提供基准比较
通过将行存储数据库改造成列存储物理设计,发现他们的查询处理性能依然很差。文章的贡献在于证明列存储系统的设计确实有某种根本性优势,使其更适合数据仓库工作负载。引出了第二个问题,相对于行存储, 列存储哪些优势更为重要?
前期的研究表明,列式DBMS的重要优化包括:
- 延迟物化(与后面的块迭代优化结合时,称为向量化查询处理)。即将从磁盘读取的列尽可能晚地在查询计划中组合成行。
- 块迭代。即将多个来自同一列的值作为一个块从一个操作符传递到下一个,而不是使用Volcano风格的逐元迭代器。如果这些值是定宽的,它们会以数组的形式进行迭代
- 列特定的压缩技术
- 提出新的优化技术,隐形连接。它显著提高了延迟物化列存储中的连接性能
文章的第三个贡献是分析了列数据库的性能来源。压缩技术可提供数量级的提升,延迟物化大约有3倍提升,其他的不如延迟物化,大概是1.5倍。
2. 背景和先前工作
- MonetDB是率先设计了现代列存储数据库系统和向量化查询的执行方式,在TPC-H的基准测试中显著由于商业和开源数据库。但是没有评估使用列存储技术的行存储系统可能实现的性能,也没有在与 C-Store 直接在压缩数据上评估他们的优化。
- 有学者提出提出了一种混合行/列存储的方法。这里,行存储主要处理更新操作,而列存储主要处理读取操作,并通过后台进程将数据从行存储迁移到列存储。
3. Star Schema Benchmark(SSBM)基准测试

在本文中,我们使用星型模式基准测试(SSBM)[18, 19] 来比较 C-Store 和商业行存储的性能。SSBM 是一个源自 TPC-H 的数据仓库基准测试。与 TPC-H 不同,它使用的是纯粹的教科书级星型模式(这是数据仓库的“最佳实践”数据组织方式)
该基准测试包含一个事实表,即 LINEORDER 表,它结合了 TPC-H 的 LINEITEM 和 ORDERS 表。这个表有 17 列,包含了关于每个订单的详细信息,其中包括由 ORDERKEY 和 LINENUMBER 属性组成的复合主键。LINEORDER 表中的其他属性包括对 CUSTOMER、PART、SUPPLIER 和 DATE 表的外键引用(分别对应订单日期和提交日期),以及每个订单的属性,包括其优先级、数量、价格和折扣。维度表按照预期的方式包含了它们各自实体的信息。图 1显示了这些表的Schema。
与 TPC-H 一样,基准测试有一个基础的“规模因子”,可用于调整基准测试的规模。每个表的大小是根据这个规模因子来定义的。在本文中,我们使用了 10 的规模因子(生成一个包含 60,000,000 个元组的 LINEORDER 表)。
SSBM 包含 13 个查询,这些查询分为四类或称为“四个航班”:
- 航班1
- 这些查询对一个维度属性(例如时间或地点)以及 LINEORDER 表中的 DISCOUNT(折扣)和 QUANTITY(数量)列进行过滤
- 这些查询测量在特定年份内,如果取消不同的折扣水平会对不同数量的订单带来的收入增长
- 航班2
- 这些查询对两个维度属性(例如产品类别和地区)进行过滤。
- 目标是计算特定产品类别在特定地区的收入,并按产品类别和年份进行分组。
- 航班3:
- 这些查询对三个维度(例如客户国家、供应商国家、时间)进行过滤。
- 计算在特定区域内某一时间段内的收入,并按客户国家、供应商国家和年份进行分组。
- 由于选择性较小,这类查询处理的数据量可能较大,考验数据库的性能。
- 航班 4:
- 这些查询也对三个维度进行过滤,但目的是计算利润(收入减去供应成本)。
- 查询按年份、国家和类别(或区域和类别)进行分组。
- 查询的选择性表明,尽管过滤条件多,但符合条件的数据相对较少。
4. 行存储执行
3种物理设计方法:完全垂直分区、仅索引计划和物化视图。
- 垂直分区(Vertical Partitioning)
- 在完全垂直分区中,每个逻辑表的每一列都被单独存储为一个物理表。为了连接同一行的字段,通常会在每个表中添加一个整数“位置”列,以便在查询时通过位置属性进行连接
- 行存储系统需要通过哈希连接或索引连接来重新组合这些分区,这在实际应用中非常昂贵且不一定高效。
- 仅索引计划(Index-only plans)
- 通过在每个表的每一列上添加一个额外的非聚簇B+树索引来优化存储。
- 通过索引构建满足查询条件的(record-id, value)对的列表,并在内存中合并这些rid列表,而无需访问实际存储在磁盘上的行数据。
- 这种方法比垂直分区更有效率,因为它避免了存储重复的列值,并且索引中的每个元组通常比垂直分区方法的元组头开销更低。
- 但是,如果一个列没有查询条件,该方法可能需要扫描索引以提取所需的值,这可能比扫描堆文件要慢。为此,使用复合键索引是一种优化策略,以提高效率。
- 物化视图(Materialized Views)
- 这种方法是通过为每个查询航班创建一组最佳的物化视图来优化性能。物化视图仅包含查询所需的列。
- 这种方法的目的是让系统只从磁盘访问它需要的数据,避免存储记录ID或位置的开销,并且每个元组只存储一次元组头。
- 这种方法通常比前两种方法表现更好,但它要求事先了解查询的工作负载,因此仅适用于某些特定情况下。
5. 列存储执行器
有3种常见的优化方法。
压缩
- 直观上,存储在列中的数据比存储在行中的数据更具压缩性。压缩算法在信息熵低(即数据值局部性高)的数据上表现更好。
- 行存储与列存储中的压缩最大的区别在于列被排序(或次级排序)且列中有连续重复值的情况。在列存储中,总结这些值的重复性并直接在这个摘要上进行操作非常容易
- 比如其中一系列重复值被替换为一个计数和该值(例如1, 1, 1, 2, 2 → 1×3, 2×2)——直接在压缩数据上操作可以使查询执行器一次对多个列值执行相同操作,从而进一步减少CPU成本
延迟物化
- 大多数查询会访问特定实体的多个属性,因此,在大多数查询计划中,多列的数据必须组合在一起,形成关于实体的“行”信息,因此,这种类似于连接的元组物化(也称为“元组构造”)在列存储中是一种非常常见的操作。
- 较新的列存储系统在查询计划的后期才进行操作。为此,通常需要构建中间的”位置“列表,以便匹配在不同列上执行的操作。
- 例如,考虑一个在两个列上应用谓词,并从所有满足谓词的元组中投影出第三个属性的查询。在使用延迟物化的列存储中,谓词分别应用于每个属性的列,并生成通过谓词的值的位置列表(列内的顺序偏移量)
- 然后,这些位置表示将被交集(如果它们是位字符串,可以使用按位与操作)以创建一个单一的位置列表。然后,这个列表将被发送到第三列中,以提取所需位置的值
- 四个优点:
- 减少不必要的元组构造:选择和聚合操作可能使得构造某些元组变得不必要。
- 避免解压缩开销:在合并列数据之前,如果直接操作压缩数据,可以避免解压缩,从而提高性能。
- 提高缓存效率:直接操作列数据可以避免缓存行被不相关的属性污染,提高缓存性能。
- 提高固定宽度列的性能:在处理固定宽度列时,延迟物化可以利用块迭代优化,减少每元组开销
块迭代
- 为了处理一系列元组,行存储系统首先遍历每个元组,然后需要通过元组表示接口提取这些元组中的所需属性 。在许多情况下,例如在 MySQL 中,这会导致逐元组处理,即每个操作需要 1-2 次函数调用来从元组中提取所需的数据(如果是小的表达式或谓词评估,这与函数调用相比成本较低)
- 如果一次可以获得一块元组并在单次操作调用中对其进行处理,行存储中的每元组开销可以减少
- 所有我们所知的列存储系统中,来自同一列的值块会在单次函数调用中发送给操作符。此外,不需要属性提取。如果列是固定宽度的,这些值可以直接作为数组进行迭代。作为数组操作数据不仅最小化了每元组开销,还利用了现代 CPU 的并行处理潜力,因为可以使用循环流水线技术
隐形连接
在数据仓库查询中,尤其是基于星型模式建模的数据仓库,通常查询结构如下:使用一个(或多个)维度表上的选择谓词来限制事实表中的元组集。然后,对限制后的事实表执行一些聚合操作,通常按其他维度表属性进行分组。因此,需要为每个选择谓词和每个聚合分组执行事实表与维度表之间的连接。 下面是基准测试的的实例
SELECT c.nation, s.nation, d.year,
sum(lo.revenue) as revenue
FROM customer AS c, lineorder AS lo,
supplier AS s, dwdate AS d
WHERE lo.custkey = c.custkey
AND lo.suppkey = s.suppkey
AND lo.orderdate = d.datekey
AND c.region = 'ASIA'
AND s.region = 'ASIA'
AND d.year >= 1992 and d.year <= 1997
GROUP BY c.nation, s.nation, d.year
ORDER BY d.year asc, revenue desc;这个查询的目的是计算那些居住在亚洲的客户、购买了亚洲供应商产品的总收入,并按照客户国家、供应商国家和交易年份的唯一组合进行分组。
传统的执行计划是根据谓词选择性进行连接流水线。例如,如果 c.region = 'ASIA' 是最具选择性的谓词,则首先在 lineorder 和 customer 表之间按 custkey 进行连接,过滤 lineorder 表,留下来自亚洲客户的订单。在执行这个连接时,将这些客户的国籍添加到连接的客户-订单表中。然后,这些结果被传送到与 supplier 表的连接中,应用 s.region = 'ASIA' 谓词,并提取 s.nation,接着与数据表连接,应用年份谓词。最终,将这些连接的结果进行分组、聚合,并按照 ORDER BY 子句进行排序。
另一种替代传统计划的方法是延迟物化连接技术。在这种情况下,在 c.region 列上应用谓词(c.region = 'ASIA'),提取符合该谓词的客户表中的客户键。这些键随后与事实表中的客户键列进行连接。这个连接的结果是两个位置集,一个是事实表的,一个是维度表的,指示哪些元组对满足连接谓词并且已连接。通常,最多只有一个这些位置列表是按排序顺序生成的(通常是外表,即事实表)。然后,从这些(无序的)位置集提取 c.nation 列的值,并使用有序的位置集从其他事实表列(供应商键、订单日期和收入)中提取值。类似的连接操作接着会与供应商和日期表进行。
这两种计划各有其缺点。在第一种(传统)情况下,在连接之前构造元组会排除所有延迟物化的好处。在第二种情况下,维度表的分组列的值需要以无序位置的顺序提取,这可能会带来显著的成本。
作为这些查询计划的替代方法,我们引入了一种称为隐形连接的技术,它可以在列存储数据库中用于星型模式表上的外键/主键连接。隐形连接是一种延迟物化连接,但它最小化了需要以无序方式提取的值,从而缓解了上述两种缺点。它的工作原理是将连接重写为事实表列上的谓词。这些谓词可以通过哈希查找(在这种情况下模拟哈希连接)来评估,也可以使用更高级的方法,例如我们在5.4.2节中讨论的“介于谓词重写”技术。
通过将连接重写为事实表列上的选择谓词,这些谓词可以与应用于事实表的其他选择谓词同时执行,并且可以使用先前工作的任何谓词应用算法。例如,每个谓词可以并行应用,结果使用快速位图操作合并在一起。或者,将一个谓词应用的结果通过管道传输到另一个谓词应用,以减少第二个谓词必须应用的次数。只有在所有谓词都已应用之后,才从相关维度中提取适当的元组(这也可以并行完成)。通过在执行提取之前等待所有谓词都已应用,可以最小化无序提取的数量。
连接细节
第一阶段: 谓词应用。

- 每个谓词被应用于相应的维度表,以提取满足该谓词的维度表键列表,这些键列表表示哪些维度表中的条目满足谓词条件
第二阶段:位置提取。

- 在此阶段,每个哈希表用于提取满足相应谓词的事实表记录的位置。通过在哈希表中探查事实表的外键列的每个值,生成一个满足谓词条件的外键列位置列表。然后,将所有谓词的位置信息进行交集操作,生成事实表中满足条件的位置列表P。这个阶段的执行可以通过显式的位置列表或位图来完成
第三阶段:维度表提取

- 使用第二阶段生成的事实表中的位置列表P。在事实表中,每列C(包含需要查询的维度表外键引用的列)根据位置列表P提取外键值,并在相应的维度表中查找这些值。注意,如果维度表的键是从1开始的有序连续标识符(这种情况较为常见),外键实际上表示所需元组在维度表中的位置。这意味着可以直接使用该位置列表进行快速数组查找,从而提取所需的维度表列。这种直接的数组提取是隐形连接避免传统延迟物化连接方法中高昂开销的原因之一(如示例图4所示)
谓词之间重写
如前所述,这种算法本质上与列存储的半连接或延迟物化哈希连接类似。尽管连接的哈希部分以事实表列上的谓词形式表达,但实际上谓词的应用与执行延迟物化哈希连接的方式差异不大。将连接表示为谓词的优势在于,星型模式连接中常见的情况是维度表中剩余的键集是连续的。在这种情况下,我们可以使用一种称为“区间谓词重写”的技术,将谓词从事实表上的哈希查找谓词重写为一个“区间”谓词,即外键落在两个键范围之间。例如,如果在谓词应用后有效的键集是1000到2000,则可以直接检查外键是否在1000和2000之间,而不是将每个键插入哈希表并对每个外键值进行哈希查找。如果外键在这个范围内,则元组匹配;否则不匹配。由于区间谓词可以直接计算,因此执行速度较快。
这种优化的应用取决于维度表中有效键集的连续性。在许多情况下,这个属性并不成立。例如,对非排序字段的范围谓词会导致结果位置不连续。即使对于排序字段的谓词,排序维度表时可能重新排序了主键,使其不再是一个有序的连续标识符集。然而,可以通过字典编码来解决这个问题,用于键重新分配(而非压缩)。由于键是唯一的,字典编码将列编码成从0开始的有序连续列表。只要事实表外键列使用相同的字典表进行编码,就可以进行哈希表到区间谓词重写的操作。
此外,仅对维度表的排序列的谓词使用优化并不完全正确。实际上,数据仓库中的维度表通常包含逐渐细化的属性集。例如,SSBM中的日期表包含年列、年-月列和完整日期列。如果表按年份排序,次级按年-月排序,三级按完整日期排序,那么对这三列中的任何一个列的等值谓词将产生一个连续的结果集(或者是排序列上的范围谓词)。另一个例子是供应商表,它有一个区域列、一个国家列和一个城市列(一个区域有多个国家,一个国家有多个城市)。同样,从左到右的排序将使对这三列中的任何一个的谓词产生一个连续的范围输出。数据仓库查询经常访问这些列,因为OLAP实践中会在连续查询中汇总数据(如“按区域告诉我利润”,“按国家告诉我利润”,“按城市告诉我利润”)。因此,“区间谓词重写”可以比预期的更频繁地使用,并且(正如下一节所示)通常会带来显著的性能提升。
需要注意的是,谓词重写不需要修改查询优化器来检测何时可以使用此优化。评估维度表谓词的代码能够检测结果集是否连续。如果是,事实表谓词将在运行时被重写。