SQLite Deep Dive (Part-4) — How a Query Actually Runs

ကျွန်တော်တို့ SELECT ဆိုပြီး ရိုက်လိုက်တာနဲ့ data တွေ တန်းထွက်လာတယ်လို့ ထင်ရပေမဲ့ တကယ်တော့ နောက်ကွယ်မှာ Program တစ်ခုကို Compile လုပ်နေသလိုမျိုး အလုပ်တွေ အများကြီး လုပ်သွားတာပါ။

SQLite Deep Dive.png

အပြင်မှာ Developer တော်တော်များများ မြင်ထားတဲ့ ‘SQL ရေးတယ်၊ DB က ရှာပေးတယ်’ ဆိုတဲ့ ရိုးရှင်းတဲ့ model က မမှားပေမဲ့၊ တကယ့် process ထဲက layer ခြောက်ခုလောက်ကိုတော့ ကျော်သွားသလို ဖြစ်နေတတ်ပါတယ်။ sqlite3_prepare() ကို ခေါ်လိုက်တာက SQLite ဆီကို query ပို့လိုက်တာထက် Source Code တစ်ခု ပေးလိုက်တာနဲ့ ပိုတူပါတယ်။ SQLite က အဲဒီ code ကို microsecond ပိုင်းလေးအတွင်းမှာပဲ အမြန်ဆုံး Compile လုပ်ပစ်လိုက်တာပါ။

တကယ်တမ်း နောက်ကွယ်မှာ ဘာတွေ တိတ်တဆိတ် ဖြစ်သွားလဲဆိုတာ အသေးစိတ် ကြည့်လိုက်ရအောင်။


Stage 1 - Tokenizer

SQLite က SQL Statement တစ်ခုကို နားလည်ဖို့အတွက် ပထမဆုံး လုပ်ဆောင်ရတဲ့ အဆင့်ကတော့ ပေးလိုက်တဲ့ SQL String ကို Tokens အဖြစ် ခွဲထုတ်ပစ်ဖို့ပါပဲ။ ဒါဟာ Tokenizer ရဲ့ အဓိက တာဝန် ဖြစ်ပါတယ်။

Tokenizer က Raw Text တွေကို Character တစ်လုံးချင်းစီ Scan ဖတ်ပြီး Token Stream လို့ခေါ်တဲ့ အဓိပ္ပာယ်ရှိတဲ့ Discrete Unit လေးတွေ ထုတ်ပေးပါတယ်။ ဥပမာအားဖြင့် SELECT ဆိုတဲ့ စာသားဟာ Keyword Token တစ်ခု ဖြစ်သွားမယ်၊ users ကတော့ Identifier Token ဖြစ်သွားပါမယ်။ WHERE ကတော့ နောက်ထပ် Keyword တစ်ခု ဖြစ်လာပြီး age > 30 ဆိုရင်တော့ Identifier, Comparison Operator နဲ့ Integer Literal ဆိုပြီး သီးခြားစီ ကွဲထွက်သွားမှာ ဖြစ်ပါတယ်။

ဒီ Stage ကို တမင်သက်သက် ရိုးရှင်းအောင် ဒီဇိုင်းဆွဲထားတာပါ။ Tokenizer က ပေးလိုက်တဲ့ Query တစ်ခုလုံးဟာ အဓိပ္ပာယ်ရှိမရှိ ဆိုတာကို ဂရုမစိုက်ပါဘူး။ users ဆိုတဲ့ Table တကယ် ရှိမရှိ၊ age ဆိုတဲ့ Column တကယ် ပါမပါ ဆိုတာကိုလည်း သူ မသိပါဘူး။ သူ့ရဲ့ အလုပ်က စာသားတွေကို Labeled Piece တွေအဖြစ် ခွဲခြမ်းပြီး နောက်တစ်ဆင့်ဆီကို ဆက်လက် ပေးပို့ဖို့ သက်သက်ပါပဲ။

ထူးခြားချက်တစ်ခုကတော့ SQLite ရဲ့ Tokenizer ဟာ အခြား Parser Tool တွေနဲ့ Generate လုပ်ထားတာ မဟုတ်ဘဲ Developer တွေက လက်နဲ့တိုက်ရိုက်ရေးသားထားတာ ဖြစ်ပါတယ်။ SQLite Team အနေနဲ့ အလွန်ရိုးရှင်းပြီး အမြန်ဆုံး ဖြစ်စေဖို့အတွက် ဒီနည်းလမ်းကို ရွေးချယ်ခဲ့တာပါ။ Character တစ်လုံးချင်းစီကို Process လုပ်သွားပြီး Backtracking လုံးဝ မလုပ်ပါဘူး။ Microsecond အနည်းငယ်အတွင်းမှာ အလုပ်ပြီးအောင် လုပ်ဆောင်ရတဲ့ Library တစ်ခုအတွက် ဒီလို စည်းကမ်းတကျ တည်ဆောက်ထားတဲ့ Architecture ဟာ အလွန်အရေးကြီးပါတယ်။


