SQLite Deep Dive (Part-1) — The Single File

.db ဖိုင်တစ်ခုထဲမှာ ဘာတွေရှိနေသလဲ၊ Page Structure ဆိုတာ ဘာလဲ၊ Query တစ်ကြောင်း မ Run ခင် Disk ပေါ်မှာ အရာအားလုံးကို ဘယ်လို စီစဉ်ထားသလဲ။

SQLite Deep Dive.png

SQLite ကို ပထမဆုံး စတင်သုံးစွဲဖူးတဲ့ Developer တိုင်း ဒီလို အတွေ့အကြုံမျိုး ရှိဖူးကြပါလိမ့်မယ်။ Application ကို Run လိုက်တယ်၊ .db ဖိုင်တစ်ခု ထွက်လာတယ်၊ ပြီးတော့ “ဒါပဲလား?” ဆိုပြီး အံ့ဩမိတာမျိုးပါ။

တခြား Database တွေလို Server process ကြီးတွေ မရှိဘူး။ အမည်မသိ Binary blob တွေနဲ့ ပြည့်နေတဲ့ Data directory တွေ မရှိသလို၊ Internal ID တွေနဲ့ အမည်ပေးထားတဲ့ Tablespace ဖိုင်တွေလည်း မရှိပါဘူး။ PDF တစ်ခု ဒါမှမဟုတ် ZIP ဖိုင်တစ်ခုလို Disk ပေါ်မှာ တည်ရှိနေတဲ့ ဖိုင်တစ်ခုတည်းပဲ ရှိတာပါ။

ဒီရိုးရှင်းမှုဟာ SQLite ရဲ့ အဓိက အသက်သွေးကြော ဖြစ်ပါတယ်။ “ဖိုင်တစ်ခုတည်း” ဆိုတာ အဆင်ပြေရုံသက်သက် (Convenience) အတွက် Hack လုပ်ထားတာ မဟုတ်သလို၊ “Lite” ဖြစ်ရမယ်ဆိုတဲ့ အမည်ကြောင့် လုပ်ထားတာလည်း မဟုတ်ပါဘူး။ ဒါဟာ ၂၀၀၄ ခုနှစ်ကတည်းက Backward Compatibility ဖြစ်နေစေမယ့် File Format တစ်ခုကို သေချာဂရုတစိုက် ဒီဇိုင်းဆွဲထားတဲ့ ရလဒ်ပဲ ဖြစ်ပါတယ်။ SQLite version 3.0 နဲ့ ဖန်တီးခဲ့တဲ့ Database ဖိုင်ကို နောက်ဆုံးထွက် Version နဲ့ ဖွင့်ကြည့်ရင်တောင် ဘာပြဿနာမှမရှိဘဲ အလုပ်လုပ်နေမှာပါ။ ဒါဟာ တမင်ရည်ရွယ်ပြီး ပေးထားတဲ့ ကတိတစ်ခု (Design Promise) ပဲ ဖြစ်ပါတယ်။

ဒီအပိုင်းမှာတော့ ဒီ file ရဲ့ အတွင်းပိုင်း တည်ဆောက်ပုံကို လေ့လာကြည့်ကြပါမယ်။ Query မ Run ခင်၊ WAL mode မသုံးခင်နဲ့ Index မချခင်မှာ Disk ပေါ်က Byte တွေဟာ ဘယ်လိုပုံစံနဲ့ ရှိနေသလဲဆိုတာ ကြည့်ရအောင်။


File ဆိုတာ Pages တွေ စုစည်းထားတာပါ

Hex Editor တစ်ခုနဲ့ SQLite Database တစ်ခုကို ဖွင့်ကြည့်လိုက်ရင် ပထမဆုံး သတိထားမိမှာက file ကို အရွယ်အစား တူညီတဲ့ Chunk (အပိုင်းအစ) လေးတွေ ခွဲထားတာပါ။ ဒါတွေကို Pages လို့ ခေါ်ပြီး SQLite ရဲ့ အရာအားလုံးအတွက် အခြေခံ Unit တွေ ဖြစ်ပါတယ်။

