性能提升利器|PolarDB- X 超详细列存查询技术解读

简介: 本文将深入探讨 PolarDB-X 列存查询引擎的分层缓存解决方案,以及其在优化 ORC 列存查询性能中的关键作用。

1.引言

在当今数据迅速增长的时代,高效查询海量数据已成为企业和技术人员面临的重要挑战。列式存储格式,如 ORC,虽然在特定场景下具有明显优势,但面对大规模数据集时,查询速度仍存在瓶颈。本文将深入探讨 PolarDB-X 列存查询引擎的分层缓存解决方案,以及其在优化 ORC 列存查询性能中的关键作用。我们将分析其设计原理、实现细节和适用场景,展示该方案在大数据查询中的广泛应用及其带来的高效性和可靠性。此外,文章还将介绍 ORC 文件的存储结构、数据压缩与解压技术,以及执行器中间结果缓存的反压管理策略,说明如何通过分级缓存和反压机制进一步优化查询性能。通过这些内容,读者将全面了解 PolarDB-X 分层缓存解决方案在提升列式存储查询效率方面的实际效果和技术优势。


2.多级缓存管理


2.1 概述

在 PolarDB-X 的列存查询引擎中,多级缓存管理是提升查询性能的核心策略。通过在不同层次设置缓存,系统能够根据数据访问的频率和特性,实现高效的数据加载和查询响应。这种分层的缓存架构不仅优化了数据读取路径,还有效减少了对底层存储系统的依赖,从而显著提升了查询速度。


2.2 ORC 的存储层次结构

ORC(Optimized Row Columnar)格式是一种列式存储格式,广泛应用于大数据处理。其内部结构设计精妙,通过多层次的组织方式,实现了高效的数据压缩和快速的列级别查询。

image.png

Stripe、Column 和 RowGroup


  • Stripe:ORC 文件内部默认每 64MB 形成一个 Stripe。Stripe 内部存储多个 Column(列)。
  • Column:在 Stripe 内部,每个列在一段连续的文件区域内存储,包含多个 RowGroup。
  • RowGroup:在 Column 内部,每 10,000 行数据划分为一个 RowGroup(行组)。RowGroup 是 ORC 进行压缩、解压缩、编码、解码的基本单位。当 Column 内部存储的总行数不是 10,000 的整数倍时,最后一个 RowGroup 可能不足 10,000 行。

此外,Stripe Footer 和 Index Data 这两类结构用于存储位置信息,方便快速定位数据块。


SMA 索引


ORC 文件中,SMA(Statistics Minimum and Maximum)索引用于存储每个 Stripe 和 RowGroup 的最小值和最大值统计信息。这些信息在查询时可用于快速过滤不相关的数据块,从而减少不必要的数据读取,提高查询效率。


2.3 定位 ORC 数据块

为了在 ORC 文件中精确定位到一个数据块(如 1000 行的数据单元),需要一个唯一的逻辑地址,包含以下 5 个字段:

  1. StripeId:文件内部 Stripe 的编号。
  2. ColumnId:Stripe 内部列的编号。
  3. RowGroupId:列内部 RowGroup 的编号。
  4. StartPosition:RowGroup 内部 Block 的起始位置。
  5. PositionCount:Block 内的数据行数。

通过这些字段,系统能够准确地定位到 ORC 文件中的具体数据位置,从而实现高效的数据读取。


2.4 列存查询与 ORC 的关系

在列存查询过程中,执行器(Executor)是数据查询操作的核心组件。它根据 SQL 语句、算子(如扫描、联接等)和谓词条件,制定执行计划树,并执行相应的查询操作。


执行计划与 Scan 算子

执行器生成的执行计划树中包含 Scan 算子,该算子定义了如何对 ORC 文件执行扫描操作,即读取特定列的数据。这些操作通常以列为中心,在 ORC 格式的文件中进行,大大提升了查询效率。


谓词条件与 SMA 索引

在执行查询操作时,Scan 算子带有谓词条件用于过滤数据。ORC 格式的 SMA 索引使得执行器可以在不读取所有数据的情况下,高效确定哪些 Stripe 或 RowGroup 包含符合条件的数据。通过谓词条件与 SMA 索引的结合,系统能够减少数据加载和扫描的工作量,进一步提升查询性能。


