ข้ามไปยังเนื้อหา

เก็บ JSON ด้วย JSONB

บางครั้ง row หนึ่งต้องเก็บข้อมูลที่รูปร่างไม่ได้ถูกกำหนดไว้ล่วงหน้า เช่น สินค้าที่มีคุณสมบัติต่างกันไปตามหมวดหมู่ payload ของ event จากบุคคลที่สาม หรือการตั้งค่าของผู้ใช้ที่งอกเพิ่มขึ้นตามเวลา PostgreSQL ให้ column เดียวเก็บเอกสาร JSON ทั้งก้อนด้วยชนิดข้อมูล jsonb แล้ว query เข้าไปข้างในได้ราวกับว่า key แต่ละตัวคือ column

ในบทนี้เราใช้ table ของ products ที่แต่ละ row พกเอกสาร attributes ที่ยืดหยุ่นไว้

ชนิดข้อมูล jsonb เก็บ JSON ในรูปแบบไบนารีที่ถูกแยกย่อยแล้ว (decomposed binary form) เมื่อเทียบกับชนิด json ธรรมดา jsonb จะตัด whitespace ที่ไม่สำคัญทิ้ง ลบ key ซ้ำ ไม่รักษาลำดับของ key และ — ที่สำคัญที่สุด — สามารถทำ index ได้

CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'
);

การ insert row ก็เป็น INSERT ธรรมดา เอกสารเป็นเพียง JSON string ที่ PostgreSQL parse ให้เป็น jsonb จากฝั่ง driver คุณส่ง JSON เป็น parameter แทนการต่อ string ด้วยมือ

INSERT INTO products (name, attributes) VALUES
('Trail Runner', '{"color": "teal", "sizes": [8, 9, 10], "waterproof": true}'),
('City Loafer', '{"color": "brown", "sizes": [9, 10, 11], "leather": "suede"}');

ใน pgAdmin: เซลล์ attributes จะแสดงเอกสาร JSON ดับเบิลคลิกที่เซลล์เพื่อเปิดมุมมอง editor ที่ใหญ่ขึ้นสำหรับดูค่า

operator ไม่กี่ตัวช่วยดึงค่าออกมาจากค่า jsonb สองตัวที่คุณจะหยิบใช้บ่อยที่สุดคือ -> และ ->>

Operatorคืนค่าตัวอย่าง
->ค่า jsonb (object หรือ array)attributes -> 'sizes'
->>ค่าในรูปแบบ textattributes ->> 'color'
#>ค่า jsonb ที่ path หนึ่งattributes #> '{sizes,0}'
#>>text ที่ path หนึ่งattributes #>> '{sizes,0}'

ความต่างระหว่าง -> กับ ->> นั้นสำคัญ คือ -> คงผลลัพธ์ไว้เป็น jsonb (ดีสำหรับการต่อลงไปในข้อมูลที่ซ้อนลึกขึ้น) ส่วน ->> ให้ text ธรรมดาที่คุณนำไปเทียบกับ string หรือ cast เป็นตัวเลขได้

SELECT name,
attributes ->> 'color' AS color,
attributes -> 'sizes' AS sizes,
attributes #>> '{sizes, 0}' AS smallest_size
FROM products;
name | color | sizes | smallest_size
--------------+-------+------------+---------------
Trail Runner | teal | [8, 9, 10] | 8
City Loafer | brown | [9, 10, 11]| 9
(2 rows)

operator สองตัวถูกสร้างมาเพื่อการค้นหา containment operator @> ถามว่าเอกสารฝั่งซ้ายมีเอกสารฝั่งขวาอยู่หรือไม่ ส่วน existence operator ? ถามว่า key ระดับบนสุดมีอยู่หรือไม่

-- products whose document contains color = teal
SELECT name FROM products
WHERE attributes @> '{"color": "teal"}';
-- products that have a top-level "leather" key
SELECT name FROM products
WHERE attributes ? 'leather';
name
--------------
Trail Runner
(1 row)

หากต้องการเปลี่ยนฟิลด์เดียวโดยไม่ต้องเขียนเอกสารทั้งก้อนใหม่ ให้ใช้ jsonb_set ที่รับ column, path ในรูปแบบ text array และค่าใหม่ในรูปแบบ jsonb

UPDATE products
SET attributes = jsonb_set(attributes, '{color}', '"midnight"')
WHERE name = 'Trail Runner'
RETURNING name, attributes ->> 'color' AS color;
name | color
--------------+----------
Trail Runner | midnight
(1 row)

หากต้องการเพิ่มฟิลด์ใหม่เอี่ยม คุณยังใช้ concatenation operator ได้ด้วย: attributes || '{"on_sale": true}' จะรวมเอกสารฝั่งขวาเข้าไปในฝั่งซ้าย

