【大数据专场 学习资料五】第四届字节跳动青训营

2,341 阅读36分钟

第四届字节跳动青训营讲师非常用心给大家整理了课前、中、后的学习内容,同学们自我评估,选择性查漏补缺,便于大家更好的跟上讲师们的节奏,祝大家学习愉快,多多提问交流~

第一节:Parquet 和 ORC:高性能列式存储

概述

本节课程主要分为四个方面:

  1. 列式存储和行式存储的区别
  1. Parquet 列存格式的原理详解
  1. ORC 列存格式的原理详解,以及和Parquet 的对比
  1. 列存格式的演进

课前部分主要罗列课程中涉及到的概念。对于不熟悉的概念,同学们可以提前查询预习;课中部分主要罗列每一部分的关键思路,帮助同学们跟上课程的进度;课后部分是一些问题,帮助同学们在课后梳理本课程的重点。

课前 (必须)

大数据计算生态

  • 理解 OLTP 的概念和场景
  • 理解 OLAP 的概念和场景
  • 数据湖分析,参见 AWS 和 阿里云的相关产品

计算引擎

  • 熟悉 Spark 和 Spark SQL 的使用
  • 熟悉 Hive 数仓平台的概念和使用
  • 理解 Hive Metastore (HMS) 的作用
  • 熟悉 Spark on Hive 的使用

存储

  • 熟悉 HDFS 的原理和使用
  • 熟悉对象存储 (例如 AWS S3、阿里云 OSS)的原理和使用

大数据格式

  • 在 Hive 中使用 DDL 创建 Table 使用 Parquet / ORC 格式
  • Dictionary Encoding
  • Bitmap Index / Roaring Bitmap

课中

回顾

  • 典型的大数据系统可以简单的概括为由业务层、计算层和存储层三层结构组成
  • 在计算层,有各种计算引擎解决不同场景的问题,例如

    • Spark 主要适用于批式查询场景
    • Flink 主要适用于流式处理场景
    • Presto 主要适用于交互式查询场景
    • Hive 作为 Hadoop 框架上最早的SQL数仓产品,内置了 Hive Metastore (HMS) 组件,计算引擎上支持 Map-Reduce、Tez以及 Spark。目前 HMS 依然作为大数据场景下使用最为广泛的元数据服务。
  • 在存储层,HDFS 是 Hadoop 生态里第一个分布式存储服务,大数据生态里几乎所有的计算引擎都是基于 HDFS 协议和 HDFS Client 构建的。目前距离 HDFS 的发布已经有十多年的历史了,但是 HDFS 依旧在大数据存储领域占据重要的地位。

    • 近年来,随着公有云的大力发展,对象存储最为云上的存储底座,具有低成本、高可用、超大容量等优势,越来越多的云上大数据计算生态选择基于对象存储去构建。各个云产商的对象存储会提供各自的 HDFS Client 插件,支持以 HDFS Client 和协议访问对象存储。典型的例如:

  • 一个大数据查询作业,可以简单的概括为以下几个步骤:

    • 从存储层读取文件
    • 计算层解析文件内容,运行各种计算算子
    • 计算层输出结果,或者把结果写入存储层
  • 那么请大家思考以下几个问题,正式进入今天的课程

    • 计算层是如何理解文件里的数据呢?
    • 计算引擎需要读取文件里的全部内容呢?
    • 计算引擎写入的数据在文件内部是怎么组织的?
    • 不同计算引擎创建的数据文件可以相互访问吗?

行存 vs 列存

数据格式层

  • 数据格式层:定义了存储层文件内部的组织格式,计算引擎通过格式层的支持来读写文件
  • 严格意义上,并不是一个独立的层级,而是运行在计算层的一个Library

分层视角下的数据形态

  • 存储层:File,Blocks
  • 格式层:File 内部的数据布局 (Layout + Schema)
  • 计算引擎:Rows + Columns

OLTP vs OLAP

  • OLTP 和 OLAP 作为数据查询和分析领域两个典型的系统类型,具有不同的业务特征,适配不同的业务场景
  • 理解两者的区别可以帮助更好的理解行存和列存的设计背景
OLTPOLAP
典型场景在线业务系统,例如:订单、交易、社交、评论等数据仓库或者大数据分析系统,例如:决策分析、BI系统、推荐系统等
访问特征- 事务- 实时性- 低延时- 高并发- 高可用- 弱事务性- 近实时、离线分析- 大吞吐- 并发相对不高- 可用性可以有一定的妥协
数据模型特征- Schema 相对简单- 数据维度不多- 数据规模较小- Schema 复杂- 数据维度很多,几百个Column 很常见- 数据规模巨大

行式存储格式 (行存) 与 OLTP

  • 每一行 (Row) 的数据在文件的数据空间里连续存放的
  • 读取整行的效率比较高,一次顺序 IO 即可
  • 在典型的 OLTP 型的分析和存储系统中应用广泛,例如:MySQL、Oracle、RocksDB 等

列式存储格式 (列存) 与 OLAP

  • 每一列 (Column) 的数据在文件的数据空间里连续存放的
  • 同列的数据类型一致,压缩编码的效率更好
  • 在典型的 OLAP 型分析和存储系统中广泛应用,例如:

    • 大数据分析系统:Hive、Spark,数据湖分析
    • 数据仓库:ClickHouse,Greenplum,阿里云 MaxCompute

Parquet 详解

  • Dremel 数据格式的社区实现

使用 Parquet

