Skip to content

PostgreSQL:把版本清理、JSONB 与索引选对 ​

PostgreSQL 的灵活性很容易让人一次加入许多东西:一列 JSONB、一棵 GIN 索引、几个扩展,再把维护交给默认设置。真正需要理解的是,每个选择改变了什么:旧行版本由谁清理,查询用了哪类运算,索引能排除哪些候选,扩展增加了哪些部署责任。

本文以 PostgreSQL 18 为文档基线,实际运行版本为 18.6、Linux/amd64。实验使用官方单用户离线模式,验证 SQL、行位置变化、JSONB 和 GIN/GiST/BRIN 执行计划;没有真实多会话,也不以它证明 autovacuum 调度、并发锁或生产性能。全部复现输入放在文末。

更新一行,为什么可能留下两个版本 ​

普通 PostgreSQL 表把数据放在堆页里,索引另存。MVCC 让每条查询依据快照判断哪个行版本可见:别的事务提交新版本时,旧读者可能仍需要旧版本;当前事务也能看到自己的先前写入。版本不能一更新就立即抹掉。[1][2]

本次建一行 id=1,value='old',更新成 new,前后观察:

sql
SELECT id, value, ctid FROM versions;
UPDATE versions SET value = 'new' WHERE id = 1;
SELECT id, value, ctid FROM versions;
text
1|old|(0,1)
1|new|(0,2)

ctid 表示一个行版本的物理位置。本例发生了位置变化,符合“UPDATE 创建新元组版本”的机制;这些坐标只属于这份小数据,不保证每次都是同样数值。应用不能把 ctid 当稳定业务主键,因为更新和重写表都可能改变它。xmin、xmax 等系统信息参与版本判断,xmax 也有锁和其他状态含义,不能仅看它非零就断言行已经删除。[3]

某些更新可以走 HOT:当条件满足,例如没有更新非摘要型索引所引用的列(包括 INCLUDE 列、索引表达式或部分索引谓词引用的列),并且原页有空间时,版本链可减少新索引条目的维护。PostgreSQL 18 的条件允许摘要型索引例外,核心中 BRIN 属于此类;对应摘要仍可能需要更新。HOT 不是“原地改旧行”,也不能保证每次更新都用上。判断开销时应考虑索引、页剩余空间和实际统计。[4]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    I["索引访问"] --> H["堆页中的候选版本"]
    H --> V["用快照与事务状态判断可见性"]
    V --> O["旧快照可能需要旧版本"]
    V --> N["新快照可看到已提交的新版本"]
    O --> C["没有读取者再需要它"]
    C --> P["清理失效版本<br/>空间留给后续复用"]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    I["索引访问"] --> H["堆页中的候选版本"]
    H --> V["用快照与事务状态判断可见性"]
    V --> O["旧快照可能需要旧版本"]
    V --> N["新快照可看到已提交的新版本"]
    O --> C["没有读取者再需要它"]
    C --> P["清理失效版本<br/>空间留给后续复用"]

版本清理的安全性依赖还活着的读取边界。长事务、长期闲置在事务内的连接,以及某些复制保留机制,都可能让可清理范围落后。看到表变大时,先找长期保留的快照和维护进度,而不是直接跑一次 VACUUM FULL。[5][6]

VACUUM、ANALYZE、VACUUM FULL 是三件不同的事 ​

动作主要解决什么重要代价和边界
普通 VACUUM回收不再需要的行版本所占空间供复用,维护可见性信息与冻结等会产生 I/O;一般不把中间空洞立刻还给操作系统;尾部完全空页在条件允许时可能截断
ANALYZE采样数据,更新优化器用的统计信息不清理所有旧版本;统计是估计,不是真实行数保证
VACUUM (ANALYZE)清理与更新统计一起做仍需为读取、I/O 和维护窗口考虑成本
VACUUM FULL重写并压紧整张表以归还更多空间需要额外磁盘空间与 ACCESS EXCLUSIVE 锁,会阻塞其他使用
autovacuum根据阈值等条件自动安排 vacuum/analyze不是永远足够快,也不会自动替你执行 VACUUM FULL

VACUUM 后文件大小没有立即下降,不代表它没做事。空闲空间可能留在表内,下一批 INSERT/UPDATE 可以复用;一个持续写入的表,追求稳定占用往往比不停压到最小更合理。VACUUM FULL 适合明确需要重写且有窗口的情形,不适合作为每天例行“加速按钮”。[5:1][7][8]