Stage 2 - Parser

Tokenizer ဆီက ထွက်လာတဲ့ Token Stream တွေဟာ Parser ဆီ ရောက်လာတဲ့အခါမှာတော့ Grammar ဟာ စတင်သက်ရောက်လာပါတော့တယ်။

SQLite ဟာ Lemon လို့ခေါ်တဲ့ Parser Generator ကို အသုံးပြုပါတယ်။ ထူးခြားတာက ဒီ Tool ဟာ SQLite ရဲ့ ဖန်တီးရှင် D. Richard Hipp ကိုယ်တိုင် ဒီ Project အတွက် ရည်ရွယ်ပြီး ရေးသားခဲ့တာပါ။ Lemon ဟာ Grammar သတ်မှတ်ချက်တွေကို အခြေခံပြီး C Parser တစ်ခုကို Generate လုပ်ပေးပါတယ်။ ဒီ Grammar က SQL Statement တစ်ခု “Valid” ဖြစ်ဖို့ ဘယ်လို ရှိရမယ်ဆိုတာကို သတ်မှတ်ပေးတာပါ။ ဥပမာ- SELECT ရေးရင် FROM ပါကို ပါရမယ်၊ WHERE Clause မှာ Expression တစ်ခု ရှိရမယ်၊ JOIN သုံးရင် ON Condition လိုအပ်တယ် ဆိုတာမျိုးတွေပေါ့။

Parser ရဲ့ အဓိကအလုပ်ကတော့ ပြန့်ကျဲနေတဲ့ Token Stream တွေကို Tree ပုံစံ Structure တစ်ခုအဖြစ် ပြောင်းလဲ တည်ဆောက်ပေးဖို့ ဖြစ်ပါတယ်။ ဒါဟာ SQL မှန်မမှန် စစ်ဆေးရုံတင် မဟုတ်ဘဲ Query ရဲ့ Structure တစ်ခုလုံးကို ကိုယ်စားပြုဖို့အတွက်လည်း ဖြစ်ပါတယ်။ ဘယ်အရာက ဘယ်အပေါ်မှာ မှီခိုနေသလဲ၊ ဘယ်ဟာက Subquery လဲ၊ ဘယ် Expression က Condition ထဲကို စီးဝင်နေသလဲ ဆိုတာတွေကို သူက ပုံဖော်ပေးပါတယ်။

ဒီအဆင့်ကနေ ထွက်လာတဲ့ ရလဒ်ကို Abstract Syntax Tree (AST) လို့ ခေါ်ပါတယ်။ ဒါဟာ Query ရဲ့ အစိတ်အပိုင်း တစ်ခုချင်းစီကို Node တစ်ခုစီနဲ့ ကိုယ်စားပြုထားတဲ့ Nested Data Structure တစ်ခုပါ။ SELECT Node အောက်မှာ Column တွေ ရှိမယ်၊ FROM Node ရှိမယ်၊ လိုအပ်ရင် WHERE Node တွေ ရှိပါမယ်။ တကယ်လို့ Subquery ပါခဲ့ရင် Parent Tree ထဲမှာ နောက်ထပ် Tree အသေးလေးတစ်ခု အနေနဲ့ ပေါ်လာမှာပါ။

ကျွန်တော်တို့ရေးလိုက်တဲ့ SQL မှာ Syntax Error ရှိနေရင် ဥပမာ-လက်သည်းကွင်း (Parenthesis) မပြည့်တာ၊ Keyword စာလုံးပေါင်း မှားတာမျိုးဆိုရင် အဲဒါကို Parser အဆင့်မှာတင် သိရှိနိုင်ပါတယ်။ ရှေ့က Tokenizer က ဒါတွေကို မဖမ်းမိနိုင်ပေမဲ့ Parser ကတော့ Query ရဲ့ တည်ဆောက်ပုံ မမှန်ကန်တာနဲ့ ချက်ချင်း သိရှိသွားမှာ ဖြစ်ပါတယ်။


Stage 3 - Code Generator

