2 นาที

PostgreSQL indexes for SaaS apps: btree vs GIN vs GiST

ดัชนี PostgreSQL สำหรับแอป SaaS: เลือกระหว่าง btree, GIN และ GiST โดยอิงกับรูปแบบคิวรีจริง เช่น ฟิลเตอร์, การค้นหา, JSONB และอาเรย์.

PostgreSQL indexes for SaaS apps: btree vs GIN vs GiST

ปัญหาที่ดัชนีแก้ไขบนหน้าจอ SaaS จริงๆ

ดัชนีเปลี่ยนวิธีที่ PostgreSQL หาแถว หากไม่มีดัชนี ฐานข้อมูลมักต้องอ่านส่วนมากของตาราง (sequential scan) แล้วทิ้งส่วนใหญ่ไป แต่ด้วยดัชนีที่เหมาะสม มันสามารถข้ามไปยังแถวที่ตรงเงื่อนไขได้ทันที (index lookup) แล้วดึงเฉพาะข้อมูลที่ต้องการ

คุณจะสังเกตเห็นเรื่องนี้เร็วใน SaaS เพราะหน้าจอประจำวันมักมีคิวรีหนัก ๆ เพียงคลิกเดียวอาจกระตุ้นการอ่านหลายครั้ง: หน้ารายการ, นับทั้งหมด, การ์ดแดชบอร์ดไม่กี่ชิ้น และกล่องค้นหา เมื่อโตจากหลักพันเป็นหลักล้าน แถวเดียวกันที่เคยเร็วอาจเริ่มหน่วง

ตัวอย่างทั่วไปคือหน้าคำสั่งซื้อ (Orders) ที่กรองโดยสถานะและวันที่ เรียงจากใหม่ไปเก่า และมีการแบ่งหน้า ถ้า PostgreSQL ต้องสแกนทั้งตารางเพื่อหาคำสั่งจ่ายเงินใน 30 วันที่ผ่านมา ทุกหน้าการโหลดจะทำงานเกินจำเป็น ดัชนีที่ดีจะเปลี่ยนให้เป็นการกระโดดไปยังช่วงข้อมูลที่ถูกต้องอย่างรวดเร็ว

ดัชนีไม่ฟรี ทุกดัชนีช่วยให้การอ่านเร็วขึ้นสำหรับคิวรีเฉพาะ แต่ก็ทำให้การเขียนช้าลง (INSERT/UPDATE/DELETE ต้องอัปเดตดัชนี) และใช้พื้นที่จัดเก็บมากขึ้น (พร้อมกับแรงกดในแคช) ดังนั้นควรเริ่มจากรูปแบบคิวรีจริง ไม่ใช่จากชนิดดัชนี

กฎง่าย ๆ ที่ป้องกันงานเปล่า: เพิ่มดัชนีเมื่อคุณชี้ได้ชัดว่ามันจะเร่งคิวรีที่เกิดบ่อย ถ้าคุณสร้างหน้าจอด้วยตัวสร้างแบบ chat-driven อย่าง Koder.ai มันช่วยเก็บ SQL เบื้องหลังหน้ารายการและแดชบอร์ดเพื่อนำมาใช้เป็นรายการความต้องการดัชนี

B-tree vs GIN vs GiST แบบเข้าใจง่าย

ความสับสนเรื่องดัชนีส่วนใหญ่หายไปถ้าคุณเลิกคิดเป็นฟีเจอร์ (JSON, search, arrays) และเริ่มคิดเป็นรูปแบบคิวรี: WHERE ทำอะไร และคุณคาดหวังผลลัพธ์ให้เรียงอย่างไร

B-tree: เร็วสำหรับการเรียงและการเปรียบเทียบ

ใช้ B-tree เมื่อคิวรีของคุณเป็นการเปรียบเทียบปกติและคุณใส่ใจการเรียง เป็นเครื่องมือหลักสำหรับความเท่ากับ, ช่วง และการ join

รูปแบบตัวอย่าง: การกรองด้วย tenant_id = ?, status = 'active', created_at >= ?, การ join users.id = orders.user_id หรือการแสดง “ใหม่สุดก่อน” ด้วย ORDER BY created_at DESC

GIN และ GiST: เมื่อตัวแถวมีค่าหลายตัวที่ค้นหาได้

GIN (Generalized Inverted Index) เหมาะเมื่อคอลัมน์หนึ่งประกอบด้วยสมาชิกหลายตัวและคุณถามว่า “มี X ไหม?” นั่นพบได้บ่อยกับคีย์ JSONB, อีลีเมนต์อาเรย์, และเวกเตอร์การค้นหาข้อความ

รูปแบบตัวอย่าง: metadata @> {'plan':'pro'} บน JSONB, tags @> ARRAY['urgent'], หรือ to_tsvector(body) @@ plainto_tsquery('reset password')