Page တစ်ခုဟာ 512 ကနေ 65536 bytes ကြားရှိတဲ့ Fixed-size Block တစ်ခုဖြစ်ပြီး အမြဲတမ်း Power of Two (2^n) ဖြစ်ရပါတယ်။ Default ကတော့ 4096 bytes ပါ။ ဒါဟာ OS ရဲ့ Memory Page Size နဲ့ ကိုက်ညီတဲ့အတွက် I/O Layer မှာ အလွန်ထိရောက်မှု ရှိပါတယ်။ SQLite သိမ်းဆည်းတဲ့ Data မှန်သမျှ (Table rows, Index entries, Overflow content, Free space) ဟာ Page တစ်ခုခုထဲမှာပဲ တည်ရှိရပါတယ်။ Pages တွေကို ၁ ကနေ စပြီး နံပါတ်စဉ် တပ်ပါတယ် (၀ ကနေ မစတဲ့အတွက် Source Code ဖတ်ရင် သတိထားရပါတယ်)။ Page Number ဆိုတာ SQLite အတွက် Disk ပေါ်က တည်နေရာကို ညွှန်ပြတဲ့ နည်းလမ်းပါပဲ။ ဥပမာ- Page 7 ကို ဖတ်ချင်ရင် (7 - 1) * page_size ဆိုတဲ့ နေရာကို တိုက်ရိုက် Seek သွားပြီး page_size အတိုင်း ဖတ်လိုက်ရုံပါပဲ။

ဖိုင်တစ်ခုလုံးမှာ ပါဝင်တဲ့ Page အမျိုးအစားတွေကို အောက်ပါအတိုင်း ခွဲခြားနိုင်ပါတယ်- • B-tree pages: Table data တွေနဲ့ Index entries တွေကို သိမ်းဆည်းပါတယ်။ Database တစ်ခုမှာ အများဆုံးတွေ့ရတဲ့ အမျိုးအစားပါ။ • Overflow pages: B-tree page တစ်ခုထဲမှာ မဆံ့တော့တဲ့ ကြီးမားတဲ့ Content တွေ (ဥပမာ- Text blob အရှည်တွေ) ကို သိမ်းဆည်းပါတယ်။ • Freelist pages: ဖျက်လိုက်တဲ့အတွက် ပြန်သုံးလို့ရတဲ့ (Reuse) နေရာလွတ်တွေကို ခြေရာခံတဲ့ Page တွေပါ။ • Pointer map pages: Auto-vacuum mode မှာ ဖိုင်ကို ပြန်ချုံ့နိုင်အောင် Engine ကို ကူညီပေးပါတယ်။ • Lock-byte page: Windows OS ပေါ်က Legacy file locking အတွက် သီးသန့်ထားတဲ့ Special page တစ်ခုပါ။


Page 1: Database တစ်ခုလုံးရဲ့ Control Center

Page 1 ဟာ သာမန် B-tree page တစ်ခု မဟုတ်ပါဘူး။ သူ့ကို SQLite ရဲ့ “Source of Truth” လို့တောင် ခေါ်လို့ရပါတယ်။ ဘာကြောင့်လဲဆိုတော့ SQLite engine ဟာ ဖိုင်ကို စဖွင့်လိုက်တာနဲ့ တခြားဘာမှမလုပ်ခင် Page 1 ရဲ့ ပထမဆုံး 100 bytes ကို အရင်ဆုံး ဖတ်ရတာကြောင့်ပါ။ ဒါကို Database File Header လို့ ခေါ်ပါတယ်။

ဒီ 100-byte header ထဲမှာ file ရဲ့ DNA လို့ ပြောလို့ရမယ့် အရေးကြီး အချက်အလက်တွေကို Big-endian format နဲ့ သိမ်းထားပါတယ်။ အဓိက အစိတ်အပိုင်းတွေကို အသေးစိတ် ကြည့်ရအောင်-

Byte 100 ပြီးတဲ့နောက်မှာတော့ Page 1 ရဲ့ ကျန်တဲ့အပိုင်းဟာ ပုံမှန် B-tree page အဖြစ် ဆက်သွားပြီး တိတိကျကျ ပြောရရင် sqlite_schema table ရဲ့ Root Page အဖြစ် တာဝန်ယူပါတယ်။


Schema Table

Header ဖတ်ပြီးရင် SQLite ပထမဆုံး ရှာဖွေတာက Schema ပါ။ SQLite database တိုင်းမှာ sqlite_schema ဆိုတဲ့ Table တစ်ခု အမြဲပါဝင်ပါတယ်။ (အရင် Version တွေမှာတော့ sqlite_master လို့ ခေါ်ပါတယ်)။ Database ထဲမှာရှိတဲ့ Table, Index, View နဲ့ Trigger တိုင်းရဲ့ Definition တွေကို ဒီ Table ထဲမှာပဲ သိမ်းထားတာပါ။

ဒါဟာ တခြား Table တွေလိုပဲ တကယ့် Table အစစ်တစ်ခုဖြစ်ပြီး သူ့ရဲ့ Structure က ဒီလိုရှိပါတယ်-

