लोड में data tier: PostgreSQL, कनेक्शन pooling, Redis और pgvector
ट्रैफ़िक का उछाल आए तो पहली प्रतिक्रिया डेटाबेस बड़ा करने की होती है, और वह अक्सर ग़लत निकलती है। जानिए write throughput से साइज़ कैसे तय करें, PgBouncer से कनेक्शन pool कैसे करें, Redis या Valkey को भूमिका के हिसाब से कैसे बाँटें, और checkout धीमा किए बिना pgvector search कैसे चलाएँ।
Author
Anichur Rahaman
2 सप्ताह पहले12 min read1 views
फ़्लैश सेल की सुबह 9:02 बजे, एक काल्पनिक दृश्य में, ऑन-कॉल इंजीनियर dashboard पर देखता है कि 1,000 में से 940 database connection इस्तेमाल हो रहे हैं और checkout तीन सेकंड से ऊपर खिंच रहा है। कोई सुझाव देता है कि दूसरी लहर आने से पहले database को अगले साइज़ पर चढ़ा दिया जाए।
यह अंदाज़े पर चलना होगा। उन 940 connections में से ज़्यादातर idle हैं, दो request के बीच PHP process ने उन्हें पकड़ रखा है, और database का CPU 35 प्रतिशत के आसपास है। असली कतार ग्यारह worker की है, जो उन्हीं चंद पंक्तियों पर write के लिए इंतज़ार कर रहे हैं। connection की गिनती सिर्फ़ लक्षण है, और साइज़ बढ़ाना लक्षण का इलाज करना है।
इसका असली रूप मैंने देखा है। एक प्लैटफ़ॉर्म पर मैंने ऐसे तय समय वाले उछाल की तैयारी की थी जिसमें लगभग 1,000 user एक ही मिनट में सक्रिय होते; वहाँ managed database (16 GB RAM, 4 vCPU, करीब 1,000 connection की सीमा) कभी रुकावट नहीं बना। रुकावट सिर्फ़ एक serial worker था। इस लेख में मैं data tier को उसी तरह रखूँगा जैसे आज उसकी योजना बनाता हूँ: क्या मापना है, pool कैसे करना है, replica क्या कर सकता है और क्या नहीं, Redis या Valkey को साफ़ ज़िम्मेदारी कैसे देनी है, और transactional database पर vector query डाले बिना AI search कैसे जोड़ना है।
यह "हाई-वॉल्यूम system की इंजीनियरिंग" सीरीज़ का तीसरा भाग है। पहले भाग में edge और web tier है और दूसरे में queue और worker। यहाँ हम एक परत और नीचे, data तक उतरते हैं।
connection की गिनती से नहीं, write throughput से साइज़ तय करें
"1,000 max connections" लिखा देखकर पता चलता है कि कितने क्लाइंट जुड़ सकते हैं, यह नहीं कि server कितना काम कर सकता है। चार vCPU एक ही पल में गिनती की query चला सकते हैं। बाक़ी सब इंतज़ार करते हैं, जुड़े हों या नहीं।
इसलिए वही मापिए जो रिलेशनल database को सचमुच भर देता है: write throughput (प्रति सेकंड commit और WAL की मात्रा), CPU, डिस्क की latency और lock का इंतज़ार। connection की गिनती सिर्फ़ किसी और समस्या का लक्षण है, जैसे धीमी query जो connection रोके रखती हैं।
मेरा नियम सीधा है: जब तक load test साबित न कर दे कि database भर चुका है, उसे अपग्रेड न करें। ऊपर वाले सेटअप में हल यह था कि web और worker node अलग किए गए और करीब 12 worker process चलाए गए। database ने अतिरिक्त लोड आराम से संभाल लिया। लगभग 20 worker के बाद job सिर्फ़ database write की कतार में खड़े रहते थे, और असली छत वही है, connection नहीं। ये आँकड़े एक ही सेटअप के हैं, कोई सार्वभौमिक benchmark नहीं; अपना test ख़ुद चलाइए।
एक tier, कई store, हर एक का एक ही काम और अपनी सीमाएँ।
connection pool करें
PHP बहुत सारे छोटे connection खोलता है: हर request, और हर queue job भी, जुड़ता और कटता है। PostgreSQL हर connection के लिए अलग process चलाता है, इसलिए एक हज़ार क्लाइंट एक भी काम की query चलाने से पहले काफ़ी मेमोरी खा सकते हैं। PgBouncer जैसा pooler एप्लिकेशन और database के बीच बैठता है। सैकड़ों क्लाइंट connection कुछ दर्जन असली server connections को साझा करते हैं।
एक काल्पनिक हिसाब। दो web node, हर एक में 60 PHP-FPM child, 120 connection खोल सकते हैं; 12 worker process 12 और जोड़ते हैं, और scheduler व कुछ admin session मिलाकर करीब 8। कुल करीब 140 connection, यानी 4 vCPU के लिए 140 PostgreSQL process की खींचतान। PgBouncer को transaction mode में रखकर 20 server connection का pool दें तो वही 140 क्लाइंट इन 20 को साझा करते हैं। अगर औसत transaction अपना connection 5 ms रोकता है, तो 20 connection ज़्यादा से ज़्यादा 20 ÷ 0.005 = 4,000 transaction प्रति सेकंड उठा सकते हैं। CPU आपको इससे काफ़ी नीचे रोक देगा, और यही मक़सद है: pool असली सीमा को connection की आवाजाही के नीचे छिपाने के बजाय सामने ला देता है।
PgBouncer के तीन pool mode हैं, और आप कौन-सा चुनते हैं उसी से तय होता है कि आपका app क्या कर पाएगा।
Pool mode
server connection रुका रहता है
कहाँ सबसे अच्छा
ध्यान रखें
Session
क्लाइंट connection के पूरे समय
लंबे समय तक चलने वाले listener, session state चाहने वाले टूल
सबसे कम बचत; idle क्लाइंट भी server connection पकड़े रहते हैं
Transaction
एक transaction के लिए
web request और queue job; high volume में आम पसंद
session state पर टिकी हर चीज़ टूट जाती है
Statement
एक statement के लिए
सरल, सिर्फ़ autocommit वाला काम
कई statement वाले transaction नहीं चलते
लोड में चल रहे web app के लिए transaction mode सबसे बड़ा फ़ायदा देता है। लेकिन स्विच करने से पहले इसकी कुछ सीमाएँ जान लेना ठीक रहेगा।
Transaction mode में क्या टूटता है
एक ही क्लाइंट का अगला transaction किसी दूसरे server connection पर जा सकता है, इसलिए session में रखी state पर भरोसा नहीं किया जा सकता:
Session-level setting। साधारण SET आगे नहीं टिकता; transaction के अंदर SET LOCAL टिकता है।
Session-level advisory lock और LISTEN/NOTIFY। इनके लिए direct connection या session mode इस्तेमाल करें।
Temporary table और WITH HOLD cursor जो transaction के बाद भी जीवित रहते हैं।
Prepared statement। PgBouncer के पुराने वर्ज़न transaction mode में इन्हें नहीं सँभाल पाते थे। वर्ज़न 1.21 से, max_prepared_statements विकल्प शून्य से ऊपर रखने पर PgBouncer इन्हें ट्रैक कर सकता है। मानकर न चलें, अपना वर्ज़न और ड्राइवर जाँचें।
व्यावहारिक हल है दो connection रास्ते: app के लिए pooled रास्ता, और migration, schema टूल व हर उस चीज़ के लिए direct रास्ता जो सुनती रहती है। pool size और timeout समेत सारे विकल्प PgBouncer configuration reference में हैं।
Replica: क्या ठीक करता है, और क्या primary पर ही रहेगा
Read replica primary की एक कॉपी है जो थोड़ी देरी से उसके पीछे चलती है। जो काम थोड़ा पीछे रहकर भी चल जाता है, उसके लिए यह बढ़िया है: report, export, search listing, dashboard और analytics query, जो वरना checkout से होड़ करते।
यह हर जगह रफ़्तार बढ़ाने का नुस्ख़ा नहीं है। Replication lag सामान्य बात है, और ज़्यादा write होने पर बढ़ता है। इन्हें primary पर ही रखें:
जो write के ठीक बाद पढ़ता है, जैसे checkout के बाद order पेज दिखाना।
stock रिज़र्वेशन, payment, authentication और हर वह चीज़ जो पैसे या एक्सेस का फ़ैसला करती है।
ऐसे transaction के अंदर की कोई भी query जिसमें write भी है।
व्यवहार में यह एक routing नियम बन जाता है, जो एक पन्ने पर लिखा जा सके इतना छोटा है।
कौन-सी query कहाँ चलती है, और उसे तय करने वाले तीन सवाल।
Index और N+1 query: सबसे सस्ती क्षमता
हार्डवेयर जोड़ने से पहले पता कीजिए कि कौन-सी query सबसे ज़्यादा समय खाती हैं। PostgreSQL का pg_stat_statements एक्सटेंशन query को कुल समय के हिसाब से रैंक करता है, और ANALYZE व BUFFERS विकल्पों के साथ EXPLAIN दिखाता है कि query कुछ पन्ने पढ़ रही है या दस लाख।
बर्बादी की ज़्यादातर वजह दो समस्याएँ हैं:
index न होना या ग़लत index। जिन कॉलमों पर मेल खाता index नहीं है, वहाँ filter और sort मिलीसेकंड के lookup को पूरे table scan में बदल देते हैं। अपने असली filter और ordering से मेल खाते composite index बनाइए।
N+1 query। लिस्ट पेज जो लिस्ट के लिए एक query चलाता है और फिर हर पंक्ति के लिए एक और। पचास पंक्तियाँ यानी इक्यावन बार database तक दौड़। जो relation दिखा रहे हैं, उन्हें eager-load कीजिए।
Redis और Valkey: एक server, कई काम
नामों पर एक छोटी बात। मार्च 2024 में Redis source-available लाइसेंस पर चला गया, और Linux Foundation ने Valkey शुरू किया, जो Redis 7.2.4 से बना fork है और उदार BSD लाइसेंस पर ही है। Valkey वही protocol बोलता है, इसलिए नीचे की हर बात दोनों पर लागू होती है।
सबसे आम ग़लती है cache, session, API token और queue को एक ही keyspace में रख देना। तब एक flush या मेमोरी की एक तंगी सबको एक साथ बिगाड़ देती है। हर भूमिका को अलग logical database या अलग instance दीजिए, और अलग key prefix। Cache को सुरक्षित ढंग से ख़ाली किया जा सकता है। Queue को नहीं।
भूमिका
खो जाए तो क्या होता है
Eviction policy
नियम
Cache
कुछ देर धीमापन; data फिर बन जाता है
allkeys-lru
हर चीज़ पर TTL; evict होना ठीक है
Session और API token
लोग logout हो जाते हैं
volatile-ttl या volatile-lru
इतना बड़ा रखें कि eviction कभी न हो
Queue
job खो जाते हैं
noeviction
काम चुपचाप गिराने के बजाय write ज़ोर से फ़ेल हो; मेमोरी पर alert रखें
हर policy का ब्योरा Redis eviction दस्तावेज़ों में है। दो बातें सबसे ज़रूरी हैं। allkeys-lru में Redis मेमोरी सीमा के भीतर रहने के लिए कोई भी key हटा सकता है; cache के लिए यही चाहिए, queue के लिए यही कभी नहीं चाहिए। noeviction में भरा हुआ server data मिटाने के बजाय नए write लौटा देता है, इसलिए queue सही रहती है और आपको error व alert से पता चलता है।
FLUSHDB कभी नहीं
अपने runbook और app, दोनों में FLUSHDB और FLUSHALL पर रोक लगाइए। Cache साफ़ करना हो तो prefix के हिसाब से मिटाइए। तब सबसे बुरे वक़्त पर चूक से भी cache flush queue के job नहीं मिटा सकेगा।
सही आकार में रखें
उसी प्लैटफ़ॉर्म पर managed Valkey instance 16 GB provision था और इस्तेमाल हो रहा था करीब 62 MB। असली लोड में मेमोरी का वास्तविक उपयोग देखिए, burst के दौरान queue के लिए उदार headroom रखिए, और उसी नाप तक घटाइए।
AI search के लिए pgvector, अपने database में
अगर आपके स्टोर या ERP में AI search, recommendation या दस्तावेज़ खोजकर लाने वाला असिस्टेंट है, तो आप embedding रख रहे हैं: अर्थ को दर्शाने वाली संख्याओं की लंबी सूचियाँ। pgvector एक्सटेंशन PostgreSQL में vector टाइप और nearest-neighbour search जोड़ता है, इसलिए शुरू करने के लिए अलग vector product नहीं चाहिए।
मेरा डिज़ाइन नियम है इसे अलग PostgreSQL database में चलाना, अपने connection और अपने migration रास्ते के साथ। मेरा सबसे ताज़ा सेटअप PostgreSQL 18 पर pgvector 0.8 इस्तेमाल करता था; PostgreSQL 18 सितंबर 2025 में जारी हुआ। Vector query CPU और मेमोरी पर भारी होती हैं, और index बनाना और भी भारी। अलग database में AI का लोड checkout को धीमा नहीं कर सकता, और आप उसका साइज़, backup और अपग्रेड अपने कार्यक्रम से तय कर सकते हैं।
Embedding बदलाव के बाद worker बनाते हैं, web request के अंदर कभी नहीं।
Embedding worker में बनाएँ
Embedding बनाने का मतलब है किसी model को कॉल करना, जो धीमा है और फ़ेल हो सकता है। इसे database commit के बाद शुरू होने वाले queue job से कीजिए, web request में नहीं। Job को idempotent रखिए: मूल पाठ का hash रखिए और कुछ न बदला हो तो काम छोड़ दीजिए; हर पंक्ति के साथ model का वर्ज़न रखिए ताकि बाद में बैकग्राउंड में दोबारा embed किया जा सके। यह दूसरे भाग वाला वही worker अनुशासन है।
HNSW या IVFFlat
pgvector दो तरह के approximate index देता है, और दोनों थोड़ी सटीकता के बदले काफ़ी रफ़्तार देते हैं।
HNSW
IVFFlat
रफ़्तार और recall
आमतौर पर बेहतर संतुलन
अच्छा, इस पर निर्भर कि कितनी list probe करते हैं
बनने का समय और मेमोरी
बनने में धीमा, ज़्यादा मेमोरी
बनने में तेज़, कम मेमोरी
पहले data चाहिए?
नहीं, ख़ाली table पर भी शुरू हो सकता है
हाँ, प्रतिनिधि data लोड करने के बाद बनाएँ
आम इस्तेमाल
ज़्यादातर live search का डिफ़ॉल्ट
बहुत बड़े, कम बदलने वाले सेट, जहाँ बनाने की लागत मायने रखती है
असली query कभी सिर्फ़ "इस पाठ के सबसे क़रीब" नहीं होती। वह होती है "सबसे क़रीब, stock में, इस स्टोर का, इस भाषा में"। Approximate index में filter index scan के बाद लगता है। अगर कोई filter 10% पंक्तियों से मेल खाता है और hnsw.ef_search का डिफ़ॉल्ट 40 है, तो दस माँगने पर भी औसतन चार ही मेल खाती पंक्तियाँ मिलेंगी।
2024 के अंत में जारी pgvector 0.8.0 ने ठीक इसी समस्या के लिए iterative index scan जोड़ा। जब filter के बाद बहुत कम पंक्तियाँ बचती हैं, scan तब तक चलता रहता है जब तक काफ़ी पंक्तियाँ न मिल जाएँ या कोई सीमा न आ जाए।
Setting
क्या करती है
hnsw.iterative_scan
HNSW के लिए iterative scan चालू करती है: strict_order दूरी का सटीक क्रम रखती है, relaxed_order थोड़ी बेतरतीबी की छूट देकर बेहतर recall देती है
hnsw.max_scan_tuples
iterative scan अधिकतम कितनी index entry देख सकता है, उसकी सीमा
ivfflat.iterative_scan
IVFFlat के लिए यही विचार, relaxed ordering के साथ
ivfflat.max_probes
iterative scan में अधिकतम कितनी list probe होंगी, उसकी सीमा
Object storage को compute के पास रखें
फ़ाइलें भी state हैं। product की तस्वीरें, invoice और export S3-compatible object storage में रहने चाहिए, web node की डिस्क पर नहीं; वरना load balancer के पीछे दो node नहीं चल सकते।
Bucket को servers के उसी region में रखिए। एक load test में दूसरे region का bucket हर बनी हुई फ़ाइल के upload में लगभग 50 से 80 ms जोड़ रहा था। फ़ाइलों को पहले लोकल डिस्क पर उतारने के बजाय सीधे storage से stream करने पर एक job की पीक मेमोरी भी करीब 288 MB से घटकर करीब 128 MB रह गई।
event से पहले data tier की चेकलिस्ट
लक्ष्य rate पर load test चलाइए और database का CPU, डिस्क latency, lock का इंतज़ार और प्रति सेकंड commit दर्ज कीजिए। उसके बाद ही तय कीजिए कि साइज़ बढ़ाना है या नहीं।
app के आगे PgBouncer को transaction mode में रखिए, और migration व listener के लिए direct connection अलग रखिए।
Prepared statement सपोर्ट के लिए अपना PgBouncer वर्ज़न और ड्राइवर जाँचिए।
pg_stat_statements से query रैंक कीजिए, सबसे भारी ठीक कीजिए, और सबसे व्यस्त पेजों से N+1 हटाइए।
लिख लीजिए कि कौन-सी read replica पर जा सकती है, और पक्का कीजिए कि checkout, stock और payment primary पर ही रहें।
Redis या Valkey को भूमिका के हिसाब से अलग prefix के साथ बाँटिए, हर भूमिका की eviction policy तय कीजिए, और FLUSHDB पर रोक लगाइए।
Queue की मेमोरी पर alert रखिए ताकि noeviction चुपचाप विफलता में न बदल जाए।
AI search को अपने PostgreSQL database में चलाइए, filter वाली query test कीजिए, और recall गिरे तो iterative scan चालू कीजिए।
पक्का कीजिए कि object storage आपके compute के उसी region में है।
वापस 9:02 पर। Pooler transaction mode में हो तो 940 क्लाइंट connection 20 server connections में सिमट जाते हैं, और dashboard पर डराने वाला आँकड़ा दिखना बंद हो जाता है। सेल्स report replica पर चलती है, cache और queue अलग keyspace में रहते हैं, और ऑन-कॉल इंजीनियर पूछता है "क्या saturated है?", "अगला साइज़ क्या है?" नहीं। Load test पहले ही जवाब दे चुका है: write, और database आरामदेह margin पर।
database का साइज़ write throughput और मापी गई saturation से तय करें, connection की सीमा से नहीं, और load test ज़रूरत न दिखाए तो कभी अपग्रेड न करें।
app के लिए PgBouncer को transaction mode में चलाएँ, और जानें कि वह क्या तोड़ता है: session state, session-level lock, LISTEN/NOTIFY और पुराने prepared-statement सेटअप।
Replica lag सह सकने वाली read सँभालता है; जो write के बाद पढ़े, या पैसे और stock को छुए, वह primary पर रहे।
Cache, session और queue को अलग Redis या Valkey database और prefix दीजिए, cache के लिए allkeys-lru और queue के लिए noeviction, और पूरा database कभी flush न करें।
pgvector को अपने PostgreSQL database में रखें, embedding worker में बनाएँ, शुरुआत HNSW से करें, और filter वाली search में iterative scan इस्तेमाल करें।
Cache की मेमोरी असली उपयोग के हिसाब से रखें, और object storage compute के उसी region में रखें।
अनिचुर रहमान सॉफ़्टवेयर आर्किटेक्ट और StoreConsole के संस्थापक हैं। वे बढ़ते कारोबारों के लिए कॉमर्स और ERP सिस्टम डिज़ाइन करते हैं — ख़ास ध्यान event-driven आर्किटेक्चर, डेटा की शुद्धता और अपने सर्वर पर चलने वाले सिस्टम पर।