可见性图 visibility map 标记某些堆页的 all-visible、all-frozen 状态。Index Only Scan 如果能从这份图确认候选所在页对所有相关事务可见,就可减少回堆检查。因此 vacuum 不只影响磁盘垃圾,也可能影响只读查询到底要不要访问堆。写入会改变相关可见性状态,覆盖索引本身不能保证零 Heap Fetches。[9][10]

事务 ID 还引出一项必须长期做的维护:冻结老版本,防止有限宽度事务 ID 循环使用时把很老的数据误判为未来。PostgreSQL 有抗回卷 vacuum 及保护措施;不能因为系统不忙、业务没删除数据就关闭维护。监控数据库 age(datfrozenxid)、表的 dead tuple 估计、上次 autovacuum 和长期事务,是有用的起点。不要等接近保护阈值才发现维护积压。[5:2]

这里的 n_dead_tup 是估计值;“很多旧版本”也不等于它们现在全可删。文末 monitor.sql 适用于正常服务实例的观察,本文未拿离线模式的后台统计当作 autovacuum 实测。

JSONB 保存了结构,但不会替你决定数据模型 ​

json 保存输入文本的表示,jsonb 转为便于处理的二进制结构,支持更丰富的索引。JSONB 不保留原来的空白、对象键顺序或重复键;同名键出现多次时保留最后一个。需要保留签名原文、原始排版或逐字归档时,不能指望 JSONB 还能还原原始字节。[11]

sql
SELECT '{"b":2,"a":1,"a":3}'::jsonb;

实际输出:

text
{"a": 3, "b": 2}

输出中的键次序来自该次序列化,不应成为程序协议。JSONB 的主要价值在于按结构读取和匹配:

要做的事常见操作需要区分什么
保留 JSON 类型取字段attrs -> 'name'得到 JSONB 值,不一定是文本
取文本attrs ->> 'name'文本若要参与数值比较,要合理校验并转换
取嵌套路径#>、#>>结构不存在与 JSON null 的区别
判断包含某结构@>是结构包含,不是任意字符串子串搜索
判断顶层键/数组字符串元素?、?|、?&? 不会递归查所有深层键
JSON path 查询@?、@@ 等表达式语义与可用索引操作符类

SQL NULL 与 JSON 的 null 是不同层级。下面三个结果依次为 JSON null、本值是否 SQL NULL、缺字段是否 SQL NULL:

sql
SELECT '{"x":null}'::jsonb -> 'x',
       ('{"x":null}'::jsonb -> 'x') IS NULL,
       ('{}'::jsonb -> 'x') IS NULL;
text
null|f|t

显式 JSON null 仍是一个 JSONB 值,所以第二列为 false;字段不存在时提取结果为 SQL NULL。若用 ->> 取文本,这些情况又可能合并成 SQL NULL,业务必须决定需不需要保留区分。[12]

将可扩展的商品属性放 JSONB 很自然,但订单金额、外键、状态等核心约束不该因为“JSON 灵活”就失去明确类型和约束。可以把稳定核心字段放普通列,把变化较多的附加属性放 JSONB,再按实际过滤条件增加索引。大文档上的修改仍然是行更新,可能需要锁整行并产生版本与存储开销;拆得太少也会形成争用热点。[11:1]

先看运算,再挑索引名字 ​

PostgreSQL 的索引类型是不同的访问方法;操作符类 operator class 告诉访问方法,某个类型的运算应该怎样映射到索引。写了 USING gin 并不表示任何 JSON 查询都能走它。

访问方法怎样减少候选典型用途主要取舍
B-tree沿有序键定位范围等值、范围、排序;提取出的 JSON 标量对全文包含、空间重叠等不总合适
GIN把一份复合值拆成多个键,保存键到记录位置的关联JSONB 包含、数组成员、全文词项多键维护导致写放大;通常不能直接还原整个值以做 Index Only Scan
GiST可扩展的树框架,以运算相关的摘要缩小候选范围重叠、几何关系、某些最近邻效率、是否有损和可用排序取决于操作符类
BRIN按连续物理页区间保存摘要,排除不可能匹配的区间与物理位置相关的时间、递增序列大表索引小,但候选页仍需重检;数据无相关性时可能筛不掉多少

这些不是“好、较好、最好”的排名。要查哪些运算、更新多频繁、列与物理顺序多相关,才决定合适的索引。[13][14][15][16]

