PlusMagi's Blog By Pitt Phunsanit Backend,Database,PostgreSQL ทำความรู้จัก GIN (Generalized Inverted Index) ใน PostgreSQL: ดัชนีความเร็วสูงสำหรับ JSONB, Array และ Full-Text Search

ทำความรู้จัก GIN (Generalized Inverted Index) ใน PostgreSQL: ดัชนีความเร็วสูงสำหรับ JSONB, Array และ Full-Text Search

ในสถาปัตยกรรมฐานข้อมูลเชิงสัมพันธ์แบบดั้งเดิม ดัชนีอย่าง B-Tree ถูกสร้างขึ้นมาบนสมมติฐานว่า: หนึ่งแถวข้อมูล (Row) ในคอลัมน์ จะเก็บค่าสเกลาร์เดี่ยวๆ เพียงหนึ่งค่า (1 Row = 1 Value) เช่น ตัวเลขจำนวนเงิน, สตริงชื่อผู้ใช้ หรือวันที่

แต่ในยุคที่ข้อมูลมีโครงสร้างแบบกึ่งโครงสร้าง (Semi-structured Data) คอลัมน์เดียวอาจบรรจุข้อมูลหลายชิ้นอยู่ข้างใน เช่น

  • อาเรย์ของแท็ก: ['sql', 'postgres', 'performance']
  • เอกสาร JSONB ซับซ้อน: {"role": "admin", "permissions": ["read", "write"]}
  • เอกสารข้อความยาวๆ ที่ต้องทำ Full-Text Search (หลายร้อยคำในหนึ่งช่อง)

หากนำ B-Tree ไปใช้กับการค้นหาว่า “แถวไหนมีแท็ก ‘postgres’ อยู่บ้าง” ระบบจะไม่สามารถเจาะลึกเข้าไปดูภายในโครงสร้างได้ และจบลงด้วยการทำ Full Table Scan เสมอ PostgreSQL จึงมีโซลูชันเฉพาะทางที่เรียกว่า GIN (Generalized Inverted Index)


1. GIN คืออะไร และ Inverted Index ทำงานอย่างไร?

คำว่า Inverted Index (ดัชนีแบบย้อนกลับ) คือโครงสร้างข้อมูลแบบเดียวกับที่ Search Engine อย่าง Elasticsearch หรือหน้า “ดรรชนีคำท้ายเล่ม” ของหนังสือวิชาการใช้งาน

  • ดัชนีทั่วไป (Forward Index / B-Tree): ชี้จาก แถวข้อมูล -> ไปหาค่าที่เก็บ(เช่น Row #101 มีค่าเป็น ['apple', 'banana', 'orange'])
  • ดัชนีแบบย้อนกลับ (Inverted Index / GIN): แตกข้อมูลออกเป็นคีย์ย่อยๆ (Elements/Tokens) แล้วชี้จาก คีย์ย่อย -> กลับไปหารายการแถวข้อมูลทั้งหมดที่มีคีย์นั้น
[ คีย์ย่อย (Key/Token) ]   ------>  [ Posting List (รายการ Row ID ที่พบ) ]
'apple'                     ------>  Row #1, Row #4, Row #101
'banana'                    ------>  Row #2, Row #101
'orange'                    ------>  Row #101, Row #205

เมื่อคุณสั่งค้นหาว่าแถวใดมีทั้ง 'apple' และ 'orange' ตัว GIN Index จะไม่ไปเปิดอ่านตารางทีละแถว แต่จะนำ Posting List ของ 'apple' (1, 4, 101) และ 'orange' (101, 205) มาทำ Intersection (หาจุดตัด) ในระดับหน่วยความจำทันที ผลลัพธ์คือได้คำตอบเป็น Row #101 โดยแทบไม่ต้องแตะดิสก์ของตารางหลักเลย


2. รูปแบบการใช้งานจริงที่โดดเด่น

GIN เป็นดัชนีที่ขาดไม่ได้เมื่อต้องทำงานกับ 3 งานหลักนี้

2.1 คอลัมน์ประเภท JSONB

ในการค้นหาคุณสมบัติที่ซ้อนอยู่ภายใน JSONB ดัชนี GIN ช่วยให้การตรวจสอบ Key หรือ Value ย่อยทำได้ในเสี้ยววินาที

CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    details JSONB
);

-- สร้าง GIN Index บนคอลัมน์ JSONB
CREATE INDEX idx_products_details ON products USING GIN (details);

-- ค้นหาสินค้าที่มี color เป็น 'black' และ size เป็น 'XL'
SELECT * FROM products 
WHERE details @> '{"color": "black", "size": "XL"}';

(ตัวดำเนินการ @> คือ Containment Operator ซึ่ง GIN รองรับโดยสมบูรณ์)


2.2 คอลัมน์ประเภท Array

การสแกนหาค่าสมาชิกในอาร์เรย์เป็นโจทย์ที่ B-Tree ทำไม่ได้

CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT,
    tags TEXT[]
);

CREATE INDEX idx_articles_tags ON articles USING GIN (tags);

-- ค้นหาบทความที่มีแท็ก 'postgresql'
SELECT * FROM articles WHERE tags @> ARRAY['postgresql'];

