এই পর্বে যা শিখবেন
- একটি কোয়েরি parse থেকে execute পর্যন্ত কোন কোন ধাপ পার হয়, আর iterator মডেলে tuple কীভাবে ওপরে ওঠে
- Page I/O দিয়ে খরচ মাপা, আর selection-এর তিন পথ — scan, binary search, index — কোনটির খরচ কত
- External merge sort-এর pass আর খরচের সূত্র, প্রমাণসহ
- Nested loop, index nested loop, sort-merge আর hash join — সূত্র, ধাপে ধাপে চলা, আর কখন কোনটি জেতে
- Projection, aggregation আর set operation; লাইভ SQLite-এর EXPLAIN QUERY PLAN আর PostgreSQL-এর plan পড়া
১ · একটি কোয়েরির যাত্রা — parse থেকে execute
আপনি লেখেন কী চান; কীভাবে আনতে হবে, সেটি DBMS ঠিক করে। SQL তাই declarative ভাষা। কিন্তু শেষ পর্যন্ত কাউকে না কাউকে ডিস্ক থেকে page পড়তে হয়, সারি মেলাতে হয়, সাজাতে হয়। এই পুরো কাজের নাম query processing.
একটি ব্যস্ত বিরিয়ানির দোকান ভাবুন। ওয়েটার আপনার মুখের অর্ডার খাতায় লেখেন, নিয়ম মেনে কি না দেখেন (“মেনুতে আছে তো?”) — এটি parse. রান্নাঘরে অর্ডারটি রান্নার ধাপের তালিকা হয় — মাংস, চাল, আলু. প্রধান বাবুর্চি ঠিক করেন কোন হাঁড়ি আগে চড়বে, কোন কাজ একসাথে হবে — optimization. তারপর সহকারীরা আসলে রান্না করেন — execution. খাবারের স্বাদ এক, কিন্তু কাজের ক্রম ভালো হলে খাবার আসে দশ মিনিটে, খারাপ হলে এক ঘণ্টায়।
- Parse
SQL → parse treeব্যাকরণ, নাম যাচাই - Translate
σ, π, ⋈relational algebra - Optimize
physical planসস্তা পথ বাছাই - Execute
open / nextসারি তৈরি
প্রথম ধাপে parser SQL-কে একটি parse tree-তে ভাঙে, আর system catalog দেখে যাচাই করে যে টেবিল আর কলামগুলো সত্যিই আছে। দ্বিতীয় ধাপে গাছটি পর্ব ৩২-এর relational algebra-র একটি expression tree হয় — যেমন πname, order_id(σcity='Dhaka'(customers ⋈ orders)). এটি একটি logical plan: কী করতে হবে, কীভাবে নয়। তৃতীয় ধাপে optimizer সমতুল্য বহু গাছ আর প্রতিটি operator-এর জন্য বহু অ্যালগরিদম থেকে সবচেয়ে সস্তাটি বাছে — একটি physical plan. বাছাইয়ের বিদ্যা পর্ব ৪৩-এর বিষয়। এই পর্বের বিষয় চতুর্থ ধাপ: বাছা হয়ে গেলে প্রতিটি অ্যালগরিদম আসলে কীভাবে চলে, আর তার খরচ কত। কারণ optimizer-এর হিসাব এই খরচগুলো দিয়েই তৈরি।
লাইভ SQL চলে দোকানের ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন), categories (৪টি শ্রেণি), products (১০টি পণ্য), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার) আর order_items (order_id, product_id, quantity, unit_price — ১৬টি লাইন)। প্রতিবার চালালে ডেটাবেস নতুন করে তৈরি হয়। নিচের প্রথম স্টেটমেন্ট কোয়েরিটি চালায় না, শুধু plan দেখায় — পর্ব ৩-এ দেখা EXPLAIN QUERY PLAN:
EXPLAIN QUERY PLAN
SELECT c.name, o.order_id
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE c.city = 'Dhaka';
SELECT c.name, o.order_id
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE c.city = 'Dhaka';Plan-এর detail কলামে দুটি লাইন: SCAN o আর SEARCH c USING INTEGER PRIMARY KEY (rowid=?). অর্থাৎ SQLite orders-এর প্রতিটি সারি পড়ছে, আর প্রতিটির customer_id দিয়ে customers-এর primary key-তে সরাসরি একটি খোঁজ দিচ্ছে — এটি একটি index nested loop join (অংশ ৬)। লক্ষ করুন, কোয়েরিতে customers আগে লেখা, কিন্তু SQLite বাইরের লুপে রেখেছে orders-কে; লেখার ক্রম plan ঠিক করে না। ফল পাঁচটি সারি, এই ক্রমে: Rahim Uddin | 1001, Rahim Uddin | 1003, Sabbir Hasan | 1004, Mitu Rani | 1008, Rahim Uddin | 1009 — orders-এর ক্রমেই, কারণ বাইরের লুপ orders. ORDER BY ছাড়া এই ক্রমের ওপর ভরসা করবেন না; plan বদলালে ক্রমও বদলাবে।
২ · Physical operator আর iterator মডেল
Physical plan হলো physical operator-এর একটি গাছ: Seq Scan, Index Scan, Nested Loop Join, Hash Join, Sort, Aggregate. প্রায় সব প্রচলিত DBMS এগুলোকে একটি সাধারণ নিয়মে জোড়ে, যার নাম iterator মডেল (Volcano মডেল)। প্রতিটি operator তিনটি কাজ জানে:
open()
প্রস্তুতি — ফাইল খোলা, buffer নেওয়া, child-দের open() ডাকা।
next()
পরের একটি tuple ফেরত দেওয়া; দরকার হলে child-এর next() ডেকে input আনা। আর না থাকলে “শেষ” (EOF).
close()
মেমোরি আর ফাইল ছেড়ে দেওয়া, child-দের close() ডাকা।
চমৎকার দিকটি হলো, কোনো operator জানে না তার child কে — scan, join না sort. সবাই একই তিনটি কাজ বোঝে, তাই যেকোনো গাছ জোড়া যায়। উপরের SQLite plan-টি iterator হিসেবে চালিয়ে দেখুন। ওপরের operator next() ডাকলে কীভাবে একটি একটি সারি “টেনে” তোলা হয়:
| orders পড়া | |
| customers খোঁজ | |
| ফলের সারি | |
| মেমোরিতে |
- open(). ওপর থেকে নিচ পর্যন্ত সবাই প্রস্তুত হলো, কিন্তু কোনো ডেটা এখনো ছোঁয়া হয়নি। কাজ শুরু হয় প্রথম next()-এ — একে বলে চাহিদা-চালিত (demand-driven) চলা।
- প্রথম সারি। SCAN একটি অর্ডার দিল, join সেটির গ্রাহক খুঁজে আনল, filter পাস করল, projection দুটি কলাম রেখে ফল পাঠাল। মাত্র একটি অর্ডার পড়েই প্রথম ফল ক্লায়েন্টের কাছে।
- Filter বাদ দিলে। 1002-এর গ্রাহক Chattogram-এর, তাই σ সেটি ফেলে দিয়ে নিজেই আবার child-এর next() ডাকল। ওপরের π এসব জানেও না — সে শুধু পরের ভালো সারিটি পেল।
- তৃতীয় সারি। 1004 সরাসরি পাস করল। প্রতিটি operator একসাথে মাত্র একটি tuple ধরে রাখছে।
- একটি next()-এ অনেক কাজ। চারটি অর্ডার পরপর পড়া হলো, তিনটি বাদ। একটি next() কল নিচে কতটা কাজ করাবে, তা ডেটার ওপর নির্ভর করে।
- পঞ্চম সারি। Rahim-এর তৃতীয় অর্ডার।
- শেষ। 1010-এর গ্রাহক Imran-এর শহর NULL; পর্ব ১৬-এর তিন-মানের যুক্তিতে শর্তটি unknown, তাই বাদ। তারপর SCAN “শেষ” বলল, আর EOF ওপরে উঠল। ১০টি অর্ডার, ১০টি খোঁজ, ৫টি ফল — কোথাও পুরো মধ্যবর্তী টেবিল জমা হয়নি।
Pipelining বনাম materialization
উপরের ধারা, যেখানে একটি operator-এর output সরাসরি পরেরটির input — মাঝে কোথাও জমা নয় — তাকে বলে pipelining. বিকল্প হলো materialization: একটি operator-এর পুরো ফল একটি অস্থায়ী টেবিলে লিখে, তারপর পরেরটি সেখান থেকে পড়ে। Pipelining-এ মেমোরি কম লাগে, অস্থায়ী টেবিল লেখার I/O বাঁচে, আর প্রথম সারি আসে তাড়াতাড়ি — LIMIT 10 থাকলে দশটি সারির পরই কাজ থামে।
কিন্তু কিছু operator স্বভাবতই blocking: সব input না দেখে প্রথম output দিতেই পারে না। ORDER BY-র sort — শেষ সারিটিই হয়তো সবচেয়ে ছোট। Hash join-এর build অংশ — পুরো ছোট টেবিলের hash table না বানিয়ে probe শুরু করা যায় না। GROUP BY-র hash aggregate — শেষ সারিটি যেকোনো দলের যোগফল বদলাতে পারে। উপরের কোয়েরিতে ORDER BY c.name যোগ করলে ফলের প্রথম সারি আসবে পাঁচটি সারি জমা আর সাজানোর পরে। Plan-এর গাছে এই বিন্দুগুলোই pipeline-কে টুকরো করে।
৩ · খরচের একক — page I/O
পর্ব ৩৯-এ দেখেছেন, ডিস্ক থেকে ডেটা আসে page ধরে — সাধারণত ৪ থেকে ১৬ KB-র একেকটি খণ্ড। মেমোরিতে কাজ করা ডিস্ক পড়ার চেয়ে বহুগুণ দ্রুত, তাই ডেটাবেসের পাঠ্যবইয়ে অ্যালগরিদমের খরচ মাপা হয় page I/O-এর সংখ্যায়: কতবার একটি page ডিস্ক থেকে পড়া বা ডিস্কে লেখা হলো। এই পর্ব জুড়ে একই চিহ্ন:
| চিহ্ন | অর্থ | বড় উদাহরণে মান |
|---|---|---|
| R, S | যে দুটি relation join হবে; R বাইরের (outer), S ভেতরের (inner) | R = customers, S = orders |
| M | R-এর page সংখ্যা | 500 |
| N | S-এর page সংখ্যা (sort-এর আলোচনায় যে ফাইল সাজানো হচ্ছে তার page) | 2,000 |
| pR, pS | প্রতি page-এ কতটি tuple | 80 (৪০ হাজার গ্রাহক), 100 (২ লাখ অর্ডার) |
| B | এই operator-এর জন্য মেমোরিতে কতটি buffer page | 52 |
দুটি প্রচলিত নিয়ম মনে রাখুন। এক, শেষ ফল লেখার খরচ ধরা হয় না — যেকোনো join অ্যালগরিদমে ফলের আকার একই, তাই তুলনায় সেটি বাদ। দুই, এই মডেল CPU-র কাজ, random আর sequential পড়ার পার্থক্য আর cache উপেক্ষা করে। মডেলটি তাই মোটা দাগের, কিন্তু অ্যালগরিদমের মধ্যে হাজার গুণের পার্থক্য ঠিকই ধরে। আসল optimizer-এর সূত্র এর চেয়ে সূক্ষ্ম — পর্ব ৪৩-এ।
পরীক্ষার জন্য এবার একটি বড় পরীক্ষাগার বানাই। ১০ হাজার সারি হাতে লেখা অসম্ভব, তাই পর্ব ২৫-এর recursive CTE দিয়ে সংখ্যা তৈরি করে সেখান থেকে সারি বানানো হবে। এই এডিটরে customers_big (cid, city, name — ২,০০০ জন, চার শহরে সমান ভাগ) আর returns_big (rid, cid, reason — ৩০০টি পণ্য-ফেরত) আগে থেকেই তৈরি; কোডে আপনি বানাচ্ছেন orders_big (oid, cid, amount — ১০,০০০টি অর্ডার):
CREATE TABLE orders_big (oid INTEGER PRIMARY KEY,
cid INTEGER, amount INTEGER);
WITH RECURSIVE n(i) AS (
SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 10000)
INSERT INTO orders_big
SELECT i, (i * 7) % 2000 + 1, (i * 37) % 5000 + 100 FROM n;
SELECT COUNT(*) AS orders, COUNT(DISTINCT cid) AS customers,
MIN(amount) AS lo, MAX(amount) AS hi FROM orders_big;
SELECT * FROM orders_big LIMIT 3;দুটি ✓ — CREATE TABLE আর WITH RECURSIVE (এডিটর WITH দিয়ে শুরু হওয়া INSERT-কে এভাবেই দেখায়)। প্রথম কোয়েরি: 10000 | 2000 | 100 | 5099. দ্বিতীয়টি: 1 | 8 | 137, 2 | 15 | 174, 3 | 22 | 211. সূত্রগুলো ইচ্ছে করে বাছা: 7 আর 2000-এর কোনো সাধারণ গুণনীয়ক নেই, তাই cid ঘুরে ঘুরে ১ থেকে ২০০০ সব মান নেয় — প্রত্যেক গ্রাহকের ঠিক ৫টি অর্ডার। একইভাবে 37 আর 5000 সহমৌলিক, তাই 100 থেকে 5099-এর প্রতিটি amount ঠিক দুবার আসে। ফলে পরের এডিটরগুলোর গোনা আপনি হাতেই মিলিয়ে নিতে পারবেন। পরের সব এডিটরে এই তিনটি টেবিল আগে থেকেই থাকবে।
৪ · Selection — scan, binary search না index?
সবচেয়ে সরল operator হলো selection σ: শর্ত মেলে এমন সারি বের করা। পর্ব ৪০-এর ভাষায় তিনটি পথ:
| পথ | কখন চলে | খরচ (page I/O) |
|---|---|---|
| Linear scan | সবসময়, যেকোনো শর্তে | M; unique key-তে সমতার শর্ত হলে প্রথম মিলেই থামা যায় — গড়ে M/2 |
| Binary search | ফাইলটি শর্তের কলামে সাজানো, শর্ত সমতা বা range | ⌈log2 M⌉ + (মিলে যাওয়া বাকি page) |
| Index — clustered / primary | ফাইলের সারিগুলো index-এর ক্রমেই রাখা | h + মিলে যাওয়া page-এর সংখ্যা ≈ h + sel × M |
| Index — secondary | ফাইল অন্য ক্রমে, index শুধু ঠিকানা দেয় | h + মিলে যাওয়া tuple-এর সংখ্যা (খারাপ ক্ষেত্রে প্রতিটি tuple-এ একটি page) |
এখানে h হলো B+ tree-এর উচ্চতা (বড় টেবিলেও সাধারণত ৩–৪), আর sel হলো selectivity — মোট সারির কত ভাগ শর্ত মেলে। শেষ সারিটিই সবচেয়ে বড় ফাঁদ। ধরুন orders-এর ২ লাখ সারি ২,০০০ page-এ, আর শর্তে ৫% — ১০ হাজার সারি — মেলে। Secondary index-এ খারাপ ক্ষেত্রে ১০,০০০ + h page I/O, অথচ পুরো scan মাত্র ২,০০০! তাই optimizer কম selectivity-তে index নেয়, বেশিতে scan. PostgreSQL-এর bitmap heap scan মাঝের পথ: আগে index থেকে সব ঠিকানা জোগাড় করে page-এর ক্রমে সাজায়, তারপর প্রতিটি page একবারই পড়ে।
পরীক্ষাগারে তিনটি অবস্থা দেখুন — primary key, index ছাড়া কলাম, আর index বানানোর পরে:
EXPLAIN QUERY PLAN SELECT * FROM orders_big WHERE oid = 77;
EXPLAIN QUERY PLAN SELECT * FROM orders_big WHERE amount = 500;
CREATE INDEX ob_amt ON orders_big(amount);
EXPLAIN QUERY PLAN SELECT * FROM orders_big WHERE amount = 500;
EXPLAIN QUERY PLAN
SELECT oid FROM orders_big WHERE amount BETWEEN 500 AND 600;
SELECT COUNT(*) FROM orders_big WHERE amount BETWEEN 500 AND 600;চারটি plan, পরপর: SEARCH orders_big USING INTEGER PRIMARY KEY (rowid=?) — SQLite-এর টেবিল নিজেই rowid-এর B+ tree, তাই এটি clustered index-এ সরাসরি খোঁজ। তারপর SCAN orders_big — amount-এ index নেই, তাই linear scan. Index বানানোর (✓) পরে SEARCH orders_big USING INDEX ob_amt (amount=?) — secondary index-এ খুঁজে প্রতিটি মিলের জন্য টেবিলে আরেকটি খোঁজ। শেষটি SEARCH orders_big USING COVERING INDEX ob_amt (amount>? AND amount<?): range-এ index ধরে হাঁটা, আর টেবিলে যাওয়ারই দরকার নেই, কারণ চাওয়া oid (rowid) index-এর ভেতরেই আছে — পর্ব ৪১-এর covering index. গোনা: 202 — 500 থেকে 600 পর্যন্ত ১০১টি মান, প্রতিটি দুবার।
৫ · External merge sort — মেমোরির চেয়ে বড় ফাইল সাজানো
ORDER BY, DISTINCT, GROUP BY, sort-merge join, index বানানো — সবখানে sort লাগে। ডেটা মেমোরিতে ধরলে quicksort-ই যথেষ্ট। কিন্তু ২০ GB টেবিল আর ২ GB মেমোরি হলে? তখন লাগে external merge sort, যার মূল ভাবনা DSA কোর্সের merge sort-এর merge ধাপ।
বোর্ড পরীক্ষার পর একটি কেন্দ্রে হাজার হাজার খাতা রোল নম্বরে সাজাতে হবে, কিন্তু টেবিলে একসাথে রাখা যায় মাত্র চারটি বান্ডিল। প্রথমে প্রতিবার চারটি বান্ডিল টেবিলে এনে ভেতরে সাজিয়ে আলাদা “সাজানো স্তূপ” বানানো হয়। তারপর তিনটি স্তূপের ওপরের খাতা পাশাপাশি রেখে সবচেয়ে ছোট রোলটি তুলে নতুন স্তূপে রাখা — চতুর্থ জায়গাটি নতুন স্তূপের জন্য। স্তূপ কমতে কমতে একটি হলেই কাজ শেষ।
// N page-এর ফাইল, মেমোরিতে B buffer page
Pass 0: প্রতিবার B page পড়ো, মেমোরিতে সাজাও, একটি sorted run লেখো
→ ⌈N/B⌉টি run, প্রতিটি B page (শেষটি ছোট হতে পারে)
Pass i: B−1টি input buffer, 1টি output buffer
প্রতিবার B−1টি run একসাথে merge করে একটি বড় run লেখো
যতক্ষণ না একটিই run থাকে
pass-এর সংখ্যা = 1 + ⌈log_(B−1) ⌈N/B⌉⌉
প্রতি pass-এ প্রতিটি page একবার পড়া + একবার লেখা = 2N
মোট খরচ = 2N × (1 + ⌈log_(B−1) ⌈N/B⌉⌉)পাস-সংখ্যার যুক্তি। Pass 0-এর পর run-এর সংখ্যা r0 = ⌈N/B⌉. প্রতিটি merge pass B−1টি run-কে একটিতে মেলায়, তাই k-তম pass-এর পর rk = ⌈rk−1/(B−1)⌉ ≤ ⌈r0/(B−1)k⌉. একটি run থাকে প্রথম সেই k-তে, যেখানে (B−1)k ≥ r0, অর্থাৎ k = ⌈logB−1 r0⌉. সাথে pass 0 যোগ করলে সূত্রটি আসে। কেন B−1, B নয়? একটি buffer সবসময় output-এর জন্য লাগে — output buffer ভরলে ডিস্কে লেখা হয়, তবেই merge এগোয়।
এবার N = 20, B = 4 দিয়ে পুরো কাজটি ধাপে ধাপে দেখুন:
| pass | |
| run আছে | |
| মোট I/O |
- শুরু। ২০ page-এর অসাজানো ফাইল, মেমোরিতে মাত্র ৪টি page-এর জায়গা।
- Pass 0. প্রতিবার ৪টি page মেমোরিতে এনে quicksort, তারপর একটি sorted run হিসেবে লেখা। পাঁচবারে পাঁচটি run; প্রতিটি page একবার পড়া, একবার লেখা — ৪০ I/O.
- Pass 1, প্রথম merge. তিনটি input buffer-এ R1, R2, R3-এর প্রথম page. প্রতিবার তিনটির মাথার সবচেয়ে ছোট মানটি output buffer-এ; কোনো input page খালি হলে সেই run-এর পরের page আসে। ফল R6, ১২ page.
- Pass 1, দ্বিতীয় merge. বাকি দুটি run — R4 আর R5 — একটি ২-way merge-এ R7 (৮ page). Pass 1-এও প্রতিটি page ঠিক একবার পড়া আর একবার লেখা হলো: ৪০ I/O.
- Pass 2. R6 আর R7 মিলে পুরো সাজানো ফাইল। আবার ৪০ I/O.
- সূত্রের সাথে মেলানো। ৩টি pass, ১২০ I/O — সূত্র হুবহু মেলে। মাত্র একটি buffer বাড়ালে একটি পুরো pass বাঁচে। বাস্তবে B শত শত বা হাজার page, তাই কয়েক GB-র টেবিলও সাধারণত দুই pass-এই সাজানো যায়।
শেষ বাক্যটির হিসাব: B = 1,000 page (৮ KB page-এ মাত্র ৮ MB) হলে এক merge pass-এ ৯৯৯টি run মেলে, আর pass 0-এর run প্রতিটি ১,০০০ page — তাই দুই pass-এ সাজানো যায় প্রায় B × (B−1) ≈ ১০ লাখ page, অর্থাৎ প্রায় ৮ GB. সাধারণভাবে N ≤ B(B−1) হলে দুই pass যথেষ্ট, মোটামুটি B ≥ √N.
SQLite-এ দেখুন ORDER BY কখন sort চায়, আর index কীভাবে sort-কে পুরো সরিয়ে দেয়:
EXPLAIN QUERY PLAN
SELECT oid, amount FROM orders_big ORDER BY amount DESC LIMIT 5;
CREATE INDEX ob_amt ON orders_big(amount);
EXPLAIN QUERY PLAN
SELECT oid, amount FROM orders_big ORDER BY amount DESC LIMIT 5;
SELECT oid, amount FROM orders_big ORDER BY amount DESC LIMIT 5;প্রথম plan-এ দুটি লাইন: SCAN orders_big আর USE TEMP B-TREE FOR ORDER BY — SQLite পুরো টেবিল পড়ে একটি অস্থায়ী গঠনে সাজাচ্ছে; এটি blocking, প্রথম সারি আসবে সব ১০,০০০ সারি দেখার পরে। Index-এর পরে একটিই লাইন: SCAN orders_big USING COVERING INDEX ob_amt — index-এর পাতাগুলো আগে থেকেই amount-এর ক্রমে সাজানো, তাই উল্টো দিক থেকে হাঁটলেই ক্রম মেলে, আর ৫টি সারির পরই থামা যায়; sort নেই, pipeline অটুট। ফল: 7027 | 5099, 2027 | 5099, 9054 | 5098, 4054 | 5098, 6081 | 5097. সমান amount-এ কোনটি আগে, তা ORDER BY-তে বলা নেই — দ্বিতীয় কলাম দিলেই নিশ্চিত হয়।
৬ · Join ১ — nested loop পরিবার
Join সবচেয়ে দামি আর সবচেয়ে বৈচিত্র্যময় operator. পর্ব ১৯-এর JOIN-এর অর্থ এক, কিন্তু চালানোর পথ অনেক। শুরু সবচেয়ে সরলটি দিয়ে — দুটি লুপ।
// tuple nested loop: খরচ = M + (p_R · M) · N
for each tuple r in R:
for each tuple s in S: // প্রতিটি r-এর জন্য পুরো S
if r.k = s.k: output (r, s)
// page nested loop: খরচ = M + M · N
for each page P_R of R:
for each page P_S of S:
প্রতিটি (r ∈ P_R, s ∈ P_S) জোড়া মেলাও
// block nested loop: খরচ = M + ⌈M / (B−2)⌉ · N
for each block of (B−2) pages of R: // 1টি S-এর input, 1টি output
for each page P_S of S:
block-এর প্রতিটি r আর P_S-এর প্রতিটি s মেলাওTuple nested loop-এ R-এর প্রতিটি tuple-এর জন্য S-এর N page পুরো একবার পড়া হয় — pR·M বার। Page সংস্করণে প্রতিটি R page-এর জন্য একবার, তাই M বার। Block nested loop আরও এক ধাপ এগোয়: মেমোরির B page-এর মধ্যে একটি S-এর input আর একটি output-এর জন্য রেখে বাকি B−2 page-এ R-এর একটি “block” ভরে, আর S-এর একবার পড়ায় পুরো block-এর সাথে মেলায়। তাই S পড়া হয় ⌈M/(B−2)⌉ বার। তিনটি সূত্রেই প্রথম M হলো R নিজে একবার পড়া। ছোট উদাহরণে ধাপগুলো দেখুন:
| R page পড়া | |
| S page পড়া | |
| মোট I/O |
- শুরু। R-এর ৪ page, S-এর ৩ page, মেমোরিতে ৪টি frame. সাধারণ page nested loop এখানে ১৬ I/O নিত।
- প্রথম block. দুটি frame-এ R1 আর R2 — R-এর একটি block. মাত্র ২ I/O.
- S একবার পুরো পড়া। S-এর তিনটি page এক এক করে তৃতীয় frame-এ আসে, আর প্রতিটির প্রতিটি tuple block-এর সব tuple-এর সাথে মেলানো হয়। Block দুই page-এর, তাই S-এর এক পড়ায় দুই page-এর কাজ।
- দ্বিতীয় block. R3 আর R4 আগের block-এর জায়গায়।
- S আবার পুরো। আরও ৩ I/O. R শেষ, join শেষ। S পড়া হলো ⌈4/2⌉ = ২ বার, page nested loop-এর ৪ বারের বদলে।
- সূত্র। ১০ I/O. Block যত বড়, S তত কম বার পড়া — পুরো R মেমোরিতে ধরলে M + N, এর কম কোনো join অ্যালগরিদমেই সম্ভব নয়, কারণ দুটি টেবিল অন্তত একবার পড়তেই হয়।
একটি সূক্ষ্ম কথা: কোনটি outer হবে? সূত্রে M দুবার আসে, N একবার — তাই ছোট টেবিলকে outer রাখা সস্তা। বড় উদাহরণে (M = 500, N = 2,000, B = 52) customers outer হলে 500 + ⌈500/50⌉ × 2,000 = 20,500; orders outer হলে 2,000 + ⌈2,000/50⌉ × 500 = 22,000.
Index nested loop
S-এর join কলামে index থাকলে ভেতরের লুপে পুরো S পড়ার দরকারই নেই — প্রতিটি r-এর জন্য index-এ সরাসরি মিল খোঁজা যায়। এটিই index nested loop (INL), আর অংশ ১-এর SQLite plan ঠিক এটিই করেছিল।
একটি খোঁজ = index-এর পথ (B+ tree-এ h, hash-এ ≈ 1–2) + মিলে যাওয়া S tuple আনা
শেষ পদটি আবার clustered না secondary তার ওপর নির্ভর করে। বড় উদাহরণে orders.cid-তে একটি secondary B+ tree ধরুন, প্রতি খোঁজে ৩ page আর প্রত্যেক গ্রাহকের ৫টি অর্ডার পাঁচটি আলাদা page-এ: 500 + 40,000 × (3 + 5) = 3,20,500 — block nested loop-এর চেয়ে ১৫ গুণ খারাপ! কিন্তু শুধু Sylhet-এর ২০০ জন গ্রাহক চাইলে, আর customers-এর city-তে index থাকলে outer-এর দিকেও scan লাগে না: মোটামুটি 200 × 8 = ১,৬০০ I/O, আর প্রথম ফল আসে প্রায় সাথে সাথে। ছোট outer + ভেতরে index = INL-এর রাজত্ব। OLTP অ্যাপের অধিকাংশ join ঠিক এমন: “এই গ্রাহকের অর্ডারগুলো দেখাও।”
৭ · Join ২ — sort-merge আর hash join
Sort-merge join
দুটি টেবিলই join কলামে সাজানো থাকলে দুটি আঙুল একসাথে নিচে নামানো যায় — ঠিক দুটি সাজানো রেজিস্টার পাশাপাশি রেখে রোল নম্বর মেলানোর মতো। Sort-merge join-এর দুই ধাপ: দরকার হলে দুটিকে অংশ ৫-এর মতো sort করা, তারপর merge. Merge-এ ছোট দিকের আঙুল এগোয়; সমান হলে দুই দিকের সমান-মানের পুরো দল জোড়া লাগে। ডুপ্লিকেটই এখানে আসল চ্যালেঞ্জ:
| তুলনা | |
| ফলের জোড়া |
- শুরু। দুটি তালিকাই সাজানো, আঙুল দুটি শুরুতে।
- ছোটটি এগোয়। R-এ 2, S-এ 1. S-এর 1-এর সাথে R-এর কোনো মান মিলতে পারে না, কারণ R-এর বাকি সবই ≥ 2. তাই S এগোল।
- আবার ছোটটি। 2 < 3, এবার R এগোল। প্রতিটি তুলনায় অন্তত একটি আঙুল এগোয় — তাই ডুপ্লিকেট না থাকলে merge-এর কাজ R আর S-এর মোট দৈর্ঘ্যের সমানুপাতিক।
- সমান — দল ধরে জোড়া। R-এ 3-এর দল দুটি, S-এ দুটি; ফল ২ × ২ = ৪ জোড়া। একটি সাবধানতা: R-এর দ্বিতীয় 3-এর জন্য S-এর দলটি আবার পড়তে হয় — তাই S-এর দলের শুরুতে একটি চিহ্ন (mark) রেখে ফিরে আসা হয়।
- পরের দল। 5-এর দল: R-এ একটি, S-এ দুটি — ২ জোড়া।
- শেষ। R-এর 7 S-এর 8-এর চেয়ে ছোট, R এগোতেই শেষ — S-এর 8 আর দেখার দরকার নেই। মোট ৬ জোড়া। খারাপ ক্ষেত্র: দুই দিকের সব মান এক হলে ফলটিই M·N জোড়ার cross product, আর merge-এর খরচও তেমন।
sort(X) = 2·X·(pass), আর আগে থেকে সাজানো থাকলে 0
বড় উদাহরণে B = 52: customers-এর ⌈500/52⌉ = ১০টি run, orders-এর ⌈2,000/52⌉ = ৩৯টি — দুটিই এক merge pass-এ মেলে, তাই প্রতিটির ২ pass. খরচ 2,000 + 8,000 + 2,500 = 12,500. একটি পরিচিত উন্নতি: দুই টেবিলের মোট run-সংখ্যা (১০ + ৩৯ = ৪৯) B−1-এর বেশি না হলে শেষ merge pass আর join-এর merge একসাথে করা যায় — ফাইল লিখে আবার পড়ার দরকার নেই। তখন খরচ হয় 3(M+N) = 7,500; এর শর্ত মোটামুটি B > √L, যেখানে L বড় টেবিলটির page.
Hash join
Hash join-এর ভাবনা লাইব্রেরির তাকের মতো: ছোট টেবিলের প্রতিটি সারি তার key-এর hash অনুযায়ী একটি bucket-এ রাখা (build), তারপর বড় টেবিলের প্রতিটি সারির জন্য শুধু তার bucket-টি দেখা (probe). Hash table-এর মূল কথা DSA কোর্সের hash table পর্বে। দোকানের ডেটায় customers-এর ওপর build, orders দিয়ে probe; hash হলো customer_id mod 4:
| hash table-এ | |
| probe হয়েছে | |
| key তুলনা | |
| ফলের সারি |
- Build. ছোট টেবিল customers পুরো পড়ে প্রতিটি সারি তার bucket-এ। এই ধাপ blocking — শেষ গ্রাহকটি না ঢোকা পর্যন্ত কোনো probe-এর উত্তর নিশ্চিত নয়।
- প্রথম probe. 1001-এর customer_id 1, hash 1. শুধু b1-এর দুটি সারি দেখা হলো, বাকি ছয়টি নয়। 1 মেলে, 5 মেলে না।
- দ্বিতীয় probe. b2-তে Karima. Probe অংশটি pipeline — প্রতিটি অর্ডার এলেই ফল ওপরে যেতে পারে।
- আরও দুটি। 1003 আবার b1-এ, 1004 b3-এ।
- বাকি ছয়টি। প্রতিটি অর্ডারের গ্রাহক আছে (foreign key), তাই সব ১০টি অর্ডারই একটি করে ফল দিল।
- লাভ আর সীমা। ২০টি তুলনা, nested loop-এর ৮০-র বদলে; বড় টেবিলে পার্থক্য হাজার গুণ। কিন্তু hash table মেমোরিতে ধরতে হবে — না ধরলে আগে ভাগ করে নেওয়া, যা নিচে।
ছোট টেবিলটিও মেমোরির চেয়ে বড় হলে Grace hash join: ভাগ (partition) ধাপে R আর S দুটিকেই একই hash ফাংশন h1 দিয়ে B−1টি ভাগে ভাঙা হয় (একটি input buffer, B−1টি output buffer), আর ভাগগুলো ডিস্কে লেখা হয় — খরচ 2(M+N), সব পড়া আর সব লেখা। একই key সবসময় একই নম্বরের ভাগে যায়, তাই R-এর ভাগ i-এর সারি শুধু S-এর ভাগ i-এর সারির সাথেই মিলতে পারে। Build-probe ধাপে প্রতিটি i-এর জন্য Ri মেমোরিতে এনে আরেকটি hash ফাংশন h2 দিয়ে hash table, তারপর Si পড়ে probe — খরচ M + N.
শর্ত: R-এর প্রতিটি ভাগ ≈ M/(B−1) page যেন B−2-এ ধরে → মোটামুটি B > √M
শর্তটির যুক্তি: probe-এর সময় একটি input আর একটি output buffer লাগে, তাই hash table-এর জন্য থাকে B−2 page. ভাগগুলো সমান হলে M/(B−1) ≤ B−2 দরকার, অর্থাৎ মোটামুটি M ≤ B², বা B ≥ √M (hash table-এর বাড়তি জায়গার জন্য একটি গুণক f যোগ হয়: B > √(f·M)). খেয়াল করুন, শর্তটি শুধু ছোট টেবিলের ওপর — বড়টি যত বড়ই হোক। বড় উদাহরণে √500 ≈ 22.4 < 52, তাই খরচ 3 × 2,500 = 7,500. কোনো একটি key খুব বেশি ঘন ঘন এলে (ধরুন একটি পাইকারি ক্রেতার লাখো অর্ডার) তার ভাগ মেমোরিতে ধরে না — তখন সেই ভাগটিকে আবার আলাদা hash দিয়ে ভাগ করতে হয় (recursive partitioning).
ইতিহাস এক নজরে। ১৯৭০-এর দশকে IBM-এর System R মূলত nested loop আর sort-merge join নিয়ে কাজ করত; Blasgen ও Eswaran-এর ১৯৭৭ সালের তুলনামূলক গবেষণা এই দুইয়ের খরচ বিশ্লেষণ করে। Grace hash join-এর নাম এসেছে টোকিও বিশ্ববিদ্যালয়ের GRACE database machine প্রকল্প থেকে (Kitsuregawa, Tanaka ও Moto-oka, ১৯৮৩); পরের বছর DeWitt ও সহকর্মীরা hybrid hash join প্রস্তাব করেন, যেখানে প্রথম ভাগটি ডিস্কে না লিখে মেমোরিতেই রাখা হয়। Goetz Graefe ১৯৯০-এর দশকের শুরুতে Volcano সিস্টেম আর তাঁর ১৯৯৩ সালের বিখ্যাত সমীক্ষা “Query Evaluation Techniques for Large Databases”-এ iterator মডেলকে মানক রূপ দেন।
৮ · কোনটি কখন জেতে — আর SQLite, PostgreSQL কী করে
বড় উদাহরণের (customers M = 500, pR = 80; orders N = 2,000; B = 52) সব হিসাব একসাথে:
| অ্যালগরিদম | সূত্র | page I/O | কখন জেতে |
|---|---|---|---|
| Tuple nested loop | M + pR·M·N | 8,00,00,500 | প্রায় কখনো নয় |
| Page nested loop | M + M·N | 10,00,500 | খুব ছোট টেবিল |
| Block nested loop | M + ⌈M/(B−2)⌉·N | 20,500 | ছোট R মেমোরিতে ধরে; non-equi join |
| Index nested loop | M + pR·M·(খোঁজ) | 3,20,500 (সবার জন্য); ≈ ১,৬০০ (২০০ জনের জন্য) | ছোট বা ফিল্টার করা outer + ভেতরে index |
| Sort-merge | sort(R) + sort(S) + M + N | 12,500 (উন্নত রূপে 7,500) | আগে থেকে সাজানো, বা ফল সাজানো চাই |
| Grace hash | 3(M+N), যদি B > √M | 7,500 | বড় equi-join, ক্রম দরকার নেই |
সিদ্ধান্তের চারটি নিয়ম মনে রাখুন। ছোট outer আর ভেতরে index → index nested loop. দুটি বড় টেবিলের equi-join → hash join. ইনপুট আগে থেকে সাজানো (যেমন index-এর ক্রমে আসছে), বা ফল সাজানো চাই (ORDER BY একই কলামে) → sort-merge, কারণ sort-এর খরচ হয় নেই, নয়তো দুই কাজে ভাগ হয়। আর শর্ত সমতা নয় (<, BETWEEN, LIKE) → nested loop পরিবার, কারণ hash আর merge দুটিই equi-join-এর জন্য — সমান key-ই একই bucket-এ বা পাশাপাশি আসে। (Band join-এর মতো কিছু range শর্তে sort-ভিত্তিক বিশেষ কৌশলও আছে।)
SQLite-এ লাইভ: index ছাড়া আর index-সহ join
SQLite-এ hash join বা merge join নেই — প্রতিটি join-ই nested loop. তার বদলে, ভেতরের টেবিলের join কলামে index না থাকলে SQLite কোয়েরি চলার সময়ই একটি অস্থায়ী automatic index বানিয়ে নেয়, আর সাম্প্রতিক সংস্করণে (৩.৩৮, ২০২২ থেকে) একটি Bloom filter-ও — যাতে মিলবে না এমন সারির জন্য index খোঁজাই না লাগে। কার্যত এটি hash join-এর build-এর মতো কাজ: একবার ভেতরের টেবিল পড়ে খোঁজার গঠন বানানো। দেখুন:
-- join কলাম cid-এ কোনো দিকেই index নেই
EXPLAIN QUERY PLAN
SELECT o.oid, r.reason
FROM orders_big o JOIN returns_big r ON r.cid = o.cid;
CREATE INDEX ob_cid ON orders_big(cid);
EXPLAIN QUERY PLAN
SELECT o.oid, r.reason
FROM orders_big o JOIN returns_big r ON r.cid = o.cid;
SELECT COUNT(*) FROM orders_big o JOIN returns_big r ON r.cid = o.cid;Index ছাড়া plan-এ তিনটি লাইন: SCAN o, BLOOM FILTER ON r (cid=?) আর SEARCH r USING AUTOMATIC COVERING INDEX (cid=?). অর্থাৎ বাইরের লুপে ১০,০০০ অর্ডার; returns_big থেকে চলার সময় একটি index আর একটি Bloom filter তৈরি, আর প্রতিটি অর্ডারের জন্য আগে Bloom filter-এ “থাকতে পারে কি?” প্রশ্ন, তারপর index-এ খোঁজ। Index বানানোর পর plan উল্টে যায়: SCAN r আর SEARCH o USING COVERING INDEX ob_cid (cid=?) — এখন ছোট টেবিল (৩০০টি ফেরত) বাইরে, আর বড় টেবিলে স্থায়ী index দিয়ে খোঁজ: পাঠ্যবইয়ের নিখুঁত index nested loop. দুই plan-এই ফল একই: 1500 — ৩০০টি ফেরতের প্রতিটির গ্রাহকের ৫টি অর্ডার। Plan শুধু কাজের পথ বদলায়, উত্তর নয়।
Automatic index বন্ধ করলে SQLite-কে খাঁটি nested loop চালাতে হয় — পাঠ্যবইয়ের সবচেয়ে সরল অ্যালগরিদম:
PRAGMA automatic_index = OFF;
EXPLAIN QUERY PLAN
SELECT o.oid, r.reason
FROM orders_big o JOIN returns_big r ON r.cid = o.cid;
SELECT COUNT(*) FROM orders_big o JOIN returns_big r ON r.cid = o.cid;Plan: SCAN o আর SCAN r — দুটি পূর্ণ scan, একটির ভেতরে আরেকটি: ১০,০০০ × ৩০০ = ৩০ লাখ জোড়া তুলনা। ফল তবু 1500. এই মাপে ব্রাউজারেও এক পলকে শেষ হয়, কিন্তু দুই টেবিল দশ গুণ করে বড় হলে কাজ একশো গুণ বাড়ে — automatic index-এ বাড়ত প্রায় দশ গুণ। এই PRAGMA শুধু পরীক্ষার জন্য; বাস্তবে চালু রাখুন, আর বারবার চলা কোয়েরির জন্য স্থায়ী index বানান — SQLite প্রতিবার automatic index বানায় আর ফেলে দেয়।
এবার এমন join, যেখানে সমতা নেই। দোকান অর্ডারের টাকা অনুযায়ী ছাড় দেয়: ১,০০০-এর নিচে ০%, ১,০০০–২,৯৯৯-এ ৫%, তার বেশিতে ১০%. প্রতিটি অর্ডারকে তার “স্ল্যাব”-এর সাথে মেলানো একটি non-equi join:
CREATE TABLE slabs (lo INTEGER, hi INTEGER, pct INTEGER);
INSERT INTO slabs VALUES (0, 999, 0), (1000, 2999, 5), (3000, 99999, 10);
EXPLAIN QUERY PLAN
SELECT o.oid, s.pct FROM orders_big o
JOIN slabs s ON o.amount BETWEEN s.lo AND s.hi;
SELECT s.pct, COUNT(*) AS orders FROM orders_big o
JOIN slabs s ON o.amount BETWEEN s.lo AND s.hi
GROUP BY s.pct;✓ আর “৩টি সারি বদলেছে”, তারপর plan: SCAN o আর SCAN s — automatic index-ও নেই, কারণ index-এ খোঁজার মতো কোনো সমতার শর্ত নেই। ফল: 0 | 1800, 5 | 4000, 10 | 4200 (100–999-এর ৯০০টি মান দুবার করে, ইত্যাদি)। এখানে nested loop-ই ঠিক অ্যালগরিদম: ভেতরের টেবিল মাত্র তিনটি সারির, মেমোরিতে থাকে — block nested loop-এর আদর্শ ক্ষেত্র। Hash join এই কোয়েরি চালাতেই পারত না।
একটি সিমুলেশন — I/O আর তুলনা গুনে দেখা
সূত্রগুলো কি সত্যিই মেলে? নিচের Python প্রোগ্রাম এলোমেলো key দিয়ে ছোট R (৮০টি tuple, প্রতি page-এ ৪টি, তাই M = 20) আর S (১৬০টি, N = 40) বানায়, B = 7 ধরে তিনটি অ্যালগরিদম আসলে চালায়, page I/O আর কাজ (তুলনা বা probe) গোনে, আর ফলের সারি মিলিয়ে দেখে। শেষ লাইনে বড় উদাহরণের সূত্রগুলো:
import math, random, collections
random.seed(42)
P = 4 # tuples per page
def paged(keys): return [keys[i:i + P] for i in range(0, len(keys), P)]
R = paged([random.randrange(100) for _ in range(80)]) # M = 20 pages
S = paged([random.randrange(100) for _ in range(160)]) # N = 40 pages
M, N, B = len(R), len(S), 7
flat = lambda rel: [t for p in rel for t in p]
def bnl(): # block nested loop, R outer
io = cmp = out = 0
for i in range(0, M, B - 2):
block = flat(R[i:i + B - 2]); io += len(R[i:i + B - 2])
for sp in S: # one full read of S per block
io += 1; cmp += len(sp) * len(block)
out += sum(r == s for s in sp for r in block)
return io, cmp, out
def sort_cost(n, b): # 2n I/O per pass; count passes exactly
runs, passes = math.ceil(n / b), 1
while runs > 1: runs, passes = math.ceil(runs / (b - 1)), passes + 1
return 2 * n * passes
def sort_merge():
r, s = sorted(flat(R)), sorted(flat(S))
i = j = cmp = out = 0
while i < len(r) and j < len(s):
cmp += 1
if r[i] < s[j]: i += 1
elif r[i] > s[j]: j += 1
else: # equal key: pair the two groups
a, b = r[i:].count(r[i]), s[j:].count(r[i])
out += a * b; i += a; j += b
return sort_cost(M, B) + sort_cost(N, B) + M + N, cmp, out
def grace(): # partition with h(k) = k mod (B-1)
part = lambda rel: [[t for t in flat(rel) if t % (B - 1) == h] for h in range(B - 1)]
pr, ps = part(R), part(S)
written = sum(math.ceil(len(x) / P) for x in pr + ps)
tables = [collections.Counter(br) for br in pr] # build: one table per R partition
out = sum(tables[h][t] for h, bs in enumerate(ps) for t in bs) # probe with S
return M + N + 2 * written, len(flat(S)), out
print(f"M={M} N={N} B={B} page NLJ formula M+M*N = {M + M * N}")
for name, f in (("block NLJ", bnl), ("sort-merge", sort_merge), ("grace hash", grace)):
print("{:10} page_io={:4} work={:6} rows={}".format(name, *f()))
M, N, B, pR = 500, 2000, 52, 80 # the big example, formulas only
print("big: tupleNLJ", M + pR * M * N, " pageNLJ", M + M * N, " BNL", M + math.ceil(M / (B - 2)) * N,
" SMJ", sort_cost(M, B) + sort_cost(N, B) + M + N, " grace", 3 * (M + N))প্রথম লাইন: M=20 N=40 B=7 page NLJ formula M+M*N = 820. তারপর block nested loop page_io= 180 — সূত্র 20 + ⌈20/5⌉ × 40 = 180, হুবহু; কিন্তু work= 12800, অর্থাৎ ৮০ × ১৬০টি জোড়ার প্রতিটি তুলনা। Sort-merge page_io= 300 (দুই sort-এ 80 + 160, merge-এ 60) আর মাত্র work= 134 তুলনা। Grace hash page_io= 188 — সূত্রের 3 × 60 = 180-এর চেয়ে ৮ বেশি, কারণ দুই টেবিলের ৬টি করে মোট ১২টি ভাগের অনেকগুলোর শেষ page আধা-ভরা, তবু পুরো page হিসেবে লেখা আর পড়া হয়; কাজ work= 160 — S-এর প্রতিটি tuple-এ একটি probe. তিনটিতেই rows=117 — উত্তর এক। শেষ লাইনে বড় উদাহরণ: big: tupleNLJ 80000500 pageNLJ 1000500 BNL 20500 SMJ 12500 grace 7500 — অংশ ৮-এর টেবিলের সংখ্যাগুলো। এই ছোট মাপে block nested loop-ও hash-এর সমান I/O নেয়, কারণ R প্রায় মেমোরিতে ধরে; পার্থক্য আসে মাপ বাড়লে — আর CPU-র কাজে (১২,৮০০ বনাম ১৬০) এখনই।
PostgreSQL-এর plan পড়া
PostgreSQL-এ তিনটি join-ই আছে, আর plan-এ নাম ধরেই দেখায়: Nested Loop, Hash Join, Merge Join. নিচের plan-টি ব্যাখ্যার জন্য, এই পাতায় চালানো যায় না; cost আর সময়ের সংখ্যা বাদ দিয়ে কাঠামোটুকু রাখা হয়েছে। গাছটি পড়ুন ভেতর থেকে বাইরে, নিচ থেকে ওপরে:
EXPLAIN ANALYZE
SELECT c.city, SUM(o.amount)
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
GROUP BY c.city;
HashAggregate -- ৪. শহর ধরে hash-এ যোগফল
Group Key: c.city
-> Hash Join -- ৩. probe: প্রতিটি অর্ডার
Hash Cond: (o.customer_id = c.customer_id)
-> Seq Scan on orders o -- ২. বড় দিক, pipeline-এ আসে
-> Hash -- ১. build: আগে পুরো customers
Buckets: 65536 Batches: 1 Memory Usage: ...kB
-> Seq Scan on customers c
-- একই কোয়েরি, অন্য পরিস্থিতিতে যে নোড দেখতে পারেন:
-- Merge Join Merge Cond: (c.customer_id = o.customer_id)
-- -> Index Scan using customers_pkey on customers c
-- -> Sort Sort Key: o.customer_id
-- Sort Method: external merge Disk: ...kB
-- Nested Loop
-- -> Seq Scan on customers c Filter: (city = 'Sylhet')
-- -> Index Scan using orders_customer_id_idx on orders o
-- Index Cond: (customer_id = c.customer_id)তিনটি জিনিস খুঁজে দেখার অভ্যাস করুন। Hash নোডের Batches: 1 মানে পুরো build দিক মেমোরিতে ধরেছে; ১-এর বেশি হলে PostgreSQL Grace-ধাঁচে (আসলে hybrid hash) ভাগ করে ডিস্কে লিখেছে। Sort Method: quicksort Memory মানে মেমোরিতেই সাজানো, external merge Disk মানে অংশ ৫-এর external sort, আর top-N heapsort মানে LIMIT-এর জন্য শুধু সেরা কয়েকটি রাখা। দুটির মেমোরিই work_mem সেটিং ঠিক করে — এটিই এই পর্বের B. পরীক্ষার জন্য SET enable_hashjoin = off; দিয়ে একটি অ্যালগরিদম নিষেধ করে দেখতে পারেন optimizer পরের পছন্দ কী নেয় — শুধু পরীক্ষায়, চালু সিস্টেমে নয়। MySQL দীর্ঘদিন শুধু nested loop-ভিত্তিক ছিল; 8.0.18 সংস্করণে hash join যোগ হয়, আর 8.0.20 থেকে block nested loop-এর জায়গা নেয়।
৯ · Projection, aggregation আর set operation
Join ছাড়াও তিনটি operator-এ একই দুই কৌশল ফিরে আসে — sort না hash:
- Duplicate বাদ দেওয়া (DISTINCT, relational algebra-র π): sort-ভিত্তিক পথে অপ্রয়োজনীয় কলাম ফেলে sort, তারপর পাশাপাশি সমান সারিগুলোর একটি রাখা — sort-এর pass 0-তেই কলাম ফেলা আর শেষ merge-এ duplicate বাদ দেওয়া যায়। Hash-ভিত্তিক পথে ভাগ করে প্রতিটি ভাগের hash table-এ “আগে দেখেছি কি?” প্রশ্ন। SQL-এর সাধারণ SELECT duplicate রাখে (bag semantics), তাই duplicate elimination-এর দাম দিতে হয় শুধু DISTINCT চাইলে।
- Aggregation (GROUP BY): sort করলে একই দলের সারি পাশাপাশি আসে, একটি scan-এ চলতি SUM/COUNT. Hash aggregate-এ প্রতিটি দলের জন্য একটি entry আর চলতি যোগফল — মেমোরি লাগে দলের সংখ্যা অনুযায়ী, সারির সংখ্যা নয়; ২ লাখ অর্ডার আর ৪টি শহর হলে hash table-এ ৪টি entry. GROUP BY ছাড়া SUM-এ শুধু একটি scan.
- Set operation: UNION, INTERSECT আর EXCEPT duplicate বাদ দেয় (পর্ব ২২), তাই দুই input মিলিয়ে sort বা hash লাগে।
UNION ALL-এ কিছুই লাগে না — প্রথম input শেষ হলে দ্বিতীয়টি, খাঁটি pipeline.
EXPLAIN QUERY PLAN
SELECT cid, SUM(amount) FROM orders_big GROUP BY cid;
EXPLAIN QUERY PLAN SELECT DISTINCT cid FROM returns_big;
CREATE INDEX ob_cid_amt ON orders_big(cid, amount);
EXPLAIN QUERY PLAN
SELECT cid, SUM(amount) FROM orders_big GROUP BY cid;
SELECT cid, COUNT(*) AS n, SUM(amount) AS total
FROM orders_big GROUP BY cid LIMIT 3;প্রথম plan: SCAN orders_big আর USE TEMP B-TREE FOR GROUP BY — sort-ভিত্তিক aggregation, সারিগুলো cid-এর ক্রমে সাজিয়ে তারপর দল ধরে যোগ। দ্বিতীয়: SCAN returns_big আর USE TEMP B-TREE FOR DISTINCT. (cid, amount)-এ index বানানোর পর তৃতীয় plan একটি লাইনে: SCAN orders_big USING COVERING INDEX ob_cid_amt — index-এ সারিগুলো আগে থেকেই cid-এর ক্রমে, আর amount-ও index-এর ভেতরে, তাই টেবিল না ছুঁয়ে, sort ছাড়াই, দল ধরে যোগ। ফল: 1 | 5 | 10500, 2 | 5 | 11955, 3 | 5 | 13410. Plan-এর লেখায় “TEMP B-TREE” থাকলেও বড় sort-এ SQLite-এর ভেতরের sorter আসলে external merge sort — দরকার হলে অস্থায়ী ফাইলে run লিখে।
EXPLAIN QUERY PLAN
SELECT cid FROM returns_big UNION SELECT cid FROM customers_big WHERE city = 'Dhaka';
EXPLAIN QUERY PLAN
SELECT cid FROM returns_big UNION ALL SELECT cid FROM customers_big WHERE city = 'Dhaka';
SELECT COUNT(*) AS dhaka_with_returns FROM (
SELECT cid FROM returns_big
INTERSECT
SELECT cid FROM customers_big WHERE city = 'Dhaka');UNION-এর plan: COMPOUND QUERY, তার নিচে LEFT-MOST SUBQUERY → SCAN returns_big, তারপর UNION USING TEMP B-TREE → SCAN customers_big — দুই দিকের সারি একটি অস্থায়ী B-tree-তে ঢুকিয়ে duplicate বাদ। UNION ALL-এর plan-এ একই কাঠামো, কিন্তু মাঝের লাইনটি শুধু UNION ALL — কোনো অস্থায়ী গঠন নেই। শেষ কোয়েরি: 75 — ঢাকার ৫০০ গ্রাহকের মধ্যে ৭৫ জনের অন্তত একটি পণ্য-ফেরত আছে। INTERSECT-ও duplicate বাদ দেয়, তাই একই গ্রাহকের একাধিক ফেরত একবারই গোনা হলো।
আরও গভীরে — vectorised আর compiled execution. Iterator মডেলে প্রতিটি tuple-এর জন্য প্রতিটি operator-এ একটি next() কল হয়; ডেটা ডিস্কে থাকলে এই খরচ চোখে পড়ে না, কিন্তু সব ডেটা মেমোরিতে থাকলে কোটি কোটি ফাংশন-কলই বড় খরচ হয়ে দাঁড়ায়। MonetDB/X100 (Boncz ও সহকর্মী, ২০০৫) দেখায় যে next() একবারে একটি নয়, এক হাজারের মতো মানের একটি “vector” ফেরত দিলে CPU-র cache আর SIMD নির্দেশ ভালোভাবে কাজে লাগে — DuckDB আর অনেক বিশ্লেষণী ডেটাবেস এই vectorised execution ব্যবহার করে। অন্য পথটি HyPer-এর (Thomas Neumann, ২০১১): পুরো plan-কে LLVM দিয়ে machine code-এ compile করা, যাতে একটি pipeline-এর সব operator একটি টানা লুপ হয়ে যায়; PostgreSQL 11 থেকে expression-এর জন্য ঐচ্ছিক JIT এই ভাবনার একটি ছোট রূপ।
১০ · AI-এর যুগে query processing
“কোয়েরিটা ধীর কেন?” — AI চট করে উত্তর দেয়, কিন্তু আপনার plan, ডেটার মাপ আর index সে না দেখে অনুমান করে। প্রচলিত ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| “JOIN-এর ক্রম বদলান” — যেন লেখার ক্রমই plan | সময় নষ্ট, কিছু বদলায় না | আগে-পরে EXPLAIN মেলান |
| SQLite-এ “hash join hint দিন” | অস্তিত্বহীন সিনট্যাক্স | ডকুমেন্টেশন দেখুন; SQLite-এ শুধু nested loop |
| সব কলামে index-এর পরামর্শ | লেখা ধীর, তবু secondary index-এ বেশি সারি হলে scan-ই সস্তা | selectivity হিসাব করুন; plan দেখুন |
| ভুল খরচের সূত্র (B−1 আর B−2 গুলিয়ে ফেলা, শেষ লেখা ধরা) | ভুল তুলনা | ছোট উদাহরণে হাতে গুনে মেলান |
| ORDER BY ছাড়া ফলের ক্রম ধরে নেওয়া | plan বদলালে ক্রম বদলায় | ক্রম দরকার হলে ORDER BY লিখুন |
| “optimizer-কে” বাদ দিয়ে কোয়েরি পুরো নতুন করে লেখা, NULL বা duplicate-এর অর্থ বদলে | দ্রুত কিন্তু ভুল উত্তর | আগের আর নতুন কোয়েরির COUNT আর EXCEPT মেলান |
AI-কে অনুমানের বদলে তথ্য দিন — ডেটাবেসের নাম ও সংস্করণ, schema, সারির সংখ্যা আর আসল plan:
AI-এর দেওয়া “দ্রুত করার” পরামর্শ মানার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Query processing-এর ধাপগুলো কী? — parsing ও translation, optimization, evaluation (execution); মাঝে relational algebra-র expression.
- External merge sort-এর খরচ? — 2N × (1 + ⌈logB−1⌈N/B⌉⌉). কিছু বই শেষ লেখা বাদ দিয়ে N(2⌈logB−1⌈N/B⌉⌉ + 1) লেখে — কোন নিয়ম ধরছেন বলে দিন।
- Block nested loop-এ কোনটি outer হবে? — ছোটটি; খরচ M + ⌈M/(B−2)⌉·N, তাই M ছোট হলে কম।
- Hash join কেন non-equi join পারে না? — hash শুধু সমান key-কে একই bucket-এ আনে; < বা BETWEEN-এর মিল ভিন্ন bucket-এ ছড়িয়ে থাকে।
- Pipelining আর materialization-এর পার্থক্য? — প্রথমটিতে tuple সরাসরি পরের operator-এ যায়; দ্বিতীয়টিতে মধ্যবর্তী ফল অস্থায়ী টেবিলে লেখা হয়। Sort আর hash build blocking.
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Hash join ১৯৮০-এর দশকেই গবেষণায় পরিচিত, অথচ MySQL-এ এটি এসেছে ২০১৯ সালের দিকে, আর SQLite-এ আজও নেই। এত দেরি বা অনুপস্থিতির কারণ কী হতে পারে?
প্রথম কারণ ইতিহাস আর হার্ডওয়্যার। System R-এর যুগে মেমোরি ছিল খুব দামি, আর hash join-এর শক্তি আসে বড় মেমোরি থেকে — Grace আর hybrid hash join-এর বিশ্লেষণে দেখা যায়, B যত বড়, ভাগ তত কম, আর মেমোরিতে ধরলে খরচ নেমে আসে M + N-এ। ১৯৮০-এর দশকে মেমোরি সস্তা হতে থাকায় গবেষণায় hash join জনপ্রিয় হয়, আর ১৯৯০-এর দশকে বড় বাণিজ্যিক ডেটাবেসগুলো একে একে এটি যোগ করে।
MySQL-এর গল্প আলাদা। এটি বড় হয়েছে ওয়েবের OLTP কাজে: “এই ব্যবহারকারীর অর্ডারগুলো দেখাও” ধরনের ছোট কোয়েরি, যেখানে outer ছোট আর ভেতরে index — index nested loop-এর আদর্শ ক্ষেত্র। Index না থাকলে MySQL block nested loop চালাত, আর পরামর্শ ছিল “index বানান”। বিশ্লেষণী কাজ যখন বাড়ল, দুটি বড় টেবিলের index-বিহীন equi-join-এ এই পথ দুর্বল প্রমাণিত হলো। তাই 8.0.18-এ hash join এল, আর 8.0.20 থেকে block nested loop-এর জায়গা নিল। নতুন অ্যালগরিদম যোগ করা মানে শুধু কোড নয় — optimizer-এর খরচের মডেল, মেমোরি সীমা আর ডিস্কে spill করার পথও বদলাতে হয়।
SQLite-এর লক্ষ্য একেবারে অন্য: একটি ছোট লাইব্রেরি, যা মোবাইল ফোন থেকে বিমান পর্যন্ত চলে, কম মেমোরিতে আর অনুমানযোগ্য আচরণে। এর কোয়েরিগুলো সাধারণত ছোট ডেটায়, আর বেশিরভাগ ক্ষেত্রে index থাকে। Automatic index আর Bloom filter প্রায় সেই কাজই করে, যা hash join-এর build করত — একবার ভেতরের টেবিল পড়ে খোঁজার গঠন — কিন্তু executor-এর নকশা একটিই থাকে: nested loop. কম অ্যালগরিদম মানে কম কোড, কম বাগ আর optimizer-এর কম ভুল।
মূল উপলব্ধি: কোন অ্যালগরিদম “সেরা” তা নির্ভর করে কাজের ধরন, মেমোরি আর সিস্টেমের লক্ষ্যের ওপর; প্রতিটি নতুন অ্যালগরিদম optimizer-এর জটিলতাও বাড়ায়।
প্র ০২আজকাল অনেক ডেটাবেস পুরোটাই মেমোরিতে থাকে, SSD-তে random পড়াও দ্রুত। তাহলে page I/O গুনে খরচ মাপা কি পুরোনো ধারণা? আধুনিক optimizer খরচ কীভাবে মাপে?
Page I/O মডেলের মূল দাবি হলো, ডিস্ক থেকে আনা এত ধীর যে CPU-র কাজ তুলনায় নগণ্য। ঘূর্ণায়মান হার্ডডিস্কের যুগে এটি প্রায় সত্য ছিল: একটি random পড়ায় কয়েক মিলিসেকেন্ড, যে সময়ে CPU লাখো নির্দেশ চালায়। কিন্তু মডেলটি কখনোই নিখুঁত ছিল না, তিনটি জায়গায় এটি বাস্তব থেকে সরে যায়।
প্রথমত, সব I/O সমান নয়। টানা পরপর page পড়া (sequential) random পড়ার চেয়ে অনেক সস্তা। তাই PostgreSQL তার খরচের মডেলে আলাদা দাম ধরে: ডিফল্টে seq_page_cost = 1 আর random_page_cost = 4; SSD-তে অনেকে দ্বিতীয়টি কমিয়ে দেন। এই কারণেই অংশ ৪-এর secondary index কম সারিতেই scan-এর কাছে হারে — প্রতিটি tuple-এর জন্য একটি random পড়া।
দ্বিতীয়ত, CPU-র খরচও আছে। PostgreSQL প্রতিটি tuple প্রক্রিয়ার জন্য cpu_tuple_cost (ডিফল্ট 0.01), প্রতিটি অপারেটর মূল্যায়নের জন্য cpu_operator_cost (0.0025) যোগ করে। ডেটা মেমোরিতে থাকলে এই পদগুলোই প্রধান হয়ে ওঠে — তখন block nested loop-এর লাখো তুলনা আর hash join-এর কয়েক হাজার probe-এর পার্থক্য CPU-তেই ধরা পড়ে। Main-memory ডেটাবেসের গবেষণা তাই cache miss গোনে, page নয়।
তৃতীয়ত, মেমোরির সীমা আজও আছে। একটি বড় sort বা hash join-কে work_mem-এর মতো সীমার মধ্যে চলতে হয়, কারণ একই সার্ভারে শত শত কোয়েরি একসাথে চলে। সীমা ছাড়ালেই spill — আর তখন এই পর্বের সূত্রগুলো হুবহু ফিরে আসে। ক্লাউডে ডেটা যখন নেটওয়ার্কের ওপারের স্টোরেজে থাকে, তখন “একটি page আনা” আবার দামি হয়ে যায়।
মূল উপলব্ধি: page I/O মডেল একটি সরলীকরণ, কিন্তু অ্যালগরিদমের মধ্যে বড় পার্থক্য সঠিকভাবে ধরে; আধুনিক optimizer এর ওপর random/sequential আর CPU-র খরচ যোগ করে সূক্ষ্ম করে।
প্র ০৩একটি ই-কমার্স সাইটে একটি করপোরেট ক্রেতার নামে মোট অর্ডারের অর্ধেক। orders আর customers-এর hash join আর sort-merge join-এ এই অসম বণ্টন (skew) কী সমস্যা করে, আর সিস্টেমগুলো কীভাবে সামলায়?
Grace hash join-এর হিসাব একটি নীরব অনুমানের ওপর দাঁড়িয়ে: hash ফাংশন সারিগুলো মোটামুটি সমান ভাগে ছড়াবে। কিন্তু একই key-এর সব সারি সবসময় একই ভাগে যায় — এটিই অ্যালগরিদমের সঠিকতার শর্ত। তাই একটি key-তে অর্ধেক সারি থাকলে কোনো hash ফাংশনই তাদের ছড়াতে পারে না; একটি ভাগ বাকিগুলোর চেয়ে বিশাল হয়।
Build দিক যদি customers হয়, সমস্যা কম — সেখানে করপোরেট ক্রেতা একটিই সারি। কিন্তু optimizer যদি ভুল অনুমানে orders-কে build দিক বানায়, বা join-টি orders-এর সাথে অন্য কোনো বড় টেবিলের হয়, তখন বড় ভাগটি B−2 page-এ ধরে না। প্রথম প্রতিকার recursive partitioning — সেই ভাগকে আরেকটি hash ফাংশন দিয়ে আবার ভাঙা। কিন্তু সব সারির key একই হলে আবার ভাঙলেও লাভ নেই; তখন সেই ভাগের জন্য block nested loop-এ নেমে যেতে হয়। অনেক সিস্টেম তাই খুব ঘন ঘন আসা মান আলাদা করে চেনে — PostgreSQL-এর পরিসংখ্যানে “সবচেয়ে সাধারণ মান”-এর তালিকা থাকে, আর hash join সেই মানগুলোর জন্য বিশেষ ব্যবস্থা রাখতে পারে।
Sort-merge-এ skew অন্যভাবে আঘাত করে। Sort নিজে skew-এ বিচলিত হয় না, কিন্তু merge-এ দুই দিকের সমান-key দল জোড়া লাগে। এক দিকে ১ লাখ আর অন্য দিকে ১০ হাজার একই key হলে ফলই ১০০ কোটি জোড়া — এটি অ্যালগরিদমের দোষ নয়, ফলের আকারই এমন। আর একটি দল মেমোরিতে না ধরলে সেটি বারবার পড়তে হয়।
Distributed সিস্টেমে (পর্ব ৫৪) skew আরও বড় সমস্যা: hash দিয়ে সার্ভারে ভাগ করলে একটি সার্ভার সব কাজ পায়, বাকিরা বসে থাকে। প্রচলিত কৌশল হলো ছোট টেবিলটি সব সার্ভারে পাঠানো (broadcast join), বা ঘন key-কে কৃত্রিমভাবে কয়েক টুকরো করা।
মূল উপলব্ধি: join-এর খরচের সূত্রগুলো সমান বণ্টন ধরে নেয়; বাস্তবের ডেটায় skew থাকেই, আর ভালো সিস্টেম সেটি চিনে আলাদা পথ নেয়।
প্র ০৪“সবচেয়ে দামি ১০টি অর্ডার দেখাও” — ORDER BY amount DESC LIMIT 10. ১ কোটি সারির টেবিলে এই কোয়েরি কি পুরো টেবিল sort করে? Index না থাকলে আর থাকলে কী কী পথ আছে?
সরল পথে হ্যাঁ: external merge sort দিয়ে পুরো ১ কোটি সারি সাজিয়ে প্রথম ১০টি নেওয়া। কিন্তু এটি ভীষণ অপচয় — দশটি সারির জন্য কয়েক pass-এ পুরো টেবিল পড়া আর লেখা। ভালো সিস্টেম জানে যে শুধু সেরা ১০টি দরকার।
Index না থাকলে প্রথম কৌশলটি top-N heapsort: মেমোরিতে ১০টি সারির একটি ছোট heap রাখা হয়, আর টেবিল একবার পড়ার সময় প্রতিটি নতুন সারি heap-এর সবচেয়ে ছোটটির চেয়ে বড় হলে তাকে বদলে দেয়। খরচ: টেবিলের একবার scan (M page) আর প্রতি সারিতে log 10-এর মতো তুলনা; কোনো run লেখা নেই। PostgreSQL-এর EXPLAIN ANALYZE ঠিক এই নামেই দেখায়: Sort Method: top-N heapsort. তবু এটি blocking — শেষ সারিটিও সেরা দশে ঢুকতে পারে, তাই পুরো টেবিল দেখার আগে প্রথম ফল আসে না।
amount-এ B+ tree index থাকলে পুরো চিত্র বদলায়। Index-এর পাতাগুলো ক্রমেই আছে, তাই শেষ পাতা থেকে উল্টো দিকে হাঁটলে প্রথম দশটি entry-ই উত্তর। খরচ: h page গাছ বেয়ে নামতে, তারপর কয়েকটি পাতা, আর secondary index হলে দশটি সারির জন্য দশটি টেবিল-খোঁজ। এখানে pipeline অটুট — LIMIT দশটি সারি পেয়ে child-এর next() ডাকা বন্ধ করে, আর নিচের scan আর এগোয়ই না। অংশ ৫-এর SQLite এডিটরে ঠিক এটিই দেখেছেন: TEMP B-TREE উধাও।
আরেকটি সূক্ষ্মতা: সমান amount-এর সারি থাকলে কোন দশটি আসবে তা অনির্দিষ্ট। Pagination-এ (পর্ব ১৫) এটি বাগ তৈরি করে — একই সারি দুই পাতায় আসে বা হারিয়ে যায়। তাই ORDER BY-তে একটি unique কলাম যোগ করুন (amount DESC, order_id DESC), আর index-ও সেই ক্রমে বানান।
মূল উপলব্ধি: LIMIT একটি blocking sort-কে ছোট heap-এ নামাতে পারে, আর সঠিক index sort-কে পুরো সরিয়ে pipeline ফিরিয়ে দেয় — প্রথম সারির সময় তখন টেবিলের আকারের ওপর নির্ভর করে না।
প্র ০৫Iterator মডেল প্রায় ত্রিশ বছর ধরে মানক। তাহলে DuckDB-র মতো নতুন সিস্টেম vectorised execution, আর কিছু সিস্টেম কোয়েরিকে machine code-এ compile করে কেন? Iterator মডেলের দুর্বলতা কোথায়?
Iterator মডেলের শক্তি তার সরলতা আর মডুলারিটি: প্রতিটি operator শুধু open, next, close জানে, তাই নতুন operator যোগ করা বা যেকোনোভাবে গাছ জোড়া সহজ। Graefe-এর Volcano সিস্টেম এই নকশায় সমান্তরাল চলাও একটি সাধারণ “exchange” operator দিয়ে যোগ করেছিল। যখন প্রধান খরচ ছিল ডিস্ক, প্রতি tuple-এ কয়েকটি ফাংশন-কল নগণ্য ছিল।
দুর্বলতা দেখা দেয় যখন ডেটা মেমোরিতে, আর কোয়েরি বিশ্লেষণী — কোটি সারির ওপর যোগফল। প্রতিটি সারির জন্য প্রতিটি operator-এ একটি virtual ফাংশন-কল, প্রতিটি কলে branch, আর একটি একটি মান প্রক্রিয়া মানে CPU-র pipeline আর cache ঠিকমতো ভরেই না। MonetDB/X100-এর গবেষকেরা ২০০৫ সালে দেখান, এমন কাজে সময়ের বড় অংশ যায় “কাজের ব্যবস্থাপনায়”, আসল যোগ-বিয়োগে নয়।
Vectorised পথে next() একবারে কয়েকশো থেকে হাজার মানের একটি vector ফেরত দেয়। ফাংশন-কলের খরচ হাজার ভাগ হয়, আর ভেতরের লুপ একটি কলামের মানের ওপর টানা চলে — compiler সেটিকে SIMD নির্দেশে বদলাতে পারে, আর ডেটা cache-এ থাকে। এটি কলাম-ভিত্তিক স্টোরেজের (পর্ব ৫৫) সাথে চমৎকার মেলে।
Compiled পথ (HyPer, ২০১১) আরও এগোয়: একটি pipeline-এর সব operator — scan, filter, hash table probe — একটি টানা লুপে মিশিয়ে machine code বানানো, যাতে মানগুলো CPU-র register-এই থাকে। খরচ হলো compile করার সময়, যা ছোট কোয়েরিতে লাভের চেয়ে বেশি হতে পারে; তাই PostgreSQL-এর JIT শুধু বড় খরচের কোয়েরিতে চালু হয়। দুই পথই আসলে iterator-এর ধারণা — চাহিদা-চালিত, pipeline-ভিত্তিক চলা — রেখে তার একক বড় করে।
মূল উপলব্ধি: বাধা যেখানে, অপ্টিমাইজেশন সেখানে — ডিস্কের যুগে page I/O, মেমোরির যুগে CPU-র cache আর ফাংশন-কল।
অনুশীলন
একটি কোয়েরি লেখা, দুটি হাতে হিসাব, একটি সিদ্ধান্ত।
-
কোয়েরি লিখুন: orders_big(oid, cid, amount)-এ প্রতিটি গ্রাহকের সবচেয়ে বড় অর্ডারের টাকা চাই, cid-এর ক্রমে। কোয়েরিটি লিখুন, আর এমন একটি index দিন, যাতে EXPLAIN QUERY PLAN-এ কোনো
USE TEMP B-TREEনা থাকে। (অংশ ৯-এর প্রথম এডিটরে চালিয়ে দেখতে পারেন।)CREATE INDEX ob_cid_amt ON orders_big(cid, amount); EXPLAIN QUERY PLAN SELECT cid, MAX(amount) AS biggest FROM orders_big GROUP BY cid ORDER BY cid;(cid, amount)-এর index-এ সারি cid-এর ক্রমে আসে, তাই GROUP BY আর ORDER BY দুটিই sort ছাড়া মেটে, আর amount index-এ থাকায় টেবিল ছুঁতে হয় না — plan-এ একটি লাইন:
SCAN orders_big USING COVERING INDEX ob_cid_amt. শুধু (cid)-এর index দিলেও sort চলে যায়, কিন্তু প্রতিটি সারির amount আনতে টেবিলে যেতে হয়। প্রথম তিনটি সারি:1 | 4100,2 | 4391,3 | 4682. -
হাতে হিসাব (trace): একটি ৩০ page-এর ফাইল B = 4 buffer দিয়ে external merge sort করা হবে। Pass 0-এ কতটি run, প্রতিটি কত page? পরের প্রতিটি pass-এর পর কতটি run? মোট pass আর মোট I/O কত? সূত্র দিয়ে মিলিয়ে দেখান।
Pass 0: ⌈30/4⌉ = ৮টি run — সাতটি ৪ page-এর, একটি ২ page-এর। Pass 1: ৩-way merge — ৮টি run থেকে ⌈8/3⌉ = ৩টি (১২, ১২ আর ৬ page). Pass 2: ৩টি থেকে ১টি। মোট ৩টি pass; প্রতি pass-এ 2 × 30 = 60 I/O, মোট 180. সূত্র: 1 + ⌈log3 8⌉ = 1 + 2 = 3 (কারণ 31 = 3 < 8 ≤ 9 = 32), খরচ 2 × 30 × 3 = 180. শেষ লেখা বাদের নিয়মে 150.
-
সিদ্ধান্ত নিন: students টেবিল ২০০ page, enrollments ৩,০০০ page, B = 22. join শর্ত student_id-এ সমতা, কোনো index নেই, ফলের ক্রম দরকার নেই। (ক) students outer ধরে block nested loop, (খ) Grace hash join — প্রতিটির খরচ বের করুন, hash-এর শর্ত মেলে কি না দেখুন। (গ) যদি শর্তটি হতো
e.score > s.cutoff?(ক) 200 + ⌈200/20⌉ × 3,000 = 200 + 10 × 3,000 = 30,200. (খ) 3 × (200 + 3,000) = 9,600; শর্ত B > √M: √200 ≈ 14.1 < 22, মেলে — students-এর প্রতিটি ভাগ ≈ 200/21 ≈ 10 page, hash table-এর জন্য B − 2 = 20 page যথেষ্ট। তাই hash join প্রায় তিন গুণ সস্তা। (গ) সমতা নেই, তাই hash বা merge চলে না — block nested loop-ই উত্তর, 30,200 I/O. এখানে B বাড়ানোই সবচেয়ে বড় লাভ: B = 202 হলে পুরো students মেমোরিতে, খরচ 3,200.
-
ফল আগে বলুন: R.k = (1, 4, 4, 4, 9) আর S.k = (4, 4, 6, 9, 9), দুটিই সাজানো। Sort-merge join-এ কতটি জোড়া আসবে, আর কোন কোন key থেকে? তারপর h(k) = k mod 3 দিয়ে R-এর ওপর build করলে কোন bucket-এ কী থাকবে, আর S-এর 6-এর probe কী ফল দেবে?
Merge: key 4 — R-এ ৩টি, S-এ ২টি → ৬ জোড়া; key 9 — R-এ ১টি, S-এ ২টি → ২ জোড়া; 1 আর 6 মেলে না। মোট ৮ জোড়া. Build: b0: 9; b1: 1, 4, 4, 4; b2: খালি। S-এর 6-এর hash 0 → b0-তে 9-এর সাথে তুলনা, মেলে না → কোনো ফল নেই। একই bucket মানেই মিল নয় — তাই probe-এ key আবার তুলনা করতেই হয়। দুই অ্যালগরিদমেই উত্তর ৮টি সারি, কারণ join-এর অর্থ অ্যালগরিদমের ওপর নির্ভর করে না।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি SQL কোয়েরি DBMS-এ পাঠানোর পর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- Parser লেখাটিকে parse tree-তে ভাঙে
- System catalog দেখে টেবিল আর কলামের নাম যাচাই
- Relational algebra-র expression tree (logical plan) তৈরি
- Optimizer সমতুল্য plan আর অ্যালগরিদমের খরচ তুলনা করে physical plan বাছে
- Executor সব operator-এ open() ডাকে
- বারবার next() ডেকে ফলের সারি ক্লায়েন্টে পাঠানো, শেষে close()
১০০ page-এর একটি ফাইল B = 5 buffer page দিয়ে external merge sort করলে (প্রতি pass-এ প্রতিটি page একবার পড়া ও একবার লেখা ধরে) মোট কতটি pass লাগবে?
- ২টি
- ৩টি
- ৪টি
- ৭টি
R-এর M = 100 page, S-এর N = 1,000 page, B = 12. R-কে outer ধরে block nested loop join-এর খরচ (ফল লেখা বাদে) কত page I/O?
- 1,00,100
- 10,100
- 9,100
- 1,100
Grace hash join সম্পর্কে কোন কথাটি ঠিক?
- এটি যেকোনো theta join চালাতে পারে
- এর ফল সবসময় join key-এর ক্রমে সাজানো থাকে
- এর খরচ সবসময় M + N
- খরচ প্রায় 3(M+N), আর মোটামুটি B > √M (M ছোট টেবিলের page) দরকার
কোন operator সাধারণত blocking — অর্থাৎ সব input না দেখে প্রথম output দিতে পারে না?
- ORDER BY-র sort
- WHERE-এর selection
- duplicate না বাদ দেওয়া projection
- UNION ALL
একটি SQLite কোয়েরির EXPLAIN QUERY PLAN-এ দেখা গেল SCAN orders আর USE TEMP B-TREE FOR ORDER BY. এর সবচেয়ে সঠিক অর্থ কী?
- SQLite orders-এ একটি স্থায়ী index বানিয়েছে
- কোয়েরিটিতে ত্রুটি আছে, ফল ভুল হতে পারে
- ক্রম মেলানোর মতো index নেই, তাই সারিগুলো চলার সময় অস্থায়ীভাবে সাজানো হচ্ছে
- SQLite একটি hash join চালাচ্ছে
একটি অ্যাপ বারবার জিজ্ঞেস করে, “গ্রাহক ৪২-এর সব অর্ডার”। customers ছোট নয়, orders-এ ১ কোটি সারি, orders.customer_id-তে B+ tree index আছে। কোন join অ্যালগরিদম সবচেয়ে উপযুক্ত?
- Grace hash join
- Index nested loop
- Sort-merge join
- Block nested loop
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Parse → relational algebra → optimize → execute. Physical plan হলো operator-এর গাছ, আর iterator মডেলে প্রতিটি operator open, next, close জানে; sort, hash build আর hash aggregate blocking, বাকিরা pipeline.
- খরচ page I/O-তে: R-এর M, S-এর N, B buffer; ফল লেখা ধরা হয় না। Selection: scan M, binary search ⌈log2 M⌉ + …, clustered index h + মিলের page, secondary index h + মিলের tuple.
- External merge sort: pass 0-এ ⌈N/B⌉টি run, তারপর (B−1)-way merge; pass = 1 + ⌈logB−1⌈N/B⌉⌉, খরচ 2N × pass. N ≤ B(B−1) হলে দুই pass.
- Join: block nested loop M + ⌈M/(B−2)⌉·N (ছোটটি outer); index nested loop ছোট outer-এ; sort-merge sort + M + N (উন্নত রূপে 3(M+N)); Grace hash 3(M+N), B > √M. Non-equi হলে nested loop.
- বাস্তবে plan দেখুন: SQLite-এ SCAN/SEARCH, AUTOMATIC COVERING INDEX, BLOOM FILTER, USE TEMP B-TREE; PostgreSQL-এ Nested Loop, Hash Join (Batches), Merge Join, Sort Method. Plan কাজের পথ বদলায়, উত্তর নয়।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৩ · Query optimization — cost, statistics আর planপরবর্তী পর্বএই খরচগুলো দিয়ে optimizer কীভাবে plan বাছে।
- পর্ব ৩২ · Relational algebra — select, project আর set অপারেশনফিরে দেখুনlogical plan-এর ভাষা।
- পর্ব ৪১ · Index ডিজাইন — composite, covering আর EXPLAINসম্পর্কিতকোন index কোন operator বদলায়।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- DSA · Merge sortসম্পর্কিত কোর্সexternal merge sort-এর merge ধাপের ভিত্তি।
- DSA · Hash tableসম্পর্কিত কোর্সhash join-এর build আর probe কীভাবে কাজ করে।