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

Index — B+ tree, hash আর bitmap

Database Indexing — B+ Trees, Hash Indexes and Bitmap Indexes, with Costs You Can Calculate

Taught in Bangla, this lesson explains database indexes with calculable costs: lookups with and without an index, classification such as primary versus secondary, clustered versus unclustered and dense versus sparse, B+ tree structure, height and search, insertion and deletion with splits, merges and bulk loading, why B+ trees dominate on disk, static and extendible hashing, bitmap indexes and CREATE INDEX in SQL.

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

  • Index কী, আর index থাকা-না-থাকায় কত page পড়তে হয় — সংখ্যা দিয়ে হিসাব
  • Primary বনাম secondary, clustered বনাম non-clustered, dense বনাম sparse, single বনাম multi-level
  • B+ tree-এর গঠন, উচ্চতার সীমার প্রমাণ, search, range scan, insert-এ split আর delete-এ merge
  • Static ও extendible hashing, bitmap index, আর GiST, GIN, BRIN, R-tree-এর পরিচয়
  • লাইভ SQL-এ CREATE INDEX, EXPLAIN QUERY PLAN, UNIQUE আর partial index — আর index-এর দাম

১ · বইয়ের সূচি আর লাইব্রেরির ক্যাটালগ — index কী

পর্ব ৩৯-এ দেখেছেন, টেবিলের সারিগুলো ডিস্কে page-এ ভাগ হয়ে থাকে, আর খরচ মাপা হয় কয়টি page পড়তে হলো তা দিয়ে। এখন প্রশ্ন: WHERE customer_id = 42 লিখলে DBMS কোন page-এ যাবে? কোনো সাহায্য না থাকলে উত্তর একটিই — সবগুলো page পড়ে দেখা।

৮০০ পাতার একটি পাঠ্যবইয়ে “normalization” শব্দটি খুঁজতে আপনি প্রথম পাতা থেকে পড়া শুরু করেন না। পেছনের সূচিতে যান: শব্দগুলো বর্ণানুক্রমে সাজানো, পাশে পাতার নম্বর — “normalization … ২১৪, ৩৮০”। বিশ্ববিদ্যালয়ের লাইব্রেরিতেও তাই: লক্ষ বইয়ের তাক না ঘুরে ক্যাটালগে লেখকের নাম খোঁজেন, পান call number — কোন তাক, কোন সারি। আর একই লাইব্রেরিতে লেখক, শিরোনাম আর বিষয় অনুযায়ী তিনটি আলাদা ক্যাটালগ থাকতে পারে। ডেটাবেসের index ঠিক এই সূচি বা ক্যাটালগ।

সংজ্ঞাটি স্পষ্ট করা যাক। Index হলো টেবিলের বাইরে (বা পাশে) রাখা একটি সাজানো কাঠামো, যা একটি search key-এর মান থেকে সেই মানের সারিগুলোর ঠিকানায় নিয়ে যায়। প্রতিটি entry মূলত একটি জোড়া: (key, rid). এখানে record id (rid) হলো সারির ঠিকানা — (page নম্বর, page-এর ভেতরের slot). তিনটি কথা মনে রাখুন:

  • Search key আর primary key এক নয়। যেকোনো কলাম বা কলাম-সেটের ওপর index হয়, আর একই key বহু সারিতে থাকতে পারে (একজন গ্রাহকের ১০টি অর্ডার)।
  • Index একটি কপি। key-গুলোর সাজানো কপি, যা DBMS প্রতিটি লেখার সাথে নিজে হালনাগাদ রাখে। তাই এটি physical স্তরের বিষয় — যোগ বা মুছলে কোয়েরির ফল বদলায় না, শুধু গতি বদলায় (পর্ব ৩-এর physical data independence).
  • একটি টেবিলে অনেকগুলো index থাকতে পারে — ঠিক লাইব্রেরির তিন ক্যাটালগের মতো। কিন্তু সারিগুলো ডিস্কে একটিই ক্রমে সাজানো থাকতে পারে (অংশ ৩)।

এই পর্বের লাইভ SQL-এ বেশির ভাগ সময় একটি বড় টেবিল লাগবে, কারণ ১০টি সারিতে index-এর প্রভাব চোখে পড়ে না। পর্ব ২৫-এর recursive CTE দিয়ে ১০,০০০ সারির একটি orders টেবিল বানানো যাক: order_id (১ থেকে ১০,০০০), customer_id (১ থেকে ১,০০০), order_date (২০২৫ সালের কোনো দিন), amount (টাকা) আর status. মানগুলো সূত্র দিয়ে বানানো, তাই প্রতিবার একই আসে:

CREATE TABLE orders (
  order_id    INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL,
  order_date  TEXT NOT NULL,
  amount      INTEGER NOT NULL,
  status      TEXT NOT NULL
);
WITH RECURSIVE seq(n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 10000
)
INSERT INTO orders
SELECT n, (n * 37) % 1000 + 1,
       date('2025-01-01', '+' || (n % 365) || ' days'),
       (n * 7919) % 5000 + 100,
       CASE WHEN n % 50 = 0 THEN 'pending'
            WHEN n % 10 = 0 THEN 'cancelled' ELSE 'delivered' END
FROM seq;
SELECT COUNT(*) AS total_rows, COUNT(DISTINCT customer_id) AS customers,
       MIN(order_date) AS first_day, MAX(order_date) AS last_day
FROM orders;
SELECT status, COUNT(*) AS n FROM orders GROUP BY status ORDER BY n DESC;

“✓ CREATE TABLE”, তারপর INSERT-এর জন্য “✓ WITH RECURSIVE” (স্টেটমেন্টটি WITH দিয়ে শুরু)। প্রথম ফল: 10000 | 1000 | 2025-01-01 | 2025-12-31 — ১,০০০ জন গ্রাহক, প্রত্যেকের ঠিক ১০টি অর্ডার। দ্বিতীয় ফল: delivered | 9000, cancelled | 800, pending | 200. প্রতি ৫০তম অর্ডার pending — মাত্র ২%, অংশ ৯-এ এটি কাজে লাগবে। এর পরের এডিটরগুলোতে এই টেবিল আগে থেকেই বানানো থাকবে। (PostgreSQL-এ একই কাজ সহজে হয় generate_series(1, 10000) দিয়ে।)

২ · Index ছাড়া আর index দিয়ে — খরচের হিসাব

একটি বড় টেবিল ধরুন। সংখ্যাগুলো হিসাবের সুবিধার জন্য ধরে নেওয়া, কোনো নির্দিষ্ট প্রতিষ্ঠানের নয়:

  • N = ১ কোটি (107) সারি; একটি page-এ ১০০টি সারি, তাই P = ১ লাখ data page.
  • page ৮ KB (PostgreSQL-এর ডিফল্ট; InnoDB-তে ১৬ KB). একটি index entry — ৮ বাইটের key, ৬–৮ বাইটের ঠিকানা আর কিছু overhead — মোটামুটি ২০ বাইট। তাই একটি page-এ ৪০০-র মতো entry ধরে; page পুরো ভরা থাকে না বলে কার্যকর fanout F ধরা যাক ২০০ থেকে ৫০০।

Index ছাড়া: WHERE customer_id = 42-এর উত্তর যেকোনো page-এ থাকতে পারে, তাই full table scan — P = ১,০০,০০০ page. key unique হলেও গড়ে অর্ধেক, আর না পেলে সবগুলো।

B+ tree index দিয়ে: গাছের প্রতিটি স্তরে একটি page পড়ে নিচে নামতে হয়। leaf-এ ১ কোটি entry, প্রতিটি leaf-এ ধরুন ২৫০টি — তাই ৪০,০০০ leaf. তার ওপরের স্তরে ৪০,০০০ ÷ ২৫০ = ১৬০টি node, তার ওপরে একটি root. উচ্চতা h = ৩। সাধারণভাবে:

h = ⌈logF N⌉ : F = 200 হলে ⌈7 ÷ 2.30⌉ = ⌈3.04⌉ = 4, F = 500 হলে ⌈7 ÷ 2.70⌉ = ⌈2.59⌉ = 3

তাহলে একটি সারি পেতে খরচ = hটি index page + ১টি data page = ৪ থেকে ৫টি page, ১ লাখের জায়গায়। আরও ভালো: root আর দ্বিতীয় স্তর মিলে মাত্র কয়েকশো page, যা প্রায় সবসময় buffer pool-এ থাকে। বাস্তবে ডিস্কে যেতে হয় ১–২ বার। আর টেবিল ১০ গুণ বড় হলে h বাড়ে বড়জোর ১।

Range কোয়েরি — যেখানে হিসাব জটিল হয়

“এক সপ্তাহের অর্ডার” ধরনের কোয়েরিতে ধরুন মোট সারির ১%, অর্থাৎ ১ লাখ সারি মেলে। খরচ = h (প্রথম key পর্যন্ত নামা) + মিল থাকা leaf page (১ লাখ ÷ ২৫০ = ৪০০) + data page. শেষ অংশটিই নির্ণায়ক:

পথসমতা (১টি সারি)range (১ লাখ সারি, ১%)
Full scan১,০০,০০০ page (sequential)১,০০,০০০ page (sequential)
Clustered index (সারি key-এর ক্রমে সাজানো)৩–৪ + ১৩–৪ + ৪০০ leaf + ১,০০০ data page
Non-clustered index৩–৪ + ১৩–৪ + ৪০০ leaf + সর্বোচ্চ ১,০০,০০০ random page

শেষ সারিটি অবাক করে: সারিগুলো যদি ডিস্কে ছড়ানো থাকে, প্রতিটি মিলের জন্য আলাদা একটি data page — ১ লাখ random পড়া, যা ১ লাখ sequential পড়ার চেয়েও ধীর। তাই selectivity বড় হলে optimizer জেনেশুনে index ফেলে full scan বেছে নেয় (পর্ব ৪৩)। Index সবসময় জেতে না; জেতে যখন সামান্য কিছু সারি লাগে।