GiST (Generalized Search Tree) เหมาะกับคำถามเกี่ยวกับระยะทางหรือการทับซ้อน ที่ค่าทำงานเหมือนช่วงหรือรูปทรง มักใช้กับชนิด range, ข้อมูลเชิงเรขาคณิต และการค้นหา “ใกล้ที่สุด” บางแบบ

รูปแบบตัวอย่าง: ช่วงเวลาที่ทับซ้อนกับคอลัมน์ range, การค้นหาแบบความคล้ายคลึงบางแบบ (เช่นด้วย trigram operators), หรือคิวรีเชิงพื้นที่ (ถ้าใช้ PostGIS)

แนวทางปฏิบัติในการเลือก:

  • ถ้าคุณกรองหรือเรียงด้วยคอลัมน์ปกติ ให้เริ่มด้วย B-tree.
  • ถ้าคุณเช็คการครอบคลุมหรือการเป็นสมาชิก ให้ดูที่ GIN.
  • ถ้าคำถามคือ “ใกล้แค่ไหน” หรือ “ทับกันไหม” GiST มักจะเหมาะ.
  • ถ้าคิวรีเกิดไม่บ่อยหรือโต๊ะเล็ก อาจไม่ต้องเพิ่มดัชนีใหม่.
  • ถ้าคุณบรรยายรูปแบบคิวรีไม่ได้ ให้วัดก่อน (EXPLAIN) ก่อนจะเพิ่มอะไร

ดัชนีเร่งการอ่าน แต่มีต้นทุนเรื่องเวลาการเขียนและพื้นที่ ใน SaaS ข้อนี้สำคัญที่สุดกับตารางที่ร้อนแรงเช่น events, sessions, activity logs

รูปแบบ B-tree สำหรับการกรอง, การเรียง, และการแบ่งหน้า

หน้ารายการ SaaS ส่วนใหญ่มีรูปแบบเดียวกัน: ขอบเขต tenant, ฟิลเตอร์ไม่กี่ตัว, และการเรียงที่คาดได้ B-tree มักเป็นตัวเลือกเริ่มต้นที่ถูกที่สุดในการบำรุงรักษา

รูปแบบทั่วไปคือ WHERE tenant_id = ? พร้อมฟิลเตอร์อย่าง status = ?, user_id = ?, และช่วงเวลาเช่น created_at >= ? สำหรับดัชนีคอมโพสิต ให้วางฟิลเตอร์แบบ equality ก่อน (คอลัมน์ที่จับด้วย =) แล้วเพิ่มคอลัมน์ที่คุณเรียงตาม

กฎที่ใช้ได้ดีในหลายแอป:

  • เริ่มด้วย tenant_id ถ้าคิวรีทั้งหมดจำกัดด้วย tenant.
  • ใส่ฟิลเตอร์ = ต่อไป (มักเป็น status, user_id).
  • ใส่คอลัมน์ ORDER BY ไว้ท้ายสุด (มักเป็น created_at หรือ id).
  • ใช้ INCLUDE เพื่อครอบคลุมหน้ารายการโดยไม่ทำให้คีย์กว้างขึ้น.
  • เลือก pagination แบบ seek (keyset) แทน offset เมื่อหน้าลึก

ตัวอย่างจริง: หน้า Tickets แสดงรายการใหม่สุดก่อน กรองโดยสถานะ

-- Query
SELECT id, status, created_at, title
FROM tickets
WHERE tenant_id = $1
  AND status = $2
ORDER BY created_at DESC
LIMIT 50;

-- Index
CREATE INDEX tickets_tenant_status_created_at_idx
ON tickets (tenant_id, status, created_at DESC)
INCLUDE (title);

ดัชนีนี้รองรับทั้งการกรองและการเรียง ดังนั้น Postgres จะหลีกเลี่ยงการเรียงชุดผลขนาดใหญ่ ส่วน INCLUDE (title) ช่วยให้หน้ารายการแตะตารางน้อยลง ในขณะที่คีย์ของดัชนียังคงเน้นการกรองและการเรียง

สำหรับช่วงเวลา แนวคิดเดียวกันใช้ได้

SELECT id, created_at
FROM events
WHERE tenant_id = $1
  AND created_at >= $2
  AND created_at <  $3
ORDER BY created_at DESC
LIMIT 100;

CREATE INDEX events_tenant_created_at_idx
ON events (tenant_id, created_at DESC);

การแบ่งหน้าเป็นที่ที่หลายแอป SaaS ช้าลง การแบ่งหน้าแบบ OFFSET (OFFSET 50000) บังคับให้ฐานข้อมูลเดินผ่านแถวจำนวนมาก Seek pagination คงเร็วด้วยการใช้คีย์การเรียงที่เห็นครั้งสุดท้าย

SELECT id, created_at
FROM tickets
WHERE tenant_id = $1
  AND created_at < $2
ORDER BY created_at DESC
LIMIT 50;

ด้วย B-tree ที่เหมาะสม นี่จะคงเร็วแม้ตารางจะโตขึ้น