ဒါဟာ လူတော်တော်များများ ဘယ်တော့မှ မတွေးမိဖူးတဲ့ Stage ဖြစ်သလို SQLite ကို တကယ် စိတ်ဝင်စားဖို့ကောင်းအောင် လုပ်ပေးတဲ့ အဆင့်လည်း ဖြစ်ပါတယ်။

Code Generator ဟာ Parser က ပေးလိုက်တဲ့ AST ကို တစ်ဆင့်ချင်း လျှောက်ကြည့်ပြီး SQLite ရဲ့ Internal Virtual Machine အတွက် Bytecode ကို Low-level Instruction Sequence တွေ ထုတ်ပေးပါတယ်။ ဒါဟာ Machine Code မဟုတ်သလို C Code လည်း မဟုတ်ပါဘူး။ Database Operation တွေအတွက်သာ သီးသန့်ဒီဇိုင်းဆွဲထားတဲ့ Intermediate Language တစ်ခု ဖြစ်ပါတယ်။

Code Generator ဘာတွေ ဆုံးဖြတ်ရသလဲဆိုတာ စဉ်းစားကြည့်ပါ။ AST က “users ထဲက name နဲ့ email ကို age > 30 ဖြစ်တာ ထုတ်ပေး” လို့ ပြောနေပါတယ်။ အဲဒီအခါ Code Generator က သက်ဆိုင်ရာ Index ရှိလား? Index သုံးမလား ဒါမှမဟုတ် Table တစ်ခုလုံး Scan လုပ်မလား? Join တွေ ရှိရင် ဘယ် Order နဲ့ Evaluate လုပ်မလား? မှန်ကန်တဲ့ Result ထုတ်ဖို့ Operation တွေကို ဘယ်လို Sequence နဲ့ Run မလား? ဆိုတာတွေကို ဆုံးဖြတ်ရပါတယ်။

ဒါ့အပြင် Schema Information ကို ပထမဆုံး ကြည့်တဲ့နေရာလည်း ဒီ Stage ပဲ ဖြစ်ပါတယ်။ Code Generator ဟာ Schema ထဲမှာ users table ကို ရှာဖွေတယ်၊ Column တွေ ရှိမရှိ အတည်ပြုတယ်၊ Index ဘာတွေ ရှိတယ်ဆိုတာ စစ်ဆေးပါတယ်။ မရှိတဲ့ Column တစ်ခုကို Reference လုပ်ထားရင် Row တွေ Fetch လုပ်နေတဲ့ Runtime မှာ မဟုတ်ဘဲ ဒီ Compilation Phase မှာတင် သင် သိရမှာပါ။

Output အနေနဲ့ ထွက်လာတာက Program တစ်ခုပါပဲ။ Opcode တွေနဲ့ Operand တွေ ပါဝင်တဲ့ Sequence တစ်ခု ဖြစ်ပြီး အောက်ပါအတိုင်း မြင်တွေ့နိုင်ပါတယ်။

OpenRead  0  3  0    -- "users" table ကို read အတွက် ဖွင့် (cursor 0)
Rewind    0  10      -- ပထမ row ကို သွား; ဗလာ ဆိုရင် 10 ကို jump
Column    0  2  1    -- column 2 (age) ကို register 1 ထဲ ထည့်
Integer   30  2      -- 30 ကို register 2 ထဲ ထည့်
Le        1  9  2    -- register 1 <= register 2 ဆိုရင် 9 ကို jump
Column    0  0  3    -- column 0 (name) ကို register 3 ထဲ ထည့်
Column    0  1  4    -- column 1 (email) ကို register 4 ထဲ ထည့်
ResultRow 3  2       -- register 3–4 ကို result row အဖြစ် ထုတ်ပေး
Next      0  2       -- cursor ကို တိုး; 2 ကို ပြန် jump
Halt                 -- ပြီးပြီ

ဒါဟာ VDBE Bytecode ပါ။ Query မှန်သမျှ အရှေ့မှာ EXPLAIN ထည့်ပြီး Run လိုက်ရင် SQLite ဟာ Execute မလုပ်ဘဲ Generate လုပ်ထားတဲ့ Bytecode Program ကို Print ထုတ်ပေးပါလိမ့်မယ်။

——

Stage 4 - VDBE ( Virtual Database Engine )

Virtual Database Engine (VDBE) ဆိုတာ SQLite ရဲ့ Bytecode Interpreter ဖြစ်ပါတယ်။ Code Generator က ထုတ်ပေးလိုက်တဲ့ Program ကို Instruction တစ်ခုချင်းစီ Execute လုပ်ပေးတာ သူပဲဖြစ်ပါတယ်။