এবার নিজে দেখুন। SQLite-এর EXPLAIN QUERY PLAN কোয়েরি না চালিয়ে বলে দেয় কোন পথে চালাবে। SCAN মানে পুরো টেবিল পড়া, SEARCH … USING INDEX মানে index ধরে খোঁজা:

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 42;

CREATE INDEX idx_orders_customer ON orders(customer_id);

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE customer_id = 42;

SELECT order_id, order_date, amount, status
FROM orders WHERE customer_id = 42;

প্রথম plan: SCAN orders — ১০,০০০ সারির প্রতিটি পড়ে দেখা হবে। “✓ CREATE INDEX”-এর পর একই কোয়েরির plan বদলে যায়: SEARCH orders USING INDEX idx_orders_customer (customer_id=?). (customer_id=?) বলছে index-এ কোন শর্ত দিয়ে খোঁজা হচ্ছে। শেষ কোয়েরি ১০টি সারি দেয় — প্রথমটি 893 | 2025-06-13 | 1767 | delivered, শেষটি 9893 | 2025-02-08 | 2767 | delivered. লক্ষ করুন, ফল index ছাড়াও একই হতো; index শুধু পথ বদলায়। আর সারিগুলো order_id-এর ক্রমে এসেছে, কারণ index-এ একই key-এর entry-গুলো rowid-এর ক্রমে থাকে — তবে ORDER BY ছাড়া কোনো ক্রমের নিশ্চয়তা নেই।

৩ · Index-এর শ্রেণিবিভাগ — চার জোড়া শব্দ

পাঠ্যবই আর পরীক্ষায় চার জোড়া শব্দ বারবার আসে। আগে একটি সতর্কতা: বইভেদে সংজ্ঞা একটু আলাদা। Silberschatz-এর বইয়ে primary index আর clustering index একই; Elmasri–Navathe-এর বইয়ে primary index হলো সাজানো ফাইলের key কলামে, আর clustering index সাজানো ফাইলের non-key কলামে। লিখিত পরীক্ষায় কোন সংজ্ঞা ধরছেন, এক লাইনে বলে নিন।

Primary বনাম secondary

Primary index-এর search key অনুযায়ী ফাইলের সারিগুলো নিজেরাই সাজানো — তাই একটিই হতে পারে। Secondary index-এর ক্রম ফাইলের ক্রম থেকে আলাদা; যত খুশি।

Clustered বনাম non-clustered

Clustered: সারিগুলো key-এর ক্রমে ডিস্কে (বা index-এর leaf-এই পুরো সারি)। Non-clustered: leaf-এ শুধু key আর ঠিকানা, সারি অন্য ক্রমে।

Dense বনাম sparse

Dense: প্রতিটি সারির (বা key মানের) জন্য entry. Sparse: প্রতিটি data page-এর প্রথম key-এর জন্য একটি entry — শুধু সাজানো ফাইলে চলে।

Single বনাম multi-level

Index নিজেই বড় হলে তার ওপর আরেকটি sparse index, তার ওপর আরেকটি — multi-level index. এটিই B+ tree-এর জন্মকথা।

Sparse index-এর হিসাবটি দেখুন। আমাদের ১ কোটি সারি order_id-এর ক্রমে সাজানো থাকলে dense index-এ ১ কোটি entry, কিন্তু sparse index-এ ১ লাখ — প্রতিটি data page-এর জন্য একটি। key 5,00,123 খুঁজতে sparse index-এ “5,00,123-এর সমান বা ঠিক ছোট” entry পান, সেই page-এ যান, ভেতরে খোঁজেন। কিন্তু secondary index কখনো sparse হতে পারে না: ফাইল অন্য ক্রমে থাকলে “এই page-এর প্রথম key” থেকে পরের key কোথায়, বলা যায় না।

১ লাখ entry-র sparse index-ও প্রায় ২৫০–৫০০ page. তার ওপর আরেকটি sparse index বানালে ১–২টি page — এক page-এই আঁটে। এভাবে স্তরের পর স্তর: multi-level index-এ খোঁজ = স্তরের সংখ্যা = logF N টি page. ১৯৬০-এর দশকে IBM-এর ISAM ফাইল ঠিক এমন স্থির বহু-স্তরের index ব্যবহার করত; সমস্যা ছিল, নতুন সারি এলে কাঠামো বদলাত না, overflow area-য় জমত আর ধীরে ধীরে ধীর হতো। নিজে নিজে বাড়া-কমা multi-level index — সেটিই B+ tree.

বাস্তব সিস্টেমে কে কোনটি

  • MySQL InnoDB: টেবিল নিজেই primary key-এর ওপর একটি clustered B+ tree — leaf-এ পুরো সারি। প্রতিটি secondary index-এর leaf-এ থাকে primary key-এর মান, physical ঠিকানা নয়। তাই secondary দিয়ে খুঁজলে দুটি গাছে নামতে হয়।
  • PostgreSQL: টেবিল একটি heap file — কোনো ক্রম নেই; primary key-সহ সব index আলাদা, non-clustered, leaf-এ (page, slot) ঠিকানা। CLUSTER কমান্ড একবার সাজায়, পরের লেখায় ক্রম আর রক্ষা হয় না।
  • SQL Server: একটি টেবিলে একটি clustered index (সাধারণত primary key-এ), বাকিগুলো non-clustered. Oracle-এ সাধারণ টেবিল heap, আর clustered রূপের নাম index-organized table.
  • SQLite: সাধারণ টেবিল নিজেই rowid-এর ওপর একটি B-tree, সারি থাকে leaf-এ — কার্যত clustered. INTEGER PRIMARY KEY হলো সেই rowid-এরই আরেক নাম।

শেষ কথাটি SQLite নিজেই দেখায়। order_id দিয়ে খুঁজলে কোনো CREATE INDEX লাগে না, কিন্তু amount-এ কোনো index নেই:

EXPLAIN QUERY PLAN SELECT * FROM orders WHERE order_id = 5000;
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE amount = 2500;
SELECT order_id, customer_id, amount FROM orders WHERE order_id = 5000;

প্রথম plan: SEARCH orders USING INTEGER PRIMARY KEY (rowid=?) — টেবিলের নিজের B-tree-তে সরাসরি নামা, আলাদা index নেই, তাই data page-এর বাড়তি ধাপও নেই। দ্বিতীয়: SCAN orders — amount-এ index নেই। তৃতীয় কোয়েরির ফল 5000 | 1 | 100. এখানে clustered আর non-clustered-এর পার্থক্যটি হাতে-কলমে: rowid দিয়ে খোঁজায় leaf-এই পুরো সারি; customer_id-এর index দিয়ে খোঁজায় leaf থেকে rowid পেয়ে আবার টেবিলের গাছে নামতে হয়।

৪ · B+ tree-এর গঠন, উচ্চতা আর খোঁজ

B+ tree একটি balanced বহু-শাখার গাছ, যার প্রতিটি node ঠিক একটি page. ধরুন একটি node-এ সর্বোচ্চ mটি child pointer ধরে (order m). নিয়মগুলো:

  • Internal node: kটি key আর k + ১টি pointer: P0 K1 P1 K2 P2 … Kk Pk. Pi-এর নিচের সব key Ki ≤ key < Ki+1 মানে। এখানে কোনো সারির ঠিকানা নেই — শুধু পথনির্দেশ।
  • Leaf: সাজানো (key, rid) জোড়া, আর পাশের leaf-এর একটি pointer — leaf link. সব leaf মিলে পুরো key-সেটের একটি সাজানো শিকল।
  • ভরাটের নিয়ম: root ছাড়া প্রতিটি node অন্তত অর্ধেক ভরা (internal node-এ অন্তত ⌈m/2⌉টি child). root-এ অন্তত ২টি child (যদি root নিজে leaf না হয়)।
  • Balance: সব leaf একই গভীরতায়। তাই যেকোনো key-এর খোঁজ ঠিক hটি node পড়ে।

পুরোনো B-tree-এর সাথে পার্থক্য এখানেই: B-tree-এ ভেতরের node-এও key-এর সাথে তথ্য থাকে, তাই কিছু খোঁজ আগেই থামে। কিন্তু ভেতরের node-এ তথ্য রাখলে প্রতি page-এ key কম ধরে, fanout কমে; আর range scan-এ গাছে ওঠানামা করতে হয়। B+ tree ভেতরের node-কে হালকা রাখে, সব তথ্য leaf-এ নামায় — fanout বাড়ে, আর range মানে শুধু leaf-এর শিকল ধরে হাঁটা।

উচ্চতার সীমা — একটি ছোট প্রমাণ

ধরুন Lটি leaf আর উচ্চতা h (root-কে স্তর ১ ধরে, leaf স্তর h). প্রতিটি node-এর সর্বোচ্চ mটি child, তাই স্তর i-তে বড়জোর mi−1টি node; leaf-গুলো স্তর h-এ, তাই L ≤ mh−1. উল্টো দিকে, root-এর অন্তত ২টি আর বাকি internal node-এর অন্তত ⌈m/2⌉টি child, তাই L ≥ 2·⌈m/2⌉h−2. দুই দিক থেকে log নিলে:

1 + logm L ≤ h ≤ 2 + log⌈m/2⌉ (L / 2)

দুই সীমাই log L-এর সমানুপাতিক, ভিত্তি কয়েকশো। m = ৪০০ আর L = ৪০,০০০ বসান: নিম্নসীমা 1 + 1.77 = 2.77, ঊর্ধ্বসীমা 2 + log200 20,000 = 2 + 1.87 = 3.87. উচ্চতা পূর্ণসংখ্যা, তাই ঠিক ৩। অর্থাৎ গাছটি যত খারাপভাবেই ভরা থাকুক, ভরাটের নিয়ম মানলে উচ্চতা log-এর বাইরে যেতে পারে না। এই নিশ্চয়তাই B+ tree-কে নির্ভরযোগ্য করে।

খোঁজ আর range scan, ধাপে ধাপে

নিচের গাছে প্রতিটি node একটি page. প্রথমে WHERE id = 45, তারপর WHERE id BETWEEN 42 AND 55:

উচ্চতা ৩-এর একটি B+ tree (মাঝের শাখাটুকু দেখানো)রঙিন = এই ধাপে পড়া page

            
হিসাব
এখন পড়ছি
index page পড়া
data page পড়া
  1. কাজ। id = 45 খুঁজতে হবে। গাছের উচ্চতা ৩: root, এক স্তর internal node, তারপর leaf. প্রতিটি node একটি page.
  2. Root. root page পড়ে দুটি key-এর সাথে তুলনা: 45 হলো 30 আর 60-এর মাঝে, তাই মাঝের pointer. ভেতরের তুলনা মেমরিতে, খরচ নগণ্য — খরচ হলো page পড়া।
  3. Internal node. [40 │ 50]: 45 হলো 40 আর 50-এর মাঝে, আবার মাঝের pointer. ১ কোটি সারির গাছেও এই স্তর মাত্র এক-দুটি।
  4. Leaf. page-এর ভেতরের সাজানো entry-তে binary search চালিয়ে 45 পাওয়া গেল, সাথে ঠিকানা (812, 4) — page 812-এর slot 4.
  5. Data page. ঠিকানা ধরে সারিটি পড়া। মোট ৪টি page. clustered গাছে (InnoDB-র primary key, SQLite-এর rowid) leaf-এই সারি থাকে, তাই এই ধাপও লাগত না।
  6. Range: প্রথমে নামা। 42 থেকে 55. আগের মতোই ৩টি page পড়ে 42-এর leaf-এ পৌঁছানো, তারপর সেই leaf-এর 42 আর 45 নেওয়া।
  7. Leaf link ধরে এগোনো। গাছে আবার না উঠে পাশের leaf-এ যাওয়া: 50, 55 নেওয়া, 58 সীমার বাইরে — থামা। খরচ = h + মিলের leaf page + মিলের data page. ORDER BY id-ও এভাবেই সাজানো ক্রমে বিনা sort-এ আসে।

এবার SQLite-এ একটি তারিখের range আর ORDER BY — দুটিই একই index-এর leaf শিকল ব্যবহার করে:

CREATE INDEX idx_orders_date ON orders(order_date);
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE order_date BETWEEN '2025-03-01' AND '2025-03-07';
SELECT COUNT(*) AS n, SUM(amount) AS revenue
FROM orders WHERE order_date BETWEEN '2025-03-01' AND '2025-03-07';
EXPLAIN QUERY PLAN
SELECT * FROM orders ORDER BY order_date LIMIT 5;
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE substr(order_date, 1, 7) = '2025-03';

প্রথম plan: SEARCH orders USING INDEX idx_orders_date (order_date>? AND order_date<?) — দুই সীমা দিয়ে range scan; plan-এ চিহ্ন দুটি > আর < দেখালেও BETWEEN-এর দুই প্রান্তই ফলে থাকে। ফল 196 | 507298: সাত দিনে ১৯৬টি অর্ডার। দ্বিতীয় plan SCAN orders USING INDEX idx_orders_date — পুরো index ক্রমানুসারে হাঁটা, কিন্তু আলাদা sort নেই, আর LIMIT 5 পেলেই থামে। শেষটি সতর্কবার্তা: SCAN orders. কলামটি একটি ফাংশনের ভেতরে গেলে index-এর সাজানো ক্রম আর কাজে লাগে না — পর্ব ১৭-এর sargable নিয়ম। একই প্রশ্ন order_date >= '2025-03-01' AND order_date < '2025-04-01' লিখলে index কাজ করত।

৫ · B+ tree-এ insert আর delete — split, merge আর bulk loading

Insert-এর নিয়ম সরল: খোঁজের মতো নেমে ঠিক leaf-এ যান, সাজানো জায়গায় key বসান। leaf-এ জায়গা থাকলে কাজ শেষ। না থাকলে split: leaf-এর key-গুলো দুই ভাগ, ডান ভাগের প্রথম key-এর একটি কপি parent-এ ওঠে (leaf-এ key-টি থেকেও যায়)। parent-ও উপচে পড়লে সেটিও ভাগ হয়, কিন্তু internal node ভাগে মাঝের key-টি সরে ওপরে যায়। root ভাগ হলে নতুন root — গাছ এভাবেই, শুধু ওপরের দিকে, এক স্তর বাড়ে। তাই সব leaf চিরকাল একই গভীরতায় থাকে।

প্রতিটি node-এ সর্বোচ্চ ৩টি keyরঙিন = এই ধাপে বদলানো node

            
গাছের অবস্থা
উচ্চতা
leaf-এর সংখ্যা
ঘটনা
  1. শুরু। উচ্চতা ২-এর একটি ছোট গাছ, প্রতিটি node-এ সর্বোচ্চ ৩টি key. root-এর দুটি key তিনটি leaf-এর সীমা ঠিক করে।
  2. Insert 45. 45 ≥ 40, তাই ডানের leaf. সেখানে দুটি key ছিল, জায়গা আছে — সাজানো জায়গায় বসানো। বেশির ভাগ insert এমনই সস্তা: h টি page পড়া আর একটি লেখা।
  3. Insert 28. 28 যায় মাঝের leaf-এ, যা আগেই পূর্ণ। সাময়িকভাবে ৪টি key — নিয়ম ভাঙল, ভাগ করতে হবে।
  4. Leaf split. [20 25] আর [28 30] — দুটিই অর্ধেক ভরা। ডান ভাগের প্রথম key 28-এর কপি parent-এ ওঠে; 28 leaf-এও থাকে, কারণ সব data entry leaf-এ থাকতেই হবে। leaf link-ও নতুন leaf-কে শিকলে জোড়ে।
  5. Insert 12 — ঢেউ ওপরে। বাঁয়ের leaf উপচে [5 10] আর [12 15] হয়, 12-এর কপি root-এ ওঠে। এখন root-এ ৪টি key — root-ও overflow.
  6. Root split. internal node-এর ভাগে মাঝের key 28 নিচে থাকে না, সরে নতুন root হয়। বাঁয়ে [12 │ 20] তিনটি leaf দেখায়, ডানে [40] দুটি। উচ্চতা ২ থেকে ৩ — নতুন স্তর এল ওপরে।
  7. Balance রক্ষা। গাছ নিচের দিকে কখনো লম্বা হয় না, তাই সব leaf একই গভীরতায় থাকে। split খরচসাপেক্ষ (নতুন page, parent হালনাগাদ), কিন্তু বিরল — fanout কয়েকশো হলে প্রায় প্রতি কয়েকশো insert-এ একবার।

Delete — merge আর redistribute

Delete উল্টো পথে চলে: leaf থেকে entry মুছুন। leaf অর্ধেকের নিচে নামলে দুটি উপায়। পাশের leaf-এ বাড়তি key থাকলে একটি ধার নিন — redistribution — আর parent-এর ভাগ-করা key হালনাগাদ করুন। পাশের leaf-ও টায়টায় অর্ধেক হলে দুটিকে জুড়ে দিন — merge — আর parent থেকে একটি key সরান। parent অর্ধেকের নিচে নামলে একই কাজ ওপরে, আর root-এ একটিমাত্র child থাকলে root সরে যায় — গাছ এক স্তর নামে।

বাস্তব সিস্টেম অবশ্য এত তৎপর নয়। মুছে ফেলা জায়গা সাধারণত পরের insert-এর জন্য রেখে দেওয়া হয়, কারণ যে টেবিল থেকে মোছা হয় তাতে আবার লেখাও আসে। PostgreSQL-এর B-tree মূলত পুরো খালি page-ই সরায়; InnoDB একটি page-এর ভরাট একটি সীমার (ডিফল্টে ৫০%) নিচে নামলে পাশের page-এর সাথে জোড়ার চেষ্টা করে। অনেক মোছার পর index ফাঁপা হয়ে গেলে REINDEX বা পুনর্নির্মাণ করা হয়।

Bulk loading — একবারে পুরো গাছ

একটি বিদ্যমান ১ কোটি সারির টেবিলে CREATE INDEX চালালে DBMS ১ কোটিবার insert করে না। Bulk loading-এ আগে সব (key, rid) জোড়া সাজানো হয় (external sort, পর্ব ৪২), তারপর বাঁ থেকে ডানে leaf ভরা হয়, আর প্রতিটি leaf-এর প্রথম key দিয়ে নিচ থেকে ওপরে internal স্তর বানানো হয়। ফল: প্রায় sequential লেখা, কোনো split নেই, আর leaf-গুলো ঘন। leaf পুরো ভরা না রেখে কিছু জায়গা ফাঁকা রাখা হয়, যাতে পরের insert-এ সাথে সাথে split না লাগে — PostgreSQL-এর B-tree-এ এই fillfactor ডিফল্টে ৯০%.

৬ · কেন ডিস্কে B+ tree-ই রাজা

প্রায় পঞ্চাশ বছর ধরে প্রায় সব relational DBMS-এর ডিফল্ট index B+ tree. কারণগুলো একসাথে দেখুন:

উঁচু fanout, নিচু গাছ

একটি node = একটি page = কয়েকশো key. binary search tree-তে ১ কোটি key-এ উচ্চতা অন্তত ২৪ (log₂ 107 ≈ 23.3); B+ tree-এ ৩–৪। প্রতিটি স্তর একটি সম্ভাব্য ডিস্ক-পড়া।

এক কাঠামো, অনেক কাজ

সমতা, range, ORDER BY, MIN/MAX, prefix (LIKE 'Rah%') — সবই সাজানো ক্রম থেকে আসে।

নিশ্চিত খরচ

balance আর অর্ধেক-ভরাটের নিয়মে worst case-ও log. hash-এর মতো খারাপ ডেটায় হঠাৎ ধসে পড়ে না।

ক্রমিক leaf

bulk load-এর পর leaf-গুলো ডিস্কে প্রায় পাশাপাশি, তাই range scan প্রায় sequential I/O.