CREATE TABLE sqlite_schema (
  type     TEXT,     -- 'table', 'index', 'view', 'trigger'
  name     TEXT,     -- object ရဲ့ နာမည်
  tbl_name TEXT,     -- ဆိုင်ရာ table
  rootpage INTEGER,  -- root B-tree page ရဲ့ page number
  sql      TEXT      -- မူရင်း CREATE statement
);

ဒီနေရာမှာ အရေးကြီးဆုံးက rootpage column ပါပဲ။ Table တစ်ခုကို Query လုပ်ချင်တဲ့အခါ SQLite ဟာ sqlite_schema ထဲမှာ အရင်ဆုံး Lookup လုပ်ပြီး အဲဒီ Table ရဲ့ Root Page နံပါတ်ကို ရှာပါတယ်။ အဲဒီနောက်မှ Page နံပါတ်အတိုင်း လိုက်သွားပြီး B-tree ကို Navigate လုပ်ကာ Data တွေကို ဆွဲထုတ်တာ ဖြစ်ပါတယ်။

ဒါဟာ ဒီဇိုင်းအရ Self-describing ဖြစ်တယ်လို့ ဆိုနိုင်ပါတယ်။ SQLite မှာ External Catalog တွေ ဒါမှမဟုတ် အခြား Hardcoded System Table တွေ မလိုအပ်ပါဘူး။ Schema ဟာ Data တွေနဲ့အတူ Database ထဲမှာပဲ တစ်ပါတည်း တည်ရှိနေတာကြောင့် .db ဖိုင်တစ်ခုဟာ အလွန် Portable ဖြစ်ပြီး Self-contained ဖြစ်နေရခြင်း ဖြစ်ပါတယ်။

မှတ်သားထားရမှာက Schema Table ကိုယ်တိုင်ရဲ့ Root Page ဟာ Page 1 မှာ အမြဲရှိနေပါတယ်။ ဒါဟာ တစ်ခုတည်းသော Hardcoded ဖြစ်တဲ့ အချက်ပဲ ဖြစ်ပါတယ်။


Behind the Scenes: SQLite ဖိုင်တစ်ခုကို ဖွင့်လိုက်တဲ့အခါ နောက်ကွယ်မှာ ဘာတွေဖြစ်သွားသလဲ?

ဒါတွေအားလုံးကို ပေါင်းစပ်ကြည့်ရင် SQLite ဟာ Database ဖိုင်တစ်ခုကို Cold State (ဘာမှ မဖတ်ရသေးခင်) မှာ စတင်ဖွင့်လိုက်တဲ့အခါ ဘယ်လိုအဆင့်ဆင့် လုပ်ဆောင်သွားတယ်ဆိုတာကို အတိအကျ Trace လုပ်နိုင်ပါတယ်-

  1. Page 1 ကို ဖတ်ခြင်း: File ကို ဖွင့်လိုက်တာနဲ့ Page 1 ကို အရင်ဖတ်ပါတယ်။ ပထမဆုံး 100 bytes ကို Header Parser ဆီ ပို့ပြီး Magic String ကို စစ်ဆေးပါတယ်။ Magic String မကိုက်ညီရင် file ကို ငြင်းဆန်လိုက်ပြီး၊ file အသစ်ဖြစ်နေရင်တော့ Default Header Values တွေနဲ့ Initialize လုပ်ပါတယ်။
  2. Page Size သတ်မှတ်ခြင်း: Header ထဲက Page size ကို ဆွဲထုတ်လိုက်ပါတယ်။ အဲဒီနောက်ပိုင်း ဖတ်သမျှ Read operation အားလုံးကို အဲဒီ Chunk size အတိုင်းပဲ ဆောင်ရွက်သွားတော့မှာပါ။
  3. Journal Mode စစ်ဆေးခြင်း: Header ရဲ့ Byte 18–19 ကို ကြည့်ပြီး WAL mode ဖြစ်မဖြစ် စစ်ဆေးပါတယ်။ WAL mode ဆိုရင် သက်ဆိုင်ရာ wal ဖိုင်ကို ရှာဖွေပြီး Validate လုပ်ပါတယ်။
  4. Schema ဖတ်ခြင်း: Page 1 (Header ရဲ့ နောက်ပိုင်း) မှာရှိတဲ့ sqlite_schema B-tree ကို ဖတ်ပါတယ်။ Table တိုင်း၊ Index တိုင်းရဲ့ Root Page တွေကို Memory ထဲမှာ Schema Cache အဖြစ် တည်ဆောက်လိုက်ပါတယ်။
  5. Change Counter စစ်ဆေးခြင်း: တကယ်လို့ တခြား Connection တစ်ခုက ဖိုင်ကို ပြင်ဆင်သွားခဲ့ရင် Counter မတူတော့တဲ့အတွက် Cache Invalidation လုပ်ပြီး Schema ကို Reload ပြန်လုပ်ပါတယ်။

