【数据库】PostgreSQL中JSONB的使用与踩坑记录

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

  1. jsonb_array_elements(ppc.promo_rules) WITH ORDINALITY AS t(elem, idx)

    • 将 promo_rules 数组展开成多行
    • elem 是每个元素,idx 是原始位置(从 1 开始)
  2. LEFT JOIN code_mapping cm ON elem->>'code' = cm.old_code

    • 和映射表关联,找到对应的新 code
  3. CASE WHEN ... THEN jsonb_set(...) ELSE elem END

    • 如果找到映射就替换,找不到就保持原样
  4. 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 最佳实践

设计阶段

  1. 明确哪些字段放 JSONB,哪些放普通列
  2. 高频查询的字段考虑提取为普通列
  3. 设计合理的 JSON 结构,避免过深嵌套

开发阶段

  1. 优先使用 @> 操作符(可以利用 GIN 索引)
  2. 数组操作记得保持顺序
  3. UPDATE 前先 SELECT 预览

运维阶段

  1. 监控 JSONB 列的大小
  2. 定期 VACUUM 清理死元组
  3. 关注慢查询日志

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 的使用:

  1. 基础操作->->> 的区别,条件查询的写法
  2. 索引策略:GIN 索引、jsonb_path_ops、表达式索引的选择
  3. 数组操作:展开、聚合、保持顺序的技巧
  4. 批量更新:展开-替换-聚合的通用模式
  5. 性能优化:索引选择、批量操作、EXPLAIN 分析

JSONB 是 PostgreSQL 的杀手级特性之一,掌握它可以让你在很多场景下避免引入额外的中间件。希望这篇文章能帮你在实际工作中少踩坑。


热门专栏推荐

等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持

文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊

希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏

如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟

相关推荐
Pyeako2 小时前
MySQL基础知识&Linux导入导出数据
linux·数据库·mysql·sql查询·sql分类
醉风塘2 小时前
Oracle闪回技术深度解析:时间旅行者的数据库指南
数据库·oracle
IT·陈寒2 小时前
零配置、开箱即用:seekdb 如何成为 AI 时代的“全能嵌入式数据库”? ——基于 OceanBase seekdb 的实践体验与 AI 开发思考
数据库·人工智能·oceanbase
AI_56783 小时前
MySQL索引的B+树实战哲学
数据库·b树·mysql
大锦终3 小时前
【MySQL】视图+用户管理
数据库·mysql
一位代码3 小时前
mysql | 数据表中列(字段)的添加、修改和删除
数据库·mysql
水坚石青3 小时前
Java+Swing+Mysql实现物业管理系统
java·开发语言·数据库·mysql·swing
GanGuaGua3 小时前
MySQL:内置函数
数据库·mysql·oracle