গভীরে আরও দুটি ব্যাপার: প্রতিটি page-এ key সাজানো থাকায় page-এর ভেতরে binary search চলে, আর ওপরের স্তরগুলো ছোট বলে সবসময় মেমরিতে থাকে। DSA কোর্সের B-tree পর্বে একই গাছের অ্যালগরিদমিক দিক — order, split-এর প্রমাণ আর কোড — বিস্তারিত পাবেন।

ইতিহাস এক নজরে। Rudolf Bayer আর Edward McCreight Boeing Scientific Research Laboratories-এ কাজ করার সময় B-tree উদ্ভাবন করেন; তাঁদের প্রবন্ধ “Organization and Maintenance of Large Ordered Indexes” ১৯৭২ সালে Acta Informatica-তে প্রকাশিত হয়। “B” অক্ষরের অর্থ তাঁরা কখনো নির্দিষ্ট করে বলেননি — balanced, Boeing আর Bayer, তিনটি অনুমানই চলে। ১৯৭৯ সালে Douglas Comer-এর সমীক্ষা “The Ubiquitous B-Tree” দেখায় যে ততদিনে এটি সর্বত্র, আর B+ রূপটিও সেখানে বিস্তারিত আলোচিত। একই বছর Ronald Fagin ও সহকর্মীরা extendible hashing প্রকাশ করেন, আর ১৯৮০ সালে Witold Litwin linear hashing.

৭ · Hash index — static, overflow আর extendible hashing

B+ tree-এ সমতার খোঁজও h টি page. Hash index সরাসরি লাফ দেয়: hash function h(key) থেকে bucket-এর নম্বর বের করে, সেই bucket-এর page পড়ে। আদর্শ অবস্থায় ১–২টি page. মূল ধারণা DSA-এর hash table পর্বের মতোই, শুধু এখানে প্রতিটি bucket একটি ডিস্ক page.

Static hashing: শুরুতে নির্দিষ্ট Mটি bucket, bucket = h(k) mod M. ডেটা বাড়লে bucket-এর page ভরে, তার সাথে বাড়তি page জোড়া লাগে — overflow chain. শিকল লম্বা হলে খোঁজ আর ১ page নয়, ৫–১০ page. সমাধান পুরো index নতুন M দিয়ে আবার hash করা — চালু ডেটাবেসে দীর্ঘ, ভারী কাজ।

Extendible hashing: একটি ছোট directory রাখুন, যার entry-সংখ্যা 2d; d হলো global depth — hash মানের শেষ কয়টি bit দেখা হবে। প্রতিটি bucket-এর একটি local depth — তার key-গুলো শেষ কয়টি bit-এ মেলে। bucket ভরলে শুধু সেটিই ভাগ হয়। local depth আগেই global depth-এর সমান হলে আগে directory দ্বিগুণ হয় — directory ছোট, তাই সস্তা। নিচে সহজ করার জন্য h(k) = k-এর binary রূপ, আর প্রতিটি bucket-এ সর্বোচ্চ ২টি key:

bucket-এ সর্বোচ্চ ২টি key · h(k) = k-এর binaryরঙিন = এই ধাপে বদলানো

            
অবস্থা
global depth
directory entry
bucket
  1. শুরু। global depth ১: directory-তে দুটি entry, শেষ bit 0 হলে A, 1 হলে B. 4 আর 12 দুটিরই শেষ bit 0.
  2. Insert 7. 0111-এর শেষ bit 1, তাই B. সেখানে জায়গা ছিল — কাজ শেষ। এক directory entry দেখা, এক bucket লেখা।
  3. Directory doubling. 10 (1010) যায় A-তে, A পূর্ণ। A-এর local depth ১, global depth-ও ১ — A-কে ভাগ করার মতো বাড়তি bit directory দেখে না। তাই directory দ্বিগুণ: ৪টি entry, প্রতিটি পুরোনো entry দুটি হলো, দুটিই আগের bucket দেখায়। কোনো key সরেনি।
  4. শুধু A ভাগ। এখন শেষ দুই bit দেখে A-এর key ভাগ: 4 আর 12 (..00) A-তে থাকে, নতুন bucket C পায় 10 (..10). dir 10 এখন C দেখায়। B ছোঁয়াই হয়নি।
  5. Doubling ছাড়া split. 13 (1101, শেষ দুই bit 01) যায় B-তে, B পূর্ণ। কিন্তু B-এর local depth ১, global depth ২ — দুটি entry (01, 11) আগে থেকেই B দেখায়। তাই শুধু B ভাগ: 5 আর 13 থাকে B-তে, 7 (..11) যায় নতুন D-তে। directory-র আকার একই।
  6. আবার doubling. 16 (10000, শেষ দুই bit 00) যায় A-তে, A পূর্ণ, আর local depth ২ = global depth ২। directory ৮ entry-তে; শেষ তিন bit দেখে A ভাগ: 16 (..000) নতুন E-তে, 4 আর 12 (..100) A-তে। C, B, D-এর প্রতিটিকে এখন দুটি করে entry দেখায়।
  7. খোঁজ আর সীমা। 13 খুঁজতে শেষ তিন bit 101 → B — একটি bucket page. কিন্তু range-এ hash অকেজো: পাশাপাশি key আলাদা bucket-এ ছড়ানো, তাই BETWEEN, ORDER BY বা prefix খোঁজে সব bucket পড়তে হয়।

Linear hashing directory ছাড়াই একই লক্ষ্যে পৌঁছায়: একটি “পরের-ভাগ” pointer ধরে bucket-গুলো পালাক্রমে একটি একটি করে ভাগ হয় — যে bucket উপচে পড়ল সেটি নয়, pointer যেটি দেখায় সেটি। তাই উপচে পড়া bucket-এ সাময়িক overflow page থাকে, কিন্তু index ধীরে ধীরে, সমানভাবে বাড়ে।

বাস্তবে hash index কম দেখা যায়। PostgreSQL-এ USING hash আছে, তবে version 10-এর আগে এর লেখা WAL-এ যেত না, তাই crash-এর পর নির্ভরযোগ্য ছিল না আর ডকুমেন্টেশন নিরুৎসাহিত করত। MySQL InnoDB-তে সাধারণ index (FULLTEXT আর SPATIAL ছাড়া) B+ tree; InnoDB নিজে ঘন ঘন পড়া page-এর জন্য মেমরিতে একটি adaptive hash index বানায়, আর MEMORY engine-এ আসল hash index আছে। Hash-এর আসল জয় মেমরিতে, সাময়িক কাজে — পর্ব ৪২-এর hash join.

৮ · Bitmap index আর অন্যান্য index

status কলামে হাতে-গোনা কয়েকটি মান — pending, shipped, delivered, cancelled. এমন কম আলাদা মানের (low-cardinality) কলামে B+ tree দুর্বল: “status = delivered” ৯০% সারি মেলায়, index দিয়ে লাভ নেই। Bitmap index অন্যভাবে ভাবে: প্রতিটি আলাদা মানের জন্য একটি bit-এর সারি, যার i-তম bit 1 মানে i-তম সারিতে সেই মান।

ক্লাসের হাজিরা খাতা ভাবুন: প্রতিটি দিনের জন্য একটি কলাম, প্রতিটি ছাত্রের ঘরে উপস্থিত (১) বা অনুপস্থিত (০)। “রবি আর সোমবার দুই দিনই কে উপস্থিত?” — দুই কলাম পাশাপাশি রেখে যেখানে দুটিই ১, সেটি। কোনো নাম খোঁজা লাগে না, শুধু ঘর মেলানো। bitmap index-এর AND ঠিক এই কাজ, শুধু কম্পিউটার একসাথে ৬৪টি ঘর মেলায়।

db-shop-এর orders (১০টি অর্ডার, order_id 1001–1010, status) আর customers (customer_id, name, city …) দিয়ে দুটি bitmap বানিয়ে AND করে দেখা যাক — “delivered অর্ডার, ঢাকার গ্রাহক”:

WITH b AS (
  SELECT o.order_id,
         (o.status = 'delivered')       AS delivered,
         COALESCE(c.city = 'Dhaka', 0) AS dhaka
  FROM orders o JOIN customers c ON c.customer_id = o.customer_id
)
SELECT group_concat(delivered, '' ORDER BY order_id)          AS delivered_bits,
       group_concat(dhaka, '' ORDER BY order_id)              AS dhaka_bits,
       group_concat(delivered & dhaka, '' ORDER BY order_id) AS both_bits,
       SUM(delivered & dhaka)                                 AS matches
FROM b;

একটি সারি: 1100101001 | 1011000110 | 1000000000 | 1. বাঁ থেকে প্রথম bit অর্ডার 1001-এর, শেষটি 1010-এর। delivered: 1001, 1002, 1005, 1007, 1010. ঢাকা: Rahim (1001, 1003, 1009), Sabbir (1004), Mitu (1008). AND (&) করলে শুধু প্রথম bit 1 — অর্ডার 1001. Imran-এর শহর NULL, তাই c.city = 'Dhaka' হয় NULL; COALESCE দিয়ে 0 করা হয়েছে — বাস্তব bitmap index-ও NULL-এর জন্য আলাদা bitmap রাখে। SQLite-এর নিজের কোনো bitmap index নেই; এখানে শুধু ধারণাটি দেখানো হলো।

১ কোটি সারিতে একটি bitmap = ১ কোটি bit ≈ ১.২৫ MB, চারটি মানে ৫ MB — একই কলামের B+ tree-এর চেয়ে অনেক ছোট, আর run-length ধরনের compression-এ আরও ছোট। পাঁচটি শর্তের কোয়েরি মানে পাঁচটি bitmap-এর AND — CPU-র সবচেয়ে সস্তা কাজ। তাই data warehouse-এ (পর্ব ৫৫) bitmap index খুব জনপ্রিয়; Oracle-এ CREATE BITMAP INDEX আছে। কিন্তু OLTP-তে বিপদ: একটি সারির status বদলানো মানে দুটি bitmap বদলানো, আর একটি bitmap অংশ বহু সারি ঢাকে বলে lock-এ অনেক লেখা আটকে যায়। PostgreSQL-এ স্থায়ী bitmap index নেই, কিন্তু কোয়েরির সময় একাধিক B+ tree থেকে সাময়িক bitmap বানিয়ে AND/OR করে — plan-এ BitmapAnd আর Bitmap Heap Scan.

