第4章 关系型存储(PostgreSQL):JSONB字段、全文检索、分区表
前面咱们聊了文件型存储和键值存储,说实话,生产环境里真正扛大头的还是关系型数据库。我这些年经手的元数据项目,八成以上最后都落在了 PostgreSQL 上。为什么?因为你既要结构化的查询,又要灵活的 schema,还想要搜索能力——PG 刚好把这几样都捏在一起了。
这一章我们只聚焦三个核心特性:JSONB、全文检索、分区表。这三个东西拼起来,基本能覆盖 90% 的元数据存储检索场景。嗯,咱们直接上手。
4.1 JSONB:刚性表里的柔性字段
元数据的痛点在于字段不确定。今天要存“拍摄设备型号”,明天可能就多了“白平衡参数”。传统关系表加字段要改 DDL、做迁移,麻烦得很。
JSONB 就是 PostgreSQL 给的解决方案。它把 JSON 存成二进制格式,支持索引,能走查询优化。我在项目中见过有人把系统配置全塞进一个 JSONB 字段里,结构随时变,查询直接走 GIN 索引,性能居然还不错。
先看怎么用:
-- 创建一张元数据表
CREATE TABLE metadata (
id BIGSERIAL PRIMARY KEY,
object_id UUID NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 插入带嵌套的元数据
INSERT INTO metadata (object_id, attributes)
VALUES (
'a1b2c3d4-...',
'{
"camera": "Sony A7R4",
"lens": "FE 24-70mm",
"settings": {
"iso": 800,
"aperture": 2.8,
"shutter": "1/250"
}
}'::jsonb
);
这里有个坑。我刚开始用 JSONB 的时候,以为它跟字符串存 JSON 没区别。直到压测发现慢了十倍,才意识到没建索引。
常用索引方式有两种:
-- 方式一:GIN 通用索引(适合各种包含、存在查询)
CREATE INDEX idx_metadata_attributes_gin
ON metadata USING GIN (attributes);
-- 方式二:BTREE 表达式索引(针对特定字段)
CREATE INDEX idx_metadata_camera
ON metadata ((attributes ->> 'camera'));
查询举例:
-- 找所有用 Sony 相机的元数据
SELECT * FROM metadata
WHERE attributes @> '{"camera": "Sony A7R4"}'::jsonb;
-- 找 ISO 大于等于 1600 的
SELECT * FROM metadata
WHERE (attributes -> 'settings' ->> 'iso')::int >= 1600;
我个人习惯把经常查询的结构化字段单独拎出来当普通列,剩下的变化部分用 JSONB。这样既保住了强类型的查询性能,又留了扩展空间。
4.2 全文检索:给元数据装上搜索引擎
元数据里经常有描述性文本,比如“这张照片是在黄山山顶拍的,雾气很大”。你要搜“黄山 雾气”,普通 LIKE 查询走不了索引,而且不能做分词、词根、同义词。PostgreSQL 内置了 tsvector / tsquery,性能比 Like 好一个数量级。
看一个典型的场景:
-- 增加一个文本描述列
ALTER TABLE metadata ADD COLUMN description TEXT;
-- 生成 tsvector 向量
UPDATE metadata
SET tsvec = to_tsvector('simple', description);
-- 注意:实际中最好用触发器或者生成列自动维护
-- 这里为演示手动更新
创建全文检索索引:
-- GIN 索引 + tsvector
CREATE INDEX idx_metadata_tsvec
ON metadata USING GIN (tsvec);
查询:
-- 搜索包含 "clouds" 和 "sunset" 的元数据
SELECT object_id, description
FROM metadata
WHERE tsvec @@ to_tsquery('simple', 'clouds & sunset');
-- 带排名
SELECT object_id, description,
ts_rank(tsvec, to_tsquery('simple', 'clouds & sunset')) AS rank
FROM metadata
WHERE tsvec @@ to_tsquery('simple', 'clouds & sunset')
ORDER BY rank DESC
LIMIT 20;
我记得有一次做图库元数据检索,用户搜索“黄浦江夜景”,要求把“外滩”、“浦东”、“陆家嘴”、“夜景”这些相关记录也排到前面。普通 LIKE 根本做不到,全文检索加上同义词词典后,效果直接翻倍。
setweight() 函数,查询时按权重算分,确保标题匹配的排在前面。
4.3 分区表:让大数据不再拖垮查询
元数据会随着业务增长越堆越多。一张表几亿条记录,就算索引建得再好,维护成本也受不了。分区表能按时间、按地域把数据切到不同的物理文件里,查询时只扫描相关分区。
PostgreSQL 的分区支持声明式分区(Declarative Partitioning),从 10 版本开始就很稳了。我一般按时间分区,因为元数据的查询天然有时间范围。
-- 创建分区主表
CREATE TABLE metadata_partitioned (
id BIGSERIAL,
object_id UUID NOT NULL,
attributes JSONB,
created_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 创建分区
CREATE TABLE metadata_2024_q1 PARTITION OF metadata_partitioned
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE metadata_2024_q2 PARTITION OF metadata_partitioned
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- 再建索引,每个分区可以独立管理
CREATE INDEX idx_metadata_2024_q1_attr
ON metadata_2024_q1 USING GIN (attributes);
查询的时候完全透明:
-- 查询 2024年1月到3月的数据
-- PostgreSQL 会自动只扫描 metadata_2024_q1 分区
SELECT * FROM metadata_partitioned
WHERE created_at >= '2024-01-01'
AND created_at < '2024-04-01'
AND attributes @> '{"camera": "Nikon Z8"}'::jsonb;
DROP 整个分区比 DELETE 百万行快几十倍。而且每个分区可以单独做 VACUUM、REINDEX,不影响其他分区。我的项目里原先清理旧数据要锁表 10 分钟,分区后一秒都不用锁。
分区时要注意几个点:
- 主键必须包含分区键。上面例子中
PRIMARY KEY (id, created_at)就是这个原因。 - 分区数量别太多,几百个以内还行,几千个会让优化器变慢。
- 跨分区查询如果没带分区键条件,会扫描所有分区,性能反而更差。所以设计查询时要保证
WHERE条件有分区键。
4.4 组合起来:典型元数据存储架构
三个特性不是孤立的。我们把它拼成一张图看看整体结构。
上面这张图把数据流走了一遍。应用层写入或查询时,分区表根据时间把数据路由到对应分区;分区内的每条记录用 JSONB 存非结构化元数据,用 tsvector 字段存全文检索向量;查询时分区裁剪 + GIN 索引 + 全文检索三管齐下。
4.5 一个完整的建表脚本
最后,给你一个可以直接拿去用的脚本。我把它放在生产环境跑了快两年,基本没出过问题。
-- 主表(分区表)
CREATE TABLE metadata (
id BIGSERIAL,
object_id UUID NOT NULL,
title TEXT,
description TEXT,
tags JSONB DEFAULT '[]'::jsonb,
attributes JSONB DEFAULT '{}'::jsonb,
tsvec TSVECTOR,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 分区:按季度
CREATE TABLE metadata_2024_q1 PARTITION OF metadata
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE metadata_2024_q2 PARTITION OF metadata
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');
-- 索引
CREATE INDEX idx_metadata_attributes_gin
ON metadata USING GIN (attributes);
CREATE INDEX idx_metadata_tags_gin
ON metadata USING GIN (tags);
CREATE INDEX idx_metadata_tsvec
ON metadata USING GIN (tsvec);
-- 触发器:插入/更新时自动生成 tsvector
CREATE OR REPLACE FUNCTION metadata_tsvec_trigger()
RETURNS TRIGGER AS $$
BEGIN
NEW.tsvec :=
setweight(to_tsvector('simple', coalesce(NEW.title, '')), 'A') ||
setweight(to_tsvector('simple', coalesce(NEW.description, '')), 'B');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_metadata_tsvec
BEFORE INSERT OR UPDATE OF title, description
ON metadata
FOR EACH ROW
EXECUTE FUNCTION metadata_tsvec_trigger();
嗯,到这里你基本能架起一套能应对百万级元数据的 PostgreSQL 存储了。我之前接手过一个遗留系统,它们把所有元数据都丢在一张大表里,没有任何分区,也没用 JSONB。每天凌晨跑个定时任务去清理过期数据,经常把生产库拖死。后来我按上面的方案重构,三个月下来,查询响应从秒级降到毫秒级,半夜再也没接到过告警电话。