外部数据封装机制使得远程的Postgres表看起来就像本地的表一样。正是这种便利性,才导致人们经常会对FDW的性能感到惊讶。

一个看似普通的联接查询,在分布式系统中执行时,数据会通过网络进行传输,远程语句会被反复执行,而本地的规划器则会默默地承担起协调者的角色。在这种环境下,“快速的SQL查询”并不主要取决于CPU性能或索引机制,而是与数据传输过程以及数据在网络中的往返次数密切相关。

本手册重点讲解了决定一个联合查询是会像普通的远程查询那样执行,还是会变成一种复杂的分布式处理流程的关键机制:推下式优化机制

“推下式优化”并不是指“移动计算资源”,而是用来确定过滤、联接、排序和聚合操作是在数据源处进行,还是是在数据已经传输到本地之后才进行的。当推下式优化机制能够正常发挥作用时,本地服务器接收到的结果集规模会变小;而当这种机制失效时,Postgres往往就需要从远程获取大量的中间数据集,然后在本地完成剩余的处理工作。

后续章节将帮助你建立起关于postgres_fdw中哪些操作是真正可高效执行的、为什么有些表达式会导致性能瓶颈,以及如何正确解读EXPLAIN (ANALYZE, BUFFERS, VERBOSE)输出的结果的实用理解。

除了介绍核心机制之外,本手册还涵盖了在生产环境中需要调整的各种参数、关于数据库架构和索引设计的建议、基准测试的方法论、监控与日志记录的相关内容,并通过一个案例研究,展示了推下式优化机制在端到端应用中所能带来的实际效果。

后面的章节会进一步深入探讨一些高级的、与性能优化相关的边缘情况、成本模型的校准方法,以及如何确保FDW工作负载具备良好的稳定性。

目录

先决条件

本手册假定读者已经对Postgres的查询计划有一定的了解。它是在已有知识的基础上进行讲解的,因此不会重新介绍SQL基础、索引机制或连接算法。

本书的重点在于联邦执行模型:外键查询的具体行为,以及如何像分析本地查询计划一样清晰地理解这些外键查询。

以下是您应该已经掌握的内容:

  • 能够阅读EXPLAIN (ANALYZE, BUFFERS)的输出结果,并识别出那些明显的错误或问题(如数据量激增、连接顺序不当、未使用索引等)。

  • 了解基本的连接方式(嵌套循环连接、哈希连接、合并连接),以及基数估计的重要性。

  • 具备实际应用中所需的Postgres统计知识,包括如何使用ANALYZE命令、理解相关性概念,以及“预估行数与实际行数”之间的区别。

