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

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 โตขึ้น

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 rows
create 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 select
using (auth.uid() = user_id);
-- Without this index, the policy above forces a sequential scan on every select
create 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 คือวิธีตรงที่สุดในการดูว่า Postgres ใช้ index จริงสำหรับ query นั้นหรือเปล่า แทนที่จะเดาจากความรู้สึกว่าเร็วบน table เล็ก ๆ ในเครื่อง

explain analyze
select * 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"]
An unindexed RLS filter forces a sequential scan; an index on the same column turns it into an index scan
ทำไม column ที่ RLS policy อ้างอิงถึงเป็นเป้าหมาย indexing ที่สำคัญเป็นพิเศษ
`explain analyze` บอกอะไรที่การรัน query ปกติไม่บอก
Supabase Studio โชว์อะไรให้ได้โดยไม่ต้องรัน `explain analyze` เอง
ในตัวอย่าง `todos` การรัน `create index idx_todos_user_id on public.todos (user_id);` เปลี่ยนอะไรกับ query select ที่ถูก RLS ป้องกัน