ဒီအဆင့်တွေ ပြီးသွားတဲ့အခါမှာတော့ Query တိုင်းအတွက် လိုအပ်တဲ့ အချက်အလက်တွေဖြစ်တဲ့ Page size, Schema layout နဲ့ Root page number တွေဟာ SQLite ဆီမှာ အသင့်ရှိနေပြီဖြစ်လို့ B-tree navigation ကို စတင်လုပ်ဆောင်နိုင်ပြီ ဖြစ်ပါတယ်။


Page Size ရွေးချယ်မှု : Performance အတွက် ဘာကို သိထားသင့်သလဲ?

လက်တွေ့ကျကျ မေးစရာတစ်ခု ပေါ်လာတာကတော့ “Page size ကို တကယ်ရော ပြောင်းလဲဖို့ လိုအပ်ပါသလား?” ဆိုတာပါပဲ။

ပုံမှန်အားဖြင့်တော့ Default ဖြစ်တဲ့ 4096 bytes ဟာ Workload အများစုအတွက် အသင့်တော်ဆုံး အဖြေ (Standard Answer) ဖြစ်ပါတယ်။ ဘာကြောင့်လဲဆိုတော့ ခေတ်သစ် Operating System တွေရဲ့ Memory Page Size နဲ့ ကိုက်ညီနေတာကြောင့် Read/Write လုပ်တဲ့အခါ Kernel နဲ့ သဘာဝကျကျ အပေးအယူတည့်ပြီး (Alignment ဖြစ်ပြီး) အလုပ်လုပ်နိုင်လို့ ဖြစ်ပါတယ်။

ဒါပေမဲ့ Page size ကို ပိုကြီးတဲ့ပမာဏ (8192 သို့မဟုတ် 16384 bytes) အဖြစ် ပြောင်းလဲဖို့ စဉ်းစားသင့်တဲ့ Scenario (၂) ခု ရှိပါတယ်-

တစ်ဖက်မှာလည်း Page size ကို သေးငယ်တဲ့ပမာဏ (512 သို့မဟုတ် 1024 bytes) အဖြစ် သတ်မှတ်တာမျိုးကတော့ RAM အလွန်အမင်း ချွေတာရတဲ့ Embedded System တွေမှာပဲ အဓိပ္ပာယ်ရှိပါတယ်။ Server ဒါမှမဟုတ် Desktop Application တွေအတွက်တော့ ဒါကို မပြုလုပ်သင့်ပါဘူး။

အဓိက သတိပြုရမယ့်အချက်ကတော့ Page size ဟာ Database ဖန်တီးပြီးသွားရင် ပြောင်းလဲလို့ မရတော့ပါဘူး။ ဒါကြောင့် အစကတည်းက မှန်ကန်စွာ ရွေးချယ်ဖို့ လိုအပ်ပါတယ်။ တကယ်လို့ ပြောင်းလဲချင်တယ်ဆိုရင်တော့ VACUUM INTO ကို သုံးပြီး Database ကို အစကနေ ပြန်ဆောက်ယူရမှာ ဖြစ်ပါတယ်။


ကိုယ်တိုင် လက်တွေ့စစ်ဆေးကြည့်လို့ရအောင်

အထက်မှာ ဖော်ပြခဲ့တဲ့ အချက်အလက်တွေကို သီအိုရီအရပဲ ယုံကြည်နေစရာ မလိုပါဘူး။ သင့်ရဲ့ SQLite database ထဲမှာ တကယ်ပဲ ဘယ်လိုစီစဉ်ထားသလဲဆိုတာကို အောက်ပါ Command တွေသုံးပြီး တိုက်ရိုက် စစ်ဆေးကြည့်နိုင်ပါတယ်။

ပထမဆုံးအနေနဲ့ PRAGMA command တွေကို သုံးပြီး Header ထဲက Metadata တွေကို ဆွဲထုတ်ကြည့်ရအောင်-

PRAGMA page_size;
PRAGMA page_count;
PRAGMA freelist_count;
PRAGMA user_version;
PRAGMA application_id;
PRAGMA schema_version;

ဒါမှမဟုတ် sqlite_schema table ကို တိုက်ရိုက် Query လုပ်ပြီး သင့်ရဲ့ Table တွေ ဘယ် Page နံပါတ်ကနေ စတင်သလဲဆိုတာကိုလည်း ကြည့်နိုင်ပါတယ်-