VDBE ဟာ Register-based Virtual Machine တစ်ခုပါ။ သူ့မှာ Temporary valueတွေ သိမ်းဆည်းဖို့ Register Set တစ်ခု၊ Table နဲ့ Index တွေကြားထဲ Navigate လုပ်ဖို့ Cursor Set တစ်ခုနဲ့ လက်ရှိ ဘယ် Instruction ကို ရောက်နေလဲဆိုတာကို သိဖို့ Program Counter တစ်ခု ရှိပါတယ်။ သူ့ရဲ့ Opcode တွေဟာ Database တစ်ခု လိုအပ်သမျှ အရာအားလုံးကို လုပ်ဆောင်နိုင်ပါတယ်။ Table တွေ ဖွင့်တာ၊ Column Value တွေ ဖတ်တာ၊ Comparison လုပ်တာ၊ Row တွေ Filter လုပ်တာ၊ Aggregate တွေ တွက်ချက်တာနဲ့ Transaction တွေကို ကိုင်တွယ်တာတွေအထိ အကုန်ပါဝင်ပါတယ်။

ဒီ Architecture ရဲ့ အနှစ်သာရကတော့ သူ ခွဲခြားထားတဲ့ Separation of Concerns ပဲဖြစ်ပါတယ်။ Query Compilation Phase ဖြစ်တဲ့ Tokenizing, Parsing, Code Generation တွေဟာ တစ်ကြိမ်တည်းပဲ ဖြစ်ပေါ်ပါတယ်။ Execution Phase ကတော့ ထွက်လာတဲ့ Program ကို Run ရုံပါပဲ။ ဒါကြောင့်မို့လို့ ကျွန်တော်တို့ Prepared Statement တွေကို အသုံးပြုသင့်တာပါ။ Prepared Statement သုံးတဲ့အခါ Compilation ဟာ prepare လုပ်တဲ့အချိန်မှာ တစ်ကြိမ်တည်းပဲ ဖြစ်သွားပါတယ်။ ပြီးရင် ဒီ Bytecode တစ်ခုတည်းကိုပဲ step ခေါ်တိုင်း Execute ဖြစ်နေမှာပါ။ Parameter တွေ ကွဲပြားသွားပေမဲ့ Compilation ပြန်လုပ်ဖို့ မလိုတော့ဘဲ ထောင်နဲ့ချီတဲ့ အကြိမ်ရေအထိ Run နိုင်ပါတယ်။

ဒါကြောင့်လည်း Prepared Statement တွေဟာ ပုံမှန် Query တွေထက် သိသိသာသာကို ပိုမြန်နေရခြင်းဖြစ်ပါတယ်။ Compilation Cost ကို တစ်ကြိမ်တည်း ပေးရုံနဲ့ ကျန်တဲ့အချိန်တွေမှာ VDBE ဟာ ထွက်ပြီးသား Program ကို Run ဖို့ပဲ ကျန်တော့လို့ဖြစ်ပါတယ်။


အကုန်လုံးကို ပေါင်းစပ်ကြည့်တဲ့အခါ

ကျွန်တော်တို့ SELECT name, email FROM users WHERE age > 30 လို့ ရေးသားလိုက်တဲ့ အချိန်ကနေ Row တွေ ပြန်လည်ရရှိတဲ့ အချိန်အထိ ဘာတွေ တကယ်ဖြစ်သွားသလဲဆိုတာကို ပုံဖော်ကြည့်ရအောင်။

ပထမဆုံး SQL String ဟာ Tokenizer ထဲကို စီးဝင်သွားပြီး Labeled Token Stream တစ်ခုအဖြစ် ထွက်ပေါ်လာပါတယ်။ အဲဒီနောက် ထွက်လာတဲ့ Token တွေဟာ Parser ထဲကို ဆက်လက်ဝင်ရောက်သွားပြီး Grammar တွေ မှန်မမှန် စစ်ဆေးခံရပါတယ်။ အဲဒီကနေတစ်ဆင့် Query ရဲ့ Structure ကို ကိုယ်စားပြုတဲ့ AST (Abstract Syntax Tree) တစ်ခုကို တည်ဆောက်လိုက်ပါတယ်။

