- สรุปสาเหตุและแนวทางแก้ปัญหาของ การสะสม dead tuple ซึ่งเป็นปัญหาเรื้อรังเมื่อรัน job queue บน Postgres รวมถึง table bloat และประสิทธิภาพที่ลดลงตามมา
- ตารางคิวมักมีแถวส่วนใหญ่หมุนเวียนแบบ insert-read-delete ในช่วงเวลาสั้น ๆ จึงมีขนาดค่อนข้างคงที่ แต่มี ปริมาณงานสะสมสูงมาก
- ด้วยโครงสร้าง MVCC ของ Postgres แถวที่ถูกลบจะไม่หายไปทันที แต่จะคงอยู่เป็น dead tuple และต้องถูกเก็บกวาด โดย autovacuum เป็นผู้ทำหน้าที่นี้
- หากมีทรานแซกชันที่รันนานหรือ query วิเคราะห์ที่รันทับซ้อนกันจน MVCC horizon ถูกตรึงไว้ autovacuum จะไม่สามารถเก็บกวาด dead tuple ได้ ทำให้ประสิทธิภาพของคิวลดลง
- ฟีเจอร์ Traffic Control ของ PlanetScale (ส่วนขยายของ Insights) ถูกเสนอเป็นวิธีแก้เชิงปฏิบัติด้วยการ จำกัดทรัพยากรตามคลาสของ query
ลักษณะของงานแบบคิว
- จุดเด่นของตารางคิวคือแถวส่วนใหญ่มีอายุ ชั่วคราว (transient) — ถูก insert, ถูกอ่านหนึ่งครั้ง แล้วถูกลบ
- ขนาดตารางแทบคงที่ แต่ throughput สะสมมหาศาล
- ข้อดีหลักของการวาง job queue ไว้ใน Postgres คือสามารถ ซิงก์สถานะของ job กับตรรกะ DB อื่น ๆ ภายในทรานแซกชันเดียวกัน ได้
- หาก job ล้มเหลว ทรานแซกชันทั้งหมดจะ rollback
- หากใช้บริการคิวภายนอก การซิงก์กับสถานะทรานแซกชันของแอปพลิเคชันจะซับซ้อนขึ้น
ตัวอย่างตารางคิวและการทำงานของ worker
- สคีมาพื้นฐานที่ยกมาในบทความ
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
run_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'pending',
payload JSONB
);
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';
- worker จะเปิดทรานแซกชันแล้วล็อก pending job ที่เก่าที่สุด ด้วย
FOR UPDATE SKIP LOCKED เพื่อป้องกันการประมวลผลซ้ำ
- ถ้างานสำเร็จจะ
DELETE แล้ว COMMIT ถ้าล้มเหลวจะ rollback ทำให้แถวนั้นกลับไปมองเห็นได้สำหรับ worker ตัวอื่น
- ควรรักษาทรานแซกชันนี้ให้ สั้นที่สุดเท่าที่ทำได้ — ยิ่งเปิดค้างนานยิ่งขัดขวาง vacuum (ตัวอย่างในบทความอิง worker ระดับต่ำกว่า 1 มิลลิวินาที)
ปัญหาไม่ได้อยู่ที่ประสิทธิภาพล้วน ๆ
- มีการบันทึกไว้แล้วว่า Postgres สามารถรองรับ job queue ขนาดใหญ่ ได้ ดังนั้นความสามารถของระบบไม่ใช่ประเด็น
- ปัญหาที่แท้จริงคือ การอยู่ร่วมกับงานประเภทอื่นที่แย่งทรัพยากรบน DB เดียวกัน
- สุขภาพของตารางคิวไม่ได้ขึ้นกับการตั้งค่าของตัวมันเองเท่านั้น แต่ขึ้นกับ พฤติกรรมของทุกทรานแซกชันที่รันอยู่บน Postgres instance เดียวกัน
- บทความนี้โฟกัสที่ query traffic ที่แข่งขันกันบน primary (ผลกระทบจาก replica และ replication slot เป็นอีกประเด็นหนึ่ง)
ปัญหาจริง: การเก็บกวาด dead tuple
- Postgres ใช้ MVCC เพื่อเก็บหลายเวอร์ชันของแถวเดียวกัน — แถวที่ถูกลบจะไม่ถูกเอาออกทันที แต่จะถูก ทำเครื่องหมายว่าลบแล้ว และยังคงอยู่ในสถานะมองไม่เห็นสำหรับทรานแซกชันใหม่
- แถวที่คงอยู่นี้คือ dead tuple และจะถูกเก็บกวาดด้วย การทำงานของ vacuum
- แม้ dead tuple จะไม่ปรากฏในผล
SELECT แต่ก็ยัง สร้างต้นทุน
- Sequential scan: ตัว executor ต้องอ่าน dead tuple จาก heap page ตรวจสอบ visibility แล้วค่อยทิ้ง
- Index scan (แบบที่ใช้ในคิวด้วย
ORDER BY run_at LIMIT 1): ดัชนี B-tree จะสะสมการอ้างอิงไปยัง dead tuple ทำให้ต้องไล่อ่านรายการที่ชี้ไปยังแถวที่มองไม่เห็นแล้วด้วย
- dead index entry แต่ละรายการทำให้เกิด I/O เพิ่มเติม แม้แอปจะมองไม่เห็น แต่ต้นทุนจะเพิ่มขึ้นมากตามจำนวน dead tuple
- รอบการเก็บกวาดถูกกำหนดโดย
autovacuum_naptime (ค่าเริ่มต้น 1 นาที) และการตัดสินใจว่าจะรันหรือไม่ขึ้นกับ autovacuum_vacuum_threshold กับ autovacuum_vacuum_scale_factor
กลไกภายในของ dead tuple
- มี metadata ของแถว 3 อย่างที่สำคัญ
ctid: ตำแหน่งทางกายภาพของ tuple ใน heap (page, offset)
xmin: transaction ID (XID) ที่ insert แถวนั้น
xmax: transaction ID ที่ลบ/ล็อกแถวนั้น ถ้าเป็น 0 แปลว่ายังไม่มีการทำเครื่องหมายลบ
- แม้จะดึง pending มาเพียง 3 รายการ ก็อาจเกิดกรณีที่ executor ต้องสแกน dead tuple ที่ถูกลบไปก่อนหน้าแล้วถึง 6 รายการก่อน แล้วจึง คืนผลเพียง 3 รายการ
- ฝั่งดัชนีก็เช่นกัน หาก leaf entry ชี้ไปยัง heap tuple ที่กลายเป็น dead แล้ว ก็จะเกิด งานสูญเปล่าระหว่างการสแกน สะสมขึ้น
- ถ้า DB เก็บกวาด dead tuple ได้ช้ากว่าอัตราที่มันถูกสร้าง ระบบจะเข้าสู่เส้นทางล้มเหลว
- Postgres cluster ที่จูนมาดีสามารถรองรับ throughput คิวได้ระดับหลายหมื่นรายการต่อวินาที
สถานการณ์ที่ autovacuum ใช้งานไม่ได้ผล
- สาเหตุหลักที่ทำให้ autovacuum เก็บกวาด dead tuple ไม่สำเร็จ
- มี table lock บางตัวขัดขวาง cleanup
- การตั้งค่า autovacuum ไม่เหมาะสม
- และที่พบบ่อยที่สุดคือ มี active transaction ขัดขวางการ reclaim dead tuple
- Postgres จะไม่ vacuum dead tuple ที่ ยังอาจมองเห็นได้สำหรับ active transaction
- active transaction ที่เก่าที่สุดจะเป็นตัวกำหนด cutoff → MVCC horizon
- จนกว่าทรานแซกชันนั้นจะจบ dead tuple ทั้งหมดหลัง snapshot นั้นจะยังต้องถูกเก็บไว้
- เพียงมีทรานแซกชันเดียวที่กินเวลา 2 นาที ก็สามารถ ตรึง horizon ไว้ 2 นาที
- รูปแบบความล้มเหลวเดียวกันนี้เกิดได้จาก query ระยะกลางหลายตัวที่รันทับซ้อนกัน
- ตัวอย่าง: query วิเคราะห์ 3 ตัวที่แต่ละตัวใช้เวลา 40 วินาที แต่เริ่มสลับกันทุก 20 วินาที แต่ละตัวอาจไม่ timeout เลย ทว่ามักจะมีอย่างน้อย 1 ตัว active อยู่เสมอ ทำให้ horizon เดินหน้าไม่ได้
- เมื่อยึดแนวคิด “Just use Postgres” แล้วเอาหลาย workload ไปรวมไว้ใน DB เดียว ปัญหาจึงไม่ใช่ความเร็วของการประมวลผล job แต่เป็น query ช้าที่รันทับกันจนการเก็บกวาด dead tuple ถูกดันออกไป
เครื่องมือเดิมและข้อจำกัด
- ตัวเลือกสำหรับจูน autovacuum:
autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
- timeout สำหรับจำกัด query ที่รันนาน
statement_timeout (Postgres 7.3): ยุติ SQL statement รายตัวที่เกินเวลาที่กำหนด
idle_in_transaction_session_timeout (9.6): ยุติเซสชันที่ idle ค้างอยู่ในทรานแซกชัน
transaction_timeout (17.0): ยุติทรานแซกชันทั้งที่ active หรือ inactive หากเกินเวลาที่กำหนด
- แต่ timeout เหล่านี้ มุ่งเป้าแค่เวลารันของ query เดี่ยว ไม่สามารถจำกัด concurrency หรือค่าใช้จ่ายในการรันได้ จึงไม่เหมาะกับการสกัด workload ที่ ตรึง MVCC horizon อย่างต่อเนื่อง
- สิ่งที่ต้องการจริง ๆ คือ การแยกตาม traffic class เพื่อคง workload ที่มีความสำคัญสูงไว้เหมือนเดิม และควบคุมเฉพาะอัตราการใช้ทรัพยากรของ workload ความสำคัญต่ำ
Database Traffic Control™
- เป็นฟีเจอร์เฉพาะของ PlanetScale Postgres ซึ่งเป็นส่วนหนึ่งของ Insights extension ที่ PlanetScale พัฒนา
- ใช้เมื่อจำเป็นต้องควบคุมประสิทธิภาพและการใช้ทรัพยากรของแต่ละ query อย่างละเอียด
- ด้วย Resource Budget สามารถกำหนดเพดานทรัพยากรให้ query เป้าหมาย และบล็อกได้หากใช้เกิน
- กลยุทธ์ในการแก้ปัญหาคือ จำกัดจำนวนและความถี่ของ query ช้าที่รันทับซ้อนกัน เพื่อเปิดช่องให้ autovacuum เก็บกวาด dead tuple ได้ในอัตราที่เหมาะสม
- query ที่ถูกบล็อกจะไม่ถูกปฏิเสธถาวร แต่จะต้อง retry ดังนั้นแอปจึงต้องมีตรรกะ retry
- เป็นแนวทางที่รักษาปริมาณงานรวมไว้ แต่ ทำให้ความเร็วในการประมวลผลเรียบขึ้น
การตั้งค่าเดโมและที่มา
- ไอเดียของบทความนี้มาจากบล็อกปี 2015 ของ Brandur Leach ชื่อ "Postgres Job Queues & Failure By MVCC"
- บันทึก failure mode ร้ายแรงของ job queue บน Postgres
- มี test bench ที่พิสูจน์เชิงประจักษ์ถึงปรากฏการณ์ที่ทรานแซกชันที่ไม่ถูกปิดไปตรึง MVCC horizon และขัดขวาง cleanup
- test bench ต้นฉบับเปิดเผยไว้ที่
brandur/que-degradation-test
การจำลองปัญหา (บน Postgres 18)
- การทดสอบต้นฉบับอิง Ruby + Que gem v0.x + Postgres 9.4
- ผู้เขียนเขียนใหม่เป็น TypeScript + Bun เพื่อแยกตรวจสอบพฤติกรรมในระดับ SQL โดยตรง
- คง รูปแบบ recursive CTE แบบเดียวกับ Que รวมถึง schema, producer rate, work duration, จำนวน worker และรูปแบบ long-runner เดิมไว้
- รันบน PlanetScale PS-5 cluster (เริ่มต้นที่ $5 ต่อเดือน)
- ผลลัพธ์: ประสิทธิภาพลดลงอย่างสังเกตได้ แต่ยังอยู่ในระดับจัดการได้
- การทดสอบต้นฉบับทำให้ DB เข้าสู่ death spiral ภายใน 15 นาที แต่บน PS-5 สามารถคง worker queue ใกล้ 0 ได้ตลอด 15 นาที
- อย่างไรก็ตาม dead tuple เพิ่มขึ้นแบบเส้นตรง บ่งชี้ว่าหากปล่อยนานกว่านี้ ปัญหาเดิมก็จะกลับมา
- ด้วยการปรับปรุงการเก็บกวาดดัชนี B-tree (เช่น bottom-up deletion สำหรับ version churn และการลบ dead index tuple ระหว่าง scan) ปัญหาจึง บรรเทาลง แต่ไม่ได้หายไป
ความพยายามปรับปรุง: SKIP LOCKED + การประมวลผลแบบ batch
- มีการปรับปรุงสมัยใหม่ 2 อย่างที่ไม่มีในปี 2015
FOR UPDATE SKIP LOCKED — แทนที่ recursive CTE ทั้งหมดด้วย SELECT ตัวเดียว และข้ามแถวที่ worker อื่นล็อกไว้
- Batch processing (10 jobs ต่อทรานแซกชัน) — ใช้การ acquire lock ครั้งเดียวเพื่อประมวลผล 10 รายการ ช่วย เฉลี่ย ต้นทุนการสแกนดัชนี
- เงื่อนไขยังเหมือนเดิม: worker 8 ตัว, producer 50 jobs/sec, งานใช้เวลา 10ms, long-runner เริ่มหลัง 45 วินาที
- ผลลัพธ์สำคัญ
| ตัวชี้วัด |
original (recursive CTE) |
enhanced (SKIP LOCKED + batch) |
| Baseline lock time |
2–3ms |
1.3–3.0ms |
| End lock time (typical) |
10–34ms |
9–29ms |
| Worst spike |
84.5ms (dead tuple 33k) |
180ms (dead tuple 24k) |
| Queue depth |
0–100 (oscillating) |
0 (เกือบทั้งหมด) |
| Dead tuples at end |
42,400 |
42,450 |
| Throughput |
~89/s |
~50/s |
- เส้นโค้งการเสื่อมสภาพแทบเหมือนกัน — เพราะทั้งสองวิธีต่างก็สแกนดัชนี B-tree เดียวกันและเจอ dead tuple แบบเดียวกัน
- ความต่างของ throughput ไม่ได้มาจากกลยุทธ์การล็อก แต่เกิดจาก การออกแบบการทดสอบ (worker แบบ CTE ดึง job ได้เร็วกว่าผู้ผลิต ส่วน worker แบบ batch จะเคลียร์คิวแล้ว backoff sleep)
- สรุปคือ ดีไซน์คิวที่เมื่อ 10 ปีก่อนทำให้ DB พังใน 15 นาที ทุกวันนี้ทนได้นานขึ้น แต่ ปัญหาพื้นฐานยังคงอยู่ — หากเพิ่มเป็น 500 jobs/sec ปัญหาจะยิ่งเกิดซ้ำเร็วขึ้น
แก้ด้วย Traffic Control
- เครื่องมือควบคุมที่ Resource Budget มีให้
- Server share & burst limit: สัดส่วนและอัตราการใช้ทรัพยากรของเซิร์ฟเวอร์
- Per-query limit: เวลาที่ query สามารถรันได้ วัดเป็นวินาทีตามการใช้ทรัพยากรของเซิร์ฟเวอร์
- Maximum concurrent workers: สัดส่วนเมื่อเทียบกับ worker process ที่มีอยู่
- การระบุ query เป้าหมายทำผ่าน metadata ใน แท็ก SQLCommenter เป็นหลัก (เช่น
action=analytics)
- แทนที่จะใช้ long-runner ที่โดนจับได้ด้วย
idle_in_transaction_session_timeout ผู้เขียนเลือกสร้างการเสื่อมสภาพด้วย query วิเคราะห์ที่ active และรันทับซ้อนกัน ซึ่งเป็นสถานการณ์ที่สมจริงกว่า (และ session timeout จับไม่ได้)
- จำกัด Maximum concurrent workers ของ query
action=analytics ไว้ที่ 1 worker (max_worker_processes 25%) → ให้รัน query วิเคราะห์พร้อมกันได้เพียง 1 ตัว
- เพื่อให้เกิด death spiral ภายในกรอบเวลา 15 นาที จึงเพิ่ม producer เป็น 800 jobs/sec
- รัน workload แบบ "enhanced" 2 รอบจาก EC2 ไปยัง PlanetScale DB เดียวกัน
- 800 jobs/sec
- query วิเคราะห์ 3 ตัวที่แต่ละตัวใช้เวลา 120 วินาที รันพร้อมกันแบบเหลื่อมเวลาเพื่อให้ทับซ้อนกันตลอด
- รันต่อเนื่อง 15 นาที
- เปรียบเทียบผลลัพธ์
| ตัวชี้วัด |
ปิด Traffic Control |
เปิด Traffic Control |
| Queue backlog |
155,000 jobs |
0 jobs |
| Lock time |
300ms+ |
2ms |
| Dead tuples at end |
383,000 |
0–23,000 (วนรอบ) |
| Analytics queries |
3 concurrent, overlapping |
ทีละ 1 ตัว, อีก 2 ตัวกำลัง retry |
| VACUUM effectiveness |
ถูกขัดขวาง (horizon ถูกตรึง) |
ปกติ (มีช่วงว่างระหว่าง query ให้เก็บกวาด) |
| Outcome |
Death spiral |
เสถียรอย่างสมบูรณ์ |
- Traffic Control จำกัด concurrency ของ workload เฉพาะได้โดยตรง ซึ่งเป็นการควบคุมที่การจูน autovacuum หรือการตั้ง timeout ทำไม่ได้
- รายงานเชิงวิเคราะห์ยังคงรันต่อไปภายในความจุที่ระบบรับไหว โดยสำเร็จ 15 งานใน 15 นาที และคิวยังคงมีสุขภาพดีตลอดเวลา
สรุป
- ปัญหา MVCC dead tuple ในคิวที่สร้างบน Postgres ไม่ใช่ของเก่าจากปี 2015
- Postgres สมัยใหม่ให้พื้นที่เผื่อมากขึ้นด้วย การปรับปรุง B-tree และ
SKIP LOCKED แต่ กลไกพื้นฐานยังเหมือนเดิม
- หาก VACUUM เก็บกวาด dead tuple ไม่ทัน มันก็จะสะสม
- หากทรานแซกชันที่รันนานหรือรันทับซ้อนกันตรึง MVCC horizon ไว้ VACUUM ก็จะเก็บกวาดไม่ได้
- ในสภาพแวดล้อมที่ใช้แนวคิด “Just use Postgres” แล้วรวมคิว งานวิเคราะห์ และตรรกะแอปไว้ใน DB เดียว นี่ไม่ใช่ ความเสี่ยงเชิงทฤษฎี แต่เป็น เงื่อนไขการปฏิบัติการตามปกติ
- รูปแบบอันตรายไม่ใช่การพังแบบฉับพลัน แต่เป็น สภาวะสมดุลที่ค่อย ๆ เสื่อมลงอย่างเงียบ ๆ — lock time ค่อย ๆ เพิ่ม, job ช้าลง และไม่มีสัญญาณเตือนดังขึ้น
- เครื่องมือ timeout ของ Postgres ไม่สามารถแยกคลาสของ workload หรือจำกัด concurrency ได้
- หากจะรันคิวร่วมกับ workload อื่น มาตรการที่ได้ผลที่สุดคือ ทำให้แน่ใจว่า VACUUM ตามทันได้ และ Traffic Control ช่วยให้เรื่องนี้ง่ายขึ้น
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
Postgres ยังคงมีปัญหา vacuum horizon อยู่ นี่คืออาการที่คิวรีที่รันนานไปขัดขวางการ vacuum ของตารางที่มีการเปลี่ยนแปลงอย่างรวดเร็ว ปัญหานี้เป็นที่รู้จักกันดีมาตั้งแต่ปี 2015 แล้ว Postgres แบบมาตรฐานไม่มีเครื่องมือดี ๆ สำหรับแก้ปัญหานี้ แต่เวอร์ชันคัสตอมของบริษัทผู้เขียนมีฟีเจอร์ที่จัดการได้ โดยสรุปแล้ว การผสมงานยาวแบบ OLAP กับงานเร็วแบบคิวไว้ใน Postgres อินสแตนซ์เดียวกันยังไม่ใช่ความคิดที่ดีเสมอไป ขึ้นอยู่กับความต้องการ การใช้ message queue อย่าง 0MQ หรือ RMQ อาจเป็นวิธีแก้ที่ง่ายกว่า
บทความใช้ได้ แต่มีบางจุดที่อยากทักท้วง
SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED;มีอยู่จริง แต่สามารถบรรเทาได้โดยเพิ่มคอลัมน์ที่มีค่าเพิ่มขึ้นแบบ monotonic แล้วสร้างดัชนีไว้ แบบนี้จะไม่ต้องสนใจ dead tuple และมีแค่เปลืองพื้นที่เท่านั้น โดยประสิทธิภาพการอ่านจะลดลงน้อยกว่า อย่างไรก็ตาม การรับประกันการเพิ่มขึ้นแบบ monotonic ในสถานการณ์ที่มีการเขียนพร้อมกันนั้นขึ้นอยู่กับการออกแบบแอปพลิเคชันผู้เขียนเอง ถ้ามีคำถามก็ถามมาได้เสมอ
มันดูคล้ายโฆษณา แต่คงจะดีถ้ามีคำอธิบายเกี่ยวกับแนวทางแก้ปัญหาเชิงเทคนิคมาบ้างสักหน่อย
Postgres ทำอะไรได้เยอะมาก ผู้คนมักเลือก Kafka หรือ SQS แต่จริง ๆ แล้วก็มีงานอีกมากที่ Graphile Worker จัดการได้สบาย
ใน Postgres ปัญหาจะร้ายแรงขึ้นมากเมื่อมีการอัปเดตแถว ถ้าใช้แค่การแทรกกับการลบก็ยังพออยู่ได้นานพอสมควร