数据压缩与解压

ORC 文件通常使用多种压缩技术,如 LZ4 通用压缩和 RunLength 数值压缩,以减少存储空间并提升 I/O 效率。执行器在读取数据时会自动进行解压缩操作,压缩比通常在 2 到 4 倍之间,这不仅节省了存储空间,还提高了数据读取的效率。


3.整体层次结构

PolarDB-X 列存查询引擎采用了多级缓存机制,包括一级、二级和三级缓存,以及底层的 OSS 存储底座。每一级缓存都有其独特的设计和作用,通过分级查找和层层缓存,系统能够高效地访问和管理海量数据。

image.png


3.1 流程示意

整体流程可以概括为以下几个步骤:


  1. 分级缓存:系统配置了 三级缓存,每级缓存如果命中,则返回命中的缓存;否则继续查找下一级缓存,直到 OSS 存储底座。
  2. 缓存空间:一级缓存空间不超过运行时内存的 20%,二级缓存不超过 30%,三级缓存存储在磁盘中,空间约 500GB 到 1TB,最底层的 OSS 存储空间则几乎没有限制。
  3. 访问速度:一级缓存的访问速度等同于主存访问延迟;二级缓存的访问速度包含主存访问延迟和解压时间;三级缓存的访问速度包含磁盘访问延迟和解压时间;OSS 存储则包含网络访问延迟和解压时间。
  4. 压缩率:一级缓存存储的是解压后的数据,二级、三级缓存以及 OSS 存储则存储压缩数据,压缩比在 2 到 4 倍之间。
  5. 局部性原理:越频繁访问的数据,存储得越靠近执行器,以提高访问效率。

image.png


3.2 寻址方式

系统通过谓词条件和索引搜索,获取 {StripeId, ColumnId, RowGroupId} 地址,并通过 ORC 文件的 meta 和 footer 信息获取文件的具体物理位置 {filePath, offset, len},从而在各级缓存中进行查找和数据读取。


3.3 一级缓存的设计

一级缓存的设计旨在快速响应高频访问的数据请求。当查询到达时,基于谓词条件转换出一级缓存的 key,即 {StripeId, ColumnId, RowGroupId},并在一级缓存中查找对应的 Block 列表。如果命中,则直接使用这些 Block 进行计算。由于这些 Block 是从 RowGroup 中顺序解压出的,每个 RowGroup 包含 10,000 行数据,一个 Block 是其中的 1/10,即 1000 行。


为了控制一级缓存的空间占用,当缓存空间超过预设阈值(通常是运行时内存的 20%)时,触发 LFU(Least Frequently Used,最少使用频率)缓存淘汰机制,淘汰最少访问的缓存元素。


3.4 二级缓存的设计

当一级缓存未命中时,系统会通过 {StripeId, ColumnId, RowGroupId} 获取 {filePath, offset, len} 地址,作为二级缓存的 key。在二级缓存中搜索对应的压缩数据字节数组。如果命中,则需要对数据进行解压操作,并将解压后的数据写入一级缓存。同样,当二级缓存空间占用达到阈值时,采用 LFU 机制淘汰最少使用的数据。


3.5 三级缓存的设计

三级缓存位于磁盘层,主要存储从 OSS 存储底座获取的压缩文件片段。三级缓存以文件形式存储之前在二级缓存中的字节数组,当读取到相邻片段时,会进行合并操作,并异步执行旧文件的删除。由于文件的写入操作比较耗时,这些操作由异步线程完成。


3.6 OSS 存储底座

当所有级别的缓存都未命中时,系统会通过 {StripeId, ColumnId, RowGroupId} 映射到具体的文件路径,从 OSS 存储中获取完整的 ORC 文件,并下载需要的文件片段进行解压和读取。尽管 OSS 的访问存在网络延迟,但其近乎无限的存储空间为系统提供了强大的后备支持。


4.第一和第二级缓存的设计原理

4.1 列存数据层次结构

