Skip to content

JSON 与 JSONB 的对比 ​

本节对比 PostgreSQL 的两种 JSON 类型 JSON 与 JSONB。你将了解它们的存储格式差异、写入与查询性能取舍、键顺序与重复键处理、索引支持情况,以及在什么场景下该选哪一个。

前置知识

阅读本节前,建议先了解:UUID 类型(UUID)

基础概念 ​

PostgreSQL 提供两个存储 JSON 的类型,两者的输入值集合几乎完全一致,区别在于存储格式带来的行为与性能差异:

类型存储格式说明
JSON原文(text)精确副本处理函数每次执行都要重新解析
JSONB分解后的二进制格式写入时多一次转换开销,但处理时无需重新解析,且支持索引

JSON 类型保留输入的空白字符与对象键顺序,重复键也全部保留(处理函数取最后一个值为准)。JSONB 相反:不保留空白、不保留键顺序、不保留重复键(只留最后一个值)。

对比维度JSONJSONB
存储格式原文文本分解的二进制
写入速度快(仅做语法校验)略慢(需解析并转换)
读取/查询速度慢(每次重新解析)快(无需解析)
保留键顺序是否
保留重复键是(全部保留)否(只留最后一个)
保留空白是否
保留数字原始写法是(如 1.230e-5)否(按 numeric 输出 0.00001230)
支持索引否(无法直接建 GIN)是(GIN,支持 @> ? @? @@)
支持比较运算符否(无 = < > 等)是(B 树可用)
支持 @> / ? 系列否是
单值上限1 GB1 GB

结论先行:除极少数需要保留原文或键顺序的遗留场景,绝大多数应用都应该用 JSONB。

关键术语:

  • RFC 7159:JSON 数据交换格式的规范。注意 RFC 7159 已被 RFC 8259 取代,但 PostgreSQL 文档仍以 RFC 7159 为准。
  • 分解的二进制格式(Decomposed Binary Format):把 JSON 的键、值、类型信息拆开并按一定规则排列的存储格式,使按路径查找可以走索引而不必全文扫描。
  • 包含(Containment):判断一个 JSONB 是否「包含」另一个,用 @> / <@,只有 JSONB 有。
  • GIN:Generalized Inverted Index,倒排索引,是 JSONB 的主力索引类型。

语法与用法 ​

text
-- 类型声明
列名 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

最小示例,直接观察两者的输出差异:

sql
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;
text
                     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 的规则重写:

sql
SELECT
  '{"reading": 1.230e-5}'::json  AS raw_json,
  '{"reading": 1.230e-5}'::jsonb AS normalized_jsonb;
text
       raw_json       |    normalized_jsonb
----------------------+------------------------
 {"reading": 1.230e-5} | {"reading": 0.00001230}
(1 row)

注意 JSONB 保留了末尾的零(0.00001230),因为对 numeric 而言这些零虽然语义无效,但会被保留。这种差异在跨系统比对 JSON 字符串时会直接导致 !=,所以永远不要用字符串比较来判断两个 JSONB 是否相同,应该用 = 运算符。

JSON 原语与 PostgreSQL 类型的映射关系:

JSON 原语PostgreSQL 类型约束
stringtext禁止 \u0000;禁止无法用数据库编码表示的 Unicode 转义
numbernumeric禁止 NaN 与 infinity;超出 numeric 范围的值被拒绝
booleanboolean只接受小写 true / false
null无对应类型JSON 的 null 与 SQL 的 NULL 是两个不同概念

有一条容易被忽略的差异:JSONB 会拒绝超出 numeric 范围的数字,而 JSON 不会。

sql
-- JSON 类型照单全收,不做数值范围校验
SELECT '{"big": 1e400}'::json AS json_accepts;
text
     json_accepts
----------------------
 {"big": 1e400}
(1 row)
sql
-- 错误!JSONB 会按 numeric 处理,超出范围直接报错
SELECT '{"big": 1e400}'::jsonb;
text
ERROR:  value overflows numeric format

Unicode 转义与编码限制 ​

RFC 7159 规定 JSON 字符串使用 UTF-8 编码,因此只有当数据库编码是 UTF-8 时,JSON 类型才能严格符合规范。两者的容忍度不同:

检查项JSONJSONB
\uXXXX 语法正确性检查(必须跟 4 位十六进制)检查
转义字符能否用数据库编码表示不检查检查,不能表示则报错
\u0000输入函数不拦拒绝
代理对(surrogate pair)正确性不检查强制检查,合法者折叠为单字符
sql
-- JSON 可以存储无法在当前编码中表示的转义序列
SELECT '\u0000'::json AS json_allows_nul;
text
 json_allows_nul
-----------------
 "\u0000"
(1 row)
sql
-- 错误!JSONB 拒绝 \u0000(PostgreSQL 的 text 类型无法表示它)
SELECT '\u0000'::jsonb;
text
ERROR:  unsupported Unicode escape sequence
DETAIL:  \u0000 cannot be converted to text.

