本文是 PostgreSQL overview 的 implementation-layer deep article。Overview 已說明 PG 在 OLTP 譜系的定位、本文聚焦 JSONB deep dive — binary storage + GIN index 的結構性優勢。


JSON vs JSONB:選 JSONB

PG 9.2 加 JSON type、9.4 加 JSONB。99% 場景用 JSONB:

維度JSONJSONB
儲存純文字(原樣保存)Binary decomposed format
Parse cost每次 query parseInsert 時 parse 一次
Index 支援Limited(functional index)GIN / functional / partial 都行
Operator 支援有限(→ / →>)完整(@> / ? / @? / ? 等)
Duplicate key保留(原樣)只保留最後一個(normalize)
Key order保留不保留
Whitespace保留不保留

JSONB 唯一缺點是 binary 儲存(不保留 key order / whitespace / duplicate)。99% application 不在意這些。

application semantics 視角、JSONB 是 PG JSON 的 the right type、JSON 是 legacy / niche

JSONB GIN Index:核心結構性優勢

PG GIN(Generalized Inverted Index)可以對 JSONB 內所有 key/value pair 建 inverted index:

1CREATE TABLE products (
2    id SERIAL PRIMARY KEY,
3    metadata JSONB
4);
5
6-- GIN index
7CREATE INDEX idx_products_metadata ON products USING GIN (metadata);

加完後、JSONB query 用 GIN index 加速:

1-- @> (contains) 用 GIN
2SELECT * FROM products WHERE metadata @> '{"category": "shoes"}';
3
4-- ? (has key) 用 GIN
5SELECT * FROM products WHERE metadata ? 'discount';
6
7-- ?| (has any of these keys) 用 GIN
8SELECT * FROM products WHERE metadata ?| array['discount', 'promotion'];

跟 MongoDB index 對比、PG 不必 預先 define JSON path index、USING GIN (metadata)整個 JSONB document 任意 path 都有效。

jsonb_ops vs jsonb_path_ops

PG GIN 對 JSONB 有兩種 operator class

維度jsonb_ops(預設)jsonb_path_ops
索引內容Key + value 都索引只索引 path → value pair
Index size小(約一半)
支援 operator@> / ? / ?| / ?&@> (containment)
適用多種 query pattern只用 @> 的場景
1-- jsonb_ops(預設)
2CREATE INDEX idx_meta_default ON products USING GIN (metadata);
3
4-- jsonb_path_ops(小、快、但只支援 @>)
5CREATE INDEX idx_meta_path ON products USING GIN (metadata jsonb_path_ops);

選擇

  • 只跑 @> containment query → jsonb_path_ops(index 小、快)
  • ? / ?| / ?& key existence query → jsonb_ops(預設)

Operator + Path Query

JSONB 提供豐富 operator + jsonpath:

Operator

 1-- Extract value(returns jsonb)
 2SELECT metadata -> 'name' FROM products;
 3
 4-- Extract text(returns text)
 5SELECT metadata ->> 'name' FROM products;
 6
 7-- Path extract
 8SELECT metadata #> '{variants, 0, price}' FROM products;
 9SELECT metadata #>> '{variants, 0, price}' FROM products;  -- 返回 text
10
11-- Containment(用 GIN index)
12SELECT * FROM products WHERE metadata @> '{"category": "shoes", "active": true}';
13
14-- Reverse containment
15SELECT * FROM products WHERE '{"sub": "value"}' <@ metadata;
16
17-- Key existence
18SELECT * FROM products WHERE metadata ? 'discount';
19SELECT * FROM products WHERE metadata ?| array['a', 'b'];  -- 任一 key
20SELECT * FROM products WHERE metadata ?& array['a', 'b'];  -- 全部 key

jsonpath(PG 12+)

SQL/JSON jsonpath 是 SQL standard、PG 12+ 支援:

 1-- jsonb_path_query:展開 path 結果
 2SELECT jsonb_path_query(metadata, '$.variants[*].price')
 3FROM products WHERE id = 1;
 4
 5-- jsonb_path_exists:返 boolean
 6SELECT * FROM products
 7WHERE jsonb_path_exists(metadata, '$.variants[*] ? (@.price > 100)');
 8
 9-- jsonb_path_query_array:返 array of result
10SELECT jsonb_path_query_array(metadata, '$.tags[*]')
11FROM products;

jsonpath 比 PG-specific operator 標準化、跨 vendor portable。

Partial JSONB Index

只 query subset row 的場景、建 partial index:

1-- 只對 active product 建 metadata index
2CREATE INDEX idx_active_products_metadata
3ON products USING GIN (metadata)
4WHERE status = 'active';
5
6-- Query active products + JSONB filter
7SELECT * FROM products
8WHERE status = 'active' AND metadata @> '{"category": "shoes"}';
9-- → planner 用 partial GIN index

Partial index 比 full GIN 小很多、write cost 低、index hit rate 高。

5 個 Production 踩雷

1. 大 JSONB + TOAST — 性能崩潰

JSONB > 2 KB 自動進 TOAST(PG 內外部 storage)、每次 query read 該 row 都要 de-TOAST(拉外部 storage 再合併)。大 JSONB(> 50 KB)每次 query 慢 10-100x。