PolarDB-X 的列存存储格式主要由 ORC 文件构成。从 ORC 文件到执行器计算层所需的数据结构,可以划分为三个层次:


  1. 物理存储结构:纯字节序列构成的文件结构,由文件读接口 InputStream 封装。读取过程对于文件各个字节区域的含义是无感知的。
  2. 逻辑存储结构:关注 File、Stripe、Column、Stream、RowGroup、RL(轻量级)压缩和通用压缩七个层次结构。
  3. 计算结构:通过文件、列、Stripe、RowGroup 四级信息,得到具体类型的 Block,供执行器计算使用。


image.png



4.2 物理存储结构

物理存储结构是 ORC 文件的最底层表示,纯粹由字节序列构成,不携带任何语义信息。通过文件读接口(如 InputStream),系统可以顺序或随机地读取文件中的字节数据,而无需理解其具体含义。


4.3 逻辑存储结构

逻辑存储结构关注数据的组织和压缩方式,共包含以下几个层次:


File

由 file footer、postscript 和若干个 Stripe 构成。

  • Postscript:位于文件最末端,可以定位文件内部主要区域的字节位置。
  • File Footer:存储文件级别的 min-max 统计信息、压缩与加密算法信息、版本信息等。

image.png


Stripe

每 64MB 形成一个 Stripe,由 StripeFooter 和一组 Column 构成。

  • StripeFooter:
  • 存放所有 Column-Stream 两层结构的元数据信息。
  • 存放 Stripe 级别的 SMA 信息(min-max、count、sum)。
  • 存放 Stripe 级别的自定义索引。


Column

每个 Column 由若干个 Stream 构成,存储结构上是连续的。

  • Index Stream:存储该列的 row index,由 ColumnStatistics 和 positions 两部分组成。
  • ColumnStatistics:该列在每个 RowGroup 上的 min-max、count、sum 等 SMA 信息。
  • Positions:该列在每个 RowGroup 上的起始字节位置,压缩前后字节大小等信息。
  • Present Stream:存储该列的 null 值位图,数据经过轻量压缩和通用压缩。
  • Data Stream:存储该列的数据,数据经过轻量压缩和通用压缩。
  • Dictionary Stream:对于可以形成字典的列,额外产生一个 Stream 来存放 Dictionary 数据。

image.png

Stream

每个 Stream 代表一个连续的字节存储区域,包含不同类型的数据流信息。每个 Stream 由若干个 RowGroup 构成,每个 RowGroup 默认存储 10,000 行数据。


image.png

RowGroup

在每个 Stream 上,每 10,000 行数据形成一个 RowGroup。每个 RowGroup 经过轻量级压缩和通用压缩,存储在 RowIndex 中。RowIndex 描述了 RowGroup 的起始字节位置、压缩后大小、原始大小以及起始元素个数等信息。


通用压缩

ORC 默认的通用压缩方式是 Zlib,压缩率较高但解压速度较慢。PolarDB-X 列存采用了 LZ4 压缩,虽然压缩率一般,但解压速度更快,适用于高性能需求的场景。

image.png

RL(轻量级)压缩


RL(RunLength)压缩根据数据的 workload 特征,选择灵活的压缩方式来提高压缩效率。主要有四种方式:


  1. DIRECT:对于工作负载无明显特征的数据,直接存储原始数据。
  2. SHORT_REPEAT:当数据元素重复且长度符合一定范围时,采用该编码。
  3. PATCHED_BASE:当元素的分布范围较大且无法使用 DIRECT 编码时,采用 patch base 编码。
  4. DELTA:当元素符合特定规律(如等差数列)时,采用 delta 编码。


4.4 计算结构与 BlockCacheManager

通过文件、列、Stripe、RowGroup 四级信息,可以得到具体类型的 Block。例如,如果 RowGroup 步长为 10,000 行,且 chunk limit 为 1000,则 {file=xxx.orc, StripeId=0, ColumnId=1, rowGroupId={1,3}} 可以获取到 20 个 IntegerBlock 对象。


BlockCacheManager 负责将这些对象缓存起来,主要接口包括:


  • 根据 RowGroup 访问特性,缓存所属 RowGroup 的所有 Chunk。
  • 管理缓存的插入、查询和淘汰。


image.png


5.ORC读取链路概述

基于对ORC数据结构层次的深入分析与设计,新的ORC读取链路旨在实现以下目标:

  1. 输入目标:指定需要读取的列及其对应的行组信息。
  2. 输出目标:生成一组数据块(Block),并将这些数据块管理到缓存管理器中,以便后续高效访问。

