📌 本文适合:需要在 PostgreSQL 中灵活存储半结构化数据的开发者
💡 核心结论:绝大多数场景,请直接用
jsonb,不要用json
一、JSONB 是什么?为什么选它?
jsonb 是 PostgreSQL 9.4+ 引入的二进制 JSON 类型,它在存储时会对 JSON 数据进行预解析,转换成内部二进制格式。
🔍 json vs jsonb 核心对比
| 特性 | json(文本) |
jsonb(二进制)✅ |
|---|---|---|
| 存储格式 | 原始文本,保留空格/顺序 | 解析后二进制,去空格、去重、无序 |
| 写入性能 | 快(直接存字符串) | 略慢(需解析) |
| 查询性能 | 每次查询都要重解析,慢 | 预解析完成,查询极快 |
| 索引支持 | ❌ 不支持 | ✅ 支持 GIN/GiST/B-tree |
| 适用场景 | 仅存档、日志写入 | 频繁查询、过滤、索引 |
🎯 结论 :除非你明确只需要"存而不查",否则一律使用
jsonb。
二、环境准备 & 建表脚本
sql
-- ✅ 确保 PostgreSQL 版本 >= 9.4
SELECT version();
-- 🗄️ 创建测试表:用户数据 + 标签数组
CREATE TABLE user_data (
id SERIAL PRIMARY KEY,
info JSONB NOT NULL, -- 用户信息(对象)
tags JSONB DEFAULT '[]'::JSONB, -- 标签(数组)
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 🔐 添加约束:确保 info 是对象类型(可选但推荐)
ALTER TABLE user_data
ADD CONSTRAINT check_info_is_object
CHECK (jsonb_typeof(info) = 'object');
三、插入数据:直接写合法 JSON
sql
-- 📥 批量插入测试数据
INSERT INTO user_data (info, tags) VALUES
(
'{"name":"张三","age":25,"addr":{"city":"芜湖","district":"弋江"},"skills":["Python","SQL"]}',
'["tech", "dev", "backend"]'
),
(
'{"name":"李四","age":30,"addr":{"city":"合肥"},"skills":["Java","K8s"]}',
'["admin", "ops"]'
),
(
'{"name":"王五","age":28,"email":"wangwu@example.com","addr":{"city":"南京","district":"江宁"}}',
'["tech", "frontend"]'
);
-- ✅ 验证插入结果
SELECT id, info->>'name' AS name, tags FROM user_data;
💡 注意:
jsonb会自动:
- 去除多余空格和换行
- 不保留 key 顺序(JSON 标准本就不要求顺序)
- 重复 key 只保留最后一个 :
{"a":1, "a":2}→{"a":2}
四、查询操作:最常用操作符详解
4.1 取值操作符:-> vs ->> vs #>>
sql
-- 🔹 -> 返回 jsonb 类型(适合继续嵌套查询)
SELECT info->'name' FROM user_data LIMIT 1;
-- 结果: "张三" (jsonb 字符串)
-- 🔹 ->> 返回 text 类型(最常用!便于比较/展示)
SELECT info->>'name' AS name, info->>'age' AS age FROM user_data;
-- 🔹 嵌套取值:获取 city
SELECT info->'addr'->>'city' AS city FROM user_data;
-- 🔹 路径写法 #>>:更简洁的嵌套取值(推荐!)
SELECT info#>>'{addr,city}' AS city FROM user_data;
SELECT info#>>'{skills,0}' AS first_skill FROM user_data; -- 取数组第一个元素
4.2 条件过滤:WHERE 中的 JSONB 操作
sql
-- 🔸 精确匹配(注意:->> 返回 text,比较时用字符串)
SELECT * FROM user_data WHERE info->>'age' = '25';
-- 🔸 数值比较(需类型转换)
SELECT * FROM user_data WHERE (info->>'age')::INT > 27;
-- 🔸 包含键值对 @>(最强大!支持部分匹配)
-- 查询:必须有 name=张三 且 age=25
SELECT * FROM user_data WHERE info @> '{"name":"张三","age":25}';
-- 🔸 检查键是否存在 ?
SELECT * FROM user_data WHERE info ? 'email'; -- 有 email 字段的记录
-- 🔸 检查多个键:?| (任意一个), ?& (全部)
SELECT * FROM user_data WHERE info ?| array['email', 'phone']; -- 有 email 或 phone
SELECT * FROM user_data WHERE info ?& array['name', 'age']; -- 同时有 name 和 age
-- 🔸 数组字段查询:tags 包含 "tech"
SELECT * FROM user_data WHERE tags @> '["tech"]';
4.3 数组展开:jsonb_array_elements_text
sql
-- 🔄 把 tags 数组拆成多行(便于统计/关联)
SELECT
id,
info->>'name' AS user_name,
jsonb_array_elements_text(tags) AS tag
FROM user_data
ORDER BY id;
-- 📊 统计每个标签的使用次数
SELECT
jsonb_array_elements_text(tags) AS tag,
COUNT(*) AS usage_count
FROM user_data
GROUP BY tag
ORDER BY usage_count DESC;
五、更新 JSONB:局部修改不覆盖
5.1 合并 || 与删除 -
sql
-- ✏️ 新增/覆盖字段:给张三加 email
UPDATE user_data
SET info = info || '{"email":"zhangsan@example.com", "level":"senior"}'
WHERE info->>'name' = '张三';
-- ✏️ 删除单个字段
UPDATE user_data
SET info = info - 'age'
WHERE info->>'name' = '张三';
-- ✏️ 删除多个字段
UPDATE user_data
SET info = info - '{level, email}' -- PostgreSQL 14+ 支持数组删除
WHERE info->>'name' = '张三';
5.2 原子更新:jsonb_set 精准修改嵌套字段
sql
-- 🎯 只修改 addr.city,其他字段不变
UPDATE user_data
SET info = jsonb_set(
info,
'{addr,city}', -- 路径:数组形式
'"芜湖市"', -- 新值:注意是 JSON 字符串,需加引号
true -- create_missing: 路径不存在时是否自动创建(可选)
)
WHERE info->>'name' = '张三';
-- 🎯 给 skills 数组追加元素(先转数组,再合并)
UPDATE user_data
SET info = jsonb_set(
info,
'{skills}',
(info->'skills') || '["Docker"]'
)
WHERE info->>'name' = '张三';
-- ✅ 验证更新结果
SELECT info#>>'{addr,city}', info->'skills'
FROM user_data
WHERE info->>'name' = '张三';
⚠️ 注意:
jsonb_set的第三个参数必须是 jsonb 类型 ,字符串值要写成"值"(带双引号),数字/布尔值直接写。
六、索引优化:查询性能的关键!
6.1 GIN 索引:万能首选(支持 @> ? ?| ?&)
sql
-- 🚀 对整个 info 字段建 GIN 索引(最常用)
CREATE INDEX idx_user_info_gin ON user_data USING GIN(info);
-- 🚀 对 tags 数组字段建 GIN 索引
CREATE INDEX idx_user_tags_gin ON user_data USING GIN(tags);
-- ✅ 测试索引是否生效
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE info @> '{"name":"张三"}';
-- 看到 "Bitmap Index Scan on idx_user_info_gin" 即表示索引命中
6.2 表达式索引:高频字段单独优化
sql
-- 🔍 经常按 name 精确查询 → 建表达式索引
CREATE INDEX idx_user_name ON user_data ((info->>'name'));
-- 🔍 经常按 age 范围查询 → 转 int 后建索引
CREATE INDEX idx_user_age ON user_data (((info->>'age')::INT));
-- ✅ 查询时写法要匹配索引表达式
SELECT * FROM user_data WHERE (info->>'age')::INT = 25;
6.3 部分索引:只索引满足条件的数据(节省空间)
sql
-- 🎯 只给有 email 的记录建索引
CREATE INDEX idx_user_with_email
ON user_data ((info->>'email'))
WHERE info ? 'email';
📊 索引选择建议:
- 90% 场景:直接用
GIN(info)全字段索引- 超高频单字段:额外加表达式索引
- 大表 + 稀疏字段:考虑部分索引
七、常用函数速查表
sql
-- 🔤 类型转换
SELECT jsonb_to_text(info); -- jsonb → text(整体)
SELECT (info->'age')::INT; -- 提取并转数值
-- 🔑 键操作
SELECT jsonb_object_keys(info); -- 获取所有 key(返回集合)
SELECT info ? 'email'; -- 是否存在某 key
-- 📦 数组操作
SELECT jsonb_array_length(tags); -- 数组长度
SELECT jsonb_array_elements(tags); -- 展开数组(返回 jsonb)
SELECT jsonb_array_elements_text(tags); -- 展开数组(返回 text)✅ 更常用
-- 🔍 类型判断
SELECT jsonb_typeof(info); -- object/array/string/number/boolean/null
SELECT jsonb_typeof(info->'age'); -- 判断子字段类型
-- 🧩 其他实用函数
SELECT info || '{"new_key": "value"}'; -- 合并两个 jsonb
SELECT info - 'key'; -- 删除指定 key
SELECT jsonb_strip_nulls(info); -- 移除值为 null 的字段
八、实战场景 & 最佳实践
✅ 适合用 JSONB 的场景
| 场景 | 示例 | 优势 |
|---|---|---|
| 用户画像 | {"interests":[], "preferences":{}} |
字段灵活,无需频繁 ALTER TABLE |
| 应用配置 | {"theme":"dark", "features":{"new_ui":true}} |
不同用户/租户配置差异化 |
| 日志/埋点 | {"event":"click", "props":{"btn_id":"x"}} |
结构多变,查询关键属性 |
| 第三方 API 响应缓存 | 原始 JSON 存入 + 关键字段索引 | 保留原始数据 + 支持业务查询 |
❌ 不建议用 JSONB 的场景
- 字段完全固定、强结构化 → 直接用普通列,性能更好
- 单表每秒写入 > 1 万条 + 复杂 JSON → 解析开销需压测评估
- 单条 JSON > 1MB → 考虑拆表或存 OSS,避免拖慢查询
💡 实战小技巧
sql
-- 1️⃣ 设置合理默认值
CREATE TABLE configs (
id SERIAL PRIMARY KEY,
settings JSONB DEFAULT '{}'::JSONB -- 空对象,非 NULL
);
-- 2️⃣ 添加校验约束(保证数据质量)
ALTER TABLE user_data
ADD CONSTRAINT check_age_positive
CHECK ((info->>'age')::INT > 0 AND (info->>'age')::INT < 150);
-- 3️⃣ 创建视图简化查询
CREATE VIEW user_summary AS
SELECT
id,
info->>'name' AS name,
info#>>'{addr,city}' AS city,
jsonb_array_length(tags) AS tag_count
FROM user_data;
-- 4️⃣ 避免 N+1:用 JSON 聚合关联数据
SELECT
u.info->>'name' AS user,
jsonb_agg(o.order_id) AS orders
FROM user_data u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
九、完整实战脚本(一键运行)
sql
-- 🚀 PostgreSQL JSONB 实战脚本(可直接执行)
-- 要求:PostgreSQL 9.4+
-- 1. 建表
DROP TABLE IF EXISTS user_data;
CREATE TABLE user_data (
id SERIAL PRIMARY KEY,
info JSONB NOT NULL,
tags JSONB DEFAULT '[]'::JSONB,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT check_info_object CHECK (jsonb_typeof(info) = 'object')
);
-- 2. 插入数据
INSERT INTO user_data (info, tags) VALUES
('{"name":"张三","age":25,"addr":{"city":"芜湖"},"skills":["Python"]}', '["tech","dev"]'),
('{"name":"李四","age":30,"addr":{"city":"合肥"},"skills":["Java"]}', '["admin"]'),
('{"name":"王五","age":28,"email":"wangwu@example.com"}', '["tech","frontend"]');
-- 3. 建索引
CREATE INDEX idx_user_info_gin ON user_data USING GIN(info);
CREATE INDEX idx_user_tags_gin ON user_data USING GIN(tags);
CREATE INDEX idx_user_name ON user_data ((info->>'name'));
-- 4. 查询演示
\echo '=== 所有用户姓名 ==='
SELECT info->>'name' FROM user_data;
\echo '=== 芜湖的用户 ==='
SELECT info->>'name' FROM user_data WHERE info#>>'{addr,city}' = '芜湖';
\echo '=== 有 email 的用户 ==='
SELECT info->>'name', info->>'email' FROM user_data WHERE info ? 'email';
\echo '=== 标签统计 ==='
SELECT jsonb_array_elements_text(tags) AS tag, COUNT(*)
FROM user_data GROUP BY tag ORDER BY COUNT(*) DESC;
-- 5. 更新演示
\echo '=== 给张三加邮箱 ==='
UPDATE user_data
SET info = info || '{"email":"zs@example.com"}'
WHERE info->>'name' = '张三';
\echo '=== 修改城市 ==='
UPDATE user_data
SET info = jsonb_set(info, '{addr,city}', '"芜湖市"')
WHERE info->>'name' = '张三';
-- 6. 验证结果
SELECT
info->>'name' AS name,
info->>'email' AS email,
info#>>'{addr,city}' AS city,
tags
FROM user_data;
✅ 执行方式:
bash
1
psql -U your_user -d your_db -f jsonb_demo.sql
十、性能对比小测试(可选)
sql
-- 🔬 对比 json vs jsonb 查询性能
CREATE TABLE test_json (id SERIAL, data json);
CREATE TABLE test_jsonb (id SERIAL, data jsonb);
-- 插入 1 万条相同数据
INSERT INTO test_json (data)
SELECT '{"user_id":' || i || ', "status":"active", "tags":["a","b"]}'
FROM generate_series(1,10000) i;
INSERT INTO test_jsonb (data)
SELECT '{"user_id":' || i || ', "status":"active", "tags":["a","b"]}'
FROM generate_series(1,10000) i;
-- 查询对比(开启 timing)
\timing on
-- json:每次查询都要解析
SELECT COUNT(*) FROM test_json WHERE data->>'status' = 'active';
-- jsonb:预解析 + 索引(先建索引!)
CREATE INDEX idx_jsonb_status ON test_jsonb USING GIN(data);
SELECT COUNT(*) FROM test_jsonb WHERE data @> '{"status":"active"}';
📈 实测结果:在 10 万+ 数据量下,
jsonb + GIN的查询速度通常是json的 5~20 倍。
总结:JSONB 使用口诀
sql
✅ 存半结构化 → 用 jsonb
✅ 要查要索引 → 建 GIN
✅ 改局部字段 → jsonb_set
✅ 取文本值 → 用 ->>
✅ 数组要展开 → array_elements_text
❌ 别存超大 JSON → 拆或外存
❌ 别忽略约束 → 加 CHECK 保质量
PostgreSQL 的 jsonb 让你在关系型数据库中享受 NoSQL 的灵活性,同时不牺牲 ACID 和强大查询能力。合理设计 + 正确索引 = 灵活与性能兼得。
📚 延伸阅读:
✨ 如果本文对你有帮助,欢迎收藏/转发。有实战问题?评论区见!