আরও কিছু index, এক নজরে

Indexকী রাখেকোন প্রশ্নে
GIN (PostgreSQL)মানের ভেতরের প্রতিটি অংশ → সারির তালিকাjsonb-তে key আছে কি, array-তে মান, full-text শব্দ
Inverted indexশব্দ → ডকুমেন্টের তালিকাfull-text search — পর্ব ৩০-এ বিস্তারিত
GiST (PostgreSQL)প্রসারণযোগ্য balanced গাছজ্যামিতি, range overlap, নিকটতম প্রতিবেশী
R-treeপ্রতিটি node-এ ঘিরে রাখা আয়তক্ষেত্র“এই এলাকার ভেতরের দোকান” — SQLite-এর R*Tree, MySQL-এর spatial index
BRIN (PostgreSQL)প্রতি ১২৮ page-এর min আর maxসময়ের ক্রমে জমা বিশাল log টেবিল — index মাত্র কয়েক page
CREATE INDEX idx_orders_hash ON orders USING hash (customer_id);
CREATE INDEX idx_docs_gin    ON documents USING gin (body_tsv);
CREATE INDEX idx_logs_brin   ON access_logs USING brin (logged_at);
CREATE INDEX idx_shops_gist  ON shops USING gist (location);  -- PostGIS

৯ · SQL-এ index — CREATE, UNIQUE, partial আর দাম

সিনট্যাক্স প্রায় সব DBMS-এ এক: CREATE [UNIQUE] INDEX নাম ON টেবিল(কলাম, …). মোছার ধরনে পার্থক্য আছে:

DROP INDEX idx_orders_customer;                 -- SQLite, PostgreSQL
DROP INDEX idx_orders_customer ON orders;       -- MySQL (টেবিলের নাম লাগে)
CREATE INDEX CONCURRENTLY idx_orders_date
    ON orders (order_date);                     -- PostgreSQL: লেখা না আটকে বানানো

UNIQUE index — গতি নয়, নিয়ম

Unique index একই key দুবার ঢুকতে দেয় না। index সাজানো বলে “এই মান আগে আছে কি?” প্রশ্নের উত্তর log-খরচে মেলে — তাই DBMS UNIQUE constraint এভাবেই প্রয়োগ করে। এখানে একটি নতুন members টেবিল (member_id, name, email):

CREATE TABLE members (
  member_id INTEGER PRIMARY KEY,
  name      TEXT NOT NULL,
  email     TEXT NOT NULL
);
CREATE UNIQUE INDEX ux_members_email ON members(email);
INSERT INTO members VALUES (1, 'Rahim', 'rahim@example.com');
INSERT INTO members VALUES (2, 'Karima', 'karima@example.com');
SELECT * FROM members;
INSERT INTO members VALUES (3, 'Rahim Ali', 'rahim@example.com');

দুটি “✓”, দুটি INSERT-এ “১টি সারি বদলেছে”, SELECT-এ দুটি সারি। শেষ INSERT-এ ত্রুটি: UNIQUE constraint failed: members.email — এবং রানার এখানেই থামে। লক্ষ করুন, ত্রুটির বার্তা “constraint” বলে, যদিও আমরা constraint নয়, index বানিয়েছিলাম: SQLite-এর কাছে দুটি একই জিনিস। কলামে আগে থেকেই ডুপ্লিকেট থাকলে CREATE UNIQUE INDEX নিজেই ব্যর্থ হতো।

Automatic index — যা আপনি না লিখলেও আছে

PRIMARY KEY আর UNIQUE ঘোষণা করলে DBMS নিজে index বানায়। SQLite সব index-এর তালিকা রাখে sqlite_master টেবিলে, আর PRAGMA index_list একটি টেবিলের index দেখায়। db-shop-এ আমরা একটিও CREATE INDEX লিখিনি:

SELECT type, name, tbl_name, sql
FROM sqlite_master WHERE type = 'index';
SELECT * FROM pragma_index_list('order_items');
PRAGMA index_list('customers');
SELECT * FROM pragma_index_info('sqlite_autoindex_order_items_1');

প্রথম ফলে তিনটি index, প্রতিটির sql NULL — অর্থাৎ স্বয়ংক্রিয়: sqlite_autoindex_customers_1 (phone UNIQUE), sqlite_autoindex_categories_1 (name UNIQUE) আর sqlite_autoindex_order_items_1 (যৌগিক primary key). দ্বিতীয় ফল 0 | sqlite_autoindex_order_items_1 | 1 | pk | 0: unique = 1, origin = pk (primary key থেকে), partial = 0. তৃতীয়টির origin u (UNIQUE থেকে)। শেষটি দেখায় index-এর কলাম: order_id, তারপর product_id. দুটি জিনিস নেই: INTEGER PRIMARY KEY-এর কোনো আলাদা index (সেটি rowid-ই), আর foreign key কলামের index — products.category_id বা orders.customer_id-এ কোনো index নেই। SQLite আর PostgreSQL foreign key-এ নিজে index বানায় না; MySQL InnoDB বানায়।

Partial index — শুধু দরকারি সারির

আমাদের ১০,০০০ অর্ডারের মাত্র ২০০টি pending, অথচ কুরিয়ার-ডেস্কের অ্যাপ সারাদিন শুধু pending অর্ডারই খোঁজে। পুরো টেবিলের index না বানিয়ে একটি WHERE দিয়ে শুধু সেই সারিগুলোর index — partial index:

CREATE INDEX idx_pending ON orders(customer_id) WHERE status = 'pending';
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE status = 'pending' AND customer_id = 301;
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE status = 'delivered' AND customer_id = 301;
SELECT order_id, order_date, amount FROM orders
WHERE status = 'pending' AND customer_id = 301;
SELECT name, sql FROM sqlite_master WHERE type = 'index';

প্রথম plan: SEARCH orders USING INDEX idx_pending (customer_id=?). দ্বিতীয়: SCAN orders — delivered সারিগুলো এই index-এ নেই-ই, তাই ব্যবহার করা যায় না। optimizer partial index তখনই নেয়, যখন কোয়েরির WHERE থেকে index-এর শর্তটি প্রমাণ করা যায়। তৃতীয় ফলে ১০টি সারি, প্রথমটি 900 | 2025-06-20 | 2200, শেষটি 9900 | 2025-02-15 | 3200. শেষ কোয়েরি index-এর সংজ্ঞা দেখায়, WHERE-সহ। লাভ: index-এ ১০,০০০ নয়, ২০০টি entry — ছোট, প্রায় পুরোটাই মেমরিতে, আর delivered অর্ডারের লেখায় এটিকে ছুঁতেই হয় না।

Index-এর দাম — write amplification

প্রতিটি index একটি কপি, আর DBMS সেটি প্রতিটি লেখার একই transaction-এ মিলিয়ে রাখে। ধরুন orders-এ পাঁচটি index. তাহলে একটি INSERT মানে টেবিলে একটি লেখা, পাঁচটি গাছে পাঁচবার নেমে পাঁচটি leaf-এ লেখা (মাঝে মাঝে split), আর এর প্রতিটির WAL record. একটি যৌক্তিক লেখা ডিস্কে বহু লেখা হয় — write amplification. DELETE-এও প্রতিটি index থেকে মুছতে হয়; আর indexed কলামের UPDATE মানে সেই index-এ একটি delete আর একটি insert. সাথে জায়গা: পাঁচটি index মিলে টেবিলের সমান বা বড় হওয়া অস্বাভাবিক নয়, আর buffer pool-এ জায়গা নিয়ে প্রতিযোগিতা করে।

তাই নিয়ম: যে কোয়েরি সত্যিই চলে আর ধীর, তার জন্য index; অব্যবহৃত index মুছুন। PostgreSQL-এর pg_stat_user_indexes দেখায় কোন index কতবার ব্যবহার হয়েছে। কোন কলামে, কোন ক্রমে composite index, আর covering index — পর্ব ৪১-এর বিষয়।

আরও গভীরে: বহু transaction একই B+ tree-তে একসাথে লিখলে split-এর মাঝে অন্যের খোঁজ ভুল পথে যেতে পারে। Lehman আর Yao-র ১৯৮১ সালের B-link tree প্রতিটি node-এ ডান পাশের node-এর pointer আর একটি “high key” রেখে এই সমস্যা মেটায় — PostgreSQL-এর B-tree এই নকশার ওপর গড়া। অন্যদিকে লেখা-প্রধান সিস্টেম (RocksDB, Cassandra) B+ tree-এর বদলে LSM-tree ব্যবহার করে: লেখা মেমরিতে জমিয়ে সাজানো ফাইল হিসেবে একবারে ডিস্কে, তারপর পটভূমিতে জোড়া — লেখা দ্রুত, কিন্তু পড়ায় কয়েকটি ফাইল দেখতে হয়।

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

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

AI-এর ভুলফলকীভাবে ধরবেন
প্রতিটি WHERE-এর কলামে আলাদা indexলেখা ধীর, জায়গা নষ্ট, অনেকগুলো অব্যবহৃতEXPLAIN-এ index আসলে নেওয়া হচ্ছে কি না দেখুন
status-এর মতো কলামে সাধারণ indexoptimizer উপেক্ষা করে, শুধু খরচমানের বণ্টন গুনুন; partial index ভাবুন
ফাংশনে মোড়া কলাম (substr, LOWER)index থাকতেও SCANplan পড়ুন; শর্ত sargable করে লিখুন
বিদ্যমান index-এর নাম বা কলাম অনুমানডুপ্লিকেট index বা ভুল DROPsqlite_master বা pg_indexes থেকে আসল তালিকা দিন
“hash index দ্রুত” বলে range কোয়েরিতে hashBETWEEN আর ORDER BY-তে অকেজোকোয়েরির ধরন (=, range, sort) মিলিয়ে দেখুন
চালু বড় টেবিলে সাধারণ CREATE INDEXবানানোর সময় লেখা আটকে যায়CONCURRENTLY বা online DDL-এর কথা জিজ্ঞেস করুন