-- ค้นหาบทความที่มีแท็ก 'database' หรือ 'sql' (มีส่วนซ้อนทับกัน)
SELECT * FROM articles WHERE tags && ARRAY['database', 'sql'];

2.3 Full-Text Search (ค้นหาข้อความเต็มรูปแบบ)

GIN คือหัวใจสำคัญของระบบ Full-Text Search ใน PostgreSQL ร่วมกับ tsvector

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    body TEXT,
    body_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX idx_docs_tsv ON documents USING GIN (body_tsv);

-- ค้นหาเอกสารที่มีคำว่า 'database' และ 'performance'
SELECT * FROM documents 
WHERE body_tsv @@ to_tsquery('english', 'database & performance');

3. จุดแลกเปลี่ยนที่ต้องรู้: Write Overhead และ Fastupdate

เนื่องจาก GIN แตกข้อมูลหนึ่งแถวออกเป็นคีย์ย่อยจำนวนมาก ต้นทุนการอัปเดต Index จึงสูงกว่า B-Tree อย่างชัดเจน หากแถวหนึ่งมี 50 คำ การ INSERT หนึ่งครั้งหมายถึงต้องไปอัปเดต Posting List ถึง 50 จุด

เพื่อแก้ปัญหานี้ PostgreSQL จึงมีฟีเจอร์สำคัญที่ชื่อว่า fastupdate

  • กลไก Pending List
    เมื่อมีการ INSERT หรือ UPDATE ข้อมูลใหม่ ตัว Engine จะยังไม่นำคีย์ไปเรียงลงโครงสร้างหลักของ GIN ทันที แต่จะบันทึกพักไว้ในบัฟเฟอร์ชั่วคราวที่เรียกว่า Pending List
  • Vacuum & Auto-Clean
    เมื่อ Pending List เต็ม (เกินขนาด gin_pending_list_limit ซึ่งปกติคือ 4MB) หรือเมื่อกระบวนการ VACUUM ทำงาน ระบบจะทยอยนำข้อมูลที่พักไว้ไปควบรวม (Flush/Clean) เข้าสู่โครงสร้างหลักของ GIN ในเบื้องหลัง
-- สามารถสั่ง Flush Pending List ได้โดยตรงผ่านคำสั่ง:
SELECT gin_clean_pending_list('idx_products_details');

4. jsonb_ops vs jsonb_path_ops

เมื่อสร้าง GIN Index บน JSONB PostgreSQL มี Operator Class ให้เลือก 2 ตัวหลัก ซึ่งมี Trade-off ต่างกันชัดเจน

คุณสมบัติjsonb_ops (Default)jsonb_path_ops
สิ่งที่นำมา Indexทั้ง Key, Value, และ Path แยกชิ้นกันแฮชรวมเส้นทาง (Path Hash: key + value)
ขนาด Index บนดิสก์ใหญ่กว่าเล็กกว่ามาก (ลดลง 30-50%)
ความเร็วในการค้นหาเร็วเร็วกว่า
ความยืดหยุ่นรองรับการค้นหาเฉพาะ Key เช่น details ? 'wifi'ไม่รองรับ การเช็คแค่ Key ต้องค้นหาทั้ง Path/Value ผ่าน @> เท่านั้น

คำแนะนำการเลือกใช้

  • หากต้องการแค่ค้นหาแบบ Key-Value ตรง ๆ ผ่าน @> เสมอ ให้เลือก jsonb_path_ops เพื่อประหยัดพื้นที่และเพิ่มความเร็ว:SQLCREATE INDEX idx_products_details_path ON products USING GIN (details jsonb_path_ops);
  • หากต้องการเช็คว่ามี Key ชื่อนี้อยู่ใน JSON หรือไม่ (?, ?|, ?&) ให้ใช้ jsonb_ops แบบค่าเริ่มต้น

5. สรุปภาพรวม: เมื่อไหร่ควรใช้ GIN?

  • ใช้ GIN เมื่อ
    • ต้องการค้นหา ชิ้นส่วนภายใน ของ Composite Data เช่น JSONB, Array หรือ Text Corpus
    • ระบบเป็นแบบ Read-Heavy (เน้นการอ่านและค้นหาข้อมูลที่ซับซ้อน)
    • มีการทำ Query แบบค้นหาคำที่ซ้อนทับกันหลายเงื่อนไข (AND/OR Operations ข้าม Elements)
  • หลีกเลี่ยง GIN เมื่อ
    • ข้อมูลเป็นแบบ Scalar ค่าเดี่ยวทั่วไป (ตัวเลข, รหัส, วันที่) -> ให้ใช้ B-Tree
    • ข้อมูลเป็นช่วงเวลา (Intervals) หรือพิกัดภูมิศาสตร์ (Spatial) -> ให้ใช้ GiST
    • ตารางมีอัตราการเขียนสูงมาก (High-frequency Write / OLTP เข้มข้น) และไม่สามารถทนรับ Write Overhead ของการแตกคีย์ได้

GIN Index คือหนึ่งในเหตุผลสำคัญที่ทำให้ PostgreSQL สามารถทำหน้าที่เป็น Document Store ประสิทธิภาพสูงทดแทน NoSQL ได้อย่างสบาย โดยไม่ต้องแยกฐานข้อมูลออกไปดูแลต่างหาก


อ่านเพิ่มเติม

Exit mobile version