সরাসরি পর্বে যান
পর্ব ২১ · ৬৬-এর মধ্যে · মডিউল ৪

Index আর query performance — composite, covering index আর EXPLAIN

Indexes & Query Performance — Composite & Covering Indexes, EXPLAIN

Taught in Bangla, this lesson counts pages to compare full scans with B-tree index lookups on large tables, maps index types from clustered and secondary to partial and expression indexes, and teaches the leftmost-prefix rule for composite indexes and covering indexes. Live SQL editors show choosing indexes from access patterns, reading EXPLAIN plans, selectivity and common mistakes, plus local versus global secondary indexes on partitioned databases and other query-tuning tools.

এই পর্বে যা শিখবেন

  • কোটি সারির টেবিলে full scan আর index lookup-এর খরচ page গুনে হিসাব করা
  • Clustered, secondary, composite, covering, unique, partial, expression আর hash index — কোনটি কোন প্রশ্নের উত্তর
  • Access pattern-এর তালিকা থেকে index বাছাই, আর লেখার দামের সাথে মেলানো
  • EXPLAIN-এর plan পড়া, selectivity বোঝা, আর index ভেস্তে দেওয়ার চেনা ভুল সারানো
  • ভাগ করা ডেটাবেসে local বনাম global secondary index, আর index-এর বাইরে query দ্রুত করার হাতিয়ার

১ · ডেটা বাড়লে খোঁজার খরচ — full scan বনাম index

পর্ব ১৯-এ দেখেছেন storage engine ডিস্কে ডেটা কীভাবে সাজায়, পর্ব ২০-এ বেছেছেন ডেটাবেসের ধরন। এই পর্বের প্রশ্ন নকশার স্তরের: কোন query-র জন্য কোন index, তার দাম কত, আর ডেটা ১০০ গুণ বাড়লেও query দ্রুত রাখা যায় কীভাবে। B+ tree-র গঠন আর plan-এর লাইন ধরে খুঁটিনাটি আছে DBMS কোর্সের Index — B+ tree, hash আর bitmap আর Index ডিজাইন — composite, covering আর EXPLAIN পর্বে।

পুরো পর্বে একটি উদাহরণ সাথে থাকবে: একটি অনলাইন খাবার ও মুদি-ডেলিভারি সেবা (কাল্পনিক)। orders টেবিলে প্রতিটি অর্ডারের গ্রাহক (customer), দোকান (merchant), অবস্থা (status), সময় (created_at), টাকার অঙ্ক আর ডেলিভারির ফোন নম্বর। গ্রাহক app-এ নিজের অর্ডার-ইতিহাস দেখেন, দোকানের ড্যাশবোর্ডে অপেক্ষমাণ অর্ডার আসে, আর support desk ফোনের শেষ চার অঙ্ক দিয়ে অর্ডার খোঁজে।

পাড়ার মুদি দোকানের বাকির খাতা ভাবুন। সারা বছরের বিক্রি তারিখের ক্রমে লেখা। “রহিম ভাইয়ের সব বাকি” বের করতে প্রথম পাতা থেকে শেষ পাতা উল্টাতে হয় — খাতা যত মোটা, সময় তত বেশি। এটাই full table scan. চালাক দোকানি খাতার শেষে একটি সূচি রাখেন: নাম বর্ণানুক্রমে, পাশে পাতার নম্বর। খোঁজা এখন কয়েক সেকেন্ডের, কিন্তু প্রতিটি নতুন বিক্রির পর সূচিতেও এক লাইন লিখতে হয়। পড়া দ্রুত, লেখা একটু ধীর — index-এর পুরো গল্প।

হিসাবটি সংখ্যায় — ১০ কোটি সারি

ডেটাবেস ডিস্ক থেকে সারি একটা একটা করে পড়ে না, পড়ে page ধরে — কয়েক KB-র একটি খণ্ড (PostgreSQL-এ ডিফল্টে 8 KB, MySQL InnoDB-তে 16 KB, SQLite-এ 4 KB; সংস্করণ আর সেটিংভেদে আলাদা)। ধরা যাক টেবিলে N = ১০৮ (১০ কোটি) সারি, প্রতিটি প্রায় ১৬০ byte, তাই 8 KB-র একটি page-এ প্রায় ৫০টি সারি ধরে।

  • Full scan: N ÷ ৫০ = ২০,০০,০০০ page — প্রায় ১৬ GB. SSD থেকে টানা সেকেন্ডে ~১ GB পড়া গেলে (ধরা যাক) প্রায় ১৬ সেকেন্ড। ডেটা দ্বিগুণ হলে সময়ও দ্বিগুণ — খরচ O(N).
  • B-tree index: প্রতিটি page একটি node, আর একটি node-এ কয়েকশো key ধরে — এই সংখ্যাকে বলে fan-out. ধরা যাক f = ৩০০. মূল (root) থেকে পাতা (leaf) পর্যন্ত h স্তর পেরোলে সর্বোচ্চ fhটি key পৌঁছানো যায়। দরকার fh ≥ N, অর্থাৎ h ≥ logf N = ln N ÷ ln f. এখানে ৩০০৩ = ২.৭ × ১০৭ < ১০৮ ≤ ৩০০৪, তাই h = ৪. একটি সারি পেতে ৪টি index page আর টেবিলের ১টি page — মোট ৫টি, ২০ লাখের জায়গায়। খরচ O(log N).
  • বৃদ্ধির হার: ডেটা ১০০ গুণ বাড়লে উচ্চতা বাড়ে log৩০০ ১০০ ≈ ০.৮ স্তর — বড়জোর একটি page বেশি। আর মূল আর মাঝের স্তরগুলো সাধারণত memory-তে (buffer pool-এ) থাকে, তাই বাস্তবে ডিস্কে যায় মাত্র ১–২টি পড়া।

log₂ নয়, logf. Binary search tree-তে ১০৮ সারিতে লাগত log₂ ১০৮ ≈ ২৭ ধাপ, প্রতিটি হয়তো ডিস্কে আলাদা পড়া। B-tree-র “মোটা” node একবারে কয়েকশো দিক বাছে, তাই ২৭ নামে ৪-এ (DSA কোর্সের B-tree আর B+ tree পর্ব)। AI প্রায়ই দুটি গুলিয়ে “২৭টি disk read” লেখে।

একটি query ডেটাবেসের ভেতরে কোন পথে যায়, ছবিতে:

একটি query-র দুই পথ — index lookup বনাম full scan

App → query planner (with statistics) → SEARCH via B-tree, or SCAN of every page → executor

একটি query-র দুই পথ App service — অর্ডার সেবা Query planner Statistics পথ ক · SEARCH পথ খ · SCAN Executor → ফল app-এ WHERE customer_id = 42 কোন পথ সস্তা? খরচ অনুমান সারি · ভিন্ন মান B-tree index টেবিলের সব page টেবিলের ১ page ১০⁸ সারিতে ~৫ page … আরও লাখো page ২০ লাখ page ≈ ১৬ GB টানা পড়া ক: ~০.৫ ms · খ: ~১৬ s (cold SSD, ধরা যাক) root মাঝের মাঝের leaf SQL query index আছে, মিল কম index নেই, বা মিল বেশি
ছবি ২১.১ — Planner statistics দেখে খরচ অনুমান করে পথ বাছে। সবুজ রেখা SEARCH-এর পথ: root → মাঝের স্তর → leaf → টেবিলের একটি page. ডানে SCAN: প্রতিটি page পড়ে শর্ত মেলানো।

এবার একটি ছোট cost model চালিয়ে দেখুন — কয়েক মাপের টেবিলে কত page লাগে, কখন index হেরে যায়, আর প্রতিটি নতুন index লেখায় কত দাম যোগ করে। সব সংখ্যা ধরে নেওয়া (comment-এ লেখা), মাপা নয়:

// Index cost model: pages a query touches, and the price of k secondary indexes.
// Assumed (not measured): 8 KB pages, 50 rows per table page, B-tree fan-out 300,
// SSD: one random page ~0.1 ms, sequential reading ~1 GB/s (~0.008 ms per page).
const ROWS_PER_PAGE = 50, FANOUT = 300, RAND_MS = 0.1, SEQ_MS = 0.008, PAGE_B = 8192;
function height(n) {                        // B-tree levels, root to leaf, for n entries
  let pages = Math.ceil(n / FANOUT), h = 1;
  while (pages > 1) { pages = Math.ceil(pages / FANOUT); h++; }
  return h;
}
const f = x => x.toLocaleString("en-US");
const t = m => m < 1e3 ? m.toFixed(1) + " ms" : m < 6e4 ? (m / 1e3).toFixed(1) + " s" : (m / 6e4).toFixed(1) + " min";
console.log("A) one lookup or a 20-row range: full scan vs B-tree");
console.log("          rows  h     scan pages  scan time  point  20 rows  covering");
for (const n of [1e4, 1e6, 1e8, 1e10]) {
  const h = height(n), scan = Math.ceil(n / ROWS_PER_PAGE);
  console.log(f(n).padStart(14) + String(h).padStart(3) + f(scan).padStart(15) + t(scan * SEQ_MS).padStart(11) +
              String(h + 1).padStart(7) + String(h + 20).padStart(9) + String(h).padStart(10));
}
const N = 1e8, H = height(N), SCAN = N / ROWS_PER_PAGE;
console.log("\nB) N = 100,000,000: when does the index lose? (k matching rows, not covering)");
for (const k of [1e3, 1e5, 1e6, 1e7]) {
  const pages = H + Math.ceil(k / FANOUT) - 1 + k;   // index leaves + one random table page per row
  const idx = pages * RAND_MS, scan = SCAN * SEQ_MS;
  console.log(("k = " + f(k)).padEnd(15) + (k / N * 100 + "%").padEnd(7) + "index " + t(idx).padStart(8) +
              "   scan " + t(scan).padStart(6) + "   -> " + (idx < scan ? "index" : "scan"));
}
console.log("break-even k ~ " + f(Math.round(SCAN * SEQ_MS / RAND_MS)) + " rows");
const tableGB = SCAN * PAGE_B / 1e9, indexGB = Math.ceil(N / FANOUT) * PAGE_B / 1e9;
console.log("\nC) write price at N = 100,000,000 (table " + tableGB.toFixed(1) + " GB, one index ~" + indexGB.toFixed(1) + " GB)");
console.log("indexes  structures written per INSERT  path pages  disk");
for (const k of [0, 1, 3, 5]) {
  console.log(String(k).padStart(7) + String(1 + k).padStart(32) + String((1 + k) * H).padStart(12) +
              ((tableGB + k * indexGB).toFixed(1) + " GB").padStart(9));
}

আউটপুট:

A) one lookup or a 20-row range: full scan vs B-tree
          rows  h     scan pages  scan time  point  20 rows  covering
        10,000  2            200     1.6 ms      3       22         2
     1,000,000  3         20,000   160.0 ms      4       23         3
   100,000,000  4      2,000,000     16.0 s      5       24         4
10,000,000,000  5    200,000,000   26.7 min      6       25         5

B) N = 100,000,000: when does the index lose? (k matching rows, not covering)
k = 1,000      0.001% index 100.7 ms   scan 16.0 s   -> index
k = 100,000    0.1%   index   10.0 s   scan 16.0 s   -> index
k = 1,000,000  1%     index  1.7 min   scan 16.0 s   -> scan
k = 10,000,000 10%    index 16.7 min   scan 16.0 s   -> scan
break-even k ~ 160,000 rows

C) write price at N = 100,000,000 (table 16.4 GB, one index ~2.7 GB)
indexes  structures written per INSERT  path pages  disk
      0                               1           4  16.4 GB
      1                               2           8  19.1 GB
      3                               4          16  24.6 GB
      5                               6          24  30.0 GB

অংশ A: সারি ১০ লাখ গুণ বাড়ল, scan-এর page-ও ১০ লাখ গুণ (১.৬ ms থেকে প্রায় ২৭ মিনিট); অথচ উচ্চতা h মাত্র ২ থেকে ৫. একটি সারি মানে h + ১ page; ২০টি সারির range-এ প্রতিটির জন্য টেবিলে আলাদা page, তাই h + ২০; covering index-এ টেবিলে যাওয়াই নেই, তাই h (অংশ ৪)। অংশ B সবচেয়ে শিক্ষণীয়: মেলা সারি বেশি হলে index হেরে যায়। ১ লাখ সারিতে index জেতে (১০ s বনাম ১৬ s), ১০ লাখে (১%) এলোমেলো page পড়তে ১.৭ মিনিট, অথচ টানা scan ১৬ সেকেন্ড। এই মডেলে সমান-সমান বিন্দু প্রায় ১,৬০,০০০ সারি — টেবিলের ০.১৬%. বাস্তবের optimizer ভিন্ন ওজন ধরে (যেমন PostgreSQL-এর ডিফল্ট random_page_cost 4, seq_page_cost 1, কারণ cache ধরে নেয়), তাই সীমাটি প্রায়ই কয়েক শতাংশে — নীতি একই (অংশ ৬)। অংশ C: ৩টি secondary index মানে প্রতি INSERT-এ ৪টি কাঠামো লেখা, জায়গা ১৬.৪ থেকে ২৪.৬ GB. মডেলে table lookup “১টি page”, PostgreSQL-এর heap-এর মতো; InnoDB আর SQLite-এ টেবিল নিজেই B-tree, তবে তার উপরের স্তর সাধারণত memory-তে।

