PostgreSQL中JSONB的使用与踩坑记录
前言
之前接到一个数据迁移的需求,要批量修改表里 JSONB 数组中的某个字段。本以为很简单,结果折腾了大半天,踩了不少坑。这篇文章把 JSONB 的常用操作和我踩过的坑都整理出来,希望能帮到遇到类似问题的朋友。
🏠个人主页:你的主页
文章目录
一、JSONB是什么
1.1 JSON vs JSONB
PostgreSQL 提供了两种 JSON 类型:
| 类型 | 存储方式 | 查询性能 | 写入性能 | 支持索引 |
|---|---|---|---|---|
| JSON | 原始文本 | 慢(每次解析) | 快 | 不支持 |
| JSONB | 二进制格式 | 快 | 稍慢(需要转换) | 支持 |
简单理解:
- JSON 就像把 JSON 字符串原封不动存进去,每次查询都要重新解析
- JSONB 会把 JSON 转成二进制格式存储,查询时直接读取,不需要解析
99% 的场景都应该用 JSONB,除非你只是存储不查询。
1.2 JSONB的优势
相比传统的 EAV(Entity-Attribute-Value)模式,JSONB 有明显优势:
传统 EAV 模式:
sql
-- 商品表
CREATE TABLE products (id INT, name VARCHAR(100));
-- 属性表(每个属性一行)
CREATE TABLE product_attributes (
product_id INT,
attr_key VARCHAR(50),
attr_value VARCHAR(200)
);
-- 查询某商品的所有属性,需要 JOIN
SELECT p.name, pa.attr_key, pa.attr_value
FROM products p
JOIN product_attributes pa ON p.id = pa.product_id
WHERE p.id = 1;
JSONB 模式:
sql
-- 一张表搞定
CREATE TABLE products (
id INT,
name VARCHAR(100),
attributes JSONB -- 所有扩展属性都在这里
);
-- 直接查询,不需要 JOIN
SELECT name, attributes FROM products WHERE id = 1;
-- 还能直接查询 JSON 内部的字段
SELECT name FROM products WHERE attributes->>'color' = 'red';
JSONB 的核心优势:
- 灵活:不同商品可以有不同的属性,不需要改表结构
- 高效:支持 GIN 索引,查询性能有保障
- 简洁:减少表的数量,降低 JOIN 复杂度
二、JSONB基础操作
2.1 创建和插入
sql
-- 创建包含 JSONB 列的表
CREATE TABLE user_profiles (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
profile JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT NOW()
);
-- 插入数据
INSERT INTO user_profiles (user_id, profile) VALUES
(1, '{"name": "张三", "age": 28, "tags": ["开发", "后端"], "settings": {"theme": "dark", "notify": true}}'),
(2, '{"name": "李四", "age": 32, "tags": ["产品", "管理"], "settings": {"theme": "light", "notify": false}}'),
(3, '{"name": "王五", "age": 25, "tags": ["前端", "全栈"], "settings": {"theme": "dark", "notify": true}}');
2.2 查询操作符
JSONB 提供了丰富的操作符:
| 操作符 | 说明 | 返回类型 | 示例 |
|---|---|---|---|
-> |
获取 JSON 对象字段 | JSONB | profile->'name' → "张三" |
->> |
获取 JSON 对象字段 | TEXT | profile->>'name' → 张三 |
-> |
获取数组元素(按索引) | JSONB | profile->'tags'->0 → "开发" |
->> |
获取数组元素 | TEXT | profile->'tags'->>0 → 开发 |
#> |
按路径获取 | JSONB | profile#>'{settings,theme}' → "dark" |
#>> |
按路径获取 | TEXT | profile#>>'{settings,theme}' → dark |
实际使用示例:
sql
-- 获取用户名(返回 JSONB 类型,带引号)
SELECT profile->'name' FROM user_profiles WHERE user_id = 1;
-- 结果:"张三"
-- 获取用户名(返回 TEXT 类型,不带引号)
SELECT profile->>'name' FROM user_profiles WHERE user_id = 1;
-- 结果:张三
-- 获取嵌套字段
SELECT profile->'settings'->>'theme' FROM user_profiles WHERE user_id = 1;
-- 结果:dark
-- 使用路径操作符(更简洁)
SELECT profile#>>'{settings,theme}' FROM user_profiles WHERE user_id = 1;
-- 结果:dark
-- 获取数组第一个元素
SELECT profile->'tags'->>0 FROM user_profiles WHERE user_id = 1;
-- 结果:开发
2.3 条件查询
sql
-- 查询年龄大于 30 的用户
SELECT * FROM user_profiles
WHERE (profile->>'age')::int > 30;
-- 查询使用深色主题的用户
SELECT * FROM user_profiles
WHERE profile#>>'{settings,theme}' = 'dark';
-- 查询标签包含"后端"的用户
SELECT * FROM user_profiles
WHERE profile->'tags' ? '后端';
-- 查询同时包含多个标签的用户
SELECT * FROM user_profiles
WHERE profile->'tags' ?& array['开发', '后端'];
-- 查询包含任意一个标签的用户
SELECT * FROM user_profiles
WHERE profile->'tags' ?| array['前端', '后端'];
2.4 包含查询
@> 操作符用于判断左边的 JSONB 是否包含右边的 JSONB:
sql
-- 查询 settings 中 theme 为 dark 的用户
SELECT * FROM user_profiles
WHERE profile @> '{"settings": {"theme": "dark"}}';
-- 查询标签包含"开发"的用户
SELECT * FROM user_profiles
WHERE profile @> '{"tags": ["开发"]}';
注意 :@> 可以利用 GIN 索引,性能很好。
三、JSONB索引详解
3.1 GIN索引基础
GIN(Generalized Inverted Index)是 JSONB 最常用的索引类型:
sql
-- 创建默认的 GIN 索引
CREATE INDEX idx_profile_gin ON user_profiles USING gin(profile);
这个索引支持以下操作符:
@>包含?键存在?&所有键存在?|任意键存在
3.2 jsonb_path_ops
如果你只需要 @> 操作符,可以使用更高效的 jsonb_path_ops:
sql
-- 创建 jsonb_path_ops 索引
CREATE INDEX idx_profile_path ON user_profiles USING gin(profile jsonb_path_ops);
对比:
| 索引类型 | 索引大小 | 支持的操作符 |
|---|---|---|
| 默认 GIN | 较大 | @>, ?, ?&, `? |
| jsonb_path_ops | 较小(约 1/3) | 仅 @> |
建议 :如果只用 @> 查询,优先选择 jsonb_path_ops。
3.3 表达式索引
如果经常查询某个特定字段,可以创建表达式索引:
sql
-- 为 profile->>'name' 创建 B-Tree 索引
CREATE INDEX idx_profile_name ON user_profiles ((profile->>'name'));
-- 为 age 创建索引(转换为整数)
CREATE INDEX idx_profile_age ON user_profiles (((profile->>'age')::int));
-- 查询时可以利用索引
SELECT * FROM user_profiles WHERE profile->>'name' = '张三';
SELECT * FROM user_profiles WHERE (profile->>'age')::int > 30;
3.4 索引选择策略
| 查询模式 | 推荐索引 |
|---|---|
profile @> '{"key": "value"}' |
GIN (jsonb_path_ops) |
profile ? 'key' |
GIN (默认) |
profile->>'key' = 'value' |
B-Tree 表达式索引 |
(profile->>'num')::int > 100 |
B-Tree 表达式索引 |
四、JSONB数组操作
JSONB 数组操作是实际开发中的高频需求,也是很多人踩坑的地方。
4.1 数组展开
jsonb_array_elements 函数可以将数组展开成多行:
sql
-- 原始数据
SELECT profile->'tags' FROM user_profiles WHERE user_id = 1;
-- 结果:["开发", "后端"]
-- 展开数组
SELECT jsonb_array_elements(profile->'tags') AS tag
FROM user_profiles WHERE user_id = 1;
-- 结果:
-- "开发"
-- "后端"
-- 展开为文本(去掉引号)
SELECT jsonb_array_elements_text(profile->'tags') AS tag
FROM user_profiles WHERE user_id = 1;
-- 结果:
-- 开发
-- 后端
4.2 保留数组顺序
展开数组时,如果需要保留原始顺序,使用 WITH ORDINALITY:
sql
SELECT elem, idx
FROM user_profiles,
jsonb_array_elements(profile->'tags') WITH ORDINALITY AS t(elem, idx)
WHERE user_id = 1;
-- 结果:
-- elem | idx
-- "开发" | 1
-- "后端" | 2
这个技巧非常重要,后面批量更新时会用到。
4.3 数组聚合
jsonb_agg 函数可以将多行聚合成数组:
sql
-- 将所有用户的名字聚合成数组
SELECT jsonb_agg(profile->>'name') FROM user_profiles;
-- 结果:["张三", "李四", "王五"]
-- 按顺序聚合
SELECT jsonb_agg(elem ORDER BY idx)
FROM user_profiles,
jsonb_array_elements(profile->'tags') WITH ORDINALITY AS t(elem, idx)
WHERE user_id = 1;
4.4 数组修改
sql
-- 追加元素到数组末尾
UPDATE user_profiles
SET profile = jsonb_set(profile, '{tags}', profile->'tags' || '"运维"'::jsonb)
WHERE user_id = 1;
-- 删除数组中的某个元素
UPDATE user_profiles
SET profile = jsonb_set(
profile,
'{tags}',
(SELECT jsonb_agg(elem) FROM jsonb_array_elements(profile->'tags') AS elem WHERE elem <> '"后端"')
)
WHERE user_id = 1;
五、JSONB批量更新实战
这是本文的重点,也是实际工作中最容易出问题的地方。
5.1 场景描述
假设我们有一个促销配置表,每个商品可以配置多条促销规则:
sql
CREATE TABLE product_promo_config (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL,
shop_id UUID NOT NULL,
promo_rules JSONB NOT NULL, -- 促销规则数组
created_at TIMESTAMP DEFAULT NOW()
);
-- 插入测试数据
INSERT INTO product_promo_config (product_id, shop_id, promo_rules) VALUES
('a1111111-1111-1111-1111-111111111111', 's1111111-1111-1111-1111-111111111111',
'[{"code": "discount-fixed", "enabled": true, "params": {"rate": 0.8}},
{"code": "coupon-amount", "enabled": true, "params": {"max": 50}},
{"code": "gift-random", "enabled": false, "params": {}}]'),
('a2222222-2222-2222-2222-222222222222', 's1111111-1111-1111-1111-111111111111',
'[{"code": "discount-percent", "enabled": true, "params": {"percent": 10}}]');
现在需求来了:批量修改所有规则的 code 字段,按照映射关系:
| 旧 code | 新 code |
|---|---|
| discount-fixed | price-discount-fixed |
| discount-percent | price-discount-percent |
| coupon-amount | order-coupon-amount |
| gift-random | activity-gift-random |
5.2 错误示范
很多人第一反应是用 jsonb_set 直接改:
sql
-- 错误!只能改数组第一个元素
UPDATE product_promo_config
SET promo_rules = jsonb_set(promo_rules, '{0,code}', '"price-discount-fixed"')
WHERE promo_rules->0->>'code' = 'discount-fixed';
这样只能改数组的第一个元素,如果一个商品配了多条规则,后面的规则就改不到。
5.3 正确方案
正确的做法是:展开 → 替换 → 聚合。
第一步:创建映射表
sql
CREATE TEMPORARY TABLE code_mapping (
old_code VARCHAR(100) PRIMARY KEY,
new_code VARCHAR(100) NOT NULL
);
INSERT INTO code_mapping (old_code, new_code) VALUES
('discount-fixed', 'price-discount-fixed'),
('discount-percent', 'price-discount-percent'),
('coupon-amount', 'order-coupon-amount'),
('coupon-percent', 'order-coupon-percent'),
('gift-random', 'activity-gift-random'),
('gift-specific', 'activity-gift-specific');
第二步:预览更新结果
在执行 UPDATE 之前,先用 SELECT 预览:
sql
SELECT
ppc.id,
ppc.promo_rules AS old_rules,
(
SELECT jsonb_agg(
CASE
WHEN cm.new_code IS NOT NULL
THEN jsonb_set(elem, '{code}', to_jsonb(cm.new_code))
ELSE elem
END
ORDER BY idx -- 保持原始顺序
)
FROM jsonb_array_elements(ppc.promo_rules) WITH ORDINALITY AS t(elem, idx)
LEFT JOIN code_mapping cm ON elem->>'code' = cm.old_code
) AS new_rules
FROM product_promo_config ppc;
解释这段 SQL:
-
jsonb_array_elements(ppc.promo_rules) WITH ORDINALITY AS t(elem, idx)- 将 promo_rules 数组展开成多行
elem是每个元素,idx是原始位置(从 1 开始)
-
LEFT JOIN code_mapping cm ON elem->>'code' = cm.old_code- 和映射表关联,找到对应的新 code
-
CASE WHEN ... THEN jsonb_set(...) ELSE elem END- 如果找到映射就替换,找不到就保持原样
-
jsonb_agg(... ORDER BY idx)- 聚合回数组,按原始顺序排列
第三步:执行更新
确认预览结果正确后,执行更新:
sql
BEGIN;
UPDATE product_promo_config ppc
SET promo_rules = (
SELECT COALESCE(
jsonb_agg(
CASE
WHEN cm.new_code IS NOT NULL
THEN jsonb_set(elem, '{code}', to_jsonb(cm.new_code))
ELSE elem
END
ORDER BY idx
),
'[]'::jsonb -- 处理空数组的情况
)
FROM jsonb_array_elements(ppc.promo_rules) WITH ORDINALITY AS t(elem, idx)
LEFT JOIN code_mapping cm ON elem->>'code' = cm.old_code
)
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(ppc.promo_rules) AS e
JOIN code_mapping cm ON e->>'code' = cm.old_code
);
-- 验证结果
SELECT id, promo_rules FROM product_promo_config;
-- 确认无误后提交
COMMIT;
5.4 通用模板
这是 JSONB 数组批量更新的通用模板,可以直接套用:
sql
UPDATE your_table t
SET json_column = (
SELECT COALESCE(
jsonb_agg(
CASE
WHEN mapping.new_val IS NOT NULL
THEN jsonb_set(elem, '{field_name}', to_jsonb(mapping.new_val))
ELSE elem
END
ORDER BY idx -- 保持原顺序
),
'[]'::jsonb -- 处理空数组
)
FROM jsonb_array_elements(t.json_column) WITH ORDINALITY AS x(elem, idx)
LEFT JOIN mapping_table mapping ON elem->>'field_name' = mapping.old_val
)
WHERE EXISTS (
SELECT 1
FROM jsonb_array_elements(t.json_column) AS e
JOIN mapping_table mapping ON e->>'field_name' = mapping.old_val
);
六、JSONB性能优化
6.1 避免全表扫描
sql
-- 慢:没有索引支持
SELECT * FROM user_profiles WHERE profile->>'name' = '张三';
-- 快:创建表达式索引
CREATE INDEX idx_profile_name ON user_profiles ((profile->>'name'));
SELECT * FROM user_profiles WHERE profile->>'name' = '张三';
-- 快:使用 @> 操作符 + GIN 索引
CREATE INDEX idx_profile_gin ON user_profiles USING gin(profile jsonb_path_ops);
SELECT * FROM user_profiles WHERE profile @> '{"name": "张三"}';
6.2 减少 JSONB 大小
JSONB 越大,查询和更新越慢。建议:
- 只存必要的字段
- 避免深层嵌套(建议不超过 3 层)
- 大文本考虑单独存储
sql
-- 不推荐:把所有东西都塞进 JSONB
profile = '{"name": "...", "avatar_base64": "超长字符串...", "history": [...]}'
-- 推荐:大字段单独存储
profile = '{"name": "...", "avatar_id": "xxx"}'
-- avatar 内容存在单独的表或对象存储
6.3 批量操作优化
sql
-- 慢:逐行更新
UPDATE user_profiles SET profile = jsonb_set(profile, '{age}', '29') WHERE user_id = 1;
UPDATE user_profiles SET profile = jsonb_set(profile, '{age}', '33') WHERE user_id = 2;
UPDATE user_profiles SET profile = jsonb_set(profile, '{age}', '26') WHERE user_id = 3;
-- 快:批量更新
UPDATE user_profiles AS up
SET profile = jsonb_set(up.profile, '{age}', to_jsonb(v.new_age))
FROM (VALUES (1, 29), (2, 33), (3, 26)) AS v(user_id, new_age)
WHERE up.user_id = v.user_id;
6.4 使用 EXPLAIN 分析
sql
EXPLAIN ANALYZE
SELECT * FROM user_profiles WHERE profile @> '{"settings": {"theme": "dark"}}';
-- 查看是否使用了索引
-- Index Scan using idx_profile_gin on user_profiles (cost=...)
七、常见问题与最佳实践
7.1 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 查询慢 | 没有合适的索引 | 根据查询模式创建 GIN 或表达式索引 |
| 更新数组只改了第一个 | 用了 jsonb_set(col, '{0,field}', ...) |
使用展开-替换-聚合模式 |
| 数组顺序乱了 | jsonb_agg 不保证顺序 |
加 WITH ORDINALITY + ORDER BY |
| 空数组报错 | jsonb_agg 对空集返回 NULL |
用 COALESCE(..., '[]'::jsonb) |
| 类型转换错误 | -> 返回 JSONB,->> 返回 TEXT |
注意操作符的返回类型 |
7.2 最佳实践
设计阶段:
- 明确哪些字段放 JSONB,哪些放普通列
- 高频查询的字段考虑提取为普通列
- 设计合理的 JSON 结构,避免过深嵌套
开发阶段:
- 优先使用
@>操作符(可以利用 GIN 索引) - 数组操作记得保持顺序
- UPDATE 前先 SELECT 预览
运维阶段:
- 监控 JSONB 列的大小
- 定期 VACUUM 清理死元组
- 关注慢查询日志
7.3 JSONB 操作速查表
sql
-- 取值
col->'key' -- 返回 JSONB
col->>'key' -- 返回 TEXT
col->0 -- 数组第一个元素(JSONB)
col->>0 -- 数组第一个元素(TEXT)
col#>'{a,b,c}' -- 路径取值(JSONB)
col#>>'{a,b,c}' -- 路径取值(TEXT)
-- 修改
jsonb_set(col, '{key}', '"value"'::jsonb) -- 设置字段
jsonb_set(col, '{key}', to_jsonb(variable)) -- 设置字段(变量)
col || '{"new_key": "value"}'::jsonb -- 合并
col - 'key' -- 删除键
col - 0 -- 删除数组第一个元素
-- 数组操作
jsonb_array_elements(col) -- 展开数组
jsonb_array_elements(col) WITH ORDINALITY -- 展开并保留顺序
jsonb_agg(elem) -- 聚合成数组
jsonb_agg(elem ORDER BY idx) -- 按顺序聚合
jsonb_array_length(col) -- 数组长度
-- 判断
col ? 'key' -- 键是否存在
col ?& array['a','b'] -- 所有键是否存在
col ?| array['a','b'] -- 任意键是否存在
col @> '{"key": "val"}' -- 是否包含
-- 类型
jsonb_typeof(col) -- 返回类型(object/array/string/number/boolean/null)
八、总结
本文从基础到进阶,系统讲解了 PostgreSQL JSONB 的使用:
- 基础操作 :
->和->>的区别,条件查询的写法 - 索引策略:GIN 索引、jsonb_path_ops、表达式索引的选择
- 数组操作:展开、聚合、保持顺序的技巧
- 批量更新:展开-替换-聚合的通用模式
- 性能优化:索引选择、批量操作、EXPLAIN 分析
JSONB 是 PostgreSQL 的杀手级特性之一,掌握它可以让你在很多场景下避免引入额外的中间件。希望这篇文章能帮你在实际工作中少踩坑。
热门专栏推荐
- Agent小册
- 服务器部署
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- 消息队列合集
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