এই পর্বে যা শিখবেন
- SQLite, PostgreSQL আর MySQL-এর EXPLAIN-এর ফল লাইন ধরে পড়া
- Composite index-এর leftmost prefix নিয়ম আর কলাম সাজানোর নিয়ম
- Covering index, আর ORDER BY ও GROUP BY-এর জন্য index
- Selectivity, sargable শর্ত, expression index আর partial index
- Foreign key-এর index, বেশি index-এর খরচ, statistics আর tuning-এর কর্মপদ্ধতি
১ · প্রথম অভ্যাস — কোয়েরির plan পড়া
পর্ব ৪০-এ দেখেছেন index-এর ভেতরের গঠন: B+ tree, hash আর bitmap. এই পর্বটি হাতে-কলমে। প্রশ্ন তিনটি: কোন কলামে index দেবেন, একাধিক কলাম হলে কোন ক্রমে, আর DBMS সত্যিই সেটি ব্যবহার করছে কি না। তৃতীয় প্রশ্নের উত্তর অনুমানে মেলে না। DBMS-এর query optimizer নিজে ঠিক করে কোয়েরিটি কোন পথে চলবে, আর সেই পথ — query plan — দেখার কমান্ড হলো EXPLAIN.
লাইব্রেরির ক্যাটালগ ভাবুন। কার্ডগুলো লেখকের নাম অনুযায়ী সাজানো, প্রতিটি কার্ডে লেখা বইটি কোন তাকে। “হুমায়ূন আহমেদের বই” চাইলে লাইব্রেরিয়ান ক্যাটালগে সরাসরি ‘হ’-এর ঘরে যান — এটি SEARCH. কিন্তু “যে বইয়ের প্রচ্ছদ নীল” চাইলে ক্যাটালগ কোনো কাজে আসে না; প্রতিটি তাক ঘুরে দেখতে হয় — এটি SCAN. EXPLAIN হলো লাইব্রেরিয়ানকে জিজ্ঞেস করা, “আপনি কোন পথে খুঁজবেন?” — খোঁজা শুরুর আগেই।
ছোট টেবিলে সব কোয়েরিই দ্রুত, তাই এই পর্বে আমরা একটি বড় টেবিল বানাব। প্রতিটি এডিটরে আগে থেকে তৈরি থাকে customers (customer_id, name, phone — ২,০০০ জন গ্রাহক; phone UNIQUE, তাই এতে স্বয়ংক্রিয় একটি index আছে)। প্রথম এডিটরে আপনি নিজে orders বানাবেন: order_id, customer_id, status, order_date আর amount (টাকা)। পর্ব ২৫-এর recursive CTE ১ থেকে ২০,০০০ পর্যন্ত সংখ্যা i বানায়, আর ভাগশেষের হিসাব (modulo) থেকে এলোমেলো-দেখতে মান আসে:
i * 7919 % 2000 + 1— customer_id. 7919 আর 2000-এর কোনো সাধারণ উৎপাদক নেই, তাই প্রতিটি গ্রাহক ঠিক ১০টি করে অর্ডার পান, কিন্তু ছড়ানোভাবে।i % 9— status: ৯ ভাগের ১ ভাগ cancelled, ২ ভাগ pending, ৩ ভাগ shipped, ৩ ভাগ delivered.i * 37 % 731— ২০২৪-০১-০১ থেকে কত দিন পরে; ফলে তারিখ ২০২৪ আর ২০২৫ জুড়ে, ৭৩১টি দিনে ছড়ানো।
CREATE TABLE orders (order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
status TEXT NOT NULL, order_date TEXT NOT NULL, amount INTEGER NOT NULL);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000)
INSERT INTO orders
SELECT i, i * 7919 % 2000 + 1,
CASE WHEN i % 9 = 0 THEN 'cancelled' WHEN i % 9 < 3 THEN 'pending'
WHEN i % 9 < 6 THEN 'shipped' ELSE 'delivered' END,
date('2024-01-01', '+' || (i * 37 % 731) || ' days'),
100 + i * 131 % 4900
FROM n;
SELECT status, COUNT(*) AS n FROM orders GROUP BY status;
SELECT * FROM orders WHERE order_id <= 3;
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 42;“✓ CREATE TABLE”, তারপর “✓ WITH RECURSIVE” (স্টেটমেন্টটি WITH দিয়ে শুরু, তাই runner সারির সংখ্যা না দেখিয়ে শুধু ✓ দেখায়; ঢুকেছে ২০,০০০টি সারি)। status-এর হিসাব: cancelled 2222, delivered 6666, pending 4446, shipped 6666. প্রথম তিনটি সারি 1 | 1920 | pending | 2024-02-07 | 231, 2 | 1839 | pending | 2024-03-15 | 362 আর 3 | 1758 | shipped | 2024-04-21 | 493. শেষ লাইনের ফলে চারটি কলাম: id, parent, notused আর detail. প্রথম তিনটি plan-এর গাছের গঠন বোঝায় (কোন লাইন কার ভেতরে); আসল কথা detail-এ: SCAN orders. customer_id-এ কোনো index নেই, তাই ২০,০০০টি সারির প্রতিটি পড়ে শর্ত মেলাতে হবে — full table scan — অথচ মিলবে মাত্র ১০টি।
SQLite-এর EXPLAIN QUERY PLAN-এর detail-এ যে শব্দগুলো বারবার দেখবেন:
| detail-এ লেখা | মানে |
|---|---|
SCAN orders | পুরো টেবিল শুরু থেকে শেষ পর্যন্ত পড়া |
SEARCH … USING INDEX i (col=?) | index-এ সরাসরি খুঁজে, তারপর প্রতিটি মেলা সারির জন্য টেবিলে যাওয়া; বন্ধনীতে লেখা কোন শর্ত দিয়ে খোঁজা হলো |
USING COVERING INDEX | দরকারি সব কলাম index-এই আছে, টেবিলে যেতেই হয় না |
USING INTEGER PRIMARY KEY (rowid=?) | টেবিলের নিজের key দিয়ে সরাসরি খোঁজা |
SCAN … USING COVERING INDEX | পুরো index পড়া (টেবিলের চেয়ে ছোট), খোঁজা নয় |
USE TEMP B-TREE FOR ORDER BY | ফল সাজাতে একটি অস্থায়ী গাছ বানাতে হচ্ছে — আলাদা sort |
USE TEMP B-TREE FOR GROUP BY | দল বানাতে আলাদা sort |
MULTI-INDEX OR | OR-এর প্রতিটি অংশ আলাদা index দিয়ে খুঁজে ফল জোড়া দেওয়া |
এবার customer_id-এ একটি index বানিয়ে একই প্রশ্ন আবার করুন। এখান থেকে প্রতিটি এডিটরে orders টেবিলও আগে থেকে তৈরি থাকে (ঠিক ওপরের কোড দিয়ে):
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE order_id = 42;
CREATE INDEX idx_orders_customer ON orders(customer_id);
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM orders WHERE customer_id = 42;
SELECT order_id, status, order_date, amount
FROM orders WHERE customer_id = 42;তিনটি plan: SEARCH orders USING INTEGER PRIMARY KEY (rowid=?), তারপর SEARCH orders USING INDEX idx_orders_customer (customer_id=?), আর COUNT-এর জন্য SEARCH orders USING COVERING INDEX idx_orders_customer (customer_id=?). শেষে ১০টি সারি — প্রথমটি 839 | pending | 2024-12-07 | 2209, শেষটি 18839 | pending | 2025-02-04 | 3309. order_id আসছে ২০০০-এর ব্যবধানে (839, 2839, 4839 …), কারণ i ≡ 839 (mod 2000) হলেই গ্রাহক ৪২. COUNT-এর জন্য টেবিলে যেতেই হয়নি — শুধু গুনতে হবে, আর গোনার জন্য index-এর entry-ই যথেষ্ট। এই “covering” ধারণাটি অংশ ৫-এ বিস্তারিত।
খরচের হিসাবটি নির্দিষ্ট করে বলা যায়। SCAN-এ n সারির প্রতিটি পড়তে হয়: খরচ n-এর সমানুপাতিক, এখানে ২০,০০০টি সারি। SEARCH-এ B+ tree-এর মূল থেকে পাতা পর্যন্ত h-টি page (h = গাছের উচ্চতা, প্রায় logf n, যেখানে f = এক page-এ কতগুলো key ধরে), তারপর মেলা k-টি entry, আর প্রতিটির জন্য টেবিলে একটি খোঁজ। একটি page-এ কয়েকশো key ধরলে কোটি সারির টেবিলেও h সাধারণত ৩–৪. তাই SEARCH-এর খরচ প্রায় h + k·(টেবিলে একটি খোঁজ), আর SCAN-এর n — k ছোট হলে পার্থক্য হাজারো গুণ।
২ · PostgreSQL আর MySQL-এর EXPLAIN
প্রতিটি DBMS-এর EXPLAIN আছে, ভাষা আলাদা। PostgreSQL-এ দুটি রূপ: EXPLAIN শুধু পরিকল্পনা দেখায় আর অনুমান করা খরচ; EXPLAIN ANALYZE কোয়েরিটি সত্যিই চালায়, তারপর অনুমানের পাশে আসল সময় আর আসল সারির সংখ্যা বসায়। সত্যিই চালায় বলে UPDATE বা DELETE-এ সাবধান — BEGIN; EXPLAIN ANALYZE …; ROLLBACK; লিখুন। নিচের ফলগুলো এই পর্বের মতো ২০,০০০ সারির একটি orders টেবিলে PostgreSQL 16-এর আদলে লেখা উদাহরণ; চলে না, আর আপনার মেশিনে সংখ্যা আলাদা হবে। ট্যাব বদলে চারটি প্রধান পথ দেখুন:
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Seq Scan on orders (cost=0.00..379.00 rows=10 width=27)
(actual time=0.031..1.874 rows=10 loops=1)
Filter: (customer_id = 42)
Rows Removed by Filter: 19990
Planning Time: 0.092 ms
Execution Time: 1.893 ms
Seq Scan = SQLite-এর SCAN. Rows Removed by Filter: 19990 সবচেয়ে বড় সংকেত: ১০টি সারির জন্য ১৯,৯৯০টি পড়ে ফেলে দেওয়া হয়েছে। cost-এর 379.00 এসেছে PostgreSQL-এর সূত্র থেকে: ১২৯টি page × 1.0 + ২০,০০০ সারি × 0.01 (প্রতি সারি দেখা) + ২০,০০০ × 0.0025 (প্রতি শর্ত মেলানো)।
Index Scan using idx_orders_customer on orders
(cost=0.29..39.44 rows=10 width=27)
(actual time=0.024..0.041 rows=10 loops=1)
Index Cond: (customer_id = 42)
Planning Time: 0.118 ms
Execution Time: 0.058 ms
Index Scan = SQLite-এর SEARCH … USING INDEX: index-এ খুঁজে প্রতিটি entry থেকে টেবিলে (PostgreSQL-এর ভাষায় heap) যাওয়া। Index Cond মানে শর্তটি index দিয়েই খোঁজা হয়েছে; শুধু Filter থাকলে সারি পড়ে তারপর ছাঁকা হয়েছে।
EXPLAIN ANALYZE SELECT order_date, amount FROM orders WHERE customer_id = 42;
Index Only Scan using idx_cust_date_amt on orders
(cost=0.29..4.46 rows=10 width=8)
(actual time=0.019..0.022 rows=10 loops=1)
Index Cond: (customer_id = 42)
Heap Fetches: 0
Index Only Scan = SQLite-এর COVERING INDEX. Heap Fetches: 0 মানে একবারও টেবিলে যেতে হয়নি। এই সংখ্যা বড় হলে বুঝবেন টেবিলের visibility map পুরোনো — PostgreSQL তখন সারিটি সবার কাছে দৃশ্যমান কি না যাচাই করতে টেবিলে যায়; VACUUM এটি হালনাগাদ করে (পর্ব ৪৭)।
EXPLAIN ANALYZE SELECT * FROM orders
WHERE order_date BETWEEN '2025-12-01' AND '2025-12-31';
Bitmap Heap Scan on orders (cost=19.01..206.53 rows=850 width=27)
(actual time=0.110..0.512 rows=850 loops=1)
Recheck Cond: ((order_date >= '2025-12-01'::date) AND (order_date <= '2025-12-31'::date))
Heap Blocks: exact=24
-> Bitmap Index Scan on idx_orders_date (cost=0.00..18.80 rows=850 width=0)
(actual time=0.081..0.081 rows=850 loops=1)
Index Cond: ((order_date >= '2025-12-01'::date) AND (order_date <= '2025-12-31'::date))
মাঝারি সংখ্যক সারির জন্য দুই ধাপ: ভেতরের Bitmap Index Scan index থেকে মেলা সারিগুলোর page-এর একটি তালিকা (bitmap) বানায়, বাইরের Bitmap Heap Scan সেই page-গুলো ডিস্কের ক্রমে একবার করে পড়ে। ফলে একই page বারবার পড়তে হয় না। plan গাছ পড়ুন সবচেয়ে ভেতরের (বেশি ডানে সরানো) লাইন থেকে বাইরের দিকে।
প্রতিটি লাইনের সংখ্যাগুলোর মানে:
- cost=শুরু..মোট — অনুমিত খরচ, একক হলো “একটি page ক্রমানুসারে পড়া = 1.0”. প্রথম সংখ্যা প্রথম সারি দেওয়ার আগের খরচ, দ্বিতীয়টি সব সারির। এটি মিলিসেকেন্ড নয়।
- rows, width — optimizer-এর অনুমান: কতগুলো সারি আসবে, প্রতিটি গড়ে কত বাইট।
- actual time=শুরু..শেষ — আসল মিলিসেকেন্ড, প্রতি loop-এ; loops — অংশটি কতবার চলেছে (nested loop JOIN-এর ভেতরে অনেকবার)। মোট সময় ≈ actual × loops.
- সবচেয়ে কাজের তুলনা: অনুমিত rows বনাম actual rows. ১০ অনুমান করে ১ লাখ পেলে plan-টি ভুল তথ্যে বানানো — সাধারণত পুরোনো statistics (অংশ ৯)।
MySQL-এর EXPLAIN ফল দেয় একটি টেবিল হিসেবে, প্রতিটি টেবিলের জন্য একটি সারি। চারটি কলাম সবচেয়ে জরুরি:
mysql> EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY amount; +-------+------+---------------------+------+-----------------------------+ | table | type | key | rows | Extra | +-------+------+---------------------+------+-----------------------------+ | orders| ref | idx_orders_customer | 10 | Using filesort | +-------+------+---------------------+------+-----------------------------+ (পরিষ্কার রাখতে id, select_type, possible_keys, key_len, ref, filtered কলাম বাদ)
| কলাম | কী দেখবেন |
|---|---|
| type | ভালো থেকে খারাপ মোটামুটি: const (একটি সারি, primary key) → eq_ref → ref (index-এ সমতা) → range → index (পুরো index পড়া) → ALL (পুরো টেবিল) |
| key | আসলে কোন index বেছে নেওয়া হলো; NULL মানে কোনোটি নয় |
| rows | কতগুলো সারি পরীক্ষা করতে হবে বলে অনুমান |
| Extra | Using index = covering; Using filesort = আলাদা sort (filesort); Using temporary = অস্থায়ী টেবিল; Using where = সারি পড়ে ছাঁকা |
MySQL 8.0-এর নতুন সংস্করণে EXPLAIN ANALYZE আর গাছ-আকারের EXPLAIN FORMAT=TREE-ও আছে, PostgreSQL-এর মতো। Oracle-এ EXPLAIN PLAN FOR …, SQL Server-এ graphical execution plan — নাম আলাদা, পড়ার প্রশ্ন একই: পুরো টেবিল না index, কতগুলো সারি, আলাদা sort আছে কি না।
৩ · Composite index আর leftmost prefix নিয়ম
একাধিক কলাম মিলে বানানো index-কে বলে composite index: CREATE INDEX … ON orders(customer_id, order_date). এটি দুটি আলাদা index নয় — একটিই সাজানো তালিকা, যেখানে entry-গুলো আগে প্রথম কলাম দিয়ে সাজানো, প্রথম কলাম সমান হলে দ্বিতীয় কলাম দিয়ে। ঠিক টেলিফোন ডিরেক্টরির মতো: আগে পদবি, একই পদবির ভেতরে নাম। এই সাজানোর ধরন থেকেই সবচেয়ে জরুরি নিয়মটি আসে — leftmost prefix. ধাপে ধাপে দেখুন:
| খোঁজার কলাম | |
| entry পড়া | |
| মিলেছে |
- সাজানোর নিয়ম। আগে last_name, একই পদবির ভেতরে first_name. তিনজন Hossain পাশাপাশি, আর তাঁদের মধ্যে নাম বর্ণানুক্রমে।
- দুই কলামেই সমতা। পদবি দিয়ে Hossain-এর ব্লকে, তারপর নাম দিয়ে Nadia-তে — একটি entry. index-এর পুরো শক্তি কাজে লাগল।
- শুধু প্রথম কলাম। সব Hossain একটি টানা ব্লকে, তাই শুরুটা খুঁজে তিনটি entry পড়লেই শেষ। প্রথম কলামটি নিজেই একটি “prefix”, তাই খোঁজা চলে।
- শুধু দ্বিতীয় কলাম। Imran নামের মানুষ ৪, ৭ আর ৯ নম্বরে — আলাদা পদবিতে ছড়ানো। নাম দিয়ে সাজানো কোনো অংশ নেই, তাই ১০টিই পড়তে হলো। ডিরেক্টরি এই প্রশ্নে সাহায্য করে না।
- প্রথম কলামে range. H থেকে J-এর পদবি একটি ব্লক (৫টি entry), কিন্তু এর ভেতরে নামগুলো আর সাজানো নয়। তাই first_name খোঁজায় কাজে লাগে না, শুধু পড়া entry ছাঁকে।
- নিয়মটি। composite index খোঁজায় কাজে লাগে বাঁ দিক থেকে টানা কলামগুলোর সমতায়; প্রথম range-এর কলামেই খোঁজা থামে, তার ডানের কলাম শুধু ছাঁকে। মাঝখানের কলাম বাদ পড়লে তার ডানের কলামও খোঁজায় লাগে না।
এবার orders-এ একই নিয়ম পরীক্ষা করুন — (customer_id, order_date) index, তিন রকম প্রশ্ন:
CREATE INDEX idx_cust_date ON orders(customer_id, order_date);
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE customer_id = 42 AND order_date >= '2025-01-01';
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE order_date = '2025-12-01';
SELECT order_id, order_date, amount FROM orders
WHERE customer_id = 42 AND order_date >= '2025-01-01';প্রথম plan: SEARCH orders USING INDEX idx_cust_date (customer_id=? AND order_date>?) — দুই কলামই খোঁজায়। দ্বিতীয়: (customer_id=?) — শুধু prefix, তবু SEARCH. তৃতীয়: SCAN orders — দ্বিতীয় কলাম একা, index অকেজো (ওই দিনে ২৮টি অর্ডার আছে, কিন্তু সেগুলো index-এ ২৮ জন গ্রাহকের ঘরে ছড়ানো)। শেষ কোয়েরিতে ৫টি সারি, order_date-এর ক্রমে: 18839 | 2025-02-04 | 3309 থেকে 4839 | 2025-11-10 | 1909. লক্ষ করুন, ORDER BY না লিখলেও সারিগুলো তারিখের ক্রমে এসেছে — কারণ index-এর ক্রমে পড়া হয়েছে। তবে ক্রম চাইলে সবসময় ORDER BY লিখুন; plan বদলালে এই “কাকতালীয়” ক্রম হারিয়ে যায়।
একটি ফল: (customer_id, order_date) থাকলে আলাদা (customer_id) index প্রায় সবসময় অপ্রয়োজনীয় — বড়টির prefix-ই সেই কাজ করে। অংশ ৮-এ এমন দ্বৈত index খুঁজে মোছার কথা আসবে।
৪ · কলামের ক্রম — আগে সমতা, পরে range
প্রশ্ন: “গ্রাহক ৪২-এর ২০২৫ সালের অর্ডার”। শর্ত দুটি — customer_id-এ সমতা (=), order_date-এ range (BETWEEN). index দুইভাবে বানানো যায়: (order_date, customer_id) অথবা (customer_id, order_date). দুটিতেই দুই কলাম আছে, তবু কাজের পরিমাণে আকাশ-পাতাল তফাত:
| index | |
| খোঁজার শর্ত | |
| entry পড়া |
- দুটি বিকল্প। একই দুই কলাম, শুধু ক্রম আলাদা। ডেটায় ২০২৫ সালের অর্ডার ৯,৯৮৫টি, তার মধ্যে গ্রাহক ৪২-এর মাত্র ৫টি।
- Index A-তে নামা। প্রথম কলাম order_date, তাই ২০২৫-এর শুরুতে সরাসরি যাওয়া যায়। কিন্তু একই তারিখের ভেতরে গ্রাহকেরা সাজানো থাকলেও পুরো বছরের range জুড়ে customer_id এলোমেলো।
- Index A-তে হাঁটা। range-এর প্রথম কলামেই খোঁজা থামে (অংশ ৩-এর নিয়ম), তাই বছরের সব ৯,৯৮৫টি entry পড়ে customer_id ছাঁকতে হয়। মিলল ৫টি।
- Index B-তে নামা। প্রথম কলাম সমতা, তাই (42, '2025-01-01')-এ সরাসরি। গ্রাহক ৪২-এর সব entry এক জায়গায়, তারিখের ক্রমে।
- Index B-তে হাঁটা। ৫টি entry পড়ে পরের গ্রাহকে পৌঁছালেই থামা। প্রতিটি পড়া entry-ই উত্তর।
- নিয়মটি। আগে সমতার কলাম, শেষে range-এর কলাম। সমতার কলাম কয়েকটি হলে তাদের নিজেদের ক্রম খোঁজার জন্য গুরুত্বপূর্ণ নয় — তখন অন্য কোয়েরিগুলো কোন prefix চায়, তা দেখে সাজান।
লাইভ যাচাই। SQLite-এর INDEXED BY দিয়ে নির্দিষ্ট index ব্যবহার করতে বাধ্য করা যায় — শুধু পরীক্ষার জন্য, বাস্তব কোডে নয়:
CREATE INDEX idx_cust_date ON orders(customer_id, order_date);
CREATE INDEX idx_date_cust ON orders(order_date, customer_id);
EXPLAIN QUERY PLAN SELECT * FROM orders INDEXED BY idx_date_cust
WHERE customer_id = 42 AND order_date BETWEEN '2025-01-01' AND '2025-12-31';
EXPLAIN QUERY PLAN SELECT * FROM orders INDEXED BY idx_cust_date
WHERE customer_id = 42 AND order_date BETWEEN '2025-01-01' AND '2025-12-31';
SELECT COUNT(*) AS in_2025 FROM orders
WHERE order_date BETWEEN '2025-01-01' AND '2025-12-31';
SELECT COUNT(*) AS cust42_2025 FROM orders
WHERE customer_id = 42 AND order_date BETWEEN '2025-01-01' AND '2025-12-31';প্রথম plan-এর বন্ধনীতে শুধু (order_date>? AND order_date<?) — customer_id খোঁজায় নেই। দ্বিতীয়টিতে (customer_id=? AND order_date>? AND order_date<?) — তিনটি শর্তই খোঁজায়। দুটি গোনা: in_2025 = 9985 আর cust42_2025 = 5 — stepper-এর দুই সংখ্যা। INDEXED BY মুছে চালালে SQLite নিজেই idx_cust_date বেছে নেয়।
প্রচলিত ভুল ধারণা: “সবচেয়ে বেশি বাছাই-করা কলাম আগে দিন।” সমতার শর্তে কলামের ক্রম খোঁজার খরচ প্রায় বদলায় না — (a, b) আর (b, a) দুটিতেই সমান entry পড়তে হয়। আসল প্রশ্ন দুটি: কোন কলামে range, আর অন্য কোন কোয়েরি কোন prefix ব্যবহার করবে। status (৪টি মান) আগে রাখলে “শুধু status” প্রশ্নও একই index-এ চলে; customer_id আগে রাখলে “শুধু customer_id” চলে।
৫ · Covering index — টেবিলে না গিয়েই উত্তর
সাধারণ index-এ entry থাকে (index-এর কলাম, সারির ঠিকানা)। SQLite-এ ঠিকানা হলো rowid; orders-এ order_id INTEGER PRIMARY KEY নিজেই rowid. কোয়েরি এমন কলাম চাইলে যা index-এ নেই, প্রতিটি মেলা entry-র জন্য টেবিলের B+ tree-তে আলাদা একটি খোঁজ লাগে — table lookup. আর কোয়েরির দরকারি সব কলাম index-এই থাকলে সেটি covering index: টেবিলে যেতেই হয় না। কতটা বাঁচে, একটি বড় টেবিল ধরে হিসাব করুন (ধরা যাক দুই গাছেরই উচ্চতা ৩, আর ক্যাশে কিছু নেই — সবচেয়ে খারাপ অবস্থা):
| index-এ | |
| টেবিলে | |
| মোট |
- index-এ খোঁজা। গাছের মূল থেকে পাতায় ৩টি page. গ্রাহক ৪২-এর ১০টি entry পাশাপাশি, সম্ভবত একই পাতায়। কিন্তু entry-তে amount নেই, আছে শুধু rowid.
- প্রথম table lookup. rowid ধরে টেবিলের নিজের B+ tree-তে আবার মূল থেকে পাতা — আরও ৩টি page. তবেই amount পাওয়া গেল।
- বাকি ন’টি। rowid-গুলো ২,০০০ ব্যবধানে, তাই প্রতিটি আলাদা page-এ। মোট ৩ + ১০ × ৩ = ৩৩টি page. ক্যাশে থাকলে কম, কিন্তু বড় টেবিলে এগুলোই random disk পড়া।
- Covering index. amount-ও index-এর entry-তে আছে। একই ৩টি page-এ উত্তর সম্পূর্ণ, টেবিলে একবারও যাওয়া নেই।
- তুলনা আর দাম। ৩৩ বনাম ৩ — k-টি সারিতে সাধারণভাবে h + k·h বনাম প্রায় h. দাম: index-এর আকার বাড়ে, আর amount বদলানো প্রতিটি UPDATE-এ index-ও বদলায়।
CREATE INDEX idx_cust_date ON orders(customer_id, order_date);
EXPLAIN QUERY PLAN SELECT order_date, amount FROM orders WHERE customer_id = 42;
DROP INDEX idx_cust_date;
CREATE INDEX idx_cust_date_amt ON orders(customer_id, order_date, amount);
EXPLAIN QUERY PLAN SELECT order_date, amount FROM orders WHERE customer_id = 42;
EXPLAIN QUERY PLAN SELECT order_id, amount FROM orders WHERE customer_id = 42;
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE customer_id = 42;প্রথম plan USING INDEX idx_cust_date — amount আনতে টেবিলে যেতে হয়। amount যোগ করার পর USING COVERING INDEX idx_cust_date_amt (customer_id=?). তৃতীয়টিও covering, অথচ order_id index-এ লেখা হয়নি — কারণ order_id-ই rowid, যা প্রতিটি entry-তে এমনিই থাকে। চতুর্থটি SELECT *: status চাই, তাই আবার USING INDEX. শিক্ষা: SELECT * covering index-এর শত্রু; যে কলাম লাগে, শুধু সেগুলো চান।
PostgreSQL (11 থেকে) আর SQL Server-এ আরও পরিষ্কার উপায় আছে — INCLUDE. INCLUDE-এর কলাম শুধু index-এর পাতায় বসে, খোঁজা বা সাজানোর key-তে নয়; তাই গাছের মাঝের স্তর ছোট থাকে, আর UNIQUE index-এর অনন্যতার নিয়মেও এগুলো গোনা হয় না:
-- খোঁজা হয় customer_id আর order_date দিয়ে; amount শুধু বহন করা হয়
CREATE INDEX idx_cust_date_inc ON orders (customer_id, order_date) INCLUDE (amount);
-- অনন্যতা শুধু email-এ, name পাতায় বহন করা
CREATE UNIQUE INDEX uq_email ON users (email) INCLUDE (name);MySQL InnoDB-তে INCLUDE নেই, কিন্তু সেখানে টেবিল নিজেই primary key দিয়ে সাজানো (clustered), আর প্রতিটি secondary index-এর entry-তে primary key-র কলামগুলো থাকে। তাই InnoDB-তে primary key কলাম চাওয়া কোয়েরি অনেক সময় এমনিই covering হয় — SQLite-এর rowid-এর মতোই।
৬ · ORDER BY আর GROUP BY-এর জন্য index
Index সাজানো তালিকা — তাই শুধু খোঁজায় নয়, সাজানোতেও কাজে লাগে। “গ্রাহক ৪২-এর সর্বশেষ ৩টি অর্ডার” প্রশ্নে index যদি (customer_id, order_date) হয়, তবে গ্রাহক ৪২-এর entry-গুলো আগে থেকেই তারিখের ক্রমে; শেষ দিক থেকে ৩টি পড়লেই উত্তর। index শুধু (customer_id) হলে ১০টি সারি পড়ে আলাদা করে সাজাতে হয় — temp B-tree. GROUP BY-ও তাই: সাজানো index-এ একই status-এর সব entry পাশাপাশি, তাই দল বানাতে আলাদা sort লাগে না।
CREATE INDEX idx_orders_customer ON orders(customer_id);
EXPLAIN QUERY PLAN SELECT order_id, order_date FROM orders
WHERE customer_id = 42 ORDER BY order_date DESC LIMIT 3;
EXPLAIN QUERY PLAN SELECT status, COUNT(*) FROM orders GROUP BY status;
CREATE INDEX idx_cust_date ON orders(customer_id, order_date);
CREATE INDEX idx_status ON orders(status);
EXPLAIN QUERY PLAN SELECT order_id, order_date FROM orders
WHERE customer_id = 42 ORDER BY order_date DESC LIMIT 3;
EXPLAIN QUERY PLAN SELECT status, COUNT(*) FROM orders GROUP BY status;
SELECT order_id, order_date FROM orders
WHERE customer_id = 42 ORDER BY order_date DESC LIMIT 3;আগে: প্রথম কোয়েরির plan-এ দুটি লাইন — SEARCH orders USING INDEX idx_orders_customer (customer_id=?) আর USE TEMP B-TREE FOR ORDER BY; GROUP BY-তে SCAN orders আর USE TEMP B-TREE FOR GROUP BY. পরে: SEARCH orders USING COVERING INDEX idx_cust_date (customer_id=?) — temp B-tree-এর লাইন নেই; আর SCAN orders USING COVERING INDEX idx_status — সাজানো index ধরে গোনা। ফল: 4839 | 2025-11-10, 12839 | 2025-09-16, 2839 | 2025-05-25. DESC হলেও index উল্টো দিক থেকে পড়া যায়।
LIMIT থাকলে লাভ আরও বড়: সাজানো index থেকে প্রথম ৩টি পেয়েই থামা যায়, সব মেলা সারি পড়তে হয় না। দোকানের অ্যাপে “সর্বশেষ অর্ডার”, ফেসবুক-ধাঁচের “নতুন পোস্ট আগে” — এ ধরনের পাতা প্রায় সবসময় (filter-এর কলাম, sort-এর কলাম) index-এ চলে, আর পর্ব ১৫-এর keyset pagination-ও এর ওপরই দাঁড়িয়ে। দুটি সতর্কতা: ORDER BY a ASC, b DESC-এর মতো মিশ্র দিক হলে index-ও সেভাবে বানাতে হয় ((a, b DESC)); আর filter-এ range থাকলে (যেমন customer_id IN (…) বা >) তার পরের কলামের ক্রম আর একটানা থাকে না, তাই sort লাগতে পারে।
৭ · Selectivity, cardinality — আর যখন index উপেক্ষিত হয়
দুটি শব্দ আলাদা করে বুঝুন। একটি কলামের cardinality হলো তাতে কতগুলো ভিন্ন মান: orders.status-এ ৪টি, customers.phone-এ ২,০০০টি (প্রতিটি সারিতে আলাদা)। আর একটি শর্তের selectivity হলো শর্তটি মোট সারির কত ভাগ রাখে: phone = '01300332598' রাখে ২,০০০-এর ১টি (০.০৫%), কিন্তু status = 'delivered' রাখে ২০,০০০-এর ৬,৬৬৬টি (প্রায় ৩৩%).
কম selectivity-তে index লাভ দেয় না, কারণ খরচের হিসাব বদলে যায়। পুরো টেবিল পড়া হলো page-এর পর page ক্রমানুসারে পড়া, যা ডিস্কে সবচেয়ে সস্তা। index দিয়ে ৬,৬৬৬টি সারি আনা মানে ৬,৬৬৬টি আলাদা table lookup, বেশিরভাগই এলোমেলো page-এ (random I/O) — আর orders-এর প্রায় প্রতিটি page-এ কোনো না কোনো delivered অর্ডার আছে, তাই শেষে সব page-ই পড়া হয়, তাও এলোমেলো ক্রমে আর একাধিকবার। PostgreSQL ডিফল্টে একটি random page-এর খরচ ধরে 4.0, ক্রমিক page-এর 1.0 (random_page_cost, seq_page_cost). তাই সাধারণত শর্তটি সারির সামান্য কয়েক শতাংশের বেশি রাখলেই PostgreSQL Seq Scan বা Bitmap Heap Scan বেছে নেয় — সীমাটি নির্দিষ্ট নয়, statistics আর সারিগুলো ডিস্কে কতটা সাজানো তার ওপর নির্ভর করে। SQLite-এর planner সরলতর; এই ডেটায় status-এ index থাকলে সে status = 'delivered'-এও index ব্যবহার করে (আমাদের পরীক্ষায় SEARCH … USING INDEX idx_status (status=?))। তাই status-এর মতো কলামে একা index খুব কমই কাজের; composite index-এর অংশ হিসেবে, বা partial index-এ (এই অংশের শেষে), সেটি অনেক বেশি উপযোগী।
Sargable শর্ত — কলামকে খালি রাখুন
পর্ব ১৭-এ দেখেছেন, sargable শর্তে কলামটি একা এক পাশে থাকে, কোনো ফাংশন বা হিসাবে মোড়া নয়। কারণটি সহজ: index-এ সাজানো আছে order_date-এর মান, strftime('%Y', order_date)-এর নয়। ফাংশনের ফল কোথায় শুরু, index জানে না — প্রতিটি সারিতে ফাংশন চালিয়ে দেখতে হয়। একই প্রশ্ন দুইভাবে লিখে দেখুন:
CREATE INDEX idx_date ON orders(order_date);
EXPLAIN QUERY PLAN SELECT SUM(amount) FROM orders
WHERE strftime('%Y', order_date) = '2025';
EXPLAIN QUERY PLAN SELECT SUM(amount) FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';
SELECT SUM(amount) FROM orders WHERE strftime('%Y', order_date) = '2025';
SELECT SUM(amount) FROM orders
WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01';
EXPLAIN QUERY PLAN SELECT * FROM customers WHERE phone LIKE '%2598';
EXPLAIN QUERY PLAN SELECT * FROM customers WHERE phone GLOB '01300*';ফাংশনে মোড়া শর্তে SCAN orders; range-এ লেখা শর্তে SEARCH orders USING INDEX idx_date (order_date>? AND order_date<?). দুটির উত্তর হুবহু এক: 25458350. range-টি half-open — >= '2025-01-01' AND < '2026-01-01' — তাই সময়সহ মান (যেমন '2025-12-31 18:30') থাকলেও কিছু বাদ পড়ে না। phone-এ LIKE '%2598'-এ SCAN customers: শুরুতে wildcard মানে প্রথম অক্ষরই অজানা, সাজানো তালিকায় শুরুর জায়গা নেই — ডিরেক্টরিতে “নামের শেষে ‘ন’” খোঁজার মতো। GLOB '01300*'-এ SEARCH … USING INDEX sqlite_autoindex_customers_1 (phone>? AND phone<?): শুরু জানা, তাই একটি range.
একটি SQLite-বিশেষ খুঁটিনাটি: SQLite-এ LIKE '0130%'-ও এখানে SCAN দেয়, কারণ SQLite-এর LIKE ডিফল্টে বড়-ছোট হাতের অক্ষর আলাদা করে না, অথচ index সাধারণ (BINARY) ক্রমে সাজানো। কলামটি COLLATE NOCASE হলে, বা GLOB ব্যবহার করলে, prefix-খোঁজা index পায়। PostgreSQL-এ LIKE 'x%' সাধারণ B-tree index পায় শুধু "C" collation-এ, নয়তো text_pattern_ops দিয়ে বানানো index লাগে; MySQL-এ LIKE 'x%' সাধারণত range হিসেবে index পায়। আর মাঝখানের শব্দ খোঁজা ('%x%') আসলে full-text search-এর কাজ — পর্ব ৩০.
Index অকেজো করার আরও তিনটি প্রচলিত পথ:
কলামে হিসাব
amount * 1.15 > 2300, customer_id + 0 = 42 — হিসাবটি অন্য পাশে সরান: amount > 2000. SQLite-এ customer_id + 0 = 42 লিখলে সত্যিই SCAN হয়।
টাইপ বদল
MySQL-এ phone VARCHAR, আর লেখা হলো WHERE phone = 1711000001 (সংখ্যা)। প্রতিটি সারির phone সংখ্যায় রূপান্তর করে তুলনা হয় (implicit conversion), ফলে index বাদ। SQLite এখানে উল্টো, সংখ্যাটিকেই TEXT করে নেয় — তাই সব DBMS-এ এক নয়; মান আর কলামের টাইপ মিলিয়ে লিখুন।
আলাদা কলামে OR
customer_id = 42 OR status = 'cancelled' — একটি index দুই কলাম একসাথে খুঁজতে পারে না। দুই কলামেই index থাকলে SQLite MULTI-INDEX OR, PostgreSQL BitmapOr, MySQL index merge দিয়ে দুই খোঁজ জোড়ে; একটিতে না থাকলে পুরো SCAN. বিকল্প: দুই অংশ UNION.
-- phone VARCHAR(15), index আছে
EXPLAIN SELECT * FROM customers WHERE phone = 1711000001; -- type: ALL (পুরো টেবিল)
EXPLAIN SELECT * FROM customers WHERE phone = '01711000001'; -- type: const/ref, key: phoneExpression index আর partial index — কোয়েরি বদলানো না গেলে
কখনো কোয়েরি বদলানো যায় না — হয়তো একটি রিপোর্টিং টুল নিজেই strftime('%Y', …) লেখে, বা email সবসময় lower(email) দিয়ে খোঁজা হয়। তখন ফাংশনের ফলের ওপরই index বানান: expression index. ফাংশনটি অবশ্যই deterministic হতে হবে — একই ইনপুটে সবসময় একই ফল। আর যদি কোয়েরি টেবিলের একটি নির্দিষ্ট অংশই বারবার খোঁজে — যেমন pending অর্ডার, যা মোটের প্রায় এক-পঞ্চমাংশ — তবে শুধু সেই সারিগুলোর index বানান: partial index. ছোট index, কম লেখার খরচ।
CREATE INDEX idx_order_year ON orders(strftime('%Y', order_date));
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM orders
WHERE strftime('%Y', order_date) = '2025';
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM orders
WHERE substr(order_date, 1, 4) = '2025';
CREATE INDEX idx_pending ON orders(order_date) WHERE status = 'pending';
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE status = 'pending' AND order_date >= '2025-12-01';
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE status = 'shipped' AND order_date >= '2025-12-01';
SELECT COUNT(*) FROM orders WHERE status = 'pending' AND order_date >= '2025-12-01';expression index-এর পর একই strftime শর্তে SEARCH orders USING COVERING INDEX idx_order_year (<expr>=?) — <expr> মানে index-এর কলাম একটি expression. কিন্তু substr(order_date, 1, 4) একই ফল দিলেও SCAN orders: DBMS দুটি expression-কে লেখা হিসেবে মেলায়, অর্থ বুঝে নয়। partial index-এ pending-এর শর্তে SEARCH orders USING INDEX idx_pending (order_date>?), shipped-এ SCAN orders — index-এ shipped সারিই নেই। শেষে 189: ডিসেম্বর ২০২৫-এ ১৮৯টি pending অর্ডার। কোয়েরির WHERE-এ index-এর শর্তটি (status = 'pending') থাকতেই হবে, নইলে DBMS প্রমাণ করতে পারে না যে দরকারি সব সারি index-এ আছে।
অন্য DBMS-এ: PostgreSQL-এ দুটিই আছে (CREATE INDEX … ON users (lower(email)), আর … WHERE status = 'pending'). MySQL 8.0.13 থেকে functional key part আছে, লিখতে হয় দুই বন্ধনীতে — ((lower(email))) — কিন্তু partial index নেই। SQL Server-এ partial index-এর নাম filtered index, আর expression-এর জন্য computed column বানিয়ে তাতে index দিতে হয়।
৮ · Foreign key-এর index, আর বেশি index-এর দাম
পর্ব ৬ আর ২৩-এ foreign key দেখেছেন। একটি বহুল প্রচলিত ভুল: ধরে নেওয়া যে foreign key কলামে index আপনা-আপনি হয়। PostgreSQL, SQL Server আর SQLite-এ হয় না — শুধু referenced দিকে (primary key বা UNIQUE) index থাকে। MySQL InnoDB ব্যতিক্রম: FK কলামে উপযুক্ত index না থাকলে নিজেই বানিয়ে নেয়। FK কলামে index দুই জায়গায় লাগে — JOIN-এ, আর parent সারি মোছা বা বদলানোর সময় child খোঁজায় (পর্ব ২৩-এর CASCADE বা RESTRICT যা-ই হোক):
EXPLAIN QUERY PLAN
SELECT c.name, o.order_date, o.amount
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE c.phone = '01300332598';
EXPLAIN QUERY PLAN DELETE FROM customers WHERE customer_id = 42;
CREATE INDEX idx_orders_customer ON orders(customer_id);
EXPLAIN QUERY PLAN
SELECT c.name, o.order_date, o.amount
FROM customers c JOIN orders o ON o.customer_id = c.customer_id
WHERE c.phone = '01300332598';
EXPLAIN QUERY PLAN DELETE FROM customers WHERE customer_id = 42;Index ছাড়া JOIN: SEARCH c USING INDEX sqlite_autoindex_customers_1 (phone=?), তারপর SCAN o — একজন গ্রাহকের ১০টি অর্ডারের জন্য ২০,০০০ সারি। DELETE-এর plan-এ দ্বিতীয় লাইনটি চমকপ্রদ: SCAN orders. গ্রাহককে মোছার আগে foreign key যাচাই করতে হয় — কোনো অর্ডার কি এখনো তাঁকে নির্দেশ করে? — আর সেই খোঁজ পুরো টেবিলে। এক হাজার গ্রাহক মুছলে এক হাজার বার পুরো orders পড়া। index-এর পর: JOIN-এ SEARCH o USING INDEX idx_orders_customer (customer_id=?), আর DELETE-এর যাচাই SEARCH orders USING COVERING INDEX idx_orders_customer (customer_id=?).
প্রতিটি index লেখার খরচ বাড়ায়
Index বিনা পয়সায় আসে না। k-টি index-ওয়ালা টেবিলে একটি INSERT মানে টেবিলের গাছে একটি আর প্রতিটি index-এর গাছে একটি করে, মোট k + 1টি B+ tree-তে লেখা — প্রতিটিতে প্রায় h-টি page ছোঁয়া, মাঝে মাঝে page split. DELETE-ও তাই। UPDATE শুধু সেই index-গুলো বদলায়, যাদের কলাম বদলেছে। একটি লেখা থেকে এই বহুগুণ কাজকে বলে write amplification. সাথে ডিস্কের জায়গা, buffer pool-এর মেমোরি (পর্ব ৩৯), আর optimizer-এর বাছাইয়ের সময়।
তাই নিয়মিত তিন ধরনের index খুঁজে মুছুন:
- দ্বৈত: হুবহু একই কলাম একই ক্রমে, ভিন্ন নামে — ORM আর হাতের migration মিলে প্রায়ই হয়।
- Prefix-দ্বৈত: (customer_id) আর (customer_id, order_date) দুটিই আছে; প্রথমটির কাজ দ্বিতীয়টি করে। ব্যতিক্রম: ছোটটি UNIQUE হলে (অনন্যতার নিয়ম রক্ষা করে), বা বড়টি অনেক চওড়া হলে।
- অব্যবহৃত: PostgreSQL-এ
pg_stat_user_indexes-এidx_scan = 0, MySQL-এsys.schema_unused_indexes— তবে পরিসংখ্যান যথেষ্ট দিন ধরে জমেছে কি না দেখুন; মাসে একবার চলা রিপোর্টের index এক সপ্তাহে অব্যবহৃত দেখাতে পারে।
৯ · Statistics, ANALYZE আর tuning-এর কর্মপদ্ধতি
Optimizer বাছাই করে অনুমান দিয়ে: কোন শর্ত কতগুলো সারি রাখবে। অনুমানের উৎস statistics — টেবিলে কত সারি, প্রতিটি index-এর কলামে গড়ে একটি মানে কতগুলো সারি। statistics বানানোর কমান্ড ANALYZE. SQLite ফলটি রাখে sqlite_stat1 টেবিলে:
CREATE INDEX idx_status ON orders(status);
CREATE INDEX idx_cust_date ON orders(customer_id, order_date);
CREATE INDEX idx_status_date ON orders(status, order_date);
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE order_date = '2025-12-01';
ANALYZE;
SELECT tbl, idx, stat FROM sqlite_stat1 ORDER BY tbl, idx;
EXPLAIN QUERY PLAN SELECT * FROM orders WHERE order_date = '2025-12-01';
EXPLAIN QUERY PLAN SELECT * FROM orders
WHERE status = 'delivered' AND customer_id = 42;ANALYZE-এর আগে SCAN orders. sqlite_stat1-এ চারটি সারি: customers | sqlite_autoindex_customers_1 | 2000 1, orders | idx_cust_date | 20000 10 1, orders | idx_status | 20000 5000, orders | idx_status_date | 20000 5000 7. পড়ার নিয়ম: প্রথম সংখ্যা টেবিলের সারি, তারপর index-এর প্রথম কলামে একটি মানে গড়ে কতগুলো সারি, তারপর প্রথম দুই কলাম মিলিয়ে, এভাবে। status-এ “5000” — একটি মান গড়ে ২০,০০০-এর এক-চতুর্থাংশ: খারাপ selectivity. phone-এ “1” — নিখুঁত। ANALYZE-এর পর একই কোয়েরিতে plan বদলেছে: SEARCH orders USING INDEX idx_status_date (ANY(status) AND order_date=?) — এটি skip-scan: status-এর মাত্র ৪টি মান, তাই প্রতিটি status-এর ভেতরে আলাদা করে order_date খোঁজা চলে। শেষ কোয়েরিতে USING INDEX idx_cust_date (customer_id=?) — অনুমান “গড়ে ১০টি সারি” বনাম “৫০০০টি”, তাই customer_id জিতেছে। আর idx_status? idx_status_date-এর prefix — অংশ ৮-এর নিয়মে মোছার যোগ্য।
Statistics পুরোনো হলে optimizer ভুল অনুমানে ভুল plan বানায় — যেমন রাতারাতি লাখো সারি আমদানির পর। PostgreSQL-এর autovacuum নিয়মিত নিজে ANALYZE চালায়, MySQL InnoDB টেবিলের উল্লেখযোগ্য অংশ বদলালে নিজে হিসাব নতুন করে; SQLite-এ নিজে চালাতে হয় (SQLite-এর নির্দেশিকা সংযোগ বন্ধের আগে PRAGMA optimize চালানোর পরামর্শ দেয়)। বড় আমদানির পর সব DBMS-এ হাতে ANALYZE চালানো ভালো অভ্যাস।
Tuning-এর কর্মপদ্ধতি
সব মিলিয়ে index ডিজাইন অনুমানের খেলা নয়, একটি মাপজোখের চক্র। একটি বাস্তবধর্মী উদাহরণে (PostgreSQL, ৫০ লাখ অর্ডার; সংখ্যাগুলো ব্যাখ্যার জন্য ধরে নেওয়া) চক্রটি দেখুন:
- খোঁজাslow log
- EXPLAINplan পড়া
- অনুমানকোন index
- বানানোনিরাপদে
- মাপাআগে বনাম পরে
- রাখা / বাদপরিষ্কার
- খোঁজা। কোন কোয়েরি ধীর, অনুমান না করে মাপা তালিকা থেকে নিন: PostgreSQL-এর pg_stat_statements বা slow query log. মোট সময় (বার × গড়) দিয়ে সাজান — দিনে ৫০ হাজার বার চলা ১৮০ ms-এর কোয়েরি মাসে একবারের ১০ সেকেন্ডের রিপোর্টের চেয়ে জরুরি।
- EXPLAIN ANALYZE. plan বলছে Seq Scan, আর ৫টি সারির জন্য প্রায় ৫০ লাখ সারি ফেলে দেওয়া হয়েছে; উপরে আলাদা Sort. সমস্যা স্পষ্ট: customer_id-এ কোনো কাজের index নেই।
- অনুমান। অংশ ৪ আর ৬-এর নিয়ম: সমতার কলাম আগে, তারপর ORDER BY-এর কলাম — তাহলে Sort-ও বাদ। amount শুধু ফেরত দিতে লাগে, তাই INCLUDE-এ রাখলে index-only scan.
- নিরাপদে বানানো। সাধারণ CREATE INDEX বানানোর পুরো সময় টেবিলে লেখা আটকে রাখে; চালু সিস্টেমে PostgreSQL-এর
CONCURRENTLY(ধীর, কিন্তু লেখা চলতে দেয়)। আগে staging-এ আসল মাপের ডেটায় পরীক্ষা। - মাপা। একই EXPLAIN ANALYZE আবার: Index Only Scan Backward, Heap Fetches: 0. শুধু পড়া নয়, লেখার সময় আর index-এর আকারও মাপুন — অংশ ৮-এর দাম।
- রাখা বা বাদ। লাভ স্পষ্ট, তাই রাখা; আর এখন অপ্রয়োজনীয় হয়ে যাওয়া পুরোনো index মোছা। লাভ না হলে নতুন index-ই মুছুন — “থাক, ক্ষতি কী” বলে রেখে দেওয়া index-ই একদিন লেখার গতি খায়।
আরও গভীরে: optimizer-এর অনুমান সবচেয়ে বেশি ভুল হয় একাধিক কলামের শর্তে, কারণ সাধারণ statistics প্রতিটি কলামকে স্বাধীন ধরে — শহর = 'Dhaka' আর এলাকা = 'Mirpur' আলাদা আলাদা selectivity গুণ করলে অনুমান অনেক কম আসে, অথচ দুটি একসাথে চলে। PostgreSQL-এর CREATE STATISTICS (extended statistics) এমন সম্পর্ক শেখাতে পারে। আরেকটি সূক্ষ্ম বিষয় correlation: index-এর ক্রম আর টেবিলের ভৌত ক্রম কতটা মেলে; মিললে index range-এর table lookup প্রায় ক্রমিক পড়া হয়ে যায়, PostgreSQL এটি pg_stats-এর correlation কলামে রাখে। skip-scan বহুদিন ধরে SQLite আর MySQL 8.0-এ আছে; PostgreSQL B-tree-এ এটি যোগ হয়েছে সাম্প্রতিক সংস্করণে (18).
ইতিহাস এক নজরে। Rudolf Bayer আর Edward McCreight বোয়িং-এর গবেষণাগারে B-tree উদ্ভাবন করেন, প্রবন্ধ প্রকাশ ১৯৭২ সালে। ১৯৭৯ সালে IBM-এর System R দলের Patricia Selinger ও সহকর্মীদের প্রবন্ধ “Access Path Selection in a Relational Database Management System” cost-based optimization-এর ভিত্তি গড়ে — statistics দিয়ে selectivity অনুমান, index বনাম পুরো টেবিলের খরচ তুলনা; “sargable” শব্দটিও এই ধারা থেকে এসেছে। আজকের প্রায় সব relational DBMS-এর optimizer এই কাঠামোরই উত্তরসূরি।
১০ · AI-এর যুগে index ডিজাইন
“কোয়েরি ধীর, index বলে দাও” — AI দ্রুত একটি CREATE INDEX লিখে দেয়। সমস্যা হলো, সে আপনার ডেটার মাপ, মানগুলোর বণ্টন, অন্য কোয়েরি আর লেখার হার জানে না; প্রায়ই plan না দেখেই উত্তর দেয়। সাধারণ ভুলগুলো:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| WHERE-এর প্রতিটি কলামে আলাদা index | composite-এর বদলে কয়েকটি অকেজো index, লেখা ধীর | EXPLAIN-এ কোনটি আসলে ব্যবহৃত দেখুন |
| composite-এ range-এর কলাম আগে | হাজারো বাড়তি entry পড়া | অংশ ৪-এর নিয়ম; plan-এর বন্ধনীতে কোন শর্ত আছে দেখুন |
| “most selective first” মুখস্থ নিয়ম | prefix-এর সুবিধা হারানো | অন্য কোয়েরিগুলোর তালিকা দিয়ে আবার জিজ্ঞেস করুন |
| ফাংশনে মোড়া শর্ত রেখে index যোগ | index থাকে, ব্যবহার হয় না | plan-এ SCAN; শর্তটি range-এ লিখুন |
| অনুমান করা কলাম বা index-এর নাম | কোড চলেই না, বা ভুল কলামে index | আসল schema দিন; .schema / \d orders চালিয়ে মেলান |
| লেখার খরচ আর দ্বৈত index-এর কথা চুপ | সময়ের সাথে INSERT ধীর | বিদ্যমান index-এর তালিকা দিন, prefix-দ্বৈত খুঁজতে বলুন |
AI-কে অনুমানের কাঁচামাল দিন — schema, বিদ্যমান index, plan আর মাপ:
AI-এর দেওয়া index চালু করার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Composite index-এর leftmost prefix নিয়ম কী? — (a, b, c) index খোঁজায় কাজে লাগে a, (a, b) বা (a, b, c)-এর শর্তে; শুধু b বা c-তে নয়, আর প্রথম range-এর কলামের পরে খোঁজা থামে।
- Covering index কী? — যে index-এ কোয়েরির দরকারি সব কলাম আছে, তাই টেবিলে যেতে হয় না (index-only scan).
- Index থাকলেও কেন full scan হয়? — কম selectivity, ফাংশনে মোড়া কলাম, টাইপ বদল, শুরুতে wildcard, পুরোনো statistics, বা টেবিল খুব ছোট।
- Index-এর অসুবিধা? — প্রতিটি INSERT/UPDATE/DELETE-এ বাড়তি লেখা, ডিস্ক আর মেমোরি; বেশি index মানে ধীর লেখা।
- EXPLAIN আর EXPLAIN ANALYZE-এর পার্থক্য? — প্রথমটি শুধু পরিকল্পনা আর অনুমান; দ্বিতীয়টি কোয়েরি চালিয়ে আসল সময় ও সারি দেখায়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১একটি টেবিলে status কলামে index আছে, তবু PostgreSQL WHERE status = 'delivered'-এ Seq Scan বেছে নেয়। এটি কি optimizer-এর ভুল? SSD-র যুগে হিসাবটি কীভাবে বদলেছে?
সাধারণত ভুল নয়, সঠিক সিদ্ধান্ত। শর্তটি সারির প্রায় এক-তৃতীয়াংশ রাখলে index দিয়ে প্রতিটি সারি আনতে আলাদা table lookup লাগে। সারিগুলো টেবিলের প্রায় প্রতিটি page-এ ছড়ানো, তাই শেষ পর্যন্ত সব page-ই পড়া হয় — তাও এলোমেলো ক্রমে, অনেক page একাধিকবার। পুরো টেবিল ক্রমানুসারে পড়লে প্রতিটি page একবার, আর ডিস্ক ও অপারেটিং সিস্টেম আগাম পড়া (read-ahead) দিয়ে কাজটি আরও দ্রুত করে।
PostgreSQL এই পার্থক্য খরচের মডেলে রাখে: ডিফল্টে seq_page_cost = 1.0, random_page_cost = 4.0. মান দুটি ঘোরানো চাকতির হার্ড ডিস্কের যুগের — সেখানে random পড়া আসলে বহুগুণ ধীর ছিল, আর 4.0 ধরা হয়েছিল এটা মেনে যে বেশিরভাগ পড়া ক্যাশ থেকেই আসবে। SSD-তে random আর ক্রমিক পড়ার ফারাক অনেক কম, তাই অনেক দল random_page_cost কমিয়ে ১-এর কাছাকাছি রাখে; তখন optimizer আগের চেয়ে বেশি ক্ষেত্রে index বেছে নেয়। তবে ফারাক শূন্য হয় না — প্রতিটি lookup-এ গাছের কয়েকটি page ঘোরা আর CPU-র কাজ থেকেই যায়।
মাঝামাঝি ক্ষেত্রে Bitmap Heap Scan আসে: index থেকে সব মেলা page-এর তালিকা বানিয়ে সেগুলো ডিস্কের ক্রমে একবার করে পড়া — index-এর বাছাই আর ক্রমিক পড়ার সুবিধা মিলিয়ে। optimizer আসলেই ভুল করে তখন, যখন statistics পুরোনো বা কলামগুলোর সম্পর্ক সে জানে না। সেটি ধরার উপায় EXPLAIN ANALYZE-এ অনুমিত rows আর actual rows মেলানো; বড় ফারাক থাকলে আগে ANALYZE, তারপর অন্য কিছু।
মূল উপলব্ধি: index ব্যবহার না করা সবসময় দোষ নয় — অনেক সারি আনতে হলে ক্রমিক পড়াই সস্তা। optimizer-কে দোষ দেওয়ার আগে অনুমান আর বাস্তবের সারি-সংখ্যা মেলান।
প্র ০২“সবচেয়ে বাছাই-করা কলাম composite index-এর শুরুতে দিন” — বহুল প্রচলিত এই উপদেশ কোথা থেকে এল, আর কেন এটি প্রায়ই ভুল পথে নেয়?
উপদেশটির পেছনে একটি সত্যি অনুভূতি আছে: শুধু প্রথম কলাম দিয়ে খোঁজা হলে, প্রথম কলামটি বাছাই-করা হলে কাজ কম। পুরোনো কিছু DBMS-এ, যেখানে composite index-এর পুরো key-তে খোঁজার ক্ষমতা সীমিত ছিল বা statistics শুধু প্রথম কলামের রাখা হতো, এটি বাস্তব প্রভাব ফেলত। ফলে নিয়মটি বইয়ে আর ফোরামে ছড়িয়ে যায়।
কিন্তু আধুনিক B+ tree-তে দুই কলামেই সমতার শর্ত থাকলে খোঁজা হয় পুরো key দিয়ে — (status, customer_id) আর (customer_id, status) দুটিতেই গাছের উচ্চতা সমান, আর মেলা entry একই সংখ্যক, পাশাপাশি। অর্থাৎ সমতার কলামগুলোর নিজেদের ক্রম খোঁজার খরচে প্রায় কোনো প্রভাব ফেলে না। যা প্রভাব ফেলে, তা দুটি জিনিস। এক, range: range-এর কলাম আগে এলে তার পরের কলামগুলো আর খোঁজায় লাগে না, তাই range সবসময় শেষে। দুই, prefix-এর পুনর্ব্যবহার: (status, order_date) index “সব pending অর্ডার তারিখের ক্রমে” প্রশ্নেও চলে, আর ORDER BY order_date-এর sort-ও বাঁচায়।
উল্টো দিকে, খারাপ selectivity-র কলাম আগে রাখার একটি সুবিধাও আছে: সংকোচনক্ষম index (যেমন PostgreSQL 13 থেকে B-tree-এর deduplication) একই মানের পুনরাবৃত্তি জায়গা বাঁচিয়ে রাখে, আর skip-scan প্রথম কলামের মান কম হলেই কাজ করে। Markus Winand-এর মতো লেখকেরা SQL পারফরম্যান্সের বইয়ে এই ভুল ধারণাটি নিয়ে বিস্তারিত লিখেছেন।
সঠিক প্রক্রিয়া তাই কোয়েরির তালিকা থেকে শুরু হয়: কোন কোয়েরিগুলো চলে, কোন কলামে সমতা, কোনটিতে range, কোনটি দিয়ে সাজানো — তারপর এমন একটি ক্রম, যা সবচেয়ে বেশি কোয়েরিকে prefix দেয়।
মূল উপলব্ধি: composite index-এর ক্রম ঠিক করে কোয়েরির ধরন — সমতা আগে, range বা sort শেষে, আর prefix-এর পুনর্ব্যবহার — শুধু কলামের selectivity নয়।
প্র ০৩একটি ব্যস্ত লেনদেন-টেবিলে (ধরুন মোবাইল ব্যাংকিংয়ের লেনদেন) দশটির বেশি index জমে গেছে, আর INSERT ধীর হচ্ছে। কেন হয়, আর কোন index রাখবেন কীভাবে ঠিক করবেন?
প্রতিটি লেনদেনের INSERT টেবিলে একবার, আর প্রতিটি index-এ একবার লেখা হয়। দশটি index মানে একটি লেনদেনে এগারোটি B+ tree-তে লেখা, প্রতিটিতে কয়েকটি page ছোঁয়া, মাঝে মাঝে page split; সব পরিবর্তন write-ahead log-এও যায় (পর্ব ৪৮)। ক্রমবর্ধমান key-এর index (যেমন সময়) সবসময় গাছের ডান কিনারায় লেখে, তাই ক্যাশে থাকে; কিন্তু এলোমেলো মানের index (যেমন গ্রাহকের ফোন, বা UUID) প্রতিটি লেখায় অন্য একটি page চায় — যা মেমোরিতে না থাকলে ডিস্ক থেকে আনতে হয়। index যত বেশি, buffer pool-এ তাদের জায়গা তত কম, আর এই miss তত বেশি।
PostgreSQL-এ আরেকটি সূক্ষ্ম খরচ: কোনো indexed কলাম না বদলানো UPDATE “HOT update” হতে পারে — নতুন সংস্করণ একই page-এ বসে, index ছোঁয় না। একটি অপ্রয়োজনীয় index কোনো ঘন ঘন বদলানো কলামে থাকলে এই সুবিধা হারায়, আর প্রতিটি UPDATE সব index-এ নতুন entry লেখে।
কোনটি রাখবেন, তার একটি পদ্ধতি। প্রথমে ব্যবহারের পরিসংখ্যান: PostgreSQL-এর pg_stat_user_indexes-এ idx_scan, MySQL-এর sys.schema_unused_indexes — যথেষ্ট দীর্ঘ সময় ধরে, মাসিক রিপোর্টসহ। তারপর দ্বৈত আর prefix-দ্বৈত খোঁজা। তারপর যে index-গুলো শুধু বিরল কোয়েরির, সেগুলোর বদলে replica-তে বা রাতের রিপোর্ট-ডেটাবেসে index রাখা যায় কি না ভাবা। মোছার আগে MySQL 8.0-এ index-কে “invisible” করে কিছুদিন দেখা যায় — optimizer সেটি ব্যবহার করে না, কিন্তু হালনাগাদ চলতে থাকে, তাই সমস্যা হলে সাথে সাথে ফেরানো যায়।
মূল উপলব্ধি: index পড়ার গতি কেনে লেখার দামে। লেনদেন-ভারী টেবিলে প্রতিটি index-কে তার অবস্থানের যুক্তি দেখাতে হয় — মাপা ব্যবহার দিয়ে।
প্র ০৪PostgreSQL-এর Index Only Scan-এ কখনো “Heap Fetches” অনেক বড় দেখায়, অথচ SQLite-এর COVERING INDEX-এ এমন কিছু নেই। পার্থক্যটি কোথা থেকে আসে?
পার্থক্যটি concurrency control-এর নকশায়। PostgreSQL MVCC ব্যবহার করে (পর্ব ৪৭): একটি সারির পুরোনো আর নতুন সংস্করণ একসাথে টেবিলে থাকে, আর কোন transaction কোন সংস্করণ দেখবে, সেই তথ্য থাকে টেবিলের সারিতেই — index-এ নয়। ফলে index-এর একটি entry দেখে বলা যায় না, সারিটি এই transaction-এর কাছে দৃশ্যমান কি না।
সমাধান হিসেবে PostgreSQL প্রতিটি টেবিলের একটি visibility map রাখে: প্রতিটি page-এর জন্য একটি বিট, যা বলে “এই page-এর সব সারি সবার কাছে দৃশ্যমান”। Index Only Scan প্রতিটি entry-র page-এর বিট দেখে; বিট সেট থাকলে টেবিলে যায় না, না থাকলে যায় — সেটিই Heap Fetch. VACUUM পুরোনো সংস্করণ পরিষ্কার করে বিটগুলো সেট করে। তাই সদ্য অনেক লেখা হওয়া টেবিলে, বা যেখানে autovacuum পিছিয়ে, “index only” আসলে প্রায় সাধারণ Index Scan-এর মতো আচরণ করে। Index-only scan এসেছে PostgreSQL 9.2-এ (২০১২)।
SQLite-এর নকশা ভিন্ন: একসাথে একটিমাত্র লেখক, আর পাঠকেরা journal বা WAL-এর মাধ্যমে একটি সামঞ্জস্যপূর্ণ snapshot দেখে — page-স্তরে, সারির সংস্করণ রেখে নয়। তাই index-এর entry-ই সঠিক, টেবিলে যাচাই লাগে না। MySQL InnoDB-তে সংস্করণের তথ্য undo log-এ; secondary index-এর page-এ একটি “সর্বশেষ বদলের transaction”-এর চিহ্ন থাকে, যা পুরোনো হলে index থেকেই উত্তর চলে, নইলে মূল সারিতে যেতে হয়।
মূল উপলব্ধি: “covering” কাগজে একই ধারণা, কিন্তু এর আসল লাভ নির্ভর করে DBMS কীভাবে দৃশ্যমানতা যাচাই করে তার ওপর — PostgreSQL-এ VACUUM-এর স্বাস্থ্যও index ডিজাইনের অংশ।
প্র ০৫ORM ব্যবহার করা একটি অ্যাপে প্রতিটি পাতা দ্রুত, তবু ডেটাবেস ব্যস্ত। EXPLAIN প্রতিটি কোয়েরিকে “ভালো” বলে। index ডিজাইন এখানে কতটা সাহায্য করতে পারে, আর কোথায় এর সীমা?
EXPLAIN একটি কোয়েরি দেখে, অ্যাপের আচরণ দেখে না। ORM-এর সবচেয়ে পরিচিত ফাঁদ “N+1”: একটি কোয়েরিতে ১০০ জন গ্রাহক আনা, তারপর প্রতিটি গ্রাহকের অর্ডারের জন্য আলাদা একটি কোয়েরি — মোট ১০১টি। প্রতিটি কোয়েরি index দিয়ে মিলিসেকেন্ডের কম সময়ে চলে, EXPLAIN-এ নিখুঁত SEARCH. কিন্তু প্রতিটির সাথে নেটওয়ার্কের যাতায়াত, parse আর plan-এর খরচ যোগ হয়; পাতাপ্রতি ১০১ বার, হাজারো ব্যবহারকারীতে লাখো বার।
এখানে index-এর ভূমিকা সীমিত: customer_id-এ index না থাকলে প্রতিটি ছোট কোয়েরি SCAN হতো, তাই index অবশ্যই দরকার — কিন্তু সমস্যার মূল কোয়েরির সংখ্যা। সমাধান অ্যাপের দিকে: একটি JOIN, বা WHERE customer_id IN (…)-এ একসাথে আনা (ORM-এর eager loading). পর্ব ৪৯-৫০-এ এ নিয়ে বিস্তারিত আসবে।
এমন সমস্যা ধরতে হয় মোট-সময়ের পরিসংখ্যান দিয়ে: pg_stat_statements-এ কোন কোয়েরি সবচেয়ে বেশি বার চলে, একক সময় ছোট হলেও মোট সময় কত। slow query log শুধু ধীর কোয়েরি ধরে, তাই N+1 সেখানে অদৃশ্য থাকে। APM টুলগুলো একটি অনুরোধে কতগুলো কোয়েরি গেল, তা দেখায়।
আরেকটি সীমা: index পড়া দ্রুত করে, কিন্তু অপ্রয়োজনীয় কাজ বাদ দেয় না। SELECT * দিয়ে ৫০টি কলাম এনে অ্যাপে দুটি ব্যবহার করা, বা প্রতিবার একই অপরিবর্তিত তালিকা আনা — এগুলো cache বা কোয়েরি বদলে মেটে।
মূল উপলব্ধি: index একটি কোয়েরির খরচ কমায়, কোয়েরির সংখ্যা নয়। tuning শুরু করুন মোট সময় থেকে — কখনো উত্তর একটি index, কখনো অ্যাপের একটি লাইন।
অনুশীলন
একটি index আর কোয়েরি লেখা, একটি plan আগে থেকে বলা, একটি শর্ত শোধরানো, একটি পরিষ্কারের সিদ্ধান্ত।
-
লিখুন: এই পর্বের orders টেবিলে একটি পাতা দেখায় ১ ডিসেম্বর ২০২৫ থেকে সব pending অর্ডারের order_id, order_date আর amount, তারিখের ক্রমে। কোয়েরিটি লিখুন, আর এমন একটি index দিন যাতে plan-এ টেবিলে যাওয়া আর আলাদা sort দুটিই না থাকে। (যেকোনো লাইভ এডিটরে লিখে চালাতে পারেন।)
CREATE INDEX idx_status_date_amt ON orders(status, order_date, amount); EXPLAIN QUERY PLAN SELECT order_id, order_date, amount FROM orders WHERE status = 'pending' AND order_date >= '2025-12-01' ORDER BY order_date;plan:
SEARCH orders USING COVERING INDEX idx_status_date_amt (status=? AND order_date>?)— কোনো temp B-tree নেই। ক্রম: status সমতা → order_date range ও sort → amount শুধু বহন; order_id rowid বলে এমনিই আছে। বিকল্প partial indexON orders(order_date, amount) WHERE status = 'pending'-ও একই কাজ করে আরও ছোট আকারে (USING COVERING INDEX … (order_date>?)), তবে শুধু pending-এর প্রশ্নে। -
ফল আগে বলুন (trace): orders-এ একটিমাত্র index আছে:
(customer_id, status, order_date), ANALYZE চালানো হয়নি। প্রতিটি কোয়েরির SQLite plan-এর detail কী হবে? (ক)customer_id = 7 AND status = 'pending'; (খ)customer_id = 7 AND order_date > '2025-06-01'; (গ)status = 'pending'; (ঘ)customer_id = 7 AND status = 'pending' ORDER BY order_date; (ঙ)customer_id = 7 ORDER BY order_date. সবগুলোSELECT *.- (ক)
SEARCH … (customer_id=? AND status=?)— প্রথম দুই কলামের prefix. - (খ)
SEARCH … (customer_id=?)— মাঝের status বাদ, তাই order_date শুধু ছাঁকে। - (গ)
SCAN orders— প্রথম কলাম নেই (ANALYZE ছাড়া skip-scan হয় না)। - (ঘ)
SEARCH … (customer_id=? AND status=?), temp B-tree নেই — দুই সমতার পরে entry-গুলো order_date-এর ক্রমেই। - (ঙ)
SEARCH … (customer_id=?)আরUSE TEMP B-TREE FOR ORDER BY— একই গ্রাহকের ভেতরে আগে status দিয়ে সাজানো, তারিখ দিয়ে নয়।
পাঁচটিই লাইভ এডিটরে index বানিয়ে যাচাই করা যায়।
- (ক)
-
Sargable করুন: order_date ('YYYY-MM-DD' লেখা), amount, phone আর customer_id-এ index আছে। প্রতিটি শর্ত index-বান্ধব করে লিখুন: (ক)
amount + 100 > 2000; (খ)date(order_date) = '2025-03-15'; (গ)substr(phone, 1, 5) = '01300'; (ঘ)strftime('%m', order_date) = '03' AND strftime('%Y', order_date) = '2025'; (ঙ)customer_id + 0 = 42.- (ক)
amount > 1900 - (খ)
order_date = '2025-03-15'— মান শুধু তারিখ বলে; সময়সহ হলে>= '2025-03-15' AND < '2025-03-16'. - (গ)
phone >= '01300' AND phone < '01301'(বা SQLite-এGLOB '01300*') - (ঘ)
order_date >= '2025-03-01' AND order_date < '2025-04-01' - (ঙ)
customer_id = 42
প্রতিটিতে কলাম একা এক পাশে; হিসাব বা রূপান্তর গেছে ধ্রুবকের দিকে, যা একবারই হিসাব হয়।
- (ক)
-
সিদ্ধান্ত নিন: orders-এ এই index-গুলো আছে, পাশে এক মাসে ব্যবহারের সংখ্যা: (ক)
(customer_id)— ১২,০০০; (খ)(customer_id, order_date)— ৯০,০০০; (গ)(status)— ০; (ঘ)(status, order_date)— ৪,০০০; (ঙ)(order_date)— ৩০০; (চ)(customer_id, order_date)নামেorders_cust_dt— ০. কোনগুলো মুছবেন, কোনটি রাখবেন, কেন?- (চ) মুছুন — (খ)-এর হুবহু দ্বৈত।
- (ক) মুছুন — (খ)-এর prefix; ১২,০০০ ব্যবহার (খ)-এ চলে যাবে। ব্যতিক্রম: (ক) UNIQUE হলে রাখতে হতো।
- (গ) মুছুন — অব্যবহৃত, আর (ঘ)-এর prefix-ও।
- (খ), (ঘ) রাখুন — ব্যবহৃত, আর আলাদা কাজের।
- (ঙ) যাচাই করে সিদ্ধান্ত — মাসে ৩০০ বার কম মনে হলেও তারিখের range-এর একমাত্র index; কোন কোয়েরি ব্যবহার করে আর সেটি কতটা জরুরি (যেমন মাসিক হিসাব), দেখে নিন। মোছার আগে (MySQL-এ) invisible করে পরীক্ষা করা যায়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি ধীর কোয়েরির জন্য index যোগ করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- মোট সময়ের পরিসংখ্যান বা slow query log থেকে কোয়েরিটি বাছাই
- EXPLAIN ANALYZE চালিয়ে plan আর সময় সংরক্ষণ
- কলামের ক্রমসহ একটি index-এর অনুমান
- লেখা আটকানো ছাড়া index বানানো
- আবার EXPLAIN ANALYZE আর লেখার খরচ মাপা
- রাখা বা বাদ, আর অপ্রয়োজনীয় হয়ে যাওয়া index মোছা
একটি টেবিলে index (a, b, c) আছে, আর কোনো index নেই। কোন WHERE শর্তে index a আর b দুই কলাম দিয়েই খুঁজতে পারে?
- b = 2 AND c = 3
- a = 1 AND b = 2
- a > 1 AND b = 2
- a = 1 AND c = 3
SQLite-এর EXPLAIN QUERY PLAN-এ লেখা: SEARCH orders USING COVERING INDEX idx_x (customer_id=?). এর মানে কী?
- পুরো টেবিল শুরু থেকে শেষ পর্যন্ত পড়া হবে
- index-এ খুঁজে প্রতিটি সারির জন্য টেবিলে যাওয়া হবে
- customer_id দিয়ে index-এ খোঁজা হবে, আর টেবিলে যেতে হবে না
- ফল সাজাতে একটি অস্থায়ী গাছ বানানো হবে
order_date কলামে ('YYYY-MM-DD' লেখা) index আছে। ২০২৫ সালের অর্ডার খোঁজার কোন শর্তটি index দিয়ে খোঁজা যায়?
- order_date >= '2025-01-01' AND order_date < '2026-01-01'
- strftime('%Y', order_date) = '2025'
- substr(order_date, 1, 4) = '2025'
- order_date LIKE '2025%' (SQLite-এ, ডিফল্ট সেটিংয়ে)
১ কোটি সারির একটি টেবিলে status কলামে ৪টি মান, একটি মান ('delivered') প্রায় ৪০% সারিতে। status-এ index থাকলেও PostgreSQL WHERE status = 'delivered'-এ সাধারণত Seq Scan বেছে নেয়। প্রধান কারণ?
- text কলামে index ব্যবহার করা যায় না
- PostgreSQL কখনো একক-কলাম index ব্যবহার করে না
- শর্তটির selectivity খুব ভালো
- শর্তটি অনেক সারি রাখে; সেগুলো index দিয়ে আনতে এলোমেলো পড়া, পুরো টেবিল ক্রমিক পড়ার চেয়ে দামি
orders টেবিলে primary key ছাড়া আরও ৫টি index আছে। একটি নতুন সারি INSERT করলে মোট কতগুলো B+ tree-তে লিখতে হয় (SQLite-এর rowid টেবিল ধরুন)?
- ১টি — শুধু টেবিলে
- ৬টি — টেবিলের গাছ আর প্রতিটি index
- ৫টি — শুধু index-গুলো
- শূন্য — index রাতে একসাথে হালনাগাদ হয়
একটি টেবিলে index (customer_id) আর index (customer_id, order_date) দুটিই আছে; কোনোটি UNIQUE নয়। সাধারণত কোনটি মুছে ফেলা নিরাপদ?
- (customer_id, order_date)
- কোনোটিই নয় — দুটির কাজ আলাদা
- (customer_id), কারণ সেটি অন্যটির prefix
- দুটিই
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- অনুমান নয়, EXPLAIN. SQLite-এ SCAN বনাম SEARCH, COVERING INDEX, USE TEMP B-TREE; PostgreSQL-এ Seq/Index/Index Only/Bitmap Scan, আর অনুমিত বনাম actual rows.
- Composite index-এ leftmost prefix: বাঁ দিক থেকে টানা কলামের সমতায় খোঁজা চলে, প্রথম range-এ থামে। সমতা আগে, range বা sort-এর কলাম শেষে।
- Covering index টেবিলে যাওয়া বাঁচায় (h + k·h বনাম প্রায় h); ORDER BY আর GROUP BY-এর ক্রমে সাজানো index আলাদা sort বাঁচায়।
SELECT *এড়ান। - Index উপেক্ষিত হয় কম selectivity, ফাংশনে মোড়া কলাম, টাইপ বদল,
'%x'আর আলাদা কলামে OR-এ। শর্ত sargable করুন; না পারলে expression index; একটি অংশের জন্য partial index. - প্রতিটি index-এর দাম আছে: foreign key কলামে index দিন, দ্বৈত আর prefix-দ্বৈত মুছুন, statistics হালনাগাদ রাখুন, আর চক্র মেনে চলুন — খোঁজা → EXPLAIN → অনুমান → মাপা → রাখা বা বাদ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪২ · Query processing — sort আর join অ্যালগরিদমপরবর্তী পর্বplan-এর প্রতিটি অপারেটরের ভেতরের কাজ।
- পর্ব ৪০ · Index — B+ tree, hash আর bitmapফিরে দেখুনindex-এর ভেতরের গঠন আর খরচ।
- পর্ব ১৭ · SQL ফাংশন — string, number আর dateসম্পর্কিততারিখের ফাংশন আর sargable শর্তের প্রথম পরিচয়।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- DSA · B-tree আর B+ treeসম্পর্কিত কোর্সগাছের উচ্চতা কেন logf n.
- Python · SQLite ডেটাবেসহাতে-কলমেনিজের অ্যাপে index বানিয়ে plan দেখা।