开篇先说明一下,这篇文章不是要把官方文档逐字翻译一遍,而是围绕“PostgreSQL里的JSON类型字段”这个话题,把官方文档里值得关注的点抽出来,结合我实际项目里用下来的经验,讲清楚到底该怎么选型、怎么写SQL、怎么建索引、怎么避坑。如果你正在为MySQL和PostgreSQL之间迁JSON数据犯愁,或者刚接触PostgreSQL想用JSONB又怕用错,这篇文章应该能帮你省下不少查文档的时间。
1. 先搞明白:JSON和JSONB到底怎么选
1.1 官方文档里最关键的一句话
PostgreSQL官方文档在JSON类型这一章,开篇就点明了核心区别:json类型会完整保存输入文本的拷贝,处理函数在每次执行时都需要重新解析原始文本;而jsonb类型则会把输入文本解析成一种分解好的二进制格式,虽然存储时略有额外开销,但处理起来明显更快,同时还支持索引。这句话就是整个选型的分水岭。
很多从MySQL转过来的同学,习惯把JSON当做一个“能存大文本”的字段。但在PostgreSQL里,你一旦需要对这个字段做查询、过滤、聚合,哪怕只是简单取一个键值,json类型都会让查询性能吃大亏。因为json存的是原始文本,每次访问都得现解析,等于每次查询都白做一遍语法分析。而jsonb在写入时就已经完成了解析,查询时直接读二进制结构,效率完全不在一个量级。
1.2 我见过的真实选型教训
我之前接过一个线上项目,原本MySQL里有个extra_info字段,存的是各种业务扩展信息的JSON字符串,迁到PostgreSQL时,开发图省事直接用了json类型。结果上线后,列表页为了展示一个status键,动不动就几百毫秒。后来一查,就是因为每次查询都触发了全量JSON解析,加上没有索引兜底,慢成了家常便饭。
如果一开始就按官方文档的建议选jsonb,哪怕字段长度比json多一点点存储开销,性能和索引能力也完全不一样。所以这里给一个直接可用的结论:新项目、新字段,一律默认jsonb,除非你有极特殊的场景必须保留原始文本格式的空白、键顺序、重复键,否则不要碰json类型。这个观点不是我拍脑袋,官方文档里也明确写了:在大多数应用场景下,应该优先考虑使用jsonb。
| 对比项 | json | jsonb |
|---|---|---|
| 存储格式 | 原始文本拷贝 | 解析后的二进制结构 |
| 写入速度 | 快(不解析) | 相对慢(需解析) |
| 查询速度 | 每次重新解析,慢 | 直接读结构,快 |
| 索引支持 | 不支持直接建GIN索引(需表达式) | 支持GIN索引 |
| 重复键 | 保留 | 去重,只保留最后一个值 |
| 键顺序 | 保留 | 不保留 |
| 大文本存储 | 占用空间小 | 占用空间略大 |
2. JSON字段的写入、更新与日常维护
2.1 插入是最容易忽略细节的一步
很多人的第一反应是:插入JSON不就写个字符串嘛,有什么好讲的?其实坑点不少。官方文档反复强调的插入方式就是直接用字符串字面量,PostgreSQL会自动完成类型转换。但如果你写的是json类型,输入的文本会被原样存进去,所以文本里如果有不规范的空格、换行,都会原封不动保留。而改用jsonb后,空格会被去掉,键的顺序可能变化,重复键只保留最后一个。这个特性既是优点也是坑,后面第5章会专门展开。
实操里我建议,只要是jsonb字段,插入时直接传字符串即可,比如:
CREATE TABLE user_profile ( user_id BIGINT PRIMARY KEY, profile JSONB NOT NULL DEFAULT '{}'::JSONB ); INSERT INTO user_profile (user_id, profile) VALUES (1001, '{"name": "张三", "age": 28, "tags": ["vip", "老用户"] }');这里有一个很实用的细节:默认值建议直接写成'{}'::jsonb,不要用NULL。后面查询时,很多JSON操作符遇到NULL会直接返回NULL,而空对象配合COALESCE处理起来要顺手得多。
2.2 改字段值:jsonb_set和||操作符的取舍
PostgreSQL里更新JSON字段不像改普通字段那么直白,官方文档推荐的方式是jsonb_set函数。用法是jsonb_set(target jsonb, path text[], new_value jsonb, create_missing boolean),其中path是个文本数组,用来指定要修改的路径。
我举个实际例子,假如要把上面那条记录的age改成30:
UPDATE user_profile SET profile = jsonb_set(profile, '{age}', '30'::jsonb, true) WHERE user_id = 1001;这里特别提醒一点:path里的键名不要加引号,写成'{age}'而不是'{"age"}'。我第一次用的时候就踩了这个板子,传了一个带双引号的json路径,结果jsonb_set直接找不到键,字段完全没更新。
对于只追加新键,或者只合并少量键值的场景,||操作符其实更方便:
UPDATE user_profile SET profile = profile || '{"city": "杭州"}'::jsonb WHERE user_id = 1001;这个写法的底层逻辑是两个jsonb做合并,右侧对象的键会覆盖左侧同名键。但注意,||只适合浅层合并,如果你想给内层嵌套对象里的某个键单独改值,还是得老老实实用jsonb_set。
2.3 删除键和字段的两种姿势
删除JSON字段里的某个键,可以用减号操作符-,它既可以删指定的键,也可以按数组下标删数组元素。这个在官方文档的“jsonb Operators”小节里有明确说明。举个例子:
-- 删除顶层键 "age" UPDATE user_profile SET profile = profile - 'age' WHERE user_id = 1001; -- 删除 tags 数组里下标为 0 的元素 UPDATE user_profile SET profile = jsonb_set(profile, '{tags}', (profile->'tags') - 0) WHERE user_id = 1001;注意第二个例子里,(profile->'tags')返回的是jsonb,对jsonb数组类型的值再用减号0,才是按下标删元素。如果你删的是文本数字,那会被当成键名删除,这是很容易踩的细节。
3. 查询JSON字段:运算符与函数就是屠龙刀
3.1 最常用的四个运算符
PostgreSQL为JSONB准备了一套极其好用的运算符,官方文档把它们列在“jsonb Operators”里,我用一张表先给结论,再逐个举例:
| 运算符 | 含义 | 返回类型 | 典型场景 |
|---|---|---|---|
-> | 按键名或数组下标获取 | jsonb | 取整个子对象/数组做进一步处理 |
->> | 按键名或数组下标获取 | text | 取出文本值用于筛选或展示 |
#> | 按路径获取 | jsonb | 直接取嵌套深层值 |
#>> | 按路径获取 | text | 取深层文本值 |
这四个运算符是所有JSON查询的基础,官方文档里的所有示例都绕不开它们。举个例子,查上面那条记录的姓名和城市:
SELECT profile->>'name' AS name, profile#>>'{address, city}' AS city FROM user_profile WHERE user_id = 1001;如果address是个嵌套对象,#>>后面跟一个路径数组就可以一路点进去,省得一层层写->。这个写法在查多级嵌套结构时确实方便得多,代码也干净。
3.2 过滤查询不是只有->>,还有包含运算符
实际项目里,JSON字段最大的用处是存半结构化数据,然后需要按里面的某个键值过滤。最直观的写法是用->>取出文本再比较:
SELECT * FROM user_profile WHERE profile->>'age' = '28';注意,这里比较的是文本,所以你写28还是'28'效果一样。但如果你的JSON里存的是数字,而你用profile->'age' = '28'::jsonb,也等值,因为'28'::jsonb是数字类型的jsonb。关键点在于:用->>一律是文本比较,用->是jsonb比较。如果你存的是数字,而你想做范围查询,直接文本比较会走字典序,结果可能是错的。
更好的做法是用官方文档重点介绍的包含运算符@>。它的语义是“左侧jsonb是否包含右侧jsonb”,非常契合JSON过滤的场景:
SELECT * FROM user_profile WHERE profile @> '{"age": 28}'::jsonb;这个写法不仅好看,而且能配合GIN索引走索引扫描,性能比->>表达式上的函数索引更稳。我建议能用@>表达的条件,就不要用->>加等值,这是写PostgreSQL JSON查询的一条纪律。
3.3 数组与通配:exists和jsonb_path_exists
如果JSON里存的是数组,官方文档给出的方案也很成熟。比如你要查tags数组里包含“vip”的用户,可以用?运算符:
SELECT * FROM user_profile WHERE profile->'tags' ? 'vip';?的语义是“字符串是否作为jsonb对象的键或数组的字符串元素存在”。注意它跟在profile->'tags'的结果上,而不是直接跟在profile上。如果直接写profile ? 'vip',那意思是顶层有没有叫vip的键,语义就变了。
从PostgreSQL 12开始,官方还加入了SQL/JSON路径表达式支持,你可以用jsonb_path_exists实现更复杂的通配和条件判断。比如查tags里包含“vip”或者“gold”任意一个:
SELECT * FROM user_profile WHERE jsonb_path_exists(profile, '$.tags[*] ? (@ == "vip" || @ == "gold")');路径表达式的方式适合特别复杂的查询,但对新手来说门槛略高。我个人的建议是:先掌握@>和?这两个运算符,能覆盖80%的业务需求;确实遇到复杂的层级嵌套、数组条件,再考虑jsonb_path_*系列函数,因为那个写起来确实有那么点反直觉。
4. 索引设计:JSONB的查询性能全靠GIN撑腰
4.1 普通B-Tree索引救不了JSONB
官方文档里写得很明白:jsonb类型默认没有等值和范围比较操作符,所以不能直接用B-Tree索引来加速查询。这意味着,你如果直接在profile字段上建一个普通索引,然后跑WHERE profile->>'age' = '28',PostgreSQL根本不会用这个索引,该全表扫还是全表扫。
要让JSONB查询快起来,核心是GIN索引。GIN索引本质上是对jsonb里每个键值对建立倒排索引,适合处理“文档内部是否存在某个键/值”这类条件。官方文档分别介绍了jsonb_ops和jsonb_path_ops两种GIN操作符类,后者体积更小、查询更快,但有局限性:只支持@>这一个运算符。所以如果你只需要做包含查询,优先用jsonb_path_ops;如果还需要用?、?|、?&这些运算符,那就只能用默认的jsonb_ops。
4.2 两种GIN索引的推荐写法
最直接的一招,给整个JSONB字段建GIN索引:
CREATE INDEX idx_user_profile_gin ON user_profile USING GIN (profile);这个索引能加速profile @> '{"age": 28}'这类查询,也可以加速profile->'tags' ? 'vip'里的?运算符(因为操作符可以下推到索引)。但注意,profile->>'age' = '28'这种写法走不了这个索引。
那如果业务里就是高频用profile->>'age'做等值查询怎么办?官方文档提供了另一个思路:建表达式索引。你把profile->>'age'这个表达式固化成一个索引字段:
CREATE INDEX idx_user_profile_age ON user_profile ((profile->>'age'));之后执行WHERE profile->>'age' = '28'时,PostgreSQL会识别出这个表达式,自动走索引。这个方案在数据量不大、条件固定的场景下很管用,但别指望它能支持范围查询以外的模糊匹配,毕竟->>返回的text类型,范围查询依然有类型转换的潜在问题。
4.3 索引设计的一条实战经验
结合官方文档和实际压测,我的经验是分两步走:先用默认的GIN索引,解决80%的“包含”类查询;如果压测发现某些固定字段的等值查询很频繁,再为这些字段单独建表达式索引。我见过不少团队一上来就给所有JSON字段建一堆索引,结果写入放大严重,磁盘占用翻倍,查询也没明显变快,最后还得回头清理。
一个还算合理的索引设计参考:
CREATE INDEX idx_user_profile_gin_path ON user_profile USING GIN (profile jsonb_path_ops); CREATE INDEX idx_user_profile_level ON user_profile ((profile->>'level'));第一个索引管所有@>查询,第二个管固定的level等值查询。既保证了高频查询的性能,又不会让索引数量失控。
5. 官方文档没明说的坑和排查方法
5.1 返回类型和类型转换的坑
用->和->>取出来的数据类型不同,这个看似基础,但实际上很容易埋雷。->返回的是jsonb,->>返回的是text。如果你写profile->'age' + 1,PostgreSQL会直接报错,因为jsonb没有和整数相加的操作符。必须先(profile->>'age')::int + 1。这种错误通常很直观,但一旦嵌套在复杂的子查询里,报错信息就会非常晦涩。
排查问题的时候,我习惯先跑一个简单的SELECT pg_typeof(profile->'age')来确认返回类型,而不是逐行眼查。这个方法在官方文档里不算显眼,但实际调试时确实能省很多时间。
5.2 jsonb文本格式的无序性
官方文档明确提示过:jsonb不会保留键的顺序,也不保证重复键的保留。这导致一个常见现象:你插入一条格式工整的JSON文本,查出来却变成了“乱序”的键。比如:
SELECT '{"b": 1, "a": 2}'::jsonb; -- 输出:{"a": 2, "b": 1}很多人刚遇到会以为数据被写坏了,其实这是jsonb二进制的正常表现。如果你真的需要保持原始顺序,比如做签名校验,就必须用json类型存储,或者在查询时用jsonb_pretty重新格式化。实际项目里,前端展示一般无所谓顺序,但如果你把jsonb字段用来存一段需要原样返回给第三方的报文,这里就会出事。我的做法是:这类报文一律单独用text字段存原文,业务扩展字段才用jsonb。
5.3 JSON字段为NULL与键缺失是两回事
官方文档的JSON运算符,对“不存在的键”和“值为NULL的键”处理是不同的。profile->>'missing_key'返回NULL,profile->>'key_with_null'也返回NULL。如果用COALESCE包一层,很容易把两者搞混。但如果业务需要区分“键不存在”和“键值为null”,可以用?运算符先判断键是否存在:
SELECT profile ? 'missing_key' AS key_exists, profile ? 'key_with_null' AS key_exists_with_null FROM user_profile WHERE user_id = 1001;这种区分在实际业务里挺重要。比如用户画像里,phone字段缺失表示未填写,phone为null表示曾经清空过,二者语义完全不同。如果只用->>判断,两个场景会被当成一样处理,容易出隐蔽的逻辑问题。
5.4 更新jsonb容易触发表膨胀
jsonb更新时,通常是把整个字段的新值写进去,并不会真正原地更新某个键。因为jsonb是变长类型,PostgreSQL的MVCC机制决定了更新会生成一个新版本行。如果一张表频繁小范围更新JSON里的某个键,表膨胀会很严重,VACUUM跟不上,查询性能会逐渐劣化。官方文档虽然没有专门说这个坑,但这属于PostgreSQL通用的更新机制问题,在JSON字段上表现尤其明显。
规避思路有两个:一是控制写入频率,二是定期跑VACUUM,必要时可以对大表做pg_repack。我在高并发更新场景下,会把频繁变动的键拆到独立普通字段里,JSONB只留真正低频变化的半结构化数据,效果显著。
6. 一个可以照抄的实战示例:订单扩展信息设计
光讲理论没有综合示例,很多人看完还是不知道如何落地。这里我给出一个常见的电商订单扩展信息示例,涵盖建表、写入、查询、索引四个环节,你可以直接在自己的项目里参考改造。
假设订单表需要一个灵活的extra_info字段,存优惠券信息、用户备注、配送偏好等:
CREATE TABLE orders ( order_id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, total_amount NUMERIC(10,2) NOT NULL, extra_info JSONB NOT NULL DEFAULT '{}'::jsonb ); INSERT INTO orders (order_id, user_id, total_amount, extra_info) VALUES (2026001, 1001, 199.00, '{"coupon": {"code": "SAVE50", "amount": 50}, "note": "尽快发货", "delivery": {"prefer_time": "evening"}}'), (2026002, 1002, 599.00, '{"coupon": {"code": "SAVE100", "amount": 100}, "delivery": {"prefer_time": "morning"}}'), (2026003, 1001, 89.00, '{"note": "放门口就好"}');查询某个用户所有用了优惠券的订单:
SELECT order_id, total_amount, extra_info->'coupon'->>'code' AS coupon_code FROM orders WHERE user_id = 1001 AND extra_info @> '{"coupon": {}}'::jsonb;注意这里用extra_info @> '{"coupon": {}}'判断是否存在coupon键,比extra_info ? 'coupon'更严谨一点,因为?只能判断顶层键,而@>支持嵌套路径。实测下来,这种写法配合GIN索引,即使表里数据量达到千万级,也能保持稳定在毫秒级返回。
配套索引建议:
CREATE INDEX idx_orders_extra_gin ON orders USING GIN (extra_info jsonb_path_ops);如果你需要频繁按extra_info->'delivery'->>'prefer_time'做筛选,再补一个表达式索引即可。整个设计既灵活又有性能兜底,是我比较推荐的一种方案。
最后再分享一个小技巧
如果你经常在命令行里调试JSON字段,jsonb_pretty这个函数比官方文档里大部分示例都更实用。它能把一段压缩的jsonb格式化成带缩进的文本,排查嵌套结构时简直神器。比如:
SELECT jsonb_pretty(extra_info) FROM orders WHERE order_id = 2026001;输出结果就像格式化过的JSON文件一样,层级一目了然。此外,我还习惯在调试时配合jsonb_typeof确认某个键的类型,这样能避免很多类型转换错误。根据我个人在实际项目里的经验,JSONB字段最怕的不是不会用高级功能,而是基础运算符和类型概念没吃透,导致每写一条SQL都像在碰运气。把这篇文章里提到的选型原则、运算符、索引策略和排查手法过一遍,绝大多数JSON字段问题都能在这个框架内解决。