# Spark
df.write.parquet("/path/to/file.parquet")
df.write
  .partitionBy(”col1")
  .format("parquet")
  .saveAsTable(”sometable")
val df = spark.read.parquet(”/path/to/file.parquet")

# Hive DDL
CREATE TABLE table_name (x INT, y STRING) STORED AS PARQUET;

数据模型

  • Protocol Buffer 定义
  • 支持可选和重复字段
  • 支持嵌套类型

  • 构建出如下的语法树

  • 只有叶子节点的数据会被保存在数据文件里

数据文件布局

  • RowGroup: 每一个行组包含一定数量或者固定大小的行的集合,在 HDFS 上,RowGroup 大小建议配置成 HDFS Block 大小
  • ColumnChunk: RowGroup 中按照列切分成多个 ColumnChunk
  • Page:ColumnChunk内部继续切分成 Page,一般建议 8KB 大小。Page 是压缩和编码的基本单元

    • 根据保存的数据类型,Page 可以分为:Data Page,Dictionary Page,Index Page
  • Footer 保存文件的元信息

    • Schema
  • Config
  • Metadata

    • RowGroup Meta

      • Column Meta

Parquet CLI 工具

Parquet 中的数据编码

  • 在 Parquet 的 ColumnChunk 里,同一个 ColumnChunk 内部的数据都是同一个类型的,可以通过编码的方式更高效的存储
  • Parquet 支持的编码方式有如下:
enum Encoding {
  /** Default encoding.
   * BOOLEAN - 1 bit per value. 0 is false; 1 is true.
   * INT32 - 4 bytes per value.  Stored as little-endian.
   * INT64 - 8 bytes per value.  Stored as little-endian.
   * FLOAT - 4 bytes per value.  IEEE. Stored as little-endian.
   * DOUBLE - 8 bytes per value.  IEEE. Stored as little-endian.
   * BYTE_ARRAY - 4 byte length stored as little endian, followed by bytes.
   * FIXED_LEN_BYTE_ARRAY - Just the bytes.
   */
  PLAIN = 0;

  /** Group VarInt encoding for INT32/INT64.
   * This encoding is deprecated. It was never used
   */
  //  GROUP_VAR_INT = 1;

  /**
   * Deprecated: Dictionary encoding. The values in the dictionary are encoded in the
   * plain type.
   * in a data page use RLE_DICTIONARY instead.
   * in a Dictionary page use PLAIN instead
   */
  PLAIN_DICTIONARY = 2;

  /** Group packed run length encoding. Usable for definition/repetition levels
   * encoding and Booleans (on one bit: 0 is false; 1 is true.)
   */
  RLE = 3;

  /** Bit packed encoding.  This can only be used if the data has a known max
   * width.  Usable for definition/repetition levels encoding.
   */
  BIT_PACKED = 4;

  /** Delta encoding for integers. This can be used for int columns and works best
   * on sorted data
   */
  DELTA_BINARY_PACKED = 5;

  /** Encoding for byte arrays to separate the length values and the data. The lengths
   * are encoded using DELTA_BINARY_PACKED
   */
  DELTA_LENGTH_BYTE_ARRAY = 6;

  /** Incremental-encoded byte array. Prefix lengths are encoded using DELTA_BINARY_PACKED.
   * Suffixes are stored as delta length byte arrays.
   */
  DELTA_BYTE_ARRAY = 7;

  /** Dictionary encoding: the ids are encoded using the RLE encoding
   */
  RLE_DICTIONARY = 8;

  /** Encoding for floating-point data.
      K byte-streams are created where K is the size in bytes of the data type.
      The individual bytes of an FP value are scattered to the corresponding stream and
      the streams are concatenated.
      This itself does not reduce the size of the data but can lead to better compression
      afterwards.
   */
  BYTE_STREAM_SPLIT = 9;
}
  • 下面举例介绍常见的 Encoding:

    • Run Length Encoding (RLE):适用于列基数不大,重复值较多的场景,例如:Boolean、枚举、固定的选项等

    • Bit-Pack Encoding: 对于 32位或者64位的整型数而言,并不需要完整的 4B 或者 8B 去存储,高位的零在存储时可以省略掉。适用于最大值非常明确的情况下。

      • 一般配合 RLE 一起使用
    • Dictionary Encoding:适用于列基数 (Column Cardinality) 不大的字符串类型数据存储;

      • 构造字典表,用字典中的 Index 替换真实数据
      • 替换后的数据可以使用 RLE + Bit-Pack 编码存储

  • 默认场景下 parquet-mr 会自动根据数据特征选择
  • 业务自定义:org.apache.parquet.column.values.factory.ValuesWriterFactory

Parquet 中的压缩方式

  • Page 完成 Encoding 以后,进行压缩
  • 支持多种压缩算法

    • snappy: 压缩速度快,压缩比不高,适用于热数据
    • gzip:压缩速度慢,压缩比高,适用于冷数据
    • zstd:新引入的压缩算法,压缩比和 gzip 差不多,而且压缩速度略低于 Snappy

  • 建议选择 snappy 或者 zstd,根据业务数据类型充分测试压缩效果,以及对查询性能的影响

索引和排序 Index and Ordering

  • 和传统的数据库相比,索引支持非常简陋
  • 主要依赖 Min-Max Index 和 排序 来加速查找
  • Page:记录 Column 的 min_value 和 max_value
  • Footer 里的 Column Metadata 包含 ColumnChunk 的全部 Page 的 Min-Max Value
  • 一般建议和排序配合使用效果最佳
  • 一个 Parquet 文件只能定义一组 Sort Column,类似聚集索引概念

典型的查找过程:

  • 读取 Footer
  • 根据 Column 过滤条件,查找 Min-Max Index 定位到 Page
  • 根据 Page 的 Offset Index 定位具体的位置
  • 读取 Page,获取行号
  • 从其他 Column 读取剩下的数据

Bloom Filter 索引

  • parquet.bloom.filter.enabled
  • 适用场景

    • 对于列基数比较大的场景,或者非排序列的过滤,Min-Max Index 很难发挥作用
  • 引入 Bloom Filter 加速过滤匹配判定
  • 每个 ColumnChunk 的头部保存 Bloom Filter 数据
  • Footer 记录 Bloom Filter 的 page offset

过滤下推 Predicate PushDown

  • parquet-mr 库实现,实现高效的过滤机制
  • 引擎侧传入 Filter Expression
  • parquet-mr 转换成具体 Column 的条件匹配
  • 查询 Footer 里的 Column Index,定位到具体的行号
  • 返回有效的数据给引擎侧
  • 优点:

    • 在格式层过滤掉大多数不相关的数据
    • 减少真实的读取数据量

Parquet & Spark

  • 作为最通用的 Spark 数据格式
  • 主要实现在:ParquetFileFormat
  • 向量化读是主流大数据分析引擎的标准实践,可以极大的提升查询性能
  • Spark 以 Batch 的方式从 Parquet 读取数据,下推的逻辑也会适配 Batch 的方式

图片来源:databricks.com/session/vec…

ORC 详解

  • 产生于 Hive 项目
  • 大数据分析领域使用最广的列存格式之一

数据模型

  • ORC 会给包括根节点在内的中间节点都创建一个 Column
  • 下图中,会创建 8 个 Column
  • 嵌套类型或者集合类型支持和 Parquet 差别较大
  • optional 和 repeated 字段依赖父节点记录额外信息来重新 Assembly 数据

图片来源: orc.apache.org

数据布局

  • 类似 Parquet
  • Rooter + Stripe + Column + Page (Row Group) 结构
  • Encoding / Compression / Index 支持上和 Parquet 几乎一致

图片来源: orc.apache.org

ACID 特性

  • 支持 Hive Transactions 实现,目前只有 Hive 本身集成
  • 类似 Delta Lake / Hudi / Iceberg
  • 基于 Base + Delta + Compaction 的设计

AliORC

  • ORC 在阿里云计算平台被广泛应用,主流产品 MaxCompute + 交互式分析 Hologres 的最新版本都支持 ORC 格式
  • AliORC 是对 ORC 的深度定制版

索引增强

  • 支持 Clusterd Index,更快的主键查找
  • 支持 Bitmap Index,更快的过滤

    • Roaring Bitmap

      • 更高效的压缩保存 Bitmap Index

      • 以 16 bit 的 bitmap 空间为一个保存单元,每个单元可以是以下三种形式之一:

        • Array Container:只保存为 1 的 Index
        • Run Container:类似 RLE 编码
        • Bitset container:原始 bitmap 存储

图片来源: Roaring Bitmaps: Implementation of an Optimized Software Library

其他优化

  • 小列聚合,减少小 IO

    • 重排 ColumnChunk

图片来源:www.alibabacloud.com/blog/aliorc…

  • 异步预取优化

    • 在计算引擎处理已经读到的数据的时候,异步去预取下一批次数据

图片来源:www.alibabacloud.com/blog/aliorc…

Parquet vs ORC 对比

  • 从原理层面,最大的差别就是对于 NestedType 和复杂类型处理上
  • Parquet 的算法上要复杂很多,带来的 CPU 的开销比 ORC 要略大
  • ORC 的算法上相对加单,但是要读取更多的数据
  • 因此,这个差异的对业务效果的影响,很难做一个定性的判定,更多的时候还是要取决于实际的业务场景

性能对比 1

  • Parquet 在复杂 Schema 场景下的算法开销影响较大
  • 测试平台 Hive,Hive 上 ORC 更有优势
  • 2016年

简单 Schema 复杂 Schema

来源:www.slideshare.net/HadoopSummi…

性能对比 2

  • 在 Spark 场景下 Parquet 工作的更好;在 Hive 场景下,ORC 更好

来源:onlinelibrary.wiley.com/doi/epdf/10…

如何选择

  • 当前项目使用的??
  • 性能上很多情况下依赖于数据集和测试环境,不能迷信 Benchmark 结果
  • 根据实际业务做细粒度的调优
  • Spark 生态下 Parquet 比较普遍
  • Hive 生态下 ORC 有原生支持
  • 整体上,Spark 比 Hive 更加有优势,所以大部分情况下,Parquet 可能是个更好的选择

问题:在 Nested Type 的处理上,Parquet 和 ORC 实现机制的不同对于最终性能有什么影响?

列存演进

数仓中的列存

  • 典型的数仓,例如 ClickHouse 的 MergeTree 引擎也是基于列存构建的

    • 默认情况下列按照 Column 拆分成单独的文件,也支持单个文件形式

图片来源:www.jianshu.com/p/4a86a4f5b…

  • 支持更加丰富的索引,例如 Bitmap Index、Reverted Index、Data Skipping Index、Secondary Index 等
  • 湖仓一体的大趋势下,数仓和大数据数据湖技术和场景下趋于融合,大数据场景下的格式层会借鉴更多的数仓中的技术

存储侧下推

  • 更多的下推工作下沉到存储服务侧
  • 越接近数据,下推过滤的效率越高
  • 例如 AWS S3 Select 功能

图片来源: aws.amazon.com/blogs/aws/s…

  • 挑战:

    • 存储侧感知 Schema
    • 计算生态的兼容和集成

Column Family 支持

  • 背景:Hudi 数据湖场景下,支持部分列的快速更新
  • 在 Parquet 格式里引入 Column Family 概念,把需要更新的列拆成独立的 Column Family
  • 深度改造 Hudi 的 Update 和 Query 逻辑,根据 Column Family 选择覆盖对应的 Column Family
  • Update 操作实际效果有 10+ 倍的提升

课后

  1. 实操 TPCH 数据集,Parquet 和 ORC 两种格式的数据导入和Query
  1. 阅读 Dremel 论文,理解 Column 编码和重新装配细节

第二节:实操课-简易列式存储

前置克隆项目地址: github.com/theMYang/da… ,开课前1天完成项目运行,有问题可以记录一下,直播课跟着讲师节奏走,或者积极在弹幕区提问哈。

背景知识

行存 VS 列存

行存列存
按行写入数据,读取数据时需要读取不必要的列可以只读取请求的列
适用于OLTP系统适用于OLAP系统
适用于按记录读取数据适用于按列读取数据
不利于大数据集聚合统计操作利于大数据集聚合统计操作
不利于数据压缩利于数据压缩

Parquet 数据布局

项目目标

  1. 理解列存数据排布
  1. 代码设计 - 了解主要实现类的行为和功能
  1. 代码实现 - 以列存格式写入数据并正确读出
  1. 优化现有代码

项目设计

写入过程

示例表结构

a_field (int)b_field (long)
a1b1
a2b2
......
a100b100

写入流程

  1. 将一行行数据的字段拆分,分别存储在各字段对应的ChunkWriter中
  1. 当写入的数据达到一个块的长度,或是数据已经写完,此时会持久化数据,以及生成块的元数据

    1. 持久化数据至输出流中
    2. 生成元数据,包括每个字段的元信息以及该块已写数据量
  1. 当整个文件写完,将文件元信息写至输出流,并在文件最后记录元数据占用字节数。

image.png 最终文件组织示例

image.png

文件

image.png

读取流程

  1. 读取元数据
  1. 根据元数据,获取每个block中各个列信息
  1. 根据请求列,读取对应列数据
  1. 将读取到的数据拼成Record返回

代码流程图

写入流程

读取流程

项目仓库

代码结果

代码仓库: github.com/theMYang/da… 利用cloc工具进行代码统计:

编译

mvn clean install -DskipTests

代码优化

  • 支持复杂类型读写
  • 增加列基本统计值,增加filter过滤不需要读取的数据
  • 支持数据压缩、编码
  • 预读取优化
  • 拆列优化
  • 对接Spark或Hive等计算引擎

第三节:LSMT 存储引擎浅析

概述

本节课程主要分为 4 个方面:

  1. 介绍 LSMT 与存储引擎
  1. 分析 LSMT 存储引擎的优势与实现
  1. LSMT 模型理论分析
  1. LSMT 存储引擎调优以及案例介绍

课前部分给出一些 LSMT 存储引擎的基础知识点,作为课堂内容的前置基础,请同学们按需提前自行学习;

课中部分对课堂内容作一些细节上的补充,旨在帮助同学们加深对课题的理解;

课后部分作为重点总结,帮助同学们梳理复习本课程涵盖了 LSMT 存储引擎的哪些主题。

课前

最早提出 Log-Structured Merge-Tree(LSMT) 的论文

www.cs.umb.edu/~poneil/lsm… in 1996

相较而言 B-Tree 就早得多了,1970 提出

这在一定程度上可以解释,较早的数据库产品,如 MySQL,PostgresQL 默认均采用 B+Tree(B-Tree 变种)索引。较新的数据库产品,如 TiDB,CockroachDB,默认均采用 LSMT 存储引擎(RocksDB / Pebble)。LSMT 和 B-Tree 看起来是一个时代的产物,其实二者差了 26 年。对技术历史想「考古」的同学可以自行搜索相关资料。

从趋势来看,LSMT 模型变得越来越流行。LSMT 模型广泛应用于目前的数据库系统,例如 Google BigTable,HBase,Canssandra,RocksDB 等,可以说是数据库存储子系统的基石之一。

LSMT 是如何工作的?

一言以蔽之,通过 Append-only Write + 择机 Compact 来维护索引树的结构。

Ref: github.com/facebook/ro…

数据先写入 MemTable,MemTable 是内存中的索引可以用 SkipList / B+Tree 等数据结构实现。当 MemTable 写到一定阈值后,冻结,成为 ImmemTable,任何修改只会作用于 MemTable,所以 ImmemTable 可以被转交给 Flush 线程进行写盘操作而不用担心并发问题。Flush 线程收到 ImmemTable ,在真正执行写盘前,会进一步从 ImmemTable 生成 SST(Sorted String Table),其实也就是存储在硬盘上的索引,逻辑上和 ImmemTable 无异。

新生成的 SST 会存放于 L0(Layer 0),除了 L0 以外根据配置可以一直有 Ln。SST 每 Compact 一次,就会将 Compact 产物放入下一层。Compact 可以大致理解为 Merge Sort,就是将多个 SST 去掉无效和重复的条目并合并生成新的 SST 的过程。Compact 策略主要分为 Level 和 Tier 两种,会在课中进行更详细的描述。

为什么要采用 LSMT 模型?

All problems in computer science can be solved by another level of indirection

From Butler Lampson

在计算机存储乃至整个工程界都在利用 Indirection 处理资源的不对称性,比方说内存价格上升了,就要想办法 offload 数据到别的廉价介质上。存储引擎面对的资源不对称性在不同时期是不同的。

HDD 时代:

顺序操作和随机操作的不对称性

Ref: 头条百科

机械硬盘的读写依赖于磁盘的旋转和机械臂移动。工程上一般估计机械硬盘的点查(主要开销是 Seek 寻道)延迟是 1ms。即使每次点查都读 4KB(对于点查来说相当大了),也就只能输出约 4MB/s。

反观顺序写,由于不需要寻道,磁头始终能处在工作状态,基本都能做到至少 100MB/s 写吞吐,是点查的 25 倍!

SSD 时代:

顺序写和随机写的不对称性

SSD 是基于 NAND Flash 颗粒的构建的,称之为 DIE,DIE 上有多个 Plane,每个 Plane 能单独提供读写能力,Plane 包含多个 Block,Block 包含多个 Page。擦除的电路实现比较复杂,出于成本的考量,写入的最小单位是 Page,而擦除的最小单位是 Block。

随着用户不断写入和删除,有可能出现有很多 Page 已经被删除了,逻辑上有可用空间,但是物理上 Block 还有别的有效 Page,无效 Page 无法回收。这样用户就写不进数据了。因此,SSD 主控必须执行 GC(Garbage Collection),将有效的 Page 从要回收的 Block 中挑出来,写到另一个 Block 上,再整体回收旧 Block。因此如果用户长期都是随机写,大量 Block 都会处于一部分 Page 是有效,一部分 Page 是无效的状态,SSD 主控不得不频繁 GC。

以经典服务器 SSD,Intel P4510 2TB 为例,根据官方 spec,随机写吞吐是 318MB/s,顺序写则高达 2000MB/s 是随机写的 6 倍多!

简单总结一下,无论对于 HDD 还是 SSD,顺序写都是一个很好的特质,LSMT 符合这一点,B+Tree 则依赖原地更新,会导致随机写。

存储引擎如何与数据库结合?

上文大致介绍了存储引擎,逻辑上大致可以看作一个持久化的索引,那么这是怎么和数据库结合起来的呢?

以单机数据库 MySQL 为例,

Ref: vldb.org/pvldb/vol13…

传统数据库大致可以分为

  • 计算层
  • 存储层(存储引擎层)

介于二者之间还有一些界限比较模糊的组件,比如 Replication,MySQL 是用 bin log 独立于存储引擎,而对于一些 NoSQL 数据库(字节 Abase 1.0)来说,Replication 直接基于存储引擎的 WAL。

计算层主要负责 SQL 解析/ 查询优化 / 计划执行。我们重点关注存储层提供了什么能力。数据库著名的 ACID 特性,在 MySQL 中全部强依赖于存储引擎。

ACID 定义参见:en.wikipedia.org/wiki/ACID

  • Atomicity

原子性依赖于存储引擎 WAL(Redo Log)

  • Consistency (Correctness)

一致性需要数据库整体来保证

  • Isolation

隔离性依赖于存储引擎提供 Snapshot(有时候会直接说 MVCC)能力。如果上层没有单独的事务引擎的话,也会由存储引擎提供事务能力。一般的是实现是 2PL(2 Phase Lock) + MVCC。2PL 可以简单理解为对所有需要修改的资源上锁。

  • Durability

持久性依赖于存储引擎确保在 Transaction Commit 后通过操作系统 fsync 之类的接口确保落盘了

课中

LSMT 与 B+Tree 的异同

先简单回顾下经典 B+Tree 写入流程,

Ref: www.geeksforgeeks.org/insertion-i…

有一 Order 为 5 的 B+Tree,目前存有 (10, 20, 30, 40),继续插入 15,节点大小到达分裂阈值 5,提取中位数 20 放入新的内部节点,比 20 大的 (30, 40) 移入新的叶节点。这个例子虽然简单,但是涉及了 B+Tree 最核心的两个变化,插入与分裂。

在 B+Tree 中,数据插入是原地更新的,装有 (10, 20, 30, 40) 的节点在插入和分裂后,原节点覆写成 (10, 15)。此外,B+Tree 在发生不平衡或者节点容量到达阈值后,必须立即进行分裂来平衡。

反观 LSMT,数据的插入是追加的(Append-only),当树不平衡或者垃圾过多时,有专门 Compact 线程进行 Compact,可以称之为延迟(Lazy)的。

思考一个问题,B+Tree 能不能把部分数据采用追加写,然后让后台线程去 Compact 维护树结构呢?或者 LSMT 能不能只有一层 L0,ImmemTable 给 Flush 线程之后,立马 Compact 呢?

答案是都可以。前者的做法叫做 Fractal tree(分型树)应用在了 TokuDB 中。后者的做法在 OceanBase 或者类似对延迟有严格要求的在线数据库中得到了应用,因为 LSMT 层数越少,读取越快。

所以从高层次的数据结构角度来看,B+Tree 和 LSMT 并没有本质的不同,可以统一到一个模型里,根据 Workload 的不同互相转换。这是 CIDR19 论文「Design Continuums and the Path Toward Self-Designing Key-Value Stores that Know and Learn」,www.cidrdb.org/cidr2019/pa…

Ref: Design Continuums and the Path Toward Self-Designing Key-Value Stores that Know and Learn

B+Tree 中内部节点指向其它节点的指针,被称之为 Fence Pointers。在 LSMT 也有,只不过是隐式表达的。B+Tree 直接通过 Fence Pointer 一层一层往下找,而 LSMT 是有一个中心的 Meta 信息记录所有 SST 文件的 Key 区间,通过区间大小关系,一层一层向下找。

再看 LSMT 的 SST,其实和 B+Tree 的 Node 也没有本质差别,逻辑上就是一个可查询的有序块,统一模型中称之为 Run。B+Tree 为了支持随机修改,结构会比较松散和简单,LSMT 则因为不需要支持随机修改,利用压缩技术,结构可以更紧凑。

更详细的统一模型描述,请同学们参见论文。尽管 LSMT 和 B+Tree 可以用一个模型描述,工程实践上我们还是用 LSMT 来表示一个 Append-only 和 Lazy Compact 的索引树,B+Tree 来表示一个 Inplace-Update 和 Instant Compact 的索引树。Append-only 和 Lazy Compact 这两个特性更符合现代计算机设备的特性

存储引擎在数据库中做了什么?

在课前部分,我们以 ACID 特性为切入点,大概了解存储引擎在数据库系统中的定位,现在让我们来学习下除了保障 ACID 以外,存储引擎究竟还在数据库中做了什么。

  • 屏蔽 IO 细节提供更好的抽象

IO 是一种具体实现很复杂,但是逻辑边界很清晰的任务,存储引擎需要屏蔽不同 IO 硬件设备(HDD,SSD,PMem etc),不同系统 API(pread / libaio / iouring)的差别,给出统一的抽象。

对于不同硬件设备,存储引擎要能选择最合适的数据分块尺寸,例如 SSD 中一般 Page 大小为 4KB,但在 PMem 中,读写单位就是 256Bytes 了。

对于不同的系统,最佳的读写 API 也不同。例如在 Linux 系统中提供了 libaio 和 iouring 这样的异步 IO 接口,可以避免多线程 + 同步接口带来的频繁线程 context swtich 开销,读写引擎要能在上层 API 不变或者很小的情况下充分利用这些特性。

存储引擎更好的利用这些环境的差异就能更好地服务上层。还有一种相反的做法是这些细节都由操作系统来屏蔽,比如使用 mmap 接口。由于操作系统并不完全感知数据库任务的特性,会造成以下问题:

  1. 事务不安全,因为操作系统并不知道具体的事务 commit 时机,有可能事务还没 commit,但数据已经落盘了
  1. IO Stall,mmap 在发生 page fault 的时候,用户态程序是没办法插手的,也没办法预期什么时候完成,不可控
  1. 错误处理,mmap 在发生硬件错误的时候,并没有办法通知用户态程序,每次读取都要进行校验
  1. 性能,mmap 触发 page fault 的成本很高,无法完全发挥硬件性能

具体可以参见论文,www.cidrdb.org/cidr2022/pa… zhuanlan.zhihu.com/p/470109297

  • 提供统计信息与 Predicate Push Down 能力

数据库绝大部分持久化状态和数据都存放在存储引擎里,因此存储引擎相比于上层有着对数据更准确的信息。例如 RocksDB 提供了 ApproxSize 接口,可以让优化器在估算代价的时候,得到区间内大概有多少元素,生成更优的执行计划。

存储引擎是数据读取的源头,因此还可以将一些过滤条件下推引擎,避免无意义的 IO,例如在 Parquet 数据格式中,数据是分 column 存储的。如果只需要部分 column,就可以把这个信息传给存储引擎,不用去读不需要的 column。

LSMT 存储引擎的优势

  • 相对于 B+Tree 的优势

我们在前文已经阐述了 LSMT 与 B+Tree 的异同,在这里总结下 LSMT 的优势。

  1. 顺序写模型对于 SSD 设备更友好
  1. SST 不可修改的特性使得其能使用更加紧凑的数据排列和加上压缩
  1. 后台延迟 Compact 能更好利用 CPU 多核处理能力,降低前台请求延迟
  • 相对于 HashTable 的优势

LSMT 存储引擎是有序索引抽象,HashTable 是无序索引抽象。无序索引是有序索引的真子集。LSMT 相比于 HashTable 更加通用。HashTable 能处理点查请求,LSMT 也能,但 LSMT 能处理 TopK 请求,但 HashTable 就不行了。为了避免维护多套存储引擎,绝大多数数据库都直接采用一套有序的存储引擎而非针对点查和顺序读取分别维护两个引擎。

LSMT 存储引擎的实现,以 RocksDB 为例

RocksDB 是一款十分流行的开源 LSMT 存储引擎,最早来自 Facebook(Meta),应用于 MyRocks,TiDB,在字节内部也有 Abase,ByteKV,ByteNDB,Bytable 等用户。因此接下来将会以 RocksDB 为例子介绍 LSMT 存储引擎的经典实现。

Write

为了确保操作的原子性,RocksDB 在真正执行修改之前会先将变更写入 WAL(Write Ahead Log),WAL 写成功则写入成功。因为即使这时候程序 crash,在重启阶段可以通过回放 WAL 来恢复或者继续之前的变更。操作只有成功和失败两种状态。

RocksDB WAL 写入流程继承自 LevelDB。LevelDB 在 WAL 写入主要做的一个优化是多个写入者会选出一个 Leader,由这个 Leader 来一次性写入。这样的好处在于可以批量聚合请求,避免频繁提交小 IO。

但很多业务其实不会要求每次 WAL 写入必须落盘,而是写到 Kernel 的 Page Cache 就可以,Kernel 自身是会聚合小 IO 再下刷的。这时候,批量提交的好处就在于降低了操作系统调度线程的开销。

批量提交时,Leader 可以同时唤醒其余 Writer。

暂时无法在飞书文档外展示此内容

image.png

如果没有批量提交就只能链式唤醒了。

image.png

写完 WAL 实际还要写 MemTable,这步相比于写 WAL 到 Page Cache 更耗时而且是可以完全并行化的。RocksDB 在 LevelDB 的基础上主要又添加了并发 MemTable 写入的优化,由最后一个完成 MemTable 写入的 Writer 执行收尾工作。完整 RocksDB 写入流程如下:

为了方便更好表明哪些事件是同时发生的,相同时刻的事件的背景颜色是一样的。

image.png

RocksDB 为了保证线性一致性,必须有一个 Leader 分配时间戳,每条修改记录都会带着分配到的时间戳,也必须有一个 Leader 推进当前可见的时间戳。目前的写入流程已经相当优化了。

Snapshot & SuperVision

RocksDB 的数据由 3 部分组成,MemTable / ImmemTable / SST。直接持有这三部分数据并且提供快照功能的组件叫做 SuperVersion。

image.png

RocksDB 的 MemTable 和 SST 的释放与删除都依赖于引用计数,SuperVersion 不释放,对应的 MemTable 和 SST 就不会释放。对于读取操作来说,只要拿着这个 SuperVersion,从 MemTable 开始一级一级向下,就能查询到记录。那么拿着 SuperVersion 不释放,等于是拿到了快照。

如果所有读者开始操作前都给 SuperVersion 的计数加 1,读完后再减 1,那么这个原子引用计数器就会成为热点。CPU 在多核之间同步缓存是有开销的,核越多开销越大。一般工程上可以简单估计,核多了之后 CAS 同一个 cache line,性能不会超过 100W/s。为了让读操作更好的 scale,RocksDB 做了一个优化是 Thread Local SuperVersion Cache。每个读者都缓存一个 SuperVersion,读之前检查下 SuperVersion 是否过期,如果没有就直接用这个 SuperVersion,不需要再加减引用计数器。如果 SuperVersion 过期了,读者就必须刷新一遍 SuperVersion。为了避免某一个读者的 Thread Local 缓存持有一个 SuperVersion 太久导致资源无法回收,每当有新的 SuperVersion 生成时会标记所有读者缓存的 SuperVersion 失效。

没有 Thread Local 缓存时,读取操作要频繁 Acquire 和 Release SuperVersion

image.png

有 Thread Local 缓存时,读取只需要检查一下 SuperVersion 并标记缓存正在使用即可,可以看出多核之间的交互就仅剩检查 SuperVersion 缓存是否过期了。

image.png

Get & BloomFilter

由于 LSMT 是延迟 Compact 的且 SST 尺寸(MB 级别)比 B+Tree Node (KB 级别)大得多。所以相对而言,LSMT 点查需要访问的数据块更多。为了加速点查,一般 LSMT 引擎都会在 SST 中嵌入 BloomFilter,例如 RocksDB 默认的 BlockBasedTable。BloomFilter 可以 100% 断言一个元素不在集合内,但只能大概率判定一个元素在集合内。

RocksDB 的读取在大框架上和 B+ Tree 类似,就是层层向下。[1, 10] 表示这个索引块存储数据的区间在 1 - 10 之间。索引块可以是 MemTable / ImmemTable / SST,它们抽象上是一样的。查询 2,就是顺着标绿色的块往下。如果索引块是 SST,就先查询 BloomFilter,看数据是否有可能在这个 SST 中,有的话则进行进一步查询。

image.png

除了 BloomFilter 外,BlockBasedTable 还有额外两个值得提的实现。一个是两层索引:

image.png

浅黄部分是 DataBlock,绿色部分是 IndexBlock。DataBlock 记载实际数据,IndexBlock 索引 DataBlock。假如要查询 3,先从 IndexBlock 中找到 >= 3 的第一条记录是什么,发现是 4,对应的 value 是 data_block_0 的 offset,直接定位到 Data Block 0。然后可以在 Data Block 0 中进行搜索。

另一个是前缀压缩,RocksDB 源代码中的注释已经写得很明白了。

// BlockBuilder generates blocks where keys are prefix-compressed:
//
// When we store a key, we drop the prefix shared with the previous
// string.  This helps reduce the space requirement significantly.
// Furthermore, once every K keys, we do not apply the prefix
// compression and store the entire key.  We call this a "restart
// point".  The tail end of the block stores the offsets of all of the
// restart points, and can be used to do a binary search when looking
// for a particular key.  Values are stored as-is (without compression)
// immediately following the corresponding key.
//
// An entry for a particular key-value pair has the form:
//     shared_bytes: varint32
//     unshared_bytes: varint32
//     value_length: varint32
//     key_delta: char[unshared_bytes]
//     value: char[value_length]
// shared_bytes == 0 for restart points.
//
// The trailer of the block has the form:
//     restarts: uint32[num_restarts]
//     num_restarts: uint32
// restarts[i] contains the offset within the block of the ith restart point.

Ref: RocksDB Source Code

每 k 个元素会共享前缀。每个元素会用 varint32 记录和前一个元素有多少前缀重合。

Compact

Compact 在 LSMT 中是将 Key 区间有重叠或无效数据较多的 SST 进行合并,以此来加速读取或者回收空间。Compact 策略可以分为两大类。

  • Level

Ref: LSM-based Storage Techniques: A Survey

Level 策略直接来自于 LevelDB,也是 RocksDB 的默认策略。每一个层不允许有 SST 的 Key 区间重合。当用户写入的 SST 加入 L0 的时候会和 L0 里区间重叠的 SST 进行合并。当 L0 的总大小到达一定阈值时,又会从 L0 挑出 SST,推到 L1,和 L1 里 Key 区间重叠的 SST 进行合并。Ln 同理。

由于在 LSMT 中,每下一层都会比上一层大 T 倍(可配置),那么假设用户的输入是均匀分布的,每次上下层的合并都一定是一个小 SST 和一个大 SST 进行 Compact。这个从算法的角度来说是低效的,增加了写放大,具体理论分析会在之后阐述,这里可以想象一下 Merge Sort。Merge Sort 要效率最高,就要每次 Merge 的时候,左右两边的数组都是一样大。

实际上,RocksDB 和 LevelDB 都不是纯粹的 Level 策略,它们将 L0 作为例外,允许有 SST Key 区间重叠来降低写放大。

  • Tier

Ref: LSM-based Storage Techniques: A Survey

Tier 策略允许 LSMT 每层有多个区间重合的 SST,当本层区间重合的 SST 到达上限或者本层大小到达阈值时,一次性选择多个 SST 合并推向下层。Tier 策略理论上 Compact 效率更高,因为参与 Compact 的 SST 大小预期都差不多大,更接近于完美的 Merge Sort。

Tier 策略的问题在于每层的区间内重合的 SST 越多,那么读取的时候需要查询的 SST 就越多。Tier 策略是用读放大的增加换取了写放大的减小。

Cloud-Native LSMT Storage Engine

RocksDB 是单机存储引擎,那么现在都说云原生,HBase 比 RocksDB 就更「云」一些,SST 直接存储于 HDFS 上,Meta 信息 RocksDB 自己管理维护于 Manifest 文件,HBase 放置于 ZK。二者在理论存储模型上都是 LSMT。

LSMT 模型理论分析

T: size ratio,每层 LSMT 比上一层大多少,L0 大小为 1,则 L1 大小为 T,L2 为 T^2,以此类推

L: level num,LSMT 层数

B: 每个最小的 IO 单位能装载多少条记录

M: 每个 BloomFilter 有多少 bits

N: 每个 BloomFilter 生成时用了多少条 Key

eMNe^{- \frac{M}{N} } 是 BloomFilter 的 false positive rate

S:区间查询的记录数量

Ref: LSM-based Storage Techniques: A Survey

Short Range Query / Long Range Query / Space Amplification,篇幅有限,建议直接阅读原论文。

  • Level

Write:每条记录抵达最底层需要经过 L 次 Compact,每次 Compact Ln 的一个小 SST 和 Ln+1 的一个大 SST。设小 SST 的大小为 1,那么大 SST 的大小则为 T,合并开销是 1+T,换言之将 1 单位的 Ln 的 SST 推到 Ln+1 要耗费 1+T 的 IO,单次 Compact 写放大为 T。每条记录的写入成本为 1/B 次最小单位 IO。三者相乘即得结果。

Point Lookup:对于每条 Key,最多有 L 个重叠的区间,每个区间都有 BloomFilter,失效率为eMNe^{- \frac{M}{N} } ,只有当 BloomFilter 失效时才会访问下一层。因此二者相乘可得读取的开销。注意,这里不乘 1/B 的原因是写入可以批量提交,但是读取的时候必须对齐到最小读取单元尺寸。

  • Tier

Write:每条记录抵达最底层前同样要经过 L 次 Compact,每次 Compact Ln 中 T 个相同尺寸的 SST 放到 Ln+1。设 SST 大小为 1,那么 T 个 SST Compact 的合并开销是 T,换言之将 T 单位的 Ln 的 SST 推到 Ln+1 要耗费 T 的 IO,单次 Compact 的写放大为 T / T = 1。每条记录的写入成本为 1/B 次最小单位 IO。三者相乘即得结果。

Point Lookup:对于每条 Key,有 L 层,每层最多有 T 个重叠区间的 SST,对于整个 SST 来说有 T *

L 个可能命中的 SST,乘上 BloomFilter 的失效率即可得结果。

总结,Tier 策略降低了写放大,增加了读放大和空间放大,Level 策略增加了写放大,降低了读和空间放大。

LSMT 引擎调优案例

TerarkDB aka LavaKV 是字节跳动内部基于 RocksDB 深度定制优化的自研 LSMT 存储引擎,其中完全自研的 KV 分离功能,上线后取得了巨大的收益。

KV 分离受启发于论文 WiscKey: Separating Keys from Values in SSD-conscious Storage,www.usenix.org/system/file… 较长的记录的 Value 单独存储,避免 Compact 过程中频繁挪动这些数据。做法虽然简单,但背后的原理却十分深刻。存储引擎其实存了两类数据,一类是索引,一类是用户输入的数据。对于索引来说,随着记录不断变更,需要维护索引的拓扑结构,因此要不断 Compact,但对于用户存储的数据来说,只要用户没删除,可以一直放着,放哪里不重要,能读就行,不需要经常跟着 Compact。只要 Value 足够长,更少 Compact 的收益就能覆盖 KV 分离后,额外维护映射关系的开销。

这里分享两个字节内部真实案例。

Abase 图存储场景使用 TerarkDB

  • 图存储场景描述

    • Key size :20B ~ 30B
    • Value size:数十 KB 级别
    • 写多读少
  • 收益结论:

延迟大幅度降低,长尾消失,扛住了比 RocksDB 高 50% 的负载。

# RocksDB# TerarkDB

Flink 流计算场景使用 TerarkDB

  • 收益结论:
  1. 平均 CPU 开销在 3个作业上降低了 26%~39%
  1. 峰值 CPU 开销在广告作业上有明显的收益,降低了 67%

    1. live_feed_head 作业上峰值 CPU 开销降低 43%
    2. multi_trigger 受限于分配的CPU 资源,没有观察到峰值 CPU 收益( 平均 CPU 开销降低 39% )
  1. 平均容量开销在 3 个作业上降低了17%~31.2%
  1. 直播业务某集群容量不收缩,TerarkDB 的 schedule TTL GC 彻底解决了该问题
  • 收益说明:
  1. 平均 CPU 收益主要来自于,开启 KV 分离,减少写放大
  1. 容量收益主要来自于 schedule TTL GC,该功能可以根据 SST 的过期时间主动发起Compaction,而不需要被动的跟随 LSM-tree 形态调整回收空间
multi_trigger 4620 corelive_feed_head (3600core : 3000core)ad_online_joiner (786core )RocksDBTerarkDB收益描述
MaxAverageMaxAverage
multi_trigger(4/12 15点~4/13 15点)Application Used CPU4.68K2.51K4.76K1.54K平均 CPU 开销降低 39%
Application Used Memory10.4TB10.1TB9.97TB9.35TB内存开销降低 3.1%
Total CheckPoint State Size19.3TB12.7TB16.3TB8.74TB平均容量降低 31.2%

Flink 使用 FIFO 和 TerarkDB 对比

线上 live head 作业,FIFO 和 TerarkDB 通过不同的方式减少了 Compaction ,相比于 Leveled Compaction, FIFO CPU 收益 29%, 略低于 TerarkDB 的 34% 。

FIFO 存在较严重的空间放大,live head作业上, FIFO 容量峰值 比 Leveled 大30%,而 TerarkDB 容量峰值比 Leveled 小 15%。

课后

存储引擎最新发展趋势

新硬件

在新的硬件(SMR HDD,Zoned SSD,PMem)上设计存储引擎,

e.g.

MatrixKV: Reducing Write Stalls and Write Amplification in LSM-tree Based KV Stores with Matrix Container in NVM

新模型

在现有模型上添加新的扩展,

e.g.

KV 分离,WiscKey: Separating Keys from Values in SSD-conscious Storage

REMIX: Efficient Range Query for LSM-trees

新参数 / 新工况

发现现有模型在某些工况中表现不够好,并调整现有参数,

e.g.

The Log-Structured Merge-Bush & the Wacky Continuum