ONLY 关键字与查询
ONLY 是继承世界里最重要的一个「安全阀」。默认情况下,对父表的 SELECT、UPDATE、DELETE、多数 ALTER TABLE 变体以及 VACUUM/ANALYZE 都会递归到全部后代表;ONLY 把这个范围收回到命名的那一张表本身。
如果不理解默认递归,一条看似普通的 DELETE FROM parent 可能清空所有子表数据。本节系统讲解 ONLY 的适用范围、与 SQL 标准的差异、执行计划里的 Append 节点、递归查询的性能代价,以及它和声明式分区裁剪的关系。
基础概念
默认递归,ONLY 收窄。 PostgreSQL 用「表引用是否带 ONLY」来决定查询范围:
| 写法 | 范围 | 别名改写示例 |
|---|---|---|
FROM parent | 父表 + 全部后代 | parent_1、child_2 |
FROM ONLY parent | 仅父表 | parent |
FROM parent * | 显式含后代(= 默认) | parent_1、child_2 |
文档原文:「a query can reference either all rows of a table or all rows of a table plus all of its descendant tables. The latter behavior is the default.」
-- 默认递归
SELECT * FROM cities;
-- 仅父表
SELECT * FROM ONLY cities;与 SQL 标准的差异。 SQL 标准要求「未修饰的表引用必须包含子表」,PostgreSQL 的默认行为符合标准;而把子表排除是 PostgreSQL 的扩展,通过 ONLY 表达。历史上 PostgreSQL 曾用 sql_inheritance 参数把默认改成「不递归」,该参数在 PostgreSQL 10 已被移除,因此现在默认递归不可关闭:
-- 以下操作在 PostgreSQL 18 中不存在
SHOW sql_inheritance;
ERROR: unrecognized configuration parameter "sql_inheritance"ONLY 的作用位置。 ONLY 修饰的是表引用,即 ONLY 表名 这一整体,而不是整条语句:
SELECT ... FROM ONLY t ... -- ONLY 在表引用处
UPDATE ONLY t SET ... -- ONLY 紧跟 UPDATE
DELETE FROM ONLY t WHERE ... -- ONLY 在表引用处
ALTER TABLE ONLY t ... -- ONLY 在表名处
TRUNCATE ONLY t; -- ONLY 在表名处
VACUUM ONLY t; -- 只清理这一张表
ANALYZE ONLY t; -- 只统计这一张表语法与用法
支持与不支持 ONLY 的命令一览(依据官方参考页说明):
-- 支持 ONLY,且默认递归到子表
SELECT … FROM ONLY 表…
UPDATE ONLY 表 …
DELETE FROM ONLY 表 …
ALTER TABLE ONLY 表 …
VACUUM ONLY 表; -- VACUUM 默认递归,ONLY 可排除
ANALYZE ONLY 表; -- ANALYZE 默认递归,ONLY 可排除
TRUNCATE ONLY 表;
-- 不递归、也不支持 ONLY
INSERT INTO 表 … -- 永远只写指定表
ALTER TABLE 表 RENAME … -- 只改这一张表的名字
REINDEX … -- 只针对单张物理表最简可运行示例——验证递归与 ONLY 的差别:
DROP TABLE IF EXISTS ok_child, ok_parent CASCADE;
CREATE TABLE ok_parent (id int, label text);
CREATE TABLE ok_child () INHERITS (ok_parent);
INSERT INTO ok_parent VALUES (1, 'parent-row');
INSERT INTO ok_child VALUES (2, 'child-row');
SELECT 'all' AS scope, count(*) FROM ok_parent
UNION ALL
SELECT 'only', count(*) FROM ONLY ok_parent; scope | count
-------+-------
all | 2
only | 1
(2 rows)详细说明
SELECT 中的 ONLY
SELECT 是 ONLY 最常见的用法。它既影响结果集,也影响执行计划形态:
DROP TABLE IF EXISTS sel_c2, sel_c1, sel_p CASCADE;
CREATE TABLE sel_p (id int, created date);
CREATE TABLE sel_c1 () INHERITS (sel_p);
CREATE TABLE sel_c2 () INHERITS (sel_c1); -- 孙表
INSERT INTO sel_p VALUES (1, '2025-01-01');
INSERT INTO sel_c1 VALUES (2, '2025-02-01');
INSERT INTO sel_c2 VALUES (3, '2025-03-01');
-- 默认:整棵子树(含孙表)
SELECT id FROM sel_p ORDER BY id;
-- 仅父表
SELECT id FROM ONLY sel_p ORDER BY id; id
----
1
2
3
(3 rows)
id
----
1
(1 row)计划形态差异明显:默认递归会出现 Append,ONLY 则是对单表的直接扫描:
EXPLAIN (COSTS OFF) SELECT * FROM sel_p;
EXPLAIN (COSTS OFF) SELECT * FROM ONLY sel_p; Append
-> Seq Scan on sel_p sel_p_1
-> Seq Scan on sel_c1 sel_p_2
-> Seq Scan on sel_c2 sel_p_3
(4 rows)
Seq Scan on sel_p
(1 row)注意递归展开是传递的——sel_c2 虽然只是孙表,也被包含进来。
UPDATE 与 DELETE 中的 ONLY(安全关键)
这是 ONLY 最重要的使用场景:生产环境的批量写操作必须先加 ONLY 缩小范围。
EXPLAIN (COSTS OFF) DELETE FROM sel_p WHERE created < '2025-03-01'; Delete
-> Append
-> Seq Scan on sel_p sel_p_1
Filter: (created < '2025-03-01'::date)
-> Seq Scan on sel_c1 sel_p_2
Filter: (created < '2025-03-01'::date)
-> Seq Scan on sel_c2 sel_p_3
Filter: (created < '2025-03-01'::date)
(7 rows)一个 DELETE 语句会删除多张物理表的数据。加上 ONLY 后就只作用于父表:
-- 安全:只删父表
DELETE FROM ONLY sel_p WHERE created < '2025-03-01';
EXPLAIN (COSTS OFF) DELETE FROM ONLY sel_p WHERE created < '2025-03-01'; Delete
-> Seq Scan on sel_p
Filter: (created < '2025-03-01'::date)
(3 rows)UPDATE 同理:
-- 危险:改到整棵子树
UPDATE sel_p SET created = created + 1;
-- 安全:只改父表
UPDATE ONLY sel_p SET created = created + 1;:: warning TRUNCATE 的默认范围 TRUNCATE sel_p 在继承结构下默认会清空整棵子树;只清父表必须写 TRUNCATE ONLY sel_p。这是「清空临时数据」脚本最容易误伤子表的地方。 ::
-- 只清父表
TRUNCATE ONLY sel_p;
-- 清整棵子树(危险)
-- TRUNCATE sel_p;ONLY 在 DDL(ALTER TABLE)中的语义
ALTER TABLE 的多数变体默认传播到子表,ONLY 则只改当前表。但并非所有变更都适合加 ONLY:
DROP TABLE IF EXISTS ddl_child, ddl_parent CASCADE;
CREATE TABLE ddl_parent (id int, v int);
CREATE TABLE ddl_child () INHERITS (ddl_parent);
-- 传播:列被加到父子表
ALTER TABLE ddl_parent ADD COLUMN note text;
-- 只加父表:父子结构不一致,父表查询会报错
ALTER TABLE ONLY ddl_parent ADD COLUMN extra text;SELECT id, v, note, extra FROM ddl_parent; -- 展开子树时子表缺 extraERROR: column "extra" does not exist因此 ALTER TABLE ONLY ... ADD COLUMN 在继承结构上通常是坏主意。ONLY 在 DDL 里更适合这些场景:
| 场景 | 写法 | 说明 |
|---|---|---|
| 只给父表加约束(不传播) | ALTER TABLE ONLY p ADD CONSTRAINT ... | 子表不受约束 |
| 只改父表索引/存储参数 | ALTER TABLE ONLY p SET (fillfactor=80) | 不影响子表 |
| 只重命名父表 | ALTER TABLE p RENAME TO p2 | RENAME 本身就不递归,无需 ONLY |
-- 只给父表设置存储参数
ALTER TABLE ONLY ddl_parent SET (fillfactor = 70);
-- 查看:只有父表变了
SELECT relname, reloptions FROM pg_class WHERE relname IN ('ddl_parent', 'ddl_child'); relname | reloptions
------------+--------------------
ddl_child |
ddl_parent | {fillfactor=70}
(2 rows)VACUUM、ANALYZE 与 ONLY
VACUUM 与 ANALYZE 默认也递归到子表,这点常被忽略:
-- 默认:父表 + 全部子表
VACUUM (VERBOSE) sel_p;
-- 只处理父表
VACUUM (VERBOSE) ONLY sel_p;
-- ANALYZE 同理
ANALYZE ONLY sel_p;-- VACUUM (VERBOSE) sel_p; 的输出会分别列出每张物理表:
INFO: vacuuming "public.sel_p"
INFO: vacuuming "public.sel_c1"
INFO: vacuuming "public.sel_c2"
INFO: "public.sel_p": found 0 removable, ...实际含义:给父表做 ANALYZE 会顺带统计所有子表,这是好事(统计信息一起更新);但反过来,如果你只想统计某一张子表,应直接对子表执行 ANALYZE child。
-- 只统计一张子表
ANALYZE sel_c1;
-- 查看统计信息是否覆盖子表
SELECT c.relname, s.last_analyze
FROM pg_stat_user_tables s JOIN pg_class c ON c.oid = s.relid
WHERE c.relname IN ('sel_p', 'sel_c1', 'sel_c2')
ORDER BY c.relname;tableoid:识别行的来源
tableoid 是每张表都有的系统列,配合 ::regclass 可直接显示表名,用于确认「某行到底来自哪张表」:
SELECT tableoid::regclass AS src, id, created
FROM sel_p ORDER BY src::text, id; src | id | created
------------+----+------------
sel_c1 | 2 | 2025-02-01
sel_c2 | 3 | 2025-03-01
sel_p | 1 | 2025-01-01
(3 rows)
-- 等价但更啰嗦的写法(连接 pg_class)
SELECT p.relname AS src, c.id
FROM sel_p c JOIN pg_class p ON c.tableoid = p.oid
ORDER BY p.relname, c.id;tableoid 也可以用于「按来源表聚合」:
SELECT tableoid::regclass AS src, count(*) AS rows
FROM sel_p GROUP BY 1 ORDER BY 2 DESC; src | rows
---------+------
sel_c1 | 1
sel_c2 | 1
sel_p | 1
(3 rows)若发现所有行都来自父表,说明写入时没有把数据放进正确的子表——这是老式分区最常见的故障。
EXPLAIN 中的 Append 节点
继承查询在执行计划中体现为 Append(附加)节点,每个子计划对应一张物理表:
DROP TABLE IF EXISTS ex_y2025, ex_y2026, ex CASCADE;
CREATE TABLE ex (id int, d date);
CREATE TABLE ex_y2025 () INHERITS (ex);
CREATE TABLE ex_y2026 () INHERITS (ex);
EXPLAIN (VERBOSE, COSTS OFF) SELECT * FROM ex WHERE d = DATE '2025-06-01'; Append
-> Seq Scan on public.ex ex_1
Output: ex_1.id, ex_1.d
Filter: (ex_1.d = '2025-06-01'::date)
-> Seq Scan on public.ex_y2025 ex_2
Output: ex_2.id, ex_2.d
Filter: (ex_2.d = '2025-06-01'::date)
-> Seq Scan on public.ex_y2026 ex_3
Output: ex_3.id, ex_3.d
Filter: (ex_3.d = '2025-06-01'::date)
(10 rows)关键观察点:
- 别名改写:子计划里的表名被改写为
ex_1、ex_2、ex_3,父表自身是ex_1。 - 父表永远参与:没有范围约束的父表无法被排除。
Append之上还可以有Sort、Aggregate、Limit等,说明继承是「先合并再计算」。
对比 ONLY 的计划:Append 完全消失,只剩单个扫描节点。这也是判断一条 SQL 是否意外递归的最快方法。
EXPLAIN (COSTS OFF) SELECT * FROM ONLY ex WHERE d = DATE '2025-06-01'; Seq Scan on ex
Filter: (d = '2025-06-01'::date)
(2 rows)递归查询的性能与约束排除
默认递归的代价是「必须访问每一张物理表」。老式分区靠 constraint_exclusion 利用子表 CHECK 约束跳过无关表:
ALTER TABLE ex_y2025 ADD CONSTRAINT ck_ex_y2025
CHECK (d >= DATE '2025-01-01' AND d < DATE '2026-01-01');
ALTER TABLE ex_y2026 ADD CONSTRAINT ck_ex_y2026
CHECK (d >= DATE '2026-01-01' AND d < DATE '2027-01-01');
SET constraint_exclusion = on;
EXPLAIN (COSTS OFF) SELECT * FROM ex WHERE d = DATE '2025-06-01'; Append
-> Seq Scan on ex ex_1
Filter: (d = '2025-06-01'::date)
-> Seq Scan on ex_y2025 ex_2
Filter: (d = '2025-06-01'::date)
(4 rows)ex_y2026 被成功排除,但 ex(父表)仍在。父表没有任何范围约束,规划器无法证明它不含该日期。
约束排除的三个前提:
| 前提 | 说明 |
|---|---|
constraint_exclusion 不为 off | 默认 partition,对分区/继承子表启用 |
子表有可用的 CHECK 约束 | 约束必须与查询条件可比较 |
| 条件是「常量/参数」可推导 | 例如 d = DATE '...'、d >= ... |
-- 查看当前设置
SHOW constraint_exclusion; constraint_exclusion
----------------------
partition
(1 row)这不是免费的午餐:子表越多,规划时间越长;constraint_exclusion = on 会让规划器对所有表都尝试约束排除,可能拖慢简单查询的规划。partition(默认)只对分区/继承子表启用,是更合理的折中。
ONLY 在继承与声明式分区下的差异
继承与声明式分区在 pg_inherits 里都有记录,但 ONLY 的含义与效果不同:
| 维度 | 继承(INHERITS) | 声明式分区(PARTITION BY) |
|---|---|---|
| 父表能否存数据 | 能(父表也是普通表) | 默认不能(写入报错,除非有 DEFAULT 分区) |
ONLY parent 效果 | 返回父表自身数据 | 返回 0 行(分区表本身不存数据) |
| 裁剪机制 | 依赖 CHECK 约束排除 | 规划期分区裁剪(基于 relpartbound) |
| 裁剪可靠性 | 需手工约束,易出错 | 自动、更强、更早 |
ONLY partition | 有效(分区是独立表) | 有效,用于只查某个分区 |
-- 声明式分区:父表不存数据,ONLY 返回空
DROP TABLE IF EXISTS ptab CASCADE;
CREATE TABLE ptab (id bigint, d date) PARTITION BY RANGE (d);
CREATE TABLE ptab_2025 PARTITION OF ptab
FOR VALUES FROM (DATE '2025-01-01') TO (DATE '2026-01-01');
INSERT INTO ptab VALUES (1, '2025-06-01');
SELECT count(*) FROM ptab; -- 1
SELECT count(*) FROM ONLY ptab; -- 0(分区表本身无数据)
SELECT count(*) FROM ONLY ptab_2025; -- 1(直接查分区) count
-------
0
(1 row)声明式分区的裁剪自动生效,无需 CHECK 约束:
EXPLAIN (COSTS OFF) SELECT * FROM ptab WHERE d = DATE '2025-06-01'; Append
-> Seq Scan on ptab_2025 ptab_1
Filter: (d = '2025-06-01'::date)
(3 rows)只有 ptab_2025 参与——父表根本没有出现在 Append 里(因为分区表自身不存数据)。这是声明式分区在查询裁剪上优于继承的直接体现。
ONLY 与权限、行级安全的交互
ONLY 只改变行的来源范围,不改变权限检查方式。文档说明:继承查询只在父表上做访问权限检查:
-- 有父表权限即可通过父表访问子表数据
GRANT SELECT ON ok_parent TO app_user;
-- 但直接查询子表还需要子表自身的权限
GRANT SELECT ON ok_child TO app_user; -- 不加这句则下面报错SET ROLE app_user;
SELECT count(*) FROM ok_parent; -- 允许(走父表权限)
SELECT count(*) FROM ONLY ok_child; -- 需要 ok_child 的权限
RESET ROLE;行级安全(RLS)策略同理:父表的策略会作用于来自子表的行;子表自己的策略只在该子表被直接引用时生效。
-- 父表上的 RLS 策略会影响整棵子树的行
ALTER TABLE ok_parent ENABLE ROW LEVEL SECURITY;
CREATE POLICY p_ok ON ok_parent USING (id > 0);这意味着用 ONLY 绕过 RLS 是行不通的——ONLY 只影响范围,不影响策略来源。
实战示例
场景:多租户分表中「只查 / 只删某个租户」,并验证不会误伤其他租户。
第一步,建结构:
DROP TABLE IF EXISTS mt_b, mt_a, mt_all CASCADE;
CREATE TABLE mt_all (
id bigserial,
tenant_id text NOT NULL,
amount numeric(12,2),
created date NOT NULL DEFAULT current_date
);
CREATE TABLE mt_a () INHERITS (mt_all);
CREATE TABLE mt_b () INHERITS (mt_all);
ALTER TABLE mt_a ADD CONSTRAINT ck_mt_a CHECK (tenant_id = 'a');
ALTER TABLE mt_b ADD CONSTRAINT ck_mt_b CHECK (tenant_id = 'b');
INSERT INTO mt_a (tenant_id, amount, created) VALUES
('a', 100.00, '2025-01-10'),
('a', 200.00, '2025-06-20');
INSERT INTO mt_b (tenant_id, amount, created) VALUES
('b', 300.00, '2025-02-15');第二步,验证「全局视图 vs 单租户视图」:
SELECT 'all' AS scope, count(*) FROM mt_all
UNION ALL
SELECT 'tenant a (ONLY)', count(*) FROM ONLY mt_a
UNION ALL
SELECT 'tenant b (ONLY)', count(*) FROM ONLY mt_b; scope | count
--------------------+-------
all | 3
tenant a (ONLY) | 2
tenant b (ONLY) | 1
(3 rows)第三步,用 ONLY 安全地只删租户 A 的过期数据,并验证租户 B 未受影响:
BEGIN;
DELETE FROM ONLY mt_a WHERE created < DATE '2025-03-01';
SELECT 'a after delete' AS scope, count(*) FROM ONLY mt_a
UNION ALL
SELECT 'b after delete', count(*) FROM ONLY mt_b
UNION ALL
SELECT 'all after delete', count(*) FROM mt_all;
ROLLBACK; scope | count
--------------------+-------
a after delete | 1
b after delete | 1
all after delete | 2
(3 rows)对比「不加 ONLY」的危险写法的计划:
EXPLAIN (COSTS OFF) DELETE FROM mt_all WHERE created < DATE '2025-03-01'; Delete
-> Append
-> Seq Scan on mt_all mt_all_1
Filter: (created < '2025-03-01'::date)
-> Seq Scan on mt_a mt_all_2
Filter: (created < '2025-03-01'::date)
-> Seq Scan on mt_b mt_all_3
Filter: (created < '2025-03-01'::date)
(7 rows)第四步,验证只查单租户时的执行计划与裁剪:
SET constraint_exclusion = on;
EXPLAIN (COSTS OFF) SELECT * FROM mt_all WHERE tenant_id = 'b'; Append
-> Seq Scan on mt_all mt_all_1
Filter: (tenant_id = 'b'::text)
-> Seq Scan on mt_b mt_all_3
Filter: (tenant_id = 'b'::text)
(4 rows)-- 更优:明确只查租户 B 的子表,完全不涉及父表与其他租户
EXPLAIN (COSTS OFF) SELECT * FROM ONLY mt_b WHERE amount > 0; Seq Scan on mt_b
Filter: (amount > 0::numeric)
(2 rows)结果分析:
ONLY mt_b的计划里没有任何Append,说明只访问了一张物理表——这是多租户分表下最优的访问方式。mt_all上的查询即使有tenant_id = 'b',父表分支仍会参与(父表无tenant_id约束),无法彻底排除。DELETE FROM ONLY mt_a精确限定在租户 A,租户 B 的行数与总数都不受影响,验证了ONLY在写操作中的安全价值。- 生产脚本应以「
ONLY+ 明确子表名」为默认,而不是依赖默认递归后靠WHERE兜住范围。
场景二:用 ONLY 做单表维护(VACUUM / ANALYZE / REINDEX)。
-- 只统计某张子表,避免全树统计带来的额外开销
ANALYZE ONLY mt_a;
-- 只清理某张子表
VACUUM (ANALYZE) ONLY mt_a;
-- REINDEX 只能针对单张物理表(本身不递归)
REINDEX TABLE mt_a;
-- 查看各子表统计时间,确认只有 mt_a 被更新
SELECT c.relname, s.last_analyze
FROM pg_stat_user_tables s JOIN pg_class c ON c.oid = s.relid
WHERE c.relname IN ('mt_all', 'mt_a', 'mt_b')
ORDER BY c.relname;注意事项
错误!Cleanup 脚本不带
ONLY就执行DELETE/UPDATE/TRUNCATE。 会作用于整棵子树。sql-- 危险:删除所有子表中符合条件的行 DELETE FROM mt_all WHERE created < DATE '2025-03-01';错误!以为
ONLY能让查询「不检查权限/不套 RLS」。ONLY只改变行范围,权限与策略来源不变。sql-- 仍然按父表权限与父表 RLS 策略执行 SELECT * FROM ONLY mt_all;错误!在继承结构上用
ALTER TABLE ONLY parent ADD COLUMN。 父子列不一致,父表查询直接报错。sql-- 错误! ALTER TABLE ONLY mt_all ADD COLUMN remark text;错误!在 PostgreSQL 18 上尝试配置
sql_inheritance。 该参数自 PostgreSQL 10 起已不存在,默认递归无法关闭。sql-- 不存在 SET sql_inheritance = off;注意
TRUNCATE only(小写 only 作为表名)与TRUNCATE ONLY(关键字)不是一回事:前者会去删一张名为only的表,后者是关键字。ONLY必须写在表引用位置且大小写不敏感,但不要与标识符混淆。注意
constraint_exclusion不是万能的。 父表若没有任何范围约束,永远无法被排除;子表越多,规划开销越大。注意
ANALYZE parent会顺带统计所有子表,这通常是好事,但大表族下耗时更长;只想更新一张表时用ANALYZE ONLY。注意声明式分区下
ONLY parent返回 0 行(分区表本身不存数据),不要误判为「数据丢失」。
最佳实践
写操作默认加
ONLY。DELETE FROM ONLY、UPDATE ONLY、TRUNCATE ONLY,确认范围后如需放开再显式去掉。查询时明确写出范围意图:要么写
ONLY,要么在评审中确认「需要整棵树」,不要依赖默认行为的隐式理解。用
EXPLAIN检查是否出现Append,它是「这条 SQL 正在递归」的最直观信号:sqlEXPLAIN (COSTS OFF) SELECT count(*) FROM mt_all;用
tableoid::regclass审计数据落点,尤其批量导入后确认数据没有全部堆在父表:sqlSELECT tableoid::regclass AS src, count(*) FROM mt_all GROUP BY 1;为继承子表加范围
CHECK约束并保持constraint_exclusion = partition,让规划器能排除无关子表;同时接受「父表无法被排除」这一固有代价。维护类命令按需使用
ONLY:VACUUM ONLY、ANALYZE ONLY用于单表精细维护;REINDEX本身就只针对单张物理表。把
ONLY用法写入团队 SQL 规范,并配上静态检查(如扫描迁移脚本中的DELETE FROM/UPDATE/TRUNCATE是否缺ONLY)。新项目优先选声明式分区,它的裁剪更可靠、父表不存数据、无需
ONLY兜底;继承与ONLY主要用于建模需求与存量系统维护。
下一节
继续学习:声明式分区概述