การทำดัชนีที่เป็นมิตรกับเทนแนนท์โดยไม่เพิ่มดัชนีเกินความจำเป็น

แอป SaaS ส่วนใหญ่เป็นมัลติเทนแนนท์: ทุกคิวรีต้องอยู่ภายในเทนแนนท์เดียว หากดัชนีของคุณไม่มี tenant_id PostgreSQL ยังอาจหาแถวได้เร็ว แต่บ่อยครั้งต้องสแกนรายการดัชนีมากกว่าที่ควร ดัชนีที่คำนึงถึงเทนแนนท์จะทำให้ข้อมูลของแต่ละเทนแนนท์อยู่รวมกันในดัชนี ทำให้หน้าจอทั่วไปเร็วและคาดเดาได้

กฎง่าย: ใส่ tenant_id เป็นคอลัมน์แรกในดัชนีเมื่อคิวรีมักกรองด้วย tenant แล้วค่อยเพิ่มคอลัมน์ที่คุณกรองหรือเรียงบ่อยที่สุด

ดัชนีที่มีผลสูงและน่าเบื่อมักหน้าตาเป็น:

  • (tenant_id, created_at) สำหรับรายการล่าสุดและ cursor pagination
  • (tenant_id, status) สำหรับฟิลเตอร์สถานะ (Open, Paid, Failed)
  • (tenant_id, user_id) สำหรับหน้าจอ “ไอเท็มของผู้ใช้คนนี้”
  • (tenant_id, updated_at) สำหรับมุมมอง admin ที่เปลี่ยนล่าสุด
  • (tenant_id, external_id) สำหรับการมองขึ้นจาก webhook หรือการนำเข้า

การเพิ่มดัชนีเกินความจำเป็นเกิดเมื่อคุณเพิ่มดัชนีใหม่สำหรับทุกหน้าจอที่ต่างกันเล็กน้อย ก่อนสร้างใหม่ ให้เช็กว่าดัชนีคอมโพสิตที่มีอยู่ครอบคลุม leftmost columns ที่คุณต้องการหรือไม่ เช่น ถ้าคุณมี (tenant_id, created_at) โดยทั่วไปคุณไม่ต้องมี (tenant_id, created_at, id) อีกเว้นแต่คุณจะกรองตาม id หลังจากคอลัมน์เหล่านั้นจริง ๆ

ดัชนีแบบพาร์เชียลช่วยลดขนาดและต้นทุนการเขียนเมื่อแถวส่วนใหญ่ไม่เกี่ยวข้อง เหมาะกับ soft deletes และ “active only” เช่น ดัชนีเฉพาะที่ deleted_at IS NULL หรือเฉพาะ status = 'active'

ดัชนีเพิ่มเติมทุกชิ้นทำให้การเขียนหนักขึ้น Inserts ต้องอัปเดตดัชนีทุกตัว และ updates อาจแตะหลายดัชนีแม้คุณเปลี่ยนคอลัมน์ตัวเดียว หากแอปของคุณรับเหตุการณ์จำนวนมาก ให้โฟกัสดัชนีไว้ที่รูปแบบคิวรีที่ผู้ใช้ใช้ทุกวัน

การทำดัชนี JSONB: GIN และดัชนี expression เฉพาะจุด

สร้างหน้าจอ SaaS ที่เร็วขึ้น
สร้างหน้ารายการ SaaS และดู SQL ที่ควรสร้างดัชนีเพื่อโหลดเร็วขึ้น.

JSONB ใช้สะดวกเมื่อแอปต้องการฟิลด์ยืดหยุ่นอย่าง feature flags, คุณลักษณะผู้ใช้, หรือการตั้งค่าต่อเทนแนนท์ ข้อควรระวังคือโอเปอเรเตอร์ JSONB ต่างกัน ดังนั้นดัชนีที่ดีที่สุดขึ้นกับวิธีที่คุณคิวรี

สองรูปแบบที่สำคัญ:

  • Containment: “JSON นี้มีคู่คีย์-ค่าเหล่านี้ไหม?” ด้วย @>
  • Path extraction: “ค่าของฟิลด์เฉพาะนี้คืออะไร?” โดยใช้ -> / ->> (มักเปรียบเทียบด้วย =)

เมื่อใดที่ดัชนี GIN เหมาะ

ถ้าคุณกรองด้วย @> บ่อย ๆ ดัชนี GIN บนคอลัมน์ JSONB มักคุ้มค่า

-- Query shape: containment
SELECT id
FROM accounts
WHERE tenant_id = $1
  AND metadata @> '{"region":"eu","plan":"pro"}';

-- Index
CREATE INDEX accounts_metadata_gin
ON accounts
USING GIN (metadata);

ถ้าโครงสร้าง JSON ของคุณคาดเดาได้และคุณใช้ @> บนคีย์ระดับบนเป็นส่วนใหญ่ jsonb_path_ops อาจเล็กกว่าและเร็วกว่า แต่รองรับ operator น้อยกว่า