GIN:从“某个属性值”找到包含它的文档 ​

本次造了 10,000 份文档,每份含不同的 rare 值,建立 jsonb_path_ops GIN:

sql
CREATE INDEX docs_gin ON docs USING gin(attrs jsonb_path_ops);
SELECT id FROM docs WHERE attrs @> '{"rare":7777}';

实际返回 7777,执行计划包含:

text
Bitmap Index Scan on docs_gin ... (actual rows=1.00 loops=1)
  Index Cond: (attrs @> '{"rare": 7777}'::jsonb)

计划外层还有 Bitmap Heap Scan 与重检条件,不能把命中 GIN 理解为直接从索引返回完整文档。

JSONB GIN 操作符类支持的主要查询如何取舍
默认 jsonb_ops?、?|、?&,以及 @>、@?、@@ 等查询形态更广;索引可能更大
jsonb_path_ops@>、@?、@@,不支持键存在运算符那一组对适合的路径/值包含查询通常更紧凑、更有选择性;不适合只查某些空结构等场景

如果业务主要按 attrs->>'status' = 'open' 过滤,可以考虑对应表达式的 B-tree,而不是指望任意整列 GIN 都匹配这个表达式。GIN 的 fastupdate/pending list 等机制可以推迟部分维护工作,但最终仍要清理处理,写入延迟和维护成本需要一起观察。[17][18]

GiST:把“范围相交”交给理解范围的索引 ​

本例每条范围是 [g,g+10),查与 [500,501) 相交的记录:

sql
CREATE INDEX ranges_gist ON ranges USING gist(during);
SELECT COUNT(*) FROM ranges
WHERE during && int4range(500,501,'[)');

实际输出 10,对应起点 491 到 500。实际计划使用 ranges_gist 的 Bitmap Index Scan。&& 表示范围重叠,GiST 与内置 range 操作符类能利用这类关系定位候选。若改成两个普通端点列,也能写出逻辑等价条件,但索引规划能力不必完全相同。[19]

范围类型还能结合排他约束表达“同一资源的预约时间不得重叠”,但约束涉及列类型、操作符类和可能的扩展支持,需要单独设计,不能仅在应用里先查一次再插入。

BRIN:先排除整片物理区域,再检查区域内的行 ​

实验按 id 递增插入 100,000 行,建立每 16 页一个范围的 BRIN。查询 id BETWEEN 99000 AND 99010,实际返回 11 行。计划关键部分:

text
Bitmap Heap Scan on events ... (actual rows=11.00 loops=1)
  Rows Removed by Index Recheck: 1653
  Heap Blocks: lossy=32
  -> Bitmap Index Scan on events_brin

BRIN 帮忙选出了 32 个候选堆页,页里还有大量不匹配行,重检后去掉 1,653 行。lossy 在这里表示索引/位图只精确到页区域等较粗粒度,不表示查询结果错误。它用更小的索引换取更多候选重检。若时间列与插入顺序高度相关,很多无关区间可以整体跳过;如果数据随机散落,摘要范围彼此重叠,效果会差很多。[16:1]

pages_per_range 越小通常摘要更细,但索引也更大。新页区间还需要 summary,autosummarize 等维护设置影响何时补齐;不能只建完一次就假定后续追加数据永久具有同样摘要覆盖。本文只验证静态测试表,不测长期维护效果。

扩展生态:能力很强,也意味着多一份依赖 ​

扩展把函数、类型、操作符、索引支持等对象作为一个可安装和升级的单元。服务器必须先有相应扩展文件,再在指定数据库执行 CREATE EXTENSION。某些扩展需要超级用户,有些被标记为 trusted,在满足相应数据库权限时可由普通角色安装;这是能力与安全边界,不是一个可以随意运行外部代码的免审入口。[20]

扩展值得了解的实际用途采用前检查
pg_stat_statements按归一化语句累计规划/执行统计,帮助找到总耗时高的 SQL需要相关预加载与配置;观察的是累计统计,重启/重置和采样窗口要明确
pg_trgm将文本拆成三字符片段,支持相似度及合适的 LIKE/正则查询索引短查询、语言与分词需求可能不同;相似度不是语义理解
postgres_fdw在一个 PostgreSQL 中访问另一 PostgreSQL 的外部表,部分运算可下推网络开销、远端计划、权限、事务一致性边界;不能当作免费的本地表
PostGIS为地理/几何数据提供类型、关系运算及空间索引能力坐标系、单位、geometry/geography 区别和版本兼容
pgvector在 PostgreSQL 中保存向量并做精确或近似近邻查询向量维度、距离度量、索引维护、过滤后召回率;近似查询需要质量验证

