选择分布列

Citus 使用分布式表中的分布列(distribution column)将表行分配给分片。 为每个表选择分布列是 最重要的建模决策之一,因为它决定了数据如何跨节点分布。

如果正确选择了分布列,那么相关数据将在相同的物理节点上组合在一起,从而使查询快速并添加对所有 SQL 功能的支持。 如果列选择不正确,系统将不必要地缓慢运行,并且无法支持跨节点的所有 SQL 功能。

本节提供两种最常见的 Citus 方案的分布列提示。最后,它深入探讨了共置(co-location),即节点上理想的数据分组。

多租户应用

多租户架构使用一种分层数据库建模形式在分布式集群中的节点之间分布查询。 数据层次结构的顶部称为 tenant id,需要存储在每个表的列中。 Citus 检查查询以查看它们涉及的 tenant id,并将查询路由到单个 worker 节点进行处理,特别是保存与 tenant id 关联的数据分片的节点。 运行将所有相关数据放置在同一节点上的查询称为 表共置

下图说明了多租户数据模型中的共置(co-location)。 它包含两个表,Accounts 和 Campaigns,每个表都由 account_id 分布。 阴影框代表分片,每个分片的颜色代表哪个 worker 节点包含它。 绿色分片一起存储在一个 worker 节点上,蓝色分片存储在另一个节点上。 请注意,当将两个表限制为相同的 account_id 时,Accounts 和 Campaigns 之间的 join 查询如何将所有必要的数据放在一个节点上。

co-located tables in multi-tenant architecture

要在您自己的 schema 中应用此设计,第一步是确定在您的应用程序中构成租户(tenant)的内容。 常见实例包括公司(company)、帐户(account)、组织(organization)或客户(customer)。 列名称类似于 company_idcustomer_id。 检查您的每个查询并问自己:如果它有额外的 WHERE 子句将所有涉及的表限制为具有相同 tenant id 的行,它会起作用吗? 多租户模型中的查询通常以租户为范围,例如销售(sales)或库存(inventory)查询将在某个商店(store)内进行。

最佳实践

  • 按公共 tenant_id 列对分布式表进行分区。 例如,在租户是公司的 SaaS 应用程序中,tenant_id 可能是 company_id。

  • 将小型跨租户表转换为引用表。 当多个租户共享一个小信息表时,将其作为 reference table 分发。

  • 限制按 tenant_id 过滤所有应用程序查询。 每个查询应一次请求一个租户的信息。

阅读 多租户应用程序 指南,了解构建此类应用程序的详细示例。

实时应用

虽然多租户架构引入了分层结构并使用数据共置(data co-location)来路由每个租户的查询,但实时架构依赖于其数据的特定分布属性来实现高度并行处理。

我们在实时模型中使 “entity id” 作为分布列的术语,而不是多租户模型中的租户 ID。典型的实体是用户(users)、主机(hosts)或设备(devices)。

实时查询通常要求按日期(date)或类别(category)分组的数字聚合。 Citus 将这些查询发送到每个分片以获得部分结果,并在 coordinator 节点上组装最终答案。 当尽可能多的节点做出贡献并且没有单个节点必须做不成比例的工作时,查询运行速度最快。

最佳实践

  • 选择具有高基数的列作为分布列。 为了比较,订单表上的 “status” 字段具有”新(new)”、”已付款(paid)” 和”已发货(shipped)”值,是分布列的一个糟糕选择,因为它只假设这几个值。 不同值的数量限制了可以保存数据的分片数量以及可以处理数据的节点数量。在具有高基数的列中,最好另外选择那些经常用于 group-by 子句或作为 join 键的列。

  • 选择分布均匀的列。 如果您将表分布在偏向某些常见值的列上,则表中的数据将倾向于在某些分片中累积。持有这些分片的节点最终会比其他节点做更多的工作。

  • 将事实表和维度表分布在它们的公共列上。 您的事实表只能有一个分布 key。在另一个 key 上 join 的表不会与事实表共置(co-locate)。根据 join 的频率和 join 行的大小,选择一个维度来共置。

  • 将一些维度表更改为引用表。 如果维度表不能与事实表共置,您可以通过将维度表的副本以 reference table 的形式分发到所有节点来提高查询性能。

阅读 实时仪表盘 指南,了解构建此类应用程序的详细示例。

时间序列数据

在时间序列工作负载中,应用程序在归档旧信息的同时查询最近的信息。

在 Citus 中建模时间序列信息的最常见错误是将`时间戳(timestamp)`本身用作分布列。 基于时间的 hash 分布将看似随机的时间分布到不同的分片中,而不是将时间范围保持在分片中。 但是,涉及时间的查询通常会参考时间范围(例如最近的数据),因此这样的 hash 分布会导致网络开销。