整个读取过程主要由两个核心接口驱动:StripeLoader接口ColumnReader接口,它们各自承担不同的职责,确保数据的高效读取与解析。

image.png



5.1 IO过程的主入口

StripeLoader接口负责管理ORC文件中的流(Stream)信息,并协调IO操作的执行。其主要流程如下:


  1. 初始化流信息:首先,通过调用初始化方法,获取当前数据条带(stripe)中所有相关流的信息,并由流管理器(StreamManager)进行统一维护。
  2. 获取字节区域链表:在加载过程中,根据指定的列和相应的行组,定位每个行组在物理存储中的字节起始位置及其压缩后的大小。基于这些信息,为每个行组创建一个缓冲区节点(buffer chunk),并将相邻的行组通过链表形式组织起来,形成一个连贯的字节区域链表。
  3. 异步读取数据:通过异步线程,启动数据读取操作,并返回一个未来结果对象(CompletableFuture),包含各个流对应的输入流(InStream)。接下来,执行后续的合并与读取步骤。
  4. 合并与IO策略:利用数据读取接口(DataReader),对缓冲区链表进行合并处理,将相邻或距离较近的节点合并为一次IO操作,以减少IO次数,提高读取效率。
  5. 执行IO操作:数据读取接口通过文件系统或输入流接口,将合并后的IO结果写回各个链表节点中,完成实际的数据读取。
  6. 组装输入流对象:根据每个链表节点所属的不同流,划分并组装成多个输入流对象(InStream),以供后续解析使用。

整个链表结构涵盖了链表指针、字节区域信息及实际的物理字节数据。这些信息由StreamInformation管理,确保流与缓冲区节点之间的关系清晰、可控。


5.2 解析与生成Block

ColumnReader接口承担将StripeLoader获取的ORC原始字节数据,解析为执行器所需的Block对象的任务。其解析过程具有以下特点:

  1. 优化内存操作:尽量减少内存的拷贝与分配次数,并将数据写入过程优化为连续的内存拷贝,提高执行效率。
  2. 直接数据转换:建立ORC字节数据到执行器数据格式的直接转换路径,避免引入中间格式(如VectorizedBatch)带来的额外开销。
  3. 异步IO管理:管理异步IO操作的回调,根据IO结果和流信息,自动创建多个解析器,确保数据解析过程的高效与并行。
  4. 缓冲区回收:自动回收异步IO操作占用的缓冲区,避免内存泄漏,提升系统的资源利用率。

ColumnReader接口主要包含三个核心方法:

  • 初始化方法:用于初始化数据解析过程,可以通过传入StripeLoader加载返回的回调,或内部直接触发加载过程并同步等待结果。
  • 指针定位方法:将内部的读取指针移动到指定行组及元素的位置,使其具备随机访问的能力,便于高效定位和读取特定数据。
  • 数据读取方法:从当前指针位置开始,读取指定数量的数据元素,并将其写入到随机访问的Block中。这种方式避免了动态数组扩容和边界检查等额外开销,提升了数据写入的效率。


5.3 通用的抽象列读取器

AbstractLongColumnReader是一个抽象类,适用于处理所有基于整型或长整型表示的数据类型,如整数(int)、大整数(bigint)、小数(decimal64)、日期(date)、日期时间(datetime)等。其主要步骤如下:

  1. 解析器初始化:根据输入流和流信息,构建相应的解析器。例如,对于常见的bigint类型,分别基于数据流和存在流构建压缩和解压解析器。数据流存储压缩后的bigint值,存在流存储压缩后的空值信息。
  2. 多层指针管理:解析器采用三层指针管理机制,
  • 指针1:指向解压后的数据缓存当前位置,用于快速获取已解压的数值或布尔值。
  • 指针2:指向Lz4解压缓存的位置,用于管理解压后的中间数据。
  • 指针3:指向原始字节数据的链表位置,管理尚未解压的原始数据。
  1. 定位指定位置:通过定位方法,根据行组ID和元素位置,计算出具体的字节起始位置和压缩大小,并调整各层指针以正确定位到需要读取的数据位置。
  2. 数据拷贝与Block生成:通过读取方法,从当前指针位置开始,按需拷贝内存数据到随机访问的Block中。根据数据类型的不同,分配相应类型的Block对象,并在循环中解析存在流和数据流,将最终数据写入Block,完成Block的构造。

