बेहतरीन प्रदर्शन के लिए SQLite on Rails का तरीका और कारण
(fractaledmind.github.io)- Rails में SQLite को default settings के साथ production environment में इस्तेमाल करना अभी पर्याप्त नहीं है, लेकिन कुछ settings जोड़ने पर high-performance और resilient applications बनाए जा सकते हैं
- Concurrent write load आने पर SQLite के global write lock की वजह से
SQLITE_BUSYहोता है, और Rails का सामान्य write transaction flow default deferred mode के साथ अच्छी तरह मेल नहीं खाता sqlite3-ruby1.6.9 सेdatabase.ymlमें default transaction mode को IMMEDIATE में बदला जा सकता है, जिससे write lock हासिल करने में failure को ज्यादा सुरक्षित तरीके से wait/retry कराया जा सकता हैbusy_timeoutwait करते समय भी Ruby GVL को पकड़े रखता है और Puma parallelism को नुकसान पहुंचाता है, इसलिएbusy_handlerमेंsleepसे GVL छोड़कर 1ms interval पर retry करने से long-tail latency घटती है- WAL mode, IMMEDIATE transactions, GVL release करने वाला busy handler, Rails 7.1 की SQLite default settings, और वैकल्पिक read/write connection pool separation SQLite on Rails performance improvement के मुख्य स्तंभ हैं
Default Rails + SQLite settings में दिखने वाली सीमाएं
- Rails application को SQLite के आधार पर अच्छे performance और resilience के साथ चलाने के लिए मौजूदा default settings पर्याप्त नहीं हैं
- लक्ष्य Rails 8 में SQLite के default experience को production-ready स्थिति में लाना है
- Demo application Lorem News है
- यह Hacker News जैसा एक basic clone है जिसमें users, posts, और comments शामिल हैं
- Content Lorem Ipsum से बना है
- Load testing के लिए oha load testing CLI और app में built-in benchmarking routes का उपयोग किया गया
post#createendpoint पर 5 सेकंड तक sequential requests भेजने पर RPS stable रहता है और सभी requests सफल होती हैं- उसी endpoint पर 5 सेकंड तक 4 concurrent requests भेजने पर कुछ requests 500 responses लौटाती हैं
SQLITE_BUSY और IMMEDIATE transactions
- Logs में सबसे पहले दिखने वाली समस्या
SQLITE_BUSYexception है - SQLite एक साथ केवल एक write operation की अनुमति देने के लिए database पर write lock का उपयोग करता है
- एक समय में केवल एक connection के पास write lock हो सकता है
- जब कोई दूसरा connection lock पकड़े हुए हो और नया connection write lock हासिल करने की कोशिश करे, तो
SQLITE_BUSYहोता है
- Rails app पर अधिक concurrent load आने पर
SQLITE_BUSYके कारण fail होने वाली requests का अनुपात भी बढ़ता है - SQLite का default transaction mode deferred है, और वास्तविक write operation होने तक lock हासिल नहीं करता
- Single-connection environment या बहुत अधिक read-only transactions वाले environment में यह performance के लिहाज से फायदेमंद है
- Production Rails apps कई threads में कई connections इस्तेमाल करते हैं, और Rails write queries को transactions में wrap करता है, इसलिए यह default value उससे टकराती है
- Transaction के बीच में write lock हासिल करने में failure होने पर SQLite serializable isolation को तोड़े बिना उस query को सुरक्षित रूप से retry नहीं कर सकता, इसलिए तुरंत exception throw करता है
IMMEDIATEtransaction transaction शुरू होते ही write lock हासिल करने की कोशिश करता है- SQLite write query को queue में डालकर बाद में फिर से lock हासिल करने की कोशिश कर सकता है
- Rails के write transaction pattern के लिए यह तरीका deferred से ज्यादा उपयुक्त है
sqlite3-rubygem 1.6.9 से default transaction mode setting को support करता है- Rails
database.ymlकी top-level keys कोsqlite3-rubydatabase initialization में pass करता है database.ymlमें Rails के SQLite transactions को IMMEDIATE mode में चलाने के लिए configure किया जा सकता है
- Rails
- इस बदलाव के बाद simple load test में concurrent load पर भी लगभग बिना 500 errors के processing होती है, लेकिन 16 concurrent requests पर फिर कुछ errors दिखते हैं
busy_timeout, GVL, custom busy_handler
- अगला bottleneck यह है कि concurrent requests की संख्या Puma workers की संख्या के करीब पहुंचने या उससे ज्यादा होने पर p99 latency तेजी से बढ़ती है
- वास्तविक request processing time Puma workers की संख्या से 3 गुना concurrent load पर भी stable रहता है, लेकिन लगभग 5 सेकंड लगने वाली requests शुरू होते ही
SQLITE_BUSY500 responses भी साथ में आने लगते हैं - ये 5 सेकंड
database.ymlकी timeout setting से मेल खाते हैं और SQLite कीbusy_timeoutsetting में map होते हैं busy_timeoutतुरंतBUSYexception throw करने के बजाय तय किए गए milliseconds तक wait करता है और write lock दोबारा हासिल करने की कोशिश करता है- SQLite एक तरह के exponential backoff तरीके से lock फिर से हासिल करने की कोशिश करता है
- Timeout के भीतर lock न मिलने पर ही
BUSYexception throw करता है - Web applications कई connections खोलने के बावजूद write order को सीधे manage किए बिना SQLite पर छोड़ सकते हैं
- Bottleneck इसलिए बनता है क्योंकि SQLite Ruby process के अंदर embedded है, और
sqlite3-rubySQLite C code call करते समय Ruby GVL release नहीं करता- एक Puma worker database query return का इंतजार करते हुए GVL को पकड़े रखता है
- दूसरे Puma workers के लिए उसी समय SQLite को write query भेजना भी मुश्किल हो जाता है
- SQLite की sequential write प्रकृति Rails request processing को भी अधिक linear बना देती है, जिससे throughput बहुत घटता है
- SQLite
busy_timeoutसे lower-levelbusy_handlerhook देता हैbusy_timeoutSQLite द्वारा दिया गया एक खासbusy_handlerimplementation हैsqlite3-rubysqlite3_busy_handlerC function के लिए binding देता है, इसलिए query queue में जाने पर call होने वाला Ruby callback बनाया जा सकता है
- Ruby callback में
Kernel.sleepइस्तेमाल करने पर query write lock retry का wait करते समय GVL release कर सकती है - यह तरीका concurrent load में p99 latency को काफी सुधारता है, लेकिन p99.99 latency में concurrent load बढ़ने पर सबसे slow request और धीमी होने की समस्या बची रहती है
Exponential backoff की लंबी tail और 1ms retry
- Ruby में reimplemented SQLite
busy_timeoutlogic की बनावट ऐसी है कि ज्यादा देर से wait कर रही queries के लिए यह और नुकसानदेह हो जाता है - शुरुआती retries में wait time छोटा होता है, लेकिन callback calls की संख्या बढ़ने पर wait time बढ़ता है
- पहले wait में 1ms wait करता है
- दसवें call पर 50ms wait करता है
- 12वें के बाद हर बार 100ms wait करता है
- कुल wait time timeout 5000ms से पार होते ही exception होता है
- अगर लगातार नई write queries आती रहें, तो नई query छोटे wait time की वजह से lock हासिल करने के लिए ज्यादा बार retry कर सकती है
- पहले से तीन बार wait कर चुकी पुरानी query जब 10ms wait कर रही होती है, नई query 1ms, 2ms, 5ms wait से गुजरते हुए तीन बार retry कर सकती है
- यह बढ़ता हुआ backoff पुरानी query के write lock न हासिल कर पाने और timeout होने की संभावना बढ़ाता है
- समाधान यह है कि query की उम्र चाहे जो हो, सभी queries समान frequency से retry करें
sqlite3-rubymain branch में यह बदलाव शामिल है- लेख लिखे जाने के समय तक यह feature tagged release में अभी शामिल नहीं है
- Ruby callback
sleepसे wait के दौरान GVL release करता है - हमेशा केवल 1ms sleep करता है
- इस बदलाव के बाद benchmark में p99.99 latency curve flatten हो जाता है
- Concurrency जब Puma workers की संख्या के आधे से ऊपर जाती है, तो jump अभी भी रहता है
- उसके बाद long-tail latency लगभग 0.5 सेकंड के स्तर पर flat हो जाती है
WAL mode और read/write connection pool separation
- SQLite on Rails performance के लिए जरूरी चार conditions हैं: IMMEDIATE transactions, GVL release करने वाला busy handler, उपयुक्त SQLite settings, और WAL mode
- write-ahead log SQLite को कई concurrent read operations handle करने देता है
- Default rollback journal mode read हो या write, एक समय में केवल एक query की अनुमति देता है
- WAL mode कई readers को एक साथ allow करता है, लेकिन writer एक समय में एक ही allow करता है
- Rails 7.1 से Rails SQLite databases पर बेहतर default settings लागू करता है
- ये settings web application context में SQLite को सही ढंग से काम कराने के लिए महत्वपूर्ण हैं
- Detailed settings और कारण अलग blog post में बताए गए हैं
- वैकल्पिक पांचवां performance lever read-only pool और write-only pool को अलग करना है
- SQLite WAL mode कई read connections और एक write connection support करता है
- अगर Active Record connection pool write connections से भर जाए, तो read operations बेवजह block हो सकते हैं
- Rails के multiple databases support का उपयोग करके reader और writer settings को उसी SQLite database की ओर point कराया जा सकता है
- वास्तव में ये अलग databases नहीं, बल्कि उसी single database को point करते हैं
- नतीजतन अलग-अलग connection pools और connection settings बनाई जा सकती हैं
- Reader connection pool को read-only connections से बनाया जाता है, और writer connection pool में केवल एक connection रखा जाता है
- Active Record models को role के अनुसार उपयुक्त connection pool से connect होने के लिए configure किया जाता है
- Rails के automatic role switching का उपयोग करके सभी web requests के लिए default connection को reader pool बनाया जाता है, और database write की जरूरत होने पर ही writer pool पर switch किया जाता है
- एक ही database इस्तेमाल होने के कारण
read your own writesguarantee के लिएdelayकी जरूरत नहीं है - ActiveRecord adapter के
transactionmethod को patch किया जाता है ताकि transaction writer database से connect हो
- एक ही database इस्तेमाल होने के कारण
- “deferred requests” और isolated connection pools का यह combination comment create endpoint test में simple RPS के आधार पर performance improvement दिखाता है
Gem के रूप में packaged improvements
- सभी improvements को Rails app में खुद implement करने की जरूरत नहीं है
activerecord-enhancedsqlite3-adapterinstall करने पर संबंधित improvements लागू किए जा सकते हैं- Isolated connection pool feature नया experimental feature है, इसलिए इसे opt-in करने के लिए setting जोड़नी होगी
- यह approach SQLite को Rails production environment में तेज और flexible तरीके से इस्तेमाल करने के tools, techniques, और defaults को bundle करके देता है
- Rails, SQLite के साथ काम करने के लिए अच्छा web application framework है, और संबंधित tools और gem ecosystem बढ़ रहा है
1 टिप्पणियां
Hacker News राय
अगर आप SQLite + Rails इस्तेमाल करने की सोच रहे हैं, तो Oldmoe (X/GitHub) का Litestack प्रोजेक्ट ज़रूर देखने लायक है
Litestack एक Ruby gem है जो Ruby और Ruby on Rails ऐप्स में SQLite की embedded प्रकृति का फायदा उठाकर SQL database, तेज़ cache, job queue, message broker, full-text search engine और metrics platform—सब कुछ एक ही package में देता है
मैं इसे अभी अपने प्रोजेक्ट में इस्तेमाल कर रहा हूँ और बेहद संतुष्ट हूँ; लिंक है https://github.com/oldmoe/litestack
इतनी detail वाला लेख लिखने में कितने दिन लगे होंगे, कल्पना नहीं कर सकता
Rails से आगे बढ़कर SQLite web applications को scale करने पर सोचने वालों के लिए यह उपयोगी लेख है
SQLite के साथ काम करने वाले हर व्यक्ति को, चाहे वह किसी भी भाषा या framework का उपयोग कर रहा हो, यह लेख पढ़ना चाहिए
कुछ साल पहले मुझे इनमें से ज़्यादातर बातें खुद ही पता लगानी पड़ी थीं, इसलिए इन्हें व्यवस्थित करके लिखने के लिए धन्यवाद
मैं एक FOSS analytics system बना रहा हूँ और installation आसान होनी चाहिए, इसलिए event data को main app data से अलग एक अलग SQLite database में भेजना चाहता हूँ
मध्यम स्तर पर busy websites में भी प्रति सेकंड 1000 से ज़्यादा events आ सकते हैं, इसलिए scaling को लेकर चिंता है
क्या server memory में events जमा करके हर 1 सेकंड में एक बार batch write करना SQLite की multiple-write limitation से बचने का उचित तरीका है, या कोई बेहतर idea है?
इसे benchmark से भी आसानी से साबित किया जा सकता है, और पूरे batch को एक single transaction में भी रखा जा सकता है
batching में असल में एक thread होता है जो batch लेकर actual write करता है, इसलिए यह SQLite की एक समय में 1 concurrent write वाली सीमा के साथ भी अच्छी तरह फिट बैठता है
हालांकि complexity थोड़ी बढ़ती है। अगर single batch write 1 सेकंड में खत्म न हो तो क्या करेंगे, memory में store होने वाली queue size unlimited रखेंगे या नहीं, अगर unlimited है तो क्या आप यकीन से कह सकते हैं कि server overload होने पर OOM से process नहीं मरेगी, अगर limit लगाते हैं तो क्या item loss स्वीकार करेंगे, drop करना हो तो कौन से items drop करेंगे, queue limit कितनी होगी—ये तय करना होगा
ये सवाल लगभग हर उस system में आते हैं जहाँ queue की ज़रूरत होती है, और ये सिर्फ अभी नहीं बल्कि आगे की स्थितियों में भी मददगार होंगे
अगर SQLite की transaction concurrency performance पर भरोसा करना मुश्किल हो, तो सभी writes को किसी specific thread या process में serialize कर दें
ClickHouse के async-insert[0] feature का उपयोग करने पर application side पर event batching की चिंता नहीं करनी पड़ती
अगर embedded solution ढूँढ रहे हैं, तो ClickHouse-based chDB इस्तेमाल कर सकते हैं
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
जितने चाहें उतने read workers रख सकते हैं, और writer queue जैसी किसी चीज़ से input ले सकता है
बस इतना करने से performance अक्सर सचमुच हैरान करने वाली होती है
SQLite को production backend database के रूप में इस्तेमाल करने का चलन अभी भी ठीक से समझ नहीं आता
SQLite मोबाइल फोन की address book जैसी छोटी, embeddable client-side application database के लिए शानदार है, लेकिन इसके developers ने भी लगातार इसे उस दायरे से आगे बढ़ाने का विरोध किया है
उदाहरण के लिए date/time या UUID जैसे उपयोगी native types नहीं जोड़े गए। वजह यह है कि code और built-in objects का आकार बढ़ेगा, और नतीजतन यह “सब कुछ string है” वाली स्थिति में अटका रहता है
Referential integrity चालू की जा सकती है, लेकिन constraint options भी बहुत सीमित हैं
समझ नहीं आता कि इसे ऐसी role में जबरन फिट करने की कोशिश क्यों जारी है जिसके लिए यह उपयुक्त नहीं है और जिसे ठीक से support भी नहीं किया गया
वजह सरल है। read-heavy access patterns में SQLite बेहद तेज़ है, इतना तेज़ कि database access code को simplify किया जा सकता है, और उदाहरण के लिए N+1 queries भी अक्सर वास्तव में समस्या नहीं बनतीं
साथ ही SQLite N-tier architecture से एक layer हटा देता है, और उतने ही failure points भी कम कर देता है। अगर आपने खुद Postgres या MySQL operate किया है, तो आपको पता होगा कि असल में बहुत सी चीज़ें गलत हो सकती हैं
यह हर app, बल्कि कई apps के लिए भी perfect choice नहीं है, लेकिन मौजूदा चलन उस साफ़ तौर पर गलत सोच के लिए संतुलन सुधार जैसा है कि SQLite सिर्फ़ “छोटी embedded client app database” के लिए ही उपयुक्त है
इस तरह देखने पर, वही data संभालने वाले DBMS के बगल में एक और DBMS होना काफ़ी अजीब लगता है
इसलिए रास्ते बंटते हैं: या तो client को सीधे Postgres से connect करने दें, या Postgres हटाकर अपने DBMS पर ज़्यादा focus करें
अगर दूसरा विकल्प चुनते हैं, तो SQLite उसके ऊपर बनाने के लिए सुविधाजनक engine है। perfect नहीं है, लेकिन अभी हमारे पास यही tool है
यह realization बड़े पैमाने पर फैलना अपेक्षाकृत हाल की बात है, इसलिए क्या काम करता है और क्या नहीं, यह जानने के लिए बहुत experiments चल रहे हैं
यह computing का natural cycle है, यानी पुरानी चीज़ फिर से नई बन रही है
Postgres की जगह MySQL, MSSQL, Oracle या कोई और DBMS पढ़ सकते हैं
ऐसी simplification से सिर्फ़ SQLite developers या low-end hardware को ही नहीं, app developers को भी कई तरह से फायदा मिलता है
documentation सरल हो जाती है, learning curve छोटा होता है, bug surface और binary size भी घटते हैं
आजकल software में हर चीज़ में bloat और complexity जोड़ने की प्रवृत्ति है, इसलिए अच्छा है कि SQLite जैसे कुछ projects उसके खिलाफ खड़े हैं
बढ़िया लेख है, और सोच रहा हूँ कि Django के लिए भी ऐसा कोई similar material है या नहीं
ArchiveBox Django के ज़रिए SQLite इस्तेमाल करता है, और लेख में Rails के बारे में बताए गए ठीक वही issues काफी बार झेले हैं
अच्छा होगा अगर कोई SQLite layer solution हो जिसमें app के किसी दूसरे path से सभी writes को serialize न करना पड़े
sqlite3-rubygem design के हिसाब से SQLite calls के दौरान GVL release नहीं करता, और linked issue comment https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss... देखने पर लगता है कि lock को फिर से acquire करने की cost बड़ी होने का शक था, लेकिन verify नहीं किया गया थाइन सारे workarounds को देखते हुए यह थोड़ा संदिग्ध लगता है
Python extension culture में शायद इसका design उल्टा होता, लेकिन वहाँ वास्तव में कैसे किया जाता है, यह जानने की उत्सुकता है
इसके अलावा linked issue में यह comment भी है कि “extralite gem एक alternative SQLite client है जो blocking के दौरान GVL release करता है, और concurrency से जुड़ी बातें https://github.com/digital-fabric/extralite?tab=readme-ov-fi... में हैं। आम तौर पर यह इस gem से काफी तेज़ है और concurrency issues भी नहीं हैं”
यह verify हो चुका है कि SQLite virtual machine के हर
stepपर सिर्फ़ GVL release करने से single-thread performance काफी खराब हो जाती हैsingle-thread और multi-thread performance के बीच middle ground ढूँढना मुश्किल है
Rails में connection pool की वजह से पता होता है कि यह multi-threaded है, लेकिन low-level gem single-threaded environments में इस्तेमाल होने वाली दूसरी libraries और tools में भी खूब इस्तेमाल होता है
अपने personal toy web service में बनाए रखे कुछ tuning values ये हैं
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;और BEGIN IMMEDIATE transactions इस्तेमाल करता हूँ
https://kerkour.com/sqlite-for-servers
cache_sizeऔरmmap_sizeके बारे में आपका क्या खयाल हैमुझे SQLite भी पसंद है और Rails भी, लेकिन यह production environment में MS Access इस्तेमाल करने जैसा लगता है
ऊपर से, जिन दशकों पहले Access का इस्तेमाल ज़्यादा होता था, उनकी तुलना में आज computers और disks की speed बहुत बेहतर है
अगर workload read-heavy हो, तो SQLite तो क्या Jet भी प्रति सेकंड दसियों हज़ार requests तक काफ़ी आसानी से पहुँच सकता है
ज़्यादातर applications में concurrent users की संख्या लाखों में नहीं होती, इसलिए SQLite अच्छी तरह fit हो सकता है
SQLite की एक ताकत यह भी है कि लगभग हर platform और language के लिए इसका client उपलब्ध है
archives, backups और portability भी SQLite के लिए अच्छी use cases हैं। एक ऐसे project में जहाँ data entry सिर्फ़ एक तय अवधि तक सीमित थी, मैंने हर box के लिए SQLite इस्तेमाल करने पर काफ़ी ज़ोर दिया था, और आज भी लगता है कि वही बेहतर होता
जटिल schema और custom export/archive features बनाने के बजाय, एक file copy करके उसे archive या backup बनाया जा सकता था, और समय के साथ होने वाले schema changes के बारे में भी इतनी गहराई से सोचना नहीं पड़ता
यह context पर निर्भर करता है, लेकिन कई समस्याओं के लिए यह काफ़ी अच्छा समाधान है। यह वैसा ही है जैसे ज़्यादातर applications में ज़्यादा scalable NoSQL options की तुलना में PostgreSQL या कोई दूसरा relational DBMS अक्सर बेहतर choice होता है
हममें over-engineering की प्रवृत्ति रही है, और अब computing और I/O performance उस स्तर के करीब पहुँच रही है जहाँ ऐसी मेहनत को justify करना धीरे-धीरे कम होता जा रहा है
बहुत जानकारीपूर्ण और अच्छी तरह लिखा गया लेख है
सोच रहा हूँ कि default
busy_timeoutmethod में पुराने queries को punish करने वाला exponential backoff क्यों होता हैइसे default के रूप में reasonable मानने की वजह क्या है?