SQLITE_DEFAULT_MEMSTATUS - SQLite का वो म्यूटेक्स (mutex) जिसके बारे में rusqlite ने आपको नहीं बताया

लेखक
Damian
Terlecki
8 मिनट पढ़ें
डेटाबेस

अगर आप bundled फीचर के साथ rusqlite v0.39.0 का इस्तेमाल करके अपनी Rust सर्विस में SQLite को एम्बेड (embed) करते हैं, तो libsqlite3-sys डिफ़ॉल्ट रूप से SQLITE_DEFAULT_MEMSTATUS=1 के साथ sqlite3.c को कंपाइल (compile) करता है। rusqlite बिल्ड के समय कई -DSQLITE_* फ्लैग्स तो पास करता है, लेकिन इस वाले को कभी ओवरराइड नहीं करता, इसलिए SQLite का डिफ़ॉल्ट 1 ही बना रहता है।

इसका नतीजा यह होता है कि हर sqlite3_malloc और sqlite3_free एक सिंगल प्रोसेस-वाइड म्यूटेक्स (mutex) के पीछे फंस जाते हैं।

अगर आप सिंगल थ्रेड पर हैं, तो आपको कुछ महसूस नहीं होगा। लेकिन अगर आप पैरेलल रीडर्स (parallel readers) और प्रति-अनुरोध (per-request) कनेक्शन मॉडल का इस्तेमाल कर रहे हैं, तो यह आपके थ्रूपुट (throughput) को बुरी तरह जाम कर सकता है। इसका समाधान बस एक build-time environment variable है।

यह फ्लैग असल में क्या करता है?

SQLite पूरी प्रोसेस में कितनी मेमोरी इस्तेमाल हो रही है, इसे ट्रैक करता है (sqlite3_memory_used(), sqlite3_status())। सभी थ्रेड्स में यह डेटा सही रहे, इसके लिए हर एलोकेशन और फ्री (free) ऑपरेशन के दौरान एक म्यूटेक्स के अंदर काउंटर अपडेट किया जाता है। SQLite के डॉक्यूमेंटेशन में बेहतर परफॉरमेंस के लिए SQLITE_DEFAULT_MEMSTATUS=0 सेट करने की सलाह दी गई है:

यह सेटिंग मेमोरी यूसेज ट्रैक करने वाले इंटरफेस को डिफ़ॉल्ट रूप से बंद कर देती है। इससे sqlite3_malloc() काफी तेज़ी से काम करता है, और चूंकि SQLite अंदरूनी तौर पर इसी का इस्तेमाल करता है, इसलिए पूरी लाइब्रेरी की स्पीड बढ़ जाती है।

डॉक्यूमेंटेशन में स्पीड की बात तो की गई है, लेकिन इसके पीछे का असली कारण है—हर एलोकेशन से प्रोसेस-ग्लोबल म्यूटेक्स को हटाना। कीथ मेडकाल्फ (Keith Medcalf) ने SQLite फोरम पर इसे साफ तौर पर समझाया है: MEMSTATUS ऑन रहने का साइड-इफेक्ट यह है कि मेमोरी एलोकेटर म्यूटेक्स के अंदर आ जाता है (यानी एक बार में केवल एक ही थ्रेड मेमोरी एलोकेटर को एक्सेस कर सकता है)। कंपाइल के समय इसे 0 सेट करने से काउंटर और म्यूटेक्स दोनों हट जाते हैं और C-लेवल के malloc/free कॉल्स सीधे काम करने लगते हैं।

कौन इसे 0 पर सेट करता है और कौन नहीं?

दिलचस्प बात यह है कि अलग-अलग ड्राइवर्स इसे अलग तरह से हैंडल करते हैं। हालांकि SQLite का सोर्स कोड एक ही है, लेकिन हर इकोसिस्टम का अपना बिल्ड तरीका है:

प्रोजेक्टSQLITE_DEFAULT_MEMSTATUSकहाँ सेट है
rusqlite (bundled)=1 (डिफ़ॉल्ट)ओवरराइड नहीं किया गया
sqlite-jdbc=0Makefile.common
node:sqlite (Node)=0PR #56541 (Jan 2025)
CPython sqlite3=0setup_helpers.py

चार मुख्य ड्राइवर्स में से तीन पहले से ही इस म्यूटेक्स को हटाकर शिप (ship) करते हैं। सिर्फ Rust वाला (rusqlite) इसे डिफ़ॉल्ट पर छोड़ देता है।

जब डिफ़ॉल्ट सच में "चुभता" है

