接手过不少想把“复杂业务全部塞进关系型数据库”的项目,我一度对 PostgreSQL 里的 JSONB 又爱又恨。它好用吗?好用,能把动态字段、日志结构、第三方回调数据一口气装进一个字段里,省掉无数张“列不断变多”的临时表。它危险吗?也危险,索引设计错了,更新频繁了,或者把数值类型存错了,生产环境会给你上深刻一课。今天这篇不写官方文档复读,就聊聊 JSON/JSONB 的存储差异、索引套路、常用查询操作和我在真实业务里踩过的坑。
如果你正在做公众号后台、物联网设备上报、订单快照、用户行为日志这类“结构总是变”的功能,或者已经建了 jsonb 字段但查询越来越慢,这篇应该能帮你把认知理顺。内容偏 PostgreSQL 实战,我也会把常见版本差异一并说清楚。
1. 为什么用 JSONB,而不是 JSON,也不是单独建表
很多初学 PostgreSQL 的人,看到 JSON 和 JSONB 两种类型的第一反应是“名字差不多,性能应该也差不多”。但它们的存储机制完全是两套逻辑。理解这一点,后续的索引和查询优化才不会跑偏。
1.1 JSON 与 JSONB 的存储差异
JSON 类型在我眼里更像一个“忠实记录的转储箱”。你写入{"name":"张三","age":30},它就原样把这段文本存下来。插入时不需要做额外解析,速度很快,但每次查询都要临时解析整段文本,而且没有索引可以加速。适合存“只需要完整读出来、不需要频繁查询内部字段”的原始报文。
JSONB 则完全不同。它在写入时会把纯文本解析为内部二进制结构,同时做三件很关键的事:去掉不必要的空格、对键进行排序、处理重复键。不要小看这一步,它带来的直接好处是,查询时不需要反复解析原始文本,还能建 GIN 索引去加速内容匹配。
我把差异整理成了下面这张表,方便你在写建表语句前快速做决定:
| 对比项 | JSON | JSONB |
|---|---|---|
| 存储格式 | 原文本,保留空格和键顺序 | 解析后的二进制格式 |
| 写入速度 | 快,只做文本保存 | 较慢,需要解析和规范化 |
| 查询速度 | 每次查询需要即时解析 | 快,可直接访问内部结构 |
| 重复键处理 | 保留重复键 | 只保留最后一个键值 |
| 键顺序 | 保持输入原样 | 按键长度和字节排序 |
| 索引支持 | 不支持内部字段索引 | 支持 GIN 索引等多种索引 |
| 典型场景 | 审计日志、原始报文存档 | 动态表单、业务查询、配置数据 |
从这张表能看出来,我在实际项目中 90% 的情况会选 JSONB。唯一例外是有合规、原始报文完整性要求的场景,比如支付回调的原始返回,必须逐字保留,连键顺序和空白都不能变,这时候才用 JSON 存原文。
1.2 什么时候非常适合 JSONB,什么情况要忍住不要用
JSONB 最适合处理的,是“schema 不稳定”的数据。典型就是用户自定义字段。今天给表单加了一个“微信号”,明天又加一个“收货地址”,如果全部做成数据库列,就得频繁执行 ALTER TABLE,迁移脚本堆积成山。把这类动态属性丢进一个jsonb字段,产品迭代就不用每次动表结构。
还有事件日志和 webhook 回调,报文结构由上游系统决定,双方只要约定好顶层协议,内部字段随时变。我做过一个设备上报系统,上报的传感器字段会根据设备型号变化,有的上报温度、有的上报湿度,还有的带一组数组。用 JSONB 存 body 字段,查询“某个设备上报过的所有事故字段”变得非常灵活。
但如果你是做订单金额统计、商品库存扣减、用户余额变动,这些数据有强约束、强事务和明确的汇总需求,就千万别把它们塞进 JSONB。关系模型在约束、索引、JOIN、聚合上仍然不可替代。把金额放进 JSONB 再做汇总,性能差不说,还会遇到类型不一致的麻烦。
1.3 版本差异:别拿着旧知识套新版本
PostgreSQL 的特性演进很快。JSON 类型从 9.2 加入,JSONB 从 9.4 加入,9.5 加入了jsonb_set等修改函数,9.6 引入了jsonb_insert,12 版本带来了 JSON Path 表达式,16 版本还在持续优化 JSON 数据读取的逻辑。
这里有一个很实际的建议:如果你已经打算在生产环境使用 JSONB 的高级查询,至少用 PostgreSQL 12 以上版本。JSON Path(@@、@?)让复杂嵌套条件的写法大幅简化,老版本只能用@>和一堆->拼接,可读性很差。新版本对 JSONB 索引的优化也更激进,同样的查询在 12 和 16 上执行计划可能完全不同。
2. 写入 JSONB:插入、更新与规范化细节
JSONB 的写入不是简单的“往字段里塞一段文字”。它有一整套解析、排序、压缩的流程。搞懂这个过程,才能解释为什么你写进去的 JSON 和查出来的会不一样。
2.1 文本到 JSONB 的转换过程
最常见的插入方式是这样的:
CREATE TABLE user_profile ( user_id bigint PRIMARY KEY, profile jsonb ); INSERT INTO user_profile (user_id, profile) VALUES (1, '{"city": "杭州", "name": "阿飞", "age": 30}'::jsonb);执行完这条 SQL 后,你会发现如果用jsonb_pretty(profile)查出来,键顺序变成了age在前、city在后、name在中间,和我们写的顺序完全不同。原因很简单:JSONB 内部按键的长度排序,长度相同的再按字节排序。
还有重复键问题。下面这条语句我用同一个键name传了两个值:
SELECT '{"name": "前端", "name": "后端"}'::jsonb;结果返回的是{"name": "后端"},因为 JSONB 只保留最后一个重复键。这个行为有时候是好意,帮你避免了歧义,但也容易掩盖上游数据问题。我在某采集系统里就见过,源系统重发了同一个字段,结果是新值覆盖旧值,排查问题时才发现原始报文里有两个name。
2.2 更新嵌套字段:jsonb_set 和删除路径
JSONB 最大的优势之一是更新嵌套字段时不用把整个对象读出来再写回去。你可以精准定位到某个内部路径直接改:
UPDATE user_profile SET profile = jsonb_set( profile, '{address, city}', '"上海"'::jsonb, true ) WHERE user_id = 1;jsonb_set的第四个参数表示当路径不存在时是否自动创建。默认是true,意味着如果你写了一个之前不存在的嵌套路径,它会自动铺出中间对象。如果希望严格模式,改成false,路径不存在就不修改,返回原值。
删除路径用#-:
UPDATE user_profile SET profile = profile #- '{age}' WHERE user_id = 1;这里有个很容易忽略的点:修改 JSONB 字段本质上是创建了一个新版本的字段值,而不是在原存储位置上直接“原地打洞”。所以频繁 UPDATE 一个大 JSONB 对象,会让表产生大量 dead tuple,触发表膨胀。这个在高频写入场景非常致命,后面我会专门讲。
2.3 键顺序、空格和等号比较的连锁反应
由于 JSONB 不再保留原始文本,两个内容等价的 JSONB 值最终会被规范化成完全一样的内部结构。举个例子:
SELECT '{"a": 1, "b": 2}'::jsonb = '{"b":2,"a":1}'::jsonb AS equal;结果是true,因为 JSONB 比较的是内容语义而不是原始文本。这在某些业务场景会带来“误导性惊喜”。如果你拿 JSONB 字段做唯一约束或等值匹配,必须清楚它比较的是规范化的值,不是用户回头查的“原样字符串”。
此外,JSON 类型里出现"a": 1和"a": 1.0在数学上相等,但 JSONB 可能把它们存成不同表示吗?实际上,JSONB 会尽量保留数字精度,但浮点数和纯整数在内部类型表达上也有区别。涉及数值字段时,我会在应用层统一规范,避免因为1和1.0导致查询结果不一致。
2.4 写入性能:别被“二进制格式更高效”骗了
很多人以为 JSONB 既然是二进制格式,写入就一定比 JSON 快,这是误解。JSONB 写入需要解析、校验、排序、存储,整体成本比纯文本存储高不少。但它的优势在查询侧被无限放大,所以这是典型的“空间换时间、写入换查询”。
在我做过的一次批量导入测试中,向一张 500 万行表插入 JSONB 字段,单行 1KB 左右,原生 JSONB 写入耗时比 JSON 高约 18%。这个差异在高并发下会放大,但通常不会成为瓶颈。更要注意的是序列化和反序列化的浪费:如果应用层已经有一个对象,最好直接用jsonb_build_object或to_jsonb在数据库里组装,而不是在前端序列化成一段 JSON 字符串再传进来。
3. 索引设计:JSONB 查询快不快,就看这一步
JSONB 查询容易慢,并不是因为它本身差,而是因为它给了你太多“看似随意查询”的自由度。自由是要付出代价的,这个代价就是索引设计必须更讲究。如果连普通列都没有建立合适的索引,查询性能自然一塌糊涂。
3.1 GIN 索引与适用操作符
JSONB 最常用的索引是 GIN(Generalized Inverted Index)。它的核心思想是,把 JSONB 内部每个键值对都拆成索引条目,这样查询“是否存在某个键”“是否包含某个子对象”时,可以直接通过倒排索引定位。
创建方式非常简单:
CREATE INDEX idx_user_profile_gin ON user_profile USING GIN (profile);默认 GIN 索引支持的操作符包括:
@>:判断左侧 JSONB 是否包含右侧 JSONB?:判断某个顶层键是否存在?|:检查数组列出的键中是否有任意一个存在?&:检查数组列出的键是否全部存在
实际业务中,最常见的查询是:
SELECT * FROM user_profile WHERE profile @> '{"city": "杭州"}';这条查询能走 GIN 索引,速度很快。但如果你的查询是profile ->> 'city' = '杭州',默认 GIN 索引并不会直接加速这个等值条件,需要额外建表达式索引。
3.2 更高效的 jsonb_path_ops
GIN 索引有两种 opclass:默认的和jsonb_path_ops。后者是一种更紧凑的索引实现,它把整个 JSONB 路径作为一个整体来建立索引项,索引体积更小,通常只有默认 GIN 的四分之一到一半,@>查询速度也更快。
如果你业务里的 JSONB 查询几乎都是“判断是否包含某个子对象”,可以放心用:
CREATE INDEX idx_user_profile_path_ops ON user_profile USING gin (profile jsonb_path_ops);这个 opclass 的代价是,?、?|、?&这些判断键是否存在的操作符不再支持。方案选择上也很明确:如果你的查询多是“某键是否存在”,就选默认 GIN;如果多是“整个子结构是否匹配”,就走jsonb_path_ops。
3.3 表达式索引:给提取得很频繁的字段单独建索引
不是所有 JSONB 字段都适合建 GIN 索引。很多业务查询是精确取某个值,比如查订单表里payload->>'order_no' = '123456',这种查询用 GIN 索引帮助不大,更好的方式是建表达式索引:
CREATE INDEX idx_orders_order_no ON orders ((payload ->> 'order_no'));前提是查询语句的写法必须和索引表达式完全一致。如果你在查询里写成payload -> 'order_no' = '"123456"',索引就失效了,因为->返回的是 JSONB,->>返回的是文本,两者是不同的表达式。
如果某个 JSONB 字段里存放的是数字类型,表达式索引应该带类型转换:
CREATE INDEX idx_orders_amount ON orders (((payload ->> 'amount')::numeric)); SELECT * FROM orders WHERE (payload ->> 'amount')::numeric > 1000;这样写既能走索引,又保证了数值比较语义正确,不会发生字符串排序把 “100” 排在 “20” 前面的尴尬情况。
4. 查询 JSONB:操作符、函数与检索技巧
JSONB 查询的坑多数不在语法上,而在“返回值类型是什么”和“这个查询能不能走索引”这两件事。很多初级写法看起来差不多,实际性能天差地别。
4.1 基础提取操作符:-> 和 ->> 别混用
->返回 JSONB,->>返回文本。这句话是 JSONB 查询的第一条军规。
SELECT profile -> 'name' AS name_jsonb, profile ->> 'name' AS name_text FROM user_profile;返回结果里name_jsonb是"阿飞",注意它带双引号,看起来像个字符串但其实是 JSONB 标量;name_text是阿飞,才是纯文本。如果你在 WHERE 里用profile->'name' = '阿飞',会直接报错,因为左边是 JSONB,右边是普通字符串,类型不匹配。
还有一组按路径提取的操作符#>和#>>。它们接受一个文本数组作为路径,适合深层取值:
SELECT profile #> '{address, city}' AS city_jsonb, profile #>> '{address, city}' AS city_text FROM user_profile;从可读性上说,->适合取一层,嵌套两层以上建议用#>或 JSON Path,否则查询会变成一串->接力和很多括号,后期没人读得懂。
4.2 包含、存在与 JSON Path
@>是 JSONB 用的最多的查询操作符。它判断左边是否“包含”右边,包含的语义非常强大:
SELECT * FROM user_profile WHERE profile @> '{"tags": ["程序员", "PostgreSQL"]}';这里有一个容易误判的点:数组的包含不要求顺序一致。如果 JSONB 值是["PostgreSQL", "程序员"],上面的查询也会命中,因为只要左边数组里出现了右边数组的所有元素就算包含。
判断键是否存在就用?系列:
SELECT * FROM user_profile WHERE profile ? 'vip_level';判断多个键是否部分存在或全部存在:
SELECT * FROM user_profile WHERE profile ?| '{"weixin", "phone"}'; SELECT * FROM user_profile WHERE profile ?& '{"name", "age"}';PostgreSQL 12 加入的 JSON Path 让复杂条件变得更好写。比如:
SELECT * FROM user_profile WHERE profile @@ '$.address.city == "上海"';@@返回布尔值,@?返回是否存在匹配路径。JSON Path 写嵌套条件时比#>一串路径直观很多,但也需要注意版本兼容和索引是否真的被用到。经验是,每次写完先看EXPLAIN,不要想当然。
4.3 常用函数:拆开 JSONB 做统计
JSONB 本身天生适合存,但不适合直接做聚合运算。想把 JSONB 数组里的元素拆成多行,用jsonb_array_elements:
SELECT event_id, item FROM event_logs, jsonb_array_elements(payload -> 'items') AS item WHERE payload @> '{"type": "order"}';如果想连带数组下标一起返回,用WITH ORDINALITY:
SELECT event_id, ordinality, item FROM event_logs, jsonb_array_elements(payload -> 'items') WITH ORDINALITY AS item(entry, ordinality);要拆整个对象,用jsonb_each:
SELECT * FROM jsonb_each('{"a": 1, "b": 2}'::jsonb);输出两列:key 和 value。这个函数在横向转纵向做报表统计时非常有用。
4.4 查询优化:尽量先缩小范围,再展开 JSONB
JSONB 查询最常见的问题不是某个函数写错了,而是把 JSONB 当作主查询驱动。假设一张日志表有几百万行,每条日志的 JSONB 字段有一大堆内部字段。你直接对全表做jsonb_array_elements(payload->'items'),JSONB 的解析和展开成本会摊到每一行上,查询瞬间变慢。
正确思路是先用普通列或者其他索引条件把范围缩小,再展开 JSONB。比如先按时间范围过滤,或者按event_type过滤出 1% 的数据,再做 JSONB 拆解。我在一个事件系统里,把某条慢查询从 8 秒优化到 0.3 秒,核心改动就是先缩小了时间范围。这个经验比任何函数技巧都实在。
5. 真实场景:动态表单、事件日志与配置快照
光讲操作符和索引没有体感。我用三个经常遇到的业务场景,把前面那堆知识串起来,看看实际建模时应该怎么考虑。
5.1 场景一:用户自定义字段 / 动态表单
某业务系统需要让每个企业用户自定义自己的商品属性,比如有的要颜色、有的要材质、有的要走关系链。传统做法是维护一张“用户字段配置表”和一张“字段值表”,查询时还要做复杂 PIVOT,极其痛苦。
我用一张主表加 JSONB 搞定:
CREATE TABLE product_custom ( product_id bigint PRIMARY KEY, owner_id bigint NOT NULL, attrs jsonb NOT NULL ); INSERT INTO product_custom (product_id, owner_id, attrs) VALUES (101, 1001, '{"颜色": "红色", "材质": "棉"}');查询某个所有者下所有包含“材质=棉”的产品,可以走 GIN 索引:
SELECT product_id FROM product_custom WHERE owner_id = 1001 AND attrs @> '{"材质": "棉"}';这里我的索引设计是组合索引(owner_id, attrs gin),先按所有权缩小范围,再用 GIN 对 JSONB 做包含匹配,效果比单独建 GIN 好很多。注意 JSONB 的键如果是中文,索引一样可以用,但执行计划要认真确认。
5.2 场景二:事件日志
事件日志是最适合 JSONB 的场景之一,因为日志来源多、字段杂、没人能提前定死 schema。我常用结构如下:
CREATE TABLE event_logs ( id bigserial PRIMARY KEY, happened_at timestamptz NOT NULL DEFAULT now(), event_type text NOT NULL, source text NOT NULL, payload jsonb NOT NULL ); CREATE INDEX idx_event_logs_time ON event_logs (happened_at); CREATE INDEX idx_event_logs_payload ON event_logs USING GIN (payload);查最近一小时,指定来源,看 payload 里是否包含某个错误码:
SELECT id, payload FROM event_logs WHERE happened_at > now() - interval '1 hour' AND source = 'payment-service' AND payload @> '{"error": true}' ORDER BY happened_at DESC LIMIT 100;这个查询的关键点是,happened_at和source先完成过滤,JSONB 的@>条件在缩小后的数据集上执行,压力很小。如果我把 JSONB 条件放前面,优化器确实可能重新选择执行顺序,但在数据量极大的情况下,提前用普通列缩小范围几乎总是稳的。
5.3 场景三:业务配置快照
电商下单时,商品名称、价格、规格可能会后改。如果订单详情直接 JOIN 商品表,用户查看历史订单时价格会跟着商品表变动,这是不能接受的。正确的做法是把下单那一刻的商品信息快照到订单表。
快照字段用 JSONB 比较合适:
CREATE TABLE orders ( id bigserial PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, product_snapshot jsonb NOT NULL );product_snapshot里保存商品名、单价、SKU 属性、图片 URL。需要统计历史订单中购买过“某个属性商品”的数量时,直接用@>查询快照。这种设计的本质是用 JSONB 保留“历史事实”,避免关系模型里因为主数据变更导致的历史失真。
5.4 JSONB 与关系模型混合设计
我很少建议把核心业务字段全塞进 JSONB。真正稳妥的设计是“混合建模”:需要做约束、JOIN、聚合、排序的字段,单独拎出来作为普通列;其他动态、低频检索、结构不稳定的字段放 JSONB。
例如订单表:
CREATE TABLE orders ( id bigserial PRIMARY KEY, order_no text UNIQUE NOT NULL, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, paid_at timestamptz, extra jsonb );order_no建唯一约束,amount做统计,status做状态查询,extra存放红包、优惠券、渠道来源、配送备注等随时会加的东西。这样既保证了核心接口的稳定和性能,又给了产品迭代足够的灵活性。
6. 常见故障与避坑实录
要说什么地方最容易让 JSONB 项目翻车,基本都是下面这几个坑。我在多个生产项目里都踩过,有些问题不大但排查起来极其费神,这里一起列出来。
6.1 存储膨胀:UPDATE 是 JSONB 的隐形杀手
前面讲过,JSONB 的修改不是原地更新,而是生成新值并废弃旧值。如果一个高频接口频繁调用jsonb_set更新订单状态、进度等字段,那么数据表会产生大量死行。如果 autovacuum 的阈值配置不合理,表会越来越大,最终查询性能和磁盘占用一起崩。
我遇到过一次最典型的故障:一张表每天更新 200 万次,没及时做 vacuum,三个月后表物理大小膨胀到逻辑大小的 5 倍。排查起来很痛苦,因为SELECT count(*)很快,但全表扫描越来越慢。
解决方案有两个思路。一是把频繁更新的 JSONB 字段拆出来放到单独的“更新日志表”,主表保留最近一次完整快照;二是调优 autovacuum 参数,比如适当缩小autovacuum_vacuum_scale_factor,让回收死行更积极。如果业务完全无法容忍膨胀,甚至可以考虑用“重建表+填充数据”的定期任务。
6.2 数字类型匹配问题
JSONB 中"age": 30和"age": "30"在 JSONB 语义中是两种完全不同的类型。查询时用payload @> '{"age": "30"}'永远不会匹配数字类型的 30。这个问题在处理第三方回调时经常出现,因为上游系统可能在不同环境下返回不同类型。
解决思路是写入时统一数据类型,或者查询时同时考虑两种可能。最稳的办法是在应用层做数据清洗,把数字字段统一成 numeric 或统一转成字符串,不要让同一字段出现两种类型。
6.3 等值比较和唯一约束的迷惑行为
有同学尝试在 JSONB 字段上建唯一索引,用来防止“相同内容重复插入”。但 JSONB 的等值比较是内容语义比较,键顺序不同也算相同,空白不同也算相同。所以以下几点必须清楚:
- 数字
1和1.0在 JSONB 中不一定相等 - 对象键顺序不影响等值判断
- 数组顺序会影响等值判断
这导致 JSONB 唯一约束只能用于“绝对规范化数据”,对绝大多数业务不稳定。如果你需要按某个业务键去重,别拿整个 JSONB 做唯一,应该把这个业务键提取成独立列,再建唯一索引。
6.4->和->>选错导致索引失效
索引失效是慢查询的主要来源。同样查一个字段:
-- 走表达式索引 WHERE payload ->> 'order_no' = '123'; -- 不一定走索引 WHERE payload -> 'order_no' = '"123"';第二条写法把order_no的值和 JSON 字符串"123"比较,表面上看是对的,但对索引使用非常不友好,而且容易写成与表达式索引不一致的形式,优化器只能扫描。我建议团队统一规范:JSONB 取值优先用->>,不要贪图省事随便混用。
6.5 嵌套层级过深的函数调用
深嵌套 JSONB 的读写复杂度会指数级上升。比如五层嵌套的字段更新,每层都要写一串路径参数,代码可读性极差。我在代码评审时经常做的一条规定是:如果 JSONB 路径超过四层,必须考虑拆表或者用 JSON Path 简化,否则后面维护的人会非常痛苦。
6.6 没有在开发环境执行 EXPLAIN
JSONB 查询在数据量小的时候,什么写法都快。数据量上到千万级,执行计划才会现出原形。我在优化慢查询时,几乎不会跳过EXPLAIN ANALYZE:
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM event_logs WHERE happened_at > now() - interval '1 day' AND payload @> '{"error": true}';看输出时重点关注:是不是走了 Bitmap Index Scan 而不是 Seq Scan,实际扫描行数是多少,Buffers 是多少。很多时候,一行EXPLAIN输出比猜半个小时的“优化技巧”都管用。
回到开头那句话,我对 JSONB 的态度很复杂,但长大之后反而简单了。能用关系模型讲清楚的业务,不硬塞 JSONB;一旦决定用 JSONB,就把规范化写入、索引原则、更新频率和膨胀预期一并规划好。如果你正在生产环境里被 JSONB 查询卡住,建议先别急着换数据库,去看看你的EXPLAIN和pg_stat_user_tables里的n_tup_dead,多半能找到答案。