最佳实践

  • 不要选择时间戳作为分布列。 选择不同的分布列。在多租户应用程序中,使用租户(tenant) ID,或在实时应用程序中使用实体(entity) ID。

  • 改为使用 PostgreSQL 表分区。 使用表分区将一个按时间排序的数据大表分解为多个继承表,每个表包含不同的时间范围。在 Citus 中分发 Postgres-partitioned 的表会为继承的表创建分片。

阅读 时间序列数据 指南,了解构建此类应用程序的详细示例。

表共置

关系数据库因其巨大的灵活性和可靠性而成为许多应用程序的首选数据存储。 从历史上看,对关系数据库的一个批评是它们只能在一台机器上运行,当数据存储需要超过服务器改进时,这会产生固有的限制。 快速扩展数据库的解决方案是分发它们,但这会产生其自身的性能问题:join 等关系操作需要跨越网络边界。 共置(Co-location) 是一种策略性地划分数据的做法,将相关信息保存在同一台机器上以实现高效的关系操作,但利用整个数据集的水平可扩展性。

数据共置的原理是数据库中的所有表都有一个共同的分布列,并以相同的方式跨机器分片, 使得具有相同分布列值的行总是在同一台机器上,即使跨不同的表也是如此。 只要分布列提供了有意义的数据分组,就可以在组内执行关系操作。

Citus 中用于 hash-distributed 表的数据共置

PostgreSQL 的 Citus 扩展在能够形成分布式数据库方面是独一无二的。 Citus 集群中的每个节点都是一个功能齐全的 PostgreSQL 数据库,Citus 在其上添加了单一同质数据库的体验。 虽然它没有以分布式方式提供 PostgreSQL 的全部功能,但在许多情况下, 它可以通过共址(co-location)方式在一台机器上充分利用 PostgreSQL 提供的功能,包括完整的 SQL 支持、事务和外键。

在 Citus 中,如果分布列中值的哈希值落在分片的哈希范围内,则将一行存储在分片中。 为了确保共置,即使在重新平衡操作之后,具有相同哈希范围的分片也始终放置在同一个节点上, 这样相等的分布列值始终位于跨表的同一个节点上。

illustration of shard hash ranges

我们发现在实践中运行良好的分布列是多租户应用程序中的 tenant ID。 例如,SaaS 应用程序通常有许多租户,但它们所做的每个查询都是特定于特定租户的。 虽然一种选择是为每个租户提供 database 或 schema, 但它通常成本高昂且不切实际,因为可能有许多跨用户的操作(数据加载、迁移、聚合、分析、schema 更改、备份等)。 随着租户数量的增加,这变得更难管理。

共置的实际示例

考虑以下表格,这些表格可能是多租户 Web 分析 SaaS 的一部分:

CREATE TABLE event (
  tenant_id int,
  event_id bigint,
  page_id int,
  payload jsonb,
  primary key (tenant_id, event_id)
);

CREATE TABLE page (
  tenant_id int,
  page_id int,
  path text,
  primary key (tenant_id, page_id)
);

现在我们要回答可能由面向客户的仪表板发出的查询,例如:“返回租户六中所有以‘/blog’开头的页面在过去一周的访问次数。”

使用常规 PostgreSQL 表

如果我们的数据位于单个 PostgreSQL 节点中, 我们可以使用 SQL 提供的丰富的关系操作集轻松地表达我们的查询:

SELECT page_id, count(event_id)
FROM
  page
LEFT JOIN  (
  SELECT * FROM event
  WHERE (payload->>'time')::timestamptz >= now() - interval '1 week'
) recent
USING (tenant_id, page_id)
WHERE tenant_id = 6 AND path LIKE '/blog%'
GROUP BY page_id;

只要此查询的 working set 适合内存, 这是许多应用程序的合适解决方案,因为它提供了最大的灵活性。 但是,即使您还不需要扩展,考虑扩展数据模型的影响也会很有用。

按 ID 分布表

随着租户数量和为每个租户存储的数据的增长,查询时间通常会增加,因为工作集不再适合内存或 CPU 成为瓶颈。 在这种情况下,我们可以使用 Citus 跨多个节点分片数据。 分片时我们需要做出的第一个也是最重要的选择是分布列。 让我们从一个天真的选择开始, 将 event_id 用于事件表,将 page_id 用于 page 表:

-- naively use event_id and page_id as distribution columns

SELECT create_distributed_table('event', 'event_id');
SELECT create_distributed_table('page', 'page_id');

鉴于数据分散在不同的 worker 中, 我们不能像在单个 PostgreSQL 节点上那样简单地执行 join。 相反,我们需要发出两个查询:

跨 page 表的所有分片(Q1):

SELECT page_id FROM page WHERE path LIKE '/blog%' AND tenant_id = 6;

跨 event 表的所有分片(Q2):

SELECT page_id, count(*) AS count
FROM event
WHERE page_id IN (/*…page IDs from first query…*/)
  AND tenant_id = 6
  AND (payload->>'time')::date >= now() - interval '1 week'
GROUP BY page_id ORDER BY count DESC LIMIT 10;

之后,应用程序需要组合这两个步骤的结果。

