Performance และ Indexing
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ทำ index บน column ที่ query ของคุณใช้ filter และ join บ่อย ๆ และใน Supabase เรื่องนี้สำคัญเป็นสองเท่าสำหรับ column ไหนก็ตามที่ Row Level Security policy อ้างอิงถึง เพราะ policy condition นั้นรันกับ row candidate ทุกตัวในทุก query — column ของ policy ที่ไม่มี index อาจแอบเปลี่ยน query ที่ดู “simple” ให้กลายเป็น sequential scan ได้เมื่อ table โตขึ้น
ทำไม column ของ RLS policy ถึงเป็นเป้าหมาย indexing พิเศษ
หัวข้อที่มีชื่อว่า “ทำไม column ของ RLS policy ถึงเป็นเป้าหมาย indexing พิเศษ”Row Level Security policy ไม่ใช่อะไรที่รันแค่ครั้งเดียวต่อ query — Postgres evaluate condition ของตัวเองกับทุก row ที่มีโอกาสถูก return ในทุก query ของทุก user policy อย่าง auth.uid() = user_id ดูเหมือนเป็นการเช็คบรรทัดเดียวที่ไม่มีพิษภัย แต่ถ้า user_id ไม่มี index Postgres ก็ไม่มีทางกระโดดตรงไปยัง row ที่ match ได้ ต้องเดินอ่านทั้ง table แล้ว evaluate policy condition ทีละ row
ส่วนที่อันตรายคือปัญหานี้จะยังมองไม่เห็นไปอีกนาน table ที่มีแค่ไม่กี่ร้อย row sequential scan กับ index scan จะรู้สึกเร็วเท่ากันทั้งคู่ และ query ก็ “ดูเหมือน” simple เพราะเป็น select บรรทัดเดียวที่ไม่มีความซับซ้อนให้เห็น ต้นทุนนี้จะโผล่มาก็ตอนที่ table โตไปถึงหลักหมื่นหรือหลักแสน row ซึ่งตอนนั้นกลายเป็น production incident ไปแล้ว ไม่ใช่แค่ comment ใน code review
-- A todos table, RLS-protected so users can only see their own rowscreate table public.todos ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users (id), title text not null, done boolean not null default false);
alter table public.todos enable row level security;
create policy "Users can view their own todos"on public.todos for selectusing (auth.uid() = user_id);
-- Without this index, the policy above forces a sequential scan on every selectcreate index idx_todos_user_id on public.todos (user_id);ก่อนมี index ทุกครั้งที่ user รัน select * from todos Postgres จะต้อง scan ทุก row ใน table แล้วเช็ค auth.uid() = user_id กับแต่ละ row หลังมี index Postgres กระโดดตรงไปยัง row ของ user คนนั้นได้เลย ต้นทุนของ query จะเลิกโตตาม size ของทั้ง table แล้วเริ่ม scale ตาม size ของข้อมูล user คนเดียวนั้นแทน ที่เป็นรูปแบบที่ query ที่ถูก RLS ป้องกันเกือบทุกตัวมีอยู่จริง
อ่าน explain analyze ก่อนที่จะช้าไปแล้ว
หัวข้อที่มีชื่อว่า “อ่าน explain analyze ก่อนที่จะช้าไปแล้ว”explain analyze คือวิธีตรงที่สุดในการดูว่า Postgres ใช้ index จริงสำหรับ query นั้นหรือเปล่า แทนที่จะเดาจากความรู้สึกว่าเร็วบน table เล็ก ๆ ในเครื่อง
explain analyzeselect * from public.todos where user_id = auth.uid();บรรทัดบนสุดของ output บอกชื่อ plan ที่ Postgres เลือก Seq Scan on todos หมายความว่าอ่านทุก row ส่วน Index Scan using idx_todos_user_id on todos หมายความว่าใช้ index ANALYZE ยังรัน query จริงและรายงานเวลาที่ใช้จริงกับจำนวน row จริง ไม่ใช่แค่ estimate ของ Postgres ดังนั้นควรระวังกับ insert, update หรือ delete เพราะรันซ้ำมีผลข้างเคียงจริง ให้เช็คกับ select แทน การทำเป็นนิสัยว่าจะรันคำสั่งนี้กับ RLS-protected query ใหม่ทุกตัว — ก่อน ship ไม่ใช่หลังจาก query ช้าใน production แล้ว — จะจับ index ที่หายไปได้ตั้งแต่ตอนที่ยังแก้ง่าย ๆ แค่บรรทัดเดียว
สิ่งที่ Supabase Studio โชว์ให้โดยไม่ต้องรัน explain analyze เอง
หัวข้อที่มีชื่อว่า “สิ่งที่ Supabase Studio โชว์ให้โดยไม่ต้องรัน explain analyze เอง”Supabase Studio มี Query Performance และ Advisor tooling ในตัวที่โชว์เรื่องพวกนี้ให้อัตโนมัติ โดยไม่ต้องหยิบ explain analyze มาใช้เองสำหรับการเช็คทั่วไป Query Performance ใน dashboard จะ list query ที่ช้าที่สุดและถูกรันบ่อยที่สุดตรงจาก statistics ของ Postgres เอง ส่วน Advisors panel จะ flag index ที่หายไปและปัญหา performance ที่พบบ่อยอื่น ๆ — รวมถึง column ที่ RLS policy อ้างอิงถึงแต่ไม่มี index โดยเฉพาะ ไม่ได้มาแทนที่ความเข้าใจเรื่อง explain analyze แต่เป็นเครื่องมือที่ควรเช็คเป็นอันดับแรกสำหรับมุมมองต่อเนื่องแบบ low-effort ว่า bottleneck จริง ๆ ของ project อยู่ตรงไหน
flowchart LR q1["select * from todos where user_id = auth.uid()"] --> rls1["RLS policy checks user_id on every row"] rls1 --> seq["No index on user_id"] seq --> scan["Sequential scan - cost grows with table size"] q2["Same query, after create index idx_todos_user_id"] --> rls2["RLS policy checks user_id on every row"] rls2 --> idx["Index on user_id"] idx --> iscan["Index scan - cost grows with matching rows only"]