यह दिक्कत तब आती है जब ये तीन शर्तें एक साथ मिलें:

  1. SQLite C कोड के अंदर एक साथ कई थ्रेड्स चल रहे हों। जैसे कि लोड के दौरान वर्कर पूल वाली कोई वेब सर्विस। सिंगल-थ्रेडेड कोड में यह म्यूटेक्स कभी महसूस नहीं होता।
  2. Per-request कनेक्शन लाइफसाइकल, या बहुत ज़्यादा एलोकेशन चर्न (allocation churn) — जैसे बार-बार कनेक्शन खोलना और बंद करना। हर sqlite3_open स्कीमा को पार्स करता है और कई छोटे-छोटे एलोकेशन करता है। जितने ज़्यादा एलोकेशन, उतनी बार म्यूटेक्स लॉक होगा।
  3. म्यूटेक्स के टकराने (contention) के लिए पर्याप्त CPU कोर। अगर आप 1-2 vCPU वाले कंटेनर पर हैं, तो फर्क शायद ही पता चले।

मुझे यह समस्या 16-थ्रेड वाले Ryzen प्रोसेसर पर मिली, जहाँ एक Rust + axum सर्विस हर रिक्वेस्ट पर नया Connection खोल रही थी। 50 कंकरेंट (concurrent) यूजर्स पर थ्रूपुट सिर्फ 5,622 req/s था और perf record में दिखा कि CPU का 18% हिस्सा sqlite3_malloc के म्यूटेक्स वेट में बर्बाद हो रहा था। फ्लैग बदलते ही थ्रूपुट बढ़कर 30,737 req/s हो गया और म्यूटेक्स की समस्या पूरी तरह खत्म हो गई।

दो वर्कर थ्रेड्स एक ही ग्लोबल म्यूटेक्स के लिए वेट कर रहे हैं

इसे अपने Rust ऐप में कैसे बदलें?

आप सामान्य तरीके से rusqlite का इस्तेमाल करते हैं (bundled फीचर के साथ), ताकि cargo आपके लिए SQLite कंपाइल करे:

# Cargo.toml
[dependencies]
rusqlite = { version = "0.39", features = ["bundled"] }

Cargo.toml में ऐसा कोई फीचर नहीं है जो इस फ्लैग को बदल सके। इसका सही तरीका LIBSQLITE3_FLAGS नाम का एक environment variable है, जिसे libsqlite3-sys कंपाइल करते समय पढ़ता है। इसे सेट करने की सबसे अच्छी जगह .cargo/config.toml फाइल है:

# .cargo/config.toml
[env]
LIBSQLITE3_FLAGS = "SQLITE_DEFAULT_MEMSTATUS=0 SQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS"

इस फाइल को Cargo.toml के साथ ही कमिट कर दें। इससे आपके प्रोजेक्ट पर काम करने वाले हर डेवलपर और हर CI रनर को यह सेटिंग अपने आप मिल जाएगी। चूंकि cargo यह ट्रैक करता है कि कब एनवायरनमेंट वेरिएबल बदला है, इसलिए वैल्यू बदलते ही libsqlite3-sys अपने आप rebuild हो जाएगा (आपको cargo clean करने की ज़रूरत नहीं है)।

अगर आप इसे एक बार के लिए टेस्ट करना चाहते हैं, तो टर्मिनल से भी कर सकते हैं:

export LIBSQLITE3_FLAGS="SQLITE_DEFAULT_MEMSTATUS=0 SQLITE_DISABLE_PAGECACHE_OVERFLOW_STATS"
cargo build --release

यही तरीका Nix devshell, GitHub Actions या Docker के लिए भी काम करता है।

यह चेक करने के लिए कि फ्लैग काम कर रहा है या नहीं, आप रनटाइम पर कंपाइल ऑप्शंस को क्वेरी (query) करके देख सकते हैं:

let flags: Vec<String> = conn
    .prepare("SELECT * FROM pragma_compile_options();")?
    .query_map([], |r| r.get(0))?
    .collect::<Result<_, _>>()?;

ध्यान दें कि डिफ़ॉल्ट बिल्ड में DEFAULT_MEMSTATUS लिस्ट में नहीं दिखेगा, क्योंकि SQLite सिर्फ वही चीजें दिखाता है जो डिफ़ॉल्ट से बदली गई हों। जब आप इसे =0 सेट करेंगे, तब आपको आउटपुट में DEFAULT_MEMSTATUS=0 नज़र आएगा।

ट्रेड-ऑफ़ (Trade-offs)

इसे 0 सेट करने के बाद sqlite3_memory_used() और sqlite3_status() हमेशा जीरो दिखाएंगे। अगर आपका कोड इन पर निर्भर है (जो कि बहुत कम ही होता है, क्योंकि ये ज़्यादातर डिबगिंग के लिए होते हैं), तो वे काम करना बंद कर देंगे।

परफॉर्मेंस और डेटा की शुद्धता (correctness) पर इसका कोई बुरा असर नहीं पड़ता। एलोकेशन और फ्री वैसे ही होते रहेंगे, बस उनकी गिनती बंद हो जाएगी। चूंकि बाकी बड़े ड्राइवर्स (Node, Python, JDBC) इसे पहले से ही बंद रखते हैं, तो इसे बदलकर आप भी एक तरह से स्टैंडर्ड प्रैक्टिस को ही अपना रहे हैं।

इस पर और ज़्यादा चर्चा के लिए SQLITE_DEFAULT_MEMSTATUS का SQLite फोरम थ्रेड देखें।