前三项属于 PostgreSQL 官方随附扩展文档;后两项来自各项目官方资料,未在本次环境安装或运行。pgvector 的说明固定参照 v0.8.0 README,不声称这是当前最新版。扩展能让一套数据库承接更多查询,但也把升级、备份恢复兼容和资源争用带进同一系统。[21][22][23][24][25]

选扩展时先问:这是需要在事务库旁边直接完成的功能,还是会抢占在线事务资源的大型专用负载?已有 SQL、权限和备份能力能复用是优点,独立扩容、不同故障域和专门运维也是成本维度。没有真实负载数据时,不能仅凭“支持插件”下容量结论。

这组实验能说明什么 ​

实际看到了 UPDATE 后 ctid 变化、JSONB 重复键合并及 null 区别、GIN 包含检索、GiST 范围重叠、BRIN 的有损候选重检。执行计划未强制关闭顺序扫描,保留了优化器自行选择。临时表、人工分布、单用户模式只适合观察机制,不适合用单次耗时推导生产吞吐。

没有测试 autovacuum 后台触发、长事务积压、事务 ID 回卷、扩展安装/升级或并发访问。这些部分严格以官方资料解释。真正上线前,应在接近实际的持久表、更新比例、索引组合与维护配置下补测。

参考资料 ​

完整复现材料 ​

需要 PostgreSQL 18.6 的 postgres/initdb 和 Python 3。只对新建可丢弃数据目录执行,不能与其他进程同时使用同一目录。单用户模式限制见官方 postgres 说明。

sh
export POSTGRES=/path/to/postgresql-18.6/bin/postgres
export PGDATA="$(mktemp -d)/data"
/path/to/postgresql-18.6/bin/initdb -D "$PGDATA" -A trust --no-locale -E UTF8
python3 run_single.py postgresql.sql

monitor.sql 是正常服务实例的只读检查材料,本次未运行。运行它需要正常连接和相应统计可见权限,按自己的连接设置使用 psql -X -f monitor.sql。不要从不可见的统计行推断“没有长事务”。

postgresql.sql ​

代码块 · 41 行 · 点击收起 / 展开
sql
\set ON_ERROR_STOP on
CREATE TEMP TABLE versions(id integer PRIMARY KEY, value text);
INSERT INTO versions VALUES(1,'old');
\echo version-before
SELECT id,value,ctid FROM versions;
UPDATE versions SET value='new' WHERE id=1;
\echo version-after
SELECT id,value,ctid FROM versions;
VACUUM (ANALYZE) versions;
\echo jsonb-shape
SELECT '{"b":2,"a":1,"a":3}'::jsonb;
SELECT '{"x":null}'::jsonb->'x' AS json_null,
       ('{"x":null}'::jsonb->'x') IS NULL AS json_is_sql_null,
       ('{}'::jsonb->'x') IS NULL AS missing_is_sql_null;
SELECT '{"tags":["go","sql"]}'::jsonb @> '{"tags":["go"]}'::jsonb AS contains,
       '{"tags":["go","sql"]}'::jsonb ? 'tags' AS top_key;
CREATE TEMP TABLE docs(id integer PRIMARY KEY, attrs jsonb NOT NULL);
INSERT INTO docs SELECT g,jsonb_build_object('team',g%100,'rare',g,'status','open')
FROM generate_series(1,10000) g;
CREATE INDEX docs_gin ON docs USING gin(attrs jsonb_path_ops);
ANALYZE docs;
\echo gin
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM docs WHERE attrs @> '{"rare":7777}';
SELECT id FROM docs WHERE attrs @> '{"rare":7777}';
CREATE TEMP TABLE ranges(id integer PRIMARY KEY,during int4range NOT NULL);
INSERT INTO ranges SELECT g,int4range(g,g+10,'[)') FROM generate_series(1,10000) g;
CREATE INDEX ranges_gist ON ranges USING gist(during);
ANALYZE ranges;
\echo gist
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM ranges WHERE during && int4range(500,501,'[)');
SELECT COUNT(*) FROM ranges WHERE during && int4range(500,501,'[)');
CREATE TEMP TABLE events(id integer NOT NULL,payload text NOT NULL);
INSERT INTO events SELECT g,repeat('x',120) FROM generate_series(1,100000) g;
CREATE INDEX events_brin ON events USING brin(id) WITH(pages_per_range=16);
ANALYZE events;
\echo brin
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM events WHERE id BETWEEN 99000 AND 99010;
SELECT COUNT(*) FROM events WHERE id BETWEEN 99000 AND 99010;