SELECT type, name, tbl_name, rootpage FROM sqlite_schema;

ဒီ Query ကို Run ကြည့်လိုက်ရင် Database ထဲမှာရှိတဲ့ Object တိုင်းနဲ့ သူတို့ရဲ့ Root Page Number တွေကို ရှင်းရှင်းလင်းလင်း မြင်တွေ့ရမှာ ဖြစ်ပါတယ်။

ပိုပြီး နက်နက်နဲနဲ လေ့လာချင်တယ်ဆိုရင်တော့ Hex Editor တစ်ခုခုကို သုံးပြီး ဖိုင်ရဲ့ ပထမဆုံး 100 Bytes ကို ကိုယ်တိုင် ဆင်းစစ်ကြည့်နိုင်သလို၊ SQLite distribution နဲ့အတူပါဝင်တဲ့ sqlite3_analyzer tool ကိုလည်း သုံးနိုင်ပါတယ်။ ဒီ Tool ဟာ Page တစ်ခုချင်းစီရဲ့ Space Usage အခြေအနေကို အသေးစိတ် ပြသပေးနိုင်ပါတယ်။

SQLite ရဲ့ File Format Specification ကိုယ်တိုင်ကလည်း sqlite.org/fileformat2.html မှာ အပြည့်အစုံ Documented ဖြစ်ပြီးသားပါ။ ဒါဟာ Open Source လောကမှာ အကောင်းဆုံးစာရင်းဝင်တဲ့ Documentation တစ်ခုဖြစ်လို့ SQLite ကို အလေးအနက် အသုံးပြုမယ့်သူတိုင်း တစ်ကြိမ်လောက်တော့ အနားယူရင်း ဖတ်ရှုကြည့်ဖို့ အကြံပေးချင်ပါတယ်။


ဒါတွေအားလုံးကို နားလည်ထားဖို့ ဘာကြောင့် အရေးကြီးတာလဲ?

အခုလို File Format အကြောင်းကို အသေးစိတ် လေ့လာတာဟာ ဗဟုသုတ (Trivia) သက်သက် မဟုတ်ပါဘူး။ ဒါဟာ SQLite ရဲ့ Behavior (အပြုအမူ) တိုင်းကို သဘောပေါက်နားလည်ဖို့အတွက် အခြေခံအုတ်မြစ် (Fundamental) ပဲ ဖြစ်ပါတယ်။

ကျွန်တော်တို့ ဒီ Series ရဲ့ နောက်ပိုင်းအဆင့်တွေ ရောက်တဲ့အခါ ဒီအချက်တွေက အခုလိုမျိုး အချိတ်အဆက်မိသွားပါလိမ့်မယ်-

အနှစ်ချုပ်ရရင် SQLite ရဲ့ Performance ဖြစ်ဖြစ်၊ Concurrency ဖြစ်ဖြစ်၊ Recovery Mechanism ဖြစ်ဖြစ်၊ အရာအားလုံးဟာ နောက်ဆုံးမှာ မေးခွန်းတစ်ခုတည်းကိုပဲ ဖြေဆိုနေတာပါ-

“Disk ပေါ်က Pages တွေကို ဘယ်လိုမျိုး အမြန်ဆုံးနဲ့ စိတ်အချရဆုံး Read/Write လုပ်မလဲ?”

ဒီမေးခွန်းရဲ့ အဖြေဟာ အခုကျွန်တော်တို့ လေ့လာခဲ့တဲ့ Page Structure နဲ့ Header တွေကနေ စတင်တာပဲ ဖြစ်ပါတယ်။


ရှေ့ဆက်သွားမယ့် ခရီးစဉ်

Part-2 မှာတော့ SQLite ရဲ့ Type System ဟာ တခြား SQL Database တွေနဲ့ ဘာကြောင့် လုံးဝမတူတာလဲ? Dynamic Typing က Database တစ်ခုမှာ တကယ်ရော ဘယ်လို အလုပ်လုပ်သလဲ? ဆိုတာတွေကို Deep Dive လုပ်သွားပါမယ်။ ဒါ့အပြင် Developer တိုင်းကို အနည်းဆုံး တစ်ကြိမ်လောက်တော့ “ထောင်ချောက်” ဆင်ဖမ်းနိုင်တဲ့ SQLite ရဲ့ ထူးခြားတဲ့ Edge Cases တွေကိုလည်း အသေးစိတ် ဖော်ပြပေးသွားမှာပါ။