image.png

通过以上设计,新的ORC读取链路实现了对每一列和每个行组的精确控制,优化了数据读取和解析的各个环节。StripeLoader接口负责高效管理流信息和组织IO操作,ColumnReader接口则专注于高效解析原始字节数据,生成执行器所需的Block对象。结合AbstractLongColumnReader的抽象设计,整个读取流程不仅具备高效性和灵活性,还能够适应多种数据类型的处理需求。这种定制化的ORC读取链路设计,为大规模数据处理提供了强有力的支持,显著提升了数据读取的性能与可靠性。


6.第三级缓存的设计原理

6.1 第三级缓存系统的组成

第三级缓存系统的管理涵盖了多个关键部分,以确保高效的数据存取和系统性能的优化。首先,文件系统的输入输出(IO)流程是系统的基础,它包括了网络IO和本地持久化缓存两个方面。网络IO负责远程数据的传输,而本地持久化缓存则确保数据在本地存储的可靠性和快速访问。其次,文件元数据管理负责维护存储在对象存储服务(OSS)实例上的表文件和索引文件,确保数据结构的有序性和检索的高效性。第三,数据源管理则负责配置和加载不同引擎下的数据源,支持多样化的数据输入渠道。最后,文件访问的调度通过大规模并行处理(MPP)框架在执行前对文件访问进行合理的分配与调度,优化资源利用和访问效率。

image.png


6.2 Hadoop文件系统与缓存文件系统

Hadoop文件系统

在Hadoop生态系统中,Hadoop文件系统提供了一种统一的文件系统抽象,通过实现其核心抽象类,可以将各种不同的数据源进行封装。这种设计使得不同类型的数据源能够通过统一的接口进行访问,大大简化了文件操作的复杂性。Hadoop文件系统通过初始化连接、开放文件流以及创建文件流等操作,实现了对文件的读取和写入,从而支持了分布式数据存储和高效的数据处理。


缓存文件系统

为了进一步提升文件访问的效率,PolarDB-X设计并实现了一种缓存文件系统,该系统能够同时支持本地持久化缓存和远程OSS实例的文件访问。该缓存文件系统由两个主要组件构成:一个负责处理远程OSS实例上的文件访问,另一个则负责本地缓存的管理,包括数据的持久化和缓存文件的维护。通过这种双层缓存机制,系统能够在保证数据一致性的同时,大幅提高文件访问的速度和系统的整体性能。


6.3 读取流程

第三级缓存系统的读取流程经过精心设计,以确保数据的高效获取和缓存管理。首先,系统通过缓存文件系统获取目标文件的输入流,准备进行数据读取。接下来,系统根据需要读取文件的特定字节范围,这一过程能够灵活地访问文件的任意位置,满足不同的数据需求。读取到的数据随后由缓存管理器进行缓存处理,缓存管理器内部的异步刷盘线程会定期将从远程获取的数据持久化到本地磁盘,并维护一个范围映射,记录每个缓存块的远程路径、偏移量和长度信息。当缓存中存在相邻或重叠的数据块时,系统会触发文件合并操作,将多个小文件合并为一个连续的文件,以优化存储和访问效率。同时,后台的缓存统计线程和文件清理线程会定期评估缓存的大小,并清理过期的缓存,确保缓存系统的健康和高效运行。


6.4 缓存管理策略

线程池设计

缓存管理器内部设计了多个线程池,以协调不同的缓存管理任务。首先是异步刷盘线程池,负责将网络IO获取的数据定期持久化到本地磁盘中,并维护缓存的范围映射。该线程池在发现缓存位置相邻或重叠时,会自动触发文件合并操作。其次,缓存统计线程池定期统计已持久化的本地缓存文件的总大小,确保缓存总量不会超过预设的上限,从而防止资源过度消耗。最后,缓存文件清理线程池则根据最近最少使用(LRU)的策略,标记并清除那些不再需要的过期缓存文件,保持缓存系统的高效和整洁。