အဲဒီနောက် AST ဟာ Code Generator ဆီကို ရောက်ရှိသွားပါတယ်။ ဒီအဆင့်မှာ Schema တွေကို ပြန်လည်ကြည့်ရှုပြီး အကောင်းဆုံး Access Path တွေကို ရွေးချယ်ကာ VDBE Bytecode တွေကို ထုတ်လုပ်ပေးပါတယ်။ နောက်ဆုံးမှာတော့ အဆိုပါ Bytecode Program ဟာ VDBE (Virtual Machine) ထဲမှာ Execute အလုပ်ခံရပါတယ်။ VDBE ဟာ Cursor တွေကို ဖွင့်တယ်၊ Page Cache မှတစ်ဆင့် Disk ပေါ်က Page တွေကို ဖတ်တယ်၊ Expression တွေကို Evaluate လုပ်ပြီး Result Row တွေကို တစ်ကြောင်းချင်း ထုတ်ပေးပါတယ်။

ဒါဟာ သီးခြားစီ ဖြစ်တည်နေတဲ့ Stage ငါးခု ဖြစ်ပါတယ်။တကယ်လိုချင်တဲ့ Data Row တစ်ကြောင်းတောင် မထွက်လာခင်မှာ ဒီလုပ်ငန်းစဉ်တွေအားလုံးဟာ နောက်ကွယ်မှာ ပြီးစီးသွားခဲ့ပြီ ဖြစ်ပါတယ်။


ဘာကြောင့် ဒီလို ဒီဇိုင်းဆွဲထားတာလဲ?

ဒီ Compilation Model ဟာ မတော်တဆ ဖြစ်လာခဲ့တာ မဟုတ်ပါဘူး။ SQLite ရဲ့ ထူးခြားတဲ့ စွမ်းဆောင်ရည်တွေဟာ ဒီတည်ဆောက်ပုံအပေါ်မှာ အခြေခံထားတာ ဖြစ်ပါတယ်။

ပထမဆုံးအနေနဲ့ EXPLAIN ဆိုတဲ့ Feature ကို ဖြစ်နိုင်စေတာ ဒါကြောင့်ပါ။ SQLite က ဘာလုပ်ဖို့ ရည်ရွယ်ထားသလဲဆိုတာကို Runtime အထိ စောင့်စရာမလိုဘဲ Bytecode တွေကို Dump ထုတ်ပြီး ကြိုတင်ဖတ်ကြည့်နိုင်ပါတယ်။ ဒုတိယအချက်ကတော့ Query Optimization ကို ပိုမိုထိရောက်စေပါတယ်။ Index Usage တွေနဲ့ Join Order ဆိုင်ရာ ဆုံးဖြတ်ချက်တွေ ချမှတ်ဖို့အတွက် အသင့်တော်ဆုံးနေရာဟာ Code Generator ပဲ ဖြစ်ပါတယ်။

နောက်ထပ် အရေးကြီးတဲ့အချက်ကတော့ Portability ပါ။ VDBE (Virtual Machine) ဟာ Operating System နဲ့ Filesystem Layer တွေရဲ့ အပေါ်မှာ သီးခြားရပ်တည်နေတာ ဖြစ်ပါတယ်။ ဒါကြောင့် ဒီ Bytecode တစ်ခုတည်းဟာ ဖုန်းမှာပဲဖြစ်ဖြစ်၊ လေယာဉ်ပေါ်မှာပဲဖြစ်ဖြစ်၊ ဂြိုဟ်တုပေါ်မှာပဲဖြစ်ဖြစ် ဘယ်နေရာမှာမဆို ထပ်တူညီတဲ့ ရလဒ်နဲ့ Run နိုင်တာ ဖြစ်ပါတယ်။

ဒါဟာ ဒဿနပိုင်းအရလည်း အလွန်ကို ကိုက်ညီမှုရှိပါတယ်။ SQLite ဟာ SQL ကို Interpret လုပ်ရုံသက်သက်မဟုတ်ဘဲ Compile လုပ်ရမယ့် Language တစ်ခုအဖြစ် အလေးအနက် ဆက်ဆံတာပါ။ ဒီလို တိကျသေချာမှု (Rigor) ဟာ Engine ရဲ့ အောက်ဆုံး Layer အထိ စီးဆင်းသွားတာ ဖြစ်ပါတယ်။


For the Part-5

Part-5 မှာတော့ ကျွန်တော်တို့ Database ရဲ့ Space Management အပိုင်းကို လေ့လာသွားပါမယ်။ Row တွေကို Delete လုပ်လိုက်တဲ့အခါ File Size က ဘာလို့ ချက်ချင်း သေးမသွားတာလဲ? Freelist ဆိုတာ ဘာလဲ? Part-1 မှာ ကြည့်ခဲ့တဲ့ Page တွေကြားမှာ SQLite က နေရာလွတ်တွေကို ဘယ်လို စီမံခန့်ခွဲသလဲ ဆိုတာတွေကို အသေးစိတ် ဆွေးနွေးသွားကြပါမယ်။