注意:虽然 JSON 的输入函数不做这些检查,但许多 JSON 处理函数会把转义转成真实字符,那时同样会抛错。也就是说 JSON 保存的「宽容」只在不处理的前提下存在。

键顺序、重复键与空白 ​

sql
-- JSON 保留重复键的全部副本
SELECT '{"a": 1, "a": 2}'::json AS json_keeps_both;
text
    json_keeps_both
----------------------
 {"a": 1, "a": 2}
(1 row)
sql
-- JSONB 只保留最后一个值
SELECT '{"a": 1, "a": 2}'::jsonb AS jsonb_keeps_last;
text
 jsonb_keeps_last
------------------
 {"a": 2}
(1 row)

这是两种类型最本质的语义分歧点。如果业务上依赖「原样保存上游 JSON(含重复键、含字段顺序)以便审计或签名校验」,那么只能选 JSON;否则 JSONB 的去重归一化反而是优点——它保证同一个逻辑对象只有一个确定表示,= 比较才能可靠工作。

写入与查询性能对比 ​

用一个具体的实验对比两者。先用 generate_series 造一份结构相同的 JSON,各自插入 10 万行:

sql
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);
text
DROP TABLE
DROP TABLE
CREATE TABLE
CREATE TABLE
sql
-- 插入 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;
text
INSERT 0 100000
INSERT 0 100000
sql
-- 比较两者的物理体积: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'));
text
 type_name | total_size
-----------+------------
 json      | 14 MB
 jsonb     | 12 MB
(1 row)

JSONB 体积更小,原因是去掉了冗余空白、去掉了重复键、并对键做了归一化。

再看查询侧。两个查询的语义完全相同,都取 user_name 为 user_5000 的那一行:

sql
-- JSON:每次都要重新解析整列文本
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM json_perf
WHERE doc->>'user_name' = 'user_5000';
text
                            QUERY PLAN
-------------------------------------------------------------------
 Seq Scan on json_perf
   Filter: ((doc ->> 'user_name'::text) = 'user_5000'::text)
   Rows Removed by Filter: 99999
(3 rows)
sql
-- JSONB:结构已分解,取键是直接的
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM jsonb_perf
WHERE doc->>'user_name' = 'user_5000';
text
                             QUERY PLAN
--------------------------------------------------------------------
 Seq Scan on jsonb_perf
   Filter: ((doc ->> 'user_name'::text) = 'user_5000'::text)
   Rows Removed by Filter: 99999
(3 rows)

两者都是顺序扫描——因为 ->> 表达式本身不可索引。但这不等于没有差异:

性能环节JSONJSONB
INSERT 开销低(只校验语法)较高(解析 + 转二进制)
路径查找单次开销高(每次重新解析)低(直接定位)
能否用 GIN 索引加速不能能(把 @> 等条件变成索引扫描)
大文档上的差距随文档变大而急剧放大相对平稳

决定性差异在最后两行。JSON 连 @> 都没有,想加速只能退化成表达式索引 ((doc->>'user_name')),而那样索引存的只是单个字段、无法覆盖多字段组合查询。JSONB 配合 GIN 索引可以把「文档里某处存在某键值对」这类查询直接变成索引查找。

用 @> 对比就能看出差距:

sql
-- 给 JSONB 加 GIN 索引
CREATE INDEX idx_jsonb_perf_doc ON jsonb_perf USING GIN (doc);
text
CREATE INDEX
sql
-- JSONB 走索引,无需扫描全表
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT doc->>'user_name'
FROM jsonb_perf
WHERE doc @> '{"user_name": "user_5000"}';
text
                             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)
sql
-- 错误!JSON 类型没有 @> 运算符,写不出来等价的索引查询
SELECT doc->>'user_name'
FROM json_perf
WHERE doc @> '{"user_name": "user_5000"}';
text
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:

能力JSONJSONB
GIN 索引(jsonb_ops)不支持支持 ? `?
GIN 索引(jsonb_path_ops)不支持支持 @> @? @@
表达式索引 (doc->>'k')支持支持
支持 = < > 比较不支持支持
可作 B 树索引键不支持支持
DISTINCT / GROUP BY / UNION不支持支持

JSON 连等值比较都不支持,因此无法参与 DISTINCT、GROUP BY、ORDER BY、集合运算,也无法建唯一索引:

sql
-- 错误!JSON 类型没有 = 运算符
SELECT DISTINCT doc FROM json_perf;
text
ERROR:  could not identify an equality operator for type json
LINE 1: SELECT DISTINCT doc FROM json_perf;
                ^
sql
-- JSONB 正常
SELECT count(DISTINCT doc) AS distinct_docs FROM jsonb_perf;
text
 distinct_docs
---------------
        100000
(1 row)

存储结构带来的更新代价 ​

JSONB 的二进制结构还有个副作用需要知道:它是整体存储的值,无法原地局部更新。哪怕只改文档里的一个键,PostgreSQL 也要重写整个 JSONB 值(并生成新版本的行),因为二进制节点之间通过偏移量互相引用,长度一变就要整体重建。

sql
-- 更新一个键,观察 WAL 与行重写代价
UPDATE jsonb_perf
SET doc = jsonb_set(doc, '{score}', '99.99'::jsonb)
WHERE id = 1;
text
UPDATE 1

这意味着:

  • JSONB 文档越大,单键更新的代价越高(与文档大小近似成正比)。
  • 更新会持有整行的行级锁,大文档上的高频更新会加剧锁竞争。
  • 文档里如果混放「高频更新的计数器」和「几乎不变的大段配置」,每次改计数器都要重写整段配置。

因此官方建议:让 JSONB 文档保持可控大小,理想情况下每个文档是一个业务上不可再分的原子数据单位。

实战示例 ​

下面用一个「商品扩展属性」表走完整流程,展示 JSONB 在真实场景下的建表、写入、查询与索引优化,并对照说明为什么不用 JSON。

第一步:建表

sql
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 '商品动态属性,结构随品类变化';
text
DROP TABLE
CREATE TABLE
COMMENT

第二步:插入不同品类的数据,属性结构各不相同

sql
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);
text
INSERT 0 2

第三步:查询深层嵌套值

sql
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';
text
    sku    |    name    | screen_type | brand_json | brand_text
-----------+------------+-------------+------------+------------
 PHONE-001 | 智能手机 A | OLED        | "Acme"     | Acme
(1 row)

注意 -> 与 ->> 的区别:-> 返回 JSONB(输出带引号),->> 返回 text(不带引号)。

第四步:用包含操作符做多条件筛选

sql
-- 找出品牌为 Acme 且带 5g 标签的商品
SELECT sku, name
FROM products
WHERE attributes @> '{"brand": "Acme", "tags": ["5g"]}';
text
    sku    |    name
-----------+------------
 PHONE-001 | 智能手机 A
(1 row)

第五步:验证体积差异

sql
-- 同一份数据存成 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;
text
 as_json_bytes | as_jsonb_bytes |    sku
---------------+----------------+-----------
           268 |            224 | PHONE-001
           142 |            126 | SHOE-001
(2 rows)

JSONB 明显更省空间。文档越大、空白与键名重复越多,差距越明显。

第六步:加索引并验证执行计划

sql
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
text
CREATE INDEX
sql
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT sku, name
FROM products
WHERE attributes @> '{"brand": "Acme"}';
text
                                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 类型,这一步根本无法完成。

第七步:清理

sql
DROP TABLE products;
DROP TABLE json_perf;
DROP TABLE jsonb_perf;
text
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 会丢信息(键顺序、重复键、原文空白、数字书写形式)。单向转换是不可逆的。

sql
-- 转成 JSONB 后,重复键与键顺序都丢了,再转回 JSON 也回不来
SELECT '{"b": 2, "a": 1, "a": 9}'::json::jsonb AS lost_info;
text
   lost_info
---------------
 {"a": 9, "b": 2}
(1 row)
  • 不要把 JSONB 当作「什么都往里面塞」的垃圾桶。关系型数据该建的列还是要建,JSONB 只承载真正结构不定的部分。

  • jsonb 的键名长度也占空间。键名重复出现在每一行里,键名越长体积越大。字段名建议用短名(如 uid 而非 user_identifier)。

最佳实践 ​

  • 默认一律用 JSONB。只有在需要保留原始文本(如存储上游签名报文的原文以便校验)时才用 JSON:

    sql
    payload_raw   json    NOT NULL,    -- 原文留档,用于审计/验签
    payload_parsed jsonb  GENERATED ALWAYS AS (payload_raw::jsonb) STORED   -- 便于查询
  • 给 JSONB 列一个非空默认值 '{}'::jsonb(对象)或 '[]'::jsonb(数组),避免下游到处判空:

    sql
    attributes jsonb NOT NULL DEFAULT '{}'::jsonb
  • 高频查询路径上的字段,考虑提升为独立列。JSONB 方便,但独立列的类型约束、可读性、索引效率都更好:

    sql
    brand     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);
  • 控制文档大小。把「独立可变更的大块内容」拆出去,避免一个键的修改触发整个大文档重写。

  • 需要校验结构时用检查约束,把明显的脏数据挡在写入侧:

    sql
    ALTER TABLE products
      ADD CONSTRAINT ck_attributes_is_object
      CHECK (jsonb_typeof(attributes) = 'object');
  • 在应用层统一做 JSON 序列化,不要把拼接的字符串直接塞进 SQL。用参数绑定,既避免注入也避免引号转义问题。

下一节

继续学习:JSONB 的创建与插入

参考链接 ​