JSON 與 JSONB 操作:PostgreSQL 的半結構化資料處理 | PostgreSQL

2026/06/26
JSON 與 JSONB 操作:PostgreSQL 的半結構化資料處理 | PostgreSQL

JSONJSONBPostgreSQL 處理半結構化資料的核心型別。JSON 保留原始文字格式,JSONB 則以二進位格式儲存並支援索引,讓關聯式資料庫也能高效處理 NoSQL 風格的彈性結構。本篇深入解析兩者的儲存差異、查詢運算子、GIN 索引策略、JSONPath 語法,以及實務中的設計模式。

JSON vs JSONB:選擇正確的型別

儲存方式差異

JSONJSONB 的核心差異在於儲存與處理方式:

JSON(文字格式):
  寫入: '{"name": "Alice", "age": 30}'
  儲存: 原始 JSON 字串(含空白、鍵順序、重複鍵全部保留)
  讀取: 每次存取都重新解析 JSON 文字 → 慢

JSONB(二進位格式):
  寫入: '{"name": "Alice", "age": 30}'
  儲存: 解析後的二進位樹結構(去除空白、排序鍵、移除重複鍵)
  讀取: 直接存取二進位結構,無需重新解析 → 快
特性JSONJSONB
儲存格式原始文字二進位
寫入速度快(不需解析)稍慢(需解析為二進位)
讀取速度慢(每次重新解析)快(直接存取)
保留格式保留空白、鍵順序、重複鍵不保留
索引支援不支援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_opsjsonb_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.5jsonb_set()jsonb_pretty()|| 合併運算子
PG 9.6jsonb_insert()jsonb_path_exists()
PG 10全文搜尋對 JSONB 的改進
PG 11jsonb_to_tsvector() 直接轉全文搜尋向量
PG 12JSONPath 支援(@?@@jsonb_path_query 系列)、Generated Column
PG 14JSONB subscript 語法:data['key']to_tsvector 直接接受 jsonb
PG 15JSON/JSONB 合併改進、json_serialize
PG 16IS JSON 謂詞、SQL/JSON 建構子
PG 17JSON_TABLEJSON_QUERYJSON_VALUEJSON_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 JSONBMongoDB
Schema可選(CHECK Constraint)可選(JSON Schema)
索引GIN、B-tree 表達式索引B-tree、多鍵索引、文字索引
交易完整 ACID多文件 ACID(4.0+)
JOIN原生 SQL JOIN$lookup(效能較差)
聚合SQL GROUP BY + JSON 聚合Aggregation Pipeline
全文搜尋內建 FTSAtlas 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 內建的文字搜尋引擎如何實現無需外部服務的生產級搜尋功能。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。