虽然SQL是为关系模型而发明的,但它对于许多形式来说都非常有效数据,包括类型异构、嵌套和无模式的文档数据。 Couchbase Capella 拥有操作引擎和分析引擎。操作和分析引擎都支持用于数据建模的 JSON 和用于查询的 SQL++。由于操作和分析用例具有不同的工作负载要求,因此 Couchbase 的两个引擎具有不同的功能,这些功能专为满足每个工作负载的要求而定制。本文重点介绍了 Couchbase 的新分析服务 Capella Columnar 服务的一些新特性和功能。

为了改进实时数据处理,Couchbase 推出了 Capella Columnar 服务。这项新服务有许多差异化技术,包括无模式数据引擎的按列存储及其处理。在本文中,我们将概述为 JSON 实现按列存储的挑战以及 柱状服务来应对这些挑战。

数据的行式和列式存储

按行存储

使用按行存储,表的每一行都存储为表数据页内的连续单元。单行的所有字段连续存储在一起,后面是下一行的字段,依此类推。每行可以有 1 到 N 列,要访问任何一列,整行都会被放入内存并进行处理。这对于事务操作非常有效,因为通常需要所有或大多数字段。

图 1:逻辑表到物理行的表示

按列存储

按列存储,另一方面,存储数据的每一列(也称为 JSON 文档中的字段)分别。单列的所有值都是连续存储的。这种格式对于涉及查询多行上的几列的分析和大量读取操作特别有效,因为它可以更快地读取相关数据、更好的压缩以及更有效地使用磁盘 I/O 请求等资源。图 2:逻辑表到物理按列表示

图 2:逻辑表到物理按列表示

文档方式(类似于行方式)存储

集合中的每个文档都存储为连续的单元。这意味着单个文档的所有字段子字段存储在一起,后面是下一个文档的字段,依此类推。这种方法对于事务操作非常有效,因为它允许快速检索完整的文档。但是,对于仅访问多个文档中的少数字段的分析查询来说,效率可能较低,因为还会读取每个文档中不必要的数据。

使用 JSON(文档)数据模型时,与关系表中的单行相比,每个文档中通常会包含更多字段。但是,对于分析工作负载,每个查询仍然从每个文档中读取一些字段。因此,按列存储对文档存储的潜在好处甚至超过了关系数据库。但是,实施起来也不容易!在平面关系表中,模式是明确定义的,并且列的类型是先验已知的,用于存储和查询此类表的数据的技术是很好理解的。然而,为 JSON 文档存储实现按列存储引擎很困难,因为许多使 JSON 成为现代应用程序理想选择的因素却让按列存储变得困难!以下是主要原因:

  • 架构灵活性
  • 嵌套和复杂的结构
  • 处理异构类型
  • 动态架构更改
  • 压缩和编码挑战

让我们简要研究一下这些挑战:

  1. 架构灵活性:JSON 是无架构的,这意味着每个文档都是自描述的,并且可以具有包含不同字段的不同结构。这种灵活性与传统关系数据库僵化的预定义模式形成鲜明对比。在客户文档的 JSON 模型中,在不同的子结构中,一条记录可能具有地址字段,而另一条记录可能没有,如下例所示。在列式数据库中,这种不可预测性使得定义一致的列结构变得具有挑战性。
JSON

 

{
  “客户 ID”:101,
  “名称”:“爱丽丝·史密斯”,
  “电子邮件”:“alice.smith@example.com”,
  “地址”: {
    “街道”:“苹果街 123 号”,
    “城市”:“仙境”,
    “邮编”:“12345”
  }
}

{
  “客户 ID”:102,
  “姓名”:“鲍勃·琼斯”,
  “电子邮件”:“bob.jones@example.com”
  // 注意:没有地址字段
}

