FDW 性能优化
前面几节我们把 FDW 搭起来了,也能查出正确结果了。但"能查"和"查得快"之间往往差着几个数量级。一条跨库查询从 200ms 变成 20 秒,最常见的原因不是网络慢,而是该下推的条件没有下推、统计信息不准导致优化器选错计划、或者一次查询发了几万次网络往返。
本节专门讲 FDW 的性能。核心方法论只有一句话:让远端干尽可能多的活,让网络传尽可能少的行。围绕这句话,我们会讲透:
- 什么是下推(pushdown),哪些表达式是"可下推(shippable)"的;
- 如何用
EXPLAIN诊断下推有没有生效; - 三个关键估算参数(
fdw_startup_cost、fdw_tuple_cost、fetch_size)怎么调; - 统计信息为什么在 FDW 场景下格外重要;
- 连接管理、批量写入、异步与并行提交的使用时机;
- 以及"什么时候该承认 FDW 不合适,改走 ETL"。
前置知识
阅读本节前,建议先了解:其他第三方 FDW
基础概念
下推:FDW 性能的一切
下推(pushdown) 指:本地优化器把查询的一部分"翻译"成远端能执行的语句,让远端先算,只把结果传回本地。
无下推(最坏情况) 有下推(理想情况)
┌──────────────┐ ┌──────────────┐
│ 远端全表 1 亿行│ │ 远端全表 1 亿行│
└──────┬───────┘ └──────┬───────┘
│ 传输 1 亿行 │ 远端先过滤/聚合
│ (可能几 GB) │ → 只剩 200 行
▼ ▼
┌──────────────┐ ┌──────────────┐
│ 本地逐行过滤 │ │ 传输 200 行 │
│ 本地排序/聚合 │ │ (几 KB) │
└──────────────┘ └──────────────┘
耗时:数十秒 耗时:几十毫秒差距的来源很清楚:网络传输量。本地 CPU 再快,也顶不住把几 GB 数据从网络搬一遍。
可下推表达式(shippable expression)
并非所有 SQL 构造都能下推。一个表达式"可下推",意味着它能被安全地转换成远端等价语句。判断标准主要是:
- 用到的函数在远端也存在且语义一致;
- 用到的操作符在远端也存在且语义一致;
- 涉及的类型与排序规则在两端一致(否则比较语义可能不同)。
postgres_fdw 内置了一张"安全函数/操作符"清单,只下推它认为绝对安全的。扩展提供的函数(如 PostGIS)默认不在清单里——这就是 extensions 选项存在的意义。
下推能力的层级
不同下推类型的收益不同。按"收益从高到低"排:
| 下推类型 | 收益 | 说明 |
|---|---|---|
LIMIT / OFFSET | 极高 | 让远端只返回前 N 行,避免拉全表 |
聚集(GROUP BY + 聚合函数) | 极高 | 把百万行压成几十行再传输 |
WHERE 过滤 | 很高 | 减少传输行数 |
JOIN(同远端两表) | 很高 | 避免把两张大表都拉到本地 |
DISTINCT | 高 | 去重后传输量可能大幅下降 |
ORDER BY | 中 | 省掉本地排序,但传输量不变 |
表达式计算(如 upper(x)) | 低 | 省一点本地 CPU |
优化顺序建议:先确保 WHERE、LIMIT、聚集能下推;其次是 JOIN;最后才关心 ORDER BY 和表达式。
FDW 查询的三个性能瓶颈
一次外部表查询的耗时可以拆成三部分:
总耗时 = 远端执行时间 + 网络传输时间 + 本地处理时间
▲ ▲ ▲
│ │ │
缺索引/全表扫 下行行数×行宽 未下推的
远端本身慢 往返次数 过滤/排序/聚合对应三类优化手段:远端执行(确保远端有合适索引、别把过滤留本地)、网络传输(提高下推程度、调整 fetch_size、避免 SELECT * 拉宽行)、本地处理(尽量让本地只做"接收 + 轻量合并")。
第三项最容易被忽视:很多人只关注"远端快不快",却忘了看"本地到底在处理多少行"。
连接缓存与连接数
postgres_fdw 为每个会话维护到远端的连接缓存。这意味着:
- 同一会话的连续查询复用连接,握手成本只付一次;
- 不同会话各自建连接,100 个应用连接并发访问外部表 = 远端多 100 个连接;
- 不同 user mapping 各占一条连接。
本地连接池(100 个连接)
│
├─ 会话 1 ──► 远端连接 1
├─ 会话 2 ──► 远端连接 2
├─ ...
└─ 会话 100 ─► 远端连接 100 ← 远端 max_connections 可能被撑爆这是 FDW 最常见的生产事故:本地一切正常,远端因为连接数耗尽开始拒绝服务。评估 FDW 时,必须把远端连接数纳入容量规划。
语法与用法
查看执行计划中的远端语句
诊断下推的第一工具是 EXPLAIN (VERBOSE):
EXPLAIN (VERBOSE, COSTS OFF)
SELECT tenant_id, sum(amount)
FROM remote_orders
WHERE status = 'paid' AND created_at >= now() - interval '7 days'
GROUP BY tenant_id
ORDER BY sum(amount) DESC
LIMIT 10;关键是看输出的 Remote SQL 行——它是实际发给远端的语句:
QUERY PLAN
------------------------------------------------------------------------------
Limit
Output: tenant_id, (sum(amount))
-> Sort
Output: tenant_id, (sum(amount))
Sort Key: (sum(remote_orders.amount)) DESC
-> Foreign Scan
Output: tenant_id, (sum(amount))
Relations: Aggregate on (public.remote_orders)
Remote SQL: SELECT tenant_id, sum(amount) FROM public.orders
WHERE ((status = 'paid') AND (created_at >= (now() - '7 days'::interval)))
GROUP BY tenant_id ORDER BY (sum(amount)) DESC NULLS LAST LIMIT 10如果 Remote SQL 里只有 SELECT tenant_id, amount, status, created_at FROM public.orders,没有 WHERE、没有 GROUP BY、没有 LIMIT,那就说明下推全部失败了——这时你是在把整张远端表拉回本地。
用 EXPLAIN ANALYZE 看真实数据量
COSTS OFF 看结构,ANALYZE 看真实数字:
EXPLAIN (ANALYZE, VERBOSE, BUFFERS)
SELECT tenant_id, sum(amount)
FROM remote_orders
WHERE status = 'paid'
GROUP BY tenant_id;关注输出里的 rows= 与实际返回行数。如果 Foreign Scan 节点返回的行数与远端表总行数接近,说明过滤没有下推。
调整估算参数与批大小
-- 提高"进入外部扫描"的门槛,让优化器更谨慎
ALTER SERVER remote_pg
OPTIONS (SET fdw_startup_cost '1000', SET fdw_tuple_cost '0.5');
-- 读:每次取 2000 行;写:每批 500 行
ALTER FOREIGN TABLE remote_orders OPTIONS (SET fetch_size '2000');
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '500');
-- 对特定表改用远端实时估算
ALTER FOREIGN TABLE remote_orders OPTIONS (ADD use_remote_estimate 'true');
-- 启用异步扫描与并行提交/回滚
ALTER SERVER remote_pg
OPTIONS (ADD async_capable 'true',
ADD parallel_commit 'true',
ADD parallel_abort 'true');
-- 收集统计信息(外部表统计不会自动保持新鲜)
ANALYZE remote_orders;
ANALYZE remote_order_items;详细说明
哪些能下推,哪些不能
这是本节最需要背下来的内容。基于 postgres_fdw 的实际能力:
| SQL 构造 | 能否下推 | 备注 |
|---|---|---|
WHERE 常量过滤 | ✅ | 最常见也最有效 |
WHERE 内置函数过滤 | ✅ | 如 lower(x) = 'a' |
WHERE 扩展函数过滤 | ❌(默认) | 需 extensions 声明 |
JOIN(两表在同一远端) | ✅ | 前提是 JOIN 条件可下推 |
JOIN(跨不同远端/本地) | ❌ | 必须本地执行 |
GROUP BY + 内置聚合 | ✅ | sum/count/min/max/avg 等 |
GROUP BY + 自定义聚合 | ❌ | 本地执行 |
ORDER BY | ✅ | 需排序规则一致 |
LIMIT / OFFSET | ✅ | 需与 ORDER BY 配合才有意义 |
DISTINCT | ✅ | |
窗口函数(row_number() 等) | ⚠️ 通常下推不了 | 取决于具体实现与版本 |
| 子查询(相关子查询) | ⚠️ 部分 | 简单形式可下推 |
集合操作(UNION / INTERSECT) | ⚠️ 部分 | |
SELECT * | ✅(作为投影) | 但会拉宽行,不利传输 |
最需要警惕的是"看起来能下推、实际不行"的情况:跨远端 JOIN、涉及本地自定义函数、排序规则不一致、类型不一致。这四类都会让优化器静默地把数据拉回本地。
排序规则与类型不一致会阻断下推
两个非常隐蔽的陷阱,都会让本来该下推的语句被静默地拉回本地。
排序规则不一致:假设远端以 C 排序规则初始化,本地是 en_US.UTF-8:
-- 两端排序规则不同 → 排序结果可能不一致 → 不下推,改为本地排序
EXPLAIN (VERBOSE, COSTS OFF)
SELECT * FROM remote_orders ORDER BY status;输出里会出现本地 Sort 节点,而 Remote SQL 中没有 ORDER BY。修复方式是让两端一致:
-- 显式声明列的排序规则,与远端保持一致
CREATE FOREIGN TABLE remote_orders (
status text COLLATE "C"
) SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');类型不一致:远端 amount 是 numeric,本地声明成 text,比较时会引入转换,同样可能阻断下推。
-- 错误!本地声明为 text,与远端的 numeric 不一致
CREATE FOREIGN TABLE bad_orders (amount text)
SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');最佳实践:外部表列类型必须与远端完全一致(包括精度、长度、是否带时区)。用 IMPORT FOREIGN SCHEMA 自动生成能避免这类问题,因为它读取的是远端的真实类型。
fdw_startup_cost 与 fdw_tuple_cost 的调优逻辑
这两个参数本质上是给优化器的一剂"心理暗示":告诉它"外部扫描比本地扫描贵"。
-- 默认值
-- fdw_startup_cost = 100
-- fdw_tuple_cost = 0.2调优思路:
| 情况 | 调整方向 |
|---|---|
| 优化器总选外部表扫描,但实际很慢 | 调大两个值 |
| 优化器总避开外部表,但实际很快 | 调小两个值 |
| 高延迟链路(跨机房) | 调大 fdw_startup_cost |
| 每行开销大(行很宽) | 调大 fdw_tuple_cost |
-- 跨机房场景:强调连接建立成本高
ALTER SERVER remote_pg_remote_region
OPTIONS (SET fdw_startup_cost '5000', SET fdw_tuple_cost '0.5');注意:这两个值不是"毫秒",而是与本地代价单位同量纲的抽象代价。调参要靠
EXPLAIN前后对比验证,不要凭感觉。
use_remote_estimate 的取舍
ALTER FOREIGN TABLE remote_orders OPTIONS (ADD use_remote_estimate 'true');开启后,postgres_fdw 会在生成计划阶段向远端发送 EXPLAIN,用远端的真实估算来算代价。
| 维度 | false(默认) | true |
|---|---|---|
| 计划生成速度 | 快 | 每次多一次网络往返 |
| 估算准确性 | 依赖本地缓存的统计信息 | 使用远端实时估算 |
| 适用场景 | 简单查询、统计稳定 | 复杂 JOIN、远端数据分布多变 |
推荐策略:
- 简单查询保持默认
false; - 涉及多表 JOIN、且曾观察到计划劣化的表,在表级开启
true; - 不要在 server 级全局开启,否则每个查询都要多付一次
EXPLAIN往返。
统计信息:比参数更重要的东西
FDW 场景下,统计信息的重要性高于一切调参。原因:优化器要决定 JOIN 顺序、连接算法、是否下推,全靠行数估算。估算错了,再好的参数也救不回来。
-- 定期收集
ANALYZE remote_orders;
-- 查看统计信息是否已收集
SELECT relname, reltuples, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'remote_orders';
-- 控制采样方式(远端表极大、或物理分布与逻辑分布不一致时有用)
ALTER FOREIGN TABLE remote_orders OPTIONS (SET analyze_sampling 'random');注意:外部表的 ANALYZE 需要能够访问远端(或文件)。对 postgres_fdw,它会去远端采样或使用远端统计;对 file_fdw,它需要读取文件。另外,外部表很少被自动 ANALYZE,建议用运维任务定期执行。
fetch_size:读路径的批大小
ALTER FOREIGN TABLE remote_orders OPTIONS (SET fetch_size '2000');默认 100,决定每次往返取回多少行。取 10 万行时,fetch_size = 100 需要 1000 次往返,fetch_size = 5000 只需 20 次——高延迟链路上差距是数量级的。
| 场景 | 建议 |
|---|---|
| 局域网(<1ms 延迟) | 默认 100 尚可,可调到 1000 |
| 跨机房(5~30ms 延迟) | 调到 2000~5000 |
带 LIMIT 的小查询 | 不要调太大,否则可能多取 |
| 行非常宽(大文本/JSON) | 调小,避免单批内存占用过高 |
注意:
fetch_size只影响SELECT。写入路径由batch_size控制。
batch_size:写路径的批大小
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '500');默认 1,即逐行发送。批量插入 10 万行时,默认值意味着 10 万次往返,而 batch_size = 500 只需 200 次。推荐做法是临时调大、写完恢复:
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '500');
INSERT INTO remote_orders SELECT * FROM staging;
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '1');注意:批量写入会占用远端更多内存(一批缓存在内存中)。
batch_size不是越大越好,500~1000 是常见的安全区间。
另外,对"把大量数据灌入外部表"的场景,COPY 通常比 INSERT 更高效——它走的是批量协议而非逐行协议。注意 COPY 的目标是外部表时,文件仍需要服务器进程可读(与 file_fdw 相同的权限约束)。
异步执行:async_capable
ALTER SERVER remote_pg OPTIONS (ADD async_capable 'true');默认 false。开启后,对同一远端的多个外部表扫描可以异步并行。典型受益场景:一条查询需要扫描同一远端的 3 张外部表(例如三表 UNION 或多次扫描),默认情况下它们串行往返;开启后可以并发发出,代价是同一会话可能同时持有多个远端连接。
并行提交/回滚:parallel_commit / parallel_abort
ALTER SERVER remote_pg OPTIONS (ADD parallel_commit 'true', ADD parallel_abort 'true');默认均为 false。parallel_commit 在本地 COMMIT 时并行提交所有涉及的远端事务,parallel_abort 在本地回滚时并行回滚所有远端事务。多个远端的写入事务(如"同时写 A 库和 B 库")下收益明显:三次远端提交从串行变为并发。前提条件是远端必须启用两阶段提交:
# 远端 postgresql.conf:默认为 0(禁用)
max_prepared_transactions = 100两阶段提交的性能代价
顺带说一个反向的优化点:如果不需要跨库原子性,就不要触发 2PC。什么时候会触发?在一个本地事务里修改了两个及以上远端。
-- 这会在提交时走 2PC(三次 PREPARE + COMMIT PREPARED)
BEGIN;
INSERT INTO remote_a ...;
INSERT INTO remote_b ...;
COMMIT;若业务允许"两个远端各自独立提交",可以拆成两个独立事务,省掉 2PC 的额外往返:
-- 拆成两个事务,避免 2PC
BEGIN; INSERT INTO remote_a ...; COMMIT;
BEGIN; INSERT INTO remote_b ...; COMMIT;反过来说,如果确实需要原子性,就必须付这个代价——正确性优先于性能。
连接管理与连接数控制
FDW 连接数问题在生产中非常真实。几个缓解手段:
为报表类访问单独建 server,这样连接池可以独立限制:
-- 报表专用 server,与业务访问隔离
CREATE SERVER report_pg FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '10.0.0.5', port '5432', dbname 'report', updatable 'false');限制访问外部表的角色数量,避免大量并发会话各自建连。
在远端设置合理的 max_connections,并为 FDW 访问预留配额。
使用连接池(如 PgBouncer)时要注意:postgres_fdw 的连接由本地会话持有。若本地也走连接池(例如 transaction 模式),会话的建立/释放会更频繁,FDW 连接缓存可能失效,导致远端连接数波动更大。
监控远端连接来源:
-- 在远端执行,查看来自 FDW 的连接
SELECT client_addr, count(*), state
FROM pg_stat_activity
WHERE application_name = 'postgres_fdw' OR backend_type = 'client backend'
GROUP BY client_addr, state
ORDER BY count(*) DESC;减少传输量的其他手段
-- 不推荐:拉回所有列,网络传输量翻倍
SELECT * FROM remote_orders;
-- 推荐:只取需要的列
SELECT id, amount FROM remote_orders WHERE status = 'paid';
-- 不推荐:把明细拉回本地聚合
SELECT tenant_id, sum(amount) FROM (SELECT * FROM remote_orders) x GROUP BY tenant_id;
-- 推荐:让远端聚合(下推)
SELECT tenant_id, sum(amount) FROM remote_orders GROUP BY tenant_id;
-- 不推荐:本地函数包裹导致无法下推
SELECT * FROM remote_orders WHERE my_local_func(status);
-- 推荐:使用两端都有的内置函数
SELECT * FROM remote_orders WHERE status = 'paid';远端建索引:这一点容易被忘记——FDW 的过滤条件最终要在远端执行,远端没索引照样全表扫。
-- 在远端执行
CREATE INDEX ON orders (status, created_at);
CREATE INDEX ON orders (tenant_id);何时该承认 FDW 不合适
FDW 的性能优化有天花板。出现以下信号时,应该考虑改方案:
| 信号 | 建议方案 |
|---|---|
Remote SQL 永远下推不了关键条件 | 第三方 FDW 能力不足 → ETL |
| 每次查询都拉回数百万行 | 数据量超出 FDW 适用范围 → ETL/数仓 |
| 远端连接数持续打满 | 架构问题 → 逻辑复制/物化 |
| 跨源 JOIN 占查询耗时 90% | 把其中一方同步到本地 |
| 需要本地索引加速 | 逻辑复制或 ETL 落库 |
| 远端抖动直接拖垮本地业务 | 核心链路必须本地化 |
判断原则:如果优化的方向是"不断调参数去迁就它",那说明这个场景本质上不适合 FDW。FDW 适合"少搬数据"的场景,不适合"必须搬很多数据"的场景。
一个完整的优化流程
把前面的内容串成一个可执行的检查清单:
第 1 步:看 Remote SQL
EXPLAIN (VERBOSE) → 确认 WHERE / GROUP BY / LIMIT / JOIN 是否都下推
没下推 → 查原因(类型?排序规则?扩展函数?跨远端?)
第 2 步:看数据量
EXPLAIN (ANALYZE) → 看 Foreign Scan 实际返回多少行
返回行数 ≈ 远端总行数 → 过滤没生效
第 3 步:看统计信息
外部表是否 ANALYZE 过?reltuples 是否合理?
计划明显选错 → 表级开 use_remote_estimate
第 4 步:调网络参数
高延迟 → 调大 fetch_size
批量写 → 调大 batch_size
多远端写 → parallel_commit
第 5 步:优化远端
远端是否缺索引?
远端是否有慢查询?
第 6 步:还不行就换方案
ETL / 逻辑复制 / 本地物化实战示例
示例一:诊断一次"慢得离谱"的跨库查询
问题现象:一条本该秒级的统计查询跑了 40 秒。
SELECT status, count(*), sum(amount)
FROM remote_orders
WHERE created_at >= now() - interval '30 days'
GROUP BY status;先看计划:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT status, count(*), sum(amount)
FROM remote_orders
WHERE created_at >= now() - interval '30 days'
GROUP BY status; QUERY PLAN
--------------------------------------------------------------------
HashAggregate
Output: status, count(*), sum(amount)
Group Key: remote_orders.status
-> Foreign Scan on public.remote_orders
Output: id, tenant_id, amount, status, created_at
Remote SQL: SELECT id, tenant_id, amount, status, created_at FROM public.orders
(5 rows)问题一目了然:Remote SQL 里没有任何 WHERE、没有 GROUP BY,等于把远端 orders 全表拉回本地聚合。远端有 800 万行。
排查原因:检查外部表的列类型是否与远端一致。
\d remote_orders Foreign table "public.remote_orders"
Column | Type | Collation | Nullable
------------+-----------------------------+-----------+----------
created_at | timestamp without time zone | |远端实际类型是 timestamp with time zone,而本地声明成了 timestamp without time zone(少了 with time zone)。类型不一致导致 now() - interval '30 days' 的比较无法下推。
修复:
-- 用 IMPORT FOREIGN SCHEMA 重新生成正确的列类型
DROP FOREIGN TABLE remote_orders;
IMPORT FOREIGN SCHEMA public LIMIT TO (orders)
FROM SERVER remote_pg INTO public;
ANALYZE remote_orders;同样检查计划:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT status, count(*), sum(amount)
FROM remote_orders
WHERE created_at >= now() - interval '30 days'
GROUP BY status; QUERY PLAN
--------------------------------------------------------------------
Foreign Scan
Output: status, (count(*)), (sum(amount))
Relations: Aggregate on (public.remote_orders)
Remote SQL: SELECT status, count(*), sum(amount) FROM public.orders
WHERE (created_at >= (now() - '30 days'::interval))
GROUP BY status
(4 rows)现在过滤和聚合都下推了,远端只返回几行(每个 status 一行)。查询从 40 秒降到不到 200ms。
这个案例的教训:类型不一致导致的"静默不下推",是最常见也最隐蔽的 FDW 性能问题。修复成本很低(重新导入),但不知道要去看 Remote SQL 就完全找不到方向。
示例二:优化跨机房报表查询的往返次数
场景:本地在北京,远端在上海,RTT(往返时延)约 20ms。查询需要每次分页返回 50 行,总共翻 200 页。
-- 每页查询(第 n 页)
SELECT id, tenant_id, amount, created_at
FROM remote_orders
WHERE tenant_id = 42
ORDER BY created_at DESC
LIMIT 50 OFFSET 5000;远端索引已建好,WHERE、ORDER BY、LIMIT/OFFSET 都能下推。但每页仍要传输 5000+50 行吗?不会——下推的 OFFSET 在远端执行,只返回 50 行。
真正的瓶颈是往返次数:200 页 × 20ms RTT = 4 秒纯网络开销(这已经是最优情况)。若下推失败,则每次要传几千行,网络时间会放大几十倍。
优化手段一:调大 fetch_size,避免后端在单页内多次往返。
ALTER FOREIGN TABLE remote_orders OPTIONS (SET fetch_size '1000');优化手段二:批量拉取,而不是逐页拉取。把多页合并成一次查询。
-- 一次取回前 1000 行,应用层分页
SELECT id, tenant_id, amount, created_at
FROM remote_orders
WHERE tenant_id = 42
ORDER BY created_at DESC
LIMIT 1000;优化手段三:如果报表是固定形态,用本地物化表 + 增量刷新替代实时查询,把 RTT 成本降到最低。
-- 本地物化一张摘要表,定期刷新
CREATE TABLE local_orders_summary (
tenant_id bigint,
day date,
cnt bigint,
total numeric(14,2),
PRIMARY KEY (tenant_id, day)
);
-- 刷新(每晚)
INSERT INTO local_orders_summary (tenant_id, day, cnt, total)
SELECT tenant_id,
created_at::date,
count(*),
sum(amount)
FROM remote_orders
WHERE created_at >= current_date - 7
GROUP BY tenant_id, created_at::date
ON CONFLICT (tenant_id, day) DO UPDATE
SET cnt = EXCLUDED.cnt, total = EXCLUDED.total;这里把"聚合下推 + 小结果集传输"的优势发挥到极致:无论远端有多少行,网络只传"租户 × 天数"量级的汇总数据。
示例三:批量写入的性能对比与调优
场景:把本地 20 万行暂存数据写入远端外部表。
默认配置(batch_size = 1):
\timing on
INSERT INTO remote_orders (id, tenant_id, amount, created_at)
SELECT id, tenant_id, amount, created_at FROM staging_orders;INSERT 0 200000
Time: 184320.551 ms (03:04.321)调大 batch_size:
ALTER FOREIGN TABLE remote_orders OPTIONS (ADD batch_size '500');
INSERT INTO remote_orders (id, tenant_id, amount, created_at)
SELECT id, tenant_id, amount, created_at FROM staging_orders;INSERT 0 200000
Time: 9841.220 ms (00:09.841)从 3 分 4 秒降到 9.8 秒,约 19 倍提升。差异全部来自"往返次数 20 万次 → 400 次"。
再用 COPY 对比:
-- 先把暂存数据导出为 CSV(本地)
COPY staging_orders TO '/data/staging_orders.csv' WITH (FORMAT csv);-- 再从文件直接灌入外部表
\timing on
COPY remote_orders (id, tenant_id, amount, created_at)
FROM '/data/staging_orders.csv' WITH (FORMAT csv);COPY 200000
Time: 6420.118 ms (00:06.420)性能阶梯:
| 方式 | 耗时 | 相对倍数 |
|---|---|---|
INSERT(batch_size=1,默认) | 184.3 s | 1× |
INSERT(batch_size=500) | 9.84 s | ~19× |
COPY FROM | 6.42 s | ~29× |
结论:批量写远端时,COPY 优于 INSERT,且务必调大 batch_size。但同时要注意:COPY 需要文件位于服务器可读路径,若数据来自本地暂存表,需要额外落盘一次,要权衡这个成本。
记得用完恢复 batch_size,避免影响日常小事务写入:
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '1');注意事项
不检查 Remote SQL 就等于盲调;调参前也要先量化基线
-- 任何外部表查询的调优都从这一步开始
EXPLAIN (VERBOSE, COSTS OFF) SELECT ... FROM remote_table ...;
-- 记录优化前的耗时作为对照
\timing on
SELECT ...;类型或排序规则不一致会静默阻断下推
-- 错误!远端是 timestamptz,本地写成 timestamp
CREATE FOREIGN TABLE remote_orders (created_at timestamp)
SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');
-- 两端 collation 不同 → ORDER BY 只能在本地做
SELECT * FROM remote_orders ORDER BY status;扩展函数不声明 extensions 就不下推
-- 错误!未声明 extensions,ST_DWithin 无法下推
SELECT * FROM remote_shops WHERE ST_DWithin(location, ...);
-- 正确
ALTER SERVER remote_geo OPTIONS (ADD extensions 'postgis');use_remote_estimate 全局打开会拖慢所有查询
-- 不推荐
ALTER SERVER remote_pg OPTIONS (SET use_remote_estimate 'true');
-- 推荐:只在需要的表上开
ALTER FOREIGN TABLE remote_orders OPTIONS (ADD use_remote_estimate 'true');fetch_size 与 batch_size 不能互换,过大会撑爆内存
-- 错误思路:想加快写入却调节 fetch_size
ALTER FOREIGN TABLE t OPTIONS (SET fetch_size '5000'); -- 对 INSERT 无帮助
-- 不推荐:过大的批大小会占用大量内存(本地或远端)
ALTER FOREIGN TABLE t OPTIONS (SET fetch_size '100000');
ALTER FOREIGN TABLE t OPTIONS (SET batch_size '100000');跨远端写事务需要 max_prepared_transactions
# 错误!默认 0 会导致跨远端事务提交失败
max_prepared_transactions = 0不要试图在外部表上建索引
CREATE INDEX ON remote_orders (created_at); -- 错误!外部表不支持索引正确做法是在远端建索引。同理,SELECT * 会拉回全部列(行更宽、传输更多),大表上不应无 LIMIT 地全量拉取:
SELECT * FROM remote_orders; -- 不推荐
SELECT id, status, amount FROM remote_orders; -- 推荐窗口函数通常下推不了
-- 这类查询会把数据拉到本地再算窗口
SELECT *, row_number() OVER (PARTITION BY tenant_id ORDER BY created_at)
FROM remote_orders;对大数据集要谨慎;可考虑在远端建视图预先计算,或用本地物化。
外部表的统计信息不会自动保持新鲜;连接数耗尽会表现为"随机报错"而非"慢"
远端数据变化后本地统计可能严重过时,导致 JOIN 顺序选错,需要定期 ANALYZE。另需注意,远端 max_connections 打满时报错类似 could not connect to server: FATAL: sorry, too many clients already——这类故障排查方向与性能问题完全不同,要能区分。
最佳实践
每次改外部表定义后都看一遍 Remote SQL
EXPLAIN (VERBOSE, COSTS OFF) SELECT ... FROM remote_orders WHERE ...;外部表列类型严格与远端一致
优先用导入方式生成:
DROP FOREIGN TABLE IF EXISTS remote_orders;
IMPORT FOREIGN SCHEMA public LIMIT TO (orders) FROM SERVER remote_pg INTO public;显式保证排序规则一致;远端必须有支撑查询的索引
CREATE FOREIGN TABLE remote_orders (
status text COLLATE "C"
) SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');
-- 在远端执行,否则过滤条件照样全表扫
CREATE INDEX ON orders (status, created_at);定期 ANALYZE 外部表
psql -c "ANALYZE remote_orders;" -c "ANALYZE remote_order_items;"关键表用 use_remote_estimate,其余保持默认;按链路延迟设置 fetch_size
ALTER FOREIGN TABLE remote_orders OPTIONS (ADD use_remote_estimate 'true');
-- 内网
ALTER SERVER remote_pg OPTIONS (SET fetch_size '1000');
-- 跨机房
ALTER SERVER remote_pg_remote OPTIONS (SET fetch_size '5000');批量写用 COPY 或调大 batch_size;多远端写入开启并行提交
ALTER FOREIGN TABLE remote_orders OPTIONS (SET batch_size '500');
COPY remote_orders FROM '/data/x.csv' WITH (FORMAT csv);
ALTER SERVER remote_pg_a OPTIONS (ADD parallel_commit 'true', ADD parallel_abort 'true');
ALTER SERVER remote_pg_b OPTIONS (ADD parallel_commit 'true', ADD parallel_abort 'true');不需要原子性就不要触发 2PC;调参前先用 \timing 建立基线
-- 拆成两个独立事务,避免 2PC 开销
BEGIN; INSERT INTO remote_a ...; COMMIT;
BEGIN; INSERT INTO remote_b ...; COMMIT;
-- 记录优化前后的耗时作为对照
\timing on
SELECT tenant_id, sum(amount) FROM remote_orders WHERE status = 'paid' GROUP BY tenant_id;查询只取需要的列,把过滤写成可下推形式,大结果集改为"远端聚合 + 本地精加工"
-- 只取需要的列,避免 SELECT * 拉宽行;过滤条件避免被本地函数包裹
SELECT id, amount FROM remote_orders WHERE status = 'paid';
-- 让远端先把百万行压成几十行
SELECT tenant_id, sum(amount) AS total
FROM remote_orders
WHERE created_at >= now() - interval '30 days'
GROUP BY tenant_id;为 FDW 访问规划远端连接数配额,用物化表替代高频实时查询
-- 远端侧监控连接来源
SELECT client_addr, count(*) FROM pg_stat_activity GROUP BY client_addr ORDER BY 2 DESC;
-- 高频固定报表改用物化表刷新
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_remote_summary;把优化决策写成注释留在表定义旁;定期复核 FDW 方案是否仍然合适
COMMENT ON FOREIGN TABLE remote_kpi IS
'性能经调优:fetch_size=5000(跨机房 20ms RTT)、analyze_sampling=random';数据量、查询模式、网络拓扑都会变。半年前合适的 FDW 方案,今天可能已经撞到天花板;此时应当考虑改走 ETL 或逻辑复制,而不是继续调参。
下一节