เมื่อใดที่ดัชนี expression ดีกว่า

ถ้า UI ของคุณกรองซ้ำ ๆ บนฟิลด์หนึ่ง (เช่น plan) การดึงฟิลด์นั้นออกมาแล้วทำดัชนีมักเร็วและถูกกว่าการทำ GIN กว้าง ๆ

SELECT id
FROM accounts
WHERE tenant_id = $1
  AND metadata->> 'plan' = 'pro';

CREATE INDEX accounts_plan_expr
ON accounts ((metadata->> 'plan'));

กฎปฏิบัติ: เก็บ JSONB สำหรับแอตทริบิวต์ที่ยืดหยุ่นและกรองไม่บ่อย แต่โปรโมตฟิลด์ที่นิ่งและใช้งานบ่อย (plan, status, created_at) เป็นคอลัมน์จริง ถ้าคุณพัฒนาเร็วบนแอปที่สร้างด้วย Koder.ai เป็นเรื่องง่ายที่จะปรับสคีมาเมื่อเห็นว่าฟิลเตอร์ไหนปรากฏในทุกหน้าจอ

ตัวอย่าง: ถ้าคุณเก็บ {"tags":["beta","finance"],"region":"us"} ใน JSONB ให้ใช้ GIN เมื่อคุณกรองโดยกลุ่มแอตทริบิวต์ (@>), และเพิ่มดัชนี expression สำหรับคีย์ไม่กี่ตัวที่ขับเคลื่อนรายการส่วนใหญ่ (plan, region)

การทำดัชนีอาเรย์: เมื่อต้องใช้ GIN

อาเรย์น่าสนใจเพราะเก็บง่ายและอ่านง่าย คอลัมน์ users.roles text[] หรือ projects.labels text[] ทำงานได้ดีเมื่อคำถามหลักคือ: แถวนี้มีค่าหนึ่งค่าหรือไม่? นั่นคือจุดที่ GIN ช่วย

GIN เป็นตัวเลือกหลักสำหรับการสอบถามการเป็นสมาชิกในอาเรย์ มันแตกอาเรย์ออกเป็นไอเท็มเดี่ยวและสร้าง lookup ที่รวดเร็วไปยังแถวที่มีไอเท็มเหล่านั้น

รูปแบบคิวรีอาเรย์ที่ได้ประโยชน์บ่อย ๆ:

  • มีค่าหรือชุดค่า: @> (array contains)
  • ทับซ้อนกับชุด: && (array shares any items)
  • บางครั้ง: = ANY(...), แต่ @> มักคาดเดาได้มากกว่า

ตัวอย่างทั่วไปการกรองผู้ใช้ตามบทบาท:

-- Find users who have the "admin" role
SELECT id, email
FROM users
WHERE roles @> ARRAY['admin'];

CREATE INDEX users_roles_gin ON users USING GIN (roles);

และการกรองโปรเจกต์ที่มีชุดป้ายกำกับ (ต้องมีทั้งสอง):

SELECT id, name
FROM projects
WHERE labels @> ARRAY['billing', 'urgent'];

CREATE INDEX projects_labels_gin ON projects USING GIN (labels);

ที่คนมักประหลาดใจ: บางรูปแบบจะไม่ใช้ดัชนีตามที่คิด หากคุณแปลงอาเรย์เป็นสตริง (array_to_string(labels, ',')) แล้วใช้ LIKE ดัชนี GIN จะไม่ช่วย และถ้าคุณต้องการ “starts with” หรือการจับคู่ fuzzy ภายใน labels คุณควรใช้การค้นหาข้อความ ไม่ใช่การเป็นสมาชิกอาเรย์

อาเรย์ยังยากจะดูแลเมื่อมันกลายเป็นฐานข้อมูลจิ๋ว: อัปเดตบ่อย ต้อง metadata ต่อไอเท็ม (ใครเพิ่มเมื่อไหร่ ทำไม) หรือต้องการการวิเคราะห์ต่อ label ในกรณีนั้น ตารางเชื่อมอย่าง project_labels(project_id, label) มักง่ายกว่าทั้งเรื่องตรวจสอบและการค้นหา

ดัชนีการค้นหา: full-text และการจับคู่แบบ fuzzy (GIN และ GiST)

สำหรับกล่องค้นหา สองรูปแบบที่พบบ่อยคือ full-text search (ค้นหาเอกสารตามหัวข้อ) และ fuzzy matching (รองรับพิมพ์ผิด, ชื่อบางส่วน, และรูปแบบ ILIKE) ดัชนีที่เหมาะสมต่างกันระหว่าง “ทันที” กับ “หมดเวลาเมื่อผู้ใช้มากขึ้น”

การค้นหาข้อความเต็ม: tsvector + GIN

