你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

超大规模 (Citus) 性能优化

适用于:Azure Database for PostgreSQL - 超大规模 (Citus)

充分发挥分布式数据库的潜力可提供高性能。 但是,达到该性能可能需要对应用程序代码和数据建模进行一些调整。 本文介绍了一些提高性能的最常见且有效的技术。

客户端连接池

连接池会保留打开的数据库连接以供重复使用。 应用程序根据需要从池中请求连接,如果可能,池会返回一个已建立的连接,或者建立新连接。 完成后,应用程序会将连接释放回池,而不是将其关闭。

添加客户端连接池是一种通过最少的代码更改来提升应用程序性能的简单方法。 在我们的度量中,在启用池的超大规模 (Citus) 服务器组上运行单行插入语句的大约可达 24 倍的速度。

有关在应用程序代码中添加池的特定语言示例,请参阅应用堆栈指南

注意

超大规模 (Citus) 还使用 pgbouncer 提供服务器端连接池,但它主要用于增加客户端连接限制。 单个应用程序的性能受益于客户端池,而不是服务器端池。 (虽然这两种形式的池可同时使用而不会造成伤害。)

界定分布式查询范围

更新

更新分布式表时,请尝试筛选分布列上的查询,至少在有意义的情况下,而新的筛选器不会改变查询含义。

在某些工作负载中,这很容易。 多租户 SaaS 应用或物联网等事务/操作工作负载按租户或设备分发表。 查询的范围限定为租户 ID 或设备 ID。

例如,在多租户教程中,我们有一个由 company_id 分发的 ads 表。 更新广告的原始方法是像这样将其单独列出:

-- slow

UPDATE ads
   SET impressions_count = impressions_count+1
 WHERE id = 42; -- missing filter on distribution column

尽管查询唯一地标识了一行并对其进行更新,但超大规模 (Citus) 在计划时并不知道查询将更新哪个分片。 Citus 将所有分片上的 ShareUpdateExclusiveLock 设置为安全,这会阻止尝试更新表的其他查询。

尽管 id 足以标识行,但可以包含额外的筛选器来加快查询速度:

-- fast

UPDATE ads
   SET impressions_count = impressions_count+1
 WHERE id = 42
   AND company_id = 1; -- the distribution column

超大规模 (Citus) 查询规计划器查看分布列的直接筛选器,并确切知道要锁定的单个分片。 在我们的测试中,为分布列添加筛选器提升了 100 倍的并行更新性能。

联接和 CTE

我们了解了 UPDATE 语句应如何按分布列界定范围,从而避免不必要的分片锁。 其他查询也受益于范围界定,通常是为了避免在工作器节点之间进行不必要数据混用的网络开销。

-- logically correct, but slow

WITH single_ad AS (
  SELECT *
    FROM ads
   WHERE id=1
)
SELECT *
  FROM single_ad s
  JOIN campaigns c ON (s.campaign_id=c.id);

我们可以通过在 CTE 和主 SELECT 语句中筛选分布列 company_id 来加快查询速度。

-- faster, joining on distribution column

WITH single_ad AS (
  SELECT *
    FROM ads
   WHERE id=1 and company_id=1
)
SELECT *
  FROM single_ad s
  JOIN campaigns c ON (s.campaign_id=c.id)
 WHERE s.company_id=1 AND c.company_id = 1;

通常,联接分布式表时,请尝试在联接条件中包含分布列。 但是,在分布式表和引用表之间联接时,则不需要,因为引用表内容在所有工作器节点之间复制。

如果向所有查询添加额外的筛选器似乎不方便,请记住,多种热门应用程序框架的帮助程序库可将其简化。 以下是说明:

高效数据库日志记录

一直记录所有 SQL 语句会增加开销。 在我们的度量中,与完整日志记录相比,使用更明智的日志级别可将每秒事务数提高 10 倍。

为实现高效的日常操作,可以禁用日志记录,但错误和异常长时间运行的查询除外:

设置 value reason
log_statement_stats OFF 避免分析开销
log_duration OFF 不需要知道正常查询的持续时间
log_statement 没有更具体的原因时不要记录查询
log_min_duration_statement 值长于你认为普通查询应采用的值 显示异常长的查询

注意

托管服务中的日志相关设置将上述建议考虑在内。 可以将它们保留原样。 但是,我们有时会看到客户更改设置,使日志记录变得激进,这导致了性能问题。

锁争用

数据库使用锁使数据在并发访问下保持一致。 但是,某些查询模式需要过多锁定,并且存在更快的替代方法。

系统运行状况和锁

在深入了解常见锁定效率低下前,我们来了解如何查看整个数据库群集中的锁和活动。 citus_stat_activity 视图提供了详细的视图。

其中,该视图显示了查询如何受到“等待事件”(包括锁)阻止。 按 wait_event_type 分组可绘制系统运行状况的图片:

-- general system health

SELECT wait_event_type, count(*)
  FROM citus_stat_activity
 WHERE state != 'idle'
 GROUP BY 1
 ORDER BY 2 DESC;