২ · Index-এর ধরন — নকশার চোখে একটি মানচিত্র

নকশার আলোচনায় একটি index-এর বিষয়ে তিনটি প্রশ্নই আসল: কী দিয়ে সাজানো, কোন প্রশ্নের উত্তর দেয়, আর কোন প্রশ্নে অচল. ভেতরের গঠন আলাদা পর্বের বিষয়; এখানে একটি মানচিত্র:

ধরনকী রাখেকাজে লাগেমনে রাখুন
Primary / clusteredটেবিলের সারিগুলোই key-এর ক্রমে সাজানোkey দিয়ে খোঁজা, key-এর rangeInnoDB-তে primary key; SQLite-এ rowid বা WITHOUT ROWID. PostgreSQL-এ টেবিল একটি heap — সব index secondary
Secondaryআলাদা গাছ: (key, সারির ঠিকানা)অন্য কলাম দিয়ে খোঁজাঠিকানা PostgreSQL-এ ভৌত (page, slot); InnoDB-তে primary key-এর মান — তাই দুবার গাছে নামা
Compositeকয়েকটি কলাম, বাঁ থেকে ক্রমেসমতা + range + sort একসাথেশুধু বাঁ দিকের অংশ খোঁজায় লাগে (অংশ ৩)
Coveringquery-র দরকারি সব কলামtable lookup ছাড়াই উত্তরindex চওড়া হয় (অংশ ৪)
Uniqueএকই key দুবার নয়খোঁজা আর নিয়ম রক্ষাretry-নিরাপদ লেখার ভিত্তি (নিচে)
Partial / filteredশুধু শর্ত মানা সারিছোট অংশে বারবার খোঁজা, যেমন pendingPostgreSQL আর SQLite-এ partial, SQL Server-এ filtered; MySQL-এ লেখার সময়ে নেই
Expressionহিসাবের ফল, যেমন lower(email)যে query হুবহু সেই expression লেখেPostgreSQL, SQLite; MySQL 8.0.13 থেকে functional key part
Hashkey-এর hash থেকে bucketশুধু সমতা, গড়ে O(1)range, ক্রম বা prefix পারে না; key-value store-এর মূল কাঠামো
Full-text (inverted)শব্দ → নথির তালিকালেখার ভেতরের শব্দ খোঁজাপর্ব ২৭
GeospatialR-tree, GiST বা geohash“কাছের ড্রাইভার”, এলাকার ভেতরেপর্ব ৫৮-এর ride-sharing case study

Clustered বনাম secondary — নকশার ফল। InnoDB-তে টেবিল primary key-এর ক্রমে সাজানো, তাই primary key-এর range একটানা page-এ পড়ে, আর প্রতিটি secondary entry-তে primary key থাকে। তাই primary key ছোট রাখুন — ১৬ byte-এর এলোমেলো UUID প্রতিটি secondary index-কে মোটা করে, আর গাছের এলোমেলো জায়গায় ঢুকে page split বাড়ায় (সময়-ক্রমের ID পর্ব ২৩-এ)। PostgreSQL-এ টেবিল heap; CLUSTER একবার সাজায়, পরের লেখায় সেই ক্রম থাকে না।

Hash বনাম B-tree. Hash index শুধু “ঠিক এই key” প্রশ্নে দ্রুত; “গত সাত দিনের অর্ডার” বা “নতুন আগে” পারে না। তাই relational ডেটাবেসে ডিফল্ট B-tree. PostgreSQL-এ hash index আছে (সংস্করণ ১০ থেকে crash-safe); InnoDB-তে নিজে hash index বানানো যায় না; ভেতরে একটি adaptive hash index নিজে থেকে তৈরি হতে পারে (ডিফল্টে চালু না বন্ধ, তা সংস্করণভেদে আলাদা)। পুরো ডেটাবেসটাই hash-এ সাজানো key-value store-এর কথা পর্ব ২০-এ।

Unique index — গতি নয়, নিয়ম। “আগে খুঁজে দেখি, না থাকলে INSERT” — দুটি অনুরোধ একসাথে এলে দুজনেই “নেই” দেখে, আর দুটি সারি ঢোকে; ব্যাংকের দুই কাউন্টারে একই চেক একসাথে জমা নেওয়ার মতো। সমাধান ডেটাবেসকেই বিচারক বানানো: UNIQUE (customer_id, idem_key) থাকলে দ্বিতীয় INSERT ব্যর্থ হয়, বা ON CONFLICT DO NOTHING-এ বাদ যায়। পর্ব ০৮-এর idempotency key এর ওপরই দাঁড়িয়ে; delivery semantics পর্ব ৩৪-এ।

-- composite + covering: search by (customer_id, created_at), carry status and amount in the leaves
CREATE INDEX idx_orders_cust_time ON orders (customer_id, created_at) INCLUDE (status, amount);
-- partial: only the ~6% of rows that are still pending
CREATE INDEX idx_orders_pending ON orders (merchant_id, created_at) WHERE status = 'pending';
-- expression: the query must say lower(email) exactly
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- unique as a rule: one order per (customer, idempotency key)
CREATE UNIQUE INDEX uq_orders_idem ON orders (customer_id, idem_key);
-- hash: equality only
CREATE INDEX idx_sessions_token ON sessions USING hash (token);

৩ · Composite index আর leftmost-prefix নিয়ম

আসল query-তে সাধারণত একাধিক শর্ত: “গ্রাহক ৪২-এর অর্ডার, নতুন আগে”। এর জন্য একাধিক কলামের একটিই index — composite index. পর্ব ০৬-এ composite key (board, year, roll) দেখেছিলেন: তিনটি দিলে সরাসরি একটি সারি, শুধু roll দিলে পুরো টেবিল। এখানে নিয়মটিকে নকশার হাতিয়ার বানাব।

ডাকঘরে চিঠি বাছাইয়ের তাক ভাবুন: প্রথমে জেলা অনুযায়ী খোপ, প্রতিটি জেলার ভেতরে থানা অনুযায়ী। “ঢাকার সব চিঠি” — এক খোপ। “ঢাকার মিরপুর থানার চিঠি” — আরও ছোট খোপ। কিন্তু “সব জেলার ‘সদর’ থানার চিঠি” চাইলে প্রতিটি জেলার খোপ খুলতে হবে, কারণ ‘সদর’ ছড়িয়ে আছে চৌষট্টি জায়গায়। Composite index ঠিক এই তাক: (customer_id, created_at) মানে আগে গ্রাহক, প্রতিটি গ্রাহকের ভেতরে সময়।

নিচে (customer_id, created_at) index-এর ১০টি entry — সাজানো ক্রমে, যেমন leaf page-এ থাকে। চারটি প্রশ্নে কতগুলো entry পড়তে হয়, ধাপে ধাপে দেখুন:

index-এর entry, সাজানো ক্রমেলাল = পড়তে হলো

            
হিসাব
খোঁজায় লাগল
entry পড়া
মিলেছে
আলাদা sort
  1. সাজানোর নিয়ম। Entry আগে customer_id দিয়ে, একই গ্রাহকের ভেতরে created_at দিয়ে সাজানো। গ্রাহক ৪২-এর চারটি অর্ডার তাই পাশাপাশি, সময়ের ক্রমে।
  2. শুধু প্রথম কলাম। গাছ ধরে সরাসরি প্রথম ৪২-এ নামা, টানা ৪টি entry, ৪৩ দেখামাত্র থামা। ১০ কোটি entry হলেও কাজ একই — নামার পথ শুধু ৪–৫ page.
  3. প্রথম কলামে সমতা, দ্বিতীয়তে range. (৪২, ২০২৬-০৬-০১)-এর পরের প্রথম entry-তে নামা — ৫ নম্বর; পড়া মাত্র ২টি। ৪২-এর ব্লকে সময় সাজানো, তাই দুই কলামই খোঁজায় লাগল।
  4. সমতা আর sort. “সর্বশেষ ২টি” — ৪২-এর ব্লকের শেষ মাথা থেকে উল্টো দিকে দুই ঘর; sort নেই, LIMIT পেয়েই থামা। অর্ডার-ইতিহাসের পাতা আর পর্ব ০৮-এর keyset pagination এভাবেই চলে।
  5. শুধু দ্বিতীয় কলাম। জুনের পরের অর্ডার ৫টি — ২, ৫, ৬, ৮ আর ১০ নম্বরে, চার গ্রাহকের ব্লকে ছড়ানো। টানা অংশ নেই, তাই ১০টিই পড়তে হলো; বাস্তবে planner তখন পুরো টেবিলই scan করে।
  6. নিয়মটি — leftmost prefix. খোঁজায় লাগে বাঁ দিক থেকে টানা কলামগুলোর সমতা; প্রথম range-এর কলামে খোঁজা থামে, ডানের কলাম শুধু ছাঁকে। মাঝের কলাম বাদ পড়লে তার ডানেরগুলোও খোঁজায় লাগে না।

এবার ৫০,০০০ সারির আসল টেবিলে একই পরীক্ষা। প্রতিটি এডিটরের শুরুতে নিচের নমুনা ডেটাবেস তৈরি হয়ে যায় (“নমুনা ডেটাবেস” খুলে দেখতে পারেন): recursive CTE দিয়ে ১ থেকে ৫০,০০০ পর্যন্ত i, আর ভাগশেষের সূত্রে মান — ৫,০০০ গ্রাহকের প্রত্যেকের ঠিক ১০টি অর্ডার, ২০০টি দোকান, প্রতি ৭ মিনিটে একটি অর্ডার (২০২৬-এর জানুয়ারি থেকে আগস্টের শেষ), আর প্রায় ৬% pending. এটি single-node SQLite — query plan আর ফল দেখায়, সময় মাপে না; বহু যন্ত্রে ভাগ করা ডেটাবেসের আচরণও দেখায় না। EXPLAIN QUERY PLAN query না চালিয়েই পথটি বলে।

-- Before: no secondary index at all
EXPLAIN QUERY PLAN
SELECT order_id, created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3;

CREATE INDEX idx_cust_time ON orders(customer_id, created_at);

-- (a) customer_id only, sorted by the second column
EXPLAIN QUERY PLAN
SELECT order_id, created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3;
-- (b) customer_id = ? and a range on created_at
EXPLAIN QUERY PLAN
SELECT order_id, amount FROM orders
WHERE customer_id = 42 AND created_at >= '2026-06-01';
-- (c) created_at alone: the leading column is missing
EXPLAIN QUERY PLAN
SELECT order_id, amount FROM orders WHERE created_at >= '2026-08-31';

SELECT order_id, created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 3;

প্রথম plan-এর detail কলামে দুটি লাইন: SCAN orders আর USE TEMP B-TREE FOR ORDER BY — ৫০,০০০ সারি পড়ে ১০টি বাছাই, তারপর আলাদা করে সাজানো। Index-এর পর:

(a) SEARCH orders USING INDEX idx_cust_time (customer_id=?)
(b) SEARCH orders USING INDEX idx_cust_time (customer_id=? AND created_at>?)
(c) SCAN orders

(a)-তে temp B-tree-এর লাইন নেই — index-এর ক্রমই উত্তরের ক্রম। (b)-তে বন্ধনীতে দুটি শর্তই, অর্থাৎ দুই কলামই খোঁজায় লাগল। (c)-তে index থাকা সত্ত্বেও SCAN orders: প্রথম কলাম নেই, শুরুর বিন্দু নেই। শেষ query-র ফল 49839 | 2026-08-31 06:33:00 | 2209, 44839 | 2026-08-06 23:13:00 | 3809 আর 39839 | 2026-07-13 15:53:00 | 509. এই index থাকলে আলাদা (customer_id) index অপ্রয়োজনীয় — বড়টির prefix-ই সেই কাজ করে।

মূল ধারণা

Index (a, b, c) একটি leftmost prefix-এর জন্য: a, a, b, a, b, c; a = ?-এর পরে b-তে range বা ORDER BY b. শুধু b বা শুধু c-এ অচল। তাই একটি index-এর প্রথম কলাম বাছাই মানে ঠিক করা — কোন প্রশ্নগুলো এই index ভাগ করে নেবে। আর একই কলামের দুটি ক্রম — (a, b) আর (b, a) — দুটি আলাদা index, দুটির জন্যই লেখার দাম।

৪ · Covering index — টেবিলে না গিয়ে উত্তর