此外,在学习这些示例时,您还需要准备以下环境:

  • 一个运行着postgres_fdw模块的Postgres“本地”实例,该实例将充当协调者角色。

  • 一个存储外键表的Postgres“远程”实例。

  • 在本地端,您需要具备以下操作权限:

    • 执行CREATE EXTENSION postgres_fdw;命令。

    • 创建SERVERUSER MAPPING配置。

    • 创建FOREIGN TABLE对象(或拥有使用现有对象的权限)。

    另外,您还需要一种方式来运行查询并获取相应的查询计划结果:

    • 使用psql命令即可,任何能够执行EXPLAIN (ANALYZE, BUFFERS, VERBOSE)命令的图形化工具也同样适用。

    我们不会详细讲解环境配置的过程。这些示例都是建立在FDW相关对象已经存在的前提之下的,因此重点在于查询计划及其行为。

    同时,我们也不会深入探讨分布式系统的一般理论。只有那些与FDW查询计划相关的概念才会被用到这里。

    执行摘要

    本手册最重要的结论是:FDW的推导机制能够显著减少数据传输量。人们很容易认为,推导机制只是改变了计算任务的执行位置(即将计算任务“移送到远程服务器”而已),但实际上关键在于:远程服务器是否只会被请求获取所需的那些数据。

    当推导机制正常工作时,远程服务器会负责执行选择性的连接和过滤操作,而本地Postgres服务器则会接收到一个已经经过优化、数据量较小的结果集。如果推导机制出现故障,本地服务器就会转为分布式查询协调者,它需要从远程服务器获取大量的中间数据集,然后才能在本地完成剩余的计算工作。

    为什么这一点如此重要呢?因为只要对查询结构进行适当的调整,让更多的计算任务能够在远程服务器上完成,就能大幅降低端到端的延迟时间——而且这种优化并不会改变最终的输出结果。在后面我们会通过一个案例来说明这一点:将某个查询重新设计后,使用FDW机制执行该查询,其运行时间从大约166毫秒缩短到了25毫秒。业务逻辑本身并没有发生变化,只是数据的处理方式发生了改变而已。

    下图是一张简单的条形图,直观地展示了这种显著的性能下降情况。该图表使用了案例研究中的实际数据。如果你自己进行实验,由于硬件和网络环境的不同,具体数值可能会有所差异,但相对变化趋势应该是显而易见的。

    这张条形图的标题为“查询执行时间:重构前后对比”。纵轴表示以毫秒为单位的执行时间。‘重构前’的柱状图高度明显更高,超过了160毫秒,而‘重构后’的柱状图高度则低于20毫秒,这一数据说明在重构之后,查询执行时间确实得到了显著改善。

    动机

    外部数据访问机制允许你使用与本地相同的SQL语法来查询远程数据。正是这种便利性,使得它们有时会带来误导。

    一个联合查询看起来可能像是一个普通的联接操作,但实际上它的运行方式更类似于一个分布式系统:查询计划的某些部分在远程服务器上执行,某些部分在本地服务器上执行,而它们之间的所有交互都依赖于网络传输。导致查询速度变慢的原因通常不是“糟糕的SQL代码”,而是以下两种情况的结合:

    1. 通过网络传输了过多的数据行。在没有数据推降机制的情况下,外部数据访问模块会从远程表中获取大量数据,然后在这些数据上应用过滤条件和联接操作。这样一来,即使你实际上只需要几百行数据,也可能有数万甚至数百万行数据需要被传输到网络另一端。

    2. 发生了过多的网络往返操作。如果查询计划中包含了嵌套循环,从而导致了对远程数据的多次访问,那么同样的查询可能会被执行成百上千次。虽然每次单独的请求可能耗时不多,但累积起来就会导致严重的性能下降。

    这些结论并非臆测。PostgreSQL的官方文档明确指出,外部数据表并没有本地存储空间,Postgres会“要求外部数据访问模块从外部数据源获取数据”[1]。因此,不存在任何本地缓冲区或堆存储空间来隐藏执行错误。你检索的每一行数据都必须至少通过网络传输一次。如果查询计划中获取的数据量超过了实际需要,或者这种操作被反复执行,性能就会迅速下降。

    正因如此,你应该将EXPLAIN (VERBOSE)命令输出的远程SQL代码视为查询计划的一部分。它能够清楚地告诉你远程服务器被要求执行哪些操作。如果查询计划中缺少你的过滤条件或联接操作,那么你就知道本地服务器必须完成剩下的工作。本书的后续内容会教你如何解读这些查询计划,如何在可能的情况下强制实施数据推降机制,以及如何识别出系统出现故障的迹象。

    无需复杂配置即可掌握的外部数据访问机制基础

    如果你已经在自己的数据库中创建了外部表,可能会想跳过这一部分。但千万不要这样做。了解外部数据封装层的架构对于理解为什么“下推处理”如此重要至关重要。

    简述SQL/MED

    PostgreSQL通过其FDW框架实现了SQL/MED(外部数据管理)标准。要通过postgres_fdw访问远程的Postgres服务器,需要执行以下四个步骤:

    1. 安装扩展模块:执行CREATE EXTENSION postgres_fdw命令,即可让Postgres加载FDW相关代码。

    2. 创建外部服务器:使用CREATE SERVER foreign_server FOREIGN DATA WRAPPER postgres_fdw OPTIONS (host '...", port '..., dbname '...')命令指定远程服务器的位置及连接方式。

    3. 设置用户映射关系:执行CREATE USER MAPPING FOR your_user SERVER foreign_server OPTIONS (user 'remote_user', password '...')命令,告诉Postgres如何在远程端进行身份验证。

    4. 创建外部表:使用CREATE FOREIGN TABLE remote_table (...) SERVER foreign_server OPTIONS (schema_name '...", table_name '...')命令定义外部表的列结构,并将其与远程表关联起来。

    完成这些步骤后,你就可以像操作本地表一样对这个外部表执行SELECT等查询语句了。但需要注意的是,这种外部表实际上并没有对应的存储空间[1]。每次执行SELECTINSERTUPDATEDELETE操作时,FDW都会先连接到远程服务器,生成相应的查询语句并发送过去,然后再读取结果。对于简单的查询来说,这种开销并不明显;但当查询变得复杂时,这种性能损耗就会变得非常严重。

    postgres_fdw的功能与局限

    postgres_fdw主要承担两项任务:

    1. 它会根据你的查询语句在远程服务器上生成相应的SQL执行计划,同时会在条件允许的情况下将一些安全的过滤操作、连接操作、排序操作以及聚合操作直接在远程端完成。

    2. 它会从远程服务器获取数据并将其传递给本地执行引擎;如果查询中的某些部分无法在远程端执行,那么这些部分就会由本地执行引擎来处理。

    3. FDW会尽力减少数据传输量:例如,它会将WHERE子句中的大部分条件直接发送到远程服务器,同时也不会检索那些不会被使用的列[2]。此外,还有一些可调参数可以帮助优化FDW的性能(比如fetch_sizeuse_remote_estimatefdw_startup_costfdw_tuple_cost[3])。不过,真正能提高性能的关键在于合理设计查询语句,让FDW能够尽可能多地在远程端完成处理工作。

      还有最后一点需要注意:远程服务器运行在受限的会话环境中。通过postgres_fdw打开的远程会话中,search_path仅被设置为pg_catalog,而TimeZoneDateStyleIntervalStyle也被设置为特定的值[4]。这意味着,任何你希望在远程端运行的函数,都必须带有模式限定符,或者必须以FDW能够识别的方式被封装起来。这也说明了为什么除非你完全清楚自己在做什么,否则不应该覆盖FDW连接相关的会话设置[4]

      推下机制

      从宏观上来说,“推下”意味着尽可能多地将SQL查询代码发送到远程服务器进行处理。但是,FDW不能随意发送任意形式的SQL代码;这些代码必须具备安全性可移植性,才能在远程服务器上被正确执行。Postgres使用“可传输的

      “可传输的”在实际中意味着什么

      如果一个表达式满足以下条件,那么它就被认为是“可传输的”:

      1. 它使用内置函数、运算符或数据类型,或者那些通过外部服务器上的扩展选项被明确允许使用的扩展功能[2]。如果你使用了自定义函数或未被声明的扩展模块,FDW会认为这些函数无法在远程端执行。

      2. 该表达式被标记为“不可变”的。Postgres区分了IMMUTABLESTABLEVOLATILE三种类型的函数。只有那些对于相同的输入总是返回相同结果的不可变函数,才适合被推送到远程服务器进行执行[5]。这一规则确保了像now()random()这样的时间依赖函数不会在远程服务器上被错误地执行,因为它们的结果可能会在本地服务器和远程服务器上有所不同。

      3. 该表达式不依赖于本地的排序规则或类型转换功能。PostgreSQL的文档警告称,如果类型或排序规则的差异会导致语义上的异常,那么这种表达式就不适合被推送到远程服务器[1]。如果FDW无法保证某个比较操作在本地服务器和远程服务器上的执行结果完全一致,它就会拒绝将这个表达式推下去。例如,如果在远程服务器上没有安装citext扩展模块,那么将citext类型的列与text常量进行比较可能会带来安全风险。

      根据这些规则,你可以得出以下注意事项:在WHERE子句中避免使用不可变的函数,保持连接条件的简洁性并确保其类型正确;对于任何想要在远程服务器上使用的第三方扩展模块,请将其添加到远程服务器的扩展选项中,这样它们才能被视为可发布的[2]

      WHERE子句的推导执行

      如果WHERE子句完全由可发布的表达式组成,那么它将会被包含在远程查询中;否则,它就会在本地进行评估。这一点非常重要,因为将过滤条件推送到远程服务器可以减少返回给本地服务器的行数。

      以这样的谓词为例:

      WHERE created_at >= now() - interval 连接的推导执行条件
      

      连接操作是另一个重要的优化点。当postgres_fdw遇到在同一台远程服务器上进行的表连接操作时,除非认为单独获取这些表会更高效,或者这些表使用了不同的用户映射设置[6],否则它就会将整个连接操作发送到远程服务器进行处理。

      对于连接条件,同样需要遵循上述规则:连接条件必须是可发布的,并且两个参与连接的表必须位于同一台服务器上。跨服务器的连接操作永远不会被推送到远程服务器进行处理——FDW会直接在本地执行这些操作。

      可发布性决策树

      将这些可发布性规则可视化为流程图会很有帮助。下面是一个简单的决策树模型,你可以在检查某个表达式或连接条件时使用它。

      这个决策过程首先会判断该表达式是位于WHERE子句中还是JOIN子句中;随后会根据是否使用了易变函数、内置函数、类型不匹配的情况,或是是否涉及跨服务器连接等因素来做出进一步的判断。最终,流程图会给出“不可发布,需在本地评估”或“可发布,将包含在远程SQL查询中”这样的结果。

      如果分析的结果表明该表达式位于树的左侧,那么它就会在本地进行评估;如果位于右侧,FDW就可以将其纳入远程SQL查询中。

      用于判断SQL表达式是否可发布的流程图。首先会确定该表达式是位于WHERE子句还是JOIN子句中,随后会根据各种因素来做出进一步判断。最终结果可能是“不可发布,需在本地评估”或“可发布,将包含在远程SQL查询中”。

      可执行的操作:深入分析

      在多个版本中,Postgres不断扩展了可以向下推导的postgres_fdw的功能。本节将详细介绍各类操作以及进行向下推导所需满足的条件。

      过滤器(WHERE子句)

      如上所述,那些使用内置运算符和不可变函数的简单过滤器通常会被向下推导。如果在查询计划中看到“Foreign Scan”节点上方有Filter:节点,那就说明你的查询条件中存在不符合要求的部分。常见的原因包括使用了now()timezone()等易变的函数,引用了不被允许使用的扩展模块,或者比较了不同的排序规则。

      当出现这种情况时,系统会获取整个表中的数据(或者至少是所有符合其他可执行条件的数据),然后在本地应用过滤器进行过滤操作。

      识别提示:如果看到“Foreign Scan”节点上方直接有Filter:行,那就说明过滤操作是在本地进行的。另外,也要注意那些使用远程SQL语句的查询,例如:

      SELECT * FROM remote_table name = 连接操作
      

      在同一台远程服务器上,对外部表进行简单的内连接操作通常是可以被向下推导的。连接条件必须满足与过滤器相同的可执行性规则。但如果连接涉及多台远程服务器,或者连接条件使用了不可被向下推导的函数,又或者外部表使用了不同的用户映射设置,那么FDW会分别获取每张表的数据,然后在本地进行连接操作[6]。这种情况下,可能会产生大量的中间数据需要传输。

      识别提示:如果哈希连接或合并连接的两个输入都是“Foreign Scan”节点,那就说明连接操作是在本地进行的;相反,如果一个“Foreign Scan”节点代表了连接操作,并且在远程SQL语句中包含了JOIN ... ON子句,那就说明连接操作是被向下推导到远程服务器去的。

      [7]。

      要使聚合操作能够被向下推导,那么分组表达式以及聚合函数本身都必须满足可执行性要求。如果FDW无法将某个聚合操作向下推导,它就会获取原始数据并在本地进行聚合计算。

      判断计划是否适合进行推导优化的方法:查看那些位于“Foreign Scan”操作之上、且会返回大量数据的GroupAggregate节点。当聚合操作被推导到下层时,就不会出现本地聚合节点;相反,远程SQL语句中会包含GROUP BY子句。

      ORDER BY与LIMIT

      在PostgreSQL 12版本之前,排序和限制操作很少会被推导到下层。从12版本开始,Etsuro Fujita提出的补丁使得在更多情况下[8],ORDER BY排序语句和LIMIT子句可以被推导到postgres_fdw远程服务器上。不过,要使这些操作能够被推导下去,底层的数据扫描操作必须适合进行推导优化,同时排序表达式也必须能够被有效处理。如果查询涉及分区操作或复杂的连接结构,那么排序/限制操作可能仍然需要在本地执行。

      判断计划是否适合进行推导优化的方法:如果在“Foreign Scan”操作之上存在本地排序或限制节点,那就说明这些操作并没有被推导到下层;相反,如果远程SQL语句中包含了ORDER BY和LIMIT子句,那就说明推导优化成功了。

      DISTINCT操作

      当distinct表达式列表适合被推导下去时,DISTINCT操作也可以被推导到下层。但是,如果distinct操作与那些不适合被推导优化的表达式结合在一起,或者如果在无法进行推导优化的连接操作之后才应用distinct操作,那么FDW会读取所有数据并在本地执行distinct操作。

      窗口函数

      在实际应用中,窗口函数很少会被通过postgres_fdw层进行推导优化。因为这些函数往往需要特定的排序或分区机制,而这些机制很难被以可移植的方式实现。因此,如果在查询计划中看到了WindowAgg节点,那么几乎可以肯定这些操作是在本地执行的。这并不意味着不能在远程表上使用窗口函数,但需要注意的是,使用它们会带来额外的网络开销和CPU消耗。

      版本差异

      PostgreSQL的开发人员一直在不断改进FDW层。以下是不同版本中的一些重要变化:

      1. PostgreSQL 9.6版本引入了远程连接操作的推导优化功能,同时也允许UPDATE/DELETE操作被推导到下层。在9.6版本之前,所有的连接操作都必须在本地执行。

      2. PostgreSQL 10版本引入了聚合操作的推导优化功能,使得远程GROUP BY和聚合函数能够被有效利用[7]

      3. PostgreSQL 12版本进一步扩展了ORDER BY和LIMIT操作的推导优化功能[8]

      4. PostgreSQL 15版本为某些CASE表达式添加了推导优化功能,并对其他方面也进行了改进。

      如果你是在较早的版本上了解FDW的工作原理的,那么现在有必要重新审视你的那些认知了。

      推导优化的阻碍因素及其存在原因

      当“下推优化”失败时,这并非运气不佳所致。背后总是存在与安全性或正确性相关的原因。以下是常见的导致优化失败的因素以及相应的诊断方法。

      不可变函数

      被标记为VOLATILESTABLE的函数无法进行“下推优化”,因为它们的计算结果在本地服务器和远程服务器上可能会存在差异。例如now()random()current_user这些函数,以及那些会访问会话变量或查询数据库的用户自定义函数,都属于此类函数。即便是像age()clock_timestamp()这样看似无害的函数,也可能会导致“下推优化”失败。

      解决方法:在引用外部表之前,应在应用程序中或通过CTE计算出这些易变的值。例如,可以将'now' - interval '30 days'这个时间戳计算为一个常量,然后将其与created_at列进行比较。或者,也可以将相关的逻辑移到远程表中的存储生成列中。

      类型和排序规则不匹配

      文档中明确指出,当本地表和远程表之间的数据类型或排序规则不匹配时,远程服务器可能会以不同的方式解读查询条件[1]。特别是在进行文本比较、使用不区分大小写的排序规则,或者设置非默认的区域设置时,这种问题会更加明显。如果Postgres无法保证相同的语义处理结果,它就会在本地获取数据并执行查询操作。

      解决方法:确保外部表的定义使用的数据类型和排序规则与远程表一致。如果有疑问,应明确地将相关值转换为相同的类型。

      跨服务器连接

      不同远程服务器之间的连接无法进行“下推优化”。FDW只有在两个表格都位于同一台远程服务器上,并且使用相同的用户映射设置时,才能执行连接操作[6]。否则,它将会分别扫描这两张表,然后在本地进行连接操作。

      解决方法:如果经常需要跨服务器连接表格,可以考虑将这些表格合并到同一台服务器上;或者在一侧创建物化视图;又或者在进行连接之前,将较小的表格拉取到本地的临时表中。

      混合类型的本地表与外部表连接

      本地表与外部表之间的连接也无法进行“下推优化”。即使外部表满足“下推优化”的条件,FDW也无法在远程服务器上将其与本地数据连接起来。在这种情况下,通常需要使用带有参数化查询的嵌套循环结构,从而导致大量的远程调用操作。

      解决方法:在将外部表的数据与本地表进行连接之前,应尽可能在外部表端先进行过滤或聚合操作(可以通过CTE实现,或者创建子集来进行处理)。

      远程会话设置与搜索路径

      由于postgres_fdw会在远程会话中设置受限的search_pathTimeZoneDateStyleIntervalStyle[4],因此你调用的任何函数都必须加上模式限定符,或者确保其兼容这些设置。如果某个函数依赖于当前的搜索路径或会话配置,那么在远程端运行时,它可能会出错或产生不同的结果。

      解决方法:请为远程函数添加模式限定符,并确保那些依赖环境设置的逻辑能够在默认的FDW会话配置下正常执行。如有必要,可以在远程函数中添加SET search_path等设置来调整行为。

      故障排查指南

      下表将你在EXPLAIN计划中看到的各种问题与可能的原因及相应的解决方法进行了对应关联。当发现异常情况时,可以利用这个表格进行快速诊断。

      EXPLAIN计划中的异常现象 可能的原因 建议的解决方法
      外键扫描操作中循环次数超过1次 由于嵌套循环或连接条件的问题,导致参数化远程查询无法正常执行 重新编写连接语句,使FDW能够生成一个完整的联合查询结果;或者通过IN列表或临时表来批量处理远程请求
      远程SQL语句过于复杂,缺乏范围限定条件 WHERE子句中使用了不可变的函数或不支持的运算符 将易变函数替换为常量,或者使用允许列表中的扩展函数;同时确保数据类型和排序规则一致
      在两个外键表之间进行了本地哈希连接或合并连接操作 由于服务器不同、用户映射信息不一致,或者连接条件无法被优化处理,导致连接操作无法在远程端执行 将相关表格整合到同一台服务器上,调整用户映射设置,或重新编写连接条件
      在外键扫描之后进行了本地排序、限制查询结果数量或去重操作 ORDER BYLIMITDISTINCT等操作无法在远程端执行 简化排序逻辑,将过滤条件进一步下推到远程端;同时检查PG版本是否提供了相关优化功能
      虽然计划能够正常运行,但在启用了相关优化功能后仍然得到了错误的结果 由于数据类型、排序规则的不同,或者远程会话设置的影响[1][4] 确保数据类型和排序规则一致,为函数添加模式限定符,使用稳定的会话配置

      像专业人士一样阅读并理解EXPLAIN命令

      SQL执行计划分析表,其中包含以下列:exclusive、inclusive、rows x、rows、loops以及节点详细信息。该表格会显示嵌套循环连接、哈希连接和顺序扫描操作的相关信息,包括成本、耗时及缓冲区使用情况。高亮显示的单元格表示重要的指标。

      许多开发人员在查看本地查询的EXPLAIN执行计划时,只会关注顶部的节点和整体成本。但对于FDW查询来说,这种习惯必须改变:应该先阅读与远程数据库相关的部分。远程SQL字符串会说明远程服务器需要执行哪些操作,而“loops”字段则能显示这些远程调用被执行的次数。

      检查与远程扫描相关的节点

      首先找到那些与远程扫描相关的节点。在EXPLAIN (VERBOSE)输出中,每个远程扫描操作都会包含这样一行代码:

      远程 SQL语句:行数=循环次数=SELECT id, tenant_id public.user_entity AND service_account_client_link NULL $1

      这种情况实际上就是所谓的“N+1”问题。该执行计划会针对每一行外部数据都执行一次远程扫描操作,因此将每次循环所消耗的成本乘以循环次数,就能明白为什么这个查询会如此耗时了。解决这个问题的方法是重新编写SQL语句,使得连接操作和过滤条件能够在一次远程调用中完成。

      区分InitPlan与SubPlan

      InitPlan只会执行一次,并将其结果缓存起来。而SubPlan则可能会针对每一行外部数据都被执行多次。在FDW查询中,SubPlan通常会被用来驱动参数化的远程扫描操作。如果你发现某个SubPlan被用于嵌套循环中,进而触发对外部表的扫描操作,那么就很有可能存在参数化查询的情况;此时你应该想办法将这个SubPlan改写成InitPlan,或者将其合并到一次远程调用中,从而提高查询效率。

      理解CTE的物化机制

      公共表表达式(CTE)的行为会因其是否被标记为MATERIALIZEDNOT MATERIALIZED而有所不同。被物化的CTE会先被计算一次并存储在临时结构中,之后查询的其他部分会直接读取这个临时结果;而非物化的CTE则会直接内嵌到父查询中,这样就可以让优化措施在整个查询过程中发挥作用。

      在PostgreSQL 12及更高版本中,除非CTE被多次引用或明确标记为MATERIALIZED,否则它们默认会被内嵌到父查询中。如果一个CTE包含了远程数据扫描操作,那么将其物化可能会导致大规模的远程数据获取操作被阻塞,从而影响后续查询的执行效率;不过,当CTE被多次引用时,物化机制确实可以避免重复进行远程数据扫描。因此,请谨慎使用这一功能,以控制远程操作的执行位置。

      带注释的示例

      让我们来看一个来自真实查询计划的简化示例。这样做的目的是帮助大家更快地理解相关内容。

      嵌套的循环(循环次数:1,处理行数:414)
        -> 哈希连接操作(处理行数:416,循环次数:1)
             -> 对public.user_entity表进行1,循环次数:416)
                  远程SQL语句:FROM public.user_entity AND service_account_client_link NULL $1
        -> 对public.user_attribute表进行671,循环次数:1)
             远程SQL语句:FROM user_attribute ua ON ua.user_id = u.id ON u.tenant_id = r.id 'attribute A' 'demo' AND u.service_account_client_link NULL 'keycloak-group-a' $1)
      

      在原来的方案中,第一次“外部扫描”操作被执行了416次,每次只检索一行数据。“远程SQL”仅会对已启用的字段以及`service_account_client_link`应用过滤条件,并不考虑租户或组的范围限制。这些范围限制是由“外部扫描”之外的嵌套循环来处理的。

      在经过重构后的方案中,第二次“外部扫描”是通过将`user_attribute`、`user_entity`、`user_group_membership`、`keycloak_group`和`tenant`这些字段合并到一个远程查询中来完成的。这样一次查询就能检索到671行数据,并且会应用所有相关的过滤条件。因此不存在重复的远程调用操作。性能差异主要取决于循环中的处理逻辑以及“远程SQL”语句的选择性。

      如何优化postgres_fdw的性能

      在确保查询结构已经尽可能优化以便数据能够被高效地传输到目标服务器之后,还可以通过调整相关配置参数来进一步提升性能,并影响查询规划器的决策过程。

      fetch_size的设置

      fetch_size决定了`postgres_fdw`每次通过网络请求会检索多少行数据。默认值为100[9]。如果将`fetch_size`设置得较小,虽然每次请求需要更多的网络往返次数,但内存使用量会减少;相反,如果将`fetch_size`设置得较大,虽然会增加网络开销,但可以在内存中缓存更多数据,从而降低处理速度。

      在实际应用中,将`fetch_size`设置为几千行,可以显著减少处理大量数据集时的延迟。这个参数可以在外部服务器级别或外部表级别进行配置:

      ALTER SERVER foreign_server OPTIONS (ADD fetch_size ALTER TABLE remote_table OPTIONS (ADD fetch_size use_remote_estimate的用途
      

      默认情况下,查询规划器会使用本地数据统计信息来估算“外部扫描”操作的成本。但如果外部表中的数据分布与预期不同,这种估算方法可能会产生很大的误差。将`use_remote Estimate`设置为`true`,就会让`postgres_fdw`在远程服务器上执行`EXPLAIN`命令,以获取行数和成本估算值。虽然这样会在查询规划阶段增加一次远程请求操作,但这样做可以显著提高连接操作的效率[3]。这个参数可以在每个表或整个服务器上分别进行配置:

      ALTER OPTIONS (SET use_remote_estimate fdw_startup_cost与fdw_tuple_cost
      

      这些成本参数用于模拟执行远程扫描操作时所产生的开销,以及每次获取数据行所对应的成本。调整这些参数会影响规划器选择连接策略的方式。较高的fdwstartup_cost会使得规划器倾向于避免使用那些需要执行大量小型远程扫描操作的方案(因为这类操作可能会产生大量的远程调用请求);而较高的fdw_tuple_cost则会阻碍规划器选择那些需要获取大量数据行的方案[3]。只有在通过EXPLAIN命令获得可靠的分析结果,并经过充分实验验证之后,才建议使用这些参数。

      ANALYZE与analyzesampling选项

      对远程表执行ANALYZE操作时,系统会通过从远程表中抽取样本数据来收集本地统计信息[3]。当use_remote_estimate选项被设置为“false”时,准确的统计信息对于获得可靠的估算结果至关重要。

      然而,如果远程表的数据经常发生变化,这些统计信息很快就会变得失效。analyze_sampling选项用于控制采样操作是在远程端进行还是在本机端进行。当analyzesampling被设置为randomsystembernoulliauto时,ANALYZE会直接在远程端抽取样本数据,而不会将所有数据拉回到本地服务器[3]

      扩展功能

      extensions选项用于指定哪些扩展功能的函数和操作符可以被传输到远程服务器[2]。如果你需要使用citext、pg_trgm或其他扩展功能中的函数,就需要将它们添加到服务器配置中:

      ALTER SERVER foreign_server OPTIONS (SET extensions 快速调整参数的影响示意图
      
      参数名称 主要影响 何时需要调整 可能带来的负面影响
      fetch_size 每次获取的数据行数 当结果集规模较大时,延迟会成为主要影响因素;数值过大会消耗过多内存
      use_remote_estimate 有助于提高对数据行数量及成本的估算精度 但如果设置不当,可能会导致规划器错误估计远程扫描操作的耗时
      fdw_startup_cost 每次执行远程扫描操作所对应的成本 数值过高会促使规划器选择大量小型远程扫描操作,从而影响查询效率
      fdw_tuple_cost 每获取一行数据所对应的成本 数值设置不合理可能会导致规划器拉取过多数据
      extensions 哪些扩展功能的函数可以被传输到远程服务器 如果在查询条件中使用了这些扩展功能,那么确保这两台服务器上都安装了相应的扩展模块

      模式与索引建议

      “下推操作”并不会消除对高质量索引的需求。事实上,能否有效实现下推操作,取决于远程服务器是否拥有能够支持你所使用的过滤条件和连接条件的索引。

      以下是一些在FDW查询中需要注意的模式,以及相应的支持这些模式的索引。你可以根据自己的数据结构对这些模式进行调整。

      表名 访问方式 推荐使用的索引 原因
      tenant (远程) 按tenant.name进行过滤 UNIQUE (name)或BTREE (name) 能够快速匹配tenant ID
      keycloak_group (远程) 按名称过滤,通过tenant_id连接,再根据parent_group进一步过滤 复合索引(tenant_id, name)和(parent_group) 支持解析根组并遍历一级层次结构
      user_group_membership (远程) 通过user_id连接,按group_id过滤 BTREE (group_id, user_id) 能够高效地在多个组中查找用户
      user_attribute (远程) 按名称过滤,通过user_id连接 复合索引(name, user_id)(可选:包含值字段) 符合“属性名 → 用户 → 值”这种数据访问逻辑
      user_entity (远程) 按tenant_id、enabled字段过滤,同时满足service_account_client_link IS NULL条件,然后通过id连接 在(tenant_id, id)上创建部分索引,并添加enabled和service_account_client_link IS NULL作为筛选条件 当应用了tenant和user过滤条件时,有助于远程规划器从用户表开始执行查询
      filtercategory (本地) 按category和uuid[]进行过滤,然后通过(entitytype, entityid)连接 在category上使用GIN索引,在(entitytype, entityid)上使用BTREE索引 能够加快数组重叠检查及连接操作的效率

      一般来说,索引应当反映你期望远程规划器使用的连接顺序。如果你的Remote SQL查询语句如下:

      FROM user_attribute ua JOIN user_entity u ON ua.user_id = u.id JOIN user_group_membership ugm ON...

      那么请确保user_attribute(user_id)user_groupmembership(user_id)这两个表上存在相应的索引。

      基准测试方法

      如果没有进行适当的测量,就很容易声称性能有所提升。下面是一种可重复使用的基准测试方法,可用于评估FDW查询语句的变化所带来的性能影响。

      1. 预热缓存。先运行每个查询语句一次,以便将数据加载到远程缓冲区和本地的FDW连接中。这次运行的时间可以忽略不计。

      2. 测量延迟时间。使用EXPLAIN (ANALYZE, BUFFERS, VERBOSE)来记录执行时间、缓冲区使用情况以及远程服务器返回的行数。需要注意的是,EXPLAIN ANALYZE会增加一定的性能开销,因此如果可能的话,直接运行查询语句来获取原始的执行时间会更加准确。

      3. 记录远程服务器的统计信息。在远程服务器上启用pg_stat_statements功能,记录每个查询语句的调用次数、总执行时间和返回的行数。这样就可以了解每个查询的具体执行情况了。

      4. 控制并发请求和网络延迟的影响。在网络环境较为稳定的时间段进行基准测试,或者将测试集群隔离出来进行测试。如果你的环境中存在较高的网络延迟,建议单独记录往返传输时间,以便准确分析延迟的原因。

      5. 确保比较条件的一致性。在完全相同的条件下对旧版本和新版本的查询语句进行基准测试。必须使用相同的样本数据、相同的远程服务器以及相同的连接设置。

      6. 关注返回的行数。

        “下推操作”的主要目的就是减少需要传输的数据量,因此请对比每个涉及Foreign Scan操作的节点返回的行数。

      以下是一个简单的矩阵,你可以用它来记录自己的实验结果:

      测试场景 你要检测的内容 远程SQL中预期的变化 需要记录的指标
      基准情况(旧查询方式) 初始状态:进行大规模远程扫描操作并结合本地连接操作 远程SQL中缺乏范围限定条件 p50/p95延迟时间、远程记录的行数、本地排序/哈希处理所需时间
      优化后的情况(新查询方式) 将连接操作和过滤条件下推到远程端执行 远程SQL中包含了连接操作和过滤条件 相同的指标,再加上远程记录的行数
      引入易变函数的情况 测试这种操作对查询性能的影响 相关子句从远程SQL中移除 远程记录的行数会增加,本地过滤操作的耗时也会增加
      类型或排序规则不匹配的情况 检测这种情况可能带来的语义问题 远程SQL的行为可能会发生变化,或者无法正常实现数据下推操作 需要比较查询结果的正确性以及记录的行数是否一致
      ORDER BY/LIMIT语句的下推效果 这种测试依赖于具体的版本设置 远程SQL中会包含ORDER BY和LIMIT语句 排序操作会在远程端执行,但记录的行数应该保持不变
      是否启用use_remote_estimate功能 测试这一功能对查询计划制定的影响 如果启用了该功能,规划器会使用远程端的估算数据 需要比较使用该功能前后,查询的规划时间、连接顺序以及实际执行时间是否有差异

      监控与日志记录

      在生产环境中,你需要及时发现哪些查询开始出现异常行为。有两个地方可以查看相关信息:本地服务器和远程服务器。

      本地指标

      1. pg_stat_statements。这个扩展工具可以记录每个查询的规划时间、执行时间、记录的行数以及缓冲区使用情况。需要注意那些总执行时间相对于记录的行数或调用次数来说过长的查询。

      2. Auto Explain或auto_explain。auto_explain.log_min_duration_statement设置为开启状态,就可以捕获那些执行速度较慢的查询及其对应的规划方案。这样你就能看到远程SQL的具体执行内容,以及规划方案是否发生了变化。

      3. 连接池相关指标。需要监控与FDW操作相关的连接数和等待事件(例如PostgresFdwConnect、PostgresFdwGetResult等),具体操作方法请参考文档[10]

      远程指标

      1. 在远程服务器上使用pg_stat_statements。通过这个工具,你可以了解哪些远程SQL查询正在被执行、它们的执行频率以及耗时情况。将这些信息与本地EXPLAIN计划中显示的远程SQL语句进行对比分析。

      2. 服务器日志。在远程服务器上将log_statementlog_min_duration_statement设置为开启状态,这样就可以记录那些执行时间较长的远程查询。

      通过对比本地和远程的指标数据,你可以发现一些规律:比如新的代码路径是否会导致远程查询量激增,或者数据下推操作是否出现故障,进而导致远程端需要执行大量的扫描操作。

      案例研究:重构Keycloak的覆盖率查询

      上述理论在实践中应用之前可能会显得有些抽象。让我们通过一个受Keycloak集成启发的实际例子来详细说明这一过程。

      最初的查询用于计算覆盖率:给定一系列类别ID,该查询会返回那些属性与这些类别相关联的用户所占的比例,并同时返回一个包含实体计数的JSON数组。该查询使用了CTE来构建用户列表,然后将其与用户属性、类别映射关系以及其他一些表格进行连接。

      存在的问题

      在拥有10万条用户记录的测试环境中,这个查询的平均执行时间为166毫秒,这一结果比预期的要慢。通过运行EXPLAIN (ANALYZE, BUFFERS, VERBOSE)命令,我们发现Keycloak数据库中发生了两次外部扫描操作:第一次扫描了user_entity表416次;第二次则从user_attribute表中检索出了所有名称为“attributeA”的记录,之后再在本地根据租户和组别条件进行过滤。

      以下是简化后的查询代码片段(数值仅供参考):

      外部扫描 操作在 public.user_entity 表上执行 (实际耗时 0.117..00117 秒,扫描行数 1,循环次数 416)  
        远程SQL语句:SELECT id, tenant_id public.user_entity AND service_account_client_link NULL $1)
      外部扫描 操作在 public.user_attribute 表上执行 (实际耗时 41.267.80,739,循环次数 1)  
        远程SQL语句:value, user_id public.user_attribute "attributeA" = name)

      第一次扫描操作实际上进行了416次单行查询;第二次扫描则检索出了80,739条记录,因为唯一的过滤条件就是“name = ‘attributeA’”。租户和组别的筛选操作是在本地完成的,这意味着有8万条记录被传输到网络中,然后在本地被过滤掉,最终只剩下大约671条记录。

      诊断

      主要存在两个问题。

      第一个问题是关于用户实体的N+1次远程调用。由于与user_entity的连接操作没有在底层进行优化,因此查询计划会对user_group_membership表中的每一行都执行远程查找操作,这样就产生了416次远程查询。

      第二个问题是属性获取时范围界定不当。因为在更高级别的CTE中,WHERE子句包含了user_entity.tenant_id = tenant.idkeycloak_group.name = 'groupA'这些条件,所以FDW在扫描user_attribute表时无法识别这些筛选条件。因此,它检索出了所有名称为attributeA的记录,而将租户和组别的过滤条件留在了本地处理阶段。

      重构

      为了解决这些问题,我们需要将涉及租户和组的连接操作直接嵌入到查询中,以避免嵌套循环结构。经过重构后的selected_user_attributes CTE如下所示(为便于阅读已进行了简化):

      WITH selected_user_attributes AS (
        SELECT DISTINCT ua.user_id, ua.value
        FROM public.user_attribute ua
        JOIN public.user_entity u ON u.id = ua.user_id
        JOIN public.user_groupmembership ugm ON ugm.user_id = u.id
        JOIN public.keycloak_group g ON g.id = ugm.group_id
        JOIN public.tenant r ON r.id = u.tenant_id
        WHERE ua.name = 'attributeA'
          AND u.enabled
          AND u.service_account_client_link IS NULL
          AND r.name = 'tenantA'
          AND (g.name = OR g.parent_group = (
               SELECT id FROM public.keycloak_group WHERE name = AND tenant_id= r.id
          ))
      )
      )

      这个查询语句表达了之前分散在多个CTE中的相同逻辑。由于所有的连接操作都在同一台远程服务器上完成,并且使用了内置的连接操作符,因此FDW能够将整个连接过程优化到底层。重构后的查询计划如下所示:

      外部扫描(实际耗时 7.840..7.856 毫秒,共扫描了 671 行数据,执行了 1 次循环操作)
      远程 SQL 查询语句如下:SELECT ua.user_id, ua.value JOIN user_entity u JOIN user_groupmembership ugm JOIN keycloak_group g JOIN tenant r WHERE ua.name = 'attributeA' AND u.service_account_client_link IS NULL AND (g.name = 'groupA' 性能提升的原因
      
      1. 减少跨网络传输的数据量。旧方案会获取 80,000 行属性数据并在本地进行过滤,而新方案仅获取了 671 行符合条件的数据。

      2. 减少了本地计算负担。由于连接和过滤操作都在远程服务器上完成,因此本地系统无需处理大量数据。

      关键要点

      如果发现某个外部扫描操作的循环次数过多,或者远程 SQL 查询语句中包含了不必要的过滤条件和连接操作,那么这些因素都会影响性能。将所有的过滤条件和连接操作合并成一次远程查询(前提是符合部署规则),通常能够显著提升性能。

      检查清单与故障排除指南

      以下步骤总结了进行 FDW 性能调优的方法:

      1. 检查远程SQL语句。**始终运行EXPLAIN (VERBOSE)命令,查看实际发送到远程服务器的查询内容。如果查询中的某些条件判断语句被遗漏了,那么FDW就不会将这些语句推送到远程服务器进行处理。

      2. 检查循环结构。**在进行外部扫描操作时,如果循环次数超过1次,就会导致重复的远程调用,从而增加性能开销。请重新编写查询语句或调整连接顺序,以确保外部扫描只执行一次。

      3. 确保条件判断语句能够被推送到远程服务器。**将易变的函数替换为常量或参数,并确认所使用的运算符和函数是内置的,或者通过扩展选项明确允许其在远程服务器上使用[2]

      4. 保持数据类型和排序规则的统一性。**在本地服务器和远程服务器上使用相同的数据类型和排序规则,以避免出现语义上的差异[1]

      5. 将跨服务器的连接操作集中在同一台服务器上。**如果可能的话,应该将涉及多台服务器的连接操作合并到同一台远程服务器上。因为跨服务器的连接操作无法被推送到远程服务器进行处理[6]

      6. 当查询计划似乎不合理时,使用use_remote_estimate选项。**启用远程估计功能可以帮助优化连接顺序的选择[3]

      7. 调整fetch_size参数及相关成本设置**。如果查询需要传输大量数据,增大fetch_size值可以减少数据传输的往返次数;同时,通过调整fdw_startup_costfdw_tuple_cost参数,也可以影响查询优化器的决策过程[3]

      8. 定期分析远程表的结构。**如果你的查询依赖于本地提供的成本估算信息,那么需要定期分析远程表的数据结构,因为这些统计信息可能会很快变得过时[3]

      9. 监控两台服务器的运行状况。**在本地服务器和远程服务器上使用pg_stat_statements命令,了解远程查询的执行频率及耗时情况。

      10. 测试版本升级带来的影响。**每次重大版本更新都会优化FDW的功能机制。例如,在10.0版本中,聚合操作得到了改进[7];在12.0版本中,ORDER/LIMIT语句的优化效果更加明显[8]。升级后请务必重新进行测试。

      案例研究总结

      如果遵循PostgreSQL的postgres_fdw的相关机制,使用它来查询远程数据会变得快速且方便。所谓“下推处理”,其实就是指将只有部分相关的数据传输到本地服务器,而不是将大量数据全部通过网络传输。这不仅仅涉及CPU资源的消耗,还会影响数据传输量、网络往返次数以及本地服务器需要执行的操作。

      这些规则看起来可能有些限制性——比如只能使用不可变函数、避免跨服务器连接、确保类型和排序规则一致等等——但它们的存在正是为了在保证数据正确性的同时实现优化。

      通过从底层开始分析EXPLAIN结果、仔细检查远程SQL语句,并理解这些规则,你可以迅速发现那些导致性能低下的问题。利用fetch_sizeuse_remote_estimate这样的调优参数,以及重新编写查询语句以使连接操作和过滤条件能够被“下推”到远程服务器进行处理,你往往能够在不更换硬件的情况下显著提升性能。

      这个案例说明,通过重新编写查询语句,使得只需要进行一次连接的远程查询成为可能,从而将运行时间从大约166毫秒缩短到了25毫秒。这样的优化效果并不罕见,因为当你把FDW查询视为分布式查询而不是伪装成本地查询的程序时,这种优化效果就会显现出来。

      下次当你调试那些运行缓慢的FDW查询时,请记得参考这份手册。检查远程SQL语句,数一数其中包含了多少循环结构,然后问问自己:“我是将处理任务放在靠近数据的地方进行,还是把数据拖到需要处理的地方来?”根据这些分析结果做出相应的调整,你就能编写出既能充分利用Postgres的联合处理能力,又能有效控制延迟时间的查询语句。

      这一部分总结了本案例研究中的关键变化,以及这些变化为何能带来如此显著的性能提升。手册的后续章节会将这种优化方法转化为可复用的技巧:比如Postgres是如何判断哪些操作可以“下推”到远程服务器处理的,如何快速解读FDW查询计划,哪些操作和版本是重要的,以及如何排查那些阻碍数据下推处理的常见问题。

      高级操作:深入探讨数据下推机制

      前几部分介绍了关于哪些操作可以“下推”到远程服务器以及为什么可以这样做的基本规则。要想真正理解这些规则,就需要看看它们在实际应用中是如何发挥作用的。

      这一部分会更详细地讲解过滤操作、连接操作、聚合操作、排序操作、限制操作、DISTINCT查询以及窗口函数等内容。读完这部分内容后,你应该能够清楚地知道在阅读查询计划时,哪些操作是可以放心使用的,哪些需要仔细核对。

      过滤条件与简单谓词

      WHERE子句的重要性远超你的想象

      当你在外键表上指定WHERE attribute = 'value'这样的条件时,只要比较操作使用的类型是内置类型,并且使用的运算符也是不可变的,FDW就会将这些条件直接传输到远程服务器。例如:

      • WHERE id = 42这种写法是可行的。

      • 如果lower()函数被允许使用且其返回值是不可变的,那么WHERE lower(username) = 'hamdaan'这种写法也是可行的。

      • WHERE created_at >= now() - interval '7 days'这种写法是不可行的,因为now()函数的返回值是不稳定的。

      当这样的查询条件无法被推送到远程服务器时,FDW会获取所有符合这些条件的记录,然后在这些本地记录上执行剩余的查询操作。这意味着,一个看似无害的now()调用实际上可能会导致网络流量急剧增加。

      因此,一个简单的解决办法是:在应用程序中或使用CTE提前计算出那些不稳定的值,然后在针对外部表的查询中将这些值作为常量来使用。

      复杂的表达式并不一定是不安全的

      假设你有这样的查询条件:WHERE (status = 'active' AND (age BETWEEN 18 AND 29 OR age > 65))。由于这个表达式使用了内置的布尔逻辑、简单的比较操作以及不可变的运算符,因此它是可以被推送到远程服务器进行处理的。只有当其中某个子表达式包含了FDW无法识别或无法确保在远程服务器上存在的函数或运算符时,你才需要担心安全问题。

      一个很好的判断标准是:如果你能够仅使用简单的比较操作、布尔逻辑以及内置函数来表达你的过滤条件,那么这种查询方式就应该是可以被推送到远程服务器的。如果有疑问,可以直接查看生成的远程SQL语句。

      数组和JSON运算符

      现代版的Postgres大量使用了数组和JSON相关的功能。其中许多函数都是内置的,因此可以被直接用于查询中;不过也有一些JSON函数是需要通过扩展模块来提供的(比如jsonb_path_querypgjson系列中的函数)。

      如果你的过滤条件中使用了这些扩展模块提供的函数,那么请确保这些扩展模块在远程服务器上已经安装并且被允许使用。否则,FDW会先在本地获取数据,然后再执行相关的JSON逻辑操作。而在处理大型JSON字段时,这种情况通常是人们不希望看到的。

      连接操作:好的、坏的、以及那些不太理想的方案

      在同一台服务器上进行的连接操作是非常有用的

      如果你要连接的多个外部表都位于同一台服务器上,并且连接条件只使用了可以被推送到远程服务器的查询表达式,那么FDW就可以生成一个简单的远程连接请求。这当然是最理想的情况。

      例如,如果订单表和客户表都存储在同一台服务器上,并且它们的连接条件是orders.customer_id = customers.id,那么这种查询是可以被推送到远程服务器的。远程服务器会利用自己的统计信息和索引来优化连接操作,而本地服务器则只需遍历查询结果即可。Postgres 9.6及更高版本都支持这种连接方式[6]

      跨服务器连接操作会阻碍数据推导过程

      如果你尝试将位于不同服务器上的两个外部表进行连接(或者即使它们在同一台远程服务器上,但用户映射信息不同),postgres_fdw也会分别获取这两个表的数据,然后在本地进行连接操作。这种处理方式几乎总是比直接在远程服务器上进行连接要慢,因为最终需要传输整个表的结构。

      FDW开发团队决定不支持跨服务器连接功能,因为目前没有一种通用的方法可以让两台远程服务器协同完成连接操作。因此,你可以选择以下几种方案:将其中一个表复制到另一台服务器上;在连接之前先在本地生成较小的数据表;或者重新设计查询语句,在进行本地连接之前先对数据进行筛选。

      混合类型的本地/外部表连接操作很复杂

      将本地表与外部表进行连接时,由于远程服务器无法访问本地数据,因此这种连接操作无法在远程服务器上完成。以下是一个常见的会导致频繁远程调用的查询示例:

      SELECT u.id, a.value
      LEFT JOIN user_attribute a
        AND a.name = 'favorite_color';
      

      如果users是本地表,而user_attribute是外部表,那么查询计划可能会使用嵌套循环结构:对于每一条本地记录,系统都会在远程服务器上查找对应的user_attribute记录。

      解决这个问题的方法是调整查询语句的顺序:先在远程服务器上一次性检索出所有相关的user_attribute记录,然后再在本地进行连接操作。或者,如果条件允许,可以在远程服务器上创建一个包含u.id值的小临时表,然后完全在远程服务器上进行连接操作,最后获取结果。

      连接条件非常重要

      即使是在同一台服务器上连接两个外部表,不合适的连接条件也会导致连接操作必须在本地完成。例如,使用JOIN ON textcol ILIKE '%foo%'这样的连接语句是无法在远程服务器上执行的,因为远程服务器可能没有ILIKE函数,或者该函数的实现方式与本地不同。

      如果需要不区分大小写的匹配操作,可以考虑将查询中的字段值都转换为小写形式:例如LOWER(textcol) = 'foo'(前提是远程服务器支持lower()函数)。同样地,如果连接条件涉及到类型转换,比如JOIN ON CAST(a.id AS text) = b.text_id,这种连接方式也会阻碍数据推导过程的执行。在这种情况下,应该为相关字段定义相同的数据类型。

      聚合操作与分组处理

      在数据传输效率方面,聚合操作确实能够发挥重要作用。当你可以将GROUP BY子句以及COUNTSUMAVGMAX等聚合函数直接应用于远程服务器时,就可以大大减少需要传输的数据量,从而提高查询效率。这种优化效果有时甚至可以达到几个数量级。

      Postgres 10引入了聚合函数的“下推”机制[7]。但并非所有的聚合函数都适合使用这种机制:

      简单的聚合函数,如COUNT(*)SUM(col)AVG(col)MIN(col)MAX(col),当它们应用于适合被下推的表达式时,就可以使用这种机制。甚至COUNT(DISTINCT col)也常常可以被下推,因为远程服务器可以在计数之前对数据进行去重处理。FDW会将这些聚合函数包装成远程查询,并只返回最终的结果。

      如果在本地侧看到GroupAggregate节点,就需要检查所有涉及的列和函数是否都适合被下推。如果它们确实适合被下推,还要确保上述的连接条件也可以被下推。

      带过滤条件的聚合函数,如COUNT(*) FILTER (WHERE x > 5)SUM(col) FILTER (WHERE status = 'active'),通常也可以被下推,因为它们可以转化为SUM(CASE WHEN condition THEN col ELSE 0 END)COUNT(...)这样的形式。只要过滤条件本身适合被下推,FDW就会将其一起下推到远程服务器进行处理。

      用户自定义的聚合函数很少适合被下推。如果你有自己定义的聚合函数,FDW不会假定该函数在远程服务器上同样可用或具有相同的功能。即使你在两台服务器上都安装了该函数,postgres_fdw也不会自动将其下推,除非该函数属于允许被下推的扩展模块。

      分组集和汇总操作目前还不适合被下推。当你编写GROUP BY GROUPING SETS (...)ROLLUP(...)这样的语句时,Postgres会本地进行分组计算,即使底层的数据扫描是在远程服务器上进行的。

      如果你需要复杂的汇总结果,可以考虑分两步来完成:首先将初始的分组操作下推到远程服务器以减少数据量,然后再在本地进行汇总计算。

      ORDER BY、LIMIT和DISTINCT

      排序和限制查询结果的数量看似只是些辅助功能,但实际上它们会影响到数据传输的量。如果远程服务器能够完成排序和限制操作,那么本地服务器就只需要接收前N条记录;但如果无法完成这些操作,本地服务器就必须对所有数据进行排序。

      Postgres 12扩展了适合使用ORDER BY和LIMIT进行下推的情况[8]。以下是一些使用指南:

      • 针对单个外部表的简单排序操作:如果你的查询是从一个外部表中获取数据,并且使用了适合被下推的排序条件(例如ORDER BY created_at DESC),FDW会将ORDER BY语句包含在远程SQL中,同时也会将LIMITOFFSET一起下推。这种处理方式非常理想,因为远程服务器会完成排序工作,然后只返回前几条记录。

      • 连接操作后的排序:如果你在同一个服务器上连接了两个外部表之后再进行排序,并且这些排序条件适合被下推,FDW可能会将它们一起下推。但是,如果排序需要使用本地侧的列或另一个远程服务器上的数据,FDW就无法将这部分逻辑下推。

      • 聚合操作后的排序:只要聚合函数本身适合被下推,对聚合结果进行排序通常也是可以的。不过,当分组操作在本地进行时,排序操作也会保持在本地完成。

      • DISTINCT与GROUP BY的行为类似:如果用于区分重复记录的表达式列表适合被下推,FDW就可以将其一起下推。例如,当你编写SELECT DISTINCT ON (col1) col2, col3 FROM...这样的语句时,如果col3不在DISTINCT列表中,Postgres会将其视为GROUP BY操作,并可能将其下推。需要注意的是,DISTINCT ON的语法与普通的DISTINCT有所不同,在较旧版本的Postgres中可能无法被下推。

      窗口函数

      窗口函数(例如ROW_NUMBER() OVER (PARTITION BY ...), RANK(), LAG(), LEAD())依赖于对数据进行排序和分区处理。

      Postgres目前尚未教会postgres_fdw如何执行窗口函数的推导操作。当你在查询计划中看到WindowAgg节点时,几乎可以肯定这些操作是在本地进行的:FDW会获取相关数据,然后由本地服务器负责排序、分区和计算窗口函数的结果。如果你需要对远程数据应用窗口函数,那么就需要先将数据传输到本地再进行处理。

      版本特定的特性

      不同版本的Postgres在支持窗口函数的推导功能方面存在差异。在规划数据库迁移或决定是否依赖这些推导功能时,请务必查阅相应的版本说明:

      • 9.6版本:首个支持对连接操作、排序操作以及远程更新/删除操作进行推导处理的版本。

      • 10版本:引入了对聚合函数的推导支持[7],这大大减少了执行GROUP BY查询时所需的网络资源消耗。

      • 11版本:改进了对外部表的分区优化机制以及连接操作的排序逻辑。

      • 12版本:进一步扩展了对ORDER BYLIMIT语句的推导支持[8]

      • 15版本:新增了对简单CASE表达式以及某些内置函数的推导支持。

      • 17版本:(在撰写本文时仍在开发中)继续扩展可被实际使用的功能。请务必在目标版本上进行测试,因为一些细微的改进可能会影响FDW的实际处理能力。

      常见的不良实践及避免方法

      很多人都遇到过这样的情况:某些使用FDW的查询看似合理,但实际上却成为了性能瓶颈。以下是一些最常见的错误及其解决方法。这些示例经过简化处理,因此你可以根据自己的数据库架构进行相应的调整。

      在谓词中使用易变函数

      不良实践:

      SELECT *
      FROM audit_logs
      WHERE event_ts >= now() - interval SELECT *
      FROM audit_logs
      WHERE event_ts >= $1;
      

      可以在您的应用程序中或在上游查询中计算出 `$1` 这个时间戳值。或者,也可以在 CTE 中一次性计算出来:

      WITH cutoff AS (SELECT now() - interval AS ts) SELECT * FROM audit_logs, cutoff WHERE event_ts >= cutoff.ts;
      

      FDW 会识别出这个常量,并将其直接用于后续的查询中。

      先连接本地数据与外部数据

      反面示例:

      SELECT u.email, ua.value
      LEFT JOIN user_attribute ua AND ua.name = 'favorite_movie';
      

      这种写法会使用本地表 `users` 来连接外部表 `user_attribute`。如果 `users` 表中有 10,000 条记录,那么 FDW 将收到 10,000 次单独的远程查询请求;每次查询都会从 `user_attribute` 表中获取零条或一条记录。

      更好的做法:

      -- 先从远程服务器获取所有用户喜欢的电影信息,然后再在本地进行连接
      WITH remote_movies SELECT ua.user_id, ua.value
        WHERE ua.name = 'favorite_movie'
      )
      SELECT u.email, rm.value
      LEFT JOIN remote_movies rm 不进行数据物化处理的跨服务器连接
      

      反面示例:

      SELECT *
      JOIN remote_db2.customers c 在连接键上使用复杂表达式
      

      不良做法:

      SELECT *
      FROM remote_table a
      JOIN remote_table b ON CAST(a.key AS text) = b.key_text;
      

      将数值类型的键转换为文本类型会阻止数据在远程服务器上被直接处理。此时远程服务器无法使用索引,因此必须返回两张表的所有数据,而本地服务器才会负责执行连接操作及类型转换。

      更好的做法:确保两个表的连接列具有相同的类型。如果无法修改数据库架构,可以在远程服务器上创建一个类型合适的计算列,并在连接操作中使用它。

      忽略排序规则和类型不匹配的问题

      不良做法:

      SELECT *
      FROM remote_table
      WHERE citext_col = 进一步优化调整:校准成本模型
      

      之前我们讨论过fetch_sizeuse_remote_estimate以及各种用于调节性能的参数。这一部分将详细介绍如何策略性地使用这些参数。

      平衡取数数量与内存消耗

      fetch_size决定了FDW在每次数据传输过程中会请求获取多少行数据[9]。可以将其理解为批量处理的大小。默认值(100)对于处理少量结果集来说效果不错;但如果需要检索数万条记录,较大的取数数量可以有效减少网络请求带来的开销。不过这样做也会带来一些权衡:

      • 内存消耗:每当有数据被读取时,系统都会为这些数据分配内存空间。如果取数数量过大(例如10,000条记录),尤其是当多个查询同时进行时,可能会导致内存使用量急剧增加。因此,在调整这个参数时需要密切关注系统的内存使用情况。

      • 延迟掩盖效应:当网络延迟较高时,如果多次网络请求与本地处理操作能够重叠进行,那么部分延迟可能会被掩盖掉。但是postgres_fdw并不会同时执行多个数据获取操作——它会在完成一批数据的处理后再请求下一批数据。因此,较大的批量处理规模虽然可以减少等待次数,却无法完全消除网络延迟的影响。如果需要在不同的数据中心之间进行数据传输,建议考虑使用连接池或缓存机制,而不仅仅是单纯增加fetch_size的值。

      远程估计与本地估计

      规划器会利用统计数据来估算每个节点将生成多少行数据,而这会直接影响联接操作的顺序。当use_remote_estimate的值为false时(即默认设置),规划器会根据ANALYZE命令对外部表收集到的本地统计数据来进行估算。但如果远程表的数据分布与本地样本不同,或者自上次执行ANALYZE命令以来该表的结构发生了变化,那么这种估算结果就可能不准确。

      use_remote_estimate设置为true,会指示FDW在规划阶段在远程服务器上执行EXPLAIN命令,以获取行数统计信息和成本估算值[3]。这样做有助于优化联接操作的顺序,尤其是在连接多个外部表或同时使用本地表和外部表时。不过,这样的设置会增加规划时间,因为每次进行远程估算都需要执行额外的查询操作。

      在实际应用中:

      • 对于那些联接操作比较复杂、且规划器可能选择错误的联接顺序的查询,建议启用use_remote_estimate。如果启用该选项后能够优化查询计划,那么就可以在该服务器或该表上持续使用这一设置。

      • 如果你的远程数据变化不大,建议定期对外部表执行ANALYZE命令。这样可以帮助生成本地统计数据,从而避免依赖远程估算带来的性能开销。

      • 对于简单的查询操作,不要盲目启用use_remote_estimate。因为额外的远程通信操作所带来的成本可能会超过其带来的好处。

      调整成本参数

      fdw_startup_costfdw_tuple_cost这两个参数用于控制规划器认为执行远程扫描以及获取每行数据所需的成本[3]。如果这些参数设置得过低,规划器可能会选择使用嵌套循环来获取数据,从而导致大量的远程调用操作;而如果这些参数设置得过高,即使远程扫描操作效率较高,规划器也可能避免执行这些操作。

      你可以根据实际测试结果来调整这些参数:

      • 适当增加fdw_startup_cost的值,这样就可以阻止规划器使用那些需要反复访问远程表的嵌套循环。你可以将这个参数设置为一次远程通信操作的平均成本。

      • 如果网络带宽有限或使用成本较高,可以增加fdw_tuple_cost的值。这样就能让规划器意识到,获取每一行远程数据所需的成本要比获取本地数据高,因此规划器会优先选择在远程端进行早期过滤操作的查询计划。

      调整这些设置时,请务必逐步进行,并观察其对查询计划的影响。如果不同服务器的网络环境有所不同,那么应为每台服务器分别设置不同的参数值。

      何时分析外部表

      在远程表上运行ANALYZE命令时,会从远程服务器中抽取一部分数据来收集样本统计信息。这样,在use_remote_estimate选项被关闭的情况下,规划器就能更准确地估算行数;同时,这也能够帮助决策是否应该在远程端使用索引。以下情况下,你应该对远程表进行分析:

      • 如果远程表的数据量很大且内容固定不变,那么你希望获得准确的本地统计结果,而不想承受远程计算带来的开销。

      • 当你刚刚定义了一个远程表,而它的默认统计信息还为空时,也需要进行分析。

      • 如果你修改了配置选项以允许更多的数据推导操作,那么为了让规划器能够看到这些变化带来的影响,也需要对远程表进行分析。

      相反,如果远程数据在不断变化,那么ANALYZE产生的统计结果很快就会变得失效。在这种情况下,就应该使用use_remote_estimate选项。

      更多案例分析与实际应用示例

      Keycloak示例并不是唯一一个需要关注数据推导机制的场景。以下情况进一步说明了你可能会遇到的其他应用场景。

      在分片日志系统中进行报告分析

      假设你的应用程序日志分散存储在多个分片中,每个分片都对应一个独立的Postgres数据库。此时,如果你想要生成一份按服务类型和日期统计的错误日志数量的报告,那么可以采用以下方法:

      一种简单的做法是使用一条查询语句来合并所有分片中的数据:

      SELECT shard, service, date_trunc('day', log_time) AS day, COUNT(*)
      UNION SELECT shard, service, date_trunc('day', log_time) AS day, COUNT(*)
      SELECT shard, service, day, sum(count)
      SELECT 1 'day', log_time) AS count
        WHERE log_time >= $1 
        BY service, day
        ALL
        2 'day', log_time) FROM shard2.logs
         
        BY service, day
        ...
      ) x
      BY shard, service, day;
      

      在这里,每个远程服务器返回的都是一组经过汇总的数据行,而不是原始日志。外层的汇总操作是在各个分片上进行的。这种处理模式具有普遍性:先将分组和过滤操作放在远程端进行,然后再在本地进行合并。

      结合远程数据和本地数据进行分析

      假设你有一个本地的users表和一个远程的orders表,你想计算每个用户群体的平均订单金额。一个简单的查询语句可能如下所示:

      SELECT usegment, AVG(o.amount)
      JOIN orders o GROUP WITH remotetotals SELECT user_id, SUM(amount) AS n
        GROUP SELECT u.segment, AVG(rt.total / rt.n)
      JOIN remotetotals rt GROUP 为保证正确性,应避免数据下推操作
      

      在某些情况下,由于语义上的差异,你确实应该阻止数据下推操作。Postgres提供了相应的手段来实现这一点,例如可以在查询语句中添加OFFSET 0,或者将远程表放在CTE中。

      例如,如果某个内置函数在远程服务器上的运行结果与本地不同,那你可以强制在本地进行该函数的计算:

      WITH local_eval SELECT  -- CTE可以防止数据下推
      FROM local_eval
      0;
      

      另外,像random() < 0.1这样的WHERE条件也不会导致数据下推,因为random()函数的计算结果具有不确定性,所以没有必要强制在远程端进行计算。不过,添加OFFSET 0这个简单的技巧也可以有效防止数据下推操作。

      SELECT * FROM remote_table OFFSET 0;
      

      了解如何故意禁用推导功能有助于你进行故障排除。如果在启用推导功能后查询结果发生了变化,那么很可能是因为类型或排序规则不匹配,或者是远程会话的设置出了问题[4]

      监控、诊断与回归测试

      监控工作并不仅仅局限于统计远程服务器上的行数。为了确保在生产环境中推导功能的可靠性,你需要建立相应的机制来检测性能变化,并在出现异常时收集相关证据。

      自动化执行EXPLAIN回归测试

      除了单元测试和集成测试之外,你还可以添加一些专门用于验证查询执行计划的测试。例如,如果某个关键报告在执行过程中必须始终包含某个WHERE子句,那么你可以编写一个测试脚本,运行EXPLAIN (VERBOSE)命令,并检查远程服务器返回的SQL语句中是否确实包含了该过滤条件。你甚至可以解析查询中的循环结构,确认其逻辑是否正确。当开发人员不小心添加了可修改的函数或改变了查询连接方式时,这些测试就会失败,从而及时发现问题。这种做法类似于对SQL代码进行快照测试。

      跨服务器监控pg_stat_statements统计信息

      需要在本地服务器和远程服务器上都启用pg_stat_statements功能。在本地服务器上,要记录每个FDW查询所消耗的总时间、规划时间以及执行的行数;在远程服务器上,则需要跟踪哪些查询正在被执行。

      要注意那些异常情况:比如某些查询的远程调用次数突然激增,或者其执行的远程行数从几百突然增加到几千。这些往往是推导功能出现故障的早期迹象。

      使用auto_explain功能记录远程SQL语句

      auto_explain.log_min_duration_statement的值设置为500毫秒,可以让Postgres自动记录那些执行速度较慢的查询及其对应的执行计划。如果再同时启用auto_explain.logverbose = trueauto_explain.log_nested_statements = true选项,就能完整地捕获远程服务器执行的SQL语句了。当某个联接查询的性能下降时,这些日志会清楚地显示到底是哪些SQL语句被执行了,以及它们被执行的频率。在生产环境中,这种功能显得尤为宝贵,因为在这种情况下你并不总是能够交互式地运行EXPLAIN命令来查看查询细节。

      使用连接池和预编译语句

      postgres_fdw会根据用户映射关系维护一个连接池,在不同的查询之间复用这些连接。不过,你也可以在网络层面上使用连接池技术(例如通过pgbouncer或pgcat来实现)。

      保持连接处于活跃状态可以降低系统启动时的开销,这一效果可以通过fdw_startup_cost参数来体现。同时,在远程服务器上预编译查询语句(使用PREPAREEXECUTE命令),在需要频繁执行相同的查询时,能够显著节省解析时间。postgres_fdw还可以利用服务器端的预编译语句来进行参数化查询。

      版本升级后的回归测试

      Postgres的每一次重大版本更新都会对postgres_fdw的推导机制进行改进。但新的版本也会改变规划器的启发式算法以及远程SQL语句的生成方式。在升级后,请使用EXPLAIN(VERBOSE)重新运行那些关键的查询,对比生成的远程SQL语句,并对其进行性能测试。

      在某些情况下,新版本可能会将原本在本地执行的操作移到远程服务器上执行,这可能会导致类型不匹配或函数调用方式的不同。而在其他情况下,由于新的规则限制,某些操作可能仍然需要在本地完成。因此,不要认为升级后性能一定会得到提升——必须进行实际测试才能确定。

      针对高级数据库管理员的扩展指南

      作为本手册的结尾,这里汇总了一些从前面的章节中提炼出来的重要指导原则。这些指南不仅仅是简单的条目列表,它们更深入地探讨了各种细节。请将这些指南保存起来以供参考,或者为你的团队打印出来。

      1. 严格遵守FDW的安全模型。不可变函数和内置操作符是提升性能的关键工具。任何不在这个范围内的操作,都必须明确被允许在远程服务器上执行,或者需要在本地进行计算。要清楚了解哪些元素属于哪一类,并据此来设计查询语句。

      2. 务必查看生成的远程SQL语句。不要仅凭直觉来判断哪些操作被移到了远程服务器上执行。远程SQL语句才是判断真相的唯一依据,它能够说明是否进行了过滤、连接、排序或限制等操作,同时还能显示那些代表从本地计划中传递过来的参数的占位符(例如$1)。

      3. 在获取数据之前先进行优化处理。网络传输是性能提升的最大瓶颈。如果远程服务器可以通过过滤、分组或限制操作来减少需要传输的数据量,那就让它这样做;如果无法实现,那么就需要重新设计查询语句以便达到这一目的。避免那些需要从远程服务器下载大量原始数据并在本地进行处理的语句。

      4. 注意连接操作的顺序。有时规划器会选择使用外键表作为内层循环的参与方,这样就会导致多次远程调用。请仔细检查查询中的循环结构:如果发现循环次数过多,可以考虑重新编写查询语句或调整相关参数。

      5. 策略性地使用CTE。CTE可以帮助隔离那些需要在远程服务器上执行的操作,并让你能够控制这些操作是应该被物化存储还是直接内联到查询中。使用MATERIALIZED选项可以避免当CTE被多次引用时重复进行远程扫描;而使用NOT MATERIALIZED则可以让优化措施在CTE的不同部分之间生效。

      6. 进行监控、测试并不断调整。良好的FDW性能并非一次性的设置就能实现。需要持续监控查询语句和执行计划,通过测试来发现潜在的性能问题。随着数据量或工作负载的变化,及时调整优化参数和索引设置。同时,要详细记录自己的调整过程,以便其他人能够理解为什么选择这样的配置方案。

      7. 对团队进行培训。联邦查询结构容易隐藏一些错误或导致性能问题。向团队讲解一些基本的规则——例如只使用不可变函数、跨服务器的连接操作应在本地完成、务必检查远程SQL语句等——这样工程师们就会默认编写更安全的查询语句。花费30分钟的时间进行培训,以后就能节省大量的调试时间。

      整合所有内容

      这本手册涵盖了许多内容:从“推下操作其实是一种数据传输机制”这一高层次原则,到连接条件的具体细节以及调优方法,再到故障排除步骤和案例分析。本书在表述上带有明显的个人观点和主观判断——这些内容都是在实际系统中遇到的模式与陷阱,并非抽象的指导原则。通过分享具体的例子,我希望能让这些规则更容易被记住,并展示它们是如何与实际的工作负载相结合的。

      我们的目标不仅仅是告诉您该怎么做,更重要的是教会您如何思考、如何解决问题:仔细审查查询计划,追踪数据传输的过程,判断查询是否在正确的位置完成了繁重的工作。

      这种思考方式,只要经过足够的练习,就会成为您的本能。当您编写新的查询语句时,会自然而然地考虑这些条件是否具有不变性,连接操作是否可以高效进行,以及是否会引发“N+1”模式这样的问题。在审查查询计划时,你会从“外部扫描节点”和远程SQL语句开始分析,而不是从顶层节点入手。在进行调优时,你也會知道该调整哪些参数,以及调整的顺序。

      继续不断尝试吧。把这里的例子作为起点,在测试环境中尝试不同的结构,并测量它们之间的差异。您越是深入研究“推下操作”这一机制,就越能熟练掌握它的限制条件与优势。

      如果这本手册能够帮助您避免一次性能问题,或者让您免于发布有缺陷的查询语句,那么它就已经达到了预期的目的。请尽情探索Postgres这个联合数据库的世界吧。

      参考文献

      [1] [2] [3] [4] [5] [6] [9] [10] 《PostgreSQL文档:18章 F.38. postgres_fdw——访问存储在外部PostgreSQL服务器中的数据》(https://www.postgresql.org/docs/current/postgres-fdw.html)

      [7] PostgreSQL:发布说明(https://www.postgresql.org/docs/release/10.0/

      [8] PostgreSQL:发布说明(https://www.postgresql.org/docs/release/12.0/

Comments are closed.