AI-কে অনুমানের সুযোগ না দিয়ে তথ্য দিন — schema, বিদ্যমান index, ডেটার আকার আর আসল plan:

আমার ডেটাবেস PostgreSQL 16. টেবিল orders(order_id PK, customer_id, order_date, amount, status), প্রায় ৮০ লাখ সারি; দিনে ৩০ হাজার INSERT আর ৫০ হাজার status UPDATE. বিদ্যমান index: orders_pkey, idx_orders_customer(customer_id). status-এর বণ্টন: delivered ৯১%, cancelled ৭%, pending ২%. ধীর কোয়েরি: SELECT * FROM orders WHERE status = 'pending' ORDER BY order_date LIMIT 50; সাথে EXPLAIN (ANALYZE, BUFFERS)-এর ফল দিলাম। ১. plan পড়ে বলো সময় কোথায় যাচ্ছে। ২. একটি index প্রস্তাব করো, আর কেন এটি — সাধারণ, partial না composite — ব্যাখ্যা করো। ৩. এই index প্রতিটি INSERT আর status UPDATE-এ কী বাড়তি কাজ আনবে, বলো। ৪. চালু টেবিলে লেখা না আটকে বানানোর কমান্ড দাও। ৫. কোনো কলাম বা index-এর নাম অনুমান করবে না — না জানলে জিজ্ঞেস করো।

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

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

  • B-tree আর B+ tree-এর পার্থক্য? — B+ tree-এ সব data entry leaf-এ, ভেতরের node শুধু পথ দেখায়, leaf-গুলো যুক্ত; তাই fanout বেশি আর range scan সহজ।
  • Clustered আর non-clustered index? — clustered-এ সারিগুলো key-এর ক্রমে থাকে (টেবিলে একটিই); non-clustered আলাদা কাঠামো, leaf-এ ঠিকানা — অনেকগুলো হতে পারে।
  • Dense আর sparse index? — dense-এ প্রতিটি key-এর entry; sparse-এ প্রতিটি page-এর একটি, শুধু সাজানো ফাইলে চলে।
  • Hash index কখন, কখন নয়? — শুধু সমতার খোঁজে; range, ORDER BY আর prefix-এ অকেজো।
  • বেশি index-এর ক্ষতি? — প্রতিটি INSERT, DELETE আর indexed কলামের UPDATE প্রতিটি index-এ লেখে; জায়গা আর মেমরিও খায়।

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

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

প্র ০১মেমরিতে red-black tree বা AVL tree চমৎকার কাজ করে। তাহলে ডেটাবেস ডিস্কের index-এ binary search tree ব্যবহার করে না কেন — আর SSD আসার পরও B+ tree টিকে আছে কেন?

খরচের একক আলাদা বলে। মেমরিতে একটি node ছোঁয়ার খরচ কয়েক ন্যানোসেকেন্ড, তাই উচ্চতা ২৪ হলেও সমস্যা নেই। ডিস্কে খরচ মাপা হয় page-এ: HDD-তে একটি random page পড়তে কয়েক মিলিসেকেন্ড, কারণ head সরাতে আর ঘূর্ণনের অপেক্ষা করতে হয়। binary tree-এর প্রতিটি node আলাদা জায়গায় থাকলে ১ কোটি key-এ প্রায় ২৪টি random পড়া — প্রতি খোঁজে একশো মিলিসেকেন্ডেরও বেশি। আর একটি ৮ KB page পড়ে তার মাত্র ৩০–৪০ বাইট (একটি key আর দুটি pointer) কাজে লাগানো অপচয়।

B+ tree একটি page-এর পুরোটা কাজে লাগায়: এক পড়াতেই কয়েকশো key, তাই প্রতিটি স্তর কয়েকশো ভাগে ভাগ করে। উচ্চতা ২৪ থেকে নেমে ৩–৪, আর ওপরের স্তরগুলো মেমরিতে। Bayer আর McCreight ঠিক এই প্রশ্নটিই সমাধান করেছিলেন: “বড়, সাজানো index, যা মূল মেমরিতে আঁটে না।”

SSD-তে random পড়া অনেক দ্রুত, তবু সমস্যা মেটেনি। SSD-ও page বা block একক হিসেবে পড়ে, আর random পড়া sequential-এর চেয়ে ধীর থেকে যায়। এমনকি পুরোটা মেমরিতে থাকলেও CPU cache একটি cache line (সাধারণত ৬৪ বাইট) একসাথে আনে, আর প্রতিটি pointer অনুসরণে cache miss হতে পারে — তাই in-memory ডেটাবেসেও B+ tree-ধাঁচের চওড়া node প্রায়ই binary tree-এর চেয়ে দ্রুত। মূল নীতি একই: ধীর স্তর থেকে একবারে যত বড় টুকরো আসে, node তত বড় করুন।

তবে B+ tree সব কাজের রাজা নয়। প্রতিটি insert-এ একটি leaf page-এর জায়গামতো লেখা লাগে, যা লেখা-প্রধান কাজে ব্যয়বহুল। তাই RocksDB বা Cassandra-র মতো সিস্টেম LSM-tree বেছে নেয়, আর বিশ্লেষণে column store-এর নিজস্ব কাঠামো। তবু relational OLTP ডেটাবেসের ডিফল্ট index আজও B+ tree.

মূল উপলব্ধি: ডেটা কাঠামোর “ভালো” নির্ভর করে খরচের মডেলের ওপর। ডিস্কে মাপকাঠি তুলনা নয়, page পড়া — আর সেই মাপে উঁচু fanout-ই জেতে।

প্র ০২একটি দল “সব কোয়েরি দ্রুত হবে” ভেবে একটি টেবিলের প্রায় প্রতিটি কলামে index বানাল। কয়েক মাস পর অর্ডার নেওয়া ধীর হয়ে গেল। কেন, আর PostgreSQL আর MySQL-এর নকশার পার্থক্য এখানে কী ভূমিকা রাখে?

প্রতিটি index একটি সাজানো কপি, আর DBMS প্রতিটি লেখায় সবগুলো কপি মিলিয়ে রাখে। দশটি index থাকলে একটি INSERT মানে টেবিলে একটি লেখা আর দশটি গাছে দশবার নেমে দশটি leaf-এ লেখা, মাঝে মাঝে split, আর প্রতিটির WAL record. এর ওপর index-গুলো buffer pool-এ জায়গার জন্য টেবিলের সাথে প্রতিযোগিতা করে; যে গরম page আগে মেমরিতে থাকত, এখন ডিস্ক থেকে আনতে হয়। লেখার গতি তাই index-এর সংখ্যার সাথে প্রায় রৈখিকভাবে কমে, অথচ অনেকগুলো index কোনো কোয়েরিই ব্যবহার করে না।

UPDATE-এ পার্থক্য আরও স্পষ্ট। PostgreSQL-এ একটি UPDATE সারির নতুন একটি সংস্করণ লেখে (MVCC, পর্ব ৪৭), যা প্রায়ই অন্য জায়গায় যায় — ফলে সাধারণভাবে প্রতিটি index-এ নতুন ঠিকানার entry লাগে, এমনকি বদলানো কলামটি indexed না হলেও। এর প্রতিকার HOT (heap-only tuple) update: কোনো indexed কলাম না বদলালে আর একই page-এ জায়গা থাকলে index ছোঁয়া হয় না। MySQL InnoDB-তে secondary index-এর leaf-এ physical ঠিকানা নয়, primary key থাকে; তাই সারি সরলেও যেসব index-এর কলাম বদলায়নি সেগুলো অপরিবর্তিত থাকে — দামে, প্রতিটি secondary খোঁজে দুটি গাছে নামতে হয়।

২০১৬ সালে Uber-এর প্রকৌশল ব্লগে প্রকাশিত একটি লেখায় PostgreSQL থেকে MySQL-এ সরার কারণগুলোর মধ্যে এই write amplification-কে বড় করে দেখানো হয়েছিল; PostgreSQL সম্প্রদায় পরে কিছু দাবির ব্যাখ্যা ও পাল্টা যুক্তি দেয়। শিক্ষাটি নির্দিষ্ট পণ্যের নয়: index-এর দাম নকশাভেদে ভিন্ন, আর বড় মাপে সেটি স্থাপত্যের সিদ্ধান্তে পরিণত হয়।

করণীয়: প্রতিটি index-এর পক্ষে একটি আসল, মাপা কোয়েরি থাকুক। ব্যবহারের পরিসংখ্যান (PostgreSQL-এ pg_stat_user_indexes) নিয়মিত দেখে অব্যবহৃত index মুছুন; একই শুরুর কলামের একাধিক index-কে একটি composite index-এ আনুন (পর্ব ৪১); আর কম আলাদা মানের কলামে partial index ভাবুন।

মূল উপলব্ধি: index পড়াকে সস্তা করে লেখাকে দামি করে। “আরও index” নয়, “ঠিক index” — আর প্রতিটির দাম প্রতিটি লেখায় দিতে হয়।

প্র ০৩Hash table-এ খোঁজ O(1), B+ tree-এ O(log N). তবু প্রায় কোনো relational DBMS hash index-কে ডিফল্ট করে না। কেন?

প্রথমত, তাত্ত্বিক পার্থক্যটি ডিস্কে প্রায় মিলিয়ে যায়। B+ tree-এর log-এর ভিত্তি কয়েকশো, তাই ১ কোটি থেকে ১০০ কোটি key-তেও উচ্চতা ৩–৫, আর ওপরের স্তরগুলো মেমরিতে থাকায় আসল ডিস্ক-পড়া ১–২টি। hash index-এরও আদর্শ খরচ ১টি bucket page, আর overflow থাকলে বেশি। তাই সমতার খোঁজে লাভ সামান্য।