অংশ ১-এর হিসাবে ২০টি সারির range-এ page লেগেছিল h + ২০. কারণ secondary index-এর entry-তে থাকে শুধু key আর সারির ঠিকানা। Query যদি amount-ও চায়, প্রতিটি entry থেকে টেবিলে যেতে হয় — table lookup, সাধারণত এলোমেলো জায়গায় একটি করে page. আর query-র দরকারি সব কলাম index-এই থাকলে টেবিলে যাওয়ার দরকারই নেই — এটাই covering index. Plan-এ SQLite লেখে USING COVERING INDEX, PostgreSQL লেখে Index Only Scan, MySQL-এর Extra কলামে Using index.

রেস্তোরাঁর ওয়েটারের হাতের কার্ডে শুধু পদের নাম লেখা থাকলে, প্রতিটি টেবিলের “এটার দাম কত?” প্রশ্নে তাঁকে রান্নাঘরে দৌড়াতে হয়। কার্ডেই দাম লেখা থাকলে উত্তর সঙ্গে সঙ্গে। Covering index সেই কার্ড — কিন্তু কার্ডে যত বেশি লিখবেন, কার্ড তত মোটা, আর দাম বদলালে প্রতিটি কার্ডও বদলাতে হবে।

CREATE INDEX idx_cust_time ON orders(customer_id, created_at);
-- 1) The history page wants time and amount
EXPLAIN QUERY PLAN
SELECT created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
-- 2) Put amount into the index too: the query needs nothing else
CREATE INDEX idx_cust_time_amt ON orders(customer_id, created_at, amount);
EXPLAIN QUERY PLAN
SELECT created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
-- 3) SELECT * needs every column again
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
-- 4) A shop dashboard: count and total per status
EXPLAIN QUERY PLAN
SELECT status, COUNT(*), SUM(amount) FROM orders WHERE merchant_id = 7 GROUP BY status;
CREATE INDEX idx_merch_status_amt ON orders(merchant_id, status, amount);
EXPLAIN QUERY PLAN
SELECT status, COUNT(*), SUM(amount) FROM orders WHERE merchant_id = 7 GROUP BY status;
SELECT status, COUNT(*) AS n, SUM(amount) AS taka FROM orders
WHERE merchant_id = 7 GROUP BY status;

Plan-গুলো ক্রমে:

1) SEARCH orders USING INDEX idx_cust_time (customer_id=?)
2) SEARCH orders USING COVERING INDEX idx_cust_time_amt (customer_id=?)
3) SEARCH orders USING INDEX idx_cust_time (customer_id=?)
4) SCAN orders
   USE TEMP B-TREE FOR GROUP BY
   SEARCH orders USING COVERING INDEX idx_merch_status_amt (merchant_id=?)

(১)-এ amount আনতে প্রতিটি সারির জন্য টেবিলে যাওয়া। (২)-এ planner নিজেই চওড়া index-টি বাছল — টেবিলে একবারও নয়; order_id চাইলেও covering থাকত, কারণ SQLite-এ order_id-ই rowid, যা প্রতিটি entry-তে থাকে। (৩)-এ SELECT * সব কলাম চায়, তাই আবার table lookup — SELECT * covering index-এর শত্রু। (৪)-এ আগে পুরো টেবিল আর আলাদা sort; পরে শুধু index থেকে গোনা আর যোগ। দোকান ৭-এর ফল: cancelled | 20 | 53400, delivered | 217 | 543384, pending | 15 | 46656. এখন idx_cust_time চওড়াটির prefix — মুছে দিলে প্রতি INSERT-এ একটি লেখা কমে।

PostgreSQL (১১ থেকে) আর SQL Server-এ আরও পরিষ্কার উপায় INCLUDE: বাড়তি কলাম শুধু leaf-এ বসে, key-তে নয় — গাছের মাঝের স্তর সরু থাকে, আর unique-এর নিয়মেও সেগুলো গোনা হয় না। InnoDB-তে প্রতিটি secondary entry-তে primary key থাকে, তাই primary key-এর কলাম চাওয়া query অনেক সময় এমনিই covering. PostgreSQL-এ সারিটি সবার কাছে দৃশ্যমান কি না, তা থাকে visibility map-এ (MVCC, পর্ব ২৪); সদ্য বদলানো page-এর জন্য Index Only Scan-কেও টেবিলে যেতে হয় — plan-এর Heap Fetches-এ দেখা যায়, আর VACUUM সেটি কমায়।

সবকিছু covering করবেন না। প্রতিটি নতুন কলাম entry চওড়া করে: page-এ কম entry, বড় index, memory-তে বেশি জায়গা, আর কলাম বদলালে index-ও বদলায়। Covering-এর জায়গা ঘনঘন চলা সরু query — অর্ডার-ইতিহাসের পাতা, ড্যাশবোর্ডের গোনা — মাসিক রিপোর্ট নয়।

৫ · Access pattern থেকে index — query-র তালিকা ধরে নকশা

“কোন কলামে index দেব?” প্রশ্নটি ভুল দিক থেকে শুরু। সঠিক প্রশ্ন: “কোন query, কত ঘনঘন, কত দ্রুত চাই?” পর্ব ০৬-এর নকশার ধাপে data model-এর পাশেই এই তালিকা লেখা হয় — প্রতিটি access pattern-এর জন্য query-র রূপ, আনুমানিক QPS আর latency-র লক্ষ্য (পর্ব ০২)। তারপর পাঁচটি ধাপ:

  1. কলাম তিন ভাগে: সমতার শর্ত (=, ছোট IN), range বা sort-এর কলাম, আর শুধু ফেরত দেওয়ার কলাম।
  2. ক্রম: সমতার কলাম আগে, তারপর range বা sort; ঘনঘন চলা হলে শেষে ফেরতের কলাম, যাতে index covering হয়।
  3. মেলানো: একটির prefix আরেকটির কাজ করলে আলাদা index নয়; দ্বৈত আর অব্যবহৃত index মুছুন।
  4. দাম গোনা: প্রতি INSERT-এ কয়টি কাঠামো, আকার কত, লেখার QPS কত।
  5. যাচাই: আসল মাপের ডেটায় EXPLAIN, deploy-এর পর প্রতিটি query-র p99.

ধাপ ২-এর একটি সূক্ষ্মতা। Range আর sort একই কলামে হলে — “গত সাত দিনের অর্ডার, নতুন আগে” — দ্বিধা নেই। আলাদা কলামে হলে — WHERE merchant_id = ? AND amount > 1000 ORDER BY created_at — (merchant_id, amount, created_at) কম entry পড়ে কিন্তু আলাদা sort লাগে; (merchant_id, created_at, amount) sort বাঁচায় আর LIMIT পেয়েই থামে, কিন্তু বেশি entry পেরোয়। MongoDB-র নির্দেশিকা এজন্য “Equality, Sort, Range” (ESR) ক্রমের পরামর্শ দেয়। কোনটি জিতবে, তা মেলা সারি আর LIMIT-এর ওপর নির্ভর করে — মেপে দেখুন।

এবার উদাহরণের সেবায় তিনটি access pattern থেকে index বাছাই, ধাপে ধাপে (QPS-গুলো ধরে নেওয়া):

নকশার খাতালাল = এই ধাপের সিদ্ধান্ত

            
হিসাব
secondary index
প্রতি INSERT-এ লেখা কাঠামো
index-এ পুরো মেটানো pattern
  1. তালিকা আগে। তিনটি access pattern, query-র রূপ আর আনুমানিক QPS. পুরোনো কোডের একটি এক-কলামের index (customer_id) আছে, কিন্তু কোনো pattern পুরো মেটে না — P1-এর sort আলাদা লাগে।
  2. P1 — সমতা, তারপর sort. তাই (customer_id, created_at): গ্রাহকের ব্লকের শেষ থেকে উল্টো দিকে ২০টি entry, sort নেই; পরের পাতা keyset দিয়ে। Secondary index এখন ২টি, প্রতি INSERT-এ ৩টি কাঠামো।
  3. P2 — partial index. সাধারণ উত্তর (merchant_id, status, created_at). কিন্তু pending সারি মোটের প্রায় ৬%, আর অর্ডার delivered হলেই আর লাগে না। তাই শুধু pending সারির index: ছোট, memory-তে সহজে থাকে। নতুন অর্ডার pending হয়ে ঢোকে বলে INSERT-এ এটিও লেখা হয় — কাঠামো এখন ৪টি। MySQL-এ partial index না থাকায় সেখানে সাধারণ composite-টিই।
  4. P3 — unique index একটি নিয়ম। Retry-তে একই idempotency key আবার এলে দ্বিতীয় সারি যেন না ঢোকে। আগে খুঁজে তারপর INSERT করলে race থাকে; UNIQUE হলে বিচারক ডেটাবেস। এখন তিনটি pattern-ই পুরো মেটে, কিন্তু secondary index ৪টি, প্রতি INSERT-এ ৫টি কাঠামো।
  5. মেলানো আর বাদ। পুরোনো (customer_id) এখন idx_cust_time-এর prefix — মুছে দেওয়া যায়, প্রতি INSERT-এ একটি লেখা কমে। শুধু created_at-এর মাসিক রিপোর্ট আর ফোনের শেষ চার অঙ্কের খোঁজ কম ঘনঘন — এগুলোর জন্য এখনই index নয়।
  6. দাম আর যাচাই। চূড়ান্ত: ৩টি index. প্রতিটি INSERT-এ টেবিল আর ৩টি index — ৪টি কাঠামো; সেকেন্ডে ১২০টি অর্ডারে ৪৮০টি B-tree হালনাগাদ, সাধারণ একটি ডেটাবেসের জন্য সহনীয়। অর্ডার delivered হলে সেই UPDATE-এ idx_pending থেকে entry-টি মোছে। নিচের লাইভ SQL-এ plan দেখুন; deploy-এর পর প্রতিটি query-র p99 দেখুন।

স্টেপারের নকশা নমুনা টেবিলে যাচাই করুন। P3-এর জন্য idem_key কলাম যোগ করা হলো; শেষে একই অনুরোধ দুবার আসে, যেমন timeout-এর পর app আবার পাঠালে হয়:

-- P1: a customer's history, newest first
CREATE INDEX idx_cust_time ON orders(customer_id, created_at);
-- P2: a shop's queue of pending orders (about 6% of rows), oldest first
CREATE INDEX idx_pending ON orders(merchant_id, created_at) WHERE status = 'pending';
-- P3: retry-safe order creation
ALTER TABLE orders ADD COLUMN idem_key TEXT;
CREATE UNIQUE INDEX uq_cust_key ON orders(customer_id, idem_key);

EXPLAIN QUERY PLAN SELECT order_id, created_at, amount FROM orders
WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 20;
EXPLAIN QUERY PLAN SELECT order_id, created_at FROM orders
WHERE merchant_id = 7 AND status = 'pending' ORDER BY created_at;
SELECT order_id, created_at FROM orders
WHERE merchant_id = 7 AND status = 'pending' ORDER BY created_at LIMIT 3;

-- The app timed out and sent the same order again (the server made a new order_id)
INSERT INTO orders VALUES (50001, 42, 7, 'pending', '2026-09-01 02:00:00', 650,
  '01304398618', 'K-7a1f') ON CONFLICT DO NOTHING;
INSERT INTO orders VALUES (50002, 42, 7, 'pending', '2026-09-01 02:00:05', 650,
  '01304398618', 'K-7a1f') ON CONFLICT DO NOTHING;
SELECT order_id, idem_key FROM orders WHERE customer_id = 42 AND idem_key = 'K-7a1f';

P1-এর plan SEARCH orders USING INDEX idx_cust_time (customer_id=?), কোনো temp B-tree নেই। P2-এর plan SEARCH orders USING COVERING INDEX idx_pending (merchant_id=?) — index-এ শুধু pending সারি, তাই status আলাদা করে মেলাতে হয় না, আর created_at ও order_id index-এই। দোকান ৭-এর সবচেয়ে পুরোনো তিনটি pending: 1446 | 2026-01-08 00:42:00, 5645 | 2026-01-28 10:35:00, 9844 | 2026-02-17 20:28:00. তারপর দুটি INSERT-এ “✓ INSERT — ১টি সারি বদলেছে” আর “✓ INSERT — ০টি সারি বদলেছে”: দ্বিতীয়টি UNIQUE-এ আটকে চুপচাপ বাদ। শেষ query-তে একটিই সারি, 50001 | K-7a1f. পুরোনো সারিগুলোর idem_key NULL, আর SQLite-এ (PostgreSQL-এও ডিফল্টে) UNIQUE-এ একাধিক NULL চলে।

লেখার দাম — write amplification, জায়গা আর bloat

