JSON 与 JSONB 的对比
本节对比 PostgreSQL 的两种 JSON 类型 JSON 与 JSONB。你将了解它们的存储格式差异、写入与查询性能取舍、键顺序与重复键处理、索引支持情况,以及在什么场景下该选哪一个。
前置知识
阅读本节前,建议先了解:UUID 类型(UUID)
基础概念
PostgreSQL 提供两个存储 JSON 的类型,两者的输入值集合几乎完全一致,区别在于存储格式带来的行为与性能差异:
| 类型 | 存储格式 | 说明 |
|---|---|---|
JSON | 原文(text)精确副本 | 处理函数每次执行都要重新解析 |
JSONB | 分解后的二进制格式 | 写入时多一次转换开销,但处理时无需重新解析,且支持索引 |
JSON 类型保留输入的空白字符与对象键顺序,重复键也全部保留(处理函数取最后一个值为准)。JSONB 相反:不保留空白、不保留键顺序、不保留重复键(只留最后一个值)。
| 对比维度 | JSON | JSONB |
|---|---|---|
| 存储格式 | 原文文本 | 分解的二进制 |
| 写入速度 | 快(仅做语法校验) | 略慢(需解析并转换) |
| 读取/查询速度 | 慢(每次重新解析) | 快(无需解析) |
| 保留键顺序 | 是 | 否 |
| 保留重复键 | 是(全部保留) | 否(只留最后一个) |
| 保留空白 | 是 | 否 |
| 保留数字原始写法 | 是(如 1.230e-5) | 否(按 numeric 输出 0.00001230) |
| 支持索引 | 否(无法直接建 GIN) | 是(GIN,支持 @> ? @? @@) |
| 支持比较运算符 | 否(无 = < > 等) | 是(B 树可用) |
支持 @> / ? 系列 | 否 | 是 |
| 单值上限 | 1 GB | 1 GB |
结论先行:除极少数需要保留原文或键顺序的遗留场景,绝大多数应用都应该用 JSONB。
关键术语:
- RFC 7159:JSON 数据交换格式的规范。注意 RFC 7159 已被 RFC 8259 取代,但 PostgreSQL 文档仍以 RFC 7159 为准。
- 分解的二进制格式(Decomposed Binary Format):把 JSON 的键、值、类型信息拆开并按一定规则排列的存储格式,使按路径查找可以走索引而不必全文扫描。
- 包含(Containment):判断一个
JSONB是否「包含」另一个,用@>/<@,只有JSONB有。 - GIN:Generalized Inverted Index,倒排索引,是
JSONB的主力索引类型。
语法与用法
-- 类型声明
列名 JSON
列名 JSONB
-- 字面量(两种类型写法相同)
'{"a": 1}'::json
'{"a": 1}'::jsonb
'[1, 2, "foo", null]'::jsonb
'5'::jsonb -- 标量也是合法 JSON
-- 类型互转
json_value::jsonb
jsonb_value::json
-- 键顺序与重复键(JSON 保留,JSONB 归一)
SELECT '{"b": 2, "a": 1}'::json; -- 输出保持 {"b": 2, "a": 1}
SELECT '{"b": 2, "a": 1}'::jsonb; -- 输出变为 {"a": 1, "b": 2}
-- 索引(仅 JSONB)
CREATE INDEX idx_gin ON t USING GIN (jsonb_col);
CREATE INDEX idx_gin_path ON t USING GIN (jsonb_col jsonb_path_ops);
-- 比较(仅 JSONB)
jsonb_a = jsonb_b jsonb_a < jsonb_b jsonb_a > jsonb_b最小示例,直接观察两者的输出差异:
SET client_min_messages = warning;
SELECT
'{"bar":"baz", "balance":7.77, "active":false}'::json AS as_json,
'{"bar":"baz", "balance":7.77, "active":false}'::jsonb AS as_jsonb; as_json | as_jsonb
-------------------------------------------------+------------------------------------------
{"bar":"baz", "balance":7.77, "active":false} | {"active": false, "balance": 7.77, "bar": "baz"}
(1 row)JSON 原样回显输入,连多余的空格都保留;JSONB 把键重新排序(按长度优先、再按字节序)并规范了空白。
详细说明
数字表示:写进去的是原文还是数值
JSONB 把 JSON 的 number 原语映射到 PostgreSQL 的 numeric 类型,因此输出格式会按 numeric 的规则重写:
SELECT
'{"reading": 1.230e-5}'::json AS raw_json,
'{"reading": 1.230e-5}'::jsonb AS normalized_jsonb; raw_json | normalized_jsonb
----------------------+------------------------
{"reading": 1.230e-5} | {"reading": 0.00001230}
(1 row)注意 JSONB 保留了末尾的零(0.00001230),因为对 numeric 而言这些零虽然语义无效,但会被保留。这种差异在跨系统比对 JSON 字符串时会直接导致 !=,所以永远不要用字符串比较来判断两个 JSONB 是否相同,应该用 = 运算符。
JSON 原语与 PostgreSQL 类型的映射关系:
| JSON 原语 | PostgreSQL 类型 | 约束 |
|---|---|---|
string | text | 禁止 \u0000;禁止无法用数据库编码表示的 Unicode 转义 |
number | numeric | 禁止 NaN 与 infinity;超出 numeric 范围的值被拒绝 |
boolean | boolean | 只接受小写 true / false |
null | 无对应类型 | JSON 的 null 与 SQL 的 NULL 是两个不同概念 |
有一条容易被忽略的差异:JSONB 会拒绝超出 numeric 范围的数字,而 JSON 不会。
-- JSON 类型照单全收,不做数值范围校验
SELECT '{"big": 1e400}'::json AS json_accepts; json_accepts
----------------------
{"big": 1e400}
(1 row)-- 错误!JSONB 会按 numeric 处理,超出范围直接报错
SELECT '{"big": 1e400}'::jsonb;ERROR: value overflows numeric formatUnicode 转义与编码限制
RFC 7159 规定 JSON 字符串使用 UTF-8 编码,因此只有当数据库编码是 UTF-8 时,JSON 类型才能严格符合规范。两者的容忍度不同:
| 检查项 | JSON | JSONB |
|---|---|---|
\uXXXX 语法正确性 | 检查(必须跟 4 位十六进制) | 检查 |
| 转义字符能否用数据库编码表示 | 不检查 | 检查,不能表示则报错 |
\u0000 | 输入函数不拦 | 拒绝 |
| 代理对(surrogate pair)正确性 | 不检查 | 强制检查,合法者折叠为单字符 |
-- JSON 可以存储无法在当前编码中表示的转义序列
SELECT '\u0000'::json AS json_allows_nul; json_allows_nul
-----------------
"\u0000"
(1 row)-- 错误!JSONB 拒绝 \u0000(PostgreSQL 的 text 类型无法表示它)
SELECT '\u0000'::jsonb;ERROR: unsupported Unicode escape sequence
DETAIL: \u0000 cannot be converted to text.注意:虽然 JSON 的输入函数不做这些检查,但许多 JSON 处理函数会把转义转成真实字符,那时同样会抛错。也就是说 JSON 保存的「宽容」只在不处理的前提下存在。
键顺序、重复键与空白
-- JSON 保留重复键的全部副本
SELECT '{"a": 1, "a": 2}'::json AS json_keeps_both; json_keeps_both
----------------------
{"a": 1, "a": 2}
(1 row)-- JSONB 只保留最后一个值
SELECT '{"a": 1, "a": 2}'::jsonb AS jsonb_keeps_last; jsonb_keeps_last
------------------
{"a": 2}
(1 row)这是两种类型最本质的语义分歧点。如果业务上依赖「原样保存上游 JSON(含重复键、含字段顺序)以便审计或签名校验」,那么只能选 JSON;否则 JSONB 的去重归一化反而是优点——它保证同一个逻辑对象只有一个确定表示,= 比较才能可靠工作。
写入与查询性能对比
用一个具体的实验对比两者。先用 generate_series 造一份结构相同的 JSON,各自插入 10 万行:
DROP TABLE IF EXISTS json_perf;
DROP TABLE IF EXISTS jsonb_perf;
CREATE TABLE json_perf (id int GENERATED ALWAYS AS IDENTITY, doc json);
CREATE TABLE jsonb_perf (id int GENERATED ALWAYS AS IDENTITY, doc jsonb);DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE-- 插入 10 万行,每行都是同样结构的 JSON
INSERT INTO json_perf (doc)
SELECT json_build_object(
'user_id', g,
'user_name', 'user_' || g,
'is_active', (g % 2 = 0),
'score', round((random() * 100)::numeric, 2),
'tags', json_build_array('a' || (g % 5), 'b' || (g % 7))
)
FROM generate_series(1, 100000) AS g;
INSERT INTO jsonb_perf (doc)
SELECT doc::jsonb FROM json_perf;INSERT 0 100000
INSERT 0 100000-- 比较两者的物理体积:JSONB 因去重归一化通常更小
SELECT
'json' AS type_name,
pg_size_pretty(pg_total_relation_size('json_perf')) AS total_size
UNION ALL
SELECT
'jsonb',
pg_size_pretty(pg_total_relation_size('jsonb_perf')); type_name | total_size
-----------+------------
json | 14 MB
jsonb | 12 MB
(1 row)JSONB 体积更小,原因是去掉了冗余空白、去掉了重复键、并对键做了归一化。
再看查询侧。两个查询的语义完全相同,都取 user_name 为 user_5000 的那一行:
-- JSON:每次都要重新解析整列文本
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM json_perf
WHERE doc->>'user_name' = 'user_5000'; QUERY PLAN
-------------------------------------------------------------------
Seq Scan on json_perf
Filter: ((doc ->> 'user_name'::text) = 'user_5000'::text)
Rows Removed by Filter: 99999
(3 rows)-- JSONB:结构已分解,取键是直接的
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM jsonb_perf
WHERE doc->>'user_name' = 'user_5000'; QUERY PLAN
--------------------------------------------------------------------
Seq Scan on jsonb_perf
Filter: ((doc ->> 'user_name'::text) = 'user_5000'::text)
Rows Removed by Filter: 99999
(3 rows)两者都是顺序扫描——因为 ->> 表达式本身不可索引。但这不等于没有差异:
| 性能环节 | JSON | JSONB |
|---|---|---|
| INSERT 开销 | 低(只校验语法) | 较高(解析 + 转二进制) |
| 路径查找单次开销 | 高(每次重新解析) | 低(直接定位) |
| 能否用 GIN 索引加速 | 不能 | 能(把 @> 等条件变成索引扫描) |
| 大文档上的差距 | 随文档变大而急剧放大 | 相对平稳 |
决定性差异在最后两行。JSON 连 @> 都没有,想加速只能退化成表达式索引 ((doc->>'user_name')),而那样索引存的只是单个字段、无法覆盖多字段组合查询。JSONB 配合 GIN 索引可以把「文档里某处存在某键值对」这类查询直接变成索引查找。
用 @> 对比就能看出差距:
-- 给 JSONB 加 GIN 索引
CREATE INDEX idx_jsonb_perf_doc ON jsonb_perf USING GIN (doc);CREATE INDEX-- JSONB 走索引,无需扫描全表
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM jsonb_perf
WHERE doc @> '{"user_name": "user_5000"}'; QUERY PLAN
--------------------------------------------------------------------
Bitmap Heap Scan on jsonb_perf
Recheck Cond: (doc @> '{"user_name": "user_5000"}'::jsonb)
-> Bitmap Index Scan on idx_jsonb_perf_doc
Index Cond: (doc @> '{"user_name": "user_5000"}'::jsonb)
(4 rows)-- 错误!JSON 类型没有 @> 运算符,写不出来等价的索引查询
SELECT doc->>'user_name'
FROM json_perf
WHERE doc @> '{"user_name": "user_5000"}';ERROR: operator does not exist: json @> unknown
LINE 3: WHERE doc @> '{"user_name": "user_5000"}';
^
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.索引与比较能力差异
JSONB 的索引能力远强于 JSON:
| 能力 | JSON | JSONB |
|---|---|---|
GIN 索引(jsonb_ops) | 不支持 | 支持 ? `? |
GIN 索引(jsonb_path_ops) | 不支持 | 支持 @> @? @@ |
表达式索引 (doc->>'k') | 支持 | 支持 |
支持 = < > 比较 | 不支持 | 支持 |
| 可作 B 树索引键 | 不支持 | 支持 |
DISTINCT / GROUP BY / UNION | 不支持 | 支持 |
JSON 连等值比较都不支持,因此无法参与 DISTINCT、GROUP BY、ORDER BY、集合运算,也无法建唯一索引:
-- 错误!JSON 类型没有 = 运算符
SELECT DISTINCT doc FROM json_perf;ERROR: could not identify an equality operator for type json
LINE 1: SELECT DISTINCT doc FROM json_perf;
^-- JSONB 正常
SELECT count(DISTINCT doc) AS distinct_docs FROM jsonb_perf; distinct_docs
---------------
100000
(1 row)存储结构带来的更新代价
JSONB 的二进制结构还有个副作用需要知道:它是整体存储的值,无法原地局部更新。哪怕只改文档里的一个键,PostgreSQL 也要重写整个 JSONB 值(并生成新版本的行),因为二进制节点之间通过偏移量互相引用,长度一变就要整体重建。
-- 更新一个键,观察 WAL 与行重写代价
UPDATE jsonb_perf
SET doc = jsonb_set(doc, '{score}', '99.99'::jsonb)
WHERE id = 1;UPDATE 1这意味着:
- JSONB 文档越大,单键更新的代价越高(与文档大小近似成正比)。
- 更新会持有整行的行级锁,大文档上的高频更新会加剧锁竞争。
- 文档里如果混放「高频更新的计数器」和「几乎不变的大段配置」,每次改计数器都要重写整段配置。
因此官方建议:让 JSONB 文档保持可控大小,理想情况下每个文档是一个业务上不可再分的原子数据单位。
实战示例
下面用一个「商品扩展属性」表走完整流程,展示 JSONB 在真实场景下的建表、写入、查询与索引优化,并对照说明为什么不用 JSON。
第一步:建表
DROP TABLE IF EXISTS products;
CREATE TABLE products (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
sku varchar(32) NOT NULL UNIQUE,
name varchar(128) NOT NULL,
price numeric(12,2) NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'::jsonb, -- 动态属性
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON COLUMN products.attributes IS '商品动态属性,结构随品类变化';DROP TABLE
CREATE TABLE
COMMENT第二步:插入不同品类的数据,属性结构各不相同
INSERT INTO products (sku, name, price, attributes)
VALUES
('PHONE-001', '智能手机 A', 3999.00, '{
"brand": "Acme",
"screen": {"size": 6.7, "type": "OLED"},
"storage": 256,
"colors": ["black", "blue"],
"tags": ["5g", "flagship"]
}'::jsonb),
('SHOE-001', '跑鞋 B', 599.00, '{
"brand": "RunFast",
"size_range": [38, 39, 40, 41, 42],
"material": "mesh",
"tags": ["running", "lightweight"]
}'::jsonb);INSERT 0 2第三步:查询深层嵌套值
SELECT
sku,
name,
attributes -> 'screen' ->> 'type' AS screen_type, -- 嵌套对象
attributes -> 'brand' AS brand_json, -- 保留 JSON 类型
attributes ->> 'brand' AS brand_text -- 转成 text
FROM products
WHERE sku = 'PHONE-001'; sku | name | screen_type | brand_json | brand_text
-----------+------------+-------------+------------+------------
PHONE-001 | 智能手机 A | OLED | "Acme" | Acme
(1 row)注意 -> 与 ->> 的区别:-> 返回 JSONB(输出带引号),->> 返回 text(不带引号)。
第四步:用包含操作符做多条件筛选
-- 找出品牌为 Acme 且带 5g 标签的商品
SELECT sku, name
FROM products
WHERE attributes @> '{"brand": "Acme", "tags": ["5g"]}'; sku | name
-----------+------------
PHONE-001 | 智能手机 A
(1 row)第五步:验证体积差异
-- 同一份数据存成 JSON 与 JSONB 的体积对比
SELECT
pg_column_size(attributes::json) AS as_json_bytes,
pg_column_size(attributes) AS as_jsonb_bytes,
sku
FROM products
ORDER BY sku; as_json_bytes | as_jsonb_bytes | sku
---------------+----------------+-----------
268 | 224 | PHONE-001
142 | 126 | SHOE-001
(2 rows)JSONB 明显更省空间。文档越大、空白与键名重复越多,差距越明显。
第六步:加索引并验证执行计划
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);CREATE INDEXEXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT sku, name
FROM products
WHERE attributes @> '{"brand": "Acme"}'; QUERY PLAN
---------------------------------------------------------------------------
Bitmap Heap Scan on products
Recheck Cond: (attributes @> '{"brand": "Acme"}'::jsonb)
-> Bitmap Index Scan on idx_products_attributes
Index Cond: (attributes @> '{"brand": "Acme"}'::jsonb)
(4 rows)索引被正确使用。如果当初这个列是 JSON 类型,这一步根本无法完成。
第七步:清理
DROP TABLE products;
DROP TABLE json_perf;
DROP TABLE jsonb_perf;DROP TABLE
DROP TABLE
DROP TABLE注意事项
不要用字符串比较判断 JSONB 是否相等。键顺序、数字表示、空白都可能不同,必须用
=运算符:sql-- 错误!字符串比较结果不可靠 SELECT '{"a":1, "b":2}'::jsonb::text = '{"b":2, "a":1}'::jsonb::text; -- 结果依赖内部排序JSON无法建 GIN 索引,也无法参与DISTINCT/GROUP BY/ORDER BY。选它之前先确认确实不需要这些能力。JSON的宽容只在「不处理」时成立。虽然它的输入函数允许\u0000等转义,但很多处理函数会把转义转成真实字符,届时照样报错。JSONB 更新是整值重写。单键更新会重写整个文档,文档越大代价越高,并持有整行锁。不要在设计上依赖「局部更新很便宜」。
jsonb与json之间可以互转,但转成jsonb会丢信息(键顺序、重复键、原文空白、数字书写形式)。单向转换是不可逆的。
-- 转成 JSONB 后,重复键与键顺序都丢了,再转回 JSON 也回不来
SELECT '{"b": 2, "a": 1, "a": 9}'::json::jsonb AS lost_info; lost_info
---------------
{"a": 9, "b": 2}
(1 row)不要把
JSONB当作「什么都往里面塞」的垃圾桶。关系型数据该建的列还是要建,JSONB 只承载真正结构不定的部分。jsonb的键名长度也占空间。键名重复出现在每一行里,键名越长体积越大。字段名建议用短名(如uid而非user_identifier)。
最佳实践
默认一律用
JSONB。只有在需要保留原始文本(如存储上游签名报文的原文以便校验)时才用JSON:sqlpayload_raw json NOT NULL, -- 原文留档,用于审计/验签 payload_parsed jsonb GENERATED ALWAYS AS (payload_raw::jsonb) STORED -- 便于查询给 JSONB 列一个非空默认值
'{}'::jsonb(对象)或'[]'::jsonb(数组),避免下游到处判空:sqlattributes jsonb NOT NULL DEFAULT '{}'::jsonb高频查询路径上的字段,考虑提升为独立列。JSONB 方便,但独立列的类型约束、可读性、索引效率都更好:
sqlbrand varchar(64), -- 从 attributes 中提升出来,常被筛选与展示 attributes jsonb NOT NULL DEFAULT '{}'::jsonb按查询模式选索引操作符类。需要
?/?|/?&就用默认的jsonb_ops;只需要@>/@?/@@则用更小更快的jsonb_path_ops:sql-- 只需要包含查询,索引体积可省下约一半 CREATE INDEX idx_products_attributes_path ON products USING GIN (attributes jsonb_path_ops);控制文档大小。把「独立可变更的大块内容」拆出去,避免一个键的修改触发整个大文档重写。
需要校验结构时用检查约束,把明显的脏数据挡在写入侧:
sqlALTER TABLE products ADD CONSTRAINT ck_attributes_is_object CHECK (jsonb_typeof(attributes) = 'object');在应用层统一做 JSON 序列化,不要把拼接的字符串直接塞进 SQL。用参数绑定,既避免注入也避免引号转义问题。
下一节
继续学习:JSONB 的创建与插入