PostgreSQL 中的 JSONB 详解:从入门到实战

📌 本文适合:需要在 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 的查询速度通常是 json5~20 倍


总结:JSONB 使用口诀

sql 复制代码
✅ 存半结构化 → 用 jsonb

✅ 要查要索引 → 建 GIN

✅ 改局部字段 → jsonb_set

✅ 取文本值 → 用 ->>

✅ 数组要展开 → array_elements_text

❌ 别存超大 JSON → 拆或外存

❌ 别忽略约束 → 加 CHECK 保质量

PostgreSQL 的 jsonb 让你在关系型数据库中享受 NoSQL 的灵活性,同时不牺牲 ACID 和强大查询能力。合理设计 + 正确索引 = 灵活与性能兼得

📚 延伸阅读:


✨ 如果本文对你有帮助,欢迎收藏/转发。有实战问题?评论区见!

相关推荐
Irene19911 小时前
PL/SQL:异常处理补充
数据库·sql
dishugj1 小时前
SAP HANA数据库文件目录说明
服务器·数据库·oracle
l1t1 小时前
DeepSeek总结的使用 eBPF 和硬件断点跟踪 PostgreSQL
数据库·驱动开发·postgresql
薪火铺子1 小时前
MySQL InnoDB 索引底层:B+树深度解析
数据库·b树·mysql
Elastic 中国社区官方博客1 小时前
从平均值到任意百分位数:Elasticsearch 在 ES|QL 中原生支持指数直方图
大数据·数据库·sql·elasticsearch·搜索引擎·全文检索·prometheus
今儿敲了吗2 小时前
数据库(四)——关系数据库SQL语言
数据库·笔记·sql
brevity_souls2 小时前
SQL server格式化日期
运维·服务器·数据库
虹科网络安全2 小时前
艾体宝干货|Active-Active/Active-Passive 数据库架构解析:高可用设计中的权衡与选型
数据库·数据库架构
麦聪聊数据2 小时前
SQL与数据库开发(一):用窗口函数替代应用层的嵌套循环
数据库·sql·数据库开发