热缓存与普通缓存

缓存管理器内部将缓存划分为热缓存和普通缓存两种类型,以充分利用数据的访问局部性原则。热缓存用于存储最近三秒内访问过的数据,这些数据尚未进行持久化,完全保存在内存中,因而具有更快的访问速度,适用于需要频繁访问的数据场景。普通缓存则负责存储已经持久化到本地磁盘的缓存数据,适用于长期存储和较少访问的数据。通过这种分层缓存策略,系统能够在保证数据访问速度的同时,合理利用存储资源,显著提升整体查询性能和系统响应速度。


7.执行缓存的反压机制

7.1 反压机制的背景

在基于时间片机制和 Pipeline 执行框架的数据库系统中,任务的生产者和消费者速度差异可能导致内存不足或系统过载等问题。为了解决这一问题,PolarDB-X 引入了一套反压机制,用于控制生产者和消费者的工作流程,避免频繁的反压和调度开销导致系统性能下降。


7.2 Pipeline 执行框架

Pipeline 执行框架(Execution Pipeline)是指在数据库查询处理中,将执行计划的组件或操作以流水线的形式组织和执行的一种策略。Pipeline 执行允许在一个阶段处理当前数据批次时,下一个阶段可以同时处理上一个阶段的输出结果,从而提高查询的整体性能和并行度。


主要概念包括:

  • Driver:Pipeline 执行框架中的执行和调度最小单元,负责处理一组算子的执行任务。
  • Chunk:Pipeline 执行中的数据单元,Driver 产生和消费的数据块。
  • 生产者与消费者:生产者 Driver 负责产生 Chunk,消费者 Driver 负责消费 Chunk。

image.png


7.3 反压机制的设计

反压机制主要用于解决生产者速度远大于消费者速度的问题,防止内存水位迅速上升导致系统崩溃。具体设计如下:


生产者-消费者-缓冲区模型

  • 生产者 Pipeline:并行度为 m,每个并行度上形成一个生产者 Driver。
  • 消费者 Pipeline:并行度为 n,每个并行度上形成一个消费者 Driver,拥有一个 LocalBuffer 用于接收生产者的 Chunk。
  • LocalBuffer:以无界非阻塞队列的形式存在,通过链表结构管理 Chunk 的读写。


在不考虑内存限制的情况下,生产者和消费者可以自由地对 LocalBuffer 进行读写。然而,当生产者速度远超消费者速度时,内存水位会迅速上升,造成系统不稳定。因此,引入反压机制进行控制。

image.png

内存计数器与信号机制

memory_ledger:记录所有 LocalBuffer 中 Chunk 的总字节数,作为内存水位的指标。

readable 与 writable 信号:基于 SettableFuture 实现,用于控制生产者和消费者的执行状态。

  • readable:表示缓冲区可读的信号。
  • writable:表示缓冲区可写的信号。

通过这两个信号,系统可以协调生产者和消费者的执行,避免内存过载。


反压流程

  1. 设置内存阈值 Smax:memory_ledger 中记录的内存水位一旦达到 Smax,触发反压。


  1. 触发反压:生产者在写入Chunk到LocalBuffer的过程中,如果 memory_ledger 的内存水位达到Smax,此时,触发反压。上游的所有生产者Driver立即让出时间片,进入Driver阻塞队列;


  1. 生产者处理:此时,产生一个 writable 信号,保存在memory_ledger中。writable信号代表着 生产者Driver 对于“缓冲区可写”对这一事件的等待。writable 信号注册了一个回调,该回调的执行内容是,将生产者Driver从阻塞队列中取出,放入就绪队列。


  1. 消费者处理:消费者不断地从LocalBuffer内部的队列中取出Chunk,与此同时也调用memory_ledger.subBytes 减少内存水位值。当大部分消费者Driver的LocalBuffer队列为空,且memory_ledger内存水位值接近于0时。


i.产生一个 readable信号存入memory_ledger中,代表着所有消费者Driver等待“缓冲区可读”这一事件。readable信号注册了一个回调,该回调的内容是,一旦事件完成,


1.如果消费者Driver仍在时间片内等待,则结束等待,让消费者Driver继续从缓冲区中读取Chunk;


