JSON 與 JSONB 操作:PostgreSQL 的半結構化資料處理 | PostgreSQL
2026/06/26
JSON 與 JSONB 是 PostgreSQL 處理半結構化資料的核心型別。JSON 保留原始文字格式,JSONB 則以二進位格式儲存並支援索引,讓關聯式資料庫也能高效處理 NoSQL 風格的彈性結構。本篇深入解析兩者的儲存差異、查詢運算子、GIN 索引策略、JSONPath 語法,以及實務中的設計模式。
JSON vs JSONB:選擇正確的型別
儲存方式差異
JSON 和 JSONB 的核心差異在於儲存與處理方式:
JSON(文字格式):
寫入: '{"name": "Alice", "age": 30}'
儲存: 原始 JSON 字串(含空白、鍵順序、重複鍵全部保留)
讀取: 每次存取都重新解析 JSON 文字 → 慢
JSONB(二進位格式):
寫入: '{"name": "Alice", "age": 30}'
儲存: 解析後的二進位樹結構(去除空白、排序鍵、移除重複鍵)
讀取: 直接存取二進位結構,無需重新解析 → 快
| 特性 | JSON | JSONB |
|---|---|---|
| 儲存格式 | 原始文字 | 二進位 |
| 寫入速度 | 快(不需解析) | 稍慢(需解析為二進位) |
| 讀取速度 | 慢(每次重新解析) | 快(直接存取) |
| 保留格式 | 保留空白、鍵順序、重複鍵 | 不保留 |
| 索引支援 | 不支援 | GIN、GiST、B-tree |
| 相等比較 | 不支援 | 支援 |
| 包含運算子 | 不支援 | @>、<@、?、`? |
| 適用場景 | 日誌原樣保存、格式保留需求 | 絕大多數場景 |
-- 驗證格式差異
SELECT '{"b": 1, "a": 2, "a": 3}'::json;
-- 結果: {"b": 1, "a": 2, "a": 3} ← 保留原樣
SELECT '{"b": 1, "a": 2, "a": 3}'::jsonb;
-- 結果: {"a": 3, "b": 1} ← 鍵排序、移除重複(保留最後一個)
-- 效能差異驗證
EXPLAIN (ANALYZE)
SELECT data->>'name' FROM json_table; -- 每次解析 JSON 文字
EXPLAIN (ANALYZE)
SELECT data->>'name' FROM jsonb_table; -- 直接從二進位結構提取
結論:除非需要保留原始 JSON 格式(如 API 原始回應日誌),否則一律使用 JSONB。
基本運算子
路徑存取運算子
-- 測試資料
CREATE TABLE products (
id SERIAL PRIMARY KEY,
data JSONB NOT NULL
);
INSERT INTO products (data) VALUES ('{
"name": "MacBook Pro",
"price": 59900,
"specs": {
"cpu": "M3 Pro",
"ram": 18,
"storage": {"type": "SSD", "size": "512GB"}
},
"tags": ["laptop", "apple", "professional"],
"in_stock": true
}');
-- -> 取得 JSON 物件(回傳 jsonb)
SELECT data->'specs' FROM products;
-- 結果: {"cpu": "M3 Pro", "ram": 18, "storage": {"type": "SSD", "size": "512GB"}}
-- ->> 取得文字值(回傳 text)
SELECT data->>'name' FROM products;
-- 結果: MacBook Pro
-- #> 路徑取值(回傳 jsonb)
SELECT data#>'{specs,storage,type}' FROM products;
-- 結果: "SSD"
-- #>> 路徑取值(回傳 text)
SELECT data#>>'{specs,storage,type}' FROM products;
-- 結果: SSD
-- 陣列存取(索引從 0 開始)
SELECT data->'tags'->0 FROM products;
-- 結果: "laptop"
SELECT data->'tags'->>1 FROM products;
-- 結果: apple
包含與存在運算子(僅 JSONB)
-- @> 包含(左邊包含右邊)
SELECT data FROM products
WHERE data @> '{"in_stock": true}';
-- <@ 被包含(左邊被右邊包含)
SELECT data FROM products
WHERE '{"name": "MacBook Pro", "price": 59900, "extra": true}' @> data::text::jsonb;
-- ? 鍵存在
SELECT data FROM products
WHERE data ? 'specs';
-- ?| 任一鍵存在
SELECT data FROM products
WHERE data ?| array['warranty', 'specs'];
-- ?& 所有鍵存在
SELECT data FROM products
WHERE data ?& array['name', 'price', 'specs'];
GIN 索引策略
jsonb_ops vs jsonb_path_ops
GIN 索引是加速 JSONB 查詢的關鍵,PostgreSQL 提供兩種運算子類別:
jsonb_ops(預設):
為 JSONB 中的每個鍵、值、元素建立索引條目
索引結構: key1 → [行IDs], key2 → [行IDs], val1 → [行IDs], ...
支援運算子: @>, ?, ?|, ?&, @?, @@
jsonb_path_ops:
為每個「根→鍵→值」的完整路徑建立雜湊索引條目
索引結構: hash(root,key1,val1) → [行IDs], hash(root,key2,val2) → [行IDs], ...
僅支援運算子: @>, @?, @@
| 特性 | jsonb_ops | jsonb_path_ops |
|---|---|---|
| 索引大小 | 較大(索引所有鍵值) | 較小(僅路徑雜湊) |
支援 ? 鍵存在 | 支援 | 不支援 |
@> 包含查詢 | 支援 | 支援(通常更快) |
| 適用場景 | 需要鍵存在查詢 | 僅需包含查詢、追求小索引 |
-- 建立 jsonb_ops 索引(預設)
CREATE INDEX idx_products_data ON products USING GIN(data);
-- 建立 jsonb_path_ops 索引(更小、@> 更快)
CREATE INDEX idx_products_data_path ON products USING GIN(data jsonb_path_ops);
-- 針對特定路徑建立表達式索引(最精準)
CREATE INDEX idx_products_name ON products USING GIN((data->'tags'));
-- B-tree 索引適合特定欄位的排序與範圍查詢
CREATE INDEX idx_products_price ON products ((data->>'price'));
-- 驗證索引使用
EXPLAIN (ANALYZE)
SELECT * FROM products WHERE data @> '{"in_stock": true}';
-- 應顯示 Bitmap Index Scan on idx_products_data
索引選擇決策
需要 JSONB 索引?
├── 需要查詢「鍵是否存在」(?、?|、?&)
│ └── 只能用 jsonb_ops
├── 只需要包含查詢(@>)
│ └── 用 jsonb_path_ops(更小、更快)
├── 需要特定欄位的等值/範圍查詢
│ └── 用 B-tree 表達式索引:((data->>'field'))
└── 需要特定路徑下的陣列元素搜尋
└── 用 GIN 表達式索引:((data->'array_field'))
JSONPath 查詢(PG12+)
JSONPath 是 SQL/JSON 標準的路徑語言,比傳統運算子更強大且符合標準:
-- jsonb_path_query:回傳所有匹配的值
SELECT jsonb_path_query(data, '$.specs.cpu') FROM products;
-- 結果: "M3 Pro"
-- jsonb_path_query_array:回傳匹配結果陣列
SELECT jsonb_path_query_array(data, '$.tags[*]') FROM products;
-- 結果: ["laptop", "apple", "professional"]
-- jsonb_path_exists:檢查路徑是否存在
SELECT jsonb_path_exists(data, '$.specs.gpu') FROM products;
-- 結果: false
-- 條件過濾(filter expression)
SELECT jsonb_path_query(
'{"items": [
{"name": "A", "price": 100},
{"name": "B", "price": 200},
{"name": "C", "price": 50}
]}'::jsonb,
'$.items[*] ? (@.price > 80)'
);
-- 結果: {"name": "A", "price": 100}
-- {"name": "B", "price": 200}
-- 算術運算
SELECT jsonb_path_query(
'{"width": 10, "height": 20}'::jsonb,
'$.width * $.height'
);
-- 結果: 200
-- 帶變數的 JSONPath
SELECT jsonb_path_exists(
data,
'$.price ? (@ > $min_price)',
'{"min_price": 50000}' -- 變數傳遞
) FROM products;
-- 結果: true
@? 與 @@ 運算子
-- @? 路徑存在檢查(可走 GIN 索引)
SELECT * FROM products
WHERE data @? '$.tags[*] ? (@ == "laptop")';
-- @@ JSONPath 布林檢查(可走 GIN 索引)
SELECT * FROM products
WHERE data @@ '$.price > 50000';
-- 使用 GIN 索引加速 JSONPath 查詢
CREATE INDEX idx_products_data ON products USING GIN(data);
-- 以下查詢會使用 GIN 索引
EXPLAIN (ANALYZE)
SELECT * FROM products WHERE data @? '$.specs.cpu ? (@ == "M3 Pro")';
JSONB 修改操作
JSONB 支援原地修改,無需讀取→反序列化→修改→序列化→寫回整個值:
-- || 合併(新增或覆蓋鍵值)
UPDATE products
SET data = data || '{"warranty": "2 years"}'
WHERE id = 1;
-- 巢狀合併
UPDATE products
SET data = jsonb_set(data, '{specs,ram}', '36')
WHERE id = 1;
-- jsonb_set:設定特定路徑的值
-- jsonb_set(target, path, new_value [, create_if_missing])
UPDATE products
SET data = jsonb_set(data, '{specs,gpu}', '"M3 Pro GPU"', true)
WHERE id = 1;
-- create_if_missing = true:路徑不存在時建立
-- - 刪除鍵
UPDATE products
SET data = data - 'warranty'
WHERE id = 1;
-- #- 刪除巢狀路徑
UPDATE products
SET data = data #- '{specs,gpu}'
WHERE id = 1;
-- - 刪除陣列元素(依索引)
UPDATE products
SET data = jsonb_set(
data,
'{tags}',
(data->'tags') - 2 -- 移除索引 2 的元素
)
WHERE id = 1;
-- jsonb_insert:在陣列指定位置插入
UPDATE products
SET data = jsonb_insert(
data,
'{tags,0}', -- 在索引 0 前插入
'"featured"',
false -- false=前插入, true=後插入
)
WHERE id = 1;
JSON 聚合函式
將關聯式查詢結果轉換為 JSON 結構:
-- json_agg / jsonb_agg:將多行聚合為 JSON 陣列
SELECT jsonb_agg(name) FROM departments;
-- 結果: ["Engineering", "Marketing", "Sales"]
-- json_object_agg / jsonb_object_agg:聚合為 JSON 物件
SELECT jsonb_object_agg(id, name) FROM departments;
-- 結果: {"1": "Engineering", "2": "Marketing", "3": "Sales"}
-- 組合使用:建構巢狀 JSON 回應
SELECT jsonb_build_object(
'department', d.name,
'employees', (
SELECT jsonb_agg(jsonb_build_object(
'name', e.name,
'title', e.title
) ORDER BY e.name)
FROM employees e
WHERE e.dept_id = d.id
)
)
FROM departments d;
-- row_to_json:將整行轉為 JSON
SELECT row_to_json(t)
FROM (SELECT id, name, email FROM users LIMIT 3) t;
-- to_jsonb:將任意值轉為 JSONB
SELECT to_jsonb(ROW(1, 'Alice', true));
-- 結果: {"f1": 1, "f2": "Alice", "f3": true}
-- jsonb_build_array + jsonb_build_object:手動建構
SELECT jsonb_build_object(
'total', count(*),
'items', jsonb_agg(jsonb_build_object(
'id', id,
'name', data->>'name',
'price', (data->>'price')::numeric
))
)
FROM products;
JSONB 實務設計模式
EAV 替代模式
傳統的 Entity-Attribute-Value(EAV) 模式在關聯式資料庫中效能極差,JSONB 是更好的替代方案:
-- 傳統 EAV(反模式)
CREATE TABLE product_attributes (
product_id INT,
attr_name TEXT, -- 'color', 'size', 'weight'...
attr_value TEXT
);
-- 問題:查詢需要大量 JOIN、無法有效使用索引
-- JSONB 替代方案
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category TEXT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'
);
INSERT INTO products (name, category, attributes) VALUES
('T-Shirt', 'clothing', '{"color": "red", "size": "L", "material": "cotton"}'),
('Laptop', 'electronics', '{"cpu": "M3", "ram": 16, "screen": "14inch"}'),
('Book', 'media', '{"author": "Alice", "pages": 320, "isbn": "978-xxx"}');
-- 每個 category 有不同的 attributes schema,JSONB 完美處理
CREATE INDEX idx_products_attrs ON products USING GIN(attributes);
-- 查詢特定屬性
SELECT name FROM products
WHERE attributes @> '{"color": "red"}';
-- 跨屬性查詢
SELECT name, attributes->>'color' AS color
FROM products
WHERE category = 'clothing'
AND attributes @> '{"size": "L"}';
稽核日誌模式
-- 使用 JSONB 儲存變更記錄
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id INT NOT NULL,
action TEXT NOT NULL, -- INSERT, UPDATE, DELETE
old_data JSONB,
new_data JSONB,
changed_by TEXT NOT NULL DEFAULT current_user,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 通用稽核 Trigger
CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO audit_log (table_name, record_id, action, old_data, new_data)
VALUES (
TG_TABLE_NAME,
COALESCE(NEW.id, OLD.id),
TG_OP,
CASE WHEN TG_OP IN ('UPDATE','DELETE') THEN to_jsonb(OLD) END,
CASE WHEN TG_OP IN ('INSERT','UPDATE') THEN to_jsonb(NEW) END
);
RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;
-- 綁定到目標表
CREATE TRIGGER trg_orders_audit
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_trigger();
-- 查詢特定欄位的變更歷史
SELECT
changed_at,
old_data->>'status' AS old_status,
new_data->>'status' AS new_status,
changed_by
FROM audit_log
WHERE table_name = 'orders'
AND record_id = 42
AND old_data->>'status' IS DISTINCT FROM new_data->>'status'
ORDER BY changed_at DESC;
JSON_TABLE(PG17+)
JSON_TABLE 是 SQL/JSON 標準的關鍵功能,將 JSON 資料轉換為關聯式表格:
-- JSON_TABLE 基本語法
SELECT jt.*
FROM products p,
JSON_TABLE(
p.data,
'$' COLUMNS (
product_name TEXT PATH '$.name',
price NUMERIC PATH '$.price',
cpu TEXT PATH '$.specs.cpu',
first_tag TEXT PATH '$.tags[0]'
)
) AS jt;
-- 巢狀路徑展開(NESTED PATH)
SELECT jt.*
FROM products p,
JSON_TABLE(
p.data,
'$' COLUMNS (
product_name TEXT PATH '$.name',
NESTED PATH '$.tags[*]' COLUMNS (
tag TEXT PATH '$'
)
)
) AS jt;
-- 每個 tag 展開為一行
-- 在 PG17 之前的替代方案
SELECT
data->>'name' AS product_name,
tag
FROM products,
jsonb_array_elements_text(data->'tags') AS tag;
JSONB 展開函式
-- jsonb_each / jsonb_each_text:展開物件為鍵值對
SELECT key, value
FROM jsonb_each('{"a": 1, "b": "hello", "c": true}'::jsonb);
-- key | value
-- a | 1
-- b | "hello"
-- c | true
-- jsonb_array_elements:展開陣列為多行
SELECT value
FROM jsonb_array_elements('[1, 2, 3, "four"]'::jsonb);
-- jsonb_array_elements_text:展開陣列為文字
SELECT value
FROM jsonb_array_elements_text('["apple", "banana", "cherry"]'::jsonb);
-- jsonb_to_record / jsonb_to_recordset:轉為具名欄位
SELECT *
FROM jsonb_to_record('{"name": "Alice", "age": 30}'::jsonb)
AS t(name TEXT, age INT);
-- jsonb_to_recordset:JSON 陣列轉為多行記錄集
SELECT *
FROM jsonb_to_recordset('[
{"name": "Alice", "age": 30},
{"name": "Bob", "age": 25}
]'::jsonb) AS t(name TEXT, age INT);
-- jsonb_typeof:取得 JSON 值的型別
SELECT jsonb_typeof('123'::jsonb); -- number
SELECT jsonb_typeof('"hello"'::jsonb); -- string
SELECT jsonb_typeof('true'::jsonb); -- boolean
SELECT jsonb_typeof('null'::jsonb); -- null
SELECT jsonb_typeof('[1,2]'::jsonb); -- array
SELECT jsonb_typeof('{}'::jsonb); -- object
-- jsonb_strip_nulls:移除所有 null 值的鍵
SELECT jsonb_strip_nulls('{"a": 1, "b": null, "c": {"d": null, "e": 2}}'::jsonb);
-- 結果: {"a": 1, "c": {"e": 2}}
-- jsonb_pretty:格式化輸出
SELECT jsonb_pretty('{"name":"Alice","scores":[90,85,92]}'::jsonb);
Schema 驗證
JSONB 的彈性是優勢也是風險。可透過 CHECK Constraint 進行基本 Schema 驗證:
-- 使用 CHECK Constraint 驗證 JSONB 結構
CREATE TABLE events (
id SERIAL PRIMARY KEY,
data JSONB NOT NULL,
CONSTRAINT valid_event CHECK (
data ? 'type'
AND data ? 'timestamp'
AND jsonb_typeof(data->'type') = 'string'
AND jsonb_typeof(data->'timestamp') = 'string'
)
);
-- 合法資料
INSERT INTO events (data) VALUES
('{"type": "click", "timestamp": "2026-01-01T00:00:00Z", "page": "/home"}');
-- 非法資料(缺少 type)
-- INSERT INTO events (data) VALUES ('{"timestamp": "2026-01-01"}');
-- ERROR: new row violates check constraint "valid_event"
-- PG12+ 使用 JSONPath 做更複雜的驗證
CREATE TABLE orders_v2 (
id SERIAL PRIMARY KEY,
data JSONB NOT NULL,
CONSTRAINT valid_order CHECK (
jsonb_path_exists(data, '$.customer_id ? (@ > 0)')
AND jsonb_path_exists(data, '$.items[0]')
AND jsonb_path_exists(data, '$.total ? (@ >= 0)')
)
);
版本演進
| 版本 | JSON/JSONB 主要改進 |
|---|---|
| PG 9.2 | 引入 json 型別 |
| PG 9.4 | 引入 jsonb 型別、GIN 索引支援、包含運算子 @> |
| PG 9.5 | jsonb_set()、jsonb_pretty()、|| 合併運算子 |
| PG 9.6 | jsonb_insert()、jsonb_path_exists() |
| PG 10 | 全文搜尋對 JSONB 的改進 |
| PG 11 | jsonb_to_tsvector() 直接轉全文搜尋向量 |
| PG 12 | JSONPath 支援(@?、@@、jsonb_path_query 系列)、Generated Column |
| PG 14 | JSONB subscript 語法:data['key']、to_tsvector 直接接受 jsonb |
| PG 15 | JSON/JSONB 合併改進、json_serialize |
| PG 16 | IS JSON 謂詞、SQL/JSON 建構子 |
| PG 17 | JSON_TABLE、JSON_QUERY、JSON_VALUE、JSON_EXISTS(完整 SQL/JSON 支援) |
-- PG14+ subscript 語法(更簡潔的路徑存取)
SELECT data['specs']['cpu'] FROM products;
-- 等同於 data->'specs'->'cpu'
UPDATE products
SET data['specs']['ram'] = '36'
WHERE id = 1;
-- 等同於 jsonb_set(data, '{specs,ram}', '36')
-- PG16+ IS JSON 謂詞
SELECT '{"a":1}'::text IS JSON; -- true
SELECT '[1,2,3]'::text IS JSON ARRAY; -- true
SELECT '{"a":1}'::text IS JSON OBJECT; -- true
SELECT '123'::text IS JSON SCALAR; -- true
SELECT 'not json'::text IS JSON; -- false
PostgreSQL JSONB vs NoSQL
| 特性 | PostgreSQL JSONB | MongoDB |
|---|---|---|
| Schema | 可選(CHECK Constraint) | 可選(JSON Schema) |
| 索引 | GIN、B-tree 表達式索引 | B-tree、多鍵索引、文字索引 |
| 交易 | 完整 ACID | 多文件 ACID(4.0+) |
| JOIN | 原生 SQL JOIN | $lookup(效能較差) |
| 聚合 | SQL GROUP BY + JSON 聚合 | Aggregation Pipeline |
| 全文搜尋 | 內建 FTS | Atlas Search |
| 水平擴展 | 需 Citus / 分區 | 內建 Sharding |
| 適用場景 | 關聯式 + 半結構化混合 | 純文件導向、大規模寫入 |
決策指南:
你的資料需求?
├── 大部分是結構化 + 少量彈性欄位
│ └── PostgreSQL JSONB(結構化部分用一般欄位,彈性部分用 JSONB)
├── 純文件導向、Schema 經常變動
│ └── 評估 MongoDB(但 PostgreSQL JSONB 也能處理)
├── 需要跨文件 JOIN 與 ACID 交易
│ └── PostgreSQL JSONB
└── 需要大規模水平擴展(數十 TB+)
└── 評估 MongoDB 或 PostgreSQL + Citus
常見陷阱
過度使用 JSONB
-- 反模式:把所有東西都塞進 JSONB
CREATE TABLE users (
id SERIAL PRIMARY KEY,
data JSONB -- name, email, phone, address... 全部放 JSONB
);
-- 問題:失去 NOT NULL、UNIQUE、FK 等約束保護
-- 正確:結構化欄位用一般型別,彈性部分用 JSONB
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
phone TEXT,
metadata JSONB NOT NULL DEFAULT '{}' -- 只放彈性資料
);
型別轉換陷阱
-- ->> 回傳 text,數值比較需要轉型
SELECT * FROM products
WHERE data->>'price' > '9000';
-- 字串比較!'9000' > '59900' 是 true(字母序)
-- 正確:轉為數值
SELECT * FROM products
WHERE (data->>'price')::numeric > 9000;
-- 或使用 JSONPath(自動處理型別)
SELECT * FROM products
WHERE data @@ '$.price > 9000';
大型 JSONB 的更新成本
-- JSONB 的每次更新都是「整個值的替換」
-- 即使只改一個欄位,PostgreSQL 仍需:
-- 1. 讀取整個 JSONB
-- 2. 修改目標路徑
-- 3. 寫入全新的 JSONB Tuple
-- 若 JSONB 值很大(數 KB 以上),考慮拆分
-- 反模式:
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
data JSONB -- 包含 items(可能很大)和 status(經常更新)
);
-- 正確:將頻繁更新的欄位與大型靜態資料分離
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending', -- 頻繁更新
items JSONB NOT NULL -- 很少變動
);
總結
JSON 與 JSONB 讓 PostgreSQL 兼具關聯式資料庫的嚴謹與文件資料庫的彈性:
- JSONB 是絕大多數場景的首選,支援索引與豐富的運算子
- GIN 索引是加速 JSONB 查詢的關鍵,
jsonb_ops適合通用場景,jsonb_path_ops適合純包含查詢 - JSONPath(PG12+)提供標準化的路徑查詢語法,比傳統運算子更強大
- JSON_TABLE(PG17)完成了 SQL/JSON 標準的最後一塊拼圖
- 設計原則:結構化欄位用一般型別,彈性/可變部分用 JSONB
下一篇,我們將深入探討 全文搜尋——PostgreSQL 內建的文字搜尋引擎如何實現無需外部服務的生產級搜尋功能。