ใช้ full-text เมื่อลูกค้าพิมพ์คำจริงและคุณต้องการผลตามความเกี่ยวข้อง เช่น ค้นหาตั๋วโดยหัวข้อและรายละเอียด การตั้งค่าปกติคือเก็บ tsvector (มักเป็น generated column) และใส่ดัชนี GIN คุณค้นหาด้วย @@ และ tsquery

-- Tickets: full-text search on subject + body
ALTER TABLE tickets
ADD COLUMN search_vec tsvector
GENERATED ALWAYS AS (
  to_tsvector('simple', coalesce(subject,'') || ' ' || coalesce(body,''))
) STORED;

CREATE INDEX tickets_search_vec_gin
ON tickets USING GIN (search_vec);

-- Query
SELECT id, subject
FROM tickets
WHERE search_vec @@ plainto_tsquery('simple', 'invoice failed');

-- Customers: fuzzy name search using trigrams
CREATE INDEX customers_name_trgm
ON customers USING GIN (name gin_trgm_ops);

SELECT id, name
FROM customers
WHERE name ILIKE '%jon smth%';

สิ่งที่ควรเก็บในเวกเตอร์: เก็บเฉพาะฟิลด์ที่คุณค้นหาจริง ๆ หากคุณใส่ทุกอย่าง (notes, internal logs) คุณจะจ่ายในขนาดดัชนีและต้นทุนการเขียน

การจับคู่แบบ fuzzy: trigrams กับ GIN หรือ GiST

ใช้ trigram similarity เมื่อผู้ใช้ค้นหาชื่อ, อีเมล หรือวลีสั้น ๆ และคุณต้องการ partial matches หรือการทนพิมพ์ผิด Trigram ช่วยกับ ILIKE '%term%' และตัวดำเนินการ similarity GIN มักเร็วกว่าเมื่อต้องการ “ตรงกับไหม?”; GiST อาจเหมาะกว่าเมื่อคุณต้องการเรียงตามความคล้ายคลึงด้วย

กฎสั้น ๆ:

  • ใช้ GIN + tsvector สำหรับการค้นหาแบบ relevance
  • ใช้ trigrams สำหรับ ILIKE และการค้นหาที่ทนต่อพิมพ์ผิด

จุดที่ต้องระวัง:

  • Leading wildcard โดยไม่ใช้ trigrams (ILIKE '%abc') จะบังคับให้สแกน
  • คำค้นสั้นมาก (1-2 ตัวอักษร) มักใช้ trigram ไม่ได้ดี
  • Stop words และ stemming อาจทำให้ผล full-text ตกใจผู้ใช้ ดังนั้นเลือก configuration ที่เข้ากับภาษาผลิตภัณฑ์ของคุณ

ถ้าคุณส่งหน้าค้นหาเร็ว ให้ถือว่าดัชนีเป็นส่วนหนึ่งของฟีเจอร์: UX การค้นหาและการเลือกดัชนีต้องออกแบบร่วมกัน

ขั้นตอนทีละข้อ: จากคิวรีช้าไปสู่ดัชนีที่เหมาะสม

แก้ปัญหา pagination
สร้างการแบ่งหน้าแบบ keyset ที่ยังเร็วโดยไม่ต้องสแกนลึกด้วย OFFSET.

เริ่มจากคิวรีที่แอปของคุณรันจริง ไม่ใช่การเดา “หน้าช้า” มักเป็นคำสั่ง SQL เดียวที่มี WHERE และ ORDER BY ชัดเจน คัดลอกมันจาก logs, ORM debug output, หรือที่คุณจับคิวรีไว้

เวิร์กโฟลว์ที่ใช้ได้ในงานจริง:

  • เก็บ SQL แบบเต็ม รวม WHERE, ORDER BY, LIMIT
  • รัน EXPLAIN (ANALYZE, BUFFERS) บนคิวรีเดียวกัน
  • มุ่งที่ตัวดำเนินการที่ทำงานหนัก (=, >=, LIKE, @>, @@), ไม่ใช่แค่ชื่อคอลัมน์
  • เพิ่มดัชนีตัวเล็กที่สุดที่ตรงกับตัวดำเนินการเหล่านั้น
  • รัน EXPLAIN (ANALYZE, BUFFERS) อีกครั้งกับข้อมูลสมจริง

นี่คือตัวอย่างที่จับต้องได้ หน้า Customers กรองด้วย tenant และ status เรียงใหม่สุดและแบ่งหน้า

SELECT id, created_at, email
FROM customers
WHERE tenant_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;

ถ้า EXPLAIN แสดง sequential scan และ sort ดัชนี B-tree ที่ตรงกับฟิลเตอร์และการเรียงมักจะแก้ได้

CREATE INDEX ON customers (tenant_id, status, created_at DESC);

