🎯 什麼情境該想到我

當你「有一組物件想整包序列化存進單一欄位,且不需要用 SQL 查詢它的內部」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:把一整組相連的物件序列化成單一大物件欄位(BLOB 二進位或 CLOB 文字,如 XML/JSON)儲存。

做法要點:

  • 整組物件一次序列化寫入單欄,讀取時再一次反序列化還原。
  • 適合「整組讀、整組寫」、內部結構不需要被資料庫查詢的資料。
  • 避免把複雜物件圖攤成大量表格與 join,簡化持久化。
  • 文字型(CLOB)較好除錯與跨系統,二進位(BLOB)較省空間。

★★ 今天的名字:JSONB 欄位(而它比 2002 年強大得多)

-- ★ PoEAA 時代:BLOB / CLOB(不可查詢、不可索引)
-- ★★ 今天:PostgreSQL 的 jsonb
CREATE TABLE orders (
    id       text PRIMARY KEY,
    metadata jsonb NOT NULL DEFAULT '{}'
);
-- ★★ 而 jsonb 打破了「序列化就不可查詢」這個前提:
SELECT * FROM orders WHERE metadata @> '{"channel":"mobile"}';   -- ★ 可查
CREATE INDEX ON orders USING gin (metadata);                      -- ★ 可索引
CREATE INDEX ON orders ((metadata->>'channel'));                  -- ★ 表達式索引
ALTER TABLE orders ADD CONSTRAINT chk CHECK (metadata ? 'version');  -- ★ 可約束

★★ 所以這個模式的判準變了:
2002 年是「要不要放棄查詢能力」;
今天是「要不要放棄 schema 的強制力」。

★★ 什麼時候用 JSONB:四個明確的場景

場景為什麼
① ★★ 形狀不由你決定第三方 webhook 的 payload、外部 API 的回應 —— ★ 欄位隨對方改
② ★ 稀疏/多型的屬性每種商品類型有不同的規格欄位(衣服有尺寸、書有頁數)
③ ★★ 稽核/原始資料保存「當時收到的就是這樣」—— ★ 不該被 schema 規範(見 訊息存放區)
④ ★ 演進快、查詢少的設定使用者偏好、feature flag 的 payload

★★ 反過來,四個不該用的訊號:

① ★★ 你開始用它 JOIN 或當 WHERE 的主要條件
   → ★ 那個欄位該被提升為真正的欄位(★ 可以兩者並存:提升 + 保留原始 jsonb)
② ★★ 你需要外鍵約束或 NOT NULL
   → jsonb 裡的值沒有參照完整性
③ ★★ 多個服務/語言都要讀它
   → ★ 沒有 schema 就沒有契約,每個消費者各自猜(見 [[工具-資料編碼與演進]])
④ ★ 你在裡面放陣列然後需要「更新第三個元素」
   → ★★ jsonb 的部分更新要重寫整個值 ⇒ 大物件時成本很高

★★ Go 的實作:三個關鍵細節

// ① ★★ 用 json.RawMessage 延遲解析,讓「不需要看內容的層」不必認識它
type Order struct {
    ID       string          `db:"id"`
    Metadata json.RawMessage `db:"metadata"`   // ★ 路由、稽核、複製都不必解
}
 
// ② ★★ Scanner/Valuer:jsonb 從 driver 來的是 []byte
func (m *Meta) Scan(src any) error {
    b, ok := src.([]byte)
    if !ok {
        if s, ok := src.(string); ok { b = []byte(s) } else {   // ★ 有些 driver 給 string
            return fmt.Errorf("unsupported type %T", src)
        }
    }
    return json.Unmarshal(b, m)
}
func (m Meta) Value() (driver.Value, error) { return json.Marshal(m) }
 
// ③ ★★ 部分更新用 jsonb_set,不要「讀出來改再寫回去」
UPDATE orders SET metadata = jsonb_set(metadata, '{channel}', '"web"') WHERE id = $1;
// ★ 讀改寫 = lost update(另一個人改了別的 key 會被覆蓋)

★★ ③ 是 JSONB 最常見的併發 bug:
「讀出整個 metadata → 改一個 key → 寫回」
→ 同時有人改了另一個 key,那個變更被靜默覆蓋。
→ ★ 用 jsonb_set/|| 做原子的部分更新,或加樂觀鎖(見 樂觀離線鎖)。

★★ 演進的實際代價:沒有 migration,但也沒有保護

★ 「不用 migration」聽起來很棒,實際發生的事:
① ★★ 舊資料與新資料的形狀不同,而程式碼要同時處理兩種
   → ★ 於是每次讀取都要 if v, ok := m["newField"]; ok { … } else { 舊邏輯 }
   → ★★ 這些分支永遠不會被刪除,因為沒人知道「舊資料還有多少」
② ★★ 沒有 version 欄位 → 你無法回答「有多少列是舊格式」
   → ✅ ★ 一律在 jsonb 裡放一個 schema_version(見 [[格式指示器]])
③ ★ 打錯 key 不會有任何錯誤
   m["chanel"] → 拿到零值,而不是編譯錯誤
   → ★★ 這是 JSONB 相對真欄位最大的損失

★★ 最重要的一條建議:JSONB 裡一律放 schema_version,並寫一個
「統計各版本列數」的查詢。
沒有它,你永遠無法清掉舊的相容分支。

★ 兄弟辨析

差別
內嵌值★ 攤平成真欄位 ⇒ ★ 可查、可索引、可約束
從屬對應子物件在另一張表
寄物單★ 大 payload 放物件儲存,DB/訊息只存位址
格式指示器★ schema_version 的來源
中繼資料對應元資料驅動的對應

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 無法用 SQL 查詢或索引欄位內部的資料。

  • 序列化格式版本演進會影響既有資料相容性。

  • 多處共享同一份資料、或需局部更新時不適合。

  • ★★ JSONB 打破了「序列化就不可查詢」的前提(GIN 索引、@>、表達式索引),所以判準從「放棄查詢」變成「放棄 schema 強制力」。

  • ★★ 部分更新要用 jsonb_set,不要「讀出來改再寫回去」——那是 lost update,會靜默覆蓋別人改的其他 key。

  • ★★ JSONB 裡一律放 schema_version,並寫一個「統計各版本列數」的查詢。沒有它,相容分支永遠刪不掉。

  • ★★ 打錯 key 不會有任何錯誤,拿到零值。這是相對真欄位最大的損失。

  • ★ 開始用它 JOIN 或當主要 WHERE 條件 → 該提升為真欄位(可以兩者並存)。

  • ★ 多個服務/語言都要讀它 ⇒ 沒有 schema 就沒有契約。

  • ★ jsonb 的部分更新要重寫整個值,大物件時成本很高。

  • Scanner 要同時處理 []byte 與 string(不同 driver 行為不同)。

  • jsonb 裡沒有參照完整性,不能有外鍵。

🔗 相關工具