回答查询所需的数据分散在不同节点上的分片中,每个分片都需要被查询:

queries 1 and 2 hitting multiple nodes

在这种情况下,数据分布会产生很大的缺陷:

  • 查询每个分片的开销,运行多个查询

  • Q1 的开销返回许多行给客户端

  • Q2 变得非常大

  • 需要在多个步骤中编写查询,组合结果,需要在应用程序中进行更改

相关数据分散的一个潜在好处是查询可以并行化,Citus 会这样做。 但是,这只有在查询的工作量远远大于查询许多分片的开销时才有用。 通常最好避免直接从应用程序中进行如此繁重的工作,例如通过 pre-aggregating 数据。

按租户分布表

再次查看我们的查询,我们可以看到查询需要的所有行都有一个共同的维度:tenant_id。 仪表板只会查询租户自己的数据。这意味着,如果同一租户的数据始终位于单个 PostgreSQL 节点上, 那么我们的原始查询可以由该节点通过对 tenant_idpage_id 执行 join 来一次性回答。

在 Citus 中,具有相同分布列值的行保证在同一个节点上。 分布式表中的每个分片实际上都有一组来自其他分布式表的位于同一位置的分片, 这些分片包含相同的分布列值(同一租户的数据)。 从头开始,我们可以创建以 tenant_id 作为分布列的表。

-- co-locate tables by using a common distribution column
SELECT create_distributed_table('event', 'tenant_id');
SELECT create_distributed_table('page', 'tenant_id', colocate_with => 'event');

在这种情况下,Citus 可以回答您将在单个 PostgreSQL 节点上运行而无需修改 (Q1) 的相同查询:

SELECT page_id, count(event_id)
FROM
  page
LEFT JOIN  (
  SELECT * FROM event
  WHERE (payload->>'time')::timestamptz >= now() - interval '1 week'
) recent
USING (tenant_id, page_id)
WHERE tenant_id = 6 AND path LIKE '/blog%'
GROUP BY page_id;

由于租户ID 过滤器和租户ID 连接(join), Citus 知道整个查询可以使用包含该特定租户数据的位于同一位置的分片集来回答, 并且 PostgreSQL 节点可以在一个步骤中回答查询,这启用完整的 SQL 支持。

query 1 accessing just one node

在某些情况下,查询和表 schema 需要进行少量修改, 以确保 tenant_id 始终包含在 unique 约束和 join 条件中。 但是,这通常是一个简单的更改,并且避免了在没有共置的情况下所需的大量重写。

虽然上面的示例只查询一个节点,因为有一个特定的 tenant_id = 6 过滤器, 但共置还允许我们在所有节点上有效地执行对 tenant_id 的分布式 join,尽管存在 SQL 限制。

共置意味着更好的功能支持

Citus 通过共置解锁的功能的完整列表如下:

  • 对一组位于同一位置的分片上的查询的完整 SQL 支持

  • 多语句事务支持对一组位于同一位置的分片进行修改

  • 通过 INSERT..SELECT 聚合

  • 外键

  • 分布式外部联接(outer join)

  • 下推式 CTE(要求 PostgreSQL >=12 )

使用通过共置实现关系操作的分布式数据库迁移或构建应用程序的成本通常大大低于迁移到限制性数据模型(例如 NoSQL)的成本, 并且与单节点数据库不同,它可以随着规模的大小而横向扩展您的业务。 有关迁移现有数据库的更多信息,请参阅 Transitioning to a Multi-Tenant Data Model

查询性能

Citus 通过将传入查询分解为多个在工作分片上并行运行的片段查询(”tasks”)来并行化传入查询。 这使 Citus 可以利用集群中所有节点的处理能力以及每个节点上的单个核心的处理能力来进行每个查询。 由于这种并行化,您可以获得集群中所有核心的计算能力的累积性能,与单个服务器上的 PostgreSQL 相比,查询时间显着减少。

Citus 在规划 SQL 查询时采用了两阶段优化器。 第一阶段涉及将 SQL 查询转换为它们的交换和关联形式,以便它们可以下推并在 worker 上并行运行。 如前几节所述,选择正确的分布列和分布方法允许分布式查询计划器对查询应用多种优化。 由于网络 I/O 减少,这会对查询性能产生重大影响。

Citus 的分布式执行器然后将这些单独的查询片段发送到 PostgreSQL worker 实例。 分布式规划器和执行器都有几个方面可以调整以提高性能。 当这些单独的查询片段被发送给 worker 时,查询优化的第二阶段就开始了。 worker 只是运行扩展的 PostgreSQL 服务器,他们应用 PostgreSQL 的标准计划和执行逻辑来运行这些片段 SQL 查询。 因此,任何有助于 PostgreSQL 的优化也有助于 Citus。PostgreSQL 默认带有保守的资源设置;因此优化这些配置设置可以显着缩短查询时间。

我们在文档的 performance_tuning 部分讨论了相关的性能调优步骤。