ถ้าส่วนที่ช้าคือการกรอง JSONB เช่น metadata @> '{"plan":"pro"}' นั่นชี้ไปที่ GIN ถ้าเป็น full-text แบบ to_tsvector(...) @@ plainto_tsquery(...) ก็ชี้ไปที่ดัชนีการค้นหาที่ใช้ GIN ถ้าเป็นการค้นหา “ใกล้ที่สุด” หรือ operator แบบ overlap นั่นคือที่ GiST มักเหมาะ

หลังเพิ่มดัชนี วัดผลการแลกเปลี่ยนเชิงปฏิบัติ ตรวจสอบขนาดดัชนี, เวลา insert/update, และว่ามันช่วยคิวรีช้า ๆ หลัก ๆ หรือแค่เคสขอบเขต ในโปรเจกต์ที่เคลื่อนไหวเร็ว (รวมโปรเจกต์ที่สร้างด้วย Koder.ai) การตรวจซ้ำนี้ช่วยหลีกเลี่ยงการสะสมดัชนีที่ไม่ได้ใช้

ข้อผิดพลาดด้านดัชนีที่พบบ่อยและเสียเวลาเสียเงิน

ปัญหาดัชนีส่วนใหญ่ไม่ใช่เรื่องเลือก B-tree vs GIN vs GiST แต่เป็นการสร้างดัชนีที่ดูถูกต้องแต่ไม่ตรงกับวิธีที่แอปคิวรีตาราง

ข้อผิดพลาดที่มักทำให้เสียหาย:

  • ดัชนีที่ไม่ถูกใช้งาน คิวรีใช้ operator ต่างจากที่ดัชนีรองรับ หรือดัชนีคอมโพสิตมีลำดับคอลัมน์ผิด หาก WHERE เริ่มด้วย tenant_id และ created_at แต่ดัชนีเริ่มด้วย created_at planner อาจข้ามมัน
  • ทำดัชนีคอลัมน์ความแปรผันต่ำคนเดียว เช่น status, is_active, หรือ boolean มักช่วยน้อยเพราะจับแถวได้มาก ควรจับคู่กับคอลัมน์ที่มีความเลือกมากขึ้น (เช่น tenant_id หรือ created_at) หรือข้ามไป
  • ดัชนีซ้อนทับกันทำให้บวมและช้าการเขียน ดัชนีคล้าย ๆ กันบนตารางเดียวกันสามารถเพิ่มพื้นที่เป็นสองเท่าและทำให้ inserts/updates ช้าลง
  • จุดบอดการแบ่งหน้า การทำดัชนีเพื่อรองรับ OFFSET เพียงอย่างเดียวเป็นกับดักทั่วไป ถ้าใช้ keyset pagination คุณต้องมีดัชนีที่ตรงกับการเรียงและตัวกรอง last-seen
  • สถิติเก่าและตารางรก ถ้า autovacuum ทำงานไม่ทัน หรือ ANALYZE ไม่ได้รันเมื่อเร็ว ๆ นี้ planner อาจเลือกแผนไม่ดีแม้ว่าดัชนีที่ถูกต้องจะมีอยู่

ตัวอย่างชัดเจน: หน้าจอ Invoices ของคุณกรองโดย tenant_id และ status แล้วเรียงตาม created_at DESC ดัชนีแค่ status แทบไม่ช่วย ดัชนีที่ดีกว่าคือคอมโพสิตที่เริ่มด้วย tenant_id, ตามด้วย status, แล้ว created_at (กรองก่อน เรียงทีหลัง) การเปลี่ยนแค่นี้มักเหนือกว่าการเพิ่มสามดัชนีแยก

มองว่าดัชนีทุกตัวมีต้นทุน ต้องพิสูจน์ตัวเองในคิวรีจริง ไม่ใช่ในทางทฤษฎี

เช็คลิสต์ด่วนก่อนส่งการเปลี่ยนแปลงดัชนี

ต้นแบบกับ Postgres จริง
สร้างแอป React, Go และ PostgreSQL จากแชท แล้ววนแก้สคีมาได้เร็ว.

การเปลี่ยนแปลงดัชนีส่งได้ง่ายแต่ยากแก้ถ้ามันเพิ่มต้นทุนการเขียนหรือล็อคตารางที่ใช้งานหนัก ก่อน merge ให้ถือว่าเป็นการปล่อยเล็ก ๆ

เริ่มจากตัดสินใจว่าคุณจะเพิ่มประสิทธิภาพอะไร ดึงสองอันดับสั้น ๆ จาก logs หรือมอนิเตอร์: คิวรีที่รันบ่อยที่สุด และคิวรีที่มี latency สูงที่สุด สำหรับแต่ละคิวรี ให้จดรูปแบบที่แน่ชัด: คอลัมน์กรอง, การเรียง, การ join, และ operator ที่ใช้ (=, range, IN, ILIKE, operator JSONB, array contains) นี่ช่วยป้องกันการเดาและช่วยเลือกชนิดดัชนี

