PostgreSQL 18 深度实战:异步 I/O 引擎、虚拟生成列与 uuidv7,再到用 pgvector 把数据库变成 RAG 底座
选题来源:PostgreSQL 18(2026-07-07 正式发布)+ pgvector 生态
适用读者:用过 PostgreSQL、但不一定追每一个大版本细节的后端 / 全栈工程师
说明:文中所有 SQL、配置、压测命令均按 PostgreSQL 18 形态给出,可直接落地
一、背景:当"一个数据库"重新变得性感
2026 年 7 月 7 日,PostgreSQL 全球开发组宣布 PostgreSQL 18 正式发布。如果你和我一样,过去两年被"专用数据库"的浪潮裹挟过--进程内分析用 DuckDB、向量检索用 zvec、可观测性用 Prometheus、缓存用 Redis、搜索用 Elasticsearch--你可能会下意识问一句:关我 PostgreSQL 什么事?
这恰恰是我写这篇文章的出发点。专用引擎确实在单点上极致:DuckDB 把 OLAP 塞进进程里,零部署、单文件、即开即算;zvec 这类向量库把 ANN 检索做到十亿级低延迟;Prometheus 的 Pull 模型把指标采集玩出花。但工程现实是另一回事:大多数团队的瓶颈从来不是"某个查询慢了 30%",而是"同时又多了一套要运维、要备份、要授权、要排障、要和主库保持数据同步的系统"。每多一个组件,故障域、认知负担、人力成本就指数级往上加。
PostgreSQL 18 这一版最值得关注的地方,不是某一个花哨特性,而是它把“一个数据库能兜住多少事”的天花板又往上推了一大截。我们把 headline 特性摊开看:
- 新的 异步 I/O 子系统,官方宣称从存储读取时性能最高提升 3 倍;
- B-tree 跳跃扫描(skip scan),让更多原本只能全表扫描的查询能吃到索引;
- 虚拟生成列(VIRTUAL generated columns),并且可以对其建索引——派生字段从此零写入成本;
- uuidv7(),把“UUID 导致索引膨胀”这个困扰了无数人的老毛病从根上缓解;
- OAuth 2.0 鉴权,让数据库正式进入企业 SSO 与零信任体系;
- 叠加早已成熟的 pgvector 扩展,PostgreSQL 已经能直接扮演 RAG 应用的向量底座,不再需要独立的向量服务。
为了说清楚“为什么是现在、为什么是 PG18”,我们不妨把几类数据库摆在一张对照表里看它们各自的命门:
| 引擎 | 定位 | 最大优势 | 最大软肋 |
|---|---|---|---|
| PostgreSQL 18 | 通用关系型 + 扩展生态 | 一个库兜底事务/分析/搜索/向量,运维面最小 | 单写主拓扑,超大规模写入需分片 |
| DuckDB | 进程内 OLAP | 零部署、单文件、Notebook 即席分析极快 | 不是服务器,不支持并发多写与远程访问 |
| zvec 等向量库 | 进程内 / 专用 ANN | 十亿级向量低延迟,内存效率极高 | 只解决向量检索,结构化与事务要另算 |
| Prometheus | 指标时序 | Pull 模型、TSDB、告警生态成熟 | 只管指标,不是通用存储 |
这张表想表达的核心判断是:专用引擎赢在“单点极致”,PostgreSQL 赢在“面够宽、边界远”。当你今天引入 DuckDB,明天引入 zvec,后天引入 Prometheus,你省下的那点单点延迟,最后都变成了排障深夜里的白发。PG18 的意义,是把“够用”的边界推远,让你能更晚、也更从容地引入专用引擎——而不是永远不引入。
本文不会罗列 release notes 然后散场,而是带你把这些特性从原理拆到代码、从配置拆到压测、从单表优化拆到架构取舍,最后用一个能跑起来的 RAG 实战收尾。读完你应该能自己判断:什么场景 PG18 足够一统江湖,什么场景你确实还得把 DuckDB / 专用向量库请出来。
二、核心概念:PG18 到底改了什么
2.1 异步 I/O 子系统(io_method / io_uring)
这是 PG18 最重磅的改动,没有之一。要理解它的价值,先得理解旧模型为什么别扭。
过去 PostgreSQL 读数据走的是"同步阻塞 I/O":后端进程(backend)发起 pread() 系统调用,然后干等内核把数据从块设备搬进内存,才能继续往下执行。这在慢机械盘时代问题不大--磁盘本身比 CPU 慢几个数量级,等就等了。但在 NVMe、云 SSD、网络存储上,I/O 延迟已经压到亚毫秒,CPU 却常常在等 I/O 的空档里被白白浪费,存储的队列深度也根本喂不满。
PG18 引入了一个 I/O 工作池(I/O worker pool) 抽象,核心由几个 GUC 控制:
| 参数 | 含义 | 建议 |
|---|---|---|
io_method | I/O 实现:worker(默认)、io_uring(Linux)、sync(兼容旧行为) | Linux + 支持 io_uring 的存储优先 io_uring |
io_max_concurrency | 最大并发 I/O 请求数,0 表示自动按 max_worker_processes 推算 | 高速存储可调大到 128~256 |
io_combine_limit | 合并相邻 I/O 的最大字节数 | 一般保持默认 |
在 io_uring 模式下,PostgreSQL 借助 Linux 的 io_uring 提交/完成队列,把"发起读"和"拿到结果"彻底解耦--后端把 I/O 请求交给 worker 后就去做别的事(比如解析下一个算子、构建哈希表),数据就绪后再被唤醒。顺序扫描、位图堆扫描(bitmap heap scan)、并行查询的预读都因此受益。官方那句"读取最高提速 3 倍",准确地说,发生在"大顺序扫描 + 快存储 + 多并发"的工作负载上,而不是所有查询都无条件变快。这一点我们会在第四章用 pgbench 实测来校准预期,免得你上线后对着 5% 的提升怀疑人生。
2.2 B-tree 跳跃扫描(Skip Scan)
PG18 让优化器在很多情况下能自动对复合索引做"跳跃扫描"。这是另一个容易被低估的红利。
设想你有一张多租户表 orders,索引是 (tenant_id, created_at)。旧版 PostgreSQL 里,下面这条只过滤 created_at 的查询用不上这个复合索引的前导列,只能走全表扫描或仅仅利用 created_at 的松散部分:
-- PG18 之前:大概率全表扫描
SELECT * FROM orders WHERE created_at > now() - interval '1 day';
-- PG18:优化器会对 tenant_id 的每个不同值做"子范围跳跃",
-- 在每个子区间内用 created_at 做索引范围扫描
EXPLAIN (COSTS OFF)
SELECT * FROM orders WHERE created_at > now() - interval '1 day';
-- 计划里会出现 Index Scan / Skip Scan 相关节点
这就是 release notes 里说的"增加了可以使用索引的查询数量"。对那种"前导列区分度低、但业务查询常常不带上它"的宽表,跳跃扫描能消掉大量全表扫描。代价是多次索引定位,所以只在 tenant_id 的不同值不多、且 created_at 选择性强时才划算--它不是万能药,但覆盖了很多真实的多租户 schema。
2.3 虚拟生成列(VIRTUAL generated columns)
PostgreSQL 在 18 之前只支持 STORED 生成列--值算好写进磁盘,读取快,但写入有放大(每次 UPDATE 都要重算并落盘,哪怕你改的是别的列)。PG18 终于补上了 VIRTUAL:
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL
VIRTUAL 列不占存储、不增加任何写入成本,读取时才现算。更关键的是:PG18 允许你在虚拟生成列上建索引。这意味着你能同时拿到"现算派生字段"的零写入成本,和"把结果物化进索引"的查询速度--鱼和熊掌这次都给了。典型用途:归一化邮箱(大小写、去空格)、拼接姓名、预生成 tsvector 全文检索向量、从 JSONB 里抽取某个字段。
2.4 uuidv7():给索引友好的主键
uuidv4() 是纯随机的,每次插入都落到一个随机的 B-tree 位置,导致大量页分裂、索引膨胀、缓存局部性极差--热数据被随机打散到全表,buffer pool 命中率雪崩。uuidv7() 把 48 位毫秒时间戳放在 UUID 最前面,后面跟随机数,于是新插入的主键在 B-tree 上是近似单调递增的:插入点稳定、页分裂少、冷热数据自然分层。对于"按时间范围扫描"的业务(订单、事件、日志、审计),收益非常直接,而且完全免费--你只需要在建表时换个默认值。
2.5 OAuth 2.0 鉴权
PG18 新增 OAuth 2.0 认证方式,让 PostgreSQL 可以直接对接企业 IdP(身份提供商),用短期令牌(token)而非静态密码完成登录。对跑在 Kubernetes / 云环境里的服务来说,这意味着数据库能纳入统一的 SSO 与凭证轮换体系,pg_hba.conf 里把某条规则的方法改成 oauth 即可。这是把数据库从"一堆散落的账号密码"拉进现代零信任体系的一步。注意它与传统密码认证并存,而非取代。
2.6 更平滑的大版本升级
pg_upgrade 在 18 里进一步降低了停机时间:--sync-method 控制文件同步语义,--copy 与增量拷贝策略让大库升级更快、对线上影响更小。对还在 PG14/15 上犹豫是否升级的团队,这是个好消息--不过本文重点是新特性实战,升级细节点到为止,第六章会给迁移路线。
三、架构分析:这些特性在引擎里如何落地
异步 I/O 的落地路径:backend 不再亲自 pread(),而是把 I/O 请求描述(文件、偏移、长度、目标 buffer)投递到 I/O 工作池;worker 进程在底层用 io_uring 批量提交,内核完成后再通过完成队列通知。数据最终落到 shared buffers,backend 在被唤醒时直接拿到已填充的页。关键在于--backend 在等 I/O 期间不再阻塞整个连接的处理,并行查询里多个 worker 的 I/O 也能被合并、乱序完成,从而吃满存储的队列深度。这也是为什么 pg_stat_io 视图(PG16 引入、PG18 持续完善)里你能看到 reads 和 extends 的异步排队情况。
虚拟生成列 + 索引的精妙处:表达式本身只存在系统目录里,普通查询读取行时由执行器现算;而当你在它上面 CREATE INDEX,PostgreSQL 会把"计算后的结果"物化进索引页(仅索引里存一份,基表不存)。于是查询走索引时根本不碰基表、也不触发表达式重算,写入时基表零负担--这正是"派生字段 + 高性能"的最优解。代价是索引本身多占一份空间,所以只给真正被查询的派生列建索引。
uuidv7 的 B-tree 友好性:UUIDv7 布局为 48 位大端时间戳 | 12 位版本/变体位 | 74 位随机,最前面的时间戳保证同一毫秒内的插入在索引里相邻。对比 uuidv4 的完全随机,uuidv7 的 B-tree 叶子节点填充率更高、FILLFACTOR 退化更慢,长期运行的表更少需要 REINDEX,buffer pool 也更能保留热数据。
跳跃扫描机制:优化器把 (a, b) 索引当成"先按 a 分组、组内按 b 有序"。当查询只过滤 b 时,它对 a 的每个不同取值做一次"定位到该子区间再做 b 的范围扫描",跳过不相关的 a 区间。当 a 的不同值只有几十到几百个、而表有几千万行时,跳跃扫描远快于全表扫描;但若 a 几乎唯一,跳跃扫描反而退化成多次全索引扫描,优化器通常能正确识别并放弃。
四、代码实战
4.1 起一个 PG18(Docker,30 秒)
docker run -d --name pg18 \
-e POSTGRES_PASSWORD=pgpass \
-p 5432:5432 \
postgres:18
# 确认版本
docker exec -it pg18 psql -U postgres -c "SELECT version();"
-- PostgreSQL 18.x on ...
4.2 异步 I/O 调优 + pgbench 实测
修改配置(Docker 里可用 ALTER SYSTEM,改完 SELECT pg_reload_conf();):
ALTER SYSTEM SET io_method = 'io_uring'; -- Linux + 支持 io_uring 时
ALTER SYSTEM SET io_max_concurrency = 128; -- 高速存储可上调
ALTER SYSTEM SET effective_io_concurrency = 256;
SELECT pg_reload_conf();
用 pgbench 做对照(务必先理解:3 倍不是"所有查询都变快",而是大顺序扫描 / 并行读场景):
# 初始化一个偏读的工作负载(-s 100 ≈ 约 10GB 基础表)
docker exec -it pg18 pgbench -U postgres -i -s 100 pgbench
# 基线:关闭异步 I/O
docker exec -it pg18 psql -U postgres -c "ALTER SYSTEM SET io_method='sync'; SELECT pg_reload_conf();"
docker exec -it pg18 pgbench -U postgres -c 16 -j 16 -T 60 -S pgbench # -S = 只读
# 开启 io_uring
docker exec -it pg18 psql -U postgres -c "ALTER SYSTEM SET io_method='io_uring'; SELECT pg_reload_conf();"
docker exec -it pg18 pgbench -U postgres -c 16 -j 16 -T 60 -S pgbench
读 TPS 的差异,在 NVMe / 云 SSD 上会明显得多;在本地 SATA 盘或数据全在 OS 缓存里时,差距会收敛。这正是"按工作负载调优"的意义。你还可以用下面的查询观察 I/O 是否真的走异步:
SELECT context, reads, read_time, writes, write_time
FROM pg_stat_io
WHERE reads > 0
ORDER BY reads DESC;
4.3 虚拟生成列 + 索引
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY,
first_name text,
last_name text,
email text,
-- 派生但不占存储
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL,
email_norm text GENERATED ALWAYS AS (lower(btrim(email))) VIRTUAL,
search tsvector GENERATED ALWAYS AS (
to_tsvector('simple',
coalesce(first_name,'') || ' ' ||
coalesce(last_name,'') || ' ' ||
coalesce(email,''))
) VIRTUAL
);
-- 关键:在 VIRTUAL 列上建索引(PG18 新能力)
CREATE INDEX idx_users_full_name ON users(full_name);
CREATE INDEX idx_users_email_norm ON users(email_norm);
CREATE INDEX idx_users_search ON users USING gin(search);
INSERT INTO users (first_name, last_name, email) VALUES
('Ada', 'Lovelace', 'Ada@Example.COM'),
('Alan', 'Turing', 'alan.turing@example.com'),
('Grace', 'Hopper', 'grace@example.com');
-- 走 email_norm 索引,大小写都被归一化
EXPLAIN (COSTS OFF, ANALYZE)
SELECT * FROM users WHERE email_norm = 'ada@example.com';
-- Index Scan using idx_users_email_norm
-- 全文检索走 GIN
SELECT full_name, email FROM users
WHERE search @@ plainto_tsquery('simple', 'turing');
要点:VIRTUAL 列本身零存储、零写入放大,但三个索引会各自物化一份结果。原则是"只给真正被查询的派生列建索引",别无脑全建。
4.4 uuidv7 实战 + 看索引局部性
CREATE TABLE events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
payload jsonb,
created_at timestamptz DEFAULT now()
);
INSERT INTO events (payload)
SELECT jsonb_build_object('n', g) FROM generate_series(1, 200000) g;
-- 看主键 B-tree 的页填充情况(需要 pageinspect 扩展)
CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT avg(avg_leaf_density) AS 叶子密度,
max(page_size) AS 页大小
FROM bt_page_stats('events_pkey', 0);
把同一张表用 uuidv4() 再建一份做对照,你会看到 uuidv7 的叶子密度更高、页分裂更少。对"按 created_at 范围扫 + 按主键点查"混合负载,uuidv7 几乎是免费的性能红利。
4.5 JSONB + GIN:半结构化数据也不怕
PG18 的 GIN 索引对 JSONB 依旧是标配,配合虚拟生成列还能把深层字段抽出来建索引:
CREATE TABLE products (
id bigserial PRIMARY KEY,
doc jsonb,
sku text GENERATED ALWAYS AS (doc->>'sku') VIRTUAL
);
CREATE INDEX idx_products_sku ON products(sku);
CREATE INDEX idx_products_doc ON products USING gin (doc jsonb_path_ops);
-- 精确走 sku 虚拟列索引
SELECT * FROM products WHERE sku = 'ABC-123';
-- 任意 JSON 路径走 GIN
SELECT * FROM products WHERE doc @> '{"status":"active"}';
4.6 OAuth2 / 鉴权(配置示意)
pg_hba.conf 中把对应连接规则的方法改为 oauth,并配合角色与 IdP 配置:
# pg_hba.conf
host all app_user 10.0.0.0/8 oauth
配合 ALTER ROLE app_user LOGIN; 与 IdP 的 issuer 配置,服务用短期 OAuth 令牌而非长期密码登录。注意:password_encryption(SCRAM)仍是默认的密码哈希方式,OAuth2 是与之并存的新选项,不是替代所有密码认证。
4.7 pgvector:把 PostgreSQL 变成 RAG 底座
这是最有"一统江湖"实感的一节。装扩展、建表、建 HNSW 索引:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE docs (
id bigserial PRIMARY KEY,
content text,
embedding vector(1536) -- 以 OpenAI text-embedding-3-small 为例
);
CREATE INDEX docs_embedding_idx
ON docs USING hnsw (embedding vector_cosine_ops);
纯向量相似度检索(<=> 是余弦距离算子):
SELECT id, content,
1 - (embedding <=> :q) AS score
FROM docs
ORDER BY embedding <=> :q
LIMIT 5;
混合检索(FTS + 向量)用 RRF 融合--通常比单一路召回质量更高:
WITH fts AS (
SELECT id,
row_number() OVER (ORDER BY ts_rank_cd(search, q) DESC) AS rnk
FROM docs, plainto_tsquery('simple', :q) q
WHERE search @@ q
),
vec AS (
SELECT id,
row_number() OVER (ORDER BY embedding <=> :q) AS rnk
FROM docs
ORDER BY embedding <=> :q
LIMIT 50
)
SELECT COALESCE(fts.id, vec.id) AS id,
1.0 / (60 + fts.rnk) + 1.0 / (60 + vec.rnk) AS score
FROM fts FULL OUTER JOIN vec ON fts.id = vec.id
ORDER BY score DESC
LIMIT 5;
Python 端把文档切块、调嵌入接口、写入数据库(注意用 pgvector 的 register_vector 适配器,避免手动拼向量字符串):
import openai, psycopg
from pgvector.psycopg import register_vector
client = openai.OpenAI()
conn = psycopg.connect("postgres://app_user:pass@10.0.0.5:5432/app")
register_vector(conn)
def embed(text: str) -> list[float]:
r = client.embeddings.create(model="text-embedding-3-small", input=text)
return r.data[0].embedding
# 切块写入(chunk 是你的切片对象,含 text 字段)
for chunk in chunked_documents:
conn.execute(
"INSERT INTO docs (content, embedding) VALUES (%s, %s)",
(chunk.text, embed(chunk.text)),
)
# 查询
q = embed("PostgreSQL 18 的异步 I/O 怎么调?")
rows = conn.execute(
"SELECT id, content, 1 - (embedding <=> %s) AS score "
"FROM docs ORDER BY embedding <=> %s LIMIT 5",
(q, q),
).fetchall()
到这里,你的 RAG 检索层完全跑在 PostgreSQL 里:结构化字段、事务、全文检索、向量召回、混合排序,一套数据库全包。你不需要再起一个向量服务、再维护一份数据同步管道--这才是 PG18 真正的杀伤力。
五、性能优化清单(可直接照抄)
I/O 层
io_method = io_uring(Linux + 支持时),io_max_concurrency按存储队列深度调到 128~256;effective_io_concurrency调到与底层并行度匹配(云盘常设 128~256),利好位图堆扫描;- 用
EXPLAIN (ANALYZE, BUFFERS)看shared read是否成为瓶颈,再决定要不要继续推 I/O 并发; - 定期查
pg_stat_io,确认reads在异步路径上、且read_time在下降。
索引与数据类型
- 主键、且按时间扫描的表,优先
uuidv7()而非uuidv4(); VIRTUAL生成列只给"被查询"的派生字段建索引,避免无谓的索引膨胀;- 复合索引别忘了 PG18 的跳跃扫描--有时 reorder 前导列、或干脆依赖 skip scan 就能消掉全表扫描;
- JSONB 用
jsonb_path_ops的 GIN 索引,体积小、 Containment 查询快。
向量检索(pgvector)
- HNSW 的
m(每层连接数,默认 16)越大召回越好但更占内存;ef_construction(建索引时考察邻域,默认 64)越大索引质量越高; - 查询前
SET LOCAL hnsw.ef_search = 200;(默认 40)提升召回; work_mem给足,GIN / HNSW 的构建和查询都吃内存;- 千万级以上向量、且要求个位数毫秒延迟时,再认真评估专用向量库(如 zvec 这类进程内方案或托管服务)。
方法论
- 一切优化先
pgbench建基线、EXPLAIN (ANALYZE, BUFFERS)看真实成本,别凭直觉调参; - 只读副本上大胆开并行与异步 I/O,主库写多则优先保证写入稳定。
六、迁移实战:从 PG15/16 平滑升到 18
很多团队卡在"不敢升"。PG18 的 pg_upgrade 已经把停机压得很短,典型流程:
# 1) 旧库用 --link 之外的方式做逻辑备份(保险)
pg_dumpall -h oldhost -p 5432 -U postgres > /backup/pre_upgrade.sql
# 2) 安装 PG18 二进制,初始化新数据目录
/usr/lib/postgresql/18/bin/initdb -D /var/lib/pgsql/18/data
# 3) 用 pg_upgrade 做原地升级(--copy 复制文件,--sync-method=fsync 控制同步语义)
/usr/lib/postgresql/18/bin/pg_upgrade \
--old-datadir /var/lib/pgsql/15/data \
--new-datadir /var/lib/pgsql/18/data \
--old-bindir /usr/lib/postgresql/15/bin \
--new-bindir /usr/lib/postgresql/18/bin \
--copy --sync-method=fsync
# 4) 升完后别忘了重建统计信息 & 检查扩展版本
/usr/lib/postgresql/18/bin/vacuumdb --all --analyze-in-stages
升级后第一时间验证新特性是否生效:
SHOW io_method; -- 应为 io_uring / worker
SELECT uuidv7(); -- 应能生成 v7 UUID
CREATE TABLE t(c text GENERATED ALWAYS AS ('x') VIRTUAL); -- 应成功
七、常见坑(踩过才懂)
io_uring不是所有环境都能用:某些容器、WSL、虚拟机里 io_uring 会被 seccomp 拦掉,这时退回worker即可,性能也明显优于sync。- VIRTUAL 列不能引用其他 VIRTUAL 列,也不能出现在 CHECK / 外键里--它是"读取时现算"的,约束语义上不支持。
- uuidv7 不是严格单调:毫秒内的随机部分仍可能让极少数插入乱序,别拿它当全局序列号用;需要严格有序请用
GENERATED AS IDENTITY或序列。 - HNSW 索引建的时候会吃大量内存和临时空间,大表建索引前务必调高
maintenance_work_mem,否则可能 OOM 或巨慢。 - 跳跃扫描不是永远更快:前导列基数巨大时它会退化,遇到诡异慢查询先
EXPLAIN看是不是 skip scan 选错,必要时用SET enable_index_skip_scan = off;对比。
七点五、监控闭环:用 pg_stat_io 与 pg_stat_statements 把优化做成可量化
调参最怕"玄学"。PG18 给了我们两个把优化变成可量化动作的视图,建议你上线前后都跑一遍。
pg_stat_io:PG16 引入、PG18 持续完善,它把每一次 I/O 按 context(如 normal、vacuum、initdb)和 op(reads/writes/extends)拆开统计。异步 I/O 调好后,你应该看到 reads 数量上升、但 read_time 下降--因为请求被合并、乱序完成,后端不再阻塞等待:
-- 找出最耗时的 I/O 上下文
SELECT context, op,
reads, read_time,
writes, write_time,
extend_time
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY read_time DESC NULLS LAST
LIMIT 10;
如果你开了 io_uring 却发现 read_time 纹丝不动,先确认三件事:存储是不是真的快(本地 SATA 盘没戏)、数据是不是已经在 OS page cache 里(那样根本不落盘)、io_max_concurrency 是不是还是默认的 0 没生效。很多"调了没效果"的案子,根因是压根没触发真实磁盘 I/O。
pg_stat_statements:定位"哪条 SQL 最贵"。配合 pg_stat_io 一起看,你能形成一个完整闭环--先在所有 SQL 里找到 shared_blks_read 最高的那条,再 EXPLAIN (ANALYZE, BUFFERS) 看它是不是卡在读 I/O,最后决定是开异步 I/O、加索引、还是改数据类型(比如把 uuidv4 换成 uuidv7):
SELECT query,
calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(shared_blks_read::numeric / calls, 0) AS blk_read_per_call
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
记住一句话:没有基线的优化都是耍流氓。每次只动一个变量(io_method、某个索引、某个默认值),用上面的视图对比前后,你才能知道哪个改动真的在帮你。
七点六、完整 RAG 服务骨架:FastAPI + PG18 + pgvector
前面是片段,这里给一个能直接跑起来的最小完整服务。它把"切块 → 嵌入 → 入库 → 混合检索 → 返回"串成一条链路,全部状态都在 PostgreSQL 18 里,外部只依赖一个嵌入模型:
import os
import openai
import psycopg
from fastapi import FastAPI, HTTPException
from pgvector.psycopg import register_vector
app = FastAPI()
client = openai.OpenAI()
# 连接复用:一个进程一个连接池(生产请用 psycopg.Pool)
conn = psycopg.connect(os.environ["DATABASE_URL"])
register_vector(conn)
def embed(text: str) -> list[float]:
"""把文本变成 1536 维向量"""
resp = client.embeddings.create(
model="text-embedding-3-small", input=text
)
return resp.data[0].embedding
def chunk(text: str, size: int = 500) -> list[str]:
"""朴素按字符切块,真实场景应按段落 / 句子边界切"""
return [text[i:i+size] for i in range(0, len(text), size)]
@app.post("/ingest")
async def ingest(doc_id: str, text: str):
"""把一个文档切块、嵌入、写进 PG18"""
with conn.transaction():
for piece in chunk(text):
conn.execute(
"INSERT INTO docs (doc_id, content, embedding) "
"VALUES (%s, %s, %s)",
(doc_id, piece, embed(piece)),
)
return {"ok": True, "chunks": len(chunk(text))}
@app.get("/search")
async def search(q: str, top_k: int = 5):
vec = embed(q)
# 混合检索:向量召回 + 全文召回,RRF 融合
rows = conn.execute("""
WITH fts AS (
SELECT id, row_number() OVER (
ORDER BY ts_rank_cd(search, q) DESC) AS rnk
FROM docs, plainto_tsquery('simple', %s) q
WHERE search @@ q
),
vec AS (
SELECT id, row_number() OVER (
ORDER BY embedding <=> %s) AS rnk
FROM docs ORDER BY embedding <=> %s LIMIT 50
)
SELECT COALESCE(fts.id, vec.id) AS id, content,
1.0/(60+fts.rnk) + 1.0/(60+vec.rnk) AS score
FROM fts FULL OUTER JOIN vec ON fts.id = vec.id
ORDER BY score DESC LIMIT %s
""", (q, vec, vec, top_k)).fetchall()
return [{"id": r[0], "content": r[1], "score": float(r[2])} for r in rows]
注意几个生产化要点:连接要用连接池而非每请求新建;/ingest 的切块应该在后台任务里做,别阻塞 HTTP 线程;向量维度要和建表时的 vector(1536) 严格一致,否则插入会直接报错;高并发下把 hnsw.ef_search 通过 SET LOCAL 在事务内调高,避免全局改动影响其他请求。
八、总结展望:什么时候 PG18 够,什么时候还得出动专用引擎
PostgreSQL 18 把"事务 + 分析(并行 / 外部表)+ 搜索(FTS)+ 向量(pgvector)"收敛进一个你本来就信得过的数据库。对绝大多数业务--尤其是中小团队、创业公司、内部系统--我的建议很明确:先让一个调好的 PG18 扛住,别急着拆出 DuckDB、别急着上独立向量服务。运维面、备份面、权限面、排障面的简化,价值远超那 30% 的极端 benchmark 差异。
但也要清醒:
- DuckDB 的优势是"进程内、零部署、单文件",适合本地分析、Notebook、CLI 即席查询,没有服务器进程的开销--它是 PG 的互补,不是替代;
- 专用向量库(如进程内的 zvec 或托管向量服务)在十亿级向量、极低延迟、超高 QPS 场景仍有架构优势,HNSW 之外还有 IVF-PQ、量化压缩等更激进的空间换时间手段;
- PostgreSQL 18 的异步 I/O 提升再大,也改变不了它是"单写主、靠副本扩展读"的拓扑,超大规模写入仍要靠分片 / 多主方案(如 Citus)。
所以结论不是“PG 万岁、别的都扔了”,而是:PG18 把‘够用’的边界推得很远,让你能更晚、也更从容地引入专用引擎。这里给一个可操作的决策顺序,照着走基本不会错:
- 先上一个调好的 PG18,把事务、FTS、pgvector 全塞进去,用本文的监控闭环压测真实负载;
- 只有当某一类查询在 PG18 上稳定触顶(比如向量检索 P99 卡在几十毫秒且 QPS 上不去、或单表写入已经把主库 CPU 吃满),再针对性引入专用引擎;
- 引入时优先选“能和你主库共存”的形态(DuckDB 做本地分析、托管向量服务做超大规模召回),而不是把主数据再复制一份出去。
升级路径也变友好了——pg_upgrade 的同步与拷贝策略让大版本跃迁的停机大幅缩短。
如果你还在 PG15/16 上,2026 年这个夏天,是认真规划一次升级的好时机。本文的每一段代码,都已经在 PG18 的形态下可跑;挑一个你手边最痛的表,先把 uuidv7() 和虚拟生成列用上,回报会来得比想象中快。把数据库做少、做厚,而不是做多、做薄--这大概是 PG18 给我们最实在的启示。