外部数据包装器(Foreign Data Wrapper)
PostgreSQL 里有一句流传很广的说法:"一切皆可成为一张表"。你可以在 PostgreSQL 中查询 MySQL 里的订单、读取服务器上的 CSV 日志、访问远程的另一台 PostgreSQL 实例,甚至去调用一个 HTTP 接口——而这些"外部数据"在 SQL 层面看起来就是一张普通的表。
实现这一能力的机制叫做外部数据包装器(Foreign Data Wrapper,简称 FDW)。它是 PostgreSQL 对 SQL 标准中 SQL/MED(Management of External Data,外部数据管理) 规范的实现:用一套统一的元数据描述"外部数据在哪里、怎么连、叫什么名字",然后由各个 FDW 插件把 SQL 操作翻译成对底层数据源的访问。
本节是全组文章的总览,重点讲清 FDW 的五层对象模型、一次跨库查询的执行流程,以及什么时候该用它、什么时候不该用它。后续小节会分别展开 postgres_fdw、file_fdw、第三方 FDW 和性能调优。
基础概念
为什么需要 FDW
在实际系统里,"数据都放在一个 PostgreSQL 实例里"往往只是理想状态。更常见的情况是:
- 老的业务系统还在 MySQL 上跑,新系统用 PostgreSQL,两边数据需要联合查询;
- 归档数据被导出成 CSV 文件,需要一个能直接用 SQL 查询的方式;
- 数仓与业务库分属两个 PostgreSQL 集群,做报表时要 JOIN;
- 服务器访问日志被
log_destination = 'csvlog'落成文件,运维想直接写 SQL 分析。
传统解决办法有三种,各有明显缺点:
| 方案 | 做法 | 缺点 |
|---|---|---|
| ETL 搬运 | 定时把外部数据同步进本地表 | 延迟高、存储翻倍、Schema 变更要改同步脚本 |
| 应用层拼接 | 应用分别查两边,在代码里做 JOIN | 无法下推、无法分页、应用复杂度爆炸 |
dblink 函数调用 | 在 SQL 里写 dblink('...', 'select ...') | 字符串拼 SQL、无类型检查、无执行计划优化 |
FDW 相当于第四种方案:把外部数据源纳管成表,让优化器、执行器、权限系统都能像对待本地表一样对待它。你写的是标准 SQL,FDW 负责翻译。
FDW 不是什么
初学 FDW 最容易混淆的两个点,必须先澄清:
- FDW 与 FOREIGN KEY(外键)无关。外键是"同一库内两张表之间的约束";FDW 是"把库外的数据当成表"。两者名字里都有
FOREIGN,但完全是两回事。 - FDW 不是数据副本。外部表(foreign table)本身不存储数据,它只是一份元数据(元数据可以理解为"描述数据的数据",即表的定义),真正的数据仍在远端或文件里。每次查询都会实时去取(
materialized之类的能力要靠具体 FDW 或物化视图自己实现)。
五层对象模型
FDW 的一切操作都围绕下面五个层级的对象展开。理解这五层,是理解全部 FDW 语法的基础:
| 层 | 对象 | 中文名 | 职责 |
|---|---|---|---|
| 1 | FOREIGN DATA WRAPPER | 外部数据包装器 | 定义"这类数据源怎么访问",由扩展提供,比如 postgres_fdw |
| 2 | SERVER | 外部服务器 | 定义一个具体的远端连接目标,比如"生产库 10.0.0.5:5432" |
| 3 | USER MAPPING | 用户映射 | 定义"本地某个角色连接该服务器时用什么凭据" |
| 4 | FOREIGN TABLE | 外部表 | 定义远端某张表/某个文件映射成本地哪张表、有哪些列 |
| 5 | 查询 | — | SELECT/INSERT/UPDATE/DELETE,由 FDW 翻译并下发 |
这五层是从"抽象"到"具体"的层层收窄。用一句大白话概括:
FOREIGN DATA WRAPPER → "我会开这种车" (能力,由插件提供)
│
▼
SERVER → "我要去这个地址" (目标,连接信息)
│
▼
USER MAPPING → "我用这个账号登录" (凭据)
│
▼
FOREIGN TABLE → "我要读这张表" (映射,列定义)
│
▼
SELECT / INSERT ... → "开始干活" (查询)一次跨库查询的执行流程
当你在本地执行一条涉及外部表的 SQL 时,内部大致经历以下步骤:
客户端
│ 1. SELECT * FROM remote_orders WHERE amount > 100;
▼
本地解析器 & 优化器
│ 2. 生成计划:判断哪些条件可以"下推"到远端
▼
FDW 的 Planner 回调
│ 3. 构造要发给远端的 SQL(可能带 WHERE / JOIN / LIMIT)
▼
FDW 的 Executor 回调
│ 4. 打开远端连接(复用连接缓存)
│ 5. 发送远程 SQL,按 fetch_size 分批取回结果
▼
本地执行器
│ 6. 对远端返回的行做本地再加工(如本地过滤、JOIN、排序)
▼
返回客户端其中第 2、3 步的"下推"(pushdown)是 FDW 性能的核心话题:能下推的部分越多,网络传输和本地处理的数据就越少。我们会在 FDW 性能优化 中专门讨论。
常用系统目录与视图
FDW 的对象信息存放在固定的系统目录中,排查问题时非常有用了:
| 视图 / 目录 | 用途 | 关键字段 |
|---|---|---|
pg_foreign_data_wrapper | 已安装的 FDW | fdwname、fdwhandler、fdwvalidator |
pg_foreign_server | 外部服务器 | srvname、srvowner、srvfdw、srvoptions |
pg_user_mappings | 用户映射(普通用户可见脱敏信息) | umuser、usename、srvname、umoptions |
pg_foreign_table | 外部表 | ftrelid、ftserver、ftoptions |
information_schema.foreign_tables | 外部表的标准视图 | foreign_table_schema、foreign_table_name、foreign_server_name |
information_schema.foreign_table_options | 外部表选项 | foreign_table_name、option_name、option_value |
注意:
pg_user_mappings与pg_user_mapping是两个不同的对象。前者是视图,普通用户也能查(密码会显示为****);后者是底层系统表,只有超级用户能访问。
语法与用法
FDW 的建表流程是一套固定的"四步曲"。下面按顺序给出最精简的语法骨架,具体选项留待各分节展开。
第一步:安装 FDW 扩展
-- postgres_fdw 是随 PostgreSQL 一起发布的"contrib"扩展,需要显式安装
CREATE EXTENSION postgres_fdw;
-- 查看当前库已经装了哪些 FDW
SELECT fdwname, fdwhandler::regproc, fdwvalidator::regproc
FROM pg_foreign_data_wrapper
ORDER BY fdwname;CREATE EXTENSION 会在当前数据库中注册这个 FDW。注意它的作用范围是数据库级的:同一个集群里的另一个数据库需要单独再装一次。
第二步:创建外部服务器
CREATE SERVER remote_pg
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (
host '10.0.0.5',
port '5432',
dbname 'production'
);SERVER 承载的是"连到哪里"的信息。命名建议带上用途前缀(如 remote_pg、legacy_mysql),避免和本地对象混淆。
第三步:创建用户映射
-- 只给当前角色建映射(最常见的做法)
CREATE USER MAPPING FOR CURRENT_USER
SERVER remote_pg
OPTIONS (user 'readonly_app', password 'secret');
-- 也可以给 PUBLIC 建一份默认映射,所有角色共用
CREATE USER MAPPING FOR PUBLIC
SERVER remote_pg
OPTIONS (user 'readonly_app', password 'secret');USER MAPPING 解决"用谁的身份去连远端"的问题。匹配顺序是:先找当前角色自己的映射,找不到再用 PUBLIC 的映射。
第四步:创建外部表
-- 手写列定义
CREATE FOREIGN TABLE remote_orders (
id bigint,
tenant_id bigint,
amount numeric(12,2),
created_at timestamptz
)
SERVER remote_pg
OPTIONS (schema_name 'public', table_name 'orders');
-- 或者批量导入远端某个 schema 下的所有表
IMPORT FOREIGN SCHEMA public
LIMIT TO (orders, order_items)
FROM SERVER remote_pg
INTO public;手写外部表时列必须与远端一一对应,import foreign schema 则能自动生成列定义,是更省事也更容易保持同步的方式。
查询与清理
-- 像普通表一样查询
SELECT tenant_id, count(*), sum(amount)
FROM remote_orders
WHERE created_at >= now() - interval '7 days'
GROUP BY tenant_id
ORDER BY sum(amount) DESC;
-- 清理:顺序与创建相反
DROP FOREIGN TABLE remote_orders;
DROP USER MAPPING FOR CURRENT_USER SERVER remote_pg;
DROP SERVER remote_pg;
DROP EXTENSION postgres_fdw;删除时要注意依赖关系:外部表依赖 server,server 依赖 extension。用 DROP ... CASCADE 可以一次性清理,但生产环境建议逐层显式删除,避免误删。
详细说明
内置的 FDW 有哪些
PostgreSQL 发行版自带两个 FDW 实现,它们都在 contrib 里,需要 CREATE EXTENSION 激活:
postgres_fdw:访问远程 PostgreSQL。功能最完整,支持读写、事务、下推、连接池式缓存。file_fdw:把服务器上的文本文件(CSV、TSV 等)当成只读外部表。它复用了COPY FROM的解析机制。
除此之外,社区提供了大量第三方 FDW,比如访问 MySQL、Oracle、SQLite、MongoDB、Redis 的封装。选型时最重要的判断标准是:是否还在维护、是否支持你需要的下推能力。
FDW 的分层设计带来了什么好处
五层模型不只是"层层配置比较清晰",它有实实在在的工程价值:
- 连接信息与表定义解耦:同一个 server 下可以挂成百上千张外部表,换 IP 时只改 server 一处。
- 凭据与对象定义解耦:
USER MAPPING让"谁在连"和"连到哪"分开管理,可以把密码集中放在映射里而不污染表定义。 - 权限可以逐层授予:
USAGEon server、USAGEon FDW、SELECTon foreign table,三处独立控制。 - 一个 FDW 支撑多数据源:同一类型的多个数据源建多个 server 即可。
FDW 与本地表的差异
外部表"看起来像表",但有几处行为与本地表不同,写业务代码时必须心里有数:
| 特性 | 本地表 | 外部表 |
|---|---|---|
| 数据存储 | 存在本地 | 不存在本地,实时去取 |
| 约束(PRIMARY KEY / CHECK) | 被强制 | 不被强制,仅"假定为真" |
| 索引 | 可建 | 不能建(由远程/远端自己保证) |
| 触发器 | 支持 | 不支持(由 FDW 决定,核心层不支持) |
TRUNCATE | 支持 | 由 FDW 决定(postgres_fdw 支持) |
| 统计信息 | ANALYZE 收集 | 需要能 ANALYZE,否则计划可能失准 |
| 事务 | 本地事务 | 跨库需两阶段提交(2PC)配合 |
关于"约束不被强制"这一点值得展开:外部表上你可以声明 PRIMARY KEY、NOT NULL、CHECK,但 PostgreSQL 不会去远端验证它们,只是把它们当作已知为真的事实用于计划生成。如果远端实际违反了这些假设,你可能会拿到错误的结果。
一个 FDW 到底实现了什么
从内核视角看,一个 FDW 就是一个实现了若干回调函数(callback)的扩展。核心会调用这些回调来完成工作:
| 回调类别 | 代表函数 | 何时被调用 |
|---|---|---|
| 连接管理 | GetForeignRelSize、GetForeignPaths | 生成计划时,估算代价并产生访问路径 |
| 计划生成 | GetForeignPlan、BeginForeignScan | 生成扫描计划、准备执行 |
| 执行阶段 | IterateForeignScan、ReScanForeignScan | 逐行取数、重新扫描 |
| 写入阶段 | BeginForeignModify、ExecForeignInsert | 执行 INSERT / UPDATE / DELETE |
| 远程 SQL 生成 | GetForeignJoinPaths、PlanForeignModify | 决定 JOIN、聚集等的下推 |
| 清理 | EndForeignScan、EndForeignModify | 释放资源、归还连接 |
PostgreSQL 还提供了一批辅助函数(helper functions),让 FDW 作者不必从零实现这些逻辑。这部分属于 FDW 开发者的领域,业务用户无需关心;如果你对"自己写一个 FDW"感兴趣,可以参看官方 Chapter 58。
注意:PostgreSQL 并未实现 SQL 标准里定义的那套 FDW API,它提供的是自己的一套接口。因此基于 PostgreSQL FDW 接口写的扩展无法直接移植到其他数据库。
权限模型:三个独立的授权力点
使用 FDW 需要三处权限,缺一不可:
-- 1. 使用 FDW 本身的权限
GRANT USAGE ON FOREIGN DATA WRAPPER postgres_fdw TO app_user;
-- 2. 使用服务器(能连)的权限
GRANT USAGE ON FOREIGN SERVER remote_pg TO app_user;
-- 3. 查询外部表(能读这张表)的权限
GRANT SELECT ON remote_orders TO app_user;
-- 最后别忘了底层 schema 的权限
GRANT USAGE ON SCHEMA public TO app_user;常见故障:表权限、server 权限都给了,但忘了 USAGE ON FOREIGN DATA WRAPPER,结果报 permission denied for foreign-data wrapper postgres_fdw。
何时该用 FDW
FDW 不是银弹,它有明确的适用面:
适合的场景
- 跨库只读联合查询、报表、数据分析;
- 需要把外部数据"纳入 SQL 统一视野",且两端数据量在可控范围内;
- 归档/日志文件需要临时性 SQL 查询;
- 分库分表中需要少量跨分片查询(低频、小结果集)。
不适合的场景
- 高频写入远端(每次写入都是一次网络往返,代价远高于本地写);
- 大表之间的大规模 JOIN(网络传输量可能远大于收益);
- 需要强一致、低延迟的核心交易链路;
- 数据量远超本地内存/磁盘能力,且无法用下推把结果集压小。
判断的核心原则:FDW 的价值在于"少搬数据"。如果一条 FDW 查询不得不把远端全表拉回本地再算,那它通常不如老老实实做 ETL。
FDW 与其他跨库方案的对比
| 方案 | 抽象层 | 类型安全 | 可下推 | 事务 | 适用 |
|---|---|---|---|---|---|
| FDW | 表 | 强(有列定义) | 是 | 支持 2PC | SQL 联合查询 |
dblink | 函数 | 弱(返回 record) | 否 | 支持 2PC | 一次性命令式访问 |
| 逻辑复制 | 表(副本) | 强 | — | 支持 | 需要本地副本 |
| ETL | 表(副本) | 强 | — | — | 大批量、离线 |
| 应用层聚合 | 无 | 取决于代码 | 否 | 由应用决定 | 灵活但复杂 |
dblink 适合"我就想执行一条远端 SQL,拿回一个值"这种即时性场景;FDW 适合"我要把远端数据当表长期使用"的场景。两者可以共存。
实战示例
示例一:查询远程 PostgreSQL 的订单并统计
假设远端有一张 orders 表,我们想在本地做近 7 天的租户维度统计。
-- 环境准备(只需执行一次)
CREATE EXTENSION IF NOT EXISTS postgres_fdw;
CREATE SERVER remote_pg
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '10.0.0.5', port '5432', dbname 'production');
CREATE USER MAPPING FOR CURRENT_USER
SERVER remote_pg
OPTIONS (user 'report_user', password 'report_pwd');
CREATE FOREIGN TABLE remote_orders (
id bigint,
tenant_id bigint,
amount numeric(12,2),
status text,
created_at timestamptz
)
SERVER remote_pg
OPTIONS (schema_name 'public', table_name 'orders');执行统计查询:
EXPLAIN (VERBOSE, COSTS OFF)
SELECT tenant_id, count(*) AS cnt, sum(amount) AS total
FROM remote_orders
WHERE created_at >= now() - interval '7 days'
AND status = 'paid'
GROUP BY tenant_id
ORDER BY total DESC
LIMIT 20;模拟输出如下(可以观察到聚合、过滤、排序、LIMIT 都被下推了):
QUERY PLAN
------------------------------------------------------------------------------------------
Limit
Output: tenant_id, (count(*)), (sum(amount))
-> Sort
Output: tenant_id, (count(*)), (sum(amount))
Sort Key: (sum(remote_orders.amount)) DESC
-> Foreign Scan
Output: tenant_id, (count(*)), (sum(amount))
Relations: Aggregate on (public.remote_orders)
Remote SQL: SELECT tenant_id, count(*), sum(amount) FROM public.orders
WHERE ((created_at >= (now() - '7 days'::interval)) AND (status = 'paid'))
GROUP BY tenant_id ORDER BY (sum(amount)) DESC NULLS LAST LIMIT 20
(9 rows)关键信息是 Remote SQL 那一行:过滤、聚合、排序、LIMIT 全部被塞进了发给远端的 SQL,本地只负责接收 20 行结果。这是最理想的下推形态。
示例二:把日志文件当表查
file_fdw 让我们可以不给数据库写一行 ETL 代码,就分析服务器上的日志文件:
CREATE EXTENSION IF NOT EXISTS file_fdw;
CREATE SERVER log_server FOREIGN DATA WRAPPER file_fdw;
CREATE FOREIGN TABLE pg_csvlog (
log_time timestamp(3) with time zone,
user_name text,
database_name text,
process_id integer,
connection_from text,
session_id text,
session_line_num bigint,
command_tag text,
error_severity text,
sql_state_code text,
message text,
detail text,
hint text,
query text,
application_name text
)
SERVER log_server
OPTIONS (filename '/var/lib/postgresql/18/data/log/postgresql.csv', format 'csv', header 'true');-- 统计最近日志中各条 SQL 状态码的出现次数
SELECT sql_state_code, count(*) AS n
FROM pg_csvlog
WHERE log_time > now() - interval '1 day'
AND sql_state_code IS NOT NULL
GROUP BY sql_state_code
ORDER BY n DESC
LIMIT 10; sql_state_code | n
----------------+-------
00000 | 48213
23505 | 142
42P01 | 37
40001 | 11
(4 rows)sql_state_code 为空说明不是错误日志(00000 即 successful completion)。这类分析如果用 Shell + awk 写会非常痛苦,用 SQL 写就只是几行。
注意事项
约束不被强制,只被"信任"
外部表上的 NOT NULL、CHECK、PRIMARY KEY 都只是给优化器的承诺,核心层不校验。远端数据可能违反它们:
-- 可以这样声明
CREATE FOREIGN TABLE remote_orders (
id bigint NOT NULL,
amount numeric(12,2) CHECK (amount >= 0)
) SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');
-- 但远端若真有 amount < 0 的行,这里不会报错,你会直接读到负数
SELECT * FROM remote_orders WHERE amount < 0; -- 不推荐:说明约束声明与真实情况不符列必须按名字对应,不按位置
外部表与远端表的列匹配是基于列名的(当不指定 column_name 选项时)。远端表改名或加列,都可能让本地查询悄悄出错。
-- 错误!远端表实际列名是 order_amount,本地写成 amount 会取不到值
CREATE FOREIGN TABLE bad_orders (amount numeric)
SERVER remote_pg OPTIONS (schema_name 'public', table_name 'orders');不要用外部表代替本地缓存
-- 不推荐:每次调用都跨网络查一次
CREATE VIEW v_hot_config AS
SELECT * FROM remote_pg_config WHERE key = 'feature_flags';对高频读取的小配置类数据,正确做法是用本地表 + 定时同步(或逻辑复制)。
ONLY 关键字对外部表无效
-- 错误!外部表没有继承概念,ONLY 不起作用
SELECT * FROM ONLY remote_orders;因为外部表不可能有子表,写 ONLY 没有意义(虽然在语法上是接受的)。
大结果集必须配合 LIMIT 或聚合
外部表查询若返回百万行,数据会真实地流过网络。养成习惯:
-- 不推荐:可能拉回全表
SELECT * FROM remote_orders;
-- 推荐:先聚合再返回
SELECT count(*) FROM remote_orders;跨库写操作的性能陷阱
-- 不推荐:一次性 INSERT 一万行,可能产生一万次往返
INSERT INTO remote_orders SELECT * FROM local_staging;大批量写入应尽量用 COPY(postgres_fdw 支持 COPY 到外部表),或分批提交。
密码明文存在系统目录中
USER MAPPING 里的 password 以明文形式存储在 pg_user_mapping 中,只有超级用户能读。生产环境更推荐使用 .pgpass 文件或 SSL 证书方案,避免密码出现在 DDL 语句与备份里。
ANALYZE 缺失会导致计划劣化
外部表若从未被分析过,优化器只能靠默认假设估算行数,容易选出糟糕的 JOIN 顺序:
# 定期分析关键外部表
psql -c "ANALYZE remote_orders;"统计信息在远端变化后需要重新收集
远端表数据分布变化后,本地缓存的外部表统计信息会过时。可以通过 ALTER FOREIGN TABLE ... OPTIONS (SET ...) 或定期 ANALYZE 缓解。
删除 server 会连带删除其下所有外部表
-- 危险!会级联删除该 server 下所有外部表定义
DROP SERVER remote_pg CASCADE; -- 不推荐:先确认清单执行前先查询清单确认影响范围:
SELECT c.relname
FROM pg_foreign_table ft
JOIN pg_class c ON c.oid = ft.ftrelid
JOIN pg_foreign_server s ON s.oid = ft.ftserver
WHERE s.srvname = 'remote_pg';最佳实践
命名带上语义前缀,一眼分辨内外
外部表名建议加前缀(如 remote_、ext_、fdw_),server 名带数据源标识(如 legacy_mysql、report_pg)。这样在读别人的 SQL 时,能立刻看出哪些操作会走网络。
CREATE SERVER report_pg FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '10.0.0.5', dbname 'report');
CREATE FOREIGN TABLE remote_orders (...) SERVER report_pg OPTIONS (...);优先用 IMPORT FOREIGN SCHEMA 保持列定义同步
手写列定义容易与远端漂移,导入方式能自动生成,且能通过 LIMIT TO / EXCEPT 精确控制范围:
IMPORT FOREIGN SCHEMA public
EXCEPT (internal_tmp, audit_log)
FROM SERVER report_pg
INTO reporting;为只读场景创建专用账号
不要复用业务主账号。远端的映射用户应只有 SELECT 权限,从源头限制误写风险:
CREATE USER MAPPING FOR CURRENT_USER SERVER report_pg
OPTIONS (user 'fdw_readonly', password 'xxx');集中管理连接信息
所有 server 定义建议写成可重复执行的迁移脚本(带 IF NOT EXISTS),不要手工在生产库敲命令:
CREATE SERVER IF NOT EXISTS report_pg
FOREIGN DATA WRAPPER postgres_fdw
OPTIONS (host '10.0.0.5', port '5432', dbname 'report');用 IMPORT FOREIGN SCHEMA 之后立刻 ANALYZE
导入后立即收集统计信息,避免优化器长时间使用默认假设:
ANALYZE remote_orders;把下推能力作为选型硬指标
评估任何 FDW 时,第一件事是确认它支持哪些下推(WHERE / JOIN / 聚集 / ORDER BY / LIMIT)。不支持下推的 FDW,在大数据量下基本不可用。
用 EXPLAIN (VERBOSE) 的 Remote SQL 做验收
每次新增外部表或修改查询后,看一遍 Remote SQL,确认该下推的确实下推了:
EXPLAIN (VERBOSE, COSTS OFF) SELECT ... FROM remote_orders WHERE ...;为外部表单独建 schema
把外部表集中放到 foreign_ 或 fdw schema 下,既避免与本地表重名,也便于统一授权:
CREATE SCHEMA foreign_data;
IMPORT FOREIGN SCHEMA public FROM SERVER report_pg INTO foreign_data;不要滥用转发式视图
用视图把外部表包装成"假本地表"会掩盖网络开销,让后续维护者误判。若确需封装,视图名也要保留外部语义。
为跨库查询设置超时与资源上限
外部查询可能因为网络问题长时间挂住,建议为相关角色设置:
ALTER ROLE report_user SET statement_timeout = '30s';定期审计外部表的可达性
远端实例迁移或账号过期后,外部表会突然报错。建议加一个巡检任务,对所有 server 做一次轻量探测:
-- 用一条极轻量的查询探测连通性
SELECT 1 FROM remote_orders LIMIT 1;文档化每个外部表的来源
在表注释里写清远端地址与负责人,这比任何架构图都管用:
COMMENT ON FOREIGN TABLE remote_orders IS
'来源:report_pg(10.0.0.5) public.orders;负责人:数据组';下一节
继续学习:postgres_fdw 外部数据包装器