Connection Pooling และ Supavisor
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”Postgres มีขีดจำกัดของ concurrent connection ที่ต่อตรงเข้ามาได้ ดังนั้น Supabase จึงวาง Supavisor ไว้ด้านหน้า Postgres เป็น connection pooler ที่มีสองโหมด — transaction mode บน port 6543 สำหรับ client อายุสั้นจำนวนมากที่ serverless กับ edge environment สร้างขึ้นมา และ session mode บน port 5432 สำหรับ client อายุยืนจำนวนน้อยที่ต้องการ feature ของ Postgres แบบครบถ้วน
ทำไม connection ถึงเป็นทรัพยากรที่มีจำกัด
หัวข้อที่มีชื่อว่า “ทำไม connection ถึงเป็นทรัพยากรที่มีจำกัด”connection ของ Postgres แต่ละอันกิน memory และ backend process บนฝั่ง database server ดังนั้น Postgres จึงจำกัดจำนวนที่เปิดพร้อมกันได้ — โดย default อยู่ที่หลักร้อยต้น ๆ และ managed Postgres ของ Supabase ก็ไม่ต่างกัน ขีดจำกัดนี้อยู่ร่วมกับ server แบบดั้งเดิมที่รันยาว ๆ ได้ง่าย เพราะเปิด connection ไม่กี่อันแล้วใช้ซ้ำตลอดอายุการทำงานของตัวเอง
serverless กับ edge environment ทำลายสมมติฐานนั้น Edge Function, serverless API route หรือ framework ไหนก็ตามที่สร้าง instance อายุสั้นใหม่ทุกครั้งต่อ request ล้วนพยายามเปิด connection ตรงของตัวเองไปยัง Postgres ภายใต้ traffic จริง สิ่งนี้รวมกันอย่างรวดเร็วจนเกิน concurrent connection ที่ Postgres รับไหว แล้ว request ก็เริ่ม fail ด้วย connection error — ไม่ใช่เพราะ database ช้า แต่เพราะ connection slot หมดไปเฉย ๆ
Supavisor คือ transaction mode เทียบกับ session mode
หัวข้อที่มีชื่อว่า “Supavisor คือ transaction mode เทียบกับ session mode”Supavisor คือ connection pooler ของ Supabase ทำหน้าที่อยู่ระหว่างแอปของคุณกับ Postgres ถือ pool ของ connection จริงที่มีจำนวนน้อยกว่า แล้วแจกจ่ายให้กับ logical client จำนวนมากกว่าที่ Postgres จะรับตรง ๆ ได้ มีสองโหมด และเลือกโหมดจาก port ที่คุณต่อเข้าไป
- Transaction mode (port
6543) — connection หนึ่งจะถูกถือไว้แค่ช่วงของ transaction เดียวเท่านั้น แล้วคืนกลับเข้า pool ทันที ทำให้ pool เล็ก ๆ รองรับ client อายุสั้นจำนวนมากได้ ซึ่งตรงกับรูปแบบ traffic ของ serverless กับ Edge Function พอดี ข้อแลกเปลี่ยนคือโหมดนี้ไม่รองรับ feature ระดับ session ของ Postgres อย่าง prepared statement เพราะ connection ที่อยู่ข้างใต้อาจถูกส่งต่อไปให้ client คนอื่นระหว่าง transaction - Session mode (port
5432) — client หนึ่งจะได้ connection เฉพาะของตัวเองตลอดเวลาที่ต่ออยู่ เหมือนคุยกับ Postgres ตรง ๆ เป๊ะ ๆ สิ่งนี้ให้ feature ครบถ้วนทุกอย่าง รวมถึง prepared statement แต่ client ที่ต่ออยู่แต่ละตัวจะกิน slot หนึ่งใน pool ตลอดเวลาที่ยังต่ออยู่ จึงไม่ scale ไปถึง client อายุสั้นจำนวนมหาศาลพร้อมกันได้แบบที่ transaction mode ทำได้
# Transaction mode — best for serverless functions, Edge Functions, and other short-lived clientsexport DATABASE_URL="postgres://postgres.<project-ref>:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres"
# Session mode — for long-lived processes, or anything that specifically needs prepared statementsexport DIRECT_URL="postgres://postgres.<project-ref>:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:5432/postgres"พิสูจน์ว่า pooler เปิดอยู่จริง บน local
หัวข้อที่มีชื่อว่า “พิสูจน์ว่า pooler เปิดอยู่จริง บน local”local stack ของ Supabase CLI มาพร้อม [db.pooler] enabled = false โดย default — container ของ pooler มีอยู่ แต่ supabase start เฉย ๆ จะไม่มีมันให้ใช้ stack ของคอร์สนี้เปิดมันไว้:
$ grep -A6 '\[db.pooler\]' supabase/config.toml[db.pooler]enabled = true# Port to use for the local connection pooler.port = 55329pool_mode = "transaction"default_pool_size = 20max_client_conn = 100สังเกต port: ใน layout local แบบตำรามันจะเป็น 54329; stack ของคอร์สนี้บวก port ทุกตัวขึ้น 1000 (บันทึกไว้ใน README.md ของ repo เอง) ดังนั้น pooler local จริงอยู่ที่ 55329 ยืนยันสด ๆ:
$ docker ps --format '{{.Names}}\t{{.Ports}}' | grep poolersupabase_pooler_probe 0.0.0.0:55329->6543/tcptable tenant ของ Supavisor เอง query ตรง ๆ ยืนยันว่ามีแค่โหมดเดียวที่ตั้งไว้ให้ tenant นี้ — ไม่ใช่ transaction/session สองพอร์ตแยกแบบที่ hosted project มี:
$ docker exec -i supabase_db_probe psql -U postgres -d _supabase -c \ "select db_user, pool_size, mode_type from _supavisor.users;" db_user | pool_size | mode_type-----------+-----------+------------- pgbouncer | 20 | transactionควรใช้โหมดไหน
หัวข้อที่มีชื่อว่า “ควรใช้โหมดไหน”ให้ default เป็น transaction mode สำหรับอะไรก็ตามที่เป็น serverless หรืออายุสั้น เช่น Edge Function, backend ของแอปส่วนใหญ่ที่รันบน serverless platform และ API route ที่เปิด connection ต่อ request เพราะเป็นโหมดที่ถูกสร้างมาสำหรับ traffic รูปแบบนั้นโดยตรง
ให้ใช้ session mode เฉพาะตอนที่คุณต้องการ feature ระดับ session จริง ๆ เช่น prepared statement, LISTEN/NOTIFY หรือ process ฝั่ง server อายุยืน (backend แบบดั้งเดิมที่มี connection pool ของตัวเองอยู่แล้ว หรือ migration tool) ที่ต่ออยู่ตลอดและได้ประโยชน์จาก connection เฉพาะตัวมากกว่า connection แบบ pool ถ้าไม่แน่ใจว่า framework หรือ ORM ของคุณต้องการอันไหน ให้เช็ก doc ของตัวเองเรื่อง pooler compatibility ก่อนเลือกโหมด — ORM บางตัวต้องการ session-mode URL สำหรับ migration และ transaction-mode URL สำหรับ query ตอน runtime
prepared statement พัง พิสูจน์ด้วย error จริง
หัวข้อที่มีชื่อว่า “prepared statement พัง พิสูจน์ด้วย error จริง”“transaction mode ไม่รองรับ prepared statement” พูดง่าย แต่เห็นมันพังจริงยาก — client เดียวบน pool ที่ load เบา ๆ มักได้ backend connection เดิมซ้ำโดยบังเอิญ แล้ว statement ก็ใช้ได้เฉย ๆ การบังคับให้เกิด contention จริงกับ pooler ของคอร์สนี้เอง (supavisor, pool_size = 20) reproduce error จริงได้:
$ docker exec -i supabase_db_probe psql "postgresql://postgres.pooler-dev:postgres@supabase_pooler_probe:6543/postgres" \ -c "PREPARE fnd_stmt2 AS select 2;"PREPARE
# ...มี 25 session พร้อมกันแย่ง pool 20 connection เดียวกันอยู่ระหว่างนี้...
$ docker exec -i supabase_db_probe psql "postgresql://postgres.pooler-dev:postgres@supabase_pooler_probe:6543/postgres" \ -c "EXECUTE fnd_stmt2;"ERROR: prepared statement "fnd_stmt2" does not existPREPARE กับ EXECUTE ออกไปผ่าน client connection เดียวกันเป๊ะ ๆ ไปที่ pooler — แต่ transaction mode คืน backend Postgres connection กลับ pool ทันทีหลังแต่ละ statement ที่ autocommit ดังนั้นภายใต้ contention จริงสำหรับ 20 backend นั้น EXECUTE ไปตกที่ backend คนละตัวกับที่ PREPARE รัน และ backend นั้นไม่เคยเห็น statement ถูก prepare เลย คู่ PREPARE/EXECUTE เดียวกันเป๊ะ กับ direct connection — ตัวแทน local ของ session-mode ที่บทนี้ใช้ข้างบน — ไม่พังเลย ไม่ว่า concurrency จะเท่าไหร่ เพราะ direct connection ถือ backend เดียวตลอดอายุของมัน:
$ docker exec -i supabase_db_probe psql -U postgres -c "PREPARE fnd_stmt3 AS select 3;" -c "EXECUTE fnd_stmt3;"PREPARE ?column?---------- 3นี่คือกลไกเดียวกับที่บท Edge Functions and the Database เอ่ยชื่อไว้ แต่ตามที่บทนั้นยอมรับเอง reproduce ไม่ได้ภายใต้ test local ที่ concurrency ต่ำ — การ reproduce ที่นี่ต้องมี contention จริงต่อ pool ไม่ใช่แค่หน่วงเวลาระหว่าง statement ซึ่งเป็นส่วนที่ขาดไป
Pool size: เพดานของใครกันแน่ที่กำลังถูกใช้
หัวข้อที่มีชื่อว่า “Pool size: เพดานของใครกันแน่ที่กำลังถูกใช้”ทุกค่าข้างบนมาจากแหล่งจริงที่ query ได้ ไม่ใช่การเดา — table _supavisor.users/_supavisor.tenants ของ Supavisor เองใน database _supabase:
pool_size = 20(default_pool_sizeในconfig.toml) — จำนวน backend connection ของ Postgres จริงที่ Supavisor เปิดค้างไว้ให้ tenant นี้ แชร์กันตาม client connection ที่เข้ามาmax_client_conn = 100— เพดานของ connection ฝั่ง client ที่ Supavisor เองจะรับ มากกว่า backend pool เยอะ ซึ่งเป็นประเด็นทั้งหมดของ pooling: client เยอะ แต่ Postgres connection จริงน้อยmax_connectionsของ Postgres เอง (ตั้งค่าระดับ cluster ไม่เกี่ยวกับตัวเลขสองตัวข้างบนเลย) คือเพดานแข็งจริงที่ทุกอย่างข้างบนกำลังป้องกันอยู่ — pool ของ Supavisor มีไว้เพื่อให้ traffic serverless ที่พุ่งขึ้นมาใช้ budget 100 client ของตัวเองหมดก่อนที่จะไปถึง Postgres จริงได้เลย
pgbouncer และทำไมชื่อนี้ถึงยังโผล่มา
หัวข้อที่มีชื่อว่า “pgbouncer และทำไมชื่อนี้ถึงยังโผล่มา”ก่อนมี Supavisor pooler ของ Supabase ฝั่ง hosted คือ PgBouncer ตรง ๆ Supavisor rollout ช่วงกันยายน–ตุลาคม 2023 (เปิดให้ project ใหม่ตั้งแต่ 27 กันยายน 2023 และเปิดให้ทุก project ภายใน 15 ตุลาคม 2023) ส่วน PgBouncer ถูกประกาศ deprecated อย่างเป็นทางการวันที่ 15 มกราคม 2024 บน platform hosted Supavisor คือ pooler ที่เขียนขึ้นใหม่ทั้งหมด (ด้วย Elixir ตาม repo ของมันเอง) สร้างมาเฉพาะเพื่อรองรับ scale และ multi-tenancy ที่ platform hosted เดียวที่แชร์กันต้องการ รวมถึง feature ที่ PgBouncer เดี่ยว ๆ ไม่มีให้ — แต่มันจงใจพูด wire protocol แบบ compatible กับ PgBouncer ใกล้พอที่ tooling เดิมที่สร้างมาคู่กับ PgBouncer จะยังใช้งานได้ compatibility layer นั้นแหละคือเหตุผลที่ query จริงข้างบนแสดงว่า Supavisor ต่อเข้า Postgres ด้วยชื่อ role ตัวอักษรจริง ๆ ว่า pgbouncer — ซากมรดกจากที่มา ไม่ใช่สัญญาณว่า PgBouncer ยังแอบรันอยู่ข้างใต้
IPv4, IPv6 และทำไมเรื่องนี้ถึงสำคัญกับการเลือก pooler
หัวข้อที่มีชื่อว่า “IPv4, IPv6 และทำไมเรื่องนี้ถึงสำคัญกับการเลือก pooler”ตามเอกสาร pooling and limits ของ Supabase เอง: direct connection เข้า Postgres แบบ hosted ต้องการ IPv6 (หรือ add-on IPv4 แบบเสียเงิน) — environment serverless และ CI จำนวนมากยังมีแค่ IPv4 egress เท่านั้น ซึ่งเป็นเหตุผลตรงที่ทำให้พวกมันข้าม pooling ไปต่อตรงไม่ได้เลยแม้จะเป็นแค่ connection อายุยืนตัวเดียว Supavisor (shared pooler ที่บทนี้พิสูจน์มาตลอด) ถูกระบุไว้ชัดเจนว่าเป็น IPv4-only ซึ่งตรงกับเหตุผลที่ทำให้ session mode ของมันเป็น “ทางเลือกตอน session พวกนั้นต้องการ IPv4” ตามเอกสารเดียวกัน — ไม่ใช่แค่ workaround สำหรับ limit จำนวน connection แต่บางครั้งเป็นตัวเลือกเดียวที่เข้าถึงได้ผ่าน IPv4 สำหรับ workload ที่ต้องการพฤติกรรมระดับ session เลย dedicated pooler แบบ PgBouncer (เสียเงิน รันคู่กับ database ของคุณ) รองรับ IPv6 หรือ IPv4-กับ-add-on เหมือนกับ direct connection
flowchart LR
subgraph short["Many short-lived clients"]
e1["Edge Function"]
e2["Serverless request"]
e3["Serverless request"]
end
subgraph long["Few long-lived clients"]
s1["Long-lived server process"]
s2["Tool needing prepared statements"]
end
e1 --> tx["Supavisor - transaction mode, port 6543"]
e2 --> tx
e3 --> tx
tx --> pool[("Small shared pool of Postgres connections")]
s1 --> sess["Supavisor - session mode, port 5432"]
s2 --> sess
sess --> dedicated[("One dedicated Postgres connection per client")] อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”AI assistant ถูกขอให้ “connect our serverless API to Supabase” แล้วสร้างสิ่งนี้:
🐛 หา bug ในโค้ดนี้
// An AI assistant's suggested connection setup for a serverless API routeimport postgres from 'postgres';
const sql = postgres(process.env.DIRECT_URL!, { prepare: true });
export async function getWidget(id: string) {return sql`select * from fnd_widgets where id = ${id}`;}เฉลย
สองข้อผิดพลาดที่ซ้อนกัน ทั้งคู่มองไม่เห็นจนกว่า traffic จริงจะมา อย่างแรก DIRECT_URL คือชื่อ convention ของ codebase นี้เองสำหรับ connection string แบบ session-mode / direct (ตัวที่ ORM บางตัวต้องการเฉพาะสำหรับ migration) การใช้มันเป็น runtime connection ของ serverless API route แปลว่าทุก invocation แย่งกันใช้ backend connection เฉพาะตัว ตรงปัญหาทรัพยากรขาดแคลนที่บทนี้เปิดเรื่องไว้เป๊ะ ๆ และมันจะทำให้ connection limit จริงของ Postgres หมดภายใต้ burst ของ concurrent request จริงใด ๆ อย่างที่สอง แม้จะสลับไปใช้ pooler URL แบบ transaction-mode ก็ยังไม่พอ: default ของ driver ที่ prepare: true คือ setting เดียวกับที่พิสูจน์ไว้ข้างบนว่าพังแบบคาดเดาไม่ได้ภายใต้ transaction-mode pooling เมื่อ load concurrent จริง — ไม่ใช่ demo traffic ต่ำแบบระมัดระวังของบทนี้ — เริ่มกระจาย statement ของ client ไปคนละ backend connection ทางแก้ต้องทำทั้งคู่: ชี้ serverless/edge code ไปที่ pooler URL แบบ transaction-mode และใส่ prepare: false (หรือของเทียบเท่าใน driver ของคุณ) combination เดียวกับที่ module Edge Functions พิสูจน์ว่าใช้ได้จริงกับ pooler ตัวเดียวกันนี้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”pooler ไม่ใช่ cache และไม่ใช่ proxy ที่ทำให้ Postgres เร็วขึ้น — มันคือ ห้องรอที่แลกความขาดแคลนแบบหนึ่งกับอีกแบบหนึ่ง direct connection ขาดแคลน (max_connections ของ Postgres เอง) ส่วน pooled client slot ค่อนข้างเหลือเฟือเมื่อเทียบกัน (max_client_conn = 100 ที่นี่ พิสูจน์ข้างบน) transaction mode ซื้อความเหลือเฟือนั้นมาด้วยการคืน backend connection จริงทันทีที่ transaction จบ ซึ่งเป็นเหตุผลตรงที่ทำให้อะไรก็ตามที่พึ่งพา state ที่อยู่รอด ข้าม transaction บน backend เดียวกัน — prepared statement, session variable, LISTEN/NOTIFY — ไม่สามารถสมมติว่าใช้ได้เลย พิสูจน์ข้างบนแล้วด้วย error จริง ไม่ใช่สมมติฐาน
Trade-offs
หัวข้อที่มีชื่อว่า “Trade-offs”- Transaction mode ให้ pool เล็ก ๆ ตายตัวของ Postgres connection จริง รองรับ client อายุสั้นพร้อมกันได้มากกว่ามาก — แลกมาด้วย state ระดับ session ไม่รอดข้าม statement พิสูจน์ข้างบนแล้ว: prepared statement ที่สร้างใน transaction หนึ่งหายไปแล้วตอน transaction ถัดไป
- direct connection (ตัวแทน local ของ session mode ในบทนี้) ให้ feature ของ Postgres ครบทุกอย่าง รวมถึง prepared statement — แลกมาด้วยการกิน backend connection ที่ขาดแคลนจริงของ Postgres ไว้หนึ่งตัวตลอดเวลาที่มันเปิดอยู่ ทรัพยากรตัวเดียวกันเป๊ะ ๆ ที่บทนี้เปิดเรื่องไว้ว่ามีจำกัด
- wire protocol แบบ compatible กับ PgBouncer ของ Supavisor ทำให้ tooling เดิมยังใช้งานได้โดยแก้น้อยที่สุด — แลกมาด้วยรายละเอียดที่งงเมื่อดูจริง ๆ: ชื่อ role
pgbouncerในpg_stat_activity/_supavisor.usersพิสูจน์ข้างบนแล้ว ซึ่งไม่เกี่ยวอะไรกับ PgBouncer ที่กำลังรันจริงเลย
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Transaction mode ก็แค่ default ที่ช้ากว่าและปลอดภัยกว่า — session mode ดีกว่าเสมอถ้าจ่ายไหว” ถูกหักล้างด้วยเลขคณิตของ pool size ข้างบน: “ดีกว่า” ของ session mode แลกมาด้วยการกิน backend connection ที่ขาดแคลนจริงหนึ่งตัวต่อ client ตลอดเวลาที่ client นั้นต่ออยู่ ซึ่งตรงกับสิ่งที่ไม่ scale กับรูปแบบ traffic แบบ serverless เป๊ะ ๆ
- “local stack นี้พิสูจน์ session mode ได้ด้วย เพราะแค่ใช้ direct connection ก็พอ” บอกตามตรงไว้ข้างบน: local CLI stack ตั้งค่า Supavisor ด้วย
pool_modeเดียวในแต่ละครั้ง (transactionที่นี่) — ไม่มี port session-mode ของ Supavisor local ให้ test จริง direct connection เป็นตัวแทนที่สมเหตุสมผลของ พฤติกรรม ไม่ใช่ code path เดียวกันเป๊ะที่ port session-mode ของ hosted pooler รันจริง - “IPv4/IPv6 สำคัญแค่กับ infrastructure เก่า ๆ เท่านั้น” cite จากเอกสารของ Supabase เองข้างบน: direct connection เข้า Postgres แบบ hosted ต้องการ IPv6 (หรือ add-on เสียเงิน) วันนี้ ซึ่งเป็นเหตุผลตรงที่ session mode แบบ IPv4-only ของ Supavisor ถูกระบุว่าเป็นทางเลือกจริงและปัจจุบันสำหรับ environment ที่มีแค่ IPv4 — ไม่ใช่เรื่อง legacy