เช็คลิสต์ก่อนส่ง:

  • ยืนยันว่าคิวรีมีการจำกัด tenant ตามที่ควรหรือไม่
  • จับดัชนีกับ operator: B-tree สำหรับ equality/range/sort, GIN สำหรับ membership (arrays, JSONB, full text), GiST สำหรับ overlap/ระยะทาง
  • ชอบดัชนีคอมโพสิตหนึ่งตัวที่ตรงกับ filter + sort มากกว่าหลายดัชนีคอลัมน์เดี่ยว
  • รักษาดัชนีให้แคบ: ใส่เฉพาะคอลัมน์ที่คิวรีใช้จริง
  • วางแผนการโรลเอาท์: การสร้างดัชนีจะบล็อกการเขียนหรือไม่, ต้องทำช่วงนอกเวลาไหม

หลังเพิ่มดัชนี ยืนยันว่ามันช่วยในแผนจริง รัน EXPLAIN (ANALYZE, BUFFERS) บนคิวรีเดิมและเทียบก่อน-หลัง แล้วดูพฤติกรรม production หนึ่งวัน:

  • latency การอ่านลดลงสำหรับหน้าจอเป้าหมายไหม?
  • latency การเขียนเพิ่มขึ้น (inserts/updates) ไหม?
  • CPU หรือพื้นที่พุ่งขึ้นเพราะดัชนีใหม่ไหม?
  • ดัชนีถูกใช้งานจริงหรือเป็นภาระเท่านั้น?

ถ้าคุณสร้างด้วย Koder.ai ควรเก็บ SQL ที่สร้างโดยเครื่องมือไว้เคียงกับการเปลี่ยนแปลง เพื่อให้ดัชนีตรงกับสิ่งที่แอปรันจริง

ตัวอย่าง: ทำดัชนี workflow พื้นฐานของแอป SaaS + ขั้นตอนต่อไป

นึกภาพหน้าจอ admin ทั่วไป: รายการ Users ที่มีการจำกัด tenant, ฟิลเตอร์ไม่กี่ตัว, เรียงตาม last active, และกล่องค้นหา ที่นี่ดัชนีหยุดเป็นทฤษฎีและเริ่มประหยัดเวลา

สามรูปแบบคิวรีที่คุณมักเห็น:

-- 1) List page with tenant + status filter + sort
SELECT id, email, last_active_at
FROM users
WHERE tenant_id = $1 AND status = $2
ORDER BY last_active_at DESC
LIMIT 50;

-- 2) Search box (full-text)
SELECT id, email
FROM users
WHERE tenant_id = $1
  AND to_tsvector('simple', coalesce(name,'') || ' ' || coalesce(email,'')) @@ plainto_tsquery($2)
ORDER BY last_active_at DESC
LIMIT 50;

-- 3) Filter on JSON metadata (plan, flags)
SELECT id
FROM users
WHERE tenant_id = $1
  AND metadata @> '{"plan":"pro"}'::jsonb;

ชุดดัชนีเล็ก ๆ แต่ตั้งใจสำหรับหน้านี้:

  • B-tree คอมโพสิตสำหรับ list view: (tenant_id, status, last_active_at DESC).
  • GIN สำหรับการค้นหา: คอลัมน์ tsvector ที่สร้างเป็น generated column พร้อมดัชนี GIN.
  • การทำดัชนี JSONB ตามการใช้งาน: GIN (metadata) เมื่อใช้ @> มาก หรือตัวดัชนี expression B-tree เช่น ((metadata->> 'plan')) เมื่อกรองคีย์เดียวเป็นหลัก

ความต้องการผสมเป็นเรื่องปกติ หากหน้าเดียวทำทั้งกรอง + ค้นหา + JSON อย่าอัดทุกอย่างลงในดัชนีเดียวขนาดยักษ์ เก็บ B-tree สำหรับการเรียง/แบ่งหน้า แล้วเพิ่มดัชนีเฉพาะเรื่องหนึ่งชิ้น (มักเป็น GIN) สำหรับส่วนที่แพงที่สุด

ขั้นตอนต่อไป: เลือกหน้าช้า 1 หน้า จด 2-3 รูปแบบคิวรีหลัก และทบทวนดัชนีแต่ละอันตามจุดประสงค์ (filter, sort, search, JSON) ถ้าดัชนีไม่ตรงกับคิวรีจริง ให้ตัดออก หากคุณพัฒนาเร็วบน koder.ai ทำการทบทวนนี้เมื่อเพิ่มหน้าจอใหม่เพื่อป้องกันการฟุ้งของดัชนีขณะสคีมายังเปลี่ยนอยู่

คำถามที่พบบ่อย

What does an index actually fix in a real SaaS app?

ดัชนีช่วยให้ PostgreSQL หาแถวที่ตรงเงื่อนไขได้โดยไม่ต้องอ่านทั้งตาราง สำหรับหน้าจอ SaaS ทั่วไปอย่างรายการ, แดชบอร์ด และการค้นหา ดัชนีที่เหมาะสมสามารถเปลี่ยนการสแกนเชิงลำดับที่ช้าให้เป็นการค้นหาแบบทันทีที่ขยายได้ดีกว่าเมื่อข้อมูลโตขึ้น.