অংশ ১-এর অংশ C মনে করুন: k-টি secondary index মানে প্রতি INSERT-এ k + ১টি কাঠামো লেখা — write amplification. সাথে আরও দাম:

  • UPDATE: index-এর কলাম বদলালে সেই index-এ পুরোনো entry মুছে নতুন বসানো। PostgreSQL-এ সারির নতুন সংস্করণ প্রায়ই নতুন জায়গায় লেখা হয়, তাই কলাম না বদলালেও সব index-এ নতুন entry লাগতে পারে — ব্যতিক্রম HOT update (সংস্করণ ৮.৩ থেকে)।
  • জায়গা আর memory: ১০ কোটি সারিতে একটি সরু index প্রায় ২.৭ GB (অংশ ১-এর মডেল); সব index memory-র জন্য একে অপরের সাথে লড়ে।
  • Index bloat: মোছা-বদলানো entry-র ফাঁকা জায়গা, আর page split-এর পর আধা-খালি page. এলোমেলো key (যেমন UUIDv4) নতুন entry গাছের সবখানে ছড়ায়; সময়-ক্রমের key ডান প্রান্তে জমে। প্রতিকার: PostgreSQL-এ VACUUM আর REINDEX CONCURRENTLY (১২ থেকে), MySQL-এ OPTIMIZE TABLE.
  • চালু সিস্টেমে বানানো: বড় টেবিলে সাধারণ CREATE INDEX লেখা আটকে রাখতে পারে; PostgreSQL-এ CREATE INDEX CONCURRENTLY, InnoDB-তে online DDL — ধীর, কিন্তু লেখা চলে। Zero-downtime migration পর্ব ৫৩-এ।

৬ · Query plan পড়া — EXPLAIN, selectivity আর optimiser-এর সিদ্ধান্ত

Index বানালেই ডেটাবেস সেটি ব্যবহার করবে, এমন নয়। সিদ্ধান্ত নেয় query optimizer — সম্ভাব্য পথগুলোর খরচ অনুমান করে সবচেয়ে সস্তাটি বাছে। বাছাই করা পথটির নাম query plan, আর দেখার কমান্ড EXPLAIN. PostgreSQL-এর EXPLAIN ANALYZE query-টি সত্যিই চালিয়ে অনুমানের পাশে আসল সময় আর আসল সারির সংখ্যা বসায় (UPDATE বা DELETE-এ তাই transaction-এর ভেতরে চালিয়ে ROLLBACK করুন); MySQL ৮.০.১৮ থেকে EXPLAIN ANALYZE আছে। একই ধারণার তিন ভাষা:

যা ঘটছেSQLite (এই পর্বের এডিটর)PostgreSQLMySQL
পুরো টেবিল পড়াSCAN ordersSeq Scantype: ALL
Index দিয়ে খোঁজা + table lookupSEARCH … USING INDEXIndex Scan; বেশি সারিতে Bitmap Heap Scantype: ref বা range
শুধু index থেকে উত্তরUSING COVERING INDEXIndex Only ScanExtra: Using index
পুরো index হাঁটা (খোঁজা নয়)SCAN … USING INDEXFull Index Scan-ধাঁচের পথtype: index
আলাদা করে সাজানোUSE TEMP B-TREE FOR ORDER BYSortExtra: Using filesort
OR-এর দুই অংশ দুই index-এMULTI-INDEX ORBitmapOrindex merge

নামগুলো সংস্করণভেদে কিছুটা আলাদা; লাইন ধরে পড়ার নিয়ম DBMS কোর্সের Index ডিজাইন পর্বে। নকশাকারের চারটি প্রশ্নই যথেষ্ট: পুরো টেবিল নাকি index? অনুমিত আর আসল সারি কাছাকাছি কি না? আলাদা sort আছে কি না? কতগুলো সারি পড়ে কতগুলো ফেরত — PostgreSQL-এর Rows Removed by Filter বড় হলে index ঠিক কাজ করছে না।

Selectivity আর cardinality

একটি কলামের cardinality হলো তাতে কতগুলো ভিন্ন মান; একটি শর্তের selectivity হলো শর্তটি মোট সারির কত ভাগ রাখে। নমুনা টেবিলে customer_id-এর cardinality ৫,০০০, তাই customer_id = 42 রাখে ৫০,০০০-এর ১০টি — ০.০২%, চমৎকার। status-এর cardinality ৩, আর status = 'delivered' রাখে ৪২,৭৮০টি — প্রায় ৮৬%. অংশ ১-এর অংশ B বলেছিল: এত সারি এলোমেলো table lookup-এ আনার চেয়ে টানা scan সস্তা, তাই PostgreSQL-এর মতো cost-based optimizer এমন শর্তে সাধারণত Seq Scan বাছে। SQLite-এর planner সরলতর — এই টেবিলে status-এ index বানিয়ে ANALYZE চালানোর পরও (statistics-এ 50000 16667) সে status = 'delivered'-এ index-ই নিয়েছে। আচরণ ডেটাবেসভেদে আলাদা; নিজের ডেটাবেসে plan দেখে নিন।

Optimizer index উপেক্ষা করলে কারণ সাধারণত এগুলোর একটি:

  • মিল অনেক বেশি (কম selectivity), বা টেবিল ছোট — scan সত্যিই সস্তা।
  • পুরোনো statistics: লাখো সারি আমদানির পর অনুমান ভুল — ANALYZE চালান।
  • শর্ত index-এর ভাষায় লেখা নয়: ফাংশন, টাইপ, শুরুর wildcard, আলাদা কলামে OR — অংশ ৭।
  • মানভেদে আলাদা ছবি: একটি বড় দোকানের অর্ডার মোটের ৪০% হলে একই query ছোট দোকানে দ্রুত, বড়টিতে ধীর (hot key); prepared statement-এর সাধারণ plan সব মানে সমান ভালো নাও হতে পারে।

সব মিলিয়ে একটি ধীর query সারানোর পথ — ১০ কোটি সারির টেবিলে (ধরা যাক)। Plan-এর লেখা নমুনা টেবিলে SQLite-এ যাচাই করা; page আর সময় অংশ ১-এর মডেল থেকে:

  1. খোঁজাslow log
  2. PlanEXPLAIN
  3. Indexসমতা → sort
  4. Coveringটেবিল বাদ
  5. মাপাp99, লেখা
দোকানের ড্যাশবোর্ড: “সর্বশেষ ২০টি অর্ডার”লাল = এই ধাপের মূল তথ্য

            
১০ কোটি সারিতে (মডেল, cold SSD)
page পড়া
আনুমানিক সময়
secondary index
  1. অনুমান নয়, তালিকা থেকে খোঁজা। Slow-query log বা query-ভিত্তিক পরিসংখ্যান, মোট সময় (চলার সংখ্যা × গড়) ধরে সাজানো। এক নম্বরে দোকানের ড্যাশবোর্ড; এর p99 লক্ষ্যের বহু গুণ।
  2. Plan পড়া। SCAN orders আর USE TEMP B-TREE FOR ORDER BY: পুরো টেবিল — মডেলে ২০ লাখ page, প্রায় ১৬ সেকেন্ড — তারপর আলাদা sort.
  3. সমতা আগে, sort পরে। (merchant_id, created_at) বানানোর পর SEARCH … USING INDEX idx_m_t (merchant_id=?), temp B-tree নেই। গাছে নামা ৪ page, তারপর ২০টি সারির জন্য ২০টি table lookup — মোট ২৪ page, প্রায় ২.৪ ms — সময়ে ছয় হাজার গুণেরও বেশি উন্নতি।
  4. Covering. status আর amount-ও index-এ রাখলে USING COVERING INDEX: টেবিলে যাওয়াই নেই, ৪ page, প্রায় ০.৪ ms. ঘনঘন চলা ড্যাশবোর্ড বলে বাড়তি চওড়া index-এর দাম এখানে যুক্তিসংগত। মাঝের এই ধাপে index দুটি।
  5. পরিষ্কার আর মাপা। idx_m_t এখন চওড়াটির prefix — মুছে দিন; চালু সিস্টেমে লেখা না আটকে বানান। তারপর মাপুন: p99 নামল কি না, INSERT-এর latency কতটা বাড়ল। লাভ না হলে নতুন index-টিই মুছুন।

৭ · Index ভেস্তে দেওয়ার চেনা ভুল

Index আছে, অথচ plan বলছে SCAN — এর সবচেয়ে সাধারণ কারণ query-র লেখা। Index-এ সাজানো আছে কলামের মান; শর্ত যদি মানটিকে বদলে তারপর মেলায়, সাজানো ক্রম কাজে লাগে না। যে শর্ত index ব্যবহার করতে পারে, তাকে বলে sargable. প্রথম দুটি ভুল আর তাদের সারানো রূপ:

CREATE INDEX idx_created ON orders(created_at);
CREATE INDEX idx_phone   ON orders(delivery_phone);
-- 1) A function wrapped around the indexed column
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE date(created_at) = '2026-03-15';
-- Fixed: a half-open range on the bare column
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16';
SELECT COUNT(*) AS by_function FROM orders WHERE date(created_at) = '2026-03-15';
SELECT COUNT(*) AS by_range FROM orders
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16';
-- 2) Leading wildcard: the support desk types the last 4 digits
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE delivery_phone LIKE '%8618';
-- Fixed: index exactly the expression the query asks for
CREATE INDEX idx_phone_last4 ON orders(substr(delivery_phone, -4));
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE substr(delivery_phone, -4) = '8618';
SELECT COUNT(*) AS by_last4 FROM orders WHERE substr(delivery_phone, -4) = '8618';

ফাংশনে মোড়া শর্তে SCAN orders — index-এ সাজানো আছে created_at, date(created_at) নয়। একই প্রশ্ন range-এ লিখলে SEARCH orders USING INDEX idx_created (created_at>? AND created_at<?). দুই উত্তর হুবহু এক: by_function = 205, by_range = 205 (দিনে ১,৪৪০ মিনিট ÷ ৭ ≈ ২০৫টি অর্ডার)। Range-টি half-open — >= শুরু, < পরের দিন — তাই সময়ের অংশ যা-ই হোক, কিছু বাদ পড়ে না। শুরুতে wildcard দেওয়া LIKE '%8618'-এ আবার SCAN orders: প্রথম অক্ষরই অজানা, সাজানো তালিকায় শুরুর বিন্দু নেই। শেষ চার অঙ্কের expression index-এর পর SEARCH orders USING INDEX idx_phone_last4 (<expr>=?), আর by_last4 = 10 — গ্রাহক ৪২-এর দশটি অর্ডার। Query-কে index-এর expression হুবহু লিখতে হয়; অর্থে এক কিন্তু লেখায় ভিন্ন হলে ডেটাবেস মেলায় না।

আরও তিনটি পরিচিত ফাঁদ — হিসাব, টাইপ আর OR:

CREATE INDEX idx_cust ON orders(customer_id);
-- 3) Arithmetic on the column
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders WHERE customer_id + 0 = 42;
-- 4) A text literal for an INTEGER column
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders WHERE customer_id = '42';
-- 5) OR across two columns, only one of them indexed
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE customer_id = 42 OR merchant_id = 7;
CREATE INDEX idx_merch ON orders(merchant_id);
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders
WHERE customer_id = 42 OR merchant_id = 7;
SELECT COUNT(*) AS n FROM orders WHERE customer_id = 42 OR merchant_id = 7;

(৩) customer_id + 0 = 42-এ SCAN orders — হিসাবটি অন্য পাশে সরালেই সারে। (৪) customer_id = '42'-এ কিন্তু SEARCH orders USING INDEX idx_cust (customer_id=?): SQLite কলামের টাইপ দেখে লেখাটিকে সংখ্যায় বদলে নেয়। MySQL-এ উল্টো ঘটনাটি বিপজ্জনক — VARCHAR কলামকে সংখ্যার সাথে মেলালে প্রতিটি সারির মান বদলে তুলনা হয় (implicit conversion), ফলে index বাদ। ডেটাবেসভেদে আলাদা, তাই app আর ORM-এ টাইপ মিলিয়ে লিখুন। (৫) এক কলামে index থাকলে SCAN orders; দুটিতেই থাকলে plan-এ পাঁচটি লাইন — MULTI-INDEX OR, তার নিচে INDEX 1 আর SEARCH orders USING INDEX idx_cust (customer_id=?), তারপর INDEX 2 আর SEARCH orders USING INDEX idx_merch (merchant_id=?): দুই খোঁজের ফল জোড়া। মোট n = 262 — গ্রাহক ৪২-এর ১০টি আর দোকান ৭-এর ২৫২টি, কোনোটি দুই দলেই নেই।

