🎯 什麼情境該想到我
當你「有一組物件想整包序列化存進單一欄位,且不需要用 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 裡沒有參照完整性,不能有外鍵。
🔗 相關工具
- 內嵌值 Embedded Value(把少數欄位攤平、仍可查詢的對照)
- 資料對應器 Data Mapper(決定何處使用此策略)
- 企業應用架構模式
- 內嵌值 —— 攤平成真欄位
- 從屬對應 —— 子物件在另一張表
- 寄物單 —— 大 payload 放物件儲存
- 格式指示器 —— schema_version
- 樂觀離線鎖 —— 防 JSONB 的 lost update
- 工具-資料編碼與演進 —— 沒有 schema 就沒有契約