修法:

  • 大 attribute 拆獨立 column(如 description TEXT 不放 metadata)
  • JSON path index 對 hot path 加速、不必每次讀整個 JSONB
  • pg_column_size(metadata) 監控 JSONB size 分布、找 outlier
  • 對 truly 大 document(> 1 MB)考慮 separate table 或 object storage

2. Nested update — 整個 JSONB 重寫

PG 沒 atomic partial update。修改 nested key 必須讀整個 JSONB → 修改 → 寫回:

1UPDATE products
2SET metadata = jsonb_set(metadata, '{discount}', '0.2'::jsonb)
3WHERE id = 100;
4-- 等同於:讀 metadata、改 discount、寫回整個 metadata

大 JSONB + 高頻 update 場景、寫吞吐受限。跟 MongoDB $set operator 對應 partial document update 不同。

修法:

  • high-update nested key 拆獨立 column
  • Application 層 batch update(攢一批一次 update)
  • 接受 PG JSONB 是 immutable-replace 心智模型、不是 mutable in-place

3. Index 選錯 op class — ? query 走 full scan

jsonb_path_ops index、? key existence query 走 full scan(不用 index)。Application 看 query 慢、查 EXPLAIN 才發現 index 沒用。

修法:

  • 設計階段確認 application query pattern:只用 @> 還是會用 ?
  • 多 query pattern → jsonb_ops(預設)
  • 純 containment → jsonb_path_ops(省 index size)
  • 不確定先用預設、production 觀察後再優化

4. jsonb_path_queryjsonb_path_exists 行為差

  • jsonb_path_query(metadata, '$.variants[*].price') — 展開、每個 match return 一 row
  • jsonb_path_exists(metadata, '$.variants[*]') — return boolean(true if any match)

Application 想要「過濾 row」用前者寫成:

1-- 錯:返多 row 給每個 product、結果 row count 暴增
2SELECT id, jsonb_path_query(metadata, '$.variants[*].price') FROM products;

應該:

1-- 對:只過濾 product
2SELECT * FROM products WHERE jsonb_path_exists(metadata, '$.variants[*] ? (@.price > 100)');

修法:

  • 區分 exists 過濾 row vs query 展開 row
  • 過濾用 jsonb_path_exists@> operator
  • 展開用 jsonb_path_query + 配合 LATERAL 或 subquery

5. Partial index 條件不對齊 query

1CREATE INDEX idx_active_metadata ON products USING GIN (metadata) WHERE status = 'active';
2
3-- Application query 但 status 沒 explicit
4SELECT * FROM products WHERE metadata @> '{"category": "shoes"}';
5-- → 不用 partial index(planner 不知道 status='active' 條件)

修法:

  • Application query 必須包含 partial index 的 WHERE 條件

    1SELECT * FROM products WHERE status = 'active' AND metadata @> '...';
  • 確認 planner 用 partial index:EXPLAINIndex Scan using idx_active_metadata

  • 不對齊 query pattern 的 partial index = waste

何時用 JSONB vs 拆 column

場景選擇
不規則 schema(user-generated metadata / customization)JSONB
半結構化 + 5-10 個常 query keyJSONB + GIN partial index
規則 schema、column 數量穩定拆 column(更快 / index 易)
Nested 結構 + 經常需要展開 queryJSONB + jsonb_path_query
大 document(> 1 KB)+ 高頻 update拆 column 或 separate table
完全 schemaless workload考慮 MongoDB 而非 PG

JSONB 是 PG 適合 semi-structured data 的工具、不是 MongoDB 替代品。對 主要結構化 + 少量 JSON 場景 JSONB 完美;對 主要 JSON / 複雜 nested aggregation 場景 MongoDB 仍是專業選擇。

跟其他模組整合

跟 Query Optimization

JSONB query 的 planner 行為:

  • @> containment 對 jsonb_ops / jsonb_path_ops 都用 GIN
  • ? 只對 jsonb_ops 用 GIN
  • jsonb_path_exists 用 functional index(不是 GIN)
  • 看 EXPLAIN 確認用對 index、詳見 Query Optimization

跟 SQL Features Baseline

JSONB 是 PG 結構性領先特性之一、詳見 SQL Features Baseline

跟 MVCC + Lock Model

JSONB UPDATE 整個 column 重寫、每次 update 創新 tuple、跟 row update 相同 MVCC behavior。詳見 MVCC + Lock Model

跟 MySQL JSON_TABLE

MySQL 8.0 JSON_TABLE 跟 PG jsonpath 類似(都 SQL standard)、但 index 機制 完全不同:

  • PG:JSONB + GIN index over 整個 column
  • MySQL:JSON column + generated column + index over generated

PG JSONB GIN 是 結構性領先、MySQL 短期內難對應。詳見 MySQL Modern SQL Features

觀測 metric

  • pg_column_size(metadata) — 每 row JSONB size 分布
  • pg_relation_size('idx_name') — JSONB GIN index 大小
  • pg_stat_user_indexes.idx_scan — JSONB index 使用次數
  • TOAST table size:SELECT pg_relation_size(reltoastrelid) FROM pg_class WHERE relname='products'

相關連結