ভুলকী ঘটেসারানোর পথ
কলামে ফাংশন বা হিসাবসাজানো মান কাজে লাগে না, SCANকলাম খালি রেখে range লিখুন; না পারলে expression index
শুরুতে wildcard (LIKE '%x')শুরুর বিন্দু নেই, SCANপুরো মানে খোঁজা; শেষাংশের expression বা উল্টো কলাম; মাঝের শব্দ খুঁজতে full-text (পর্ব ২৭)
আলাদা কলামে ORএক index-এ দুই প্রশ্ন হয় নাদুই কলামেই index (MULTI-INDEX OR, BitmapOr, index merge), অথবা দুই query-র UNION
টাইপ না মেলাকিছু ডেটাবেসে প্রতিটি সারির মান রূপান্তর, index বাদকলামের টাইপেই মান পাঠান
SELECT *covering অচল, network-এ বেশি byteদরকারি কলামের নাম লিখুন
N+1 queryতালিকার প্রতিটি সারির জন্য আলাদা query — N বার round tripIN (…) বা JOIN-এ একবারে; GraphQL-এ DataLoader (পর্ব ০৯)
সীমাহীন OFFSETOFFSET 100000 মানে এক লাখ সারি পেরিয়ে ফেলাkeyset pagination (পর্ব ০৮)
কম cardinality-র কলামে একা indexstatus-এর মতো কলামে মিল বেশি, লাভ কমcomposite-এর অংশ, বা partial

N+1 মনে রাখার মতো: ৫০টি অর্ডারের প্রতিটির দোকানের নাম আলাদা query-তে আনলে ৫১টি round trip — ০.৫ ms করে হলেও শুধু যাতায়াতে ২৫ ms-এর বেশি। ORM প্রায়ই নিঃশব্দে এটি করে; slow-query log-এ প্রতিটি query দ্রুত, অথচ পাতা ধীর।

৮ · ভাগ করা ডেটাবেসে secondary index — local বনাম global

ডেটা একটি যন্ত্রে না ধরলে টেবিল ভাগ হয় কয়েকটি partition বা shard-এ (পর্ব ২৩)। ধরা যাক orders ভাগ হয়েছে customer_id-এর hash দিয়ে ৮ ভাগে — partition key customer_id. গ্রাহকের ইতিহাস (P1) তাই একটি partition-এই মেটে। কিন্তু দোকানের ড্যাশবোর্ড খোঁজে merchant_id দিয়ে, আর দোকান ৭-এর অর্ডার আটটি partition-এই ছড়ানো। Secondary index তাহলে থাকবে কোথায়? দুটি উত্তর:

Local index — প্রতিটি partition নিজের

প্রতিটি partition শুধু নিজের সারির index রাখে। লেখা: একই partition, একই transaction — index কখনো পিছিয়ে থাকে না। পড়া: partition key ছাড়া query সব partition-এ যায়, তারপর ফল জোড়া — scatter-gather; খরচ partition-এর সংখ্যার সমানুপাতিক, অপেক্ষা সবচেয়ে ধীরটির জন্য।

লেখা সস্তা ও নিখুঁত, পড়া ছড়ানো

Global index — index নিজেই ভাগ করা

Index ভাগ হয় indexed কলাম দিয়েই: দোকান ৭-এর সব entry একটি index-partition-এ। পড়া: এক জায়গায়; covering হলে দ্বিতীয় ধাপও নেই। লেখা: সারি এক partition-এ, entry আরেকটিতে — হয় distributed transaction (পর্ব ২৫), নয়তো async হালনাগাদ, তখন index কিছুক্ষণ পিছিয়ে।

পড়া এক জায়গায়, লেখা দামি বা পিছিয়ে

ঈদের আগে আটটি বাস কোম্পানির কাউন্টারে টিকিট বিক্রি হচ্ছে, প্রতিটি নিজের খাতায় যাত্রীর নাম রাখে — local index. “রফিক সাহেব কোন বাসে?” জানতে আটটি কাউন্টারেই লোক পাঠাতে হয়, আর সবচেয়ে ধীরটির উত্তরের অপেক্ষা। আরেক উপায়: টার্মিনালে নামের অক্ষর ধরে ভাগ করা একটি কেন্দ্রীয় তালিকা — global index. খোঁজা এক জানালায়, কিন্তু প্রতিটি বিক্রির খবর তালিকায় পৌঁছাতে হয়, আর ভিড়ে তা একটু দেরিতে পৌঁছায়।

দুই পথের দাম সংখ্যায় দেখুন। ধরে নেওয়া হলো: একটি call সাধারণত ২–৪ ms, কিন্তু ০.৫% ক্ষেত্রে ৬০ ms (garbage collection-এর বিরতি, compaction ইত্যাদি); আর global index-এর async হালনাগাদের দেরি গড়ে ৫০ ms-এর exponential বণ্টনে। Seed-যুক্ত PRNG, তাই প্রতিবার একই ফল:

// Orders split into 8 partitions by customer_id. Query: "merchant 7's latest 20 orders".
// LOCAL index : each partition indexes only its own rows -> ask all 8, merge (scatter-gather).
// GLOBAL index: partitioned by merchant_id -> one partition answers (covering), updated async.
// Assumed latency of one call: 2-4 ms, but 60 ms with probability 0.5% (GC pause, compaction).
function mulberry32(a) {
  return () => { a |= 0; a = a + 0x6D2B79F5 | 0; let t = Math.imul(a ^ a >>> 15, 1 | a);
    t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; };
}
const rnd = mulberry32(21), P = 8, SLOW = 0.005, TRIALS = 20000;
const call = () => rnd() < SLOW ? 60 : 2 + 2 * rnd();
const pct = (a, q) => [...a].sort((x, y) => x - y)[Math.ceil(q * a.length) - 1];
const local = [], global = [];
for (let i = 0; i < TRIALS; i++) {
  let worst = 0;
  for (let s = 0; s < P; s++) worst = Math.max(worst, call());   // wait for the slowest partition
  local.push(worst + 1);                                          // + ~1 ms to merge 8 x 20 rows
  global.push(call());
}
const row = (name, a, calls) => console.log(name.padEnd(14) + String(calls).padStart(5) +
  pct(a, 0.5).toFixed(1).padStart(9) + pct(a, 0.99).toFixed(1).padStart(9) +
  (a.filter(x => x >= 60).length / TRIALS * 100).toFixed(2).padStart(9) + "%");
console.log("READ          calls  p50 ms   p99 ms   >=60 ms");
row("local index", local, P);
row("global index", global, 1);
console.log("theory: P(at least one slow of " + P + ") = 1 - 0.995^8 = " + ((1 - (1 - SLOW) ** P) * 100).toFixed(2) + "%");
// WRITE: local index lives in the same partition and the same transaction -> never stale.
// Global index entry is applied later; assumed lag ~ exponential, mean 50 ms.
console.log("\nWRITE then READ via global index after t ms: share that misses the new order");
for (const t of [10, 100, 500]) {
  let miss = 0;
  for (let i = 0; i < TRIALS; i++) if (-50 * Math.log(1 - rnd()) > t) miss++;
  console.log(("t = " + t + " ms").padEnd(12) + "simulated " + (miss / TRIALS * 100).toFixed(1).padStart(5) +
              "%   formula e^(-t/50) " + (Math.exp(-t / 50) * 100).toFixed(1).padStart(5) + "%");
}

আউটপুট:

READ          calls  p50 ms   p99 ms   >=60 ms
local index       8      4.8     61.0     3.97%
global index      1      3.0      4.0     0.50%
theory: P(at least one slow of 8) = 1 - 0.995^8 = 3.93%

WRITE then READ via global index after t ms: share that misses the new order
t = 10 ms   simulated  81.8%   formula e^(-t/50)  81.9%
t = 100 ms  simulated  13.4%   formula e^(-t/50)  13.5%
t = 500 ms  simulated   0.0%   formula e^(-t/50)   0.0%

পড়া: local-এ প্রতিটি query ৮টি call, global-এ ১টি। p50-এ পার্থক্য সামান্য — ৪.৮ বনাম ৩.০ ms. কিন্তু p99-এ ৬১ বনাম ৪ ms: আটটির অন্তত একটি ধীর হওয়ার সম্ভাবনা ১ − ০.৯৯৫৮ ≈ ৩.৯৩% (simulation-এ ৩.৯৭%), ১%-এর বেশি — তাই p99 পড়েছে ধীর অংশে; একটি call-এ সম্ভাবনা ০.৫%, তাই p99 দ্রুত অংশেই। এটাই পর্ব ০৪-এর fan-out-এ tail latency-র বৃদ্ধি; ৬৪টি partition-এ সম্ভাবনা ১ − ০.৯৯৫৬৪ ≈ ২৭%. লেখা: global index async হলে অর্ডারের ১০ ms পরে ড্যাশবোর্ড খুললে ৮১.৮% ক্ষেত্রে নতুন অর্ডারটি নেই, ১০০ ms পরে ১৩.৪%, আধা সেকেন্ডে কার্যত শূন্য — সূত্র e−t/৫০-এর সাথে মেলে। Local index-এ এই সমস্যা নেই।

বাস্তবের সিস্টেমে দুই পথই দেখা যায় (প্রকাশিত ডকুমেন্টেশন অনুযায়ী, লেখার সময়ে; খুঁটিনাটি সংস্করণভেদে আলাদা):

  • Amazon DynamoDB: local secondary index একই partition key-তে থাকে, strongly consistent read চলে, কিন্তু টেবিল বানানোর সময়েই ঘোষণা করতে হয়; global secondary index-এর আলাদা partition key, আর তার read শুধু eventually consistent.
  • Cassandra আর Elasticsearch: secondary index বা প্রতিটি shard-এর inverted index local; partition key ছাড়া query বহু node-এ যায়। MongoDB-র sharded cluster-এও shard key ছাড়া query সব shard-এ পাঠানো হয়।
  • Google Spanner: secondary index global, একই transaction-এ হালনাগাদ — পড়া নিখুঁত, কিন্তু প্রতিটি লেখায় একাধিক অংশের commit-এর দাম।
  • PostgreSQL-এর partitioned table: index প্রতিটি partition-এ local, আর unique constraint-এ partition key-এর সব কলাম থাকতে হয়। তাই user_id দিয়ে ভাগ করা টেবিলে email সব জায়গায় অনন্য রাখতে আলাদা lookup টেবিল (email → user_id) লাগে — হাতে বানানো global index, outbox বা change stream দিয়ে মেলানো (পর্ব ২৫, ৩৩)।

দুই “consistency” গোলাবেন না। Global index পিছিয়ে থাকা মানে replica-র মতো eventual consistency — CAP আর consistency model-এর অর্থে (পর্ব ৪০)। এটি ACID-এর “C” নয়, যা constraint আর নিয়ম রক্ষার কথা বলে (পর্ব ২৪)। দোকানদার সদ্য একটি অর্ডার গ্রহণ করে পাতা হালনাগাদ করলে নিজের কাজটি দেখতে চাইবেন — এমন read-your-writes পথে মূল টেবিল বা strongly consistent read ব্যবহার করুন।

৯ · Index-এর বাইরে query performance

সঠিক index প্রথম হাতিয়ার, শেষ নয়। লাখো সারির যোগফল, বহু partition জুড়ে join, বা এক যন্ত্রের সামর্থ্যের বাইরের পড়ার চাপ — তখন নকশার পরের ধাপগুলো:

হাতিয়ারকী সারায়দামবিস্তারিত
Connection poolপ্রতি অনুরোধে নতুন connection-এর খরচ; একসাথে কতগুলো query চলবে তার সীমাবড় pool ডেটাবেসে ভিড় বাড়ায়, ছোট pool-এ অপেক্ষা; মাপ Little-এর সূত্রেপর্ব ০৪
Read replicaপড়ার চাপ primary থেকে সরানোreplication lag — নিজের লেখা না দেখাপর্ব ২২
Cacheবারবার চাওয়া একই উত্তরinvalidation, পুরোনো উত্তর, stampedeপর্ব ২৮
Denormalised read modelপ্রতিটি পাতার রূপে আগে থেকে সাজানো টেবিল, join ছাড়াকপি মেলানোর দায়, কিছুটা পিছিয়ে থাকা (denormalization)পর্ব ৩৫ (CQRS)
Materialized viewভারী aggregate-এর জমা ফলrefresh-এর খরচ; মাঝের সময়ে ফল পুরোনোDBMS · View
পুরোনো ডেটা সরানোসক্রিয় টেবিল আর index ছোট রাখা — যেমন মাস ধরে partition, পুরোনোটি archive-এপুরোনো ডেটার query ধীর পথেপর্ব ২৩
Slow-query log আর query-ভিত্তিক p99কোন query আসলে ধীর, আর কবে থেকেlog-এর আকার; sampling লাগেপর্ব ৫০

Materialized view: PostgreSQL-এ REFRESH MATERIALIZED VIEW পুরো ফল নতুন করে হিসাব করে; CONCURRENTLY দিলে পড়া আটকায় না, তবে view-এ একটি unique index লাগে। MySQL-এ লেখার সময়ে নিজস্ব materialized view নেই — summary টেবিল আর job দিয়ে কাজ চালানো হয়।