2.如果消费者Driver已经让出时间片,处于阻塞队列中,则将消费者Driver转移到就绪队列。

以上两步都是回调中定义的内容,在第iii步中触发。


ii.消费者Driver从memory_ledger中获取writable信号,调用 writable.set,代表“缓冲区可写”这一事件已经完成,并触发执行注册回调中的执行体,将所有生产者Driver从阻塞队列中取出,放入到优先级最高的就绪队列中。


iii.消费者Driver调用 readable.get ,线程开始原地等待,直到两种情况发生:1)timeout时间后超时,消费者Driver让出时间片,进入阻塞队列;2)未到达超时时间,“缓冲区可读”这一事件已完成,则结束等待,继续从缓冲区读取Chunk


iii.一段时间后(通常是几十纳秒)生产者Driver从就绪队列中,被线程组取出进行调度执行,开始生产Chunk并写入到缓冲区LocalBuffer中。消费者Driver也会结束等待,继续从缓冲区中读出Chunk,消费和销毁Chunk内存。


这种机制确保了生产者和消费者的速度匹配,避免内存过载,同时减少了频繁的上下文切换,提升了系统整体性能。

image.png


7.4 控制反压频率

为了避免过多的反压和调度开销,系统采用动态调整缓冲区内存上限的方法,控制反压的频率。具体步骤如下:


  1. 设定反压次数上限 R:例如系统默认 R=1000。
  2. 设置初始 Smax 值:例如系统默认为 8MB。
  3. 时间片内动态调整:基于生产者和消费者的读写速度,动态调整 Smax 的数值,确保每个时间片内的反压次数不超过设定的上限 R。
  4. 防止内存过大:系统根据数据库实例的运行时内存大小,设定 Smax 的上限值,避免因用户设置过小 R 值导致 Smax 过大,造成内存溢出。

image.png


7.5 反压过程的时间序列

反压过程可以抽象为以下时间序列:

  1. 正常运行:生产者以速度 v1 写入数据,消费者以速度 v2 读出数据,缓冲区内存水位 S 随时间 t 变化。
  2. 触发反压:当 S 达到 Smax,生产者 Driver 进入阻塞状态,消费者 Driver 继续消费数据。
  3. 解除反压:消费者 Driver 消费完 Chunk,减少 S,触发 readable 信号,唤醒生产者 Driver,恢复正常运行。
  4. 循环往复:系统在时间片内不断调整,确保反压频率在可控范围内,提高系统稳定性和查询性能。

image.png


7.6 反压机制的优势

  • 内存稳定性:通过内存计数器和信号机制,确保内存水位在可控范围内,防止内存溢出。
  • 性能提升:减少频繁的上下文切换,降低调度开销,提高系统整体性能。
  • 动态调整:根据实际运行情况,动态调整 Smax 值,适应不同的负载和查询需求。


8.缓存预热


8.1 介绍

PolarDB-X 列存查询引擎采用了存算分离架构,计算节点在执行分析型查询前,会首先检查本地缓存是否命中所需的列存索引数据。如未命中,则需从远端 OSS 存储服务拉取数据,这会导致查询延迟增加。为了解决这一问题,PolarDB-X 提供了缓存预热(Warmup)功能,允许提前将数据载入本地缓存,从而在实际查询时避免从远端拉取数据,提升查询性能和稳定性。


8.2 缓存预热与缓存管理的区别

  • 缓存管理:是一个被动填充的过程,只有在上一级缓存未命中时,才从下一层缓存或存储服务中拉取和解析数据,并填充到上一级缓存中。
  • 缓存预热(Warmup):是一个主动填充缓存的过程,提前将指定的数据放入缓存中,属于对缓存管理能力的增强,适用于预先知晓需要查询的数据。


8.3 适用场景

  1. 稳定业务查询访问:适用于查询访问模式固定、可预知的业务场景,避免即时查询时的预热开销。
  2. 本地磁盘容量足够:适用于本地磁盘存储容量大于待预热的数据量的情况,确保预热数据能够全部缓存到本地,避免频繁的远端拉取。


8.4 Warmup 语法

PolarDB-X 提供了 WARMUP 语法,用于实现缓存预热。该语法仅在列存只读实例上生效。