NULL wait_event_type 表示查询未等待任何内容。

如果在统计信息活动输出中看到锁,可以使用 citus_lock_waits 查看特定的受阻止查询:

SELECT * FROM citus_lock_waits;

例如,如果一个查询在另一个查询试图更新同一行时受到阻止,则会看到已阻止和正在阻止的语句出现:

-[ RECORD 1 ]-------------------------+--------------------------------------
waiting_gpid                          | 10000011981
blocking_gpid                         | 10000011979
blocked_statement                     | UPDATE numbers SET j = 3 WHERE i = 1;
current_statement_in_blocking_process | UPDATE numbers SET j = 2 WHERE i = 1;
waiting_nodeid                        | 1
blocking_nodeid                       | 1

若要查看当前发生的锁以及历史模式,可以在 PostgreSQL 日志中捕获锁。 若要了解详细信息,请参阅 PostgreSQL 文档中的 log_lock_waits 服务器设置。 Citus 数据博客上的另一种重要资源是处理锁的七项技巧

常见问题和解决方案

DDL 命令

truncatedropcreate index 等 DDL 命令都采用写入锁,并阻止对整个表的写入操作。 最大程度地减少此类操作可减少锁定问题。

提示:

  • 尝试将 DDL 合并到维护时段,或者减少其使用频率。

  • PostgreSQL 支持并发生成索引,以免对表进行写入锁定。

  • 在运行繁重的 DDL 命令之前,请考虑在 SQL 会话中设置 lock_timeout。 借助 lock_timeout,如果命令等待写入锁的时间过长,PostgreSQL 将中止 DDL 命令。 等待锁的 DDL 命令可能会导致以后的查询在自身后面排队。

事务连接空闲

空闲(未提交)的事务有时会不必要地阻止其他查询。 例如:

BEGIN;

UPDATE ... ;

-- Suppose the client waits now and doesn't COMMIT right away.
--
-- Other queries that want to update the same rows will be blocked.

COMMIT; -- finally!

若要手动清理协调器节点上任何长时间空闲的查询,可以运行以下命令,如下所示:

SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE datname = 'citus'
 AND pid <> pg_backend_pid()
 AND state in ('idle in transaction')
 AND state_change < current_timestamp - INTERVAL '15' MINUTE;

PostgreSQL 还提供了 idle_in_transaction_session_timeout 设置来自动终止空闲会话。

死锁数

Citus 会检测分布式死锁并取消其查询,但这种情况的性能不如一开始就避免死锁。 死锁的一个常见源是一次从多个事务中以不同顺序更新同一组行。

例如,并行运行这些事务:

会话 A:

BEGIN;
UPDATE ads SET updated_at = now() WHERE id = 1 AND company_id = 1;
UPDATE ads SET updated_at = now() WHERE id = 2 AND company_id = 1;

会话 B:

BEGIN;
UPDATE ads SET updated_at = now() WHERE id = 2 AND company_id = 1;
UPDATE ads SET updated_at = now() WHERE id = 1 AND company_id = 1;

-- ERROR:  canceling the transaction since it was involved in a distributed deadlock

会话 A 依次更新 ID 1 和 2,而会话 B 依次更新 2 和 1。 仔细为事务编写 SQL 代码,以相同顺序更新行。 (更新顺序有时称为“锁定层次结构”。)

在我们的度量中,在避免死锁时,批量更新包含许多事务的一组行可达 3 倍速度。

引入过程中的 I/O

由于分片,对于超大规模 (Citus) 而言,I/O 瓶颈问题通常比单节点 PostgreSQL 的问题要小。 分片是各自较小的表,具有更好的索引和缓存命中率,可产生更佳性能。

但是,即使对于超大规模 (Citus),随着表和索引的增长,磁盘 I/O 可能会成为数据引入的问题。 需要注意的是,越来越多的“IO”wait_event_type 条目出现在 citus_stat_activity 中:

SELECT wait_event_type, wait_event count(*)
  FROM citus_stat_activity
 WHERE state='active'
 GROUP BY 1,2;

重复运行上述查询,以捕获与等待事件相关的信息。 请注意不同等待事件类型的计数如何变化。

另请查看 Azure 门户中的指标,尤其是逐渐最大化的 IOPS 指标。

提示:

  • 如果数据为自然排序,例如时序,请使用 PostgreSQL 表分区。 请参阅本指南,了解如何在超大规模 (Citus) 中对分布式表进行分区。

  • 移除未使用的索引。 索引维护会导致引入期间的 I/O 放大。 若要查找未使用的索引,请使用此查询

  • 如果可能,请避免为随机数据编制索引。 例如,某些 UUID 生成算法没有顺序。 为此类值编制索引会导致大量开销。 请改为尝试 bigint 序列,或单调增加 UUID。

结果摘要

在使用 INSERT、UPDATE、事务块进行简单引入的基准测试中,我们观察到适用于本文中技术的以下查询加速。

方法 查询加速
范围查询 100x
连接池 24x
高效日志记录 10x
避免死锁 3x

后续步骤