何時避免在 PostgreSQL Schema 中使用 JSONB

JSONB好用,但在PostgreSQL Schema中並非永遠適用。

何時避免在 PostgreSQL Schema 中使用 JSONB
Photo by Cameron Cress / Unsplash

PostgreSQL 9.4 版推出 JSONB 資料型別時,備受矚目(對關聯式資料庫的新資料型別來說,算是相當高的關注度)。這項功能很棒:它讓您能以現代網路服務常用的 JSON 格式儲存資料,不必每次存取欄位時都重新解析,而且可以針對像是包含其他 JSON 區塊等複雜條件建立索引。

它大幅強化 PostgreSQL 的功能,讓它成為許多文件儲存工作流程的理想選擇。這也符合新創公司的工程思維:只要在資料表新增一個 properties 欄位,用來儲存未來可能需要的額外屬性,您的 Schema 就正式「面向未來 TM」了。

Heap 大量使用 JSONB,因為我們的 API 允許客戶將任意屬性附加到我們收集的事件中,這是很自然的選擇。最近,我收到一些關於使用 JSONB 儲存整個資料表的優缺點的疑問:為什麼不只用 iddata 欄位呢?

許多人喜歡不必明確管理 Schema 的想法,所以 JSONB 會被這樣使用並不令人意外。但這樣做會付出相當大的效能成本,其中有些並不明顯。雖然有很多資料可以幫助您決定 JSON、JSONB 或 hstore 哪個最適合您的專案,但正確答案通常是「以上皆非」。*以下列出幾個原因。

隱藏成本一:缺乏統計資訊導致查詢緩慢

對於傳統資料型別,PostgreSQL 會儲存每個資料表每個欄位的值分布統計資訊,例如:

  • 相異值的數量
  • 最常見的值
  • NULL 值的比例
  • 對於有序型別,欄位值分布的直方圖

對於指定的查詢,查詢規劃器會使用這些統計資訊來估算哪個執行計畫最快。我們建立一個包含一百萬筆「測量值」的資料表,每筆包含三個值,每個值都從 {0, 1} 中隨機均勻選取。每次測量都由一萬名科學家其中一位執行,每位科學家都來自三個實驗室的其中一個:

CREATE TABLE measurements (
  tick BIGSERIAL PRIMARY KEY,
  value_1 INTEGER,
  value_2 INTEGER,
  value_3 INTEGER,
  scientist_id BIGINT
);
INSERT INTO measurements (value_1, value_2, value_3, scientist_id)
  SELECT
    trunc(2 * random()),
    trunc(2 * random()),
    trunc(2 * random()),
    trunc(10000 * random() + 1)
  FROM generate_series(0, 999999);

CREATE TABLE scientist_labs (scientist_id BIGSERIAL PRIMARY KEY, lab_name TEXT);
INSERT INTO scientist_labs (lab_name)
  SELECT (
    '{"California Scientific", "Industrial Labs", "Western Science People"}'::TEXT[]
  )[i % 3 + 1]
  FROM generate_series(1, 10000) i;

ANALYZE;

假設我們要找出三個值都是 0 的 tick 值(約佔 1/8),並查看這些測量中每個實驗室出現的次數。我們的查詢如下:

SELECT lab_name, COUNT(*)
FROM (
  SELECT scientist_id
  FROM measurements
  WHERE
    value_1 = 0 AND
    value_2 = 0 AND
    value_3 = 0
) m
JOIN scientist_labs AS s
  ON (m.scientist_id = s.scientist_id)
GROUP BY lab_name

我們的查詢計畫如下:https://explain.depesz.com/s/H4oY

這正是我們期望的:查詢規劃器從資料表統計資訊得知,約 1/8 的 measurements 資料列中,value_1value_2value_3 等於 0,所以約 125,000 筆資料需要與科學家的實驗室資訊連接,資料庫透過雜湊連接完成。也就是說,將 scientist_labs 的內容載入到以 scientist_id 為鍵的雜湊表中,掃描 measurements 中符合條件的資料列,並透過 scientist_id 值在雜湊表中查找。在我的電腦上執行速度很快,約 300 毫秒。

如果我們改用 JSONB 儲存測量值,如下:

CREATE TABLE measurements (tick BIGSERIAL PRIMARY KEY, record JSONB);
INSERT INTO measurements (record)
  SELECT (
    '{ "value_1":' || trunc(2 * random()) ||
    ', "value_2":' || trunc(2 * random()) ||
    ', "value_3":' || trunc(2 * random()) ||
    ', "scientist_id":' || trunc(10000 * random() + 1) || ' }')::JSONB
  FROM generate_series(0, 999999) i

對應的讀取查詢如下:

SELECT lab_name, COUNT(*)
FROM (
  SELECT (record ->> 'scientist_id')::BIGINT AS scientist_id
  FROM measurements
  WHERE
    (record ->> 'value_1')::INTEGER = 0 AND
    (record ->> 'value_2')::INTEGER = 0 AND
    (record ->> 'value_3')::INTEGER = 0
) m
JOIN scientist_labs AS s
  ON (m.scientist_id = s.scientist_id)
GROUP BY lab_name

效能卻大幅下降 — 在我的筆電上花了 584 秒,慢了約 2000 倍: https://explain.depesz.com/s/zJiT

根本原因是 PostgreSQL 不知道如何保存 JSONB 欄位值的統計資訊。它無法得知 record ->> 'value_2' = 0 成立的機率約為 50%,所以它使用預設的 0.1% 估計值。因此,它估計 measurements 資料表中只有 0.1% 的 0.1% 的 0.1% (約 1 列)符合條件。結果,它選擇巢狀迴圈連接:對於每個符合篩選條件的 measurements 資料列,透過主鍵在 scientist_labs 中查找對應的 lab_name。但實際上有大約 125,000 筆這樣的測量值,而不是 1 筆,導致查詢時間冗長。**

準確的統計資訊一直是資料庫效能的關鍵。缺乏這些資訊,查詢規劃器就像瞎子摸象。這是 JSONB 的隱藏成本之一:您的資料沒有統計資訊,查詢規劃器無法有效運作。

這不是紙上談兵。我們在實際產品中就遇到這個問題,唯一的解決方法是將 enable_nestloop 設為 off,完全停用巢狀迴圈連接。一般來說,您不應該這樣做。

在鍵值或文件儲存的工作負載中,您可能不會遇到這個問題,但如果您使用 JSONB 進行分析查詢,就很容易遇到。

隱藏成本二:更大的資料表佔用空間

PostgreSQL 的 JSON 資料型別將資料以字串形式儲存,只是它知道這些字串是有效的 JSON。JSONB 編碼的儲存空間開銷更大,好處是不需要解析 JSON 即可擷取特定欄位。無論如何,資料庫至少會在每一列儲存每個鍵和值。PostgreSQL 沒有任何機制可以刪除重複出現的鍵。

以上述的 measurements 資料表為例,非 JSONB 版本的資料表佔用 79MB 的磁碟空間,而 JSONB 版本佔用 164MB,超過兩倍。這是因為資料表內容大部分是重複出現的字串 value_1value_2value_3scientist_id。在這種情況下,您需要付出兩倍的磁碟空間成本,更別提後續效應會讓各種操作變慢或成本更高。原始 schema 的快取效果更好,或者可能完全放在記憶體中。檔案較小也代表大型讀取或維護操作只需要一半的 I/O。

另一個實際案例是,我們將 45 個常用欄位從 JSONB 移到一般欄位,節省了約 30% 的磁碟空間。對於 PB 級的資料集來說,效益相當可觀。

經驗法則:每個欄位在資料表的每一列約有 1 bit 的開銷,無論欄位值是否為 NULL。*** 因此,如果一個非必要欄位在 JSONB 中有個十個字元的鍵,每列至少需要 80 bit 來儲存這個鍵,那麼如果這個欄位出現在至少 1/80 的列中,將它獨立出來會比較省空間。

對於有很多非必要值的資料集,將每個值都設為資料表欄位可能不切實際或不可能。在這種情況下,JSONB 不論在簡潔性或效能上都是不錯的選擇。但對於大多數列都出現的值,最好還是將它們獨立出來。實際上,還有其他因素會影響您組織資料的方式,例如管理明確 schema 所需的工程成本,或類型安全和 SQL 可讀性的優點。但如果不必使用 JSONB,卻使用了,通常會付出相當大的效能代價。

知道其他看似無害卻對效能影響重大的變更嗎?歡迎在 @danlovesproofs 與我聯繫。我們持續評估各種 schema 和索引策略,以處理數千億事件的臨時查詢。如果您對這類工作感興趣,我們正在招募人才!歡迎參考我們的工程團隊職缺資訊

*建議您從這篇文章開始閱讀:https://www.citusdata.com/blog/2016/07/14/choosing-nosql-hstore-json-jsonb/

**順帶一提,explain.depesz 是個很棒的工具,可以找出查詢中的這類問題。在這個例子中,您可以看到查詢規劃器低估了這個子查詢返回的列數,誤差高達 124,616 倍。

***這並不完全正確。PostgreSQL 為前 8 個欄位配置每列一個位元組,之後每 64 個欄位配置 8 個位元組。例如,前 8 個欄位是免費的,第 9 個欄位在資料表的每一列佔用 8 個位元組,然後第 10 到 72 個是免費的,依此類推。(感謝 Michael Malis 的研究。)