{
  “客户 ID”:103,
  “姓名”:“詹姆斯·布朗”,
  “电子邮件”:“james.brown@example.com”,
  “地址”: {
    "street": "Kenmare House, The Fossa Way",
    // 县首次出现
    "county": "凯里郡",
    // 邮政编码而不是 zip
    “邮政编码”:“V93 A0XH”,
    // 国家首次出现
    “国家”:“爱尔兰”
  }
}
清单 1:客户数据

  • 嵌套和复杂结构:JSON 支持任意嵌套结构,例如对象内数组内的对象。列式存储通常最适合平面表格数据,并且将这些复杂的分层结构映射到列可能非常复杂。如下例所示,单个 JSON orders 文档可能包含一个 items 列表,并且每个项目可能有自己的属性。简单地将其展平为列将具有挑战性,并且可能会导致大量稀疏列。在下面的示例中,带有 item_id = 1001 的订购商品是唯一具有适用促销代码的商品,因此在所有其他商品中都缺少该代码(类似于关系数据库中的 NULL)。
    JSON

     

    {
      “订单id”:5002,
      “客户 ID”:101,
      “日期”:“2023-11-24”,
      “项目”: [
        {
          “项目 ID”:1001,
          “数量”:1,
          “购买价格”:1200.00,
          // 唯一具有适用促销代码的商品
          “促销”:“黑色星期五”
        },
        {
          “项目 ID”:1002,
          “数量”:1,
          “购买价格”:30.00
        }
      ]
    }
    
    {
      “订单id”:5003,
      “客户 ID”:103,
      “日期”:“2023-02-15”,
      “项目”: [
        {
          “项目 ID”:1003,
          “数量”:1,
          “购买价格”:80.00
        },
        {
          “项目 ID”:1004,
          “数量”:2,
          “购买价格”:300.00
        },
        {
          “项目 ID”:1005,
          “数量”:1,
          “购买价格”:99.00
        }
      ]
    }
    清单 2:订单数据

  • 处理异构类型:JSON 值可以是不同的类型(例如字符串、数字、布尔值,甚至是对象和数组等复杂类型)。在不同文档的单个字段中,数据类型可能会有所不同,而列式关系数据库甚至列式文件格式通常要求列中的所有数据都具有相同的类型。如果“价格”字段有时存储为字符串(“10.99”),有时存储为数字(10.99),甚至存储为数组([10.99, 11.99])(假设存储所有季节性价格),这会使过程变得复杂从以列式格式存储此类数据的源中存储和检索数据,其中数据类型预计是统一的。
  • JSON

     

    {
      “项目id”:3003,
      「价格」:10.99
    }
    
    {
      “项目id”:3004,
      “价格”:“10.99”
    }
    
    {
      “项目id”:3005,
      “价格”:[10.99,11.99]
    }
    
    {
      “项目id”:3006,
      // 价格缺失(可能表示价格不可用或未知)
    }
    清单 3:项目数据

  • 动态架构更改:由于 JSON 是无架构的,因此数据结构可能会随着时间的推移而发生变化。这种灵活性是 JSON 对于现代应用程序的吸引力之一。对于架构不经常更改的列式关系数据库来说,这可能会出现问题。然而,在无模式文档存储中,添加新字段甚至更改现有字段都很简单。例如,应用程序可能开始捕获其他用户数据,例如客户地址,或将价格从一种数据类型更改为另一种数据类型。在关系列式数据库中,这需要添加一个新列,这可能是一项相对繁重的操作(尤其是在数据仓库中),或者在更改字段的数据类型的情况下甚至是不可能的。
  • <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    添加新字段:客户 字段的数据类型更改:Items


    <正文>

    Upsert

    之前

    JSON

     

    {
      “客户 ID”:102,
      “姓名”:“鲍勃·琼斯”,
      “电子邮件”:“bob.jones@example.com”
      // 注意:没有地址字段
    }

    JSON

     

    {
      “项目 ID”:4004,
      “价格”:99.00
    }
    更新插入后

    JSON

     

    {
      “客户 ID”:102,
      “姓名”:“鲍勃·琼斯”,
      “电子邮件”:“bob.jones@example.com”,
      // 添加地址
      “地址”: {
        “城市”:“哥谭”,
        "country": "神秘之地",
        “邮编”:54321
      }
    }

    JSON

     

    {
      “项目 ID”:4004,
      // 价格更改为
      // 数组(季节性定价)
      “价格”:[99.99,109.99]
    }

  • 压缩和编码挑战:关系数据库中列式存储的优势之一是能够使用非常高效的编码方案,因为相同类型和相同域的值是连续存储的,并且架构与数据分开存储。然而,JSON 的多样性和复杂性使得有效应用它们变得困难。对于纯数字列,简单的编码和压缩技术可能非常有效。但是,如果列以不可预测的方式混合字符串、数字、空值和缺失(甚至数组和对象),那么找到有效的编码策略就会变得更加复杂甚至不可能。
  • Capella Columnar 中的列式存储引擎

    对于按列存储,Capella Columnar 使用本技术论文中描述的技术和方法的扩展版本:基于无模式 LSM 的文档存储的列格式。您可以在 Wail Alkowaileet 的博士论文走向分析优化的文档存储中找到更多详细信息。这些技术最初是为 Apache AsterixDB 实现的,现在是 Couchbase Capella Columnar 的一部分。

    存储概述

    Capella Columnar 中的列式技术解决了文档存储数据库在分析大量半结构化数据时的局限性,因为它们无法有效地使用列主布局,当然,这对于分析工作负载来说更有效。目标是为 JSON 提供高效的列存储,同时保留 JSON 数据模型的所有优点并改进查询处理。在本文的这一部分中,我们将描述这些领域的增强和改进,以克服前面描述的挑战。请参阅上述论文论文了解更多详细信息。

    本文的其余部分介绍了新的 Capella 柱状服务产品的以下方面:

    • JSON 数据提取和列化管道
    • 柱状 JSON 表示
    • 列式存储物理布局
    • 柱状集合的查询处理

    JSON 数据摄取和列化管道

    Capella Columnar 可以从各种数据源(例如 Capella 数据服务)或其他外部源(例如其他文档存储和关系数据库)获取数据。 Capella Columnar 的存储引擎是一种基于日志结构合并树 (LSM) 的引擎,其工作原理如图 3 所示。当 JSON 文档从受支持的源之一到达 Capella Columnar 时,这些文档将被插入到内存组件中 (在文献中也称为内存表),它是完全存储在内存中的 B+ 树。一旦内存组件满了,插入的文档就会被写入磁盘(通过刷新操作)到磁盘组件(在文献中也称为 SSTable),这是一个 B+-树片段完全存储在磁盘上。然后,释放的内存组件可以重新用于一批新文档。

    LSM 的批处理特性提供了一个机会来“重新思考”内存组件中的文档应如何写入并存储在磁盘上。在 Capella Columnar 中,我们借此机会 (1) 推断数据的架构,以及 (2) 从摄取的 JSON 文档中提取值并将其存储到列中 – 两者均由Columnar Transformer 执行,如图所示如图 3 所示。推断的模式用于识别这些文档中出现的列 – 稍后将讨论更多内容。请注意,Capella Columnar 同时执行 (1) 和 (2)(即,在每个刷新的 JSON 文档上一次传递)。在刷新操作结束时,推断的架构将持久保存到新创建的磁盘 LSM 组件中,以便在需要时进行检索。

    图 3:数据摄取工作流程

    Capella Columnar 中的柱状 JSON 表示

    现在我们知道我们有机会 (1) 推断架构并 (2) 对摄取的 JSON 文档进行列化,主要问题是:我们如何将这些摄取的 JSON 文档表示为列并允许存储它们、更新和检索——考虑到 JSON 数据模型带来的五个挑战?

    Capella Columnar 使用本文中描述的方法表示摄取的 JSON 文档,该方法扩展了Dremel 格式(由 Google 在论文 Dremel:Web 的交互式分析-扩展数据集)来应对这五个挑战。值得注意的是,Apache Parquet 是 schema-ful Dremel 格式的开源实现,因此,如果没有我们的扩展,它无法在文档数据库中按原样使用 Apache Parquet。接下来,我们将描述 Capella Columnar 如何使用我们的扩展 Dremel 格式以柱状布局表示摄取的 JSON 文档。

    让我们首先通过一个简单的示例来说明如何表示清单 1 中的第一个 Customers JSON 文档,如下面的图 4 所示。

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    文档
    推断模式


    <正文>

    JSON

     

    {
      “客户 ID”:101,
      “名称”:“爱丽丝·史密斯”,
      “电子邮件”:“alice.smith@example.com”,
      “地址”: {
        “街道”:“苹果街 123 号”,
        “城市”:“仙境”,
        “邮编”:“12345”
      }
    }
    数据列

    图 4:将第一个 Customers JSON 文档表示为列

    首先,我们看到架构是一个树结构,描述了 Customers 文档中的内容。从树的根(JSON 对象或文档)开始,我们看到它有四个子节点。根的子级代表根的字段,即 customer_idnameemailaddress。请注意,地址也是嵌套在主文档中的一个对象(或子文档),并且对象地址有三个子对象:street >、城市邮政编码。我们可以看到标量值是模式树结构的叶子——无论是根文档还是地址的子文档。每个标量值都分配有我们所说的“列索引”。例如,叶 0:int 告诉我们,字段 customer_id(集合的主键 (PK))是一个整数,并被分配列索引 0。同样,4:string 告诉我们 address 对象中的 city 值的类型string 并被分配列索引 4。从模式中,我们可以推断出我们有六列(即六个叶子,每个叶子被分配一个范围 [0, 5] 内的列索引)。

    因此,模式为我们提供了文档结构和值类型的描述,但不是值本身。在 Capella Columnar 中,文档的值作为列与模式分开存储。图 4 显示了“数据列”(准确地说是六列),每列表示为两个向量(在图中描绘为具有两列的表格,以便读者更容易理解),称为“D”和“V” ”。每列中的“D”向量就是我们所说的(如 Dremel)是D定义级向量,“V”是V价值向量。从图中可以推断,Value 的向量存储 JSON 文档中的实际值。另一方面,定义级别的向量用作“元值”来确定特定值是否存在或不存在(即 NULL 或缺失)。我们以第 1 列为例,它对应于示例中的 name 字段。我们看到值“Alice Smith”的定义级别等于 1,这告诉我们第 1 列的该特定值存在,并且其关联值为“Alice Smith”。现在,我们来看第 4 列,它对应于 address 子文档中的 city。它的定义级别等于2,值为“Wonderland”。那么,为什么子文档addressname定义级别为1,而city定义级别为2呢?定义级别(顾名思义)告诉我们在给定该特定值的推断模式定义的情况下,值出现在哪个(嵌套)级别。因此,名称是根文档的子文档。因此,它是从根开始的一级,而城市是子文档地址的子级,因此,它是从根开始的两级(根→名称与根→地址→城市)。

    为了更好地理解缺失值的处理,我们现在添加以下文档:

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    文档
    数据列


    <正文>

    JSON

     

    {
      “客户 ID”:201,
      “名称”:“蝙蝠侠”,
      “地址”: {
        “城市”:“哥谭”
      }
    }

    图 5:处理缺失值

    架构是相同的,因为上面文档(图 5)中出现的字段是图 4 中所示第一个文档字段的子集;因此,推断的模式不需要进行任何更改。不过,让我们看一下添加上述文档后的数据列。我们看到第二个文档中也出现了第 1 列(名称),其值为“Batman”。但是,第二个文档中第 2 列的定义级别等于 0,这告诉我们该客户缺少 电子邮件 字段。对于同一客户,我们发现 streetzip 也都丢失了,因此第 3 列和第 5 列的定义级别都等于 1。但是,第 4 列(城市)存在(定义级别 = 2)且值为“Gotham”。在第 3 列和第 5 列中,存在父子文档(即 address),但不存在 streetzip 值。请注意,无需在第 3 列和第 5 列中使定义级别都等于 1 即可推断出地址未丢失。由于第 4 列(城市)存在,这意味着它的父项(地址)也存在。也就是说,第3列和第5列的定义级别都可以为0,我们仍然可以将文档重新组装为其原始形式,而不会造成任何损失。因此,如果街道、城市和邮政编码的定义级别等于 0,我们可以说地址本身丢失。

    如上所述,定义级别确定值是否存在。但是,它对于主键 (PK) 列有不同的用途。 PK 中的定义级别将实际的 JSON 文档与反物质(或逻辑删除)条目区分开来。由于 PK 字段不能缺失,因此 PK 值的定义级别用于区分反物质条目(在任何非键列中都没有值)与实际 JSON 文档(非反物质),其中可以有任意数量的非键列中的值。我们在这里省略了处理反物质条目的技术细节(详细信息参见论文)。然而,值得注意的是,处理反物质条目的能力允许 Capella Columnar 对使用此柱状表示存储的集合执行删除和更新插入。

    架构灵活性和动态架构更改

    在前面的示例中,我们表明任何文档中的字段都可能丢失(即它们是可选的)——除了主键。此外,在前面的示例中,第一个文档的架构是第二个文档架构的超集。因此,当我们添加第二个文档时,架构不需要更改。但是,为了了解模式如何演变,让我们翻转处理和添加两个文档的顺序,以便我们可以观察在柱状表示中推断新列的情况。在下面的图 6 中,我们看到第一个推断的架构仅包含三列:customer_idnamecity(在子目录中)文档地址),我们分别看到它们分配的列索引为0、1和2。添加第二个文档后,会添加三个附加列,即根文档中的 email 以及 address< 中的 streetzip /strong> 子文档。请注意,新添加的列的列索引分别为 emailstreetzip 3、4 和 5。定义级别与前面的示例完全相同。这两个示例(即图 6 与图 5 中的示例)之间的唯一变化是列的索引,但其余部分相同。

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    添加带有 customer_id: 201

    的文档后

    添加带有 customer_id: 101

    的文档后


    <正文>

    图 6:处理架构更改

    这里值得注意一个技术细节。我们看到,当我们添加属于嵌套字段的新列(例如,第 5 列,对应于 zip)时,第一个定义级别为 1,对应于缺失的 第一个文档中的 zip 值。但是我们如何获得此信息(即,当我们添加第二个文档时,定义级别为 1 而不是 0)?父文档可以维护子文档地址存在或缺失的所有先前文档的整数列表(即,地址的定义级别,在所有先前文档中可以是 0 或 1)。添加新列时,我们首先将父子文档地址的定义级别设置为新创建的列。在我们的示例中,地址出现在 customer_id = 201 的文档中。因此,地址的定义级别为 1。创建第 5 列时,我们添加了定义级别 1 首先表示地址存在于上一个文档中,但缺少 zip 值。由于上述原因,在每个嵌套节点中维护此列表是可选的(我们可以简单地为每个新创建的列添加 0)。但是,使用适当的数据结构在内存中维护此列表的成本相对较低。在 Capella Columnar 中,我们实现了一个简单的数据结构,该结构利用了定义级别的修复性质 – 我们的实验表明它的内存占用可以忽略不计。此外,这个整数列表受到每个物理页面上文档数量的限制,最多限制为 15,000 个(有关此限制的更多详细信息,请参阅论文)。维护父节点的实际定义级别简化了 JSON 文档组装算法(因此,文档组装代码的可维护性更好、更容易)。因此,我们选择在 Capella Columnar 中进行此操作。

    处理 JSON 数组

    在前面的示例中,我们处理了 JSON 对象(文档和子文档)。接下来,我们重点讨论 JSON 数组的处理。让我们从图 7 中所示的简单示例开始。在这个示例中,我们有三个文档,从架构中我们可以看到所有文档都有字段 id,它是主键。前两个文档有两个附加字段,ab,其中 a 是整数数组,b是整数数组的数组。在上一个文档中,ab 均缺失。

    id开始,我们可以看到它的值存储在第0列中。在本例中,我们对定义级别进行了颜色编码,以便我们可以直观地将列中的值与其文档,其中 greens 属于第一个文档,oranges 属于第二个文档,并且sky-blues 用于第三个文档。因此,第 0 列中的 id 1、2 和 3 分别属于第一个、第二个和第三个文档。

    接下来,第 1 列存储所有三个文档的数组 a 的整数值。现在的问题是我们如何确定该列中的哪个值属于哪个文档。使用颜色编码,您可以观察到前四个值属于第一个文档 – 但机器如何确定这一点?我们可以看到第一个文档中的数组 a 具有三个整数 [1, 2, 3],每个整数的定义级别等于 2(表示值存在 – 正如我们之前看到的),后面跟着定义级别 0在前面的示例中,我们使用定义级别来确定值是否存在。在数组中,定义级别还有一个额外的作用,即充当数组分隔符;因此,在前三个定义级别(2、2和2)之后,最后一个定义级别(0)是分隔符,它告诉我们已经到达数组末尾,因此以下值属于下一个文档。请注意,我们将 ‘]’ 放入 Value 向量中;然而,这仅用于说明目的——数组分隔符没有任何值。与第一个文档中一样,以下橙色定义级别(2、2 和 2)表示存在第二个文档的数组 a 的值,这些值是 [4, 5, 6]。同样,以下定义级别 (0) 表示第二个文档的数组末尾。在第二个文档的分隔符之后,我们看到天蓝色的定义级别 (0),它对应于第三个文档,其中缺少数组 a。因此,(0) 的定义级别表明第三个文档中缺少数组 a – 您可能会问,为什么它不是分隔符?如果观察到数组元素是先验存在的,则定义级​​别可以解释为分隔符;否则,则表明存在缺失值。换句话说,分隔符前面必须有一个具有更高定义级别的值;否则,观察到的定义级别表示缺失值,而不是数组分隔符。

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    文件
    推断模式
    数据列


    <正文>

    JSON

     

    {
      “id”:1,
      “一”:[1,2,3],
      "b": [[1, 2], [3]]
    }
    
    {
      “id”:2,
      “一”:[4,5,6],
      “b”: [[4], [5, 6]]
    }
    
    {
      “id”:3
    }

    图 7:表示 JSON 数组

    现在,让我们看看如何表示数组的数组(例如上面图 7 中的 b)。只需查找第 2 列中的分隔符(内部标记为 ‘]’ 和外部标记为 ‘]]’,我们可以看到有两个分隔符,其中两个分隔符不同的定义级别:1和0。在第2列中,我们需要两个分隔符来区分内部和外部分隔符。例如,第一个文档中的值 [1, 2] 之后放置一个定义级别为 1 的分隔符,表示内部数组的末尾,后面紧跟值 [3],表示第二个文档的第一个元素内部数组。在值 [3] 之后,我们看到定义级别为 1 的两个连续分隔符,然后是 0。第一个定义级别为 1 的分隔符告诉我们内部数组的结尾,最后一个定义级别为 0 的分隔符告诉我们我们是第一个文档中数组 b 的末尾 – 因此,以下值属于第二个文档。类似地,对于第二个文档,我们有值 [4],后跟定义级别为 1 的分隔符 – 指示第一个内部数组只有一个元素 [4]。然后,我们得到值 [5, 6],后跟定义级别为 1 和 0 的分隔符——分别表示第二个内部数组和外部数组的结尾。最后,第三个文档中的定义级别 0 表示数组 b 丢失。

    这就是表示一个数组和一个标量值数组的数组!接下来,我们解释对象数组的表示。让我们从下面图 8 中所示的示例开始,使用清单 2 中的第一个 Orders 文档。该示例仅显示两列(共六列)并省略其余部分(省略的列可以用作读者的练习)。我们主要关注第 3 列和第 6 列(即架构中的 item_idpromo)。两列都属于同一数组items。从“列的角度”来看,它“将自身视为”标量值的数组。换句话说,我们可以将 item_id 的值视为整数数组 [1001, 1002],将 promo 的值视为字符串数组 [“BLACK-FRIDAY ,”缺失]。因此,表示一个对象数组可以看作表示两个具有相同数量元素的不同标量数组。因此,它们的分隔符将位于完全相同的位置。在下面的示例中,第 3 列和第 6 列的数组分隔符恰好出现在第 3rd 位置。另一个区别(与实际的标量数组相比)是值存在于哪个定义级别。由于两列都是同一对象数组的后代,因此定义级别等于 3 表示当前值 – 因为 item_idpromo 的值都出现在从根部算起的第三层。例如,根 (0) →\rightarrow< span class="mrel">→ 项目 (1) →\rightarrow< /span> 对象 (2) →\rightarrow 促销 (3)。当我们将列重新组合回原来的形式时,我们可以看到数组 items 中有两个对象,其中第一个对象中的 item_id 为 1001,第二个对象中的 item_id 为 1002。第二。同样,同一个第一个对象中的促销是“BLACK-FRIDAY”,而第二个对象中缺少

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    文件
    推断模式
    数据列


    <正文>

    JSON

     

    {
      “订单id”:5002,
      “客户 ID”:101,
      “日期”:“2023-11-24”,
      “项目”: [
        {
          “项目 ID”:1001,
          “数量”:1,
          “购买价格”:1200.00,
          “促销”:“黑色星期五”
        },
        {
          “项目 ID”:1002,
          “数量”:1,
          “购买价格”:30.00
        }
      ]
    }

    图 8:表示对象数组

    处理异构类型

    好吧!到目前为止,我们知道如何将 JSON 对象、数组及其标量值表示为列和如何处理架构更改(即推断新列)。接下来,我们解释如何处理异构数据类型。让我们以下面图 9 中所示的示例为例,使用清单 3 中所示的文档。首先,我们看到有四个文档,其中字段 price 可以是双精度值、字符串或双精度值数组,或缺失值。推断的模式将价格类型表示为双精度、字符串或数组的并集。模式树结构中的联合节点在图 9 中表示为菱形,具有三个子节点。推断模式中的联合类型在三个方面是一种特殊情况。首先,并集是一种逻辑描述,不能存在于实际文档中。其次,由于是逻辑描述,因此不影响定义层次。第三,联合值中只能存在单个值,其余值必须缺失 – 当所有值都缺失时,联合值本身就会指示为缺失。

    让我们解压它并使用下面的示例来理解联合类型的这三个标准。首先,我们看到第 1 列、第 2 列和第 3 列都属于 价格。与在子文档中一样,联合的分支可以具有任意数量的列。例如,第 1 列对应于 price 的双精度类型版本,第 2 列对应于 price 的字符串类型版本,第 3 列对应于双精度数组-类型版本的价格。当访问价值价格时,必须同时读取所有三列以确定价格的实际价值。从第 1 列开始,我们看到第一个值的定义级别等于 1,这表明该值存在,并且其值为 10.99。请注意,根和列本身之间的并集不会改变结果(即,结果仍然是第一个文档中所示的两倍 – 上面的第一个标准)。另请注意,即使从根到第 1 列的路径经过并集节点(第二个标准),定义级别仍为 1(而不是 2)。现在,由于第 1 列包含第一个文档的非缺失值,因此同一文档的第 2 列和第 3 列中的值缺失(第三个标准)。在第二个文档中,价格是一个字符串。因此,字符串值出现在第 2 列中,而第 1 列和第 3 列均缺失。在第三个文档中,价格是一个双精度数组,因此该数组的价格值存储在第 3 列中。最后,在第四个文档中,缺少字段 价格。因此,所有三列的定义级别均为 0 – 表明价格本身缺失。

    <表格样式=“最大宽度:100%;宽度:自动;表格布局:固定;显示:表格;”宽度=“自动”>
    <标题>

    文档 推断的架构 数据列


    <正文>

    JSON

     

    {
      “项目id”:3003,
      「价格」:10.99
    }
    
    {
      “项目id”:3004,
      “价格”:“10.99”
    }
    
    {
      “项目id”:3005,
      “价格”:[10.99,11.99]
    }
    
    {
      “项目id”:3006,
    }

    图 9:表示异构类型

    编码

    在此表示中,我们看到每列存储的值都具有相同的类型和域。即使在具有异构数据类型的字段(例如图 9 中的价格)中,每个物理列也专用于存储价格值的一种特定数据类型。这使我们有机会使用目前 Parquet 中使用的相同传出编码方案不包括字典编码(因为它需要额外的存储页面)。我们计划探索在未来版本中包含字典编码的潜在好处/缺点。

    我们现在看到,Capella Columnar(使用这种柱状表示)能够处理 JSON 数据模型中架构的灵活性,以及​​在将数据存储为列时处理动态架构更改。此外,该表示能够处理 JSON 数据模型的嵌套性质。最后,当遇到异构值类型时,架构和列可以动态适应。

    列式存储物理布局

    既然我们知道如何将 JSON 文档表示为列,那么现在的问题是如何实际存储它们。之前,我们描述了 Capella Columnar 中的摄取管道,并解释了如何JSON 文档作为列写入磁盘上的 LSM 组件。那么,让我们深入了解一下磁盘组件的内部内容。磁盘组件是完全存储在磁盘上的 B+ 树。在 Capella Columnar 中,我们使用论文中描述的物理存储布局,称为 AsterixDB Mega Attributes across(或AMAX),用于存储数据列。图 10 描述了存储布局。每个 AMAX LSM 组件也是一棵 B+ 树;然而,B+树的叶子节点是巨型叶子节点,每个叶子节点可以占用多个数据页,而不是像原来那样占用单个数据页B+-树。在图 10 中,我们看到有四个巨型叶节点(四个箭头来自内部节点),每个节点占用不同数量的数据页。放大其中一个巨型叶子节点,我们看到它由 5 个数据页组成(第 0 页到第 4 页)。第 0 页是一个特殊页面,因为它存储包含有关巨型叶子节点的元信息的巨型叶子节点标头。另外,Page 0存储了所有mega leaf节点的主键,以及每列的最小值和最大值的固定长度前缀,可以用于在查询LSM组件时过滤掉mega leaf节点(更多稍后再说)。

    接下来,第 1 页到第 4 页存储列的数据(即它们的定义级别及其值)。在同一张图中,我们看到一些称为 Megapages 的逻辑表示,每个逻辑表示都存储特定列的数据。例如,megapage 1 占用第 1 页、第 2 页和第 3 页的一部分,存储单个列的数据。 megapage 2也是如此,它占据了第3页和第4页,并且存储了另一列的数据。

    图 10:列式存储物理布局 (AMAX)

    图 10:列式存储物理布局 (AMAX)

    列式集合的查询处理技术

    当查询集合主索引的 LSM 组件时,将处理上面图 10 中 B+ 树中的每个巨型叶节点(从左到右)。但是,在每个大型叶节点中只会访问相关的大型页面(或列)。例如,假设执行以下查询:

    SQL

     

    选择平均值(i.price)
    来自项目我

    在每个大型叶节点中,只有第 0 页以及与价格值(或列)对应的大型页面一起被访问。例如,假设 megapage 2 存储价格值;那么只会访问 Page 0、Page 3 和 Page 4,而 Page 1 和 Page 2 将被跳过 – 减少 I/O 成本。

    正如我们提到的,Page 0 还存储每列的最小值和最大值的固定长度前缀。假设我们要运行另一个查询,如下所示:

    SQL

     

    选择 *
    来自客户 c
    WHERE 地址.city =“哥谭”

    当访问大型叶子节点时,我们读取的第一页是 Page 0。因此,在读取查询所需的其他页面(在上面的查询的情况下,是所有列)之前,我们使用当最小/最大过滤器无法满足条件 address.city = "Gotham" 时,Page 0 中的过滤器会跳过读取大型叶节点的大型页面。因此,可以跳过许多巨型叶节点。如果过滤器是选择性的,则此类过滤器可以显着加速查询的执行。

    上面的查询执行SELECT *,这需要将 JSON 文档组装回其原始形式。如果列数较多,则组装成本(CPU 成本)可能会很高。为了避免支付不必要的成本,Capella Columnar 会将 WHERE 子句的谓词下推到存储层。更具体地说,Capella Columnar 所做的是,它首先读取计算查询中的 WHERE 子句所需的列,并推迟读取其他列。当 Capella Columnar 找到满足 WHERE 子句条件的 JSON 文档时,它会读取其他列,并仅组装满足查询条件的 JSON 文档。当第 0 页上的最小/最大过滤器的范围太“宽”时,这种二级过滤方法可以充当“第二道防线”。此外,通过避免组装不满足执行查询条件的 JSON 文档,可以降低重组成本。

    上述技术适用于各种查询。例如,Capella Columnar 的编译器甚至可以针对数组项检测和使用过滤器,如下面的查询所示:

    SQL

     

    --返回至少包含一个已购买的订单数
    -- 购买价格低于 10.0 的商品
    选择计数(*)
    FROM 订单 o
    -- o.items 是 Orders 中的一个数组(参见清单 2)
    -- 过滤器将被推送并应用在
    -- 存储层甚至针对数组项
    WHERE(o.items 中的某些 i 满足 i.purchase_price < 10.0)

    最后,Capella Columnar 还支持在列式集合上创建和使用二级索引(如传统上对行集合所做的那样)以进行进一步过滤。

    结论

    Couchbase 列式服务旨在提供零 ETL 方法来提供各种将数据整合到一个平台中进行实时分析。它的 JSON 数据模型、高效的存储引擎以及查询编译器和查询处理器是此配方的秘密关键成分。现在你知道这个秘密了!

    参考文献