WARMUP [cron_expression]
SELECT
    select_expr [, select_expr] ...
    [FROM table_references]
    [WHERE where_condition]
    [GROUP BY {col_name | expr | position}
      [ASC | DESC], ... ]
    [HAVING where_condition]
    [ORDER BY {col_name | expr | position}
      [ASC | DESC], ...]
    [LIMIT {[offset,] row_count | row_count OFFSET offset}]

cron_expression:
    ('cron_string') | <empty>

select_expr:
    column_name
  | aggregate_function(column_name)
  | column_name AS alias_name
  | expression

参数说明

  • cron_expression:用于定期执行预热任务的时间表达式,采用类似 Unix cron 表达式的格式。
  • select_expr:指定需要预热的列或表达式。


8.5 缓存预热的最佳实践

  1. 定期预热常用列
  • 场景:业务写入流量高,查询延迟敏感。
  • 示例:


WARMUP('* /2 * * * *') 
SELECT col1, col2, col3... FROM your_table;

作用:每 2 分钟对目标表的常用列进行周期性预热。

  1. 夜间批量预热
  • 场景:业务需要在夜间对大量数据进行分析查询。
  • 示例:
WARMUP('30 21 * * *') 
SELECT col1, col2, col3 ...
FROM table1 
LEFT JOIN table2 ON table1.id1 = table2.id2
LEFT JOIN table3 ON table1.id1 = table3.id3
WHERE table1.col_date > '2024-01-01';

作用:每天晚上 21:30 对一天的业务数据进行预热,确保夜间 22:00 的查询能快速响应。

  1. 性能测试前预热
  • 场景:进行专业性能测试,如 TPC-H 或 ClickBench。
  • 示例:


WARMUP SELECT * FROM lineitem;
WARMUP SELECT * FROM orders;
WARMUP SELECT * FROM customer;
WARMUP SELECT * FROM part;
WARMUP SELECT * FROM partsupp;
WARMUP SELECT * FROM supplier;
WARMUP SELECT * FROM region;
WARMUP SELECT * FROM nation;

作用:对所有表进行一次性预热,确保性能测试过程中缓存命中率高,测试结果更为精准。

  1. 定时批量预热
  • 场景:每天凌晨进行运维任务前预热数据。
  • 示例:


WARMUP('*/5 1-5 * * *') 
SELECT * FROM lineitem;


作用:每天 01:00 至 05:00 的运维窗口期间,每 5 分钟预热一次指定表的数据,确保运维期间的查询性能稳定。


8.6 总结

缓存预热功能通过主动填充缓存,消除了查询时从远端拉取数据的开销,大幅提升了查询性能和稳定性。通过合理配置预热策略,结合业务访问模式和查询需求,PolarDB-X 能够为用户提供更加高效和可靠的数据查询体验。


9.结语

在大数据时代,如何高效地存储和查询海量数据成为了关键性的问题。PolarDB-X 列存查询引擎通过分层缓存管理和精细化的反压机制,有效地优化了 ORC 列存查询的性能,解决了在大规模数据集下查询速度瓶颈的问题。同时,灵活的缓存预热策略和优化的 ORC 读链路设计,使得 PolarDB-X 能够在复杂多变的业务场景中,提供高效、稳定的查询服务。

  1. 高效的多级缓存管理:通过分层缓存机制,实现了高频数据的快速访问和低频数据的有效管理,显著提升了查询性能。
  2. 精细化的反压机制:通过内存计数器和信号机制,精准控制生产者和消费者的执行流程,防止内存过载,确保系统稳定性。
  3. 灵活的缓存预热策略:支持多种预热场景和策略,满足不同业务需求,进一步提升查询响应速度。
  4. 优化的 ORC 读链路设计:绕过官方 Reader,采用自主优化的读链路,实现了高效的数据读取和解析,降低了查询延迟。
  5. 适应性强的存算分离架构:计算节点与存储节点分离,实现了资源的高效利用和系统的可扩展性,适应不同规模和复杂度的查询需求。

PolarDB-X 通过持续的技术创新和优化,致力于为用户提供更高效、更可靠的数据查询解决方案,在大数据时代,助力企业实现数据价值的最大化。








来源  |  阿里云开发者公众号

作者  |  君启







作者介绍
目录