- Postgres में job queue चलाते समय आने वाली पुरानी समस्या dead tuple accumulation और उससे होने वाले table bloat, performance degradation के कारणों और समाधान का सार
- queue table में ज़्यादातर rows insert-read-delete के छोटे चक्र से गुजरती हैं, इसलिए आकार लगभग स्थिर रहता है लेकिन cumulative throughput बहुत बड़ा होता है
- Postgres की MVCC संरचना में deleted rows तुरंत हटती नहीं हैं, बल्कि dead tuple के रूप में बची रहती हैं और इन्हें साफ़ करना पड़ता है; यह काम autovacuum करता है
- अगर long-running transactions या overlap होने वाली analytics queries MVCC horizon को स्थिर कर दें, तो autovacuum dead tuples साफ़ नहीं कर पाता और queue performance गिरने लगती है
- PlanetScale का Traffic Control फीचर (Insights extension का हिस्सा) query class के हिसाब से resource limit लगाकर इस समस्या का व्यावहारिक समाधान देता है
queue workload की विशेषताएँ
- queue table की सबसे अलग विशेषता यह है कि ज़्यादातर rows transient होती हैं — insert होती हैं, एक बार पढ़ी जाती हैं, फिर delete हो जाती हैं
- table का size लगभग स्थिर रहता है, लेकिन cumulative throughput बहुत विशाल होता है
- Postgres में job queue रखने का बड़ा फ़ायदा यह है कि job state और बाकी DB logic को एक ही transaction में sync किया जा सकता है
- job fail हो जाए तो पूरा transaction rollback हो जाता है
- external queue service इस्तेमाल करने पर application की transaction state के साथ sync रखना काफ़ी जटिल हो जाता है
उदाहरण queue table और worker behavior
- लेख में दिया गया basic schema
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 transaction खोलकर सबसे पुराना pending job
FOR UPDATE SKIP LOCKED से lock करता है ताकि duplicate processing न हो
- काम सफल होने पर
DELETE के बाद COMMIT, और failure पर rollback होने से वह row फिर किसी दूसरे worker को दिख जाती है
- इस transaction को जहाँ तक संभव हो, छोटा रखना चाहिए — यह जितनी देर खुला रहेगा, vacuum को उतना रोकेगा (लेख में उदाहरण sub-millisecond workers का है)
समस्या performance capability नहीं है
- यह पहले से documented है कि Postgres large-scale job queue संभाल सकता है; उसकी capability खुद समस्या नहीं है
- असली समस्या है उसी DB में टकराने वाले दूसरे workloads के साथ coexist करना
- queue table की सेहत सिर्फ़ उसकी अपनी settings पर नहीं, बल्कि उसी Postgres instance में चल रहे सभी transactions के behavior पर निर्भर करती है
- लेख का फ़ोकस primary पर होने वाले competing query traffic पर है (replica और replication slot का असर अलग विषय है)
असली समस्या: dead tuple cleanup
- Postgres MVCC की वजह से एक ही row के कई versions रखता है — deleted row तुरंत हटती नहीं, बल्कि delete mark होकर नए transactions के लिए invisible state में रहती है
- ऐसे बचे हुए rows ही dead tuples हैं और इन्हें vacuum operation से साफ़ किया जाता है
- dead tuples
SELECT result में नहीं दिखते, फिर भी वे cost पैदा करते रहते हैं
- Sequential scan: executor heap page से dead tuple पढ़ता है, visibility check करता है, फिर उसे discard करता है
- Index scan (queue में
ORDER BY run_at LIMIT 1 वाला pattern): B-tree index dead tuple references जमा करता रहता है, इसलिए scan को उन entries से भी गुजरना पड़ता है जो अब visible rows की ओर इशारा नहीं करतीं
- हर dead index entry extra I/O कराती है, और application को दिखे बिना dead tuples की संख्या के साथ cost काफ़ी बढ़ सकती है
- cleanup cycle
autovacuum_naptime (default 1 minute) से चलती है, और execution autovacuum_vacuum_threshold तथा autovacuum_vacuum_scale_factor पर निर्भर करता है
dead tuple का internal mechanism
- row metadata के 3 हिस्से अहम हैं
ctid: heap में tuple की physical location (page, offset)
xmin: वह transaction ID (XID) जिसने row insert की
xmax: वह transaction ID जिसने row delete/lock की; 0 होने पर delete mark नहीं है
- pending 3 records लाने वाली query में भी executor पहले delete हो चुके 6 dead tuples scan करने के बाद ही 3 rows return कर सकता है
- index में भी अगर leaf entry dead heap tuple की तरफ़ इशारा कर रही हो, तो scan के दौरान होने वाला व्यर्थ काम लगातार बढ़ता जाता है
- अगर DB में dead tuple बनने की रफ़्तार cleanup की रफ़्तार से आगे निकल जाए, तो सिस्टम failure trajectory में प्रवेश कर सकता है
- अच्छी तरह tuned Postgres cluster प्रति सेकंड हज़ारों-लाखों नहीं तो दसियों हज़ार queue operations तक संभाल सकता है
जब autovacuum बेअसर हो जाता है
- autovacuum के dead tuple cleanup में विफल होने की मुख्य वजहें
- किसी खास table lock का cleanup रोकना
- गलत autovacuum settings
- और सबसे आम, active transactions का dead tuple reclaim रोकना
- Postgres ऐसे dead tuples को vacuum नहीं करता जो किसी active transaction को अभी भी दिख सकते हों
- सबसे पुराना active transaction cutoff तय करता है → यही MVCC horizon है
- उस transaction के ख़त्म होने तक, उसके snapshot के बाद वाले सभी dead tuples बने रहते हैं
- एक 2-minute single transaction पूरे 2 मिनट horizon को रोककर रख सकता है
- यही failure pattern एक-दूसरे पर overlap करने वाली medium-length queries से भी बन सकता है
- उदाहरण: 40-second analytics queries की 3 copies अगर 20-second अंतर पर staggered चलाई जाएँ, तो कोई एक query timeout नहीं होगी, लेकिन हमेशा एक query active रहेगी, इसलिए horizon आगे नहीं बढ़ पाएगा
- “Just use Postgres” सोच के तहत कई workloads एक ही DB में रखने पर समस्या तेज job handling नहीं, बल्कि overlap होने वाली slow queries की वजह से dead tuple cleanup का पीछे छूटना है
मौजूदा tools और उनकी सीमाएँ
- autovacuum tuning options:
autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
- long-running queries को सीमित करने के लिए timeouts
statement_timeout (Postgres 7.3): तय समय से लंबी चलने वाली individual SQL statement को terminate करता है
idle_in_transaction_session_timeout (9.6): transaction के अंदर idle बनी रहने वाली session को बंद करता है
transaction_timeout (17.0): active या inactive transaction तय समय पार करने पर terminate करता है
- ये timeouts सिर्फ़ single query execution time को target करते हैं; concurrency या execution cost को सीमित नहीं करते, इसलिए ऐसे workload को रोकने में उपयुक्त नहीं हैं जो लगातार MVCC horizon को pinned रखता है
- ज़रूरत है traffic classes को अलग-अलग संभालने की, ताकि high-priority workloads वैसे ही चलते रहें और low-priority workloads का resource usage नियंत्रित किया जा सके
Database Traffic Control™
- PlanetScale द्वारा विकसित Insights extension का हिस्सा, और केवल PlanetScale Postgres के लिए उपलब्ध फीचर
- इसका उपयोग individual queries की performance और resource usage को बारीकी से नियंत्रित करने के लिए किया जाता है
- Resource Budget के ज़रिए target queries पर resource limit लगाई जा सकती है → limit पार होने पर query block की जा सकती है
- समाधान रणनीति यह है कि overlap होने वाली slow queries की concurrency और frequency को सीमित किया जाए, ताकि autovacuum को dead tuples साफ़ करने के लिए पर्याप्त breathing room मिले
- block की गई queries को स्थायी रूप से reject नहीं, बल्कि retry होना चाहिए; इसलिए application में retry logic ज़रूरी है
- यह approach कुल workload को बनाए रखते हुए execution speed को smooth करती है
demo setup और पृष्ठभूमि
- इस लेख की प्रेरणा 2015 में Brandur Leach के ब्लॉग "Postgres Job Queues & Failure By MVCC" से ली गई है
- उसमें Postgres-based job queue का एक गंभीर failure mode दर्ज किया गया था
- साथ ही ऐसा test bench भी था जो दिखाता है कि unclosed transaction कैसे MVCC horizon को रोककर cleanup बंद कर देता है
- मूल test bench
brandur/que-degradation-test के रूप में सार्वजनिक है
समस्या का reproduction (Postgres 18 के आधार पर)
- मूल test Ruby + Que gem v0.x + Postgres 9.4 पर आधारित था
- लेखक ने SQL-level behavior को अलग से verify करने के लिए इसे TypeScript + Bun में दोबारा लिखा
- Que जैसा ही recursive CTE pattern, वही schema, producer rate, work duration, worker count, और long-runner pattern रखा गया
- इसे PlanetScale PS-5 cluster पर चलाया गया (शुरुआत $5/माह से)
- परिणाम: दिखने लायक लेकिन संभालने योग्य performance degradation
- मूल test 15 मिनट में DB को death spiral में धकेल देता था, लेकिन PS-5 पर 15 मिनट तक worker queue लगभग 0 के पास रही
- फिर भी dead tuples linear रूप से बढ़ते रहे, जिससे साफ़ था कि और समय मिलने पर वही समस्या वापस आ सकती है
- B-tree cleanup में सुधार (version churn के लिए bottom-up deletion, scan-based dead index tuple removal आदि) की वजह से यह कम हुआ है, खत्म नहीं
सुधार का प्रयास: SKIP LOCKED + batch processing
- 2015 में उपलब्ध न रहे 2 आधुनिक सुधार
FOR UPDATE SKIP LOCKED — पूरे recursive CTE को एक single SELECT से बदला गया, और दूसरे workers द्वारा locked rows को skip किया गया
- Batch processing (प्रति transaction 10 jobs) — एक बार lock acquisition में 10 jobs process करके index scan cost को amortize किया गया
- समान conditions: worker 8, producer 50 jobs/sec, work 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 |
- degradation curve लगभग एक जैसी रही — दोनों तरीके वही B-tree index scan करते हैं और उसी dead tuple समस्या से टकराते हैं
- throughput का अंतर lock strategy की वजह से नहीं, बल्कि test design की वजह से है (CTE workers producer से तेज़ job उठा लेते हैं, जबकि batch workers queue खाली होने पर backoff sleep में चले जाते हैं)
- निष्कर्ष: 10 साल पहले जो queue design 15 मिनट में DB को गिरा देती थी, अब उससे ज़्यादा देर टिकती है, लेकिन मूल समस्या अब भी बनी हुई है — 500 jobs/sec पर यह और जल्दी दोहराई जा सकती है
Traffic Control से समाधान
- Resource Budget द्वारा दिए गए नियंत्रण के साधन
- Server share & burst limit: server resources का अनुपात और consumption rate
- Per-query limit: server usage के आधार पर query को सेकंड में मिलने वाला execution time
- Maximum concurrent workers: उपलब्ध worker processes के अनुपात के रूप में limit
- queries को target करना मुख्यतः SQLCommenter tags के metadata से किया जाता है (जैसे
action=analytics)
idle_in_transaction_session_timeout से पकड़ में आने वाले long-runner की बजाय, degradation पैदा करने के लिए ज़्यादा realistic scenario लिया गया: active work कर रही overlapping analytics queries (जिसे session timeout से नहीं पकड़ा जा सकता)
action=analytics queries के Maximum concurrent workers को 1 worker (max_worker_processes का 25%) तक सीमित किया गया → यानी एक समय में सिर्फ़ 1 analytics query
- 15-minute window में death spiral induce करने के लिए producer rate को 800 jobs/sec तक बढ़ाया गया
- EC2 से उसी PlanetScale DB पर "enhanced" workload दो बार चलाया गया
- 800 jobs/sec
- 120-second analytics queries की 3 instances एक साथ, लगातार overlap बनाए रखने के लिए staggered scheduling के साथ
- 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 (cycling) |
| Analytics queries |
3 concurrent, overlapping |
1 at a time, 2 retrying |
| VACUUM effectiveness |
Blocked (horizon pinned) |
Normal (queries के बीच cleanup window उपलब्ध) |
| Outcome |
Death spiral |
Completely stable |
- Traffic Control किसी खास workload की concurrency को सीधे सीमित करता है, और ऐसा नियंत्रण देता है जो autovacuum tuning या timeouts से संभव नहीं था
- analytics reports capacity की सीमा में चलते रहे और 15 मिनट में 15 runs complete हुए, जबकि queue पूरे समय स्वस्थ रही
निष्कर्ष
- Postgres-based queue की MVCC dead tuple समस्या 2015 की पुरानी बात नहीं है
- आधुनिक Postgres में B-tree improvements और
SKIP LOCKED की वजह से काफ़ी breathing room मिली है, लेकिन मूल mechanism वही है
- अगर VACUUM dead tuples साफ़ नहीं कर पाए, तो वे जमा होते रहेंगे
- अगर long-running या overlapping transactions MVCC horizon को रोक दें, तो VACUUM cleanup नहीं कर पाएगा
- “Just use Postgres” वाले environment में, जहाँ queue, analytics और app logic एक ही DB में चलते हैं, यह सिर्फ़ सैद्धांतिक जोखिम नहीं बल्कि सामान्य operating condition है
- ख़तरनाक रूप dramatic crash नहीं, बल्कि धीरे-धीरे खराब होने वाली steady state है — lock time धीरे बढ़ता है, jobs धीमे होते हैं, लेकिन alarm नहीं बजता
- Postgres के timeout tools workload class separation या concurrency limiting नहीं कर सकते
- अगर queue को दूसरे workloads के साथ चलाना है, तो सबसे असरदार उपाय यह सुनिश्चित करना है कि VACUUM पीछे न छूटे, और Traffic Control इसे सरल बनाता है
अभी कोई टिप्पणी नहीं है.