দ্বিতীয়ত, ক্ষতি বড়। hash key-এর ক্রম নষ্ট করে, তাই BETWEEN, <, ORDER BY, MIN/MAX, prefix খোঁজ (LIKE 'abc%') — কোনোটিতে কাজে লাগে না। একটি সাধারণ অ্যাপের কোয়েরির বড় অংশে এগুলো থাকে। composite key-এর শুরুর অংশ দিয়ে খোঁজাও hash-এ অসম্ভব, B+ tree-এ স্বাভাবিক। একটি B+ tree প্রায় সব কোয়েরি সামলায়; hash শুধু একটি।

তৃতীয়ত, নির্ভরযোগ্যতা আর রক্ষণাবেক্ষণ। খারাপ বণ্টনের ডেটায় একটি bucket অস্বাভাবিক বড় হতে পারে, আর বৃদ্ধির সময় split বা directory doubling-এর সাথে concurrency সামলানো জটিল। ইতিহাসেও এর ছাপ আছে: PostgreSQL-এ hash index বহুদিন WAL-এ লেখা হতো না, তাই crash বা replication-এ নির্ভরযোগ্য ছিল না; version 10 (২০১৭)-এ এটি শোধরানো হয়, তার আগে ডকুমেন্টেশন এটি ব্যবহারে নিরুৎসাহিত করত।

তবে hash-এর জায়গা আছে — মেমরিতে, সাময়িক কাজে। hash join (পর্ব ৪২) একটি টেবিল থেকে মেমরিতে hash table বানিয়ে অন্যটি দিয়ে খোঁজে; InnoDB-র adaptive hash index ঘন ঘন পড়া B+ tree page-এর ওপর মেমরিতে hash বসায়; Redis-এর মতো key-value store-এর মূল কাঠামোই hash (পর্ব ৫৮)। সেখানে প্রশ্ন সবসময় “এই key-এর মান কী”, range নয়।

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

প্র ০৪MySQL InnoDB-তে একটি দল primary key হিসেবে এলোমেলো UUID (version 4) ব্যবহার করল। টেবিল বড় হতেই INSERT ধীর হলো আর টেবিলের আকার প্রত্যাশার চেয়ে বড় হলো। ক্রমিক id-তে এমন হতো না কেন?

InnoDB-তে টেবিল নিজেই primary key-এর ওপর একটি clustered B+ tree — leaf-এ পুরো সারি, primary key-এর ক্রমে। ক্রমিক id (AUTO_INCREMENT) হলে প্রতিটি নতুন সারির key আগের সবগুলোর চেয়ে বড়, তাই সবসময় সবচেয়ে ডানের leaf-এ যায়। সেই leaf মেমরিতে থাকে, ভরে গেলে নতুন leaf শুরু হয়, আর পুরোনো leaf-গুলো প্রায় পূর্ণ থেকে যায়। এটি প্রায় bulk loading-এর মতো আচরণ — ঘন, ক্রমিক, সস্তা।

এলোমেলো UUID-তে প্রতিটি নতুন key গাছের যেকোনো জায়গায় পড়ে। টেবিল buffer pool-এর চেয়ে বড় হলে সেই leaf প্রায়ই মেমরিতে নেই — প্রতিটি INSERT-এ একটি random ডিস্ক-পড়া। leaf পূর্ণ হলে মাঝখানে split হয়, দুটি leaf-ই অর্ধেক ভরা থাকে, আর পরের এলোমেলো insert-ও সেগুলো পুরো ভরে না। ফলে গড় ভরাট অনেক কম, টেবিল ফাঁপা আর বড়, আর লেখা ছড়ানো — write amplification বাড়ে। উপরন্তু InnoDB-তে প্রতিটি secondary index-এর leaf-এ primary key কপি থাকে; ১৬ বাইটের UUID (বা ৩৬ অক্ষরের লেখা হিসেবে রাখলে আরও বেশি) প্রতিটি index-কে বড় করে।

তাহলে UUID কি নিষিদ্ধ? না — বিতরণ করা সিস্টেমে কেন্দ্রীয় সমন্বয় ছাড়াই id বানানোর সুবিধা বাস্তব। সমাধান হলো সময়-ক্রমিক id: UUID version 7, যার শুরুর bit-গুলো একটি timestamp, ২০২৪ সালে RFC 9562-এ মানসম্মত হয়েছে। নতুন id মোটামুটি বাড়তে থাকে, তাই আবার ডান প্রান্তে insert. আরেকটি উপায়: ভেতরে ক্রমিক integer primary key, বাইরে দেখানোর জন্য UUID একটি আলাদা UNIQUE কলামে।

PostgreSQL-এ টেবিল heap, তাই সারির জায়গা এতে নির্ভর করে না; কিন্তু primary key-এর B+ tree index-এ একই এলোমেলো insert আর অর্ধেক-ভরা leaf-এর সমস্যা থাকে।

মূল উপলব্ধি: clustered index-এর key শুধু পরিচয় নয়, ডিস্কে সারির ঠিকানাও ঠিক করে। ক্রমিক key ডান প্রান্তে জমে, এলোমেলো key পুরো গাছে split ছড়ায়।

প্র ০৫একটি অর্ডার টেবিলে status কলামে সাধারণ B+ tree index আছে, তবু WHERE status = 'delivered'-এ optimizer সেটি উপেক্ষা করে। আবার data warehouse-এ একই রকম কলামে bitmap index খুব কার্যকর। এই বৈপরীত্য কীভাবে ব্যাখ্যা করবেন?

Optimizer index উপেক্ষা করছে, কারণ হিসাব তাকে তা-ই বলে। delivered যদি ৯০% সারি হয়, ১ কোটির টেবিলে ৯০ লাখ সারি। non-clustered index দিয়ে প্রতিটির জন্য একটি data page — প্রায় ৯০ লাখ random পড়া, যেখানে full scan মাত্র ১ লাখ sequential page. index এখানে ক্ষতিকর। নিয়মটি সাধারণ: মিলের ভগ্নাংশ (selectivity) কয়েক শতাংশ ছাড়ালেই non-clustered index প্রায়ই হারে, আর ঠিক কোথায় হারে, তা নির্ভর করে ডিস্ক, clustering আর পরিসংখ্যানের ওপর (পর্ব ৪৩)।

তবে একই কলামের বিরল মান — pending ২% — এ index খুব কাজের। সমস্যা হলো সাধারণ index ৯৮% অপ্রয়োজনীয় entry-ও বহন করে আর প্রতিটি লেখায় হালনাগাদ হয়। partial index ঠিক এই ফাঁক পূরণ করে: শুধু pending সারি, ছোট, মেমরিতে, আর delivered সারির লেখায় অস্পর্শিত।

Warehouse-এর প্রশ্ন আলাদা ধরনের: “ঢাকা বিভাগ, ২০২৫ সাল, ইলেকট্রনিক্স, delivered, অনলাইন পেমেন্ট” — পাঁচটি শর্ত, প্রতিটি আলাদাভাবে দুর্বল (৩০–৯০%), কিন্তু একসাথে হয়তো ০.৫%. bitmap index প্রতিটি শর্তের জন্য একটি bit-সারি রাখে; পাঁচটির AND মেমরিতে, ৬৪ bit একসাথে, খুব দ্রুত — তারপর শুধু মিলের সারিগুলো পড়া হয়। কম আলাদা মান মানে bitmap কম আর compression ভালো, তাই এখানে low-cardinality দুর্বলতা নয়, শক্তি। Model 204 নামের একটি বাণিজ্যিক DBMS-এ এই ধারণা বহু আগে থেকেই ছিল — Patrick O'Neil ১৯৮৭ সালে এর বর্ণনা প্রকাশ করেন — পরে Oracle-সহ বহু warehouse ডেটাবেসে।

OLTP-তে bitmap কেন নয়? প্রতি সেকেন্ডে হাজারো ছোট লেখা, আর একটি bitmap অংশ বহু সারি ঢাকে — একটি সারির status বদলাতে সেই অংশ lock হলে অন্য বহু সারির লেখা আটকে যায়। warehouse-এ লেখা আসে রাতে, ETL-এ, একসাথে — সেখানে এই সমস্যা প্রায় নেই।

মূল উপলব্ধি: index-এর মূল্য কলামের বণ্টন আর কাজের ধরনের ওপর নির্ভর করে। এক শর্তে দুর্বল কলাম, বহু শর্তের সংযোগে শক্তিশালী হতে পারে।

অনুশীলন