How do I choose between B-tree, GIN, and GiST without overthinking it?

เริ่มจาก B-tree สำหรับคิวรีทั่วไป เพราะมันเหมาะกับฟิลเตอร์ =, ฟิลเตอร์ช่วง, การเชื่อม (JOIN) และ ORDER BY ถ้าคิวรีของคุณเป็นเรื่องการครอบคลุม/การเป็นสมาชิก (JSONB, อาเรย์) หรือการค้นหาข้อความเต็ม ฟังดูที่ GIN; GiST เหมาะกับกรณี overlap หรือการค้นหา “ใกล้ที่สุด” มากกว่า.

What’s the simplest rule for ordering columns in a composite B-tree index?

วางคอลัมน์ที่ใช้ = ไว้ก่อน แล้วค่อยใส่คอลัมน์ที่ใช้สำหรับ ORDER BY ไว้ท้ายสุด การเรียงแบบนี้ช่วยให้ planner เดินดัชนีได้อย่างมีประสิทธิภาพทั้งการกรองและการคืนผลโดยไม่ต้อง sort เพิ่มเติม.

Do I really need to include tenant_id in my indexes for multi-tenant SaaS?

ถ้าทุกคิวรีถูกกรองด้วย tenant_id การใส่ tenant_id เป็นคอลัมน์แรกจะช่วยรวมข้อมูลของแต่ละเทนแนนท์ไว้ใกล้กันในดัชนี ทำให้ PostgreSQL ต้องแตะข้อมูลน้อยลงสำหรับหน้ารายการทั่วไป.

When should I use INCLUDE on a B-tree index?

INCLUDE ช่วยเพิ่มคอลัมน์เพิ่มเติมในดัชนีเพื่อให้สามารถอ่านจากดัชนีอย่างเดียว (index-only reads) ได้ โดยไม่ทำให้คีย์ของดัชนีกว้างขึ้น ใช้เมื่อคุณกรองและเรียงด้วยไม่กี่คอลัมน์ แต่ต้องแสดงฟิลด์เสริมบางช่องบนหน้าจอ.

When is a partial index better than indexing the whole table?

ดัชนีแบบพาร์เชียลดีเมื่อคุณสนใจเฉพาะ subset ของแถว เช่น “ยังไม่ถูกลบ” หรือ “เฉพาะสถานะ active” มันทำให้ดัชนีเล็กลงและคงค่าบำรุงรักษาถูกลง ซึ่งสำคัญกับตารางที่มีการเขียนบ่อย.

Should I index JSONB with GIN or with expression indexes?

ถ้าคุณค้นหาบ่อยด้วยการครอบคลุมแบบ @> บน JSONB ดัชนี GIN บนคอลัมน์ JSONB มักจะคุ้มค่า แต่ถ้าคุณกรองบนคีย์ JSON เจาะจงเพียงหนึ่งหรือสองคีย์ การสร้างดัชนี expression แบบ B-tree บน (metadata->> 'plan') มักจะเล็กและเร็วกว่าการใช้ GIN กว้าง ๆ.

When do arrays deserve a GIN index, and when should I normalize instead?

GIN เหมาะเมื่อคำถามหลักคือ “อาเรย์นี้มี X ไหม?” โดยใช้ operator เช่น @> หรือ && แต่ถ้าคุณต้องการ metadata ต่อไอเท็ม, การแก้ไขบ่อย หรือการวิเคราะห์ต่อ label/role ในระยะยาว ให้พิจารณาทำ normalization เป็นตารางเชื่อม (join table) แทน.

What’s the right indexing approach for a search box?

สำหรับ full-text ให้เก็บ tsvector (บ่อยครั้งเป็น generated column) และใส่ดัชนี GIN แล้วค้นหาโดยใช้ @@ เพื่อการค้นหาแบบ relevance สำหรับ fuzzy/partial match (เช่น ILIKE '%name%' หรือ typo) ให้ใช้ trigram index (มักเป็น GIN) เพื่อให้ค้นหาแบบไม่เคร่งครัดได้.

How do I go from a slow query to the right index in a repeatable way?

คัดลอก SQL ที่แอปของคุณรันจริง แล้วรัน EXPLAIN (ANALYZE, BUFFERS) ดูจุดที่ใช้เวลาจริง ๆ ว่าเป็นการสแกน, การเรียง, หรือตัวดำเนินการแบบพิเศษ (=, >=, LIKE, @>, @@) แล้วเพิ่มดัชนีที่เล็กที่สุดซึ่งตรงกับตัวดำเนินการและการเรียงนั้น จากนั้นรัน EXPLAIN เดิมอีกครั้งเพื่อยืนยันว่ามันช่วยจริง.

Related posts