在业务快速迭代时,表结构往往赶不上需求变化。PostgreSQL JSONB 类型恰好补上了关系型数据库最尴尬的一块短板——它让你在不改表结构的前提下,把半结构化、schemaless 的文档直接塞进同一张关系表,还能用索引高效查询。本文用可运行的 SQL 与代码,讲清 JSONB 与 JSON 的差异、索引怎么建、查询怎么写,以及它真正的适用边界。
一、为什么关系库也需要文档能力
传统的 EAV(实体-属性-值)表用来存可变属性,结果往往是十几张 JOIN、难以维护的查询和糟糕的性能。当属性高度动态(如商品扩展字段、埋点元数据、配置项),强行把它拍平成固定列并不划算。这时 JSONB 提供了一个折中:核心字段仍是强类型关系列,易变、稀疏的附属信息放进一个 JSONB 列,两者在同一行共存。
关于 PostgreSQL 的基础安装与连接,可参考本站《PostgreSQL 入门实战》;如果你想在同一库里做更复杂的分析查询,《SQL 窗口函数实战》里的排名与累计技巧同样适用于 JSONB 列上的派生指标。
二、JSONB 与 JSON 的本质区别
很多人以为 JSON 和 JSONB 只是存储格式不同。其实是语义不同:json 原样保存输入文本(保留空格、键顺序、重复键),每次查询都要重新解析;jsonb 在写入时就被解析成二进制树状结构,去重了重复键、不保证键顺序,但查询和索引都更快。生产环境除非你要严格保留录入格式,否则一律用 JSONB。
| 维度 | json | jsonb |
|---|---|---|
| 写入开销 | 低(直接存文本) | 略高(需解析) |
| 查询速度 | 慢(每次解析) | 快(已解析) |
| 重复键 | 保留 | 去重,后者覆盖 |
| 索引支持 | 仅整列 | 支持 GIN / 表达式索引 |
| 键顺序 | 保留 | 不保证 |
三、建表与写入:定义 JSONB 列
定义一个用户画像表,核心字段(user_id、name)用关系列,易变的标签与偏好放进 profile 这个 JSONB 列:
CREATE TABLE user_profile (
user_id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
profile JSONB NOT NULL DEFAULT '{}'::jsonb
);
INSERT INTO user_profile (user_id, name, profile) VALUES
(1, 'Alice', '{"tags":["vip","early_adopter"],"theme":"dark","notify":{"email":true,"sms":false}}'),
(2, 'Bob', '{"tags":["new"],"theme":"light","notify":{"email":true,"sms":true},"level":3}');
注意默认值写成 '{}'::jsonb 而非空字符串,否则写入会报错。JSONB 允许任意嵌套对象与数组,列的 schema 不再受限。
四、GIN 索引:让 JSONB 查询飞起来
不对 JSONB 建索引,查询只能全表扫描。最常用的是 GIN(通用倒排)索引,它能索引 JSONB 里的所有键与值:
-- 默认 GIN:索引所有键和值,适合 @> 与 ? 查询
CREATE INDEX idx_user_profile_gin ON user_profile USING gin (profile);
-- 只索引键名(省空间,适合 ? 存在性判断)
CREATE INDEX idx_user_profile_keys ON user_profile USING gin (profile jsonb_path_ops);
jsonb_path_ops 算子类更小更快,但只支持 @> 包含匹配,不支持 ? 键存在判断。按需选择即可。
五、查询实战:操作符才是核心
JSONB 的威力全在专属操作符上。下面三个最常用。
5.1 取字段:-> 与 ->>
-- -> 返回 jsonb;->> 返回文本
SELECT profile->'tags' AS tags_jsonb,
profile->>'theme' AS theme_text
FROM user_profile
WHERE user_id = 1;
5.2 包含匹配:@>
-- 找出带 "vip" 标签的用户
SELECT name FROM user_profile
WHERE profile @> '{"tags":["vip"]}';
-- 嵌套匹配:notify.email 为 true
SELECT name FROM user_profile
WHERE profile @> '{"notify":{"email":true}}';
5.3 键存在判断:?
-- 找出含 level 字段的用户
SELECT name, profile->>'level' FROM user_profile
WHERE profile ? 'level';
六、更新与部分修改
JSONB 支持无 schema 迁移的”在线加字段”。用 jsonb_set 或 || 拼接运算符更新局部:
-- 给 Bob 增加 last_login 字段
UPDATE user_profile
SET profile = jsonb_set(profile, '{last_login}', '"2026-09-15"')
WHERE user_id = 2;
-- 合并新对象(|| 运算符,PostgreSQL 9.5+)
UPDATE user_profile
SET profile = profile || '{"locale":"zh-CN"}'
WHERE user_id = 1;
-- 删除键
UPDATE user_profile
SET profile = profile - 'level'
WHERE user_id = 2;
这些更新只动 JSONB 列,关系列和表结构纹丝不动——这正是它应对需求变化的底气。如果你关心这类”无锁在线变更”背后的事务保障,《事务隔离级别与 MVCC》解释了多版本并发如何避免读写互相阻塞。
七、性能与存储陷阱
JSONB 不是银弹。它最大的坑是:整列更新会重写整个 JSON 文档。如果你把一个几十 KB 的大文档频繁局部修改,写放大会很严重。另外 GIN 索引的写入成本高于 B-tree,高频写入场景要权衡。
| 场景 | 建议 |
|---|---|
| 文档大且频繁局部改 | 拆出热点字段为关系列 |
| 只做存在性判断 | 用 jsonb_path_ops 缩小索引 |
| 需要排序/范围查询 | 表达式索引 on (profile->>’level’) |
| 深度嵌套难查询 | 考虑规范化或换文档库 |
-- 查看某列平均体积,判断是否过大
SELECT pg_size_pretty(avg(octet_length(profile::text))) AS avg_jsonb_bytes
FROM user_profile;
-- 对经常范围查询的字段建表达式索引
CREATE INDEX idx_level ON user_profile (((profile->>'level')::int));
若查询变慢,先用 EXPLAIN 看是否命中 GIN;慢查询的根因定位思路也可参考《PostgreSQL 慢查询排查》。
八、与关系列混合建模的最佳实践
JSONB 的最佳姿态是”配角”:把高频过滤、需要聚合的关系列留在外面,把稀疏、易变的附属信息放进去。例如订单表把 status、amount、user_id 当关系列,把渠道回执、风控标签放进 JSONB。这样既享受关系模型的约束与索引,又保留文档的灵活性。一个实用技巧是给 JSONB 列加 CHECK 约束,限定必须包含某几个键,兼顾灵活与可控。
ALTER TABLE user_profile
ADD CONSTRAINT chk_profile_has_tags
CHECK (profile ? 'tags');
九、何时不该用 JSONB
如果数据天然结构化、字段固定、需要大量 JOIN 和强约束,老老实实用关系列。JSONB 在以下情况反而添乱:需要跨文档事务一致性、需要复杂外键引用、文档平均很小且结构稳定。把本该规范化的数据塞进 JSONB,只会把”表结构债务”变成”查询里无处下手的逻辑债务”。
十、生产核对清单
落地前对照这五条:① 用 JSONB 而非 JSON;② 按查询模式建 GIN 或表达式索引;③ 大文档避免整列高频更新;④ 用 CHECK 约束兜住关键键;⑤ 监控 JSONB 列体积与索引膨胀。做到这些,JSONB 就是关系库里最顺手的”弹性口袋”。
十一、应用层读写示例(Python)
后端用 SQLAlchemy 读写 JSONB 非常自然:列类型用 JSONB,查询用 .contains() 或原生 @> 操作符。下面是一个最小可运行片段,展示写入与包含匹配查询。
from sqlalchemy import create_engine, Column, BigInteger, Text
from sqlalchemy.orm import declarative_base, Session
from sqlalchemy.dialects.postgresql import JSONB
Base = declarative_base()
class UserProfile(Base):
__tablename__ = "user_profile"
user_id = Column(BigInteger, primary_key=True)
name = Column(Text, nullable=False)
profile = Column(JSONB, nullable=False, default=dict)
engine = create_engine("postgresql+psycopg://u:p@localhost:5432/app")
with Session(engine) as s:
# 写入:无需提前迁移表结构
s.add(UserProfile(user_id=3, name="Carol",
profile={"tags":["vip"],"theme":"dark"}))
s.commit()
# 查询:contains 生成 @> 包含匹配
rows = s.query(UserProfile).filter(
UserProfile.profile.contains({"tags":["vip"]})).all()
print([r.name for r in rows])
这样应用层与数据库层对 JSONB 的认知完全一致,新增字段无需迁移,迭代速度明显提升。需要提醒的是,.contains() 最终会生成 @> 操作符,所以务必保证对应列已挂 GIN 索引,否则高并发下会退化成全表扫描,把”灵活”变成”拖垮”。如果文档需要跨服务同步,也可把它作为 CDC 的变更载体,与下游分析链路打通。




