অধ্যায় 3 · পারফরম্যান্স ও প্রোডাকশন
EXPLAIN আর কোয়েরি অপ্টিমাইজেশন
- পৃষ্ঠা 9 / 22
- 21 মিনিট পড়া
ধীর কোয়েরি আন্দাজে খুব কমই ঠিক হয়। ডেটাবেস নিজেই আপনাকে বলে দেবে সে কোয়েরিটা ঠিক কীভাবে চালায় — কোন টেবিল স্ক্যান করে, কোন ইনডেক্স ব্যবহার করে, কোন ক্রমে জয়েন করে, কোথায় সাজায় — যদি EXPLAIN দিয়ে জিজ্ঞেস করেন। এই পাতায় শিখবেন SQLite, PostgreSQL আর MySQL-এ এই প্ল্যানগুলো কীভাবে পড়তে হয়, তারপর একে একে দেখবেন সেই সমাধানগুলো, যেগুলো আসল জীবনের বেশিরভাগ ধীরগতি সারায়: ইনডেক্স ব্যবহার করতে পারে এমন ফিল্টার, ভালো জয়েন আর সাবকোয়েরি, পাতা বাড়লেও ধীর না হওয়া পেজিনেশন, আর সেই N+1 প্যাটার্ন, যা চুপচাপ একটা কোয়েরিকে হাজারটায় বদলে দেয়।
AI আর ডেটার কাজে এটা রোজকার ব্যাপার: ফিচার কোয়েরি চলে প্রতিটা ট্রেনিং সারি বা প্রতিটা API রিকোয়েস্টে একবার করে, ইভ্যালুয়েশন ড্যাশবোর্ড লাখ লাখ লগ করা LLM কলের পাতা ওল্টায়, আর যে পাইপলাইন দুই মিনিটের বদলে দুই ঘণ্টা নেয়, সেটা কেউ আর আবার চালায় না।
যা শিখবেন
EXPLAIN QUERY PLAN(SQLite),EXPLAIN/EXPLAIN ANALYZE(PostgreSQL আর MySQL 8) পড়া- sargable ফিল্টার লেখা, আর যেগুলো ইনডেক্স ব্যবহার করতে পারে না সেগুলো চেনা
- জয়েন আর সাবকোয়েরি দ্রুত করা, আর
OFFSETপেজিনেশনের বদলে keyset পেজিনেশন - Python কোডে N+1 কোয়েরি খুঁজে বের করা আর সারানো
- পরিসংখ্যান (
ANALYZE), স্লো কোয়েরি লগ আর সৎ বেঞ্চমার্ক
প্রস্তুতি: আবার সেই events টেবিল
এই পাতাতেও ইনডেক্স: কীভাবে কাজ করে আর কখন কাজে লাগে পাতার সেই ২ লাখ সারির events টেবিল আর একই হেল্পারগুলো, শুধু একটা উন্নতিসহ: plan() এখন sqlite3 শেলের মতো ভেতরের ধাপগুলো ইনডেন্ট করে দেখায়।
CREATE TABLE events (
event_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
product_id INTEGER NOT NULL,
event_day TEXT NOT NULL,
amount INTEGER NOT NULL
);
INSERT INTO events (event_id, customer_id, event_type, product_id, event_day, amount)
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000)
SELECT i,
(i * 7919) % 5000 + 1,
CASE WHEN i % 20 = 0 THEN 'purchase' WHEN i % 5 = 0 THEN 'cart' ELSE 'view' END,
(i * 31) % 11 + 1,
date('2026-01-01', '+' || (i % 181) || ' days'),
(i * 37) % 5000 + 100
FROM n;import sqlite3
from sqlhelp import con, show
def plan(sql, params=()):
"""SQLite-এর প্ল্যান ইনডেন্ট করা গাছ হিসেবে প্রিন্ট করে।"""
fresh = sqlite3.connect("shop.db") # নতুন কানেকশন সবসময় সর্বশেষ ইনডেক্সগুলো দেখে
rows = fresh.execute("EXPLAIN QUERY PLAN " + sql, params).fetchall()
fresh.close()
depth = {0: -1}
for step_id, parent, _, detail in rows:
depth[step_id] = depth[parent] + 1
print(" " * depth[step_id] + detail)
def count_steps(sql, params=()):
"""একটা স্টেটমেন্ট চালায়; ফেরত দেয় (কয়টা সারি এল, SQLite কয়টা বাইটকোড ধাপ চালাল)।"""
steps = 0
def tick():
nonlocal steps
steps += 1
return 0
con.set_progress_handler(tick, 1)
rows = con.execute(sql, params).fetchall()
con.set_progress_handler(None, 1)
return len(rows), steps
def work(sql, params=()):
rows, steps = count_steps(sql, params)
print(f"{rows} rows, {steps:,} steps")SQLite-এর প্ল্যান পড়া
শুরু করুন শপের চেনা একটা কোয়েরি দিয়ে — প্রতি কাস্টমারের ডেলিভারড অর্ডার:
plan("""
SELECT c.name, COUNT(*) AS orders
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.status = 'delivered'
GROUP BY c.customer_id
ORDER BY orders DESC
""")SCAN o
SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
USE TEMP B-TREE FOR GROUP BY
USE TEMP B-TREE FOR ORDER BYওপর থেকে নিচে কাজের ক্রম হিসেবে পড়ুন। SQLite জয়েন করে নেস্টেড লুপ দিয়ে: প্রথম লাইনটা বাইরের লুপ (প্রতিটা অর্ডার স্ক্যান করা), দ্বিতীয়টা প্রতিটা অর্ডারের জন্য সে কী করে (প্রাইমারি কি দিয়ে কাস্টমার খোঁজে)। তারপর গ্রুপ করে আর সাজায়, দুটোই একটা অস্থায়ী B-tree দিয়ে। কোনো লাইনে "status দিয়ে ফিল্টার" লেখা নেই: যে WHERE ইনডেক্স ব্যবহার করতে পারে না, তা স্ক্যানের ভেতরেই যাচাই হয়ে যায়। যে লাইনগুলো দেখবেন:
| প্ল্যানের লাইন | অর্থ |
|---|---|
SCAN t | t-এর প্রতিটা সারি পড়া |
SEARCH t USING INDEX i (col=?) | সরাসরি ইনডেক্স i-তে খোঁজা; বন্ধনী দেখায় ইনডেক্স কোন শর্তগুলো সামলাচ্ছে |
USING COVERING INDEX | দরকারি সব ইনডেক্সেই আছে; টেবিল পড়া হয় না |
USING INTEGER PRIMARY KEY (rowid=?) | টেবিলের নিজের key দিয়ে সরাসরি খোঁজা |
USE TEMP B-TREE FOR ORDER BY / GROUP BY / DISTINCT | বাড়তি সাজানো; ঠিক ক্রমের একটা ইনডেক্স এটা বাদ দিতে পারে |
CORRELATED SCALAR SUBQUERY | বাইরের প্রতিটা সারির জন্য আবার চলা সাবকোয়েরি — ভেতরে কী করছে দেখুন |
AUTOMATIC … INDEX, BLOOM FILTER | শুধু এই কোয়েরির জন্য SQLite একটা অস্থায়ী ইনডেক্স বানিয়েছে: ইঙ্গিত যে একটা আসল ইনডেক্স নেই |
MATERIALIZE, CO-ROUTINE | একটা সাবকোয়েরি বা CTE অস্থায়ী টেবিলে হিসাব করে রাখা, অথবা সারি ধরে ধরে প্রবাহিত |
PostgreSQL-এ EXPLAIN আর EXPLAIN ANALYZE
PostgreSQL আরও বেশি দেখায়: প্রতিটা ধাপের আনুমানিক খরচ (cost) আর সারির সংখ্যা। সেখানে generate_series দিয়ে একই টেবিল বানান, আর ANALYZE দিয়ে পরিসংখ্যান জোগাড় করুন (নিচে বিস্তারিত)। প্ল্যান ছোট রাখতে শুধু এই সেশনের জন্য প্যারালাল ওয়ার্কার বন্ধ রাখা হয়েছে:
-- PostgreSQL
CREATE TABLE events AS
SELECT i AS event_id,
(i * 7919) % 5000 + 1 AS customer_id,
CASE WHEN i % 20 = 0 THEN 'purchase' WHEN i % 5 = 0 THEN 'cart' ELSE 'view' END AS event_type,
(i * 31) % 11 + 1 AS product_id,
DATE '2026-01-01' + (i % 181) AS event_day,
(i * 37) % 5000 + 100 AS amount
FROM generate_series(1, 200000) AS i;
ALTER TABLE events ADD PRIMARY KEY (event_id);
ANALYZE events;
SET max_parallel_workers_per_gather = 0;
EXPLAIN SELECT * FROM events WHERE customer_id = 42;+-----------------------------------------------------------+
| QUERY PLAN |
+-----------------------------------------------------------+
| Seq Scan on events (cost=0.00..3972.00 rows=40 width=25) |
| Filter: (customer_id = 42) |
+-----------------------------------------------------------+cost=0.00..3972.00 হলো শুরুর খরচ..মোট খরচ, বিমূর্ত এককে (মোটামুটি "ডিস্ক থেকে পরপর একটা পেজ পড়া = ১")। rows=40 প্ল্যানারের অনুমান; width হলো বাইটে সারির গড় আকার। শুধু EXPLAIN কোয়েরিটা চালায় না। EXPLAIN ANALYZE চালায়, আর আসলে কী ঘটল তাও যোগ করে। নিচের অপশনগুলো খরচ আর সময় লুকিয়ে রাখে, যাতে সারির সংখ্যায় মন দিতে পারেন:
-- PostgreSQL
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM events WHERE customer_id = 42;
CREATE INDEX idx_events_customer ON events (customer_id);
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM events WHERE customer_id = 42;+---------------------------------------------+
| QUERY PLAN |
+---------------------------------------------+
| Seq Scan on events (actual rows=40 loops=1) |
| Filter: (customer_id = 42) |
| Rows Removed by Filter: 199960 |
+---------------------------------------------+
+-------------------------------------------------------------------------+
| QUERY PLAN |
+-------------------------------------------------------------------------+
| Bitmap Heap Scan on events (actual rows=40 loops=1) |
| Recheck Cond: (customer_id = 42) |
| Heap Blocks: exact=40 |
| -> Bitmap Index Scan on idx_events_customer (actual rows=40 loops=1) |
| Index Cond: (customer_id = 42) |
+-------------------------------------------------------------------------+Rows Removed by Filter: 199960 হলো ইনডেক্স না থাকার স্পষ্ট লক্ষণ: ৪০টা রাখতে ২ লাখ সারি পড়া। ইনডেক্সের পর PostgreSQL ব্যবহার করে বিটম্যাপ স্ক্যান: ভেতরের ধাপ ইনডেক্স থেকে মিলে যাওয়া সারিগুলোর অবস্থান জোগাড় করে, বাইরের ধাপ টেবিলের সেই পেজগুলো ডিস্কে যে ক্রমে আছে সেই ক্রমে পড়ে (Heap Blocks: exact=40 — ৪০টা পেজ)। এক-দুটো সারির জন্য সে বেছে নিত সাধারণ Index Scan; টেবিলের বড় অংশের জন্য Seq Scan।
অপশন ছাড়া EXPLAIN ANALYZE দুটোই একসাথে দেখায়, সাথে সময়ও: (cost=4.60..145.32 rows=40 width=25) (actual time=0.005..0.022 rows=40 loops=1), আর একদম নিচে Planning Time ও Execution Time। প্রতিটা লাইনে আনুমানিক rows আর আসল rows মিলিয়ে দেখুন। দুটোর পার্থক্য ১০ গুণ বা তার বেশি হলে প্ল্যানার ভুল তথ্য নিয়ে কাজ করছে, আর অনুপস্থিত ইনডেক্স নয়, প্রায়ই আসল সমস্যা সেটাই। সাবধান: EXPLAIN ANALYZE স্টেটমেন্টটা সত্যিই চালায়, তাই UPDATE বা DELETE হলে BEGIN; … ROLLBACK;-এর ভেতরে রাখুন।
MySQL 8-এ EXPLAIN
MySQL-এর ক্লাসিক EXPLAIN প্রতি টেবিলের জন্য একটা সারি দেখায়, বারোটা কলামসহ। টেবিলটা বানান (cte_max_recursion_depth না বাড়ালে MySQL রিকার্সিভ CTE-কে ১,০০০ স্তরে আটকে রাখে):
-- MySQL
SET SESSION cte_max_recursion_depth = 200000;
CREATE TABLE events (
event_id INT PRIMARY KEY,
customer_id INT NOT NULL,
event_type VARCHAR(10) NOT NULL,
product_id INT NOT NULL,
event_day DATE NOT NULL,
amount INT NOT NULL
);
INSERT INTO events
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200000)
SELECT i,
(i * 7919) % 5000 + 1,
CASE WHEN i % 20 = 0 THEN 'purchase' WHEN i % 5 = 0 THEN 'cart' ELSE 'view' END,
(i * 31) % 11 + 1,
DATE '2026-01-01' + INTERVAL (i % 181) DAY,
(i * 37) % 5000 + 100
FROM n;সবচেয়ে জরুরি কলাম হলো type, key, rows আর Extra, তাই Python থেকে প্ল্যান পড়ে শুধু এগুলোই প্রিন্ট করুন। কানেকশনের সেটিং আসে এনভায়রনমেন্ট ভেরিয়েবল থেকে — কোডে কখনো পাসওয়ার্ড লিখবেন না (দেখুন নিরাপত্তা: রোল, SQL ইনজেকশন, সংবেদনশীল ডেটা আর ব্যাকআপ):
import os
import pymysql
mysql = pymysql.connect(
host=os.environ["MYSQL_HOST"], port=int(os.environ["MYSQL_PORT"]),
user=os.environ["MYSQL_USER"], password=os.environ["MYSQL_PASSWORD"],
database=os.environ["MYSQL_DATABASE"], autocommit=True,
)
cur = mysql.cursor(pymysql.cursors.DictCursor)
cur.execute("ANALYZE TABLE events")
def mysql_plan(sql):
cur.execute("EXPLAIN " + sql)
for row in cur.fetchall():
info = {k: row[k] for k in ("table", "type", "key", "rows", "Extra")}
info["rows"] = round(info["rows"], 1 - len(str(info["rows"]))) # কিছু পেজ নমুনা হিসেবে পড়ে করা অনুমান: একটা সার্থক অঙ্ক রাখুন
print(info)
mysql_plan("SELECT * FROM events WHERE customer_id = 42")
cur.execute("CREATE INDEX idx_events_customer ON events (customer_id)")
mysql_plan("SELECT * FROM events WHERE customer_id = 42"){'table': 'events', 'type': 'ALL', 'key': None, 'rows': 200000, 'Extra': 'Using where'}
{'table': 'events', 'type': 'ref', 'key': 'idx_events_customer', 'rows': 40, 'Extra': None}type হলো অ্যাক্সেসের পদ্ধতি, সবচেয়ে খারাপ থেকে সবচেয়ে ভালো: ALL (পুরো স্ক্যান), index (পুরো ইনডেক্স স্ক্যান), range, ref (ইউনিক নয় এমন মানে ইনডেক্স লুকআপ), eq_ref (ইউনিক কি দিয়ে জয়েনের প্রতি সারিতে একটা সারি), const (বড়জোর একটা সারি)। Extra-তে Using where মানে পড়ার পরে সারি ছাঁকা হচ্ছে। rows একটা অনুমান, যা InnoDB কিছু পেজ নমুনা হিসেবে পড়ে বানায়, তাই প্রতিবার চালালে একটু বদলায় (একবার ১,৯২,৮২৭, আরেকবার ১,৯৯,৮৬৫) — সেজন্যই mysql_plan() সংখ্যাটা রাউন্ড করে। MySQL 8.0.18+-এ EXPLAIN ANALYZE-ও আছে, যা কোয়েরি চালিয়ে PostgreSQL-এর মতো আনুমানিক আর আসল সারি ও সময়সহ একটা গাছ দেখায় (8.0.16 থেকে থাকা EXPLAIN FORMAT=TREE কোয়েরি না চালিয়েই একই গাছ দেখায়, শুধু আনুমানিক সংখ্যাসহ)।
Sargable ফিল্টার: ইনডেক্সকে তার কাজ করতে দিন
একটা ফিল্টার sargable ("Search ARGument ABLE" থেকে), যখন ডেটাবেস সেটাকে ইনডেক্সের একটা রেঞ্জে বদলাতে পারে। নিয়ম: তুলনার এক পাশে ইনডেক্স করা কলামটা খালি রাখুন। ফাংশন বা পাটিগণিতে মুড়ে দিলে কলামটা ইনডেক্সের চোখের আড়ালে চলে যায়:
CREATE INDEX idx_events_day ON events (event_day);pairs = [
("SELECT COUNT(*) FROM events WHERE substr(event_day, 1, 7) = '2026-03'",
"SELECT COUNT(*) FROM events WHERE event_day >= '2026-03-01' AND event_day < '2026-04-01'"),
("SELECT COUNT(*) FROM events WHERE date(event_day, '+7 days') > '2026-07-01'",
"SELECT COUNT(*) FROM events WHERE event_day > date('2026-07-01', '-7 days')"),
]
for slow, fast in pairs:
for sql in (slow, fast):
plan(sql)
work(sql)
print()SCAN events USING COVERING INDEX idx_events_day
1 rows, 834,361 steps
SEARCH events USING COVERING INDEX idx_events_day (event_day>? AND event_day<?)
1 rows, 102,784 steps
SCAN events USING COVERING INDEX idx_events_day
1 rows, 806,640 steps
SEARCH events USING COVERING INDEX idx_events_day (event_day>?)
1 rows, 13,271 stepsপ্রতিটা জোড়া একই সংখ্যা ফেরত দেয়; শুধু দ্বিতীয় রূপটাই ইনডেক্সে খুঁজতে পারে (প্রথমটা পুরো ইনডেক্স স্ক্যান করে)। একই নিয়ম অন্য ফাঁদগুলোতেও খাটে:
- শুরুতে ওয়াইল্ডকার্ড।
LIKE 'user42%'B-tree ব্যবহার করতে পারে, কারণ প্রিফিক্স মানেই একটা রেঞ্জ — তবে কেবল তখনই, যখন ইনডেক্সের তুলনার নিয়মLIKE-এর সাথে মেলে: SQLite-এLIKEবড়-ছোট হাতের অক্ষর আলাদা করে না, তাই কলামটাCOLLATE NOCASEহলে (বাPRAGMA case_sensitive_like = ONথাকলে) তবেই ইনডেক্স কাজে লাগে; PostgreSQL-এ কলামেtext_pattern_opsবা C কোলেশন লাগে (ওপরে দেখুন)।LIKE '%42'কখনোই ইনডেক্স ব্যবহার করতে পারে না। - ছোট হাতের অক্ষরে বদলানো।
lower(email) = …email-এর ইনডেক্স ব্যবহার করতে পারে না; তার বদলে এক্সপ্রেশনটাকেই ইনডেক্স করুন। - টাইপের অমিল। MySQL-এ একটা
VARCHARকলামকে সংখ্যার সাথে তুলনা করলে (WHERE phone = 01711…) প্রতিটা সারি রূপান্তর করতে হয়, ইনডেক্স কাজে লাগে না। মানটা কলামের টাইপেই পাঠান।
প্রথম দুটো দেখুন PostgreSQL-এ ১ লাখ ইমেইলের একটা টেবিলে:
-- PostgreSQL
CREATE TABLE app_users AS
SELECT i AS user_id, 'user' || i || '@example.com' AS email
FROM generate_series(1, 100000) AS i;
CREATE INDEX idx_app_users_email ON app_users (email text_pattern_ops);
CREATE INDEX idx_app_users_email_lower ON app_users (lower(email));
ANALYZE app_users;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM app_users WHERE email LIKE 'user4242@%';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT * FROM app_users WHERE email LIKE '%4242@example.com';
EXPLAIN (COSTS OFF)
SELECT * FROM app_users WHERE lower(email) = 'user4242@example.com';+----------------------------------------------------------------------------------+
| QUERY PLAN |
+----------------------------------------------------------------------------------+
| Index Scan using idx_app_users_email on app_users (actual rows=1 loops=1) |
| Index Cond: ((email ~>=~ 'user4242@'::text) AND (email ~<~ 'user4242A'::text)) |
| Filter: (email ~~ 'user4242@%'::text) |
+----------------------------------------------------------------------------------+
+------------------------------------------------+
| QUERY PLAN |
+------------------------------------------------+
| Seq Scan on app_users (actual rows=10 loops=1) |
| Filter: (email ~~ '%4242@example.com'::text) |
| Rows Removed by Filter: 99990 |
+------------------------------------------------+
+-------------------------------------------------------------+
| QUERY PLAN |
+-------------------------------------------------------------+
| Index Scan using idx_app_users_email_lower on app_users |
| Index Cond: (lower(email) = 'user4242@example.com'::text) |
+-------------------------------------------------------------+প্রিফিক্স LIKE হয়ে গেছে ইনডেক্সের একটা রেঞ্জ (~>=~ … ~<~); শুরুতে % থাকায় ১ লাখ সারিই পড়তে হয়েছে; আর এক্সপ্রেশন ইনডেক্স সামলেছে lower(email)। text_pattern_ops খেয়াল করুন: en_US.utf8-এর মতো ভাষা-সচেতন collation-এ (সাধারণত এটাই ডিফল্ট) টেক্সটের সাধারণ B-tree ইনডেক্স LIKE-এ কোনো কাজেই আসে না। "ভেতরে আছে কিনা" ধরনের খোঁজার জন্য ট্রাইগ্রাম ইনডেক্স (pg_trgm) বা ফুল-টেক্সট সার্চ ব্যবহার করুন (দেখুন AI অ্যাপের জন্য SQL: এমবেডিং, pgvector আর RAG মেটাডেটা)।
শুধু যা দরকার তা-ই নিন
খুঁজে দেখার সময় SELECT * ঠিক আছে, কিন্তু কোডে এটা দামি। এটা নেটওয়ার্কে প্রতিটা কলাম পাঠায় (ভাবুন ৬ KB-র একটা embedding বা prompt কলাম, যা আপনার লাগতই না), কভারিং ইনডেক্সের খোঁজকে এমন খোঁজে বদলে দেয় যাকে টেবিলেও যেতে হয়, আর কেউ নতুন কলাম যোগ করলে এর ফলাফল চুপচাপ বদলে যায়। যে কলামগুলো ব্যবহার করেন, সেগুলোর নাম লিখুন।
জয়েন আর সাবকোয়েরি
কোরিলেটেড সাবকোয়েরি বাইরের প্রতিটা সারির জন্য একবার চলে। ভেতরের কোয়েরি ইনডেক্স লুকআপ হলে এতে সমস্যা নেই, আর স্ক্যান হলে ভয়ংকর। "শপের প্রতিটা কাস্টমার কয়টা কেনাকাটা করেছে?":
correlated = """
SELECT c.customer_id,
(SELECT COUNT(*) FROM events e
WHERE e.customer_id = c.customer_id AND e.event_type = 'purchase') AS purchases
FROM customers c ORDER BY c.customer_id"""
grouped_first = """
SELECT c.customer_id, COALESCE(p.purchases, 0) AS purchases
FROM customers c
LEFT JOIN (SELECT customer_id, COUNT(*) AS purchases
FROM events WHERE event_type = 'purchase'
GROUP BY customer_id) p ON p.customer_id = c.customer_id
ORDER BY c.customer_id"""
for sql in (correlated, grouped_first):
plan(sql)
work(sql)
print()SCAN c
CORRELATED SCALAR SUBQUERY 1
SCAN e
8 rows, 6,400,802 steps
MATERIALIZE p
SCAN events
USE TEMP B-TREE FOR GROUP BY
SCAN c
BLOOM FILTER ON p (customer_id=?)
SEARCH p USING AUTOMATIC COVERING INDEX (customer_id=?) LEFT-JOIN
8 rows, 715,919 stepsকোরিলেটেড রূপটা ৮ জন কাস্টমারের প্রত্যেকের জন্য ২ লাখ ইভেন্ট একবার করে স্ক্যান করে। আগে অ্যাগ্রিগেট করে ছোট ফলাফলটা জয়েন করলে ইভেন্টগুলো পড়তে হয় একবারই। AUTOMATIC COVERING INDEX খেয়াল করুন: জয়েনের জন্য SQLite কাজ চালানোর মতো একটা অস্থায়ী ইনডেক্স বানিয়ে নিয়েছে, যা কোয়েরি শেষে ফেলে দেওয়া হয় — এভাবেই SQLite জানাচ্ছে যে একটা আসল ইনডেক্স নেই। জয়েনের কলামে ইনডেক্স দিন, তাহলে কোরিলেটেড রূপটাও সস্তা হয়ে যায়:
CREATE INDEX idx_events_customer ON events (customer_id, event_type);plan(correlated)
work(correlated)SCAN c
CORRELATED SCALAR SUBQUERY 1
SEARCH e USING COVERING INDEX idx_events_customer (customer_id=? AND event_type=?)
8 rows, 360 stepsশিক্ষাগুলো সব জায়গায় খাটে। যে কলামে জয়েন করেন সেগুলো ইনডেক্স করুন (আগে ফরেন কি)। সম্ভব হলে জয়েনের আগে অ্যাগ্রিগেট বা ফিল্টার করুন, যাতে জয়েনকে কম সারি সামলাতে হয়। শুধু "একটাও আছে কি?" জানতে চাইলে COUNT(*) > 0-এর বদলে EXISTS লিখুন — প্রথম মিল পেলেই সে থামতে পারে। PostgreSQL আর MySQL অনেক সাবকোয়েরি নিজেরাই জয়েনে বদলে নেয় (SQLite কম নেয়), তাই কোনো দিকেই অনুমান না করে প্ল্যান দেখুন।
পেজিনেশন: OFFSET বনাম keyset
যে ইভ্যালুয়েশন ড্যাশবোর্ড LIMIT 10 OFFSET 150000 দিয়ে প্রতি পাতায় ১০টা লগ করা কল দেখায়, সে ১৫,০০১ নম্বর পাতা দেখাতে ডেটাবেসকে দিয়ে ১,৫০,০০০টা সারি বানিয়ে ফেলে দেওয়ায়। Keyset পেজিনেশন মনে রাখে ব্যবহারকারী শেষ কোন key দেখেছেন, আর সেখান থেকে চালিয়ে যায়:
offset_page = "SELECT * FROM events ORDER BY event_id LIMIT 10 OFFSET 150000"
keyset_page = "SELECT * FROM events WHERE event_id > ? ORDER BY event_id LIMIT 10"
plan(offset_page)
work(offset_page)
plan(keyset_page, (150000,))
work(keyset_page, (150000,))SCAN events
10 rows, 300,110 steps
SEARCH events USING INTEGER PRIMARY KEY (rowid>?)
10 rows, 101 stepsএকই ১০টা সারি, কাজ ৩,০০০ গুণ কম, আর পাতার নম্বরের সাথে খরচ আর বাড়ে না। বিনিময়ে: শুধু "পরের" আর "আগের" পাতায় যেতে পারবেন, সরাসরি ১৫,০০১ নম্বরে লাফাতে পারবেন না, আর সাজানোর key ইউনিক হতে হবে (টাই ভাঙতে প্রাইমারি কি যোগ করুন: WHERE (created_at, id) > (?, ?) ORDER BY created_at, id)।
Python থেকে N+1 সমস্যা
অ্যাপ্লিকেশন কোডে সবচেয়ে সাধারণ ধীর প্যাটার্নটা কোনো ধীর কোয়েরি নয়, বরং অনেকগুলো দ্রুত কোয়েরি: প্রথমে একটা তালিকা আনা (১টা কোয়েরি), তারপর তালিকার প্রতিটা আইটেমের জন্য আলাদা করে কিছু আনা (Nটা কোয়েরি)। set_trace_callback একটা কানেকশনের চালানো প্রতিটা স্টেটমেন্ট দেখায়:
queries = []
con.set_trace_callback(queries.append)
# N+1: অর্ডারগুলোর জন্য একটা কোয়েরি, তারপর প্রতিটা অর্ডারের কাস্টমারের জন্য একটা করে
orders = con.execute("SELECT order_id, customer_id FROM orders ORDER BY order_id").fetchall()
names = {}
for order_id, customer_id in orders:
names[order_id] = con.execute(
"SELECT name FROM customers WHERE customer_id = ?", (customer_id,)).fetchone()[0]
print("N+1 version:", len(queries), "queries")
queries.clear()
rows = con.execute("""
SELECT o.order_id, c.name
FROM orders o JOIN customers c ON c.customer_id = o.customer_id
ORDER BY o.order_id""").fetchall()
print("join version:", len(queries), "query")
print(dict(rows) == names)
con.set_trace_callback(None)N+1 version: 15 queries
join version: 1 query
TrueSQLite একই প্রসেসের ভেতরে চলে বলে ১৫টা কোয়েরির খরচ সামান্য। সার্ভারের বেলায় প্রতিটা কোয়েরি মানে নেটওয়ার্কে একবার যাওয়া-আসা, ধরুন ১ মিলিসেকেন্ড, তাই ১০,০০০ অর্ডার মানে ১০ সেকেন্ড অপেক্ষা, অথচ জয়েনে লাগে কয়েক মিলিসেকেন্ড। ORM দিয়ে ভুল করে N+1 লিখে ফেলা খুব সহজ (order.customer-এর ওপর লুপ); সমাধানও তাদের কাছেই আছে (eager loading, যেমন SQLAlchemy-র selectinload)। অন্য উপায়: WHERE id IN (…) দিয়ে একবারে আনা, বা লেখার জন্য executemany।
পরিসংখ্যান আর ANALYZE
প্রতিটা ধাপ কয়টা সারি দেবে তা অনুমান করে প্ল্যানার এক প্ল্যান থেকে আরেকটা বেছে নেয়। সেই অনুমান আসে ANALYZE-এর জোগাড় করা পরিসংখ্যান (statistics) থেকে। আপনি না চালানো পর্যন্ত SQLite-এর কোনো পরিসংখ্যান থাকে না:
sql = "SELECT COUNT(*) FROM events WHERE event_type = 'view' AND event_day = '2026-03-15'"
con.execute("CREATE INDEX idx_events_type ON events (event_type)")
plan(sql)
work(sql)
con.execute("ANALYZE")
show("SELECT tbl, idx, stat FROM sqlite_stat1 WHERE tbl = 'events' ORDER BY idx")
plan(sql)
work(sql)SEARCH events USING INDEX idx_events_type (event_type=?)
1 rows, 800,900 steps
+--------+---------------------+--------------+
| tbl | idx | stat |
+--------+---------------------+--------------+
| events | idx_events_customer | 200000 40 40 |
| events | idx_events_day | 200000 1105 |
| events | idx_events_type | 200000 66667 |
+--------+---------------------+--------------+
SEARCH events USING INDEX idx_events_day (event_day=?)
1 rows, 6,427 stepsএই কোয়েরিতে দুটো ইনডেক্সই কাজে লাগতে পারত। পরিসংখ্যান ছাড়া SQLite আন্দাজ করেছে, event_type-এর ইনডেক্স বেছেছে, আর ১,৬০,০০০টা "view" এন্ট্রি পার হয়েছে। ANALYZE sqlite_stat1 ভরেছে: 200000 66667 মানে ইনডেক্সে ২ লাখ এন্ট্রি, প্রতিটা event_type মানে গড়ে প্রায় ৬৬,৬৬৭টা সারি; 200000 1105 মানে প্রতি দিনে প্রায় ১,১০৫টা সারি। এখন প্ল্যানার জানে দিনটা অনেক বেশি সিলেক্টিভ, তাই ইনডেক্স বদলেছে — একই কোয়েরি, একই ইনডেক্স, অথচ কাজ ১০০ গুণেরও বেশি কম। PostgreSQL-এর পরিসংখ্যান থাকে pg_stats-এ (আলাদা মানের সংখ্যা, সবচেয়ে বেশি আসা মান আর সেগুলো কতবার আসে, হিস্টোগ্রাম):
-- PostgreSQL
SELECT attname, n_distinct
FROM pg_stats
WHERE tablename = 'events' AND attname IN ('event_type', 'product_id', 'event_day')
ORDER BY attname;
CREATE INDEX idx_events_type ON events (event_type);
EXPLAIN (COSTS OFF) SELECT SUM(amount) FROM events WHERE event_type = 'purchase';
EXPLAIN (COSTS OFF) SELECT SUM(amount) FROM events WHERE event_type = 'view';+------------+------------+
| attname | n_distinct |
+------------+------------+
| event_day | 181.0 |
| event_type | 3.0 |
| product_id | 11.0 |
+------------+------------+
+-----------------------------------------------------------+
| QUERY PLAN |
+-----------------------------------------------------------+
| Aggregate |
| -> Bitmap Heap Scan on events |
| Recheck Cond: (event_type = 'purchase'::text) |
| -> Bitmap Index Scan on idx_events_type |
| Index Cond: (event_type = 'purchase'::text) |
+-----------------------------------------------------------+
+---------------------------------------------+
| QUERY PLAN |
+---------------------------------------------+
| Aggregate |
| -> Seq Scan on events |
| Filter: (event_type = 'view'::text) |
+---------------------------------------------+একই ইনডেক্স, দুটো প্ল্যান: পরিসংখ্যান বলছে purchase সারিগুলোর ৫% (তাই ইনডেক্স) আর view ৮০% (তাই পুরো টেবিল পরপর পড়াই, অর্থাৎ সিকোয়েনশিয়াল স্ক্যানই সস্তা)। যথেষ্ট পরিবর্তনের পর PostgreSQL-এর autovacuum আর MySQL-এর InnoDB নিজেরাই পরিসংখ্যান হালনাগাদ করে, কিন্তু বড় একটা বাল্ক লোডের পর কোনো প্ল্যানে ভরসা করার আগে নিজে ANALYZE t চালান (MySQL: ANALYZE TABLE t)। SQLite এটা কখনো নিজে থেকে করে না; মাঝে মাঝে ANALYZE বা PRAGMA optimize চালান।
প্রোডাকশনে ধীর কোয়েরি খোঁজা
কোন কোয়েরি ধীর, তা না জানলে তাকে EXPLAIN করবেন কীভাবে? প্রতিটা সার্ভার এগুলো লগ করতে পারে:
-- MySQL
SELECT @@slow_query_log, @@long_query_time;+------------------+-------------------+
| @@slow_query_log | @@long_query_time |
+------------------+-------------------+
| 0 | 10.0 |
+------------------+-------------------+-- PostgreSQL
SHOW log_min_duration_statement;+----------------------------+
| log_min_duration_statement |
+----------------------------+
| -1 |
+----------------------------+দুটোই ডিফল্টভাবে বন্ধ (চালু করলে MySQL-এর সীমা ১০ সেকেন্ড; PostgreSQL-এর -1 মানে "কখনো না")। একজন অ্যাডমিনিস্ট্রেটর এগুলো চালু করেন সার্ভারের কনফিগারেশনে, অথবা সার্ভার চলা অবস্থাতেই (MySQL: SET GLOBAL slow_query_log = ON; PostgreSQL: ALTER SYSTEM SET log_min_duration_statement = 500, তারপর SELECT pg_reload_conf()):
# MySQL: my.cnf
[mysqld]
slow_query_log = 1
long_query_time = 0.5 # সেকেন্ড
log_queries_not_using_indexes = 1
# PostgreSQL: postgresql.conf
log_min_duration_statement = 500 # মিলিসেকেন্ড
shared_preload_libraries = 'pg_stat_statements'লগের চেয়েও ভালো হলো সমষ্টি। pg_stat_statements এক্সটেনশন সার্ভার চালুর সময় লোড থাকলে (ওপরের shared_preload_libraries লাইন, তারপর রিস্টার্ট) আর আপনার ডেটাবেসে CREATE EXTENSION pg_stat_statements চালানো থাকলে PostgreSQL প্রতিটা নর্মালাইজ করা কোয়েরির মোট হিসাব রাখে, ফলে খুঁজে পান কোন কোয়েরি মোটের ওপর সবচেয়ে দামি — প্রায়ই সেটা দশ লাখবার ডাকা একটা দ্রুত কোয়েরি:
-- PostgreSQL (সার্ভার চালুর সময় pg_stat_statements লোড থাকতে হবে)
SELECT calls, round(total_exec_time) AS total_ms, round(mean_exec_time, 2) AS mean_ms, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;MySQL-এ এর সমতুল্য হলো performance_schema.events_statements_summary_by_digest (অথবা sys.statement_analysis ভিউ)।
সৎভাবে বেঞ্চমার্ক
যখন সত্যিই কোয়েরির সময় মাপবেন, ন্যায্যভাবে মাপুন। প্রথমবার চালালে ডিস্ক থেকে পেজগুলো ক্যাশে তুলতে হয়; পরেরবারগুলোতে হয় না। একবার চালিয়ে খুব কমই কিছু জানা যায়। বারবার চালান, মিডিয়ান নিন, আর কী মেপেছেন তা বলুন:
import statistics
import time
def benchmark(sql, params=(), repeat=20):
con.execute(sql, params).fetchall() # ওয়ার্ম-আপ, গোনা হয় না
times = []
for _ in range(repeat):
start = time.perf_counter()
con.execute(sql, params).fetchall()
times.append((time.perf_counter() - start) * 1000)
return statistics.median(times)
print(f"OFFSET page: {benchmark(offset_page):.3f} ms (median of 20)")
print(f"keyset page: {benchmark(keyset_page, (150000,)):.3f} ms (median of 20)")OFFSET page: 1.513 ms (median of 20)
keyset page: 0.011 ms (median of 20)- প্রোডাকশনের আকারের ডেটা দিয়ে বেঞ্চমার্ক করুন: ১৪টা সারিতে প্রতিটা প্ল্যানই দ্রুত, আর ছোট টেবিলে প্ল্যানার অন্যরকম বেছে নেয়।
- একবারে একটাই জিনিস বদলান, আর সময়ের পাশে প্ল্যানটা রাখুন — সংখ্যাটা কেন এমন, প্ল্যানই তা বোঝায়।
- ব্যবহারকারী পুরো যে পথটার জন্য অপেক্ষা করেন (নেটওয়ার্ক, ড্রাইভার, সারি আনা) সেটা মাপুন, শুধু ডেটাবেস নয়।
- বড় টেবিলে যে কোয়েরির প্ল্যান এখনো
SCANবলছে, তার একবারের দ্রুত ফলাফলে ভরসা করবেন না: সম্ভবত সেটা ক্যাশ থেকে এসেছে।
অপ্টিমাইজেশনের একটা রুটিন
- খুঁজে বের করুন ধীর বা দামি কোয়েরি: স্লো লগ,
pg_stat_statements, অথবা প্রতি রিকোয়েস্টে কোয়েরি গুনে (N+1)। - প্ল্যান দেখুন: আগে
EXPLAIN, তারপর একটা কপিতে বা রোলব্যাক করা ট্রানজ্যাকশনের ভেতরেEXPLAIN ANALYZE। - প্ল্যানে খুঁজুন: বেশিরভাগ সারি ফেলে দেওয়া পুরো স্ক্যান, আসল সারির সংখ্যা থেকে অনেক দূরের অনুমান, সাজানো আর অস্থায়ী B-tree, প্রতি সারিতে চলা সাবকোয়েরি।
- ঠিক করুন সবচেয়ে ছোট জিনিসটা: ফিল্টার sargable করুন, ইনডেক্স যোগ করুন বা কলামের ক্রম বদলান, কম কলাম নিন, জয়েনের আগে অ্যাগ্রিগেট করুন, keyset পেজিনেশন ব্যবহার করুন, পরিসংখ্যান হালনাগাদ করুন।
- যাচাই করুন: একই ফলাফল, ভালো প্ল্যান, তারপর সময়।
সাধারণ ভুল
- প্ল্যান না দেখে অপ্টিমাইজ করা। এলোমেলোভাবে যোগ করা ইনডেক্স প্রতিটা লেখাকে ধীর করে, আর প্রায়ই কিছুই ঠিক করে না। আগে প্ল্যান পড়ুন, সেটা যা দেখায় তা ঠিক করুন।
- লেখার স্টেটমেন্টে
EXPLAIN ANALYZEচালানো। এটা স্টেটমেন্টটা সত্যিই চালায়। লিখুনBEGIN; EXPLAIN ANALYZE DELETE …; ROLLBACK;। - ইনডেক্স করা কলামে ফাংশন।
WHERE strftime('%Y', event_day) = '2026'স্ক্যান করে;WHERE event_day >= '2026-01-01' AND event_day < '2027-01-01'সার্চ করে। এক্সপ্রেশনটা সত্যিই লাগলে এক্সপ্রেশনটাকেই ইনডেক্স করুন। - লুপের ভেতরে কোয়েরি। সম্পর্কিত সারিগুলো একটা জয়েন বা একটা
IN (…)কোয়েরি দিয়ে আনুন; টেস্টে প্রতি রিকোয়েস্টে কোয়েরি গুনুন।
নিজে চেষ্টা করুন
- সহজ:
SELECT COUNT(*) FROM events WHERE amount * 2 > 9000স্ক্যান করে।amount-এ একটা ইনডেক্স যোগ করুন, ফিল্টারটা sargable করে লিখুন, আর দুটো প্ল্যানই দেখান। - মাঝারি:
events-এর ওপরevent_dayআর তারপরevent_idঅনুযায়ী সাজানো keyset পেজিনেশন লিখুন, যা ('2026-03-01',100)-এর পরের ৫টা সারি দেয়। কোন ইনডেক্স এটাকে একটা মাত্র ইনডেক্স সার্চ বানায়? - কঠিন: এই N+1 কোডটাকে একটা কোয়েরিতে লিখুন, আর
set_trace_callbackদিয়ে প্রমাণ করুন যে সেটা একবারই চলে: প্রতিটা প্রোডাক্টের জন্য একটা করে কোয়েরি চালিয়েorder_itemsথেকে মোট বিক্রি হওয়া পরিমাণ আনা।
উত্তর
# ১. সহজ: পাটিগণিতটা ধ্রুবকের দিকে সরিয়ে দিন
con.execute("CREATE INDEX idx_events_amount ON events (amount)")
plan("SELECT COUNT(*) FROM events WHERE amount * 2 > 9000")
plan("SELECT COUNT(*) FROM events WHERE amount > 4500")# ২. মাঝারি: (event_day, event_id)-এর ওপর row-value তুলনা।
# SQLite-এর প্রতিটা ইনডেক্সের শেষে rowid থাকে, তাই idx_events_day আসলে (event_day, event_id);
# PostgreSQL বা MySQL-এ বানাতে হবে: CREATE INDEX idx_events_day_id ON events (event_day, event_id)
page = """SELECT event_id, event_day, event_type FROM events
WHERE (event_day, event_id) > (?, ?)
ORDER BY event_day, event_id LIMIT 5"""
plan(page, ("2026-03-01", 100))
show(page, ("2026-03-01", 100))# ৩. কঠিন
queries = []
con.set_trace_callback(queries.append)
rows = con.execute("""
SELECT p.product_id, p.name, COALESCE(SUM(oi.quantity), 0) AS sold
FROM products p
LEFT JOIN order_items oi ON oi.product_id = p.product_id
GROUP BY p.product_id, p.name
ORDER BY p.product_id""").fetchall()
con.set_trace_callback(None)
print(len(queries), "query,", len(rows), "products")সারসংক্ষেপ
EXPLAINপ্ল্যান দেখায়;EXPLAIN ANALYZE(PostgreSQL, MySQL 8.0.18+) কোয়েরি চালিয়ে আসল সারি আর সময় যোগ করে। আনুমানিক আর আসল সারি মিলিয়ে দেখুন।- ফিল্টারে ইনডেক্স করা কলাম খালি রাখুন (sargable): কলামের দিকে কোনো ফাংশন, পাটিগণিত, শুরুতে
%বা টাইপ রূপান্তর নয়। - জয়েনের কলাম ইনডেক্স করুন, জয়েনের আগে অ্যাগ্রিগেট করুন, আর প্রতি সারিতে চলা সাবকোয়েরির দিকে নজর রাখুন।
- Keyset পেজিনেশনের খরচ প্রতি পাতায় একই;
OFFSETক্রমেই ধীর হয়। N+1 কোয়েরির লুপ হয়ে যায় একটা জয়েন। - প্ল্যানার তার পরিসংখ্যানের মতোই ভালো: বাল্ক লোডের পর
ANALYZE। ধীর কোয়েরি খুঁজুন স্লো লগ আরpg_stat_statementsদিয়ে; বেঞ্চমার্ক করুন ওয়ার্ম-আপ, বারবার চালানো আর মিডিয়ান দিয়ে।
এরপর: ভিউ, স্টোরড প্রসিডিওর, ফাংশন আর ট্রিগার পাতায় লজিক যাবে ডেটাবেসের ভেতরেই: সংরক্ষিত কোয়েরি, দামি ফলাফল ক্যাশ করে রাখা ম্যাটেরিয়ালাইজড ভিউ, প্রসিডিওর আর ফাংশন, অডিট ট্রেইল রাখা ট্রিগার, আর কখন সেই লজিক আপনার Python কোডে নয়, ডেটাবেসে থাকা উচিত।