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

পারফরম্যান্স টিউনিং আর DBA-র প্রতিদিনের কাজ

Database Performance Tuning — Measurement, Query Rewrites, Schema Fixes, Configuration, Maintenance, Monitoring and the DBA's Routine

Taught in Bangla, this lesson covers database performance tuning: a measure-find-change-measure method, where time goes, finding slow queries and reading plans, rewriting queries, schema fixes with types, indexes, partitioning and archiving, configuration and maintenance with VACUUM and ANALYZE, percentile-based monitoring and alerting, caching, scaling and capacity planning, and case studies of a DBA's routine.

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

  • পারফরম্যান্স সমস্যার পাঁচ ধাপের পদ্ধতি, আর একটি অনুরোধের সময় কোথায় কোথায় খরচ হয়
  • ধীর কোয়েরি খোঁজা, plan পড়ে সমস্যা ধরা, আর লাইভ SQL-এ rewrite করে আগে-পরে plan মেলানো
  • Index, partitioning, আর্কাইভ, memory আর connection সেটিং, VACUUM ও ANALYZE — কোনটি কখন
  • p95/p99 latency আর cache hit ratio নিজের হাতে SQL দিয়ে হিসাব করা, monitoring আর alert
  • Cache, read replica, sharding, capacity planning, আর DBA-র দৈনিক ও সাপ্তাহিক চেকলিস্ট

১ · অনুমান নয়, পদ্ধতি — মাপুন, খুঁজুন, একটি বদলান, আবার মাপুন

“ডেটাবেস ধীর” — এই অভিযোগ দিয়েই সাধারণত শুরু। তারপর কেউ বলেন RAM বাড়াতে, কেউ বলেন সব কলামে index দিতে, কেউ বলেন MongoDB-তে চলে যাই। এগুলো অনুমান। Performance tuning মানে অনুমান নয়, একটি বৈজ্ঞানিক পদ্ধতি: আগে মাপা, তারপর সবচেয়ে বড় বাধাটি — bottleneck — খুঁজে বের করা, তারপর শুধু সেটিই সারানো।

ঢাকার একটি পোশাক কারখানার লাইনে প্রতিদিন ১,০০০ শার্ট হওয়ার কথা, হচ্ছে ৬০০. কাটিং, সেলাই, বোতাম, ইস্ত্রি, প্যাকিং — পাঁচটি টেবিল। সব টেবিলে লোক বাড়ালে খরচ অনেক, অথচ উৎপাদন বাড়বে শুধু বোতামের টেবিলে — সেখানেই কাপড় জমে আছে। আগে দেখুন কোথায় স্তূপ জমছে, তারপর শুধু সেখানে হাত দিন। আর বোতামের টেবিল দ্রুত হলে পরের দিন স্তূপ জমবে ইস্ত্রিতে — নতুন bottleneck. ডেটাবেসেও ঠিক এই নিয়ম।

একটি নিয়ম বিশেষভাবে গুরুত্বপূর্ণ: একবারে একটি জিনিস বদলান। একসাথে index, কনফিগারেশন আর কোয়েরি বদলালে গতি বাড়লেও জানবেন না কোনটি কাজ করেছে — আর কোনটি চুপচাপ ক্ষতি করছে। আরেকটি নিয়ম: শুরুর আগেই লক্ষ্য লিখুন, যেমন “অর্ডারের পাতার p95 ৩০০ ms-এর নিচে”. লক্ষ্য না থাকলে tuning কখনো শেষ হয় না। নিচে একটি বাস্তবধর্মী উদাহরণে চক্রটি দেখুন (সংখ্যাগুলো ব্যাখ্যার জন্য ধরে নেওয়া):

  1. মাপাbaseline
  2. বাধা খোঁজাসময় কোথায়
  3. অনুমানলিখে রাখা
  4. একটি বদলশুধু একটি
  5. আবার মাপাআগে বনাম পরে
“আমার অর্ডার” API, লক্ষ্য p95 < 300 msলাল = এই ধাপের মূল তথ্য

            
  1. মাপা। আগে baseline: এক সপ্তাহ ধরে একই ধরনের লোডে latency-র percentile (অংশ ৭). p50 চলনসই, কিন্তু p95 লক্ষ্যের প্রায় পাঁচ গুণ। DB সার্ভারের CPU আর ডিস্ক শান্ত — তাই “বড় সার্ভার কিনুন” এখানে ভুল উত্তর হতো।
  2. বাধা খোঁজা। একটি ধীর অনুরোধ ভেঙে দেখা হলো। ২০১টি কোয়েরি, প্রতিটি দ্রুত; কিন্তু প্রতিটির জন্য network-এ একটি যাওয়া-আসা, আর ২০১ × ২ ms = ৪০২ ms শুধু পথে। কোনো একটি কোয়েরি দোষী নয় — দোষী তাদের সংখ্যা।
  3. অনুমান লেখা। অনুমানটি এমনভাবে লেখা, যাতে ভুল হলে ধরা যায়: কোয়েরি কতটা কমবে, latency কতটা কমার কথা। লিখে না রাখলে পরে যেকোনো ফলকেই “আমি জানতাম” বলা সহজ।
  4. একটি বদল। শুধু ORM-এর একটি লাইন — item-গুলো এক কোয়েরিতে আনা। index বা কনফিগারেশন একই রাখা হয়েছে, যাতে উন্নতির কারণ নিয়ে সন্দেহ না থাকে। আগে staging-এ নিশ্চিত করা হলো উত্তর হুবহু একই।
  5. আবার মাপা। একই সময়ের লোডে p95 ২৯০ ms — অনুমান সত্য, লক্ষ্য পূরণ। পরের বড় খরচ এখন ডেটাবেসে নয়, অ্যাপে। লক্ষ্য পূরণ হলে থেমে যাওয়া পদ্ধতির অংশ; নইলে চক্র আবার প্রথম ধাপ থেকে।

২ · সময় কোথায় যায় — CPU, I/O, অপেক্ষা, network আর অ্যাপ

একটি অনুরোধের সময়কে কয়েকটি বাক্সে ভাগ করা যায়। প্রতিটি বাক্সের লক্ষণ আর মাপার উপায় আলাদা:

কোথায়সাধারণ কারণলক্ষণ
CPUবিশাল sort, hash, অনেক সারিতে ফাংশন, খারাপ plan-এ লাখো সারি ঘাঁটাDB সার্ভারের CPU প্রায় পূর্ণ, ডিস্ক শান্ত
ডিস্ক I/Ofull scan, ডেটা buffer pool-এ না ধরা, sort ডিস্কে উপচে পড়াread IOPS বেশি, cache hit ratio কমে যাওয়া
Lock আর অপেক্ষালম্বা transaction, একটি hot row-এ ভিড়, deadlock (পর্ব ৪৬)CPU কম অথচ সময় বেশি; wait event-এ lock
Network round tripঅসংখ্য ছোট কোয়েরি, দূরের সার্ভার, বিশাল ফল পাঠানোপ্রতিটি কোয়েরি দ্রুত, অনুরোধ ধীর
অ্যাপ নিজেN+1 কোয়েরি, connection-এর জন্য অপেক্ষা, ফল নিয়ে ধীর প্রক্রিয়াDB-র মাপ ভালো, ব্যবহারকারী তবু অপেক্ষায়

শেষ সারিটি সবচেয়ে বেশি উপেক্ষিত। N+1 মানে: একটি কোয়েরিতে N টি সারির তালিকা, তারপর প্রতিটি সারির জন্য আলাদা আরেকটি কোয়েরি — মোট N + 1টি। ORM (পর্ব ৫০) সম্পর্কিত ডেটা অলসভাবে (lazy) আনলে এটি নিঃশব্দে ঘটে। প্রতিটি কোয়েরি একটি round trip: অ্যাপ থেকে সার্ভার, পার্স, চালানো, ফল ফেরত। নিচের Python প্রোগ্রামটি (stdlib sqlite3) সময় না মেপে কতগুলো statement পাঠানো হলো গোনে — set_trace_callback প্রতিটি statement-এ একবার ডাকা হয়। ফল সবসময় একই, তাই তুলনা নির্ভরযোগ্য:

import sqlite3
con = sqlite3.connect(":memory:")
con.executescript("CREATE TABLE cust (id INTEGER PRIMARY KEY);"
                  "CREATE TABLE ord (id INTEGER PRIMARY KEY, cust_id INTEGER, amount INTEGER);"
                  "CREATE INDEX ord_cust ON ord(cust_id);")
con.executemany("INSERT INTO cust VALUES (?)", [(i,) for i in range(1, 101)])
con.executemany("INSERT INTO ord VALUES (?, ?, ?)", [(i, 1 + i % 100, 100 + i * 37 % 900) for i in range(1, 1001)])
sent = []                                   # every SQL statement the app sends
con.set_trace_callback(sent.append)
def n_plus_one():                           # 1 query for the list + 1 per customer
    ids = [r[0] for r in con.execute("SELECT id FROM cust ORDER BY id LIMIT 50")]
    return sum(con.execute("SELECT SUM(amount) FROM ord WHERE cust_id = ?", (c,)).fetchone()[0] for c in ids)
def one_join():                             # the database does the loop
    return con.execute("SELECT SUM(o.amount) FROM (SELECT id FROM cust ORDER BY id LIMIT 50) c "
                       "JOIN ord o ON o.cust_id = c.id").fetchone()[0]
for f in (n_plus_one, one_join):
    sent.clear(); total = f()
    print(f"{f.__name__:10}: {len(sent):3} statements, total = {total}, "
          f"at 2 ms network per trip = {2 * len(sent)} ms")

আউটপুট:

n_plus_one:  51 statements, total = 273850, at 2 ms network per trip = 102 ms
one_join  :   1 statements, total = 273850, at 2 ms network per trip = 2 ms

উত্তর হুবহু এক (২,৭৩,৮৫০ টাকা), কিন্তু প্রথম রূপে ৫১টি statement, দ্বিতীয়তে একটি। এখানে ডেটাবেস একই প্রক্রিয়ার ভেতরে, তাই network নেই; আসল সার্ভারে প্রতি যাওয়া-আসায় ধরুন ২ ms হলে প্রথমটি শুধু পথেই ১০২ ms খরচ করে — আর গ্রাহক ৫০ থেকে ৫০০ হলে ১ সেকেন্ডের বেশি। সমাধান দুই রকম: JOIN-এ ডেটাবেসকে loop করতে দেওয়া, অথবা একটি WHERE cust_id IN (…) কোয়েরিতে সবার ডেটা একসাথে আনা (ORM-এর eager loading). একই নীতি লেখার ক্ষেত্রেও: হাজারটি INSERT আলাদা transaction-এ না পাঠিয়ে একটি transaction-এ batch করে পাঠালে round trip আর commit দুটিই কমে (পর্ব ৪৯)।

৩ · ধীর কোয়েরি খুঁজে বের করা আর plan পড়া

ডেটাবেসের ভেতরে সময় কোথায় যায় জানতে প্রতিটি বড় DBMS-এর নিজস্ব হাতিয়ার আছে। এগুলো এখানে চালানো যায় না; আকৃতিটি দেখুন:

-- postgresql.conf-এ: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION pg_stat_statements;
SELECT calls, round(total_exec_time) AS total_ms,
       round(mean_exec_time, 1) AS mean_ms, rows,
       left(query, 60) AS query
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 10;

pg_stat_statements একই আকৃতির কোয়েরিগুলোকে (মান বাদ দিয়ে) এক সারিতে জমা করে: কতবার, মোট আর গড় কত সময়। কলামের নাম PostgreSQL 13 থেকে এই রকম; আগে ছিল total_time. আলাদা করে প্রতিটি ধীর কোয়েরি log-এ লিখতে log_min_duration_statement (যেমন 500 ms).

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;          -- সেকেন্ডে; ডিফল্ট 10
SELECT COUNT_STAR AS calls,
       ROUND(SUM_TIMER_WAIT / 1e12, 1) AS total_s,
       LEFT(DIGEST_TEXT, 60) AS query
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;

Slow query log সীমার বেশি সময় নেওয়া প্রতিটি কোয়েরি ফাইলে লেখে; mysqldumpslow বা Percona-র pt-query-digest সেগুলো জমা করে। Performance Schema-র digest টেবিল pg_stat_statements-এর মতো; সময় থাকে picosecond-এ, তাই 1012 দিয়ে ভাগ।

sqlite> .timer on
sqlite> .eqp on
sqlite> .scanstats on

.timer প্রতিটি কোয়েরির সময়, .eqp plan আর নতুন সংস্করণের .scanstats প্রতিটি loop-এ কত সারি পড়া হলো দেখায়। SQLite অ্যাপের ভেতরে চলে, তাই কেন্দ্রীয় log নেই; অ্যাপে প্রতিটি কোয়েরির সময় মেপে log করাই সাধারণ পথ।

তালিকা সাজান মোট সময় (বার × গড়) ধরে, শুধু গড় দিয়ে নয়। মাসে একবার চলা ২০ সেকেন্ডের রিপোর্টের চেয়ে দিনে পাঁচ লাখবার চলা ৪ ms-এর কোয়েরি সার্ভারের অনেক বেশি সময় খায় (৫,০০,০০০ × ৪ ms = ২,০০০ সেকেন্ড, প্রতিদিন)।

Plan পড়ে সমস্যা ধরা — তিনটি পরিচিত চেহারা

ধীর কোয়েরি পেলে পরের কাজ plan পড়া — পর্ব ৪১-এ EXPLAIN আর পর্ব ৪৩-এ optimizer-এর অনুমান দেখেছেন। tuning-এ বারবার আসে তিনটি চেহারা:

বড় টেবিলে full scan

অল্প কয়েকটি সারির জন্য পুরো টেবিল পড়া: SQLite-এ SCAN, PostgreSQL-এ Seq Scan সাথে বিশাল Rows Removed by Filter. কারণ: index নেই, অথবা শর্তটি index ব্যবহার করতে দেয় না (অংশ ৪)।

ভুল অনুমানে ভুল join order

EXPLAIN ANALYZE-এ rows=10 অনুমান, আসলে ১০ লাখ — nested loop লাখো বার চলে। প্রতিকার আগে statistics (ANALYZE), তারপর কোয়েরি (পর্ব ৪৩)।

ডিস্কে উপচে পড়া sort

PostgreSQL-এ Sort Method: external merge Disk: …: sort-এর মেমরি (work_mem) কম পড়ায় ডিস্কে লেখা spill. index-এর ক্রম দিয়ে sort এড়ানো বা মেমরি বাড়ানো (অংশ ৬)।

এবার লাইভ। এই পর্বের বেশিরভাগ এডিটর দুটি তৈরি-করা টেবিলে চলে, যা প্রতিবার recursive CTE দিয়ে নতুন করে বানানো হয় (নমুনা ডেটার বাক্সে স্ক্রিপ্টটি দেখতে পাবেন)। cust-এ ২,০০০ গ্রাহক: id, name, city (Dhaka, Chattogram, Khulna, Sylhet — সমান ভাগে)। ord-এ ২০,০০০ অর্ডার: id, cust_id (প্রতি গ্রাহকের ঠিক ১০টি), created (২০২৫ সালের তারিখ, দিনে প্রায় ৫৫টি), status (delivered ৯০%, pending ৮%, returned ২%) আর amount (১০০ থেকে ৪,৯৯৯ টাকা)। প্রথমে একটি ধীর কোয়েরি, তার plan, তারপর একটি index:

-- গ্রাহকের সর্বশেষ ৫টি অর্ডার: আগে
EXPLAIN QUERY PLAN
SELECT id, created, amount FROM ord
WHERE cust_id = 77 ORDER BY created DESC LIMIT 5;
-- একটি পরিবর্তন: (cust_id, created) index
CREATE INDEX ord_cust_created ON ord(cust_id, created);
EXPLAIN QUERY PLAN
SELECT id, created, amount FROM ord
WHERE cust_id = 77 ORDER BY created DESC LIMIT 5;
SELECT id, created, amount FROM ord
WHERE cust_id = 77 ORDER BY created DESC LIMIT 5;
-- শুধু দরকারি কলাম চাইলে: টেবিলে যেতেই হয় না
EXPLAIN QUERY PLAN
SELECT id, created FROM ord WHERE cust_id = 77 ORDER BY created DESC LIMIT 5;

আগে: SCAN ord আর USE TEMP B-TREE FOR ORDER BY — ২০,০০০ সারি পড়ে ১০টি বাছা, তারপর আলাদা sort. পরে: SEARCH ord USING INDEX ord_cust_created (cust_id=?), আর sort-এর লাইনটি নেই — index-এ সারিগুলো আগে থেকেই created-এর ক্রমে, SQLite শুধু উল্টো দিক থেকে পড়ে। ফল পাঁচটি সারি: 19604 | 2025-12-24 | 248 থেকে 11604 | 2025-07-31 | 3148. শেষ plan: SEARCH ord USING COVERING INDEX ord_cust_created (cust_id=?) — amount না চাওয়ায় দরকারি সবকিছু (id হলো rowid, যা SQLite-এর প্রতিটি index entry-তে থাকে) index-এই আছে। এটিই SELECT * এড়ানোর কারিগরি কারণ: অপ্রয়োজনীয় কলাম চাইলে covering index অকেজো হয়, network-এ বেশি বাইট যায়, আর টেবিলে নতুন কলাম যোগ হলে অ্যাপের আচরণ বদলায়।

৪ · কোয়েরি নতুন করে লেখা — সবচেয়ে সস্তা সমাধান

কোয়েরি বদলানো সাধারণত সবচেয়ে কম ঝুঁকির সমাধান: সার্ভার বা schema ছোঁয়া লাগে না, আর প্রভাব শুধু সেই কোয়েরিতে। চারটি প্রচলিত rewrite.

ক. Sargable শর্ত — কলামকে ফাংশনে মুড়বেন না

একটি শর্ত sargable (Search ARGument ABLE) যখন index দিয়ে তার মান খোঁজা যায়: কলাম অপারেটর মান আকারে। কলামকে ফাংশন বা হিসাবে মুড়লে B+ tree-র ক্রম আর কাজে লাগে না — index-এ আছে created-এর মান, strftime(…, created)-এর মান নয়:

CREATE INDEX ord_created ON ord(created);
-- non-sargable: কলাম ফাংশনে মোড়া
EXPLAIN QUERY PLAN
SELECT COUNT(*), SUM(amount) FROM ord WHERE strftime('%Y-%m', created) = '2025-03';
-- sargable: একই প্রশ্ন, half-open range
EXPLAIN QUERY PLAN
SELECT COUNT(*), SUM(amount) FROM ord WHERE created >= '2025-03-01' AND created < '2025-04-01';
SELECT COUNT(*), SUM(amount) FROM ord WHERE strftime('%Y-%m', created) = '2025-03';
SELECT COUNT(*), SUM(amount) FROM ord WHERE created >= '2025-03-01' AND created < '2025-04-01';

প্রথম plan SCAN ord — index থাকা সত্ত্বেও ২০,০০০ সারির প্রতিটিতে strftime চালানো। দ্বিতীয়টি SEARCH ord USING INDEX ord_created (created>? AND created<?) — শুধু মার্চের অংশটুকু। দুটির ফলই 1699 | 4347429, তাই rewrite-টি সঠিক। Half-open পরিসর (>= শুরু, < পরের মাসের শুরু) মাসের দিন গোনা বা ২৩:৫৯:৫৯-এর ঝামেলা এড়ায়। একই ফাঁদ অন্য চেহারায়: WHERE amount * 1.15 > 5000 (লিখুন amount > 5000 / 1.15), WHERE LOWER(email) = ? (ইমেইল আগেই ছোট হাতে জমা রাখুন, বা expression index বানান), আর ভুল টাইপের তুলনা — অনেক DBMS-এ সংখ্যার কলামকে টেক্সটের সাথে মেলালে implicit conversion কলামের ওপর ঘটে আর index বাদ পড়ে।

খ. EXISTS, IN না JOIN?

প্রশ্ন: কোন গ্রাহকদের অন্তত একটি অর্ডার ফেরত এসেছে? তিনভাবে লেখা যায়, কিন্তু তিনটি একই প্রশ্নের উত্তর দেয় না:

CREATE INDEX ord_status ON ord(status, cust_id);
-- JOIN: যত অর্ডার তত সারি
SELECT COUNT(*) AS join_rows, COUNT(DISTINCT c.id) AS customers
FROM cust c JOIN ord o ON o.cust_id = c.id WHERE o.status = 'returned';
-- EXISTS আর IN: প্রতি গ্রাহক একবার
SELECT COUNT(*) AS exists_rows FROM cust c
WHERE EXISTS (SELECT 1 FROM ord o WHERE o.cust_id = c.id AND o.status = 'returned');
SELECT COUNT(*) AS in_rows FROM cust
WHERE id IN (SELECT cust_id FROM ord WHERE status = 'returned');
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM cust c
WHERE EXISTS (SELECT 1 FROM ord o WHERE o.cust_id = c.id AND o.status = 'returned');
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM cust
WHERE id IN (SELECT cust_id FROM ord WHERE status = 'returned');

JOIN দেয় 400 | 40: ৪০ জন গ্রাহকের প্রত্যেকের ১০টি ফেরত অর্ডার, তাই প্রতিজন ১০ বার। “দ্রুত করতে” AI বা কেউ IN-কে JOIN-এ বদলালে এভাবেই সারি গুণ হয়; পরে DISTINCT দিয়ে ঢাকতে গেলে আবার বাড়তি sort. EXISTS আর IN দুটিই 40 — এগুলো semi-join: মিল পেলেই থামে। EXISTS-এ SCAN c তারপর CORRELATED SCALAR SUBQUERY 1 আর SEARCH o USING COVERING INDEX ord_status (status=? AND cust_id=?) — ২,০০০ গ্রাহকের প্রত্যেকের জন্য একটি ছোট index-খোঁজ। IN-এ উল্টো: LIST SUBQUERY 1 আগে ৪০০টি cust_id একবারে বানায় (SEARCH ord USING COVERING INDEX ord_status (status=?)), তারপর SEARCH cust USING INTEGER PRIMARY KEY (rowid=?). কোনটি দ্রুত তা নির্ভর করে দুই দিকের আকারের ওপর; PostgreSQL-এর মতো optimizer দুটিকেই প্রায়ই একই semi-join plan-এ বদলায়। নিয়মটি তাই: আগে সঠিক অর্থের রূপটি বাছুন, তারপর plan দেখুন। NOT IN-এ সাবধান — subquery-তে একটি NULL থাকলে ফল খালি হয়ে যায় (পর্ব ১৬, ২১); “নেই” প্রশ্নে NOT EXISTS নিরাপদ।

গ. Pagination — OFFSET নয়, keyset

“পাতা ১,৫০১” দেখাতে LIMIT 10 OFFSET 15000 লিখলে ডেটাবেসকে প্রথম ১৫,০০০টি সারি ক্রমে পড়ে ফেলে দিতে হয় — পাতা যত গভীর, খরচ তত বেশি, O(offset + limit). Keyset pagination আগের পাতার শেষ key মনে রাখে আর সেখান থেকে index দিয়ে সরাসরি শুরু করে, O(log n + limit):

-- OFFSET: ১৫,০০০ সারি পড়ে ফেলে দিয়ে তারপর ১০টি
EXPLAIN QUERY PLAN
SELECT id, created, amount FROM ord ORDER BY id LIMIT 10 OFFSET 15000;
-- keyset: আগের পাতার শেষ id মনে রেখে
EXPLAIN QUERY PLAN
SELECT id, created, amount FROM ord WHERE id > 15000 ORDER BY id LIMIT 10;
SELECT id, created, amount FROM ord ORDER BY id LIMIT 3 OFFSET 15000;
SELECT id, created, amount FROM ord WHERE id > 15000 ORDER BY id LIMIT 3;

OFFSET-এর plan SCAN ord; keyset-এর SEARCH ord USING INTEGER PRIMARY KEY (rowid>?). ফল দুটিতেই একই: 15001 | 2025-10-01 | 1437, 15002 | 2025-10-01 | 1474, 15003 | 2025-10-01 | 1511. দুটি plan-এর শব্দ দেখে খরচের পার্থক্য বোঝা যায় না — SCAN-টি LIMIT পূর্ণ হলে থামে, কিন্তু থামার আগে ১৫,০১০টি সারি পেরোয়। নিচের Python প্রোগ্রাম সেই কাজ গোনে।

SQLite-এর set_progress_handler(f, 10) কোয়েরি চালানোর ভেতরের যন্ত্র (virtual machine) প্রতি ১০টি নির্দেশ পার হলে f ডাকে। ঘড়ি নয়, কাজের পরিমাণ মাপা — তাই প্রতিবার একই সংখ্যা:

import sqlite3
con = sqlite3.connect(":memory:")
con.execute("CREATE TABLE ord (id INTEGER PRIMARY KEY, amount INTEGER)")
con.executemany("INSERT INTO ord VALUES (?, ?)", [(i, i * 37 % 4900) for i in range(1, 100001)])
steps = 0
def tick():                       # SQLite calls this every 10 virtual-machine steps
    global steps; steps += 1
con.set_progress_handler(tick, 10)

def cost(sql, args):
    global steps; steps = 0
    rows = con.execute(sql, args).fetchall()
    return steps, rows[0][0]

print(" page | OFFSET work | keyset work | same first id")
for page in (1, 10, 100, 1000, 9000):
    skip = (page - 1) * 10        # ids are 1..100000, so the last id of page p-1 is skip
    a, first_a = cost("SELECT id FROM ord ORDER BY id LIMIT 10 OFFSET ?", (skip,))
    b, first_b = cost("SELECT id FROM ord WHERE id > ? ORDER BY id LIMIT 10", (skip,))
    print(f"{page:5} | {a:11} | {b:11} | {first_a == first_b}")

আউটপুট (একক = ১০টি VM নির্দেশ):

 page | OFFSET work | keyset work | same first id
    1 |           6 |           4 | True
   10 |          24 |           5 | True
  100 |         204 |           5 | True
 1000 |        2004 |           5 | True
 9000 |       18004 |           5 | True

OFFSET-এর কাজ পাতার সংখ্যার সাথে সরলরেখায় বাড়ে — ৯,০০০তম পাতায় প্রথম পাতার তিন হাজার গুণ; keyset-এর কাজ প্রায় স্থির। দাম আছে: keyset-এ “সরাসরি ৪৭৩ নম্বর পাতায় যাও” সহজ নয়, শুধু আগে-পরে চলা যায়; আর ক্রমের কলামটি অনন্য না হলে (যেমন created) অনন্য একটি কলাম জুড়তে হয়: WHERE (created, id) > (?, ?) ORDER BY created, id — row value তুলনা SQLite, PostgreSQL আর MySQL-এ চলে। সোশ্যাল মিডিয়ার feed বা লেনদেনের ইতিহাসের মতো অসীম scroll-এর তালিকায় keyset (cursor-ভিত্তিক) পদ্ধতিই প্রচলিত।

ঘ. Batching আর অপ্রয়োজনীয় কাজ বাদ

  • অনেক সারি একবারে: লাখো সারির UPDATE এক transaction-এ করলে লম্বা lock, বিশাল WAL আর replica-য় দেরি; এক-এক সারি আলাদা commit-এ করলে লাখো fsync. মাঝামাঝি: ধরুন ৫,০০০ সারির খণ্ডে, keyset দিয়ে এগিয়ে।
  • অস্তিত্ব যাচাইয়ে COUNT(*) নয়: EXISTS বা LIMIT 1 প্রথম মিলেই থামে।
  • একই হিসাব বারবার নয়: প্রতিটি পাতায় মোট বিক্রি নতুন করে না গুনে summary বা materialized view (অংশ ৮)।

৫ · Schema-র স্তরে সমাধান — টাইপ, index, partitioning আর আর্কাইভ

ঠিক data type. তারিখকে টেক্সটে '25/03/2025' আকারে রাখলে না ক্রমে সাজানো যায়, না range-এ খোঁজা (পর্ব ১৩)। ছোট টাইপ মানে প্রতি page-এ বেশি সারি, buffer pool-এ বেশি ডেটা: ১০ কোটি সারিতে BIGINT-এর বদলে INT কলামপ্রতি প্রায় ৪০০ MB বাঁচায় (১০৮ × ৪ বাইট); UUID টেক্সটে ৩৬ বাইট, নিজস্ব টাইপে ১৬. join-এর দুই দিকের টাইপ এক রাখুন, নইলে রূপান্তরে index বাদ পড়তে পারে।

Index — composite, covering আর partial. পর্ব ৪১-এর নিয়মগুলোর সাথে একটি শক্তিশালী কৌশল যোগ করুন: partial index, যা শুধু শর্ত-মানা সারির জন্য। “অপেক্ষমাণ অর্ডারের ড্যাশবোর্ড” মোট সারির ৮% দেখে; সব সারির index বানানোর দরকার নেই:

-- অপেক্ষমাণ অর্ডারের ড্যাশবোর্ড: শুধু pending সারির index
CREATE INDEX ord_pending ON ord(created) WHERE status = 'pending';
EXPLAIN QUERY PLAN
SELECT id, cust_id, created FROM ord
WHERE status = 'pending' AND created >= '2025-12-01' ORDER BY created;
SELECT COUNT(*) AS pending_dec FROM ord
WHERE status = 'pending' AND created >= '2025-12-01';
-- শর্ত মেলে না: partial index কাজে লাগে না
EXPLAIN QUERY PLAN
SELECT COUNT(*) FROM ord WHERE status = 'delivered' AND created >= '2025-12-01';

প্রথম plan SEARCH ord USING INDEX ord_pending (created>?), কোনো আলাদা sort নেই; ডিসেম্বরে অপেক্ষমাণ 136টি। index-এ মাত্র ১,৬০০টি entry, পুরো টেবিলের index-এর প্রায় বারো ভাগের এক ভাগ — লেখার খরচও তেমনি কম, কারণ delivered সারি বদলালে index ছোঁয়াই লাগে না। শেষ plan SCAN ord: optimizer শুধু তখনই partial index নেয়, যখন প্রমাণ করতে পারে কোয়েরির শর্ত index-এর শর্তকে ধারণ করে। PostgreSQL আর SQLite partial index সমর্থন করে; MySQL করে না, SQL Server-এ নাম “filtered index”.

বড় টেবিল তারিখ ধরে ভাগ — partitioning

কোটি সারির লেনদেনের টেবিলে বেশিরভাগ কোয়েরি শেষ কয়েক মাসের, আর পুরোনো ডেটা শুধু জমে থাকে। Partitioning একটি যৌক্তিক টেবিলকে কয়েকটি ভৌত টেবিলে ভাগ করে — যেমন প্রতি মাসে একটি। লাভ তিনটি: কোয়েরির শর্ত দেখে অপ্রাসঙ্গিক ভাগ বাদ দেওয়া (partition pruning), প্রতিটি ভাগের index ছোট, আর পুরোনো ডেটা সরানো মানে লাখো সারির DELETE নয়, একটি পুরো ভাগ বিচ্ছিন্ন করা। PostgreSQL 10 থেকে declarative partitioning (এখানে চালানো যায় না):

CREATE TABLE payments (
  id bigint, merchant_id int, paid_at timestamptz NOT NULL, amount numeric(12,2)
) PARTITION BY RANGE (paid_at);
CREATE TABLE payments_2025_11 PARTITION OF payments
  FOR VALUES FROM ('2025-11-01') TO ('2025-12-01');
CREATE TABLE payments_2025_12 PARTITION OF payments
  FOR VALUES FROM ('2025-12-01') TO ('2026-01-01');
CREATE INDEX ON payments (merchant_id, paid_at);   -- প্রতিটি ভাগে নিজে তৈরি হয়
-- WHERE paid_at >= '2025-12-10' → plan-এ শুধু payments_2025_12
ALTER TABLE payments DETACH PARTITION payments_2025_11;  -- আর্কাইভ: মুহূর্তে

SQLite-এ partitioning নেই, কিন্তু ধারণাটি UNION ALL view দিয়ে অনুকরণ করা যায় — পুরোনো দিনের MySQL আর PostgreSQL-এও (inheritance দিয়ে) এভাবেই করা হতো:

CREATE TABLE ord_h1 (id INTEGER PRIMARY KEY, cust_id INT, created TEXT
  CHECK (created < '2025-07-01'), status TEXT, amount INT);
CREATE TABLE ord_h2 (id INTEGER PRIMARY KEY, cust_id INT, created TEXT
  CHECK (created >= '2025-07-01'), status TEXT, amount INT);
INSERT INTO ord_h1 SELECT * FROM ord WHERE created < '2025-07-01';
INSERT INTO ord_h2 SELECT * FROM ord WHERE created >= '2025-07-01';
CREATE INDEX h1_created ON ord_h1(created);
CREATE INDEX h2_created ON ord_h2(created);
CREATE VIEW ord_all AS SELECT * FROM ord_h1 UNION ALL SELECT * FROM ord_h2;
EXPLAIN QUERY PLAN SELECT COUNT(*) FROM ord_all WHERE created >= '2025-12-01';
SELECT COUNT(*) AS dec_orders FROM ord_all WHERE created >= '2025-12-01';
-- পুরোনো অর্ধেক আর্কাইভ: সারি ধরে DELETE নয়, পুরো টেবিল সরানো
DROP VIEW ord_all;
DROP TABLE ord_h1;
SELECT COUNT(*) AS remaining FROM ord_h2;

দুটি INSERT-এ 9918 আর 10082টি সারি; CHECK নিশ্চিত করে ভুল ভাগে কোনো সারি ঢুকবে না। plan-এ COMPOUND QUERY-এর দুই শাখায় SEARCH ord_h1 USING INDEX h1_created (created>?) আর SEARCH ord_h2 USING INDEX h2_created (created>?) — বাইরের WHERE শর্তটি view-এর প্রতিটি শাখায় ঠেলে দেওয়া হয়েছে (পর্ব ৪৩-এর predicate pushdown). ফল 1698. তবে খেয়াল করুন, SQLite CHECK দেখে ord_h1-কে বাদ দেয়নি — সেখানে index-খোঁজটি শূন্য সারি পায়, কয়েকটি page পড়েই। আসল partition pruning (PostgreSQL) সেই শাখা plan থেকেই সরিয়ে দেয়। শেষে DROP TABLE অর্ধেক ডেটা মুহূর্তে সরায়; 10082টি সারি থেকে যায়। একই কাজ DELETE … WHERE created < … দিয়ে করলে প্রায় দশ হাজার সারি একটি একটি করে মোছা, প্রতিটি index থেকেও, আর PostgreSQL-এ পরে VACUUM-এর বোঝা।

Partitioning জাদু নয়। যে কোয়েরির শর্তে partition key নেই (যেমন শুধু merchant_id), সেটি প্রতিটি ভাগে আলাদা খোঁজে — আগের চেয়েও ধীর হতে পারে; হাজারো ছোট ভাগ planning-এর সময় বাড়ায়। লাভ স্পষ্ট হয় বিশাল টেবিলে, যখন কোয়েরি আর আর্কাইভ দুটিই তারিখ ধরে চলে।

আর্কাইভ. partitioning না থাকলেও নীতিটি একই: চালু টেবিলে শুধু চালু ডেটা। দুই বছরের পুরোনো অর্ডার একটি আলাদা টেবিল, আলাদা সস্তা স্টোরেজ বা data warehouse-এ (পর্ব ৫৫) সরান — ছোট খণ্ডে, রাতে, আগে কপি নিশ্চিত করে তারপর মুছে। আইনগতভাবে কত দিন ডেটা রাখতে হয়, তা প্রতিষ্ঠানের নীতি থেকে জেনে নিন।

৬ · কনফিগারেশন আর রক্ষণাবেক্ষণ — memory, connection, VACUUM আর ANALYZE

কনফিগারেশনের মূল কথা

ডিফল্ট সেটিং বানানো হয় ছোট মেশিনেও নিরাপদে চালু হওয়ার জন্য, আপনার সার্ভারের জন্য নয়। তবে কনফিগারেশন কোয়েরি আর schema ঠিক করার পরে — খারাপ plan-কে কোনো সেটিং বাঁচায় না। নিচের সংখ্যাগুলো প্রচলিত শুরুর বিন্দু, নিয়ম নয়; প্রতিটি সংস্করণে ডিফল্ট বদলাতে পারে, তাই নিজের সংস্করণের নথি দেখুন আর নিজের workload-এ মেপে ঠিক করুন।

shared_buffers = 4GB            # ডিফল্ট 128MB; প্রচলিত শুরু RAM-এর ~25%
effective_cache_size = 12GB     # শুধু planner-এর অনুমান: OS cache সহ মোট কত
work_mem = 16MB                 # ডিফল্ট 4MB; প্রতিটি sort/hash-এর জন্য আলাদা!
maintenance_work_mem = 1GB      # VACUUM, CREATE INDEX-এর জন্য
max_connections = 200           # ডিফল্ট 100; বেশি নয় — pooler ব্যবহার করুন
checkpoint_timeout = 15min      # ডিফল্ট 5min
max_wal_size = 4GB              # ডিফল্ট 1GB; কম হলে ঘন ঘন checkpoint
random_page_cost = 1.1          # ডিফল্ট 4.0 (ঘোরানো ডিস্কের যুগের); SSD-তে কম
[mysqld]
innodb_buffer_pool_size = 10G   # ডিফল্ট 128M; নিবেদিত সার্ভারে প্রায়ই RAM-এর 50–75%
innodb_redo_log_capacity = 2G   # 8.0.30 থেকে; আগে innodb_log_file_size
max_connections = 300           # ডিফল্ট 151
slow_query_log = ON
long_query_time = 0.5
  • Memory: shared_buffers আর innodb_buffer_pool_size হলো পর্ব ৩৯-এর buffer pool. PostgreSQL OS-এর page cache-ও ব্যবহার করে, তাই তার ভাগ InnoDB-র চেয়ে কম রাখা প্রচলিত।
  • work_mem-এর গুণ: এটি প্রতিটি sort বা hash অপারেশনের সীমা, প্রতি connection-এর নয়। ২০০ connection × ৩টি sort × ১৬ MB = প্রায় ৯.৬ GB পর্যন্ত — খারাপ মুহূর্তে মেমরি ফুরিয়ে OS প্রক্রিয়া মেরে ফেলতে পারে। নির্দিষ্ট ভারী রিপোর্টের জন্য শুধু সেই session-এ বাড়ান: SET work_mem = '256MB'.
  • Connection আর pooling: PostgreSQL-এ প্রতিটি connection একটি আলাদা OS প্রক্রিয়া, তাই হাজারো connection নিজেই ভার। ২,০০০ জন একসাথে অ্যাপ খুললেও ডেটাবেসে কয়েক ডজন connection যথেষ্ট, যদি মাঝে একটি connection pool থাকে — অ্যাপের ভেতরে (HikariCP-এর মতো), বা আলাদা PgBouncer. pool-এর আকার প্রায়ই CPU core-এর কয়েক গুণের মধ্যে রাখা হয় (পর্ব ৪৯)।
  • Checkpoint: পর্ব ৪৮-এ দেখেছেন, checkpoint-এ dirty page ডিস্কে যায়। খুব ঘন ঘন হলে লেখার ঝাপটা আর বেশি full-page write; খুব দেরিতে হলে crash-এর পর recovery লম্বা। checkpoint_completion_target (নতুন সংস্করণে ডিফল্ট 0.9) লেখাকে সময়ে ছড়িয়ে দেয়।

রক্ষণাবেক্ষণ — VACUUM, bloat, ANALYZE আর index rebuild

ডেটাবেস একবার সাজিয়ে রেখে দিলে ধীরে ধীরে এলোমেলো হয়, ঠিক যেমন মুদি দোকানের খাতায় কাটাকাটি জমে। তিনটি নিয়মিত কাজ:

VACUUM আর bloat (PostgreSQL). পর্ব ৪৭-এর MVCC-তে UPDATE পুরোনো সারি মোছে না, নতুন সংস্করণ লেখে; DELETE শুধু চিহ্ন দেয়। পুরোনো সংস্করণগুলো dead tuple. VACUUM সেগুলোর জায়গা পুনর্ব্যবহারের জন্য মুক্ত করে (ফাইল সাধারণত ছোট করে না)। Autovacuum এটি নিজে চালায় — ডিফল্টে মোটামুটি টেবিলের ২০% সারি মরলে (autovacuum_vacuum_scale_factor = 0.2, সাথে ৫০ সারির threshold). বিশাল টেবিলে ২০% মানে কোটি সারি, তাই সেসব টেবিলে আলাদা করে ছোট scale factor দেওয়া প্রচলিত। সবচেয়ে বড় শত্রু লম্বা transaction: একটি ঘণ্টাব্যাপী খোলা transaction থাকলে VACUUM তার পরে মরা সারিও সরাতে পারে না, আর টেবিল ফুলতে থাকে — bloat. VACUUM বন্ধ রাখার আরেক বিপদ transaction ID wraparound, যা শেষ পর্যন্ত ডেটাবেসকে লেখা বন্ধ করতে বাধ্য করে।

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;
SELECT pid, now() - xact_start AS open_for, state, left(query, 40)
FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start LIMIT 5;
ALTER TABLE payments SET (autovacuum_vacuum_scale_factor = 0.02);

ফুলে যাওয়া টেবিল আসলে ছোট করতে VACUUM FULL পুরো টেবিল নতুন করে লেখে, কিন্তু পুরো সময় টেবিল আটকে রাখে (ACCESS EXCLUSIVE lock); চালু সিস্টেমে তাই pg_repack-এর মতো extension বেশি ব্যবহৃত হয়। MySQL InnoDB-তে purge থ্রেড পুরোনো সংস্করণ সরায়; জায়গা ফেরত পেতে OPTIMIZE TABLE টেবিল নতুন করে বানায়।

ANALYZE আর statistics. পর্ব ৪৩-এ দেখেছেন, ভুল statistics মানে ভুল plan. বড় আমদানি বা বিশাল DELETE-এর পর হাতে ANALYZE চালান; autovacuum বা InnoDB-র স্বয়ংক্রিয় হিসাব পরে আসবে, কিন্তু মাঝের সময়ে কোয়েরি পুরোনো ছবিতে চলে।

Index rebuild. বহু UPDATE আর DELETE-এর পর B+ tree-র page অর্ধেক খালি থাকতে পারে — index bloat. PostgreSQL 12 থেকে REINDEX INDEX CONCURRENTLY লেখা না থামিয়ে নতুন index বানায়; SQL Server-এ ALTER INDEX … REBUILD. তবে অন্ধভাবে নিয়মিত সব index rebuild নয় — আগে আকার আর bloat মাপুন। আর ব্যবহার না হওয়া index খুঁজে (PostgreSQL-এ pg_stat_user_indexes.idx_scan = 0, কয়েক সপ্তাহ ধরে) মুছে দেওয়াও রক্ষণাবেক্ষণ — প্রতিটি index প্রতিটি লেখায় দাম নেয়।

৭ · Monitoring আর alerting — গড় নয়, percentile

Monitoring মানে নিয়মিত মাপ জমিয়ে রাখা, যাতে সমস্যার আগে প্রবণতা আর সমস্যার সময় কারণ দেখা যায়। ডেটাবেসের জন্য মূল মাপগুলো:

মাপকী বলেalert-এর উদাহরণ (নিজের মতো ঠিক করুন)
QPS / TPSপ্রতি সেকেন্ডে কোয়েরি বা transaction — চাপ কতটাস্বাভাবিকের চেয়ে হঠাৎ অনেক বেশি বা শূন্য
Latency p50 / p95 / p99সাধারণ অভিজ্ঞতা আর লেজp95 টানা ১০ মিনিট লক্ষ্যের ওপরে
Cache hit ratioকত ভাগ page মেমরি থেকে এলস্বাভাবিক মাত্রা থেকে স্পষ্ট পতন
Lock wait, deadlockকে কার অপেক্ষায়৩০ সেকেন্ডের বেশি অপেক্ষমাণ lock
Replication lagreplica কত পিছিয়ে (পর্ব ৫২)লক্ষ্যের চেয়ে বেশি সেকেন্ড পিছিয়ে
ডিস্কের ব্যবহার আর বৃদ্ধিকবে ভরবে৮০% পার, বা এই হারে ১৪ দিনে ভরবে
Connectionmax-এর কত কাছেmax_connections-এর ৮০%

গড় latency কেন যথেষ্ট নয়? কারণ latency-র বণ্টন প্রায় কখনো সমান নয় — বেশিরভাগ অনুরোধ দ্রুত, অল্প কয়েকটি অনেক ধীর (lock, cache miss, checkpoint-এর মুহূর্ত)। Percentile বলে: p95 = ১২০ ms মানে ৯৫% অনুরোধ ১২০ ms বা তার কম সময়ে শেষ, আর ৫% তার বেশি নেয়। এই লেজটিকে বলে tail latency. নিচে ২০টি অনুরোধে হাতে হিসাব — nearest-rank পদ্ধতিতে: সাজানো তালিকায় p-তম percentile হলো ⌈p/100 × n⌉-তম মান।

একটি পাতার ২০টি অনুরোধ, msলাল = এই ধাপের উত্তর

            
হিসাব
ধাপ
rank
মান
  1. কাঁচা ডেটা। log-এ অনুরোধগুলো আসার ক্রমে জমা: বেশিরভাগ ৮ থেকে ২৫ ms, মাঝে দুটি বড় সংখ্যা — ২৪০ আর ৯০০. শুধু চোখে দেখে লেজটি ধরা কঠিন।
  2. সাজানো। percentile হিসাবের প্রথম কাজ সাজানো। এখন প্রতিটি মানের একটি rank: সবচেয়ে ছোট ৮ ms rank ১, সবচেয়ে বড় ৯০০ ms rank ২০.
  3. গড়। যোগফল ১,৩৮১, গড় ৬৯.০৫ ms. অথচ ২০টির ১৮টিই ২৫ ms বা কম — গড়টি প্রায় কারও অভিজ্ঞতার সাথে মেলে না। দুটি ধীর অনুরোধ একাই গড়কে প্রায় পাঁচ গুণ করেছে (তাদের বাদ দিলে গড় মাত্র ১৩.৪ ms-এর মতো)।
  4. p50. ⌈০.৫০ × ২০⌉ = ১০, তাই দশম মান ১৩ ms. অর্ধেক অনুরোধ এর মধ্যেই শেষ। এটি “সাধারণ” ব্যবহারকারীর ছবি, কিন্তু লেজ দেখায় না।
  5. p95. ⌈০.৯৫ × ২০⌉ = ১৯, তাই ২৪০ ms. প্রতি ২০ জনে একজন অন্তত এতটা অপেক্ষা করেন। দিনে লাখ অনুরোধে সেটি পাঁচ হাজার বিরক্ত মানুষ — এজন্যই লক্ষ্য সাধারণত p95 বা p99-এ লেখা হয়।
  6. p99. ⌈০.৯৯ × ২০⌉ = ⌈১৯.৮⌉ = ২০, অর্থাৎ সর্বোচ্চ মান ৯০০ ms. মাত্র ২০টি নমুনায় p99 আসলে “সবচেয়ে খারাপটি”; বিশ্বাসযোগ্য p99-এর জন্য শত শত বা হাজারো নমুনা লাগে। আর কয়েকটি সার্ভারের p95-এর গড় নিলে পুরো সিস্টেমের p95 পাওয়া যায় না।

এবার একই হিসাব SQL-এ, window function দিয়ে (পর্ব ২৬)। query_log টেবিলে ৩,০০০টি সারি: id, query (কোন ধরনের কোয়েরি — order_page, search, report) আর ms (সময়)। এটি pg_stat_statements বা অ্যাপের নিজস্ব log-এর মতো একটি তৈরি-করা log:

WITH ranked AS (
  SELECT query, ms,
         ROW_NUMBER() OVER (PARTITION BY query ORDER BY ms) AS rn,
         COUNT(*) OVER (PARTITION BY query) AS n
  FROM query_log)
SELECT query, MAX(n) AS calls, ROUND(AVG(ms), 1) AS avg_ms,
       MAX(CASE WHEN rn = (50 * n + 99) / 100 THEN ms END) AS p50,
       MAX(CASE WHEN rn = (95 * n + 99) / 100 THEN ms END) AS p95,
       MAX(CASE WHEN rn = (99 * n + 99) / 100 THEN ms END) AS p99,
       MAX(ms) AS max_ms, SUM(ms) AS total_ms
FROM ranked GROUP BY query ORDER BY total_ms DESC;

(95 * n + 99) / 100 হলো integer ভাগে ⌈0.95 × n⌉ — ceil ফাংশন ছাড়াই। ফল:

report     |  100 |   1385 | 1380 | 1920 | 1980 | 1990 | 138500
search     | 1500 |   49.6 |   49 |   77 |   79 |   79 |  74400
order_page | 1400 |   36.6 |   13 |   58 | 1088 | 1098 |  51230

তিনটি শিক্ষা। এক, order_page-এর গড় (৩৬.৬ ms) search-এর চেয়ে ভালো দেখায়, অথচ তার p99 ১,০৮৮ ms; search-এর লেজ নেই (p99 = ৭৯)। দুই, মোট সময়ে এক নম্বর report — মাত্র ১০০ বার চলে, কিন্তু সার্ভারের সময়ের অর্ধেকের বেশি খায়। তিন, p95 থেকে p99-এ লাফ (৫৮ → ১,০৮৮) বলে লেজটি আলাদা কারণ থেকে আসে — প্রায়ই lock-এর অপেক্ষা বা cache miss. PostgreSQL-এ একই কাজ সরাসরি: percentile_disc(0.95) WITHIN GROUP (ORDER BY ms).

Cache hit ratio — আর জমা হিসাবের ফাঁদ

PostgreSQL-এর pg_statio_user_tables প্রতিটি টেবিলের জন্য রাখে heap_blks_hit (buffer pool-এ পাওয়া page) আর heap_blks_read (বাইরে থেকে আনা)। hit ratio = hit / (hit + read). কিন্তু এই সংখ্যাগুলো সার্ভার চালুর (বা শেষ reset-এর) পর থেকে জমা — মাসের পুরোনো ভালো দিনগুলো আজকের খারাপ ঘণ্টাকে ঢেকে রাখে। তাই monitoring সিস্টেম নিয়মিত snapshot নেয়, আর দুই snapshot-এর পার্থক্য হিসাব করে। io_snap টেবিলে দুটি snapshot (১০টা আর ১১টা), তিনটি টেবিলের জমা সংখ্যা:

-- শুরু থেকে জমা হিসাব (cumulative)
SELECT relname, ROUND(100.0 * blks_hit / (blks_hit + blks_read), 2) AS hit_pct_total
FROM io_snap WHERE taken = '11:00' ORDER BY relname;
-- শেষ এক ঘণ্টার হিসাব: দুই snapshot-এর পার্থক্য
WITH d AS (
  SELECT taken, relname,
         blks_hit  - LAG(blks_hit)  OVER w AS hit,
         blks_read - LAG(blks_read) OVER w AS rd
  FROM io_snap WINDOW w AS (PARTITION BY relname ORDER BY taken))
SELECT relname, hit, rd, ROUND(100.0 * hit / (hit + rd), 2) AS hit_pct_last_hour
FROM d WHERE hit IS NOT NULL ORDER BY relname;
-- পুরো ডেটাবেসের এক ঘণ্টার অনুপাত
SELECT ROUND(100.0 * SUM(CASE WHEN taken = '11:00' THEN blks_hit ELSE -blks_hit END)
  / SUM(CASE WHEN taken = '11:00' THEN blks_hit + blks_read ELSE -(blks_hit + blks_read) END), 2) AS db_hit_pct
FROM io_snap;

জমা হিসাবে তিনটি টেবিলই চমৎকার: customers 99.9, order_items 99.01, orders 98.57. শেষ এক ঘণ্টার পার্থক্যে orders-এর ছবি আলাদা: 450000 | 50000 | 90 — প্রতি ১০টি page-এর একটি বাইরে থেকে এসেছে, আগের চেয়ে অনেক বেশি। পুরো ডেটাবেসের ঘণ্টার অনুপাত 94.61. জমা সংখ্যায় এই পতন প্রায় অদৃশ্য ছিল। সতর্কতা: “৯৯% হতেই হবে” — এমন সর্বজনীন সংখ্যা নেই; নিজের স্বাভাবিক মাত্রা থেকে বিচ্যুতিটাই সংকেত। আর PostgreSQL-এর “read” OS-এর page cache থেকেও আসতে পারে — সবসময় ডিস্ক নয়।

Alert-এর নিয়ম: প্রতিটি alert-এ কাউকে কিছু করতে হবে এমন হওয়া চাই, আর কারণের চেয়ে লক্ষণে (ব্যবহারকারী যা টের পান — latency, error) alert দেওয়া ভালো। অপ্রয়োজনীয় alert-এর ভিড়ে আসলটিও উপেক্ষিত হয়। Google-এর SRE বইয়ে (২০১৬) এর জন্য চারটি “golden signal” প্রস্তাব করা হয়েছে: latency, traffic, errors আর saturation.

৮ · Cache, scaling আর capacity planning

Cache-এর স্তর

সবচেয়ে দ্রুত কোয়েরি হলো যেটি চালাতেই হয় না। একটি অনুরোধ ডেটাবেসে পৌঁছানোর আগে কয়েকটি স্তরে cache থাকতে পারে:

HTTP / CDN

HTTP caching: পণ্যের তালিকার মতো সবার জন্য একই পাতা Cache-Control দিয়ে ব্রাউজার বা CDN-এ কয়েক মিনিট রাখা — অনুরোধ সার্ভার পর্যন্তই আসে না।

অ্যাপের cache / Redis

ঘন ঘন পড়া, কম বদলানো ডেটা (পণ্যের দাম, সেটিং, session) মেমরির key-value স্টোরে। Redis নিয়ে বিস্তারিত পর্ব ৫৮-এ।

Materialized view

ভারী aggregation-এর ফল ডেটাবেসের ভেতরেই জমা (পর্ব ২৪) — যেমন প্রতিদিনের বিক্রির সারাংশ, রাতে বা প্রতি ১৫ মিনিটে refresh.

ডেটাবেসের নিজের

Buffer pool আর OS page cache — এরা নিজে থেকেই কাজ করে; আপনার কাজ মেমরি যথেষ্ট রাখা আর full scan দিয়ে সেগুলো ভাসিয়ে না দেওয়া।

প্রতিটি cache একটি প্রশ্ন আনে: ডেটা বদলালে পুরোনো কপি কখন সরবে (cache invalidation)? পণ্যের ছবিতে পাঁচ মিনিট পুরোনো তথ্য চলে; ব্যাংকের ব্যালান্স বা স্টকে শেষ পিসটি — চলে না। তাই আগে ঠিক করুন কোন ডেটা কতটা পুরোনো হলে ক্ষতি নেই, তারপর cache.

Scale up না scale out?

  • Scale up (vertical): একই সার্ভারে বেশি CPU, RAM, দ্রুত SSD. কোড বদলাতে হয় না, consistency অক্ষুণ্ণ — কিন্তু একটি সীমা আছে, আর ওপরের দিকে দাম প্রায়ই দ্রুত বাড়ে। বেশিরভাগ প্রতিষ্ঠানের জন্য এটিই প্রথম, আর প্রায়ই যথেষ্ট, পদক্ষেপ।
  • Read replica: পর্ব ৫২-এর replication দিয়ে পড়ার কাজ কয়েকটি কপিতে ছড়ানো — রিপোর্ট, সার্চ, ড্যাশবোর্ড. লেখা তখনো একটি primary-তে। সাবধানতা: replica একটু পিছিয়ে থাকতে পারে, তাই “এইমাত্র যা লিখলাম তা দেখাও” পাতা primary থেকে পড়ুন।
  • Sharding (horizontal): ডেটাকেই কয়েকটি সার্ভারে ভাগ — যেমন merchant_id অনুযায়ী। লেখার ক্ষমতাও বাড়ে, কিন্তু বহু-shard join, transaction আর rebalancing কঠিন হয়। বিস্তারিত পর্ব ৫৪-এ; নিয়ম হিসেবে, সহজ পথগুলো শেষ হলে তবেই।

Capacity planning — আগামী বছরের হিসাব আজ

Capacity planning মানে বৃদ্ধির হার মেপে আগেই বলা কবে কোন সম্পদ ফুরোবে। ডিস্কের হিসাব সরল: ১ TB ডিস্কে ৪০০ GB ব্যবহৃত, মাসে ২৫ GB বাড়ছে, alert ৮০%-এ (৮০০ GB) — (৮০০ − ৪০০) / ২৫ = ১৬ মাস। index, WAL আর বড় migration-এর অস্থায়ী জায়গাও হিসাবে রাখুন। Concurrency-র হিসাবে কাজে লাগে Little's law: গড়ে একসাথে চলা কাজ L = λ × W, যেখানে λ আগমনের হার আর W গড় সময়। প্রতি সেকেন্ডে ২,০০০টি কোয়েরি, প্রতিটি গড়ে ৫ ms হলে L = ২,০০০ × ০.০০৫ = ১০ — গড়ে মাত্র ১০টি কোয়েরি একসাথে চলে। তাই কয়েক ডজনের pool-ই যথেষ্ট; কিন্তু W ৫ থেকে ৫০ ms হলে L হয় ১০০ — pool ভরে যায়, অনুরোধ লাইনে দাঁড়ায়। ধীর কোয়েরি সবার জায়গা দখল করে।

৯ · কেস স্টাডি আর DBA-র রুটিন

এবার পুরো পদ্ধতি একসাথে — একটি অনলাইন দোকানে মাসের শেষে কী ঘটতে পারে (PostgreSQL; ঘটনাটি কাল্পনিক, সংখ্যাগুলো ব্যাখ্যার জন্য ধরে নেওয়া)। লক্ষ করুন, লক্ষণ যেখানে, কারণ সেখানে নয়:

অনলাইন দোকান, orders-এ ৩ কোটি সারিলাল = এই ধাপের সূত্র

            
অবস্থা
ধাপ
checkout p95
cache hit
সন্দেহভাজন
  1. লক্ষণ। শুধু মাসের শেষ দিনের সন্ধ্যায় checkout-এর p95 আট গুণ। error নেই, শুধু ধীর। “শুধু নির্দিষ্ট সময়ে” — এটিই প্রথম সূত্র: সেই সময়ে কী আলাদা ঘটে?
  2. মাপ। CPU আর lock স্বাভাবিক, কিন্তু ডিস্ক পড়া পাঁচ গুণ আর cache hit ratio ৯৯.৪% থেকে ৯১%. মানে গরম ডেটা আর মেমরিতে থাকছে না। connection বাড়ছে, কারণ প্রতিটি কাজ বেশি সময় নিচ্ছে (Little's law).
  3. খোঁজ। মোট সময় ধরে সাজালে এক নম্বর checkout নয় — মাসিক রিপোর্ট, মাত্র ৪০ বার চলেছে কিন্তু প্রতিবার ৩৮ সেকেন্ড। ম্যানেজাররা মাসের শেষেই এটি খোলেন, তাই অন্য দিনে সমস্যা নেই।
  4. অনুমান। plan বলছে ৩ কোটি সারির Seq Scan, কারণ শর্তে created ফাংশনে মোড়া। অনুমান: প্রতিটি রিপোর্ট পুরো টেবিল পড়ে buffer pool ভাসিয়ে দেয়, আর checkout-এর দরকারি page ডিস্ক থেকে আনতে হয়।
  5. একটি বদল। শর্তটিকে half-open range-এ লেখা — index আগে থেকেই ছিল, শুধু ব্যবহার হচ্ছিল না। staging-এ আসল মাপের কপিতে যাচাই: ফল একই, plan এখন Index Scan.
  6. যাচাই। পরের মাসের শেষে: রিপোর্ট ০.৪ সেকেন্ড, cache hit ৯৯.৩%, checkout-এর p95 ৩১০ ms — অথচ checkout-এর কোড বা সার্ভার কিছুই বদলায়নি। অনুমান সত্য।
  7. শিক্ষা। লক্ষণ এক জায়গায়, কারণ আরেক জায়গায় — ডেটাবেসে সবাই একই মেমরি, ডিস্ক আর connection ভাগ করে। পরের ধাপগুলো প্রতিরোধের: রিপোর্ট replica-তে, সারাংশ materialized view-এ, cache hit-এর পতনে alert, আর code review-এ নিয়ম।

DBA-র দৈনিক আর সাপ্তাহিক চেকলিস্ট

DBA (database administrator)-এর কাজের বড় অংশ নাটকীয় নয় — নিয়মিত, একঘেয়ে, আর সেজন্যই জরুরি। একটি প্রচলিত তালিকা:

কখনকাজকেন
প্রতিদিনরাতের backup সফল হয়েছে কি না, আকার স্বাভাবিক কি নাব্যর্থ backup কেউ টের পায় না, যতক্ষণ না দরকার পড়ে
প্রতিদিনডিস্কের জায়গা আর বৃদ্ধির হার; WAL/binlog জমছে কি নাডিস্ক ভরলে ডেটাবেস লেখা বন্ধ করে
প্রতিদিনধীর কোয়েরির তালিকা, নতুন কিছু ওপরে এল কি নানতুন release প্রায়ই নতুন ধীর কোয়েরি আনে
প্রতিদিনব্যর্থ job (ETL, VACUUM, report), replication lag, error logনীরব ব্যর্থতা ধরা
প্রতি সপ্তাহেbackup থেকে আসলে restore করে দেখা (পর্ব ৫২)যাচাই না করা backup আসলে একটি আশা
প্রতি সপ্তাহেনিরাপত্তা আপডেট আর patch-এর পরিকল্পনাজানা দুর্বলতা খোলা রাখা চলে না
প্রতি মাসেকার কী অনুমতি — চলে যাওয়া কর্মী, অপ্রয়োজনীয় superuser (পর্ব ৫১)least privilege টিকিয়ে রাখা
প্রতি মাসেcapacity-র প্রবণতা, অব্যবহৃত index, bloatসমস্যা আসার আগে দেখা

DBA, developer আর cloud — কার কী দায়

Developer লেখেন কোয়েরি আর migration, আর তাঁর কোড থেকেই আসে বেশিরভাগ ধীর কোয়েরি; তাই plan পড়া আর N+1 চেনা তাঁরই দায়। DBA দেখেন পুরো সিস্টেম: কনফিগারেশন, backup ও recovery, replication, নিরাপত্তা, capacity আর সব দলের সম্মিলিত ভার। ভালো দলে দুইয়ের মাঝে দেয়াল নেই — বড় migration-এর আগে DBA-র পর্যালোচনা, developer-দের হাতে monitoring-এর ড্যাশবোর্ড।

Cloud-এর managed database (Amazon RDS, Google Cloud SQL, Azure Database ইত্যাদি; পর্ব ৬১) হার্ডওয়্যার, patch, স্বয়ংক্রিয় backup, replica আর failover-এর কাজ নিয়ে নেয়। নেয় না: schema আর index ডিজাইন, ধীর কোয়েরি, সঠিক কনফিগারেশন, অনুমতি, restore-এর পরীক্ষা — আর বিল। cloud-এ খারাপ কোয়েরি সরাসরি মাসিক খরচে দেখা দেয়। তাই ভূমিকাটি সরে যাচ্ছে স্বয়ংক্রিয়তা, পরিমাপ আর ডিজাইন-পরামর্শের দিকে।

আরও গভীরে: “সময় কোথায় যায়” প্রশ্নের সবচেয়ে নির্ভুল উত্তর দেয় wait-event বিশ্লেষণ: প্রতিটি session প্রতি মুহূর্তে হয় CPU-তে কাজ করছে, নয়তো কোনো নির্দিষ্ট কিছুর অপেক্ষায় (ডিস্ক পড়া, lock, WAL লেখা, client-এর পরের কোয়েরি)। Oracle-এর wait interface আর AWR বহু বছর ধরে এই পদ্ধতির ভিত্তি; PostgreSQL-এর pg_stat_activity.wait_event নিয়মিত নমুনা নিয়ে, আর AWS-এর Performance Insights-এর মতো সেবা একই নীতিতে “গড় সক্রিয় session” ভাগ করে দেখায়। Brendan Gregg-এর USE পদ্ধতি (প্রতিটি সম্পদের Utilization, Saturation, Errors) একই প্রশ্ন হার্ডওয়্যারের দিক থেকে করে।

১০ · AI-এর যুগে performance tuning

“এই কোয়েরি দ্রুত করো” বা “আমার PostgreSQL-এর সেরা কনফিগ দাও” — AI মুহূর্তে উত্তর দেয়। কিন্তু সে আপনার মাপ, plan, ডেটার আকার বা হার্ডওয়্যার দেখেনি। সাধারণ ভুল:

AI-এর ভুলফলকীভাবে ধরবেন
মাপ ছাড়াই একগাদা পরামর্শ একসাথেকোনটি কাজ করল জানা যায় নাএকটি করে প্রয়োগ, আগে-পরে মাপ
“work_mem 1GB করুন” ধরনের বিশাল কনফিগবহু connection-এ মেমরি ফুরিয়ে crashconnection × অপারেশন × মান হিসাব করুন
IN-কে JOIN-এ বদলানোduplicate সারি, ভুল যোগফলআগে-পরে COUNT(*) আর EXCEPT দিয়ে ফল মেলান
OFFSET-কে keyset-এ বদলানো, অনন্য tiebreaker ছাড়াপাতার সীমায় সারি বাদ বা দ্বিগুণএকই মানের বহু সারি দিয়ে পরীক্ষা
অন্য সংস্করণের সেটিং (যেমন সরানো MySQL query cache)সার্ভার চালুই হয় না বা নীরবে উপেক্ষানিজের সংস্করণের নথি দেখুন
অনুমিত কলাম, index বা view-এর নামঅচল বা ভুল কোডআসল schema আর plan নিজে দিন

AI-কে মাপ দিন, অনুমানের জায়গা নয়:

আমার ডেটাবেস PostgreSQL 16, সার্ভার ১৬ GB RAM, ৮ core, SSD. সমস্যা: "আমার অর্ডার" পাতার p95 1.4 সেকেন্ড, লক্ষ্য 300 ms. p50 380 ms. pg_stat_statements-এর প্রথম ৫টি সারি (মোট সময় ধরে) আর সবচেয়ে ধীর কোয়েরির EXPLAIN (ANALYZE, BUFFERS) নিচে দিলাম; schema আর index-তালিকাও। ১. সময় কোথায় যাচ্ছে — CPU, I/O, lock, নাকি কোয়েরির সংখ্যা (N+1) — মাপ থেকে যুক্তি দেখিয়ে বলো। ২. সবচেয়ে কম ঝুঁকির একটি পরিবর্তন আগে প্রস্তাব করো, আর কোন মাপে বোঝা যাবে সেটি কাজ করেছে। ৩. কোয়েরি নতুন করে লিখলে ফল একই থাকার যুক্তি দাও — NULL আর duplicate মাথায় রেখে। ৪. কনফিগ বদলের কথা বললে মেমরির সবচেয়ে খারাপ হিসাবটি দেখাও। ৫. কোনো কলাম বা index-এর নাম অনুমান করবে না; না জানলে জিজ্ঞেস করো।

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

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

  • একটি কোয়েরি ধীর — কীভাবে এগোবেন? — মাপুন, slow log বা pg_stat_statements থেকে নিশ্চিত হন, EXPLAIN ANALYZE পড়ুন, একটি পরিবর্তন করুন, আবার মাপুন।
  • Sargable মানে কী? — যে শর্ত index দিয়ে খোঁজা যায়: কলাম ফাংশন বা হিসাবে মোড়া নয়, যেমন range-এ লেখা তারিখের শর্ত।
  • OFFSET pagination কেন ধীর? — বাদ দেওয়া সব সারি পড়তে হয়, O(offset + limit); keyset শেষ key থেকে index দিয়ে শুরু করে।
  • N+1 সমস্যা কী? — তালিকার জন্য একটি আর প্রতিটি সারির জন্য আরেকটি কোয়েরি; JOIN বা IN দিয়ে একবারে আনা সমাধান।
  • গড়ের বদলে p95/p99 কেন? — latency-র বণ্টন অসমান; গড় লেজ লুকায়, percentile বলে কত ভাগ ব্যবহারকারী কতটা অপেক্ষা করেন।

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

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

প্র ০১Donald Knuth-এর বিখ্যাত বাক্য “premature optimization is the root of all evil” প্রায়ই উদ্ধৃত হয়। ডেটাবেসের ক্ষেত্রে কথাটি কতটা খাটে — কোন সিদ্ধান্তগুলো পরে বদলানো এত কঠিন যে শুরুতেই ভাবা দরকার?

বাক্যটি এসেছে Knuth-এর ১৯৭৪ সালের প্রবন্ধ “Structured Programming with go to Statements” থেকে, আর পূর্ণ উদ্ধৃতিটি প্রায়ই কেটে ফেলা হয়। তিনি বলেছিলেন, ছোট দক্ষতার কথা প্রায় ৯৭% সময় ভুলে যাওয়া উচিত — কিন্তু বাকি গুরুত্বপূর্ণ ৩% সুযোগ হাতছাড়া করা উচিত নয়, আর সেই ৩% খুঁজতে হবে মেপে। অর্থাৎ কথাটি “performance নিয়ে ভাবো না” নয়, “অনুমানে নয়, মেপে ভাবো”.

ডেটাবেসে কথাটি দুই ভাগে খাটে। কোয়েরির ভেতরের ছোট কৌশল, কনফিগারেশনের সূক্ষ্ম মান, এমনকি বেশিরভাগ index — এগুলো পরে মেপে যোগ করা যায়, আর আগে করলে প্রায়ই ভুল জায়গায় শ্রম যায়। কোন কোয়েরি আসলে বেশি চলবে, তা চালু হওয়ার আগে কেউ নিশ্চিত জানে না।

কিন্তু কিছু সিদ্ধান্ত ডেটার সাথে জমে যায়, আর কোটি সারি হওয়ার পর সেগুলো বদলানো মানে দীর্ঘ migration, downtime-এর ঝুঁকি আর অ্যাপের বহু জায়গায় পরিবর্তন। primary key-র ধরন (এলোমেলো UUID বনাম ক্রমিক সংখ্যা — B+ tree-তে লেখার ধাঁচ আলাদা), টাকা আর তারিখের data type, টেবিলের গঠন আর normalization-এর মাত্রা, multi-tenant সিস্টেমে প্রতিটি টেবিলে tenant-এর কলাম, ভবিষ্যতে sharding বা partitioning হলে কোন key — এগুলো “premature” নয়, স্থাপত্যের সিদ্ধান্ত। এগুলোর জন্যও মাপ লাগে, তবে ভবিষ্যতের অনুমিত মাপ: কত সারি, কত লেখা, কতদিন রাখা।

একটি ব্যবহারিক মাঝামাঝি: শুরুতে সঠিক টাইপ, পরিষ্কার schema, প্রতিটি foreign key আর স্পষ্ট খোঁজার কলামে index, আর প্রথম দিন থেকেই monitoring চালু — যাতে পরে “মেপে ঠিক করা” সম্ভব হয়। monitoring না থাকলে পরের সব optimization আবার অনুমানে ফিরে যায়।

মূল উপলব্ধি: ডেটাবেসে দুই ধরনের সিদ্ধান্ত — যেগুলো পরে সস্তায় বদলানো যায়, সেগুলো মেপে পরে; যেগুলো ডেটার সাথে জমে যায়, সেগুলো শুরুতেই যত্ন নিয়ে।

প্র ০২Google-এর Jeffrey Dean আর Luiz André Barroso ২০১৩ সালে “The Tail at Scale” প্রবন্ধে দেখান, বড় সিস্টেমে tail latency সাধারণ অভিজ্ঞতা হয়ে যায়। একটি অনুরোধ যখন অনেকগুলো ডেটাবেস বা সার্ভারে যায়, তখন p99 কেন গুরুত্বপূর্ণ হয়ে ওঠে?

ধরুন একটি পাতা বানাতে অ্যাপকে ১০০টি আলাদা সার্ভার বা shard থেকে উত্তর নিতে হয়, আর পাতাটি তৈরি হয় সবচেয়ে ধীর উত্তরটি আসার পর। প্রতিটি সার্ভার ৯৯% সময় দ্রুত, ১% সময় ধীর — অর্থাৎ প্রতিটির p99-এ সমস্যা। একটি অনুরোধে সব ১০০টি দ্রুত হওয়ার সম্ভাবনা ০.৯৯১০০ ≈ ০.৩৬৬ (স্বাধীন ধরে)। মানে প্রায় ৬৩% পাতা অন্তত একটি ধীর উত্তরের জন্য অপেক্ষা করে। একটি সার্ভারের “বিরল” লেজ পুরো সিস্টেমের “স্বাভাবিক” অভিজ্ঞতা হয়ে গেল।

এই গণিত শুধু Google-এর মাপে নয়। একটি সাধারণ অনলাইন দোকানের পাতায়ও ১০–২০টি কোয়েরি থাকে; প্রতিটির p99 খারাপ হলে পাতার p95 খারাপ হতে বাধ্য। আর N+1-এর ২০১টি কোয়েরির পাতায় অন্তত একটি কোয়েরির লেজে পড়ার সম্ভাবনা প্রায় ৮৭% (১ − ০.৯৯২০১) — কোয়েরি কমানো তাই শুধু network বাঁচায় না, লেজও কমায়।

প্রবন্ধে বেশ কিছু কৌশল আলোচনা হয়েছে। “Hedged request”: নির্দিষ্ট সময়ের মধ্যে উত্তর না এলে একই অনুরোধ আরেকটি replica-য় পাঠানো, যেটি আগে আসে সেটি নেওয়া — অল্প বাড়তি ভারে লেজ অনেক ছোট। লেজের উৎস কমানো: পটভূমির কাজ (compaction, garbage collection, ডেটাবেসে VACUUM বা checkpoint-এর ঝাপটা) এমনভাবে চালানো যাতে সামনের অনুরোধে আঘাত কম। আর অনুরোধগুলোকে অগ্রাধিকার অনুযায়ী আলাদা সারিতে রাখা।

ডেটাবেসে এর শিক্ষা: monitoring-এ p99 আর max দেখুন, শুধু গড় নয়; checkpoint আর autovacuum-কে ছড়িয়ে দিন যাতে একসাথে ঝাপটা না আসে; lock-এর অপেক্ষা খুঁজুন, কারণ সেগুলো লেজের প্রধান উৎস; আর একটি পাতায় কোয়েরির সংখ্যা কম রাখুন।

মূল উপলব্ধি: fan-out-এ সম্ভাবনা গুণ হয় — অংশগুলোর লেজ পুরো সিস্টেমের শরীর হয়ে যায়। তাই বড় সিস্টেমে p99 কোনো প্রান্তিক সংখ্যা নয়, মূল লক্ষ্য।

প্র ০৩MySQL-এ বহু বছর একটি built-in “query cache” ছিল — একই SELECT-এর ফল জমা রাখা। MySQL 8.0-এ এটি পুরোপুরি সরিয়ে দেওয়া হয়। একটি cache কেন গতি বাড়ানোর বদলে বাধা হয়ে উঠতে পারে?

ধারণাটি আকর্ষণীয় ছিল: একটি SELECT-এর লেখা হুবহু মিললে কোয়েরি না চালিয়েই আগের ফল ফেরত। কম লেখার, বেশি পড়ার ওয়েবসাইটে এক সময় এটি সত্যিই সাহায্য করত। সমস্যা ছিল তিনটি, আর তিনটিই cache-এর সাধারণ শিক্ষা।

প্রথম, invalidation-এর মোটা দাগ। কোনো টেবিলে একটি সারিও বদলালে সেই টেবিলের ওপর নির্ভর করা সব জমা ফল মুছে ফেলা হতো। ঘন ঘন লেখা হয় এমন টেবিলে cache প্রায় সবসময় খালি, অথচ প্রতিটি লেখায় মোছার কাজটি চলত। দ্বিতীয়, একসাথে কাজের বাধা: cache-টি একটি কেন্দ্রীয় কাঠামো, যা একটি lock দিয়ে রক্ষিত ছিল। CPU core বাড়ার যুগে বহু থ্রেড একই lock-এর জন্য লাইনে দাঁড়াত — অর্থাৎ বেশি core-এর সার্ভারে cache নিজেই bottleneck. তৃতীয়, মিলের সংকীর্ণতা: একটি space বা মন্তব্যের পার্থক্যেও মিল হতো না, আর অনেক কোয়েরি কখনো পুনরাবৃত্তই হয় না।

MySQL দল ২০১৭ সালের দিকে ঘোষণা করে যে সুবিধার চেয়ে ক্ষতি বেশি, আর 8.0-এ বৈশিষ্ট্যটি সরানো হয়; তাদের পরামর্শ ছিল cache অ্যাপের কাছাকাছি রাখা। এখানেই সাধারণ নীতিটি: cache সবচেয়ে কার্যকর যেখানে ডেটার অর্থ জানা — অ্যাপ জানে কোন পণ্যের দাম পাঁচ মিনিট পুরোনো হলেও চলে, কোন ব্যালান্স চলে না, আর কোন ঘটনায় কোন key মুছতে হবে। ডেটাবেসের কাছে সব SELECT সমান, তাই সে শুধু “টেবিল বদলেছে, সব মুছো” বলতে পারে।

একটি প্রচলিত রসিকতা, যা Phil Karlton-এর বলে পরিচিত: কম্পিউটার বিজ্ঞানে কঠিন কাজ দুটি — cache invalidation আর নামকরণ। প্রতিটি নতুন cache-এর আগে তাই জিজ্ঞেস করুন: কে, কখন, কোন ঘটনায় এটি মুছবে, আর মোছা ব্যর্থ হলে কী দেখা যাবে?

মূল উপলব্ধি: cache-এর দাম শুধু মেমরি নয় — invalidation, একসাথে কাজের বাধা আর পুরোনো ডেটার ঝুঁকি। ডেটার অর্থ যে স্তর জানে, cache সেখানেই সবচেয়ে কার্যকর।

প্র ০৪Stack Overflow-এর প্রকৌশলীরা বহু বছর প্রকাশ্যে লিখেছেন, বিশাল ট্রাফিকের সাইটটি অল্প কয়েকটি শক্তিশালী SQL Server-এ চলে; অন্যদিকে Instagram প্রথম দিকেই PostgreSQL-কে বহু shard-এ ভাগ করেছিল। Scale up আর scale out-এর মধ্যে বাছাইয়ের আসল মাপকাঠি কী?

দুটি গল্পই সঠিক সিদ্ধান্তের উদাহরণ, কারণ দুটির workload আলাদা। Stack Overflow-এর কাজ মূলত পড়া: প্রশ্ন আর উত্তর একবার লেখা হয়, লাখোবার পড়া হয়। পড়া ভালোভাবে cache করা যায়, আর ভালো index ও যত্নে লেখা কোয়েরিতে একটি বড় সার্ভার বিপুল পরিমাণ সামলায়। একটি ডেটাবেস মানে সহজ transaction, সহজ join, সহজ backup — দলের সময় যায় পণ্যে, distributed সিস্টেমের ঝামেলায় নয়।

Instagram-এর প্রথম দিকের প্রকৌশল ব্লগে (২০১২ সালের দিকে) তাঁরা লিখেছিলেন কীভাবে PostgreSQL-এর ভেতরে হাজারো যৌক্তিক shard বানিয়ে অল্প কয়েকটি ভৌত সার্ভারে বসিয়েছিলেন, আর shard-এর তথ্যসহ নিজস্ব id তৈরি করেছিলেন। সেখানে ছবি আর like-এর লেখার হার এত দ্রুত বাড়ছিল যে একটি primary-র লেখার সীমা বাস্তব হুমকি ছিল — আর ডেটার গঠন (ব্যবহারকারী-ভিত্তিক) ভাগ করার জন্য স্বাভাবিক।

মাপকাঠিগুলো তাই: (১) লেখার হার — পড়া replica আর cache দিয়ে ছড়ানো যায়, লেখা যায় না; একটি primary-র লেখার সীমা কাছে এলে তবেই sharding জরুরি। (২) ডেটার আকার আর বৃদ্ধি — একটি মেশিনের ডিস্ক আর মেমরিতে আগামী কয়েক বছর ধরবে কি না। (৩) প্রাকৃতিক ভাগের key আছে কি না — tenant, ব্যবহারকারী, মার্চেন্ট; বেশিরভাগ কোয়েরি একটি key-র ভেতরে থাকলে sharding সহজ, নইলে প্রতিটি কোয়েরি সব shard-এ যায়। (৪) দলের সামর্থ্য — distributed সিস্টেমের ব্যর্থতা, rebalancing, cross-shard transaction সামলানোর লোক আছে কি না। (৫) খরচ — একটি বিশাল মেশিনের দাম বনাম অনেক ছোট মেশিন আর প্রকৌশলীর সময়।

বেশিরভাগ প্রতিষ্ঠানের ক্রম তাই: কোয়েরি আর index → scale up → read replica আর cache → partitioning → শেষে sharding. আর sharding লাগবে মনে হলে, আগে থেকে ডিজাইনে একটি shard key রাখলে পরে পথটি অনেক সহজ।

মূল উপলব্ধি: “বড় কোম্পানি shard করে, তাই আমাদেরও” — ভুল যুক্তি। লেখার হার, ভাগের key আর দলের সামর্থ্যই ঠিক করে কখন একটি মেশিন যথেষ্ট নয়।

প্র ০৫Cloud-এ managed database আসার পর অনেকে বলেন “DBA-র দিন শেষ”. Laine Campbell আর Charity Majors-এর “Database Reliability Engineering” (O'Reilly, ২০১৭) বইয়ের মতো লেখা উল্টো যুক্তি দেয়। ভূমিকাটি আসলে কীভাবে বদলেছে?

পুরোনো দিনের DBA-র দিনের বড় অংশ যেত যন্ত্রে: সার্ভার কেনা আর বসানো, OS আর DBMS ইনস্টল, ডিস্কের RAID সাজানো, patch দেওয়া, রাতে backup-এর tape বদলানো, replica হাতে বানানো। managed সেবা এর প্রায় সবটা নিয়ে নিয়েছে — কয়েক মিনিটে নতুন ডেটাবেস, স্বয়ংক্রিয় backup আর point-in-time restore, এক ক্লিকে replica.

কিন্তু cloud প্রদানকারীরা নিজেরাই “shared responsibility” মডেলের কথা বলে: অবকাঠামো তাদের, ডেটা আর তার ব্যবহার আপনার। schema আর index ডিজাইন, কোন কোয়েরি ধীর, কোন সেটিং আপনার workload-এ ঠিক, কার কী অনুমতি, কোন ডেটা এনক্রিপ্ট, restore আসলে কাজ করে কি না, আর কোন region-এ ডেটা রাখা আইনসম্মত — এগুলো কেউ নিয়ে নেয় না। বরং নতুন একটি দায় যোগ হয়েছে: খরচ। নিজের সার্ভারে খারাপ কোয়েরি শুধু ধীর ছিল; cloud-এ সেটি বড় instance, বেশি IOPS আর বেশি replica-র মাসিক বিলে রূপ নেয়।

বইটির মূল যুক্তি হলো, ভূমিকাটি software engineering-এর পদ্ধতি নিচ্ছে: হাতের কাজের বদলে স্বয়ংক্রিয়তা (infrastructure as code, migration-এর pipeline), অনুমানের বদলে পরিমাপ (SLO, percentile, error budget), আর দল থেকে আলাদা “দারোয়ান” না হয়ে developer-দের পাশে পরামর্শদাতা — যাতে তাঁরা নিজেরাই নিরাপদে migration চালাতে আর plan পড়তে পারেন। এটি site reliability engineering-এর চিন্তার সাথে মেলে।

বাংলাদেশের প্রেক্ষাপটে চিত্রটি মিশ্র: ব্যাংক, টেলিকম আর সরকারি অনেক সিস্টেম নিয়ম বা নীতির কারণে নিজস্ব ডেটা সেন্টারে চলে, যেখানে প্রথাগত DBA-র চাহিদা আছে; নতুন স্টার্টআপ প্রায়ই প্রথম দিন থেকে cloud-এ। দুই জায়গাতেই টিকে থাকা দক্ষতা একই: plan পড়া, মাপা, recovery, নিরাপত্তা।

মূল উপলব্ধি: cloud যন্ত্রের কাজ নিয়েছে, বিচারবুদ্ধির কাজ নয়। DBA-র ভূমিকা মরেনি — হাত থেকে মাথায়, আর আলাদা কক্ষ থেকে দলের ভেতরে সরে এসেছে।

অনুশীলন

একটি কোয়েরি লেখা, একটি percentile হাতে বের করা, একটি plan আগে বলা, একটি capacity-র হিসাব।

  1. কোয়েরি লিখুন: একটি query_log(id, query, ms) টেবিল থেকে প্রতিটি query-র জন্য কতবার চলেছে, কতবার ৫০০ ms-এর বেশি নিয়েছে, আর সেটি মোটের কত শতাংশ — একটি কোয়েরিতে বের করুন, শতাংশ বেশি থেকে কম ক্রমে। অংশ ৭-এর এডিটরে লিখে চালাতে পারেন।
    SELECT query, COUNT(*) AS calls,
           SUM(ms > 500) AS slow_calls,
           ROUND(100.0 * SUM(ms > 500) / COUNT(*), 1) AS slow_pct
    FROM query_log GROUP BY query ORDER BY slow_pct DESC;

    SQLite-এ ms > 500-এর মান 1 বা 0, তাই SUM দিয়ে গোনা যায়; অন্য DBMS-এ লিখুন SUM(CASE WHEN ms > 500 THEN 1 ELSE 0 END) বা PostgreSQL-এ COUNT(*) FILTER (WHERE ms > 500). অংশ ৭-এর ডেটায় ফল: report | 100 | 100 | 100.0, order_page | 1400 | 28 | 2.0, search | 1500 | 0 | 0.0. এই ধরনের “সীমার বেশি কত ভাগ” মাপই SLO-র ভাষা: “৯৮% অনুরোধ ৫০০ ms-এর মধ্যে”.

  2. হাতে হিসাব করুন (trace): একটি API-র ১০টি অনুরোধের সময় (ms): 30, 12, 15, 400, 11, 14, 13, 16, 12, 17. nearest-rank পদ্ধতিতে গড়, p50, p90 আর p99 বের করুন। কোন সংখ্যাটি “সাধারণ ব্যবহারকারীর অভিজ্ঞতা” বোঝায়?

    সাজানো: 11, 12, 12, 13, 14, 15, 16, 17, 30, 400. যোগফল ৫৪০, গড় ৫৪ ms. p50: rank ⌈০.৫ × ১০⌉ = ৫ → ১৪ ms. p90: rank ⌈০.৯ × ১০⌉ = ৯ → ৩০ ms. p99: rank ⌈৯.৯⌉ = ১০ → ৪০০ ms (সর্বোচ্চ)।

    সাধারণ অভিজ্ঞতা p50 = ১৪ ms; গড় ৫৪ ms কোনো একজনের অভিজ্ঞতার সাথেও মেলে না — দশজনের নয়জন ৩০ ms-এর মধ্যে। আর মাত্র ১০টি নমুনায় p99 মানে সর্বোচ্চ মান, তাই এখান থেকে p99-এর লক্ষ্য বিচার করা যায় না।

  3. Plan আগে বলুন: ord(id INTEGER PRIMARY KEY, cust_id, created TEXT, amount) টেবিলে created-এ একটি index আছে। নিচের চারটি শর্তের কোনগুলোতে SQLite index দিয়ে SEARCH করতে পারবে? (ক) created = '2025-05-01' (খ) substr(created, 1, 7) = '2025-05' (গ) created >= '2025-05-01' AND created < '2025-06-01' (ঘ) date(created, '+1 day') = '2025-05-02'

    (ক) আর (গ) — দুটিতেই কলামটি খালি অবস্থায় তুলনার একদিকে; plan আসে SEARCH ord USING INDEX … (created=?) আর (created>? AND created<?). (খ) আর (ঘ) non-sargable: কলাম ফাংশনে মোড়া, তাই SCAN ord. (ঘ)-কে লিখুন created = '2025-05-01' — হিসাবটি কলাম থেকে মানের দিকে সরিয়ে। সমাধানের আরেক পথ expression index (যেমন substr(created, 1, 7)-এর ওপর), তবে কোয়েরিতে হুবহু একই expression লিখতে হয়।

  4. Capacity হিসাব করুন: একটি পেমেন্ট সার্ভিসে সন্ধ্যার চূড়ায় প্রতি সেকেন্ডে ১,২০০টি কোয়েরি, গড় সময় ৮ ms. (ক) গড়ে একসাথে কয়টি কোয়েরি চলে? (খ) একটি ভুল deploy-এ গড় সময় ৮০ ms হলে? pool-এ ৪০টি connection থাকলে কী ঘটবে? (গ) ডিস্ক ২ TB, ব্যবহৃত ১.১ TB, মাসে ৬০ GB বাড়ে — ৮০%-এর alert কত মাস পর বাজবে?
    • (ক) Little's law: L = λ × W = ১,২০০ × ০.০০৮ = ৯.৬ — গড়ে ১০টির মতো।
    • (খ) L = ১,২০০ × ০.০৮০ = ৯৬. pool-এ মাত্র ৪০টি connection, তাই বাকি অনুরোধ connection-এর জন্য লাইনে দাঁড়ায় — অপেক্ষার সময় যোগ হয়ে latency আরও বাড়ে, timeout শুরু হয়। pool বাড়ানো সমাধান নয় (ডেটাবেস আরও চাপে পড়ে); ধীর কোয়েরিটি ফেরানো বা সারানোই সমাধান।
    • (গ) ৮০% = ১.৬ TB; বাকি ০.৫ TB = প্রায় ৫০০ GB (১ TB = ১,০০০ GB ধরে)। ৫০০ / ৬০ ≈ ৮.৩ মাস. তার আগেই বড় ডিস্ক, আর্কাইভ বা partition সরানোর পরিকল্পনা।

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

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

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

  1. লক্ষ্য ঠিক করা আর baseline মাপা (p50, p95)
  2. সময় কোথায় যায় তা খুঁজে bottleneck চিহ্নিত করা
  3. যাচাইযোগ্য একটি অনুমান লিখে রাখা
  4. একবারে শুধু একটি পরিবর্তন করা
  5. একই অবস্থায় আবার মেপে আগে-পরে তুলনা

created কলামে index আছে। কোন শর্তটি index দিয়ে খোঁজা যায় (sargable)?

  • strftime('%Y', created) = '2025'
  • created >= '2025-01-01' AND created < '2026-01-01'
  • substr(created, 1, 4) = '2025'
  • date(created, '+30 days') > '2025-02-01'

একটি পাতা ৫০ জন গ্রাহকের তালিকা আনতে একটি কোয়েরি চালায়, তারপর প্রতিটি গ্রাহকের মোট অর্ডারের জন্য আলাদা একটি করে কোয়েরি। প্রতিটি কোয়েরি ২ ms-এ শেষ, তবু পাতা ধীর। সমস্যাটির নাম আর সমাধান কী?

  • Deadlock — transaction ছোট করা
  • Missing index — প্রতিটি কলামে index
  • N+1 কোয়েরি — JOIN বা IN দিয়ে একবারে আনা
  • Cache miss — Redis যোগ করা

২০টি অনুরোধের latency ছোট থেকে বড় সাজানো আছে। nearest-rank পদ্ধতিতে p95 কোন rank-এর মান?

  • ১৯তম
  • ২০তম
  • ১০তম
  • ৯৫তম

একটি তালিকার “পাতা ৫,০০০” দেখাতে ORDER BY id LIMIT 20 OFFSET 99980 ধীর। id অনন্য আর index-যুক্ত। সবচেয়ে কার্যকর পরিবর্তন কোনটি?

  • id-তে আরেকটি index বানানো
  • LIMIT 20-কে LIMIT 10 করা
  • work_mem বাড়ানো
  • Keyset pagination: WHERE id > :last_id ORDER BY id LIMIT 20

PostgreSQL সার্ভারে max_connections = 200, আর একটি কোয়েরিতে তিনটি sort আছে। work_mem 64MB করলে খারাপ মুহূর্তে শুধু এই কাজে মোটামুটি কত মেমরি লাগতে পারে?

  • 64 MB
  • প্রায় ৩৮ GB
  • প্রায় ১২.৮ GB
  • shared_buffers-এর সমান

একটি PostgreSQL টেবিলে ঘন ঘন UPDATE হয়। কয়েক সপ্তাহে টেবিলের আকার তিন গুণ, অথচ সারির সংখ্যা প্রায় একই। monitoring-এ দেখা গেল একটি session সাত ঘণ্টা ধরে খোলা transaction-এ বসে আছে। সবচেয়ে সম্ভাব্য ব্যাখ্যা কী?

  • টেবিলে অনেক index, তাই টেবিল বড়
  • ANALYZE চলেনি, তাই statistics পুরোনো
  • লম্বা transaction-এর কারণে VACUUM dead tuple সরাতে পারছে না — bloat
  • checkpoint খুব ঘন ঘন হচ্ছে

শব্দকোষ

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

মনে রাখুন

  1. পদ্ধতি: লক্ষ্য আর baseline → bottleneck → লিখিত অনুমান → একটি পরিবর্তন → আবার মাপ। লক্ষ্য পূরণ হলে থামুন।
  2. সময়ের পাঁচ বাক্স: CPU, I/O, lock-এর অপেক্ষা, network round trip আর অ্যাপ (N+1). ধীর কোয়েরি সাজান মোট সময় ধরে।
  3. সস্তা থেকে দামি: sargable শর্ত, দরকারি কলাম, EXISTS, keyset, batching → index (covering, partial), partitioning, আর্কাইভ → কনফিগ (work_mem-এর গুণ মনে রেখে) → scale up → replica, cache → sharding.
  4. রক্ষণাবেক্ষণ: autovacuum কাজ করছে কি না, লম্বা transaction, bloat, বড় পরিবর্তনের পর ANALYZE, অব্যবহৃত index মোছা।
  5. মাপুন percentile-এ: p95/p99, cache hit ratio-র পরিবর্তন (জমা নয়), lock, lag, ডিস্ক, connection; প্রতিটি alert-এর পেছনে একটি করণীয়। আর প্রতিদিন backup, প্রতি সপ্তাহে restore-পরীক্ষা।

পরের পর্বে: মডিউল ৮ শেষ — অ্যাপ থেকে ডেটাবেস, নিরাপত্তা, backup আর দৈনন্দিন পরিচালনা দেখা হলো। পর্ব ৫৪-এ মডিউল ৯ শুরু: একটি মেশিন যখন আর যথেষ্ট নয় — distributed database, partitioning, sharding আর CAP.

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

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