একটি কোয়েরি লেখা, একটি হাতে গাছ আঁকা, একটি খরচের হিসাব, একটি index বাছাই।

  1. কোয়েরি লিখুন: orders(order_id, customer_id, order_date, amount, status) টেবিলে নিয়ম চাই — একজন গ্রাহকের একসাথে সর্বোচ্চ একটি pending অর্ডার থাকতে পারবে, কিন্তু delivered বা cancelled অর্ডার যত খুশি। একটি স্টেটমেন্টে নিয়মটি ডেটাবেসে বসান। অংশ ১-এর ১০,০০০ সারির টেবিলে চালালে কী হবে, আর কেন? (অংশ ৯-এর partial index এডিটরে লিখে চালাতে পারেন।)
    CREATE UNIQUE INDEX ux_one_pending
    ON orders(customer_id) WHERE status = 'pending';

    একটি partial UNIQUE index: uniqueness শুধু pending সারিগুলোর মধ্যে, বাকিগুলো index-এই নেই। কিন্তু নমুনা টেবিলে চালালে ত্রুটি আসে: UNIQUE constraint failed: orders.customer_id. কারণ ২০০টি pending অর্ডার আসলে ২০ জন গ্রাহকের — প্রত্যেকের ১০টি করে। বিদ্যমান ডেটা নিয়ম ভাঙলে UNIQUE index বানানোই যায় না। আগে SELECT customer_id, COUNT(*) FROM orders WHERE status = 'pending' GROUP BY customer_id HAVING COUNT(*) > 1 দিয়ে ডুপ্লিকেট খুঁজে শোধরাতে হবে। PostgreSQL-এও একই সিনট্যাক্স চলে; MySQL-এ partial index নেই।

  2. হাতে trace করুন: একটি খালি B+ tree, প্রতিটি node-এ সর্বোচ্চ ৩টি key; leaf ভাগ হলে বাঁয়ে ২টি, ডানে বাকিগুলো, আর ডান ভাগের প্রথম key-এর কপি ওপরে যায়। পরপর insert: 10, 20, 30, 40, 50, 60. প্রতিটি split-এর পর গাছটি আঁকুন। শেষে উচ্চতা কত, leaf কয়টি?

    10, 20, 30 — একটি leaf (যা root-ও): [10 20 30]. 40: [10 20 30 40] overflow → [10 20] আর [30 40], 30-এর কপি নতুন root-এ: root [30]. 50: ডান leaf [30 40 50]. 60: [30 40 50 60] overflow → [30 40] আর [50 60], 50 ওপরে: root [30 │ 50].

    root   [30 │ 50]
    leaf   [10 20] → [30 40] → [50 60]

    উচ্চতা ২, leaf ৩টি, প্রতিটি অন্তত অর্ধেক ভরা (২টি key). লক্ষ করুন, বাড়তে থাকা key-তে প্রতিটি split সবচেয়ে ডানে হয় আর বাঁয়ের leaf-গুলো আর কখনো ভরে না — তাই অনেক DBMS ডান প্রান্তের split-এ বাঁ দিকটি প্রায় পুরো ভরা রাখে (প্র ০৪)।

  3. হিসাব করুন: একটি NID-ধাঁচের টেবিলে ধরুন ৫ কোটি সারি, প্রতি page-এ ৫০টি সারি; B+ tree index-এর fanout ৩০০, আর প্রতিটি leaf-এ ৩০০টি entry. (ক) full scan-এ কত page? (খ) leaf কয়টি, উচ্চতা কত? (গ) একটি নম্বর দিয়ে খুঁজতে কত page? (ঘ) non-clustered index-এ ০.১% সারির একটি range-এ সর্বোচ্চ কত page?
    • (ক) ৫ কোটি ÷ ৫০ = ১০ লাখ page.
    • (খ) leaf = ৫ কোটি ÷ ৩০০ ≈ ১,৬৬,৬৬৭। তার ওপরে ÷ ৩০০ ≈ ৫৫৬, তার ওপরে ÷ ৩০০ ≈ ২, তারপর ১টি root. স্তর: root, ২, ৫৫৬, leaf — উচ্চতা ৪. যাচাই: log300(5 × 107) = 7.70 ÷ 2.48 ≈ 3.1, তাই ⌈3.1⌉ = 4.
    • (গ) ৪টি index page + ১টি data page = ৫টি page; ওপরের দুই স্তর মেমরিতে থাকলে ডিস্কে ৩টি।
    • (ঘ) ০.১% = ৫০,০০০ সারি। নামা ৪ + leaf ৫০,০০০ ÷ ৩০০ ≈ ১৬৭ + data page সর্বোচ্চ ৫০,০০০ (প্রতিটি সারি আলাদা page-এ) ≈ ৫০,১৭১ page — full scan-এর ২০ ভাগের ১ ভাগের মতো, কিন্তু random. clustered হলে data page মাত্র ৫০,০০০ ÷ ৫০ = ১,০০০।
  4. Index বাছাই করুন: প্রতিটির জন্য সবচেয়ে উপযুক্ত index বলুন — B+ tree, hash, bitmap, partial, GIN/inverted, R-tree/GiST, BRIN, নাকি কোনোটিই নয়: (ক) WHERE phone = '01711000001', OLTP; (খ) WHERE order_date BETWEEN … ORDER BY order_date; (গ) warehouse-এ লিঙ্গ, বিভাগ আর বছরের বহু-শর্তের COUNT; (ঘ) ১০০ কোটি সারির সময়-ক্রমিক sensor log-এ সময়ের range; (ঙ) “আমার ২ কিলোমিটারের মধ্যে ATM”; (চ) পণ্যের বর্ণনায় শব্দ খোঁজা; (ছ) ৫০ সারির একটি settings টেবিল।
    • (ক) B+ tree (UNIQUE) — সমতা; hash-ও চলত, কিন্তু তাতে লাভ সামান্য, আর B+ tree অনেক বেশি কাজে লাগে।
    • (খ) B+ tree — range আর sort একসাথে; hash অকেজো।
    • (গ) Bitmap — কম আলাদা মান, বহু শর্তের AND, লেখা রাতে।
    • (ঘ) BRIN — ডেটা সময়ের ক্রমে জমা, তাই প্রতিটি page-গুচ্ছের min/max-ই যথেষ্ট; index মাত্র কয়েক page.
    • (ঙ) R-tree বা GiST (PostGIS) — দ্বিমাত্রিক এলাকার খোঁজ।
    • (চ) GIN বা inverted index — full-text (পর্ব ৩০); LIKE '%শব্দ%' B+ tree ব্যবহার করতে পারে না।
    • (ছ) কোনোটিই নয় (primary key ছাড়া) — পুরো টেবিল এক page-এ, scan-ই সবচেয়ে সস্তা।

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

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

ক্রমপূর্ণ leaf-এ একটি key insert করলে B+ tree-এ যা ঘটে, সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. root থেকে key-এর তুলনা করে সঠিক leaf পর্যন্ত নামা
  2. leaf-এ সাজানো জায়গায় key বসানো — সীমা ছাড়িয়ে overflow
  3. leaf-কে দুই ভাগ করে leaf link হালনাগাদ করা
  4. ডান ভাগের প্রথম key-এর কপি parent-এ পাঠানো
  5. parent-ও উপচে পড়লে তাকে ভাগ করে মাঝের key ওপরে সরানো
  6. root ভাগ হলে নতুন root বানানো — উচ্চতা এক বাড়ে

১ কোটি সারির একটি টেবিল, প্রতি page-এ ১০০টি সারি। search key-এর ওপর একটি B+ tree index-এর fanout প্রায় ৩০০। একটি নির্দিষ্ট key-এর সারি পেতে মোটামুটি কয়টি page পড়তে হবে?

  • প্রায় ১,০০,০০০
  • প্রায় ২৪
  • প্রায় ৪
  • ঠিক ১

B-tree-এর তুলনায় B+ tree-এর কোন বৈশিষ্ট্যটি range কোয়েরি (BETWEEN) সবচেয়ে বেশি সহজ করে?

  • সব leaf একই গভীরতায় থাকে
  • সব data entry leaf-এ, আর leaf-গুলো পাশের leaf-এর সাথে যুক্ত
  • ভেতরের node-এ পুরো সারি থাকে
  • key-গুলো hash করে রাখা হয়

Sparse index সম্পর্কে কোন কথাটি ঠিক?

  • এতে প্রতিটি সারির জন্য একটি entry থাকে
  • যেকোনো secondary index sparse হতে পারে
  • এটি dense index-এর চেয়ে বেশি জায়গা নেয়
  • ফাইল search key-এর ক্রমে সাজানো থাকলেই এটি কাজ করে

Extendible hashing-এ একটি bucket পূর্ণ, আর তার local depth ২, global depth ৩। নতুন key ঢোকাতে কী ঘটবে?

  • শুধু bucket-টি ভাগ হবে, directory দ্বিগুণ হবে না
  • directory দ্বিগুণ হয়ে global depth ৪ হবে
  • bucket-এর সাথে একটি overflow page জোড়া হবে
  • পুরো index নতুন hash function দিয়ে আবার বানানো হবে

orders টেবিলের status কলামে মাত্র তিনটি মান: delivered (৯০%), cancelled (৮%), pending (২%). অ্যাপ বারবার শুধু pending অর্ডার খোঁজে। OLTP ডেটাবেসে সবচেয়ে ভালো পছন্দ কোনটি?

  • status-এ একটি সাধারণ B+ tree index
  • status-এ একটি bitmap index
  • WHERE status = 'pending' দিয়ে একটি partial index
  • কোনো index নয় — সবসময় full scan

একটি টেবিলে ৬টি secondary index আছে। এর প্রভাব সম্পর্কে কোন কথাটি ঠিক?

  • সব কোয়েরি দ্রুত হয়, লেখার ওপর কোনো প্রভাব নেই
  • প্রতিটি INSERT ছয়টি index-এও লেখে, তাই লেখা ধীর হয় আর জায়গা বাড়ে
  • প্রতিটি SELECT-এ ছয়টি index হালনাগাদ হয়
  • index-গুলো রাতে একবার হালনাগাদ হয়, দিনের লেখায় খরচ নেই

শব্দকোষ

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

মনে রাখুন

  1. Index = search key → record id-এর সাজানো কপি। ফল বদলায় না, পথ বদলায়। full scan-এ P টি page, B+ tree-এ ⌈logF N⌉ + ১ — ১ কোটি সারিতে ১ লাখের জায়গায় ৪–৫।
  2. চার জোড়া: primary/secondary, clustered/non-clustered (টেবিলে একটিই clustered), dense/sparse (sparse শুধু সাজানো ফাইলে), single/multi-level. non-clustered index বড় range-এ random I/O-তে হারে।
  3. B+ tree: node = page, সব data leaf-এ, leaf যুক্ত, সব leaf একই গভীরতায়; উচ্চতা log-এ সীমিত। insert-এ split (leaf-এ কপি ওপরে, internal-এ সরে), delete-এ redistribute বা merge, নতুন index-এ bulk load.
  4. Hash শুধু সমতায়: static hashing-এ overflow chain, extendible-এ directory doubling আর local split, linear-এ পালাক্রমে split. Bitmap কম আলাদা মানে, warehouse-এ AND/OR.
  5. দাম আর SQL: প্রতিটি index প্রতিটি লেখায় হালনাগাদ — write amplification. PRIMARY KEY/UNIQUE-এ index স্বয়ংক্রিয়, foreign key-এ (SQLite, PostgreSQL) নয়; EXPLAIN দিয়ে যাচাই করুন, partial index দিয়ে ছোট রাখুন।

পরের পর্বে: কোন কলামে index, কয়টি কলাম একসাথে আর কোন ক্রমে? পর্ব ৪১-এ composite আর covering index, আর EXPLAIN পড়ার পূর্ণ কৌশল।

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

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