মাপার নিয়ম। Query-কে আক্ষরিক মান বাদ দিয়ে এক ছাঁচে আনুন — WHERE merchant_id = ? — তারপর দুটি তালিকা: মোট সময় (চলার সংখ্যা × গড়) ধরে, আর p99 ধরে। PostgreSQL-এর pg_stat_statements প্রতিটি ছাঁচের চলার সংখ্যা আর গড় সময় রাখে, log_min_duration_statement ধীর query log-এ লেখে; MySQL-এ slow query log আর long_query_time. Percentile আসে metric-এর histogram বা trace থেকে (observability, পর্ব ৫০)। আর query-প্রতি সময়সীমা (statement_timeout) রাখুন, যাতে একটি ভুল query পুরো ডেটাবেস আটকে না দেয় (পর্ব ১৮)।

আরও গভীরে: Local আর global index-কে Martin Kleppmann তাঁর Designing Data-Intensive Applications (২০১৭) বইয়ে বলেছেন document-partitioned আর term-partitioned index — partitioning অধ্যায়টি এই পর্বের অংশ ৮-এর সেরা সঙ্গী। Index নকশার হাতে-কলমে বিস্তার আছে Markus Winand-এর বিনামূল্যের অনলাইন বই Use The Index, Luke-এ। গবেষণার দিকে দুটি ধারা চোখে রাখার মতো: Microsoft-এর দল Azure SQL Database-এ স্বয়ংক্রিয়ভাবে index সুপারিশ আর যাচাইয়ের অভিজ্ঞতা প্রকাশ করেছে (SIGMOD ২০১৯), আর Kraska ও সহকর্মীদের “learned index” প্রস্তাব (SIGMOD ২০১৮) B-tree-কে একটি শেখা মডেল দিয়ে বদলানোর কথা বলে।

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • প্রতিটি কলামে index দিলে ক্ষতি কী? — প্রতিটি INSERT আর UPDATE সব index হালনাগাদ করে; জায়গা আর memory যায়, অথচ বেশির ভাগ index কাজেই লাগে না।
  • (a, b) index থাকলে WHERE b = ? দ্রুত হবে? — না; leftmost prefix নিয়মে শুরুর কলাম a লাগে। দরকার হলে (b) বা (b, a).
  • ১০ কোটি সারিতে একটি সারি খুঁজতে কয়টি page? — fan-out ~৩০০ হলে উচ্চতা ৪, তাই ~৪–৫ page; full scan ~২০ লাখ page (প্রতি page-এ ৫০ সারি ধরে)।
  • Covering index কী? — Query-র সব কলাম index-এ, তাই table lookup নেই; PostgreSQL-এ Index Only Scan, SQLite-এ USING COVERING INDEX.
  • Shard key ছাড়া খোঁজার query? — Local index-এ সব shard-এ scatter-gather আর tail latency; global index-এ এক জায়গায় পড়া, কিন্তু লেখা distributed transaction বা async-এ পিছিয়ে থাকা।

১০ · AI-এর যুগে index আর query tuning

“Query ধীর, index বলে দাও” — AI কয়েক সেকেন্ডে একটি CREATE INDEX লিখে দেয়। কিন্তু সে আপনার ডেটার মাপ, মানের বণ্টন আর লেখার হার জানে না, আর প্রায়ই plan না দেখেই উত্তর দেয়। চেনা ভুলগুলো:

AI-এর ভুলকেমন দেখায়কীভাবে যাচাই করবেন
প্রতিটি WHERE-কলামে আলাদা indexপাঁচটি এক-কলামের index, কোনো composite নেই; লেখা ধীরaccess pattern-এর তালিকা আর EXPLAIN (অংশ ৫, ৬)
কলামের ক্রম উল্টো(created_at, customer_id) — সমতার কলাম পরেleftmost-prefix নিয়ম; নমুনা ডেটায় plan
সাধারণ, ভারী উত্তরএকটি অনুপস্থিত index-এর জন্য “Redis, Elasticsearch আর microservices বসান”আগে slow log আর plan; প্রতিটি নতুন টুলের পেছনের requirement জিজ্ঞেস করুন
হিসাবে ভুল“১০ কোটি সারিতে B-tree-তে ২৭টি disk read” — log₂ আর logf গোলানোনিজে হিসাব: h = ⌈logf N⌉ (অংশ ১)
Failure আর লেখার দাম উপেক্ষাবড় চালু টেবিলে সাধারণ CREATE INDEX — লেখা আটকে যায়online বানানো, staging-এ আসল মাপে মহড়া, rollback পরিকল্পনা
Consistency গোলানো“global index eventually consistent, তাই ACID ভাঙে”ACID-এর C বনাম replica-র staleness (পর্ব ২৪, ৪০)
পণ্যের বৈশিষ্ট্য বা সীমা বানানো“MySQL-এ partial index আছে”, বা উৎসহীন সীমার সংখ্যানিজের সংস্করণের অফিশিয়াল ডকুমেন্টেশন, আর ছোট পরীক্ষা
Over-engineering৫০ হাজার সারির টেবিলে sharding আর global indexestimation (পর্ব ০৩) — এক যন্ত্রেই কি চলে?

AI-কে index-এর নকশাকার নয়, কড়া reviewer বানান। আগে নিজে access pattern আর plan জোগাড় করুন, তারপর এভাবে যাচাই করান:

তুমি একজন অভিজ্ঞ database performance reviewer. নিচে আমার টেবিলের schema, প্রধান query-গুলো (প্রতিটির আনুমানিক QPS আর latency-র লক্ষ্য), এখনকার index-এর তালিকা আর প্রতিটি query-র EXPLAIN-এর ফল দিলাম: [এখানে schema, query, index আর plan পেস্ট করুন] ১. প্রতিটি query কোন index ব্যবহার করছে বা করছে না — plan-এর লাইন উদ্ধৃত করে বলো। ২. নতুন index প্রস্তাব করলে কলামের ক্রমের যুক্তি দাও (সমতা, range, sort, covering), আর কোন পুরোনো index তাতে অপ্রয়োজনীয় হবে বলো। ৩. প্রতিটি প্রস্তাবের লেখার দাম (প্রতি INSERT-এ কয়টি কাঠামো) আর আনুমানিক আকার ধাপে ধাপে হিসাব করে দেখাও। ৪. চালু বড় টেবিলে index বানানোর নিরাপদ উপায় আর rollback পরিকল্পনা লেখো। ৫. কোনো ডেটাবেসের বৈশিষ্ট্য বা সীমা বললে সংস্করণ আর উৎস দাও; নিশ্চিত না হলে স্পষ্ট বলো। Cache বা নতুন ডেটাবেস প্রস্তাবের আগে দেখাও, query আর index বদলেই সমাধান হয় কি না।

AI-এর দেওয়া index চালু করার আগে — চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১২০১৬ সালে Uber-এর একটি প্রকাশিত engineering লেখায় PostgreSQL ছেড়ে MySQL-এ যাওয়ার বড় কারণ বলা হয়েছিল write amplification. Index-এর নকশার সাথে এর সম্পর্ক কী, আর এ থেকে কী শেখার আছে?

Uber Engineering-এর ব্লগে (জুলাই ২০১৬) প্রকাশিত লেখা অনুযায়ী, তাদের কাজের ধরনে PostgreSQL-এ লেখার খরচ অনেক বেড়ে যাচ্ছিল। মূল কারণটি লুকিয়ে আছে index কীভাবে সারির “ঠিকানা” রাখে, তাতে। PostgreSQL-এ প্রতিটি secondary index সারির ভৌত ঠিকানা রাখে — কোন page, কোন slot. MVCC-র কারণে UPDATE পুরোনো সারিটি না বদলে নতুন একটি সংস্করণ লেখে, প্রায়ই নতুন জায়গায়। ঠিকানা বদলালে প্রতিটি index-এ নতুন entry লাগে — যে কলাম বদলায়নি, তার index-এও। টেবিলে দশটি index থাকলে একটি ছোট UPDATE মানে দশটি index-এ লেখা; আর physical replication-এ সেই সব পরিবর্তন WAL হয়ে replica-তেও যায়।

InnoDB-তে secondary index রাখে primary key-এর মান, ভৌত ঠিকানা নয়। তাই index-এ নেই এমন কলাম বদলালে secondary index ছোঁয়াই হয় না। বিনিময়ে প্রতিটি secondary খোঁজায় দুবার গাছে নামা — আগে secondary গাছ, তারপর primary key-এর clustered গাছ। অর্থাৎ পড়ার পথে একটু দাম দিয়ে লেখার পথে সাশ্রয়।

PostgreSQL-এর পক্ষের লেখকেরা তখন দেখিয়েছিলেন, HOT update (সংস্করণ ৮.৩ থেকে) indexed কলাম না বদলালে আর একই page-এ জায়গা থাকলে index ছোঁয় না, আর fillfactor কমিয়ে page-এ ফাঁকা রাখলে HOT-এর সুযোগ বাড়ে। পরের সংস্করণে B-tree deduplication (১৩) আর bottom-up deletion (১৪)-এর মতো উন্নতিও এসেছে। কোন ডেটাবেস “ভালো” — এই তর্কের চেয়ে জরুরি কথা হলো, দুই নকশা দুই রকম কাজে সুবিধাজনক।

নকশাকারের জন্য শিক্ষা: index-এর সংখ্যা × লেখার QPS × UPDATE-এর ধরন — এই গুণফলটি আগেই হিসাব করুন। ঘনঘন বদলানো কলাম (যেমন “শেষ দেখা সময়”) index থেকে দূরে রাখুন, আর কাজে না লাগা index নিয়মিত মুছুন।

মূল উপলব্ধি: index শুধু পড়ার কাঠামো নয় — প্রতিটি index লেখার পথে একটি স্থায়ী কর। ডেটাবেস বা index বাছাইয়ের আগে নিজের পড়া-লেখার অনুপাত আর UPDATE-এর ধরন মেপে নিন।

প্র ০২B-tree ১৯৭২ সালের ধারণা। পঞ্চাশ বছর পরেও প্রায় সব relational ডেটাবেসের ডিফল্ট index কেন এটিই, আর অনেক NoSQL store কেন ভিন্ন পথ নিল?

Rudolf Bayer আর Edward McCreight বোয়িং-এর গবেষণাগারে B-tree উদ্ভাবন করেন; প্রবন্ধ প্রকাশ ১৯৭২ সালে। ১৯৭৯ সালে Douglas Comer-এর সমীক্ষা-প্রবন্ধের নামই ছিল “The Ubiquitous B-Tree” — অর্থাৎ তখনই সর্বব্যাপী। টিকে থাকার কারণ চারটি। এক, node-এর মাপ ডিস্কের page-এর সমান, তাই একবার পড়ায় শত শত দিকের মধ্যে বাছাই — উচ্চতা ৩–৫. দুই, এটি সাজানো, তাই সমতা, range, ORDER BY আর prefix — এক কাঠামোয় সব। তিন, ভারসাম্য নিজে রক্ষা করে, তাই সবচেয়ে খারাপ অবস্থাও অনুমানযোগ্য। চার, একসাথে বহু লেখার কৌশল পরিণত — যেমন Lehman আর Yao-র B-link tree (১৯৮১)। Hard disk থেকে SSD-তে আসার পরেও page ধরে পড়ার যুক্তি বদলায়নি।

দুর্বলতা লেখায়: প্রতিটি লেখা গাছের কোনো এলোমেলো জায়গায় একটি page বদলায়। লেখার চাপ খুব বেশি হলে এটি বোঝা। ১৯৯৬ সালে O'Neil ও সহকর্মীরা LSM-tree প্রস্তাব করেন: লেখা আগে memory-তে জমে, তারপর সাজানো ফাইল হিসেবে টানা ডিস্কে যায়, আর পেছনে compaction-এ ফাইলগুলো মেলানো হয়। Google-এর Bigtable (২০০৬), পরে Cassandra আর RocksDB এই পথে গেছে। দাম পড়ায়: একটি key কয়েকটি স্তরে থাকতে পারে, তাই Bloom filter (পর্ব ৪৫) দিয়ে অপ্রয়োজনীয় ফাইল বাদ দিতে হয়। ভেতরের খুঁটিনাটি পর্ব ১৯-এ।

২০১৬ সালে Athanassoulis ও সহকর্মীরা এই টানাপোড়েনের নাম দেন RUM conjecture: Read, Update আর Memory (জায়গা) — তিনটির বাড়তি খরচ একসাথে কমানো যায় না; দুটি কমালে তৃতীয়টি বাড়ে। B-tree পড়ায় ভালো, LSM লেখায়, hash index সমতায় দ্রুত কিন্তু range-এ অচল। প্রতিটি নকশা তিনটির মধ্যে একটি বিন্দু বেছে নেয়।

মূল উপলব্ধি: “সেরা index” বলে কিছু নেই, আছে “এই কাজের জন্য সেরা”। পড়া-লেখার অনুপাত আর প্রশ্নের ধরন দেখে কাঠামো বাছুন — আর মনে রাখুন, একটি টেবিলে প্রতিটি নতুন index-ও একই টানাপোড়েনের একটি ছোট সিদ্ধান্ত।