หากไม่มี index คำสั่ง @> จะ scan ทุก row และทดสอบแต่ละเอกสาร GIN index เปลี่ยนเรื่องนั้น เพราะทำ index ให้ทั้ง key และ value ภายใน jsonb ดังนั้นการค้นหาแบบ containment และ key-existence จึงเร็วขึ้น

CREATE INDEX idx_products_attributes ON products USING gin (attributes);
CREATE INDEX

หลังจากนี้ query ที่ใช้ @>, ?, ?| และ ?& จะใช้ index ได้ ถ้าคุณ query เฉพาะ path ใดเพียง path เดียวเสมอ expression index บน path นั้นจะเล็กกว่าและเร็วกว่าการทำ index ให้ทั้งเอกสาร

  • เลือก jsonb แทน json สำหรับเกือบทุกกรณี ชนิด json เก็บข้อความเป๊ะ ๆ ตามที่คุณส่งมา (รักษา whitespace และลำดับ) แต่ทำ index ไม่ได้ และ parse ใหม่ทุกครั้งที่เข้าถึง
  • ใช้ jsonb กับข้อมูลที่แปรผันจริง ๆ ไม่ใช่เป็นทางเลี่ยงการออกแบบ schema ถ้าฟิลด์หนึ่งมีอยู่เสมอและเป็นชนิดเดิมเสมอ column จริงจะให้คุณได้ทั้งการตรวจสอบชนิด ค่า default และ constraint
  • เพิ่ม GIN index เฉพาะเมื่อคุณ query เข้าไปข้างในเอกสารจริง ๆ การทำ index ให้ข้อมูลที่คุณอ่านกลับมาทั้งก้อนเท่านั้นเป็นการเปลืองพื้นที่และเพิ่มภาระตอนเขียน
  • จำไว้ว่า -> คืน jsonb และ ->> คืน text การเทียบ attributes -> 'color' = 'teal' จะล้มเหลวเพราะฝั่งซ้ายเป็น jsonb จงใช้ ->> สำหรับการเทียบ text
ตัวเลือกBenefitCost
jsonb columnschema ยืดหยุ่น เพิ่มหรือลบฟิลด์ได้โดยไม่ต้อง migrationPostgres บังคับ type หรือ foreign key ข้างในเอกสารให้ไม่ได้ ความถูกต้องต้องมาจากฝั่งแอปพลิเคชันเอง
column เชิงสัมพันธ์ปกติมี type checking, constraint และ foreign key ที่ Postgres บังคับใช้ให้จริงทุกฟิลด์ต้องรู้ล่วงหน้าและทุกการเปลี่ยน schema ต้องผ่าน migration
  • ยัดทุกอย่างลงใน JSONB blob ก้อนเดียว — ถ้าข้อมูลมีโครงสร้างจริง ๆ และต้องอ้างอิงไปยัง table อื่น ให้ใช้ column เชิงสัมพันธ์กับ foreign key แทน เพราะถ้าความสัมพันธ์ซ่อนอยู่ใน JSONB แล้ว Postgres จะ validate ให้ไม่ได้
  • ลืมทำ GIN index ให้ column ที่ query บ่อย — ไม่มี index แปลว่าทุก containment query ต้อง scan ทั้ง table เพิ่ม USING gin (column) ทันทีที่คุณเริ่ม query เข้าไปข้างในเอกสารเป็นประจำ
  • ใช้ JSONB แค่เพื่อเลี่ยงการเขียน migration — ถ้าฟิลด์นั้นมีอยู่เสมอและเป็น type เดิมเสมอ column จริงนั้นปลอดภัยกว่าและอ่านง่ายกว่า JSONB ควรสงวนไว้สำหรับข้อมูลที่แปรผันจริง ๆ

💡 ตัวอย่างจากของจริง

Notion — เก็บเนื้อหา block และ page ที่มีโครงสร้างยืดหยุ่นสูงไว้ใน jsonb เพราะรูปแบบ block เปลี่ยนบ่อยและหลากหลายเกินกว่าจะออกแบบเป็น column ตายตัว ส่วนความเป็นเจ้าของ สิทธิ์การเข้าถึง และโครงสร้าง workspace ยังคงอยู่ใน table เชิงสัมพันธ์ที่เคร่งครัด

Shopify — เก็บ metafield ของสินค้าและ order ที่ผู้ขายแต่ละรายปรับแต่งได้เองไว้ใน jsonb เพื่อรองรับความหลากหลายของร้านค้าโดยไม่ต้องแก้ schema หลักของ platform

ข้อได้เปรียบเชิงปฏิบัติที่สำคัญของ jsonb เหนือชนิด json ธรรมดาคืออะไร?
operator ใดทดสอบว่าเอกสาร jsonb มีคู่ key/value ที่กำหนดอยู่หรือไม่?
index ชนิดใดทำให้ containment query ของ jsonb เร็วขึ้น?