run_single.py ​

代码块 · 42 行 · 点击收起 / 展开
python
"""Run the deliberately simple fixtures shipped with these articles.
Input: ordinary SQL and optional psql \\set/\\pset/\\echo directives.
This is a fixture adapter, not a general SQL parser. No fixture may contain
semicolons inside quoted values, comments, function bodies, or identifiers.
"""
import os
from pathlib import Path
import re
import subprocess
import sys

sql = Path(sys.argv[1]).read_text()
lines = []
for line in sql.splitlines():
    if line.startswith('\\echo '):
        label = line[len('\\echo '):].replace("'", "''")
        lines.append("SELECT '" + label + "';")
    elif line.startswith('\\'):
        continue
    else:
        lines.append(line)
commands = []
for part in '\n'.join(lines).split(';'):
    part = part.strip()
    if not part:
        continue
    # COPY gives legible values; it does not change the SELECT's semantics.
    if re.match(r'(?i)^(SELECT|WITH)\b', part):
        part = "COPY (" + part + ") TO STDOUT WITH (DELIMITER '|', NULL 'NULL')"
    commands.append(part + ';\n\n')
run = subprocess.run(
    [os.environ.get('POSTGRES', 'postgres'), '--single', '-j',
     '-D', os.environ['PGDATA'], 'postgres'],
    input=''.join(commands), text=True, capture_output=True)
print(run.stdout, end='')
print(run.stderr, end='', file=sys.stderr)
if run.returncode:
    sys.exit(run.returncode)
# The stand-alone backend may recover from a SQL error and exit zero.
# An ERROR is still a failed fixture; expected failures are checked outside.
if re.search(r'\b(ERROR|FATAL|PANIC):', run.stderr):
    sys.exit(1)

monitor.sql ​

sql
-- PostgreSQL 18 normal server inspection; not measured in single-user lab.
SELECT relname,n_live_tup,n_dead_tup,last_autovacuum,last_autoanalyze
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;
SELECT datname,age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;
SELECT pid,state,xact_start,backend_xmin,wait_event_type,wait_event
FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start;
SELECT name,default_version,installed_version FROM pg_available_extensions
WHERE name IN ('pg_trgm','pg_stat_statements','postgres_fdw','postgis','vector')
ORDER BY name;

  1. PostgreSQL 18 MVCC。 ↩︎

  2. PostgreSQL 18 Transaction Isolation。 ↩︎

  3. PostgreSQL 18 System Columns。 ↩︎

  4. PostgreSQL 18 HOT。 ↩︎

  5. PostgreSQL 18 Routine Vacuuming。 ↩︎ ↩︎ ↩︎

  6. PostgreSQL 18 Replication Slots。 ↩︎

  7. PostgreSQL 18 VACUUM。 ↩︎

  8. PostgreSQL 18 ANALYZE。 ↩︎

  9. PostgreSQL 18 Visibility Map。 ↩︎

  10. PostgreSQL 18 Index-Only Scans。 ↩︎

  11. PostgreSQL 18 JSON Types。 ↩︎ ↩︎

  12. PostgreSQL 18 JSON Functions and Operators。 ↩︎

  13. PostgreSQL 18 Index Types。 ↩︎

  14. PostgreSQL 18 GIN。 ↩︎

  15. PostgreSQL 18 GiST。 ↩︎

  16. PostgreSQL 18 BRIN。 ↩︎ ↩︎

  17. PostgreSQL 18 JSONB Indexing。 ↩︎

  18. PostgreSQL 18 GIN Implementation。 ↩︎

  19. PostgreSQL 18 Range Types。 ↩︎

  20. PostgreSQL 18 CREATE EXTENSION。 ↩︎

  21. PostgreSQL 18 pg_stat_statements。 ↩︎

  22. PostgreSQL 18 pg_trgm。 ↩︎

  23. PostgreSQL 18 postgres_fdw。 ↩︎

  24. PostGIS 官方入门与文档。 ↩︎

  25. pgvector v0.8.0 官方 README。 ↩︎

保留各书来源与许可 · 示例运行条件以正文为准