প্র ০৩Optimizer-এর হাতে statistics আছে, cost model আছে — তবু সে কেন ভুল plan বাছে, আর বড় সিস্টেমে এর বিরুদ্ধে কী করা হয়?

Cost-based optimization-এর ভিত্তি ১৯৭৯ সালের IBM System R-এর প্রবন্ধ — Patricia Selinger ও সহকর্মীদের “Access Path Selection in a Relational Database Management System”. মূল ধারণা: প্রতিটি সম্ভাব্য পথের খরচ অনুমান করে সস্তাটি বাছা। অনুমানের কেন্দ্রে selectivity — কোন শর্ত কত সারি রাখবে। গোলমাল বাধে অনুমিতিতে: মানগুলো সমানভাবে ছড়ানো, আর কলামগুলো একে অপরের থেকে স্বাধীন। বাস্তবে দুটোই প্রায়ই মিথ্যা। শহর = 'ঢাকা' আর এলাকা = 'মিরপুর' — দুটির selectivity আলাদা করে গুণ করলে অনুমান অনেক কম আসে, অথচ দুটি একসাথে চলে।

Join-এ ভুল গুণ হয়ে বাড়ে। ২০১৫ সালে Viktor Leis ও সহকর্মীদের “How Good Are Query Optimizers, Really?” (PVLDB) প্রবন্ধ বাস্তব ডেটায় কয়েকটি পরিচিত ডেটাবেস পরীক্ষা করে দেখায়: কয়েকটি join-এর পর সারির সংখ্যার অনুমান বহু মাত্রায় ভুল হতে পারে, আর plan-এর মান cost model-এর সূক্ষ্মতার চেয়ে এই অনুমানের ওপরই বেশি নির্ভর করে।

আরও তিনটি বাস্তব কারণ: বড় আমদানির পর পুরোনো statistics; মানভেদে আলাদা ছবি — যে plan একটি বড় দোকানের জন্য খারাপ, ছোট দোকানের জন্য সেটিই ভালো; আর prepared statement-এ সব মানের জন্য একটি সাধারণ plan. তিনটির কোনোটিই কোডে চোখে পড়ে না, ধরা পড়ে শুধু মাপলে।

প্রতিকার: EXPLAIN ANALYZE-এ অনুমিত আর আসল সারি মেলানো; বড় আমদানির পর ANALYZE; সম্পর্কিত কলামে PostgreSQL-এর CREATE STATISTICS (সংস্করণ ১০ থেকে); SQL Server-এর Query Store-এর মতো ব্যবস্থায় plan-এর ইতিহাস রেখে হঠাৎ বদল ধরা; শেষ উপায় হিসেবে hint — সাবধানে, কারণ ডেটা বদলালে আজকের ভালো hint কালকের বোঝা। সবচেয়ে কাজের সুরক্ষা হলো query-ভিত্তিক p99-এ alert, যাতে plan বদলে কোনো query হঠাৎ ধীর হলে সঙ্গে সঙ্গে জানা যায়।

মূল উপলব্ধি: optimizer একজন দক্ষ অনুমানকারী, ভবিষ্যৎদ্রষ্টা নয়। নকশায় ধরে নিন plan একদিন বদলাবে — মাপার ব্যবস্থাই আপনার সুরক্ষা।

প্র ০৪দশ কোটি অর্ডার customer_id দিয়ে ৬৪টি shard-এ ভাগ করা। দোকানের ড্যাশবোর্ডের জন্য merchant_id-এর index local হবে, না global? সিদ্ধান্তের মাপকাঠি কী?

আগে সংখ্যা। Local index-এ প্রতিটি ড্যাশবোর্ড query ৬৪টি shard-এ যায়। প্রতিটি call-এর ০.৫% ধীর হলে অন্তত একটি ধীর হওয়ার সম্ভাবনা ১ − ০.৯৯৫৬৪ ≈ ২৭% — প্রায় প্রতি চারটি query-র একটি ধীর। মোট কাজও ৬৪ গুণ: সেকেন্ডে ৫০টি ড্যাশবোর্ড query (ধরা যাক) মানে ৩,২০০টি shard-query, যার প্রতিটি সামান্য কয়েকটি সারি নিয়ে ফেরে। Shard যত বাড়বে, এই খরচ তত বাড়বে — scale-এর উল্টো দিকে হাঁটা।

Global index-এ পড়া এক জায়গায়; দাম লেখায়, কারণ প্রতিটি অর্ডার দুই জায়গায় লেখা হয়। দুই পথ। একই transaction-এ — Spanner-এর প্রকাশিত নকশায় যেমন index transaction-এর ভেতরেই হালনাগাদ হয় — তাতে প্রতিটি লেখায় distributed commit-এর latency যোগ হয়। অথবা async — DynamoDB-র global secondary index-এর মতো — লেখা সস্তা, কিন্তু index কিছুক্ষণ পিছিয়ে। ড্যাশবোর্ডে নতুন অর্ডার ১০০ ms পরে দেখা গেলে ক্ষতি সম্ভবত নেই; কিন্তু দোকানদার নিজে যে অর্ডারটি এইমাত্র গ্রহণ করলেন, সেটি না দেখলে বিভ্রান্ত হবেন — সেই পথে মূল টেবিল থেকে পড়ুন।

সিদ্ধান্তের মাপকাঠি: (১) পড়া-লেখার অনুপাত; (২) shard-এর সংখ্যা আর tail latency-র লক্ষ্য; (৩) কতটা পিছিয়ে থাকা সহ্য হয়; (৪) query-র selectivity — একটি দোকানের অর্ডার মোটের তুলনায় যত কম, global index-এর জয় তত স্পষ্ট। অনেক দল তৃতীয় পথও নেয়: merchant_id দিয়ে ভাগ করা আলাদা একটি read model টেবিল, যা outbox বা change stream থেকে হালনাগাদ হয় (পর্ব ২৫, ৩৩, ৩৫) — কার্যত হাতে বানানো global index, নিজের নিয়ন্ত্রণে।

উল্টো উদাহরণও আছে: Elasticsearch-এর মতো search engine local পথেই চলে, আর তা যুক্তিসংগত — search-এ প্রায়ই সব নথির মধ্যে ranking লাগে, তাই সব shard-কে জিজ্ঞেস করতেই হয়।

মূল উপলব্ধি: খরচ মুছে ফেলা যায় না, শুধু সরানো যায় — পড়া থেকে লেখায়, বা উল্টো। কোন দিকে বেশি ট্রাফিক আর কোন দিকে বেশি সহনশীলতা, সেটাই উত্তর ঠিক করে।

প্র ০৫ডেটা ১০০ গুণ বাড়লেও B-tree-র উচ্চতা বাড়ে বড়জোর এক ধাপ। তাহলে বাস্তবে টেবিল বড় হওয়ার পর একই query হঠাৎ দশ গুণ ধীর হয়ে যায় কেন?

কারণ Big-O বলে কাজ কীভাবে বাড়ে, আর latency ঠিক করে প্রতিটি কাজ কোথায় হচ্ছে — memory-তে, না ডিস্কে। Memory-তে থাকা একটি page পড়া মোটামুটি এক মাইক্রোসেকেন্ডের ঘরে (RAM-এর একেকটি পড়া ~১০০ ns), আর SSD থেকে এলোমেলো একটি page ~১০০ µs (পর্ব ০৩-এর latency-র মই) — প্রায় শত গুণ। যতক্ষণ index-এর ব্যবহৃত অংশ — working set — buffer pool-এ ধরে, একটি lookup প্রায় পুরোটাই memory-তে। টেবিল আর index memory ছাড়িয়ে গেলে cache hit ratio নামে, আর একই ৫টি page-এর কয়েকটি এখন ডিস্ক থেকে আসে। Big-O বদলায়নি, ধ্রুবকটি বদলে গেছে — আর বদলটা আসে প্রায় একটি ধাপে, ধীরে ধীরে নয়।

Key-এর বণ্টন এখানে বড় ভূমিকা রাখে। এলোমেলো UUIDv4 primary key-তে নতুন সারি গাছের সবখানে ঢোকে, তাই পুরো index-কেই “গরম” থাকতে হয় — সাথে বেশি page split আর আধা-খালি page. সময়-ক্রমের key — RFC 9562 (২০২৪)-এর UUIDv7, বা Snowflake-ধাঁচের ID (পর্ব ২৩) — নতুন লেখা গাছের ডান প্রান্তে জমায়; গরম অংশ ছোট থাকে, আর সাম্প্রতিক অর্ডারের পড়াও সেখানেই।

আরও দুটি কারণ। প্রতিটি নতুন index memory-র জন্য বাকিদের সাথে লড়ে। আর ডিস্কের অপেক্ষা বাড়লে queue জমে — পর্ব ০৪-এর queueing-এ দেখেছেন, ব্যবহার ১০০%-এর কাছে গেলে latency হঠাৎ লাফ দেয়।

নকশায় করণীয়: index আর টেবিলের আকার RAM-এর সাথে নিয়মিত মিলিয়ে দেখা (PostgreSQL-এ pg_relation_size); cache hit ratio-য় alert; গরম query-র জন্য covering index, যাতে কম page লাগে; পুরোনো ডেটা সময়-ভিত্তিক partition-এ সরানো; আর বৃদ্ধির হার ধরে আগেভাগে ক্ষমতার পরিকল্পনা (পর্ব ১৩)।

মূল উপলব্ধি: system design-এর বড় একটি অংশ হলো working set-কে দ্রুত স্তরে ধরে রাখা। Big-O বলে দেয়াল কত দূরে; memory বলে দেয়ালটা কত উঁচু।

অনুশীলন

আগে নিজে খাতায় লিখুন, তারপর উত্তরের সাথে মেলান। দ্বিতীয় আর চতুর্থটি ওপরের যেকোনো লাইভ SQL এডিটরে চালিয়েও যাচাই করা যায় — সবগুলোতে একই নমুনা টেবিল।

  1. Index নকশা — একটি ride-sharing অ্যাপ (কাল্পনিক): টেবিল trips: trip_id, rider_id, driver_id, city, status (searching, on_trip, completed, cancelled), requested_at, fare, client_request_id. প্রধান query (QPS ধরা যাক): (ক) যাত্রীর ট্রিপ-ইতিহাস, নতুন আগে, পাতায় ২০টি — ব্যস্ত সময়ে সেকেন্ডে ~২,০০০; (খ) চালকের এই সপ্তাহের আয় — completed ট্রিপের fare-এর যোগফল, সেকেন্ডে ~৩০০; (গ) অপারেশন দলের পর্দা — একটি শহরে এখনো searching অবস্থার ট্রিপ, পুরোনো আগে, সেকেন্ডে ~২০; (ঘ) অর্থ বিভাগের মাসিক রিপোর্ট — শহরভিত্তিক মোট fare, মাসে একবার; (ঙ) নতুন ট্রিপের অনুরোধ, retry এলেও একবারই — সেকেন্ডে ~৫০০. প্রতিটির জন্য index (কলামের ক্রমসহ) প্রস্তাব করুন, কোথায় index দেবেন না বলুন, আর প্রতি INSERT-এর লেখার দাম গুনুন।
    • (ক) (rider_id, requested_at) — সমতা rider_id, sort requested_at; পরের পাতা keyset দিয়ে। সবচেয়ে ঘনঘন, তাই covering ভাবা যায়: PostgreSQL-এ INCLUDE (status, fare, city).
    • (খ) (driver_id, requested_at) INCLUDE (status, fare) — সমতা driver_id, range requested_at; একজন চালকের এক সপ্তাহের ট্রিপ সামান্য কয়েকশো, তাই status leaf-এ ছেঁকে নেওয়াই সস্তা, আর পুরো উত্তর index থেকে। (driver_id, status, requested_at) কম entry পড়ে, কিন্তু প্রতিটি অবস্থা-বদলে entry গাছের অন্য জায়গায় সরে — মেপে বাছুন। MySQL-এ (driver_id, requested_at, status, fare).
    • (গ) partial (city, requested_at) WHERE status = 'searching' — সক্রিয় অংশ সামান্য, index ছোট; ট্রিপ শুরু হলেই entry বেরিয়ে যায়। MySQL-এ (status, city, requested_at).
    • (ঘ) লেনদেনের ডেটাবেসে index নয় — read replica বা warehouse-এ batch (পর্ব ২২, পর্ব ৩৬), দরকারে summary টেবিল।
    • (ঙ) UNIQUE (rider_id, client_request_id) — নিয়মটি ডেটাবেসের হাতে।
    • দেব না: শুধু city, status বা fare-এ একা index. “কাছের চালক” B-tree-র কাজ নয় — geospatial index, পর্ব ৫৮.
    • লেখার দাম: নতুন ট্রিপ searching হয়ে ঢোকে, তাই প্রতি INSERT-এ টেবিল + (ক) + (খ) + (গ) + (ঙ) = ৫টি কাঠামো; সেকেন্ডে ৫০০ × ৫ = ২,৫০০টি B-tree হালনাগাদ। সাথে প্রতিটি অবস্থা-বদলে (গ) থেকে মোছা আর status থাকা index-এ হালনাগাদ।
  2. Plan আন্দাজ করুন: নমুনা orders টেবিলে একটিই secondary index — CREATE INDEX idx_m_s_t ON orders(merchant_id, status, created_at); ANALYZE চালানো হয়নি। প্রতিটি query SELECT order_id, amount FROM orders দিয়ে শুরু। SQLite-এর plan কী হবে — SCAN, নাকি SEARCH; SEARCH হলে বন্ধনীতে কোন শর্ত, আর আলাদা sort আছে কি না?
    (a) WHERE merchant_id = 7 AND status = 'pending' ORDER BY created_at
    (b) WHERE merchant_id = 7 ORDER BY created_at DESC LIMIT 20
    (c) WHERE status = 'pending'
    (d) WHERE merchant_id = 7 AND created_at >= '2026-08-01'
    (e) WHERE merchant_id = 7 AND status IN ('pending', 'cancelled')
          AND created_at >= '2026-08-01'
    (f) WHERE merchant_id + 0 = 7 AND status = 'pending'
    (a) SEARCH orders USING INDEX idx_m_s_t (merchant_id=? AND status=?)
    (b) SEARCH orders USING INDEX idx_m_s_t (merchant_id=?)
        USE TEMP B-TREE FOR ORDER BY
    (c) SCAN orders
    (d) SEARCH orders USING INDEX idx_m_s_t (merchant_id=?)
    (e) SEARCH orders USING INDEX idx_m_s_t (merchant_id=? AND status=? AND created_at>?)
    (f) SCAN orders

    (a) দুই সমতার পর created_at আগেই সাজানো, তাই sort নেই। (b) মাঝের status বাদ, তাই দোকান ৭-এর ব্লকে created_at-এর তিনটি আলাদা ক্রম (প্রতিটি status-এর একটি) — আলাদা sort লাগে। (c) প্রথম কলাম নেই। (d) status বাদ, তাই created_at খোঁজায় লাগে না, শুধু ছাঁকে। (e) সবচেয়ে চমকপ্রদ: IN-এর দুটি মান দুটি আলাদা সমতার খোঁজ, তাই তিনটি কলামই কাজে লাগে। (f) merchant_id হিসাবে মোড়া — শুরুর কলাম নেই।

  3. Page-এর হিসাব: একটি টেবিলে ৫ কোটি সারি, প্রতি page-এ ৪০টি সারি (8 KB page), B-tree-র fan-out ২০০; এলোমেলো একটি page ০.১ ms, টানা পড়ায় প্রতি page ০.০০৮ ms (ধরা যাক)। (ক) Full scan-এ কত page, কত GB, কত সময়? (খ) Index-এর উচ্চতা h আর একটি সারির lookup-এ কয়টি page? (গ) ৫০০টি সারির range — covering নয় আর covering — কয়টি page আর কত সময়? (ঘ) কতগুলো মেলা সারির পর (covering নয় এমন) index-এর চেয়ে scan সস্তা? (ঙ) ৪টি secondary index থাকলে প্রতি INSERT-এ কয়টি কাঠামো আর খোঁজার পথে কয়টি page?

    (ক) ৫ × ১০৭ ÷ ৪০ = ১২,৫০,০০০ page; × ৮,১৯২ byte ≈ ১০.২৪ GB; × ০.০০৮ ms = ১০,০০০ ms = ১০ সেকেন্ড।

    (খ) Leaf: ৫ × ১০৭ ÷ ২০০ = ২,৫০,০০০; তার ওপরের স্তর ১,২৫০; তারপর ৭ (৬.২৫ থেকে ওপরে); তারপর ১ — মোট ৪টি স্তর। যাচাই: ২০০৩ = ৮ × ১০৬ < ৫ × ১০৭ ≤ ২০০৪ = ১.৬ × ১০৯, তাই h = ৪. Lookup = ৪ + টেবিলের ১ = ৫ page, cold অবস্থায় প্রায় ০.৫ ms.

    (গ) ৫০০টি entry ⌈৫০০ ÷ ২০০⌉ = ৩টি leaf-এ, প্রথমটি নামার পথেই গোনা — তাই index-এ ৪ + ২ = ৬ page. Covering নয়: ৬ + ৫০০ = ৫০৬ page ≈ ৫০.৬ ms. Covering: ৬ page ≈ ০.৬ ms — প্রায় ৮৪ গুণ কম।

    (ঘ) Scan-এর সময় ১০,০০০ ms; প্রতিটি মেলা সারি ০.১ ms. সমান হয় ১০,০০০ ÷ ০.১ = ১,০০,০০০ সারিতে — টেবিলের ০.২%. এর বেশি মিললে (এই মডেলে) scan সস্তা।

    (ঙ) টেবিল + ৪ = ৫টি কাঠামো; খোঁজার পথে ৫ × ৪ = ২০ page. প্রতিটি index-এর leaf প্রায় ২,৫০,০০০ page ≈ ২.০ GB, চারটিতে ≈ ৮.২ GB — টেবিলের প্রায় সমান।

  4. একটি ধীর query সারান: দোকানের মাসিক অর্ডার-তালিকার পাতা ২-এর query নিচে। টেবিলে শুধু idx_created (created_at) আছে, আর নমুনা টেবিলে plan আসে SCAN orders USING INDEX idx_created. সমস্যাগুলো চিহ্নিত করুন, তারপর index আর query নতুন করে লিখুন। নতুন plan কী হবে?
    SELECT * FROM orders
    WHERE strftime('%Y-%m', created_at) = '2026-08' AND merchant_id = 7
    ORDER BY created_at DESC LIMIT 20 OFFSET 20;

    সমস্যা চারটি। (১) SCAN … USING INDEX খোঁজা নয় — পুরো created_at index উল্টো দিক থেকে হাঁটা, প্রতিটি entry-র জন্য টেবিলে গিয়ে merchant আর মাস মেলানো; ক্রমটা পেতে সুবিধা, বাকি সব খরচ। (২) মাসের শর্ত ফাংশনে মোড়া — sargable নয়। (৩) merchant_id-এ কোনো index নেই। (৪) SELECT * আর OFFSET — covering অসম্ভব, আর গভীর পাতায় আগের সব সারি পেরোতে হয়।

    CREATE INDEX idx_merch_time ON orders(merchant_id, created_at, status, amount);
    -- page 1
    SELECT order_id, created_at, status, amount FROM orders
    WHERE merchant_id = 7 AND created_at >= '2026-08-01' AND created_at < '2026-09-01'
    ORDER BY created_at DESC LIMIT 20;
    -- page 2: keyset — continue below the last created_at seen on page 1
    SELECT order_id, created_at, status, amount FROM orders
    WHERE merchant_id = 7 AND created_at >= '2026-08-01' AND created_at < '2026-08-13 19:01:00'
    ORDER BY created_at DESC LIMIT 20;

    দুই query-রই plan: SEARCH orders USING COVERING INDEX idx_merch_time (merchant_id=? AND created_at>? AND created_at<?) — সমতা, তারপর range আর sort একই কলামে, আর দরকারি সব কলাম index-এ। নমুনা টেবিলে দোকান ৭-এর আগস্টের অর্ডার ২৮টি; পাতা ১-এর শেষ সারি 46243 | 2026-08-13 19:01:00, আর পাতা ২-এ বাকি ৮টি — প্রথমটি 46242 | 2026-08-13 18:54:00, পুরোনো OFFSET-রূপের পাতা ২-এর সাথে হুবহু এক। একই সময়ের দুটি অর্ডার থাকতে পারলে cursor-এ (created_at, order_id) জোড়া রাখুন আর শর্ত লিখুন (created_at, order_id) < (:t, :id) (পর্ব ০৮)।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমচালু একটি সিস্টেমে ধীর query সারানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Slow-query log আর query-ভিত্তিক p99 থেকে সবচেয়ে ক্ষতিকর query খুঁজে বের করা
  2. EXPLAIN (বা EXPLAIN ANALYZE) দিয়ে plan পড়া — scan, আলাদা sort, অনুমিত বনাম আসল সারি
  3. Access pattern ধরে index নকশা — সমতা আগে, তারপর range বা sort, দরকারে covering
  4. Staging-এ আসল মাপের ডেটায় পরীক্ষা, তারপর লেখা না আটকে চালু সিস্টেমে বানানো
  5. p99 আর লেখার latency আবার মাপা; অপ্রয়োজনীয় হয়ে যাওয়া index মোছা

একটি orders টেবিলে index আছে (merchant_id, status, created_at). নিচের কোন শর্তে এই index দিয়ে খোঁজা (seek) একেবারেই সম্ভব নয়?

  • merchant_id = 7
  • merchant_id = 7 AND status = 'pending'
  • status = 'pending' AND created_at >= '2026-08-01'
  • merchant_id = 7 AND status = 'pending' AND created_at >= '2026-08-01'

১০ কোটি (১০৮) entry-র একটি B-tree index-এর fan-out ৩০০. Root থেকে leaf পর্যন্ত মোটামুটি কয়টি স্তর?

  • ২
  • ৪
  • ২৭
  • ১০,০০০

SQLite-এর query plan-এ USING COVERING INDEX, বা PostgreSQL-এ Index Only Scan লেখা থাকলে তার অর্থ কী?

  • পুরো index শুরু থেকে শেষ পর্যন্ত পড়া হয়েছে
  • Query-র দরকারি সব কলাম index-এই ছিল, টেবিলে যেতে হয়নি
  • Index-টি unique
  • ফল একটি আলাদা cache থেকে এসেছে

created_at (মান যেমন '2026-03-15 10:42:00') কলামে index আছে, কিন্তু WHERE date(created_at) = '2026-03-15'-এর plan বলছে SCAN. একই দিনের সব সারি পেতে, index ব্যবহার করে, শর্তটি কীভাবে লিখবেন?

  • WHERE created_at LIKE '%2026-03-15%'
  • WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'
  • WHERE date(created_at) IN ('2026-03-15')
  • WHERE created_at = '2026-03-15'

একটি orders টেবিল customer_id দিয়ে ৮টি partition-এ ভাগ করা, আর merchant_id-এর index প্রতিটি partition-এ local. “দোকান ৭-এর সর্বশেষ ২০টি অর্ডার” query-তে কী ঘটে?

  • একটি partition-ই পুরো উত্তর দেয়
  • সব ৮টি partition-এ query যায়, ফল জোড়া লাগে; অপেক্ষা সবচেয়ে ধীরটির জন্য
  • Index পিছিয়ে থাকে বলে নতুন অর্ডার দেখা যায় না
  • Partition key না থাকায় ডেটাবেস query প্রত্যাখ্যান করে

১০ কোটি সারির একটি টেবিলে একটি শর্ত প্রায় ৪০% সারি রাখে। কলামে index থাকা সত্ত্বেও PostgreSQL Seq Scan বেছে নিল। সবচেয়ে সম্ভাব্য কারণ কী?

  • Index-টি নষ্ট হয়ে গেছে
  • এত সারি এলোমেলো table lookup-এ আনার চেয়ে পুরো টেবিল টানা পড়া সস্তা
  • Seq Scan সবসময় index-এর চেয়ে দ্রুত
  • Statistics খুব বেশি হালনাগাদ

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Full scan O(N) — ১০৮ সারিতে ২০ লাখ page; B-tree O(logf N) — মোটামুটি ৪–৫ page. log₂ নয়, fan-out দিয়ে হিসাব।
  2. Index আসে access pattern থেকে: সমতার কলাম আগে, তারপর range বা sort; composite index কাজে লাগে শুধু leftmost prefix-এ; prefix-দ্বৈত index মুছুন।
  3. Covering index table lookup বাদ দেয়; partial, expression আর unique index নির্দিষ্ট প্রশ্নের হাতিয়ার; hash শুধু সমতায়।
  4. প্রতিটি index লেখার কর; EXPLAIN-এ যাচাই করুন — কম selectivity-তে optimizer scan বাছে; ফাংশন, শুরুর wildcard, OR, টাইপ, SELECT *, N+1 আর OFFSET চেনা ফাঁদ।
  5. ভাগ করা ডেটাবেসে local index-এ লেখা সস্তা কিন্তু পড়া scatter-gather; global-এ পড়া এক জায়গায়, লেখা দামি বা পিছিয়ে। Index-এর বাইরে pool, replica, cache, read model, materialized view — আর query-ভিত্তিক p99.

পরের পর্বে: পর্ব ২২ — Replication: leader-follower, multi-leader, leaderless আর replication lag. Index আর query যত ভালোই হোক, একটি যন্ত্র বন্ধ হলে সব বন্ধ, আর তার পড়ার সামর্থ্যেরও সীমা আছে। এবার ডেটার কপি — কপিগুলো কীভাবে মেলে, আর পিছিয়ে থাকলে ব্যবহারকারী কী দেখেন।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান