1 पॉइंट द्वारा GN⁺ 2024-09-13 | 1 टिप्पणियां | WhatsApp पर शेयर करें
  • 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-ruby 1.6.9 से database.yml में default transaction mode को IMMEDIATE में बदला जा सकता है, जिससे write lock हासिल करने में failure को ज्यादा सुरक्षित तरीके से wait/retry कराया जा सकता है
  • busy_timeout wait करते समय भी 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#create endpoint पर 5 सेकंड तक sequential requests भेजने पर RPS stable रहता है और सभी requests सफल होती हैं
  • उसी endpoint पर 5 सेकंड तक 4 concurrent requests भेजने पर कुछ requests 500 responses लौटाती हैं

SQLITE_BUSY और IMMEDIATE transactions

  • Logs में सबसे पहले दिखने वाली समस्या SQLITE_BUSY exception है
  • 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 करता है
  • IMMEDIATE transaction transaction शुरू होते ही write lock हासिल करने की कोशिश करता है
    • SQLite write query को queue में डालकर बाद में फिर से lock हासिल करने की कोशिश कर सकता है
    • Rails के write transaction pattern के लिए यह तरीका deferred से ज्यादा उपयुक्त है
  • sqlite3-ruby gem 1.6.9 से default transaction mode setting को support करता है
    • Rails database.yml की top-level keys को sqlite3-ruby database initialization में pass करता है
    • database.yml में Rails के SQLite transactions को IMMEDIATE mode में चलाने के लिए configure किया जा सकता है
  • इस बदलाव के बाद 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_BUSY 500 responses भी साथ में आने लगते हैं
  • ये 5 सेकंड database.yml की timeout setting से मेल खाते हैं और SQLite की busy_timeout setting में map होते हैं
  • busy_timeout तुरंत BUSY exception throw करने के बजाय तय किए गए milliseconds तक wait करता है और write lock दोबारा हासिल करने की कोशिश करता है
    • SQLite एक तरह के exponential backoff तरीके से lock फिर से हासिल करने की कोशिश करता है
    • Timeout के भीतर lock न मिलने पर ही BUSY exception throw करता है
    • Web applications कई connections खोलने के बावजूद write order को सीधे manage किए बिना SQLite पर छोड़ सकते हैं
  • Bottleneck इसलिए बनता है क्योंकि SQLite Ruby process के अंदर embedded है, और sqlite3-ruby SQLite 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-level busy_handler hook देता है
    • busy_timeout SQLite द्वारा दिया गया एक खास busy_handler implementation है
    • sqlite3-ruby sqlite3_busy_handler C 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_timeout logic की बनावट ऐसी है कि ज्यादा देर से 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-ruby main 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 writes guarantee के लिए delay की जरूरत नहीं है
    • ActiveRecord adapter के transaction method को patch किया जाता है ताकि transaction writer database से connect हो
  • “deferred requests” और isolated connection pools का यह combination comment create endpoint test में simple RPS के आधार पर performance improvement दिखाता है

Gem के रूप में packaged improvements

  • सभी improvements को Rails app में खुद implement करने की जरूरत नहीं है
  • activerecord-enhancedsqlite3-adapter install करने पर संबंधित 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 टिप्पणियां

 
GN⁺ 2024-09-13
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 है?

    • मुझे यह काफ़ी reasonable और अच्छा idea लगता है। मैंने कई systems में इसी तरह के तरीके implement किए हैं, और आम तौर पर batching प्रति item overhead कम कर देती है
      इसे 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 ऐसा नहीं है कि write कर ही नहीं सकता। बस यह एक समय में एक write transaction ही support करता है
      अगर SQLite की transaction concurrency performance पर भरोसा करना मुश्किल हो, तो सभी writes को किसी specific thread या process में serialize कर दें
    • यहाँ यह अच्छी तरह काम किया और SQLite को लेकर मेरी सोच बदल गई। Hipp ने भी साथ काम किया था, और SQLite बहुत ज़बरदस्त performance दे सकता है: https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a...
    • Analytics data आम तौर पर write-heavy होता है, इसलिए मैं ClickHouse की सलाह दूँगा
      ClickHouse के async-insert[0] feature का उपयोग करने पर application side पर event batching की चिंता नहीं करनी पड़ती
      अगर embedded solution ढूँढ रहे हैं, तो ClickHouse-based chDB इस्तेमाल कर सकते हैं
      [0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
    • Batch writes शायद अच्छा विचार है, लेकिन SQLite के साथ ऐसा करने का सबसे अच्छा तरीका WAL इस्तेमाल करना और एक designated single writer रखना है
      जितने चाहें उतने 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 भी नहीं किया गया

    • पहले तो मुझे नहीं पता कि Richard Hipp SQLite की “मूल रूप से intended” role के बारे में यही सोचेंगे या नहीं
      वजह सरल है। 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” के लिए ही उपयुक्त है
    • आखिरकार एहसास होता है कि “API server” असल में शुरू से ही DBMS था
      इस तरह देखने पर, वही data संभालने वाले DBMS के बगल में एक और DBMS होना काफ़ी अजीब लगता है
      इसलिए रास्ते बंटते हैं: या तो client को सीधे Postgres से connect करने दें, या Postgres हटाकर अपने DBMS पर ज़्यादा focus करें
      अगर दूसरा विकल्प चुनते हैं, तो SQLite उसके ऊपर बनाने के लिए सुविधाजनक engine है। perfect नहीं है, लेकिन अभी हमारे पास यही tool है
      यह realization बड़े पैमाने पर फैलना अपेक्षाकृत हाल की बात है, इसलिए क्या काम करता है और क्या नहीं, यह जानने के लिए बहुत experiments चल रहे हैं
      यह computing का natural cycle है, यानी पुरानी चीज़ फिर से नई बन रही है
      Postgres की जगह MySQL, MSSQL, Oracle या कोई और DBMS पढ़ सकते हैं
    • UUID को string या BLOB के रूप में, और dates को string या integer/real timestamps के रूप में इस्तेमाल किया जा सकता है, नहीं?
      ऐसी 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-ruby gem 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 भी नहीं हैं”

    • अधिक detailed discussion https://github.com/sparklemotion/sqlite3-ruby/pull/528 और https://github.com/digital-fabric/extralite/pull/46 में देखी जा सकती है
      यह 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 इस्तेमाल करने जैसा लगता है

    • बहुत ज़्यादा अलग नहीं है, लेकिन read-heavy scenarios में SQLite, Jet (MS Access) से कहीं बेहतर performance देता है
      ऊपर से, जिन दशकों पहले 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 करना धीरे-धीरे कम होता जा रहा है
    • काश web apps के लिए MS Access जैसा कुछ होता। मैंने कई website builder products इस्तेमाल किए हैं, लेकिन Access जितना intuitive कोई नहीं लगा
    • सोच रहा हूँ ऐसा क्यों है। खासकर read-focused application हो तो किस scaling limit की चिंता है?
    • Pieter Levels के लिए यह तरीका काम करता है। बेशक users की संख्या किसी खास threshold से ऊपर गई तो समस्या आएगी
  • बहुत जानकारीपूर्ण और अच्छी तरह लिखा गया लेख है
    सोच रहा हूँ कि default busy_timeout method में पुराने queries को punish करने वाला exponential backoff क्यों होता है
    इसे default के रूप में reasonable मानने की वजह क्या है?