এই পর্বে যা শিখবেন
- মেমোরির সিঁড়ি, HDD আর SSD-এর গতির পার্থক্য, আর কেন ডেটাবেস I/O গোনে
- Page, ফাইল সংগঠন (heap, sorted, clustered) আর তাদের I/O খরচ — লাইভ SQLite-এ page গুনে
- Record-এর গঠন: fixed আর variable length, NULL bitmap, slotted page, record id, overflow আর TOAST
- Buffer pool-এর ভেতরের কাজ: frame, page table, pin count, dirty bit, LRU আর clock
- Sequential flooding, OS page cache-এর সাথে double buffering, আর WAL-এর সাথে dirty page লেখার সম্পর্ক
১ · মেমোরির সিঁড়ি — কেন ডেটাবেস I/O গোনে
এতদিন আপনি SQL লিখেছেন, আর ডেটাবেস উত্তর দিয়েছে। পর্ব ৩-এ দেখেছেন, টেবিলের যৌক্তিক রূপ আর ডিস্কে রাখার physical রূপ আলাদা — internal schema. এবার সেই নিচের তলায় নামার পালা। কারণ একটি সহজ কথা: কোয়েরি ধীর না দ্রুত, তা প্রায় সবসময় ঠিক হয় ডেটা কোথা থেকে আসছে তা দিয়ে।
কম্পিউটারে ডেটা রাখার জায়গাগুলো একটি সিঁড়ির মতো — একে বলে memory hierarchy. ওপরে ছোট, দামি আর অতি দ্রুত; নিচে বড়, সস্তা আর ধীর। নিচের সংখ্যাগুলো আনুমানিক; যন্ত্রভেদে কয়েক গুণ এদিক-ওদিক হয়, কিন্তু মাত্রা (কত শূন্য) মোটামুটি এমনই:
| স্তর | একবার পড়ার আনুমানিক সময় (latency) | ১ ns-কে ১ সেকেন্ড ধরলে | বন্ধ করলে ডেটা থাকে? |
|---|---|---|---|
| CPU cache (L1–L3) | ~১ থেকে ~৪০ ns | ১ থেকে ৪০ সেকেন্ড | না |
| RAM (main memory) | ~১০০ ns | ~দেড় মিনিট | না |
| SSD (NVMe), random ৪ KB পড়া | ~২০–১০০ µs | ~৬ ঘণ্টা থেকে ~১ দিন | হ্যাঁ |
| HDD, random পড়া | ~৫–১০ ms | ~২ থেকে ~৪ মাস | হ্যাঁ |
রান্নাঘরের হিসাব ভাবুন। চুলার পাশে রাখা লবণের কৌটা হলো CPU cache — হাত বাড়ালেই পাওয়া যায়। তাকের মশলা হলো RAM — দুই পা হাঁটতে হয়। ঘরে নেই এমন জিনিস পাড়ার দোকান থেকে আনা হলো SSD. আর HDD থেকে random পড়া মানে বাজার থেকে নয়, অন্য জেলা থেকে জিনিস আনানো। ভালো রাঁধুনি তাই রান্না শুরুর আগে দরকারি সব জিনিস একবারে তাকে এনে রাখেন। ডেটাবেসও ঠিক তা-ই করে: ডিস্ক থেকে একসাথে একটি বড় টুকরো আনে, আর যতটা পারে মেমোরিতে ধরে রাখে।
RAM আর HDD-এর random পড়ার মধ্যে ব্যবধান পঞ্চাশ হাজার থেকে এক লাখ গুণ। তাই ডিস্ক-নির্ভর ডেটাবেসে CPU-র কাজ প্রায়ই নগণ্য, আসল খরচ ডিস্কের যাতায়াত। এই কারণে ডেটাবেসের বই আর পরের পর্বগুলো একটি সরল I/O cost model ব্যবহার করে: খরচ = কতগুলো page ডিস্ক থেকে পড়া বা ডিস্কে লেখা হলো। CPU-র হিসাব বাদ, মেমোরিতে থাকা page বিনা খরচে। মডেলটি স্থূল, কিন্তু দুটি পরিকল্পনার মধ্যে কোনটি ভালো, তা বলতে প্রায়ই যথেষ্ট — পর্ব ৪০–৪৩-এ index, join আর optimizer এই মাপকাঠিতেই তুলনা হবে।
২ · HDD আর SSD — random বনাম sequential
HDD (hard disk drive) একটি ঘুরন্ত চৌম্বক থালা, যার ওপর একটি বাহুর মাথায় পড়া-লেখার head. একটি নির্দিষ্ট জায়গা পড়তে তিনটি সময় লাগে:
- Seek time — head-কে সঠিক বৃত্তাকার পথে (track) সরানো; সাধারণ ডেস্কটপ ডিস্কে গড়ে কয়েক ms, প্রায় ৪–৯ ms.
- Rotational latency — থালা ঘুরে কাঙ্ক্ষিত অংশটি head-এর নিচে আসার অপেক্ষা। ৭২০০ rpm-এ এক পাক = ৬০ ÷ ৭২০০ সেকেন্ড ≈ ৮.৩৩ ms, গড়ে অর্ধেক পাক ≈ ৪.১৭ ms.
- Transfer time — আসল বাইটগুলো পড়া; প্রায় ২০০ MB/s হারে ৮ KB পড়তে মাত্র ~০.০৪ ms.
যোগ করলে একটি random ৮ KB পড়া ≈ ৯ + ৪.১৭ + ০.০৪ ≈ ১৩ ms. অথচ head একবার জায়গামতো বসলে পরের page-গুলো পড়তে প্রতিটিতে শুধু transfer time, ~০.০৪ ms. অর্থাৎ HDD-তে sequential I/O random I/O-এর চেয়ে প্রায় ৩০০ গুণ সস্তা। এজন্যই একটি HDD সেকেন্ডে মাত্র ~১০০–২০০টি random পড়া (IOPS) দিতে পারে, অথচ একটানা পড়ায় শতশত MB.
SSD-এ কোনো চলমান অংশ নেই; ডেটা থাকে NAND flash চিপে। random পড়া কয়েক দশ µs, আর NVMe SSD সেকেন্ডে লাখো random পড়া দিতে পারে। তবু sequential এখনো দ্রুততর, কারণ বড় একটানা অনুরোধে ভেতরের বহু চিপ একসাথে কাজ করে। লেখার দিকটি জটিল: flash-এ একটি জায়গায় সরাসরি নতুন করে লেখা যায় না। লেখা হয় ছোট flash page-এ (কয়েক KB), কিন্তু মোছা যায় শুধু অনেকগুলো page-এর বড় erase block একসাথে। তাই SSD-র ভেতরের সফটওয়্যার (FTL) প্রতিটি হালনাগাদ নতুন জায়গায় লেখে, আর পরে পুরোনো block থেকে বৈধ page সরিয়ে block মোছে। ফলে আপনি ১ MB লিখতে বললে ভেতরে হয়তো ২–৩ MB লেখা হয় — একে বলে write amplification. ছোট random লেখা আর প্রায়-ভরা ডিস্কে এটি বাড়ে। flash-এর লেখার আয়ু সীমিত, তাই এটি গতি আর আয়ু দুটিরই প্রশ্ন।
ডিস্কের ধরন যা-ই হোক, তিনটি নিয়ম টিকে থাকে: (১) মেমোরিতে থাকলে সবচেয়ে ভালো; (২) একসাথে বড় টুকরো পড়া অনেক ছোট টুকরোর চেয়ে ভালো; (৩) random লেখা সবচেয়ে দামি। ডেটাবেসের প্রায় সব নকশা — page, buffer pool, log, B+ tree — এই তিন নিয়মের উত্তর।
ইতিহাস এক নজরে। IBM ১৯৫৬ সালে প্রথম বাণিজ্যিক hard disk, IBM 350, বাজারে আনে — পঞ্চাশটি থালায় মোটামুটি ৫০ লাখ অক্ষর, আকারে দুটি বড় আলমারির মতো। কয়েক দশক ধরে ডিস্কের ধারণক্ষমতা বিস্ময়কর হারে বেড়েছে, কিন্তু seek time কমেছে সামান্যই — যান্ত্রিক বাহু তো আর আলোর গতিতে চলে না। ডেটাবেসের ক্লাসিক অ্যালগরিদমগুলো (B+ tree, external sort, WAL) জন্মেছে এই ধীর বাহুর সাথে লড়াই করে। ২০১০-এর দশকে SSD সাধারণ হয়ে ওঠে, আর নকশার হিসাব আবার নতুন করে কষা শুরু হয়।
৩ · Page — ডিস্ক আর মেমোরির লেনদেনের একক
ডেটাবেস কখনো ডিস্ক থেকে “একটি সারি” পড়ে না। সে পড়ে একটি নির্দিষ্ট আকারের টুকরো — page (অনেক বইয়ে block). একটি সারি লাগলেও পুরো page আসে, আর ভাগ্য ভালো হলে পরের দরকারি সারিগুলোও সেই page-এই থাকে। প্রচলিত আকার:
PostgreSQL · ৮ KB
compile করার সময় ঠিক হয়, বদলাতে নতুন করে build লাগে। SHOW block_size; চালালে 8192 দেখায়।
MySQL InnoDB · ১৬ KB
ডেটাবেস প্রথম বানানোর সময় 4, 8, 16, 32 বা 64 KB বেছে নেওয়া যায়, পরে নয়।
SQLite · সাধারণত ৪ KB
৫১২ বাইট থেকে ৬৪ KB-র মধ্যে দুইয়ের যেকোনো ঘাত; ২০১৬ সালের ৩.১২ সংস্করণ থেকে default ৪০৯৬.
SQL Server, Oracle · ৮ KB
SQL Server-এ ৮টি page মিলে ৬৪ KB-র একটি extent; Oracle-এ block size সাধারণত ৮ KB.
একটি টেবিল তাহলে কী? ডিস্কে সেটি এক বা একাধিক ফাইল, আর প্রতিটি ফাইল page-এর একটি সারি: page 0, page 1, page 2 … প্রতিটি page-এর নম্বর জানলেই অবস্থান জানা যায়: অফসেট = নম্বর × page-এর আকার. PostgreSQL প্রতিটি টেবিল আর index-কে আলাদা ফাইলে রাখে; SQLite পুরো ডেটাবেস একটি ফাইলে রাখে, যার ভেতরে প্রতিটি টেবিল আর index নিজস্ব page-গুলোর একটি গাছ। ফাঁকা হয়ে যাওয়া page-গুলোর একটি তালিকাও থাকে — freelist — যাতে নতুন ডেটা ফাইল না বাড়িয়ে সেগুলোতে বসতে পারে।
এবার নিজের চোখে দেখুন। প্রথম এডিটর চলে দোকানের নমুনা ডেটাবেসে (db-shop): customers (customer_id, name, city, phone, joined_on — ৮ জন গ্রাহক), categories (category_id, name — ৪টি শ্রেণি), products (product_id, name, category_id, price, stock — ১০টি পণ্য), orders (order_id, customer_id, order_date, status — ১০টি অর্ডার) আর order_items (order_id, product_id, quantity, unit_price — ১৬টি লাইন)। SQLite-এর PRAGMA কমান্ড ডেটাবেসের ভেতরের তথ্য জানায়, আর sqlite_schema টেবিলে প্রতিটি টেবিল ও index-এর গাছ কোন page থেকে শুরু (rootpage), তা লেখা থাকে। প্রতিবার চালালে ডেটাবেস মেমোরিতে নতুন করে তৈরি হয়।
PRAGMA page_size;
PRAGMA page_count;
PRAGMA freelist_count;
SELECT type, name, rootpage FROM sqlite_schema;ফল: 4096, 9 আর 0 — page ৪০৯৬ বাইটের, পুরো ডেটাবেস ৯টি page, freelist খালি। তারপর ৮টি সারি: পাঁচটি টেবিলের rootpage 2, 4, 6, 7, 8, আর তিনটি sqlite_ (3, 5, 9). এই তিনটি index আপনি বানাননি — UNIQUE (phone, categories.name) আর order_items-এর যৌগিক PRIMARY KEY মেলাতে SQLite নিজে বানিয়েছে। page 1 কোথায়? সেটি ফাইলের হেডার আর sqlite_schema-এর নিজের page. এত ছোট টেবিল একটি করে page-এই ধরে যায়।
এবার একটি বড় টেবিল বানিয়ে page বাড়তে দেখি। ধরুন বিদ্যুৎ বিতরণ সংস্থার মিটার রিডিং: readings (id, meter, units). পর্ব ২৫-এর recursive CTE দিয়ে প্রথমে ১,০০০টি, তারপর আরও ৪,০০০টি সারি ঢোকানো হচ্ছে; শেষ কোয়েরিটি PRAGMA-কে টেবিলের মতো (pragma_) ব্যবহার করে হিসাব কষে:
CREATE TABLE readings (id INTEGER PRIMARY KEY, meter INTEGER, units INTEGER);
PRAGMA page_count;
WITH RECURSIVE s(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM s WHERE x < 1000)
INSERT INTO readings SELECT x, x % 50, x * 3 FROM s;
PRAGMA page_count;
WITH RECURSIVE s(x) AS (SELECT 1001 UNION ALL SELECT x + 1 FROM s WHERE x < 5000)
INSERT INTO readings SELECT x, x % 50, x * 3 FROM s;
PRAGMA page_count;
SELECT p.page_count, s.page_size, p.page_count * s.page_size AS bytes,
round(5000.0 / (p.page_count - 2), 1) AS rows_per_page
FROM pragma_page_count() p, pragma_page_size() s;page_count তিনবার: 2 → 5 → 17; শেষ সারি 17 | 4096 | 69632 | 333.3. খালি টেবিলে ২টি page: page 1 (schema) আর readings-এর root. ১,০০০ সারিতে ৫টি, ৫,০০০ সারিতে ১৭টি — ফাইল প্রায় ৬৮ KB. ফাইলটি বাইট ধরে পড়লে দেখা যায়, ১৭টির মধ্যে একটি schema page, একটি root (সূচির কাজ করে), আর ১৫টি leaf page-এ আসল সারি; ভরা leaf-এ ৩৪১টি করে। তাই page_count − 2 দিয়ে ভাগ: গড়ে ৩৩৩.৩টি সারি প্রতি page-এ। একটি সারি তাহলে মোটামুটি ১২ বাইট (৪০৯৬ ÷ ৩৪১) — ছোট পূর্ণসংখ্যা SQLite ১–২ বাইটে রাখে।
Page বড় হলে কী হয়? একটি নতুন, ফাঁকা ডেটাবেসে প্রথম টেবিল বানানোর আগে PRAGMA page_size বদলানো যায়; পরে বদলাতে VACUUM দিয়ে পুরো ফাইল নতুন করে লিখতে হয়:
PRAGMA page_size = 8192;
CREATE TABLE readings (id INTEGER PRIMARY KEY, meter INTEGER, units INTEGER);
WITH RECURSIVE s(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM s WHERE x < 5000)
INSERT INTO readings SELECT x, x % 50, x * 3 FROM s;
PRAGMA page_size;
PRAGMA page_count;
PRAGMA page_size = 1024;
VACUUM;
PRAGMA page_size;
PRAGMA page_count;৮ KB page-এ একই ৫,০০০ সারি 10টি page (৪ KB-তে ছিল ১৭), আর VACUUM-এর পর ১ KB page-এ 61টি। মোট বাইট প্রায় একই থাকে (৮১,৯২০ / ৬৯,৬৩২ / ৬২,৪৬৪), কিন্তু page-এর সংখ্যা মানে I/O-র সংখ্যা। বড় page-এ একটানা scan-এ কম অনুরোধ, আর গাছ চওড়া হয়ে নিচু হয়; ছোট page-এ একটি সারির জন্য কম বাড়তি বাইট আনতে হয়। কোন আকার ভালো, তা কাজের ধরনের ওপর নির্ভর করে।
প্রতিটি টেবিল কত page নিল, তা জানার একটি সরাসরি পথ SQLite-এ আছে — dbstat নামের virtual table. কিন্তু সেটি SQLite compile করার সময় চালু করতে হয়। এই পাতার SQLite (sql.js) সেটি ছাড়া বানানো, তাই নিচের এডিটরটি ইচ্ছে করেই ত্রুটি দেখায়:
CREATE TABLE t (x INTEGER);
SELECT name, pageno, pagetype, ncell FROM dbstat;“✓ CREATE TABLE”, তারপর ত্রুটি (স্টেটমেন্ট ২): no such table: dbstat. runner প্রথম ত্রুটিতেই থামে। নিজের কম্পিউটারে sqlite.org-এর sqlite3 প্রোগ্রামে সাধারণত dbstat থাকে; সেখানে এই কোয়েরি প্রতিটি page-এর ধরন (leaf, internal, overflow) আর তাতে কয়টি cell, তা দেখায়। এখানে আমরা page_count-এর পার্থক্য দিয়েই হিসাব চালাব।
৪ · ফাইলের সংগঠন — heap, sorted আর clustered
সারিগুলো page-এ কোন ক্রমে বসবে, তার তিনটি মূল উত্তর:
Heap file
কোনো ক্রম নেই; যেখানে জায়গা আছে সেখানে বসে। PostgreSQL-এর টেবিল এমন, আর প্রতিটি index আলাদা কাঠামো, যা সারির ঠিকানা (ctid) রাখে।
Sorted file
একটি কলাম ধরে সাজানো। binary search দ্রুত, কিন্তু মাঝখানে নতুন সারি ঢোকাতে পরের সব কিছু সরাতে হয়। খাঁটি রূপে প্রায় কেউ ব্যবহার করে না।
Clustered (index-organized)
সারিগুলো নিজেই primary key-র B+ tree-এর leaf-এ, key-র ক্রমে। InnoDB সবসময় এমন; SQLite-এর সাধারণ টেবিল rowid ধরে একটি B-tree; Oracle-এ IOT, SQL Server-এ clustered index.
InnoDB-তে primary key না দিলে সে প্রথম UNIQUE NOT NULL index ব্যবহার করে, তাও না থাকলে একটি লুকানো ৬-বাইটের row id বানায়। আর secondary index-এর leaf-এ থাকে primary key-র মান — তাই secondary index দিয়ে খুঁজলে দুটি গাছে নামতে হয়। PostgreSQL-এ index সরাসরি heap-এর page আর slot দেখায়। দুই নকশার লাভ-ক্ষতি ভাবনার প্রশ্ন ৪-এ।
SQLite-এ সারির ঠিকানা হলো rowid. INTEGER PRIMARY KEY কলাম rowid-এরই আরেক নাম; আর WITHOUT ROWID লিখলে টেবিলটি সরাসরি primary key ধরে সাজানো গাছ হয় — তখন rowid বলে কিছু নেই:
CREATE TABLE visitors (name TEXT, city TEXT);
INSERT INTO visitors VALUES ('Rina', 'Bogura'), ('Kamal', 'Sylhet'), ('Joba', 'Barishal');
DELETE FROM visitors WHERE name = 'Kamal';
INSERT INTO visitors VALUES ('Liton', 'Cumilla');
SELECT rowid, name, city FROM visitors;
CREATE TABLE stations (code TEXT PRIMARY KEY, name TEXT) WITHOUT ROWID;
INSERT INTO stations VALUES ('KMLP', 'Kamalapur'), ('CTG', 'Chattogram');
SELECT * FROM stations;
SELECT rowid FROM stations;visitors-এ 1 | Rina, 3 | Joba, 4 | Liton — Kamal-এর rowid 2 মুছে গেছে, আর নতুন সারি 2 নয়, 4 পেয়েছে (সাধারণত সবচেয়ে বড় rowid + 1). stations-এ CTG আগে, KMLP পরে — ঢোকানোর ক্রম নয়, key-র ক্রম, কারণ সারিগুলো key ধরে সাজানো গাছে থাকে। শেষ স্টেটমেন্টটি ইচ্ছাকৃত ত্রুটি: no such column: rowid. একটি সাবধানতা: SQLite-এর ডকুমেন্টেশন বলে, INTEGER PRIMARY KEY ছাড়া টেবিলে VACUUM rowid বদলে দিতে পারে — তাই rowid-কে বাইরের কোথাও স্থায়ী পরিচয় হিসেবে রাখবেন না।
কোন সংগঠনে কত I/O
এবার I/O cost model প্রয়োগ করি। টেবিলে Bটি page, খুঁজছি একটি unique key-র মান। শুধু page-পড়া আর লেখা গোনা হচ্ছে:
| কাজ | Heap | Sorted | Clustered B+ tree (উচ্চতা h) |
|---|---|---|---|
| পুরো scan | B | B | ≈ B (leaf-গুলো) |
| key = মান (unique) | গড়ে B/2, সবচেয়ে খারাপ B | ⌈log₂ B⌉ | h (সাধারণত ৩–৪) |
| key-এর একটি পরিসর | B | ⌈log₂ B⌉ + মেলা page | h + মেলা page |
| নতুন সারি | ২ (শেষ page পড়া + লেখা) | খোঁজ + গড়ে B (অর্ধেক ফাইল পড়া ও লেখা) | h + ১ (মাঝে মাঝে split) |
Heap-এ গড় B/2 কেন? মানটি সমান সম্ভাবনায় যেকোনো page-এ থাকতে পারে; i-তম page-এ থাকলে i-টি page পড়তে হয়, তাই গড় (১ + ২ + … + B) ÷ B = (B + ১)/২. Sorted file-এ প্রতিবার অর্ধেক বাদ, তাই ⌈log₂ B⌉. ধরুন B = ১০,০০০: heap-এ গড়ে ৫,০০০টি page, sorted-এ ১৪টি, B+ tree-এ ৩–৪টি। sorted file-এর দুর্বলতা হলো ঢোকানো: মাঝখানে জায়গা করতে গড়ে অর্ধেক ফাইল সরাতে হয়। B+ tree দুই সমস্যারই মীমাংসা — পর্ব ৪০-এর বিষয়। SQLite-এর query plan এই পার্থক্য সরাসরি দেখায়:
EXPLAIN QUERY PLAN SELECT * FROM readings WHERE id = 4000;
EXPLAIN QUERY PLAN SELECT * FROM readings WHERE id BETWEEN 100 AND 200;
EXPLAIN QUERY PLAN SELECT * FROM readings WHERE units = 300;এডিটরে readings-এর ৫,০০০ সারি আগে থেকেই ভরা। detail কলামে তিনটি উত্তর: SEARCH readings USING INTEGER PRIMARY KEY (rowid=?), SEARCH readings USING INTEGER PRIMARY KEY (rowid>? AND rowid<?) আর SCAN readings. id-তে গাছ ধরে নেমে যাওয়া যায় — root থেকে একটি leaf, ২টি page. units-এ কোনো ক্রম নেই, তাই টেবিলের সব ১৬টি page (root আর ১৫টি leaf) পড়তে হয় — full table scan. টেবিলটি id-এর জন্য “clustered”, কিন্তু অন্য সব কলামের জন্য heap-এর মতোই।
৫ · Record আর slotted page
একটি সারি ডিস্কে একটি record — বাইটের একটি ধারা। সব কলাম নির্দিষ্ট আকারের হলে (INTEGER, CHAR(10)) record-ও নির্দিষ্ট আকারের: fixed-length record. তখন i-তম কলাম কোথায়, সরল যোগে বলা যায়। কিন্তু নাম, ঠিকানা, বিবরণ — এগুলো VARCHAR বা TEXT, প্রতিটি সারিতে আলাদা দৈর্ঘ্যের: variable-length record. তখন record-এর শুরুতে একটি ছোট হেডার রাখা হয়, যাতে প্রতিটি variable কলাম কোথায় শুরু আর কত লম্বা, লেখা থাকে।
NULL কীভাবে রাখা হয়? বেশির ভাগ সিস্টেম record-এর হেডারে একটি NULL bitmap রাখে: প্রতিটি কলামের জন্য একটি bit, ১ মানে NULL, আর NULL কলামের মান কোনো জায়গাই নেয় না। PostgreSQL-এ সারির কোনো কলাম NULL হলেই bitmap যোগ হয়; InnoDB-র compact ধরনেও nullable কলামের জন্য bit থাকে। SQLite আরেকটু চতুর: হেডারে প্রতিটি কলামের একটি “serial type” সংখ্যা, যা টাইপ আর আকার দুটিই বলে — NULL, ০ আর ১-এর জন্য বডিতে কোনো বাইটই লাগে না, আর পূর্ণসংখ্যা মান অনুযায়ী ১, ২, ৩, ৪, ৬ বা ৮ বাইটে বসে।
এখন বড় প্রশ্ন: আলাদা দৈর্ঘ্যের record একটি page-এ কীভাবে সাজানো হবে, যাতে মোছা আর নতুন ঢোকানো সহজ হয়, অথচ বাইরের কেউ (যেমন index) record-টিকে একটি স্থির ঠিকানায় খুঁজে পায়? উত্তর slotted page. page-এর শুরুতে হেডার; তার পরে slot array — প্রতিটি slot-এ একটি record-এর অফসেট আর দৈর্ঘ্য — যা সামনের দিকে বাড়ে; আর record-গুলো বসে page-এর শেষ থেকে, পেছনের দিকে। মাঝখানের ফাঁকা জায়গা দুই দিক থেকে খায়। record-এর ঠিকানা হলো (page নম্বর, slot নম্বর) — একে বলে record id (RID). PostgreSQL-এ এটিই ctid, যেমন (0,1): block 0, slot 1.
| slot 1 | |
| slot 2 | |
| slot 3 | |
| একটানা ফাঁকা | |
| মোট ফাঁকা |
- খালি page. হেডারে তিনটি সংখ্যা: কয়টি slot, ফাঁকা জায়গা কোথায় শুরু (free_start = 16) আর কোথায় শেষ (free_end = 400). মাঝের ৩৮৪ বাইট ফাঁকা।
- R1 ঢুকল (৬০ বাইট). record বসে শেষ থেকে: ৩৪০–৩৯৯. সামনে একটি ৪-বাইটের slot যোগ হয়: slot 1 → (৩৪০, ৬০). free_start ২০, free_end ৩৪০ — ফাঁকা ৩২০.
- R2 ঢুকল (১০০ বাইট). ২৪০–৩৩৯-এ; slot 2 → (২৪০, ১০০). দুই দিক থেকে ফাঁকা জায়গা কমছে: ২১৬.
- R3 ঢুকল (৮০ বাইট). ১৬০–২৩৯-এ; slot 3. এখন ফাঁকা ১৩২ বাইট, একটানা এক টুকরো।
- R2 মোছা হলো। slot 2 শুধু “খালি” চিহ্ন পায়; slot array ছোট হয় না, কারণ slot 3-এর নম্বর বদলালে R3-এর RID ভেঙে যেত। R2-এর ১০০ বাইট এখন একটি গর্ত — মোট ফাঁকা ২৩২, কিন্তু একটানা মাত্র ১৩২.
- R4-এর জন্য compaction. ১৫০ বাইট লাগবে, একটানা নেই, তবে মোট আছে। DBMS page-এর ভেতরে R3-কে শেষের দিকে ঠেলে গর্ত বোজায় আর slot 3-এ নতুন অফসেট (২৬০) লেখে। এই কাজ মেমোরিতে, একটি page-এর মধ্যে — কোনো বাড়তি I/O নেই।
- R4 বসল। ১১০–২৫৯-এ, খালি slot 2-এ। R3 আসলে সরেছে, কিন্তু তার RID এখনো (7, 3) — index বা অন্য কারও কিছু বদলাতে হয়নি। এটিই slot-এর একটি স্তর বাড়তি পরোক্ষতা (indirection) রাখার পুরস্কার।
বাস্তব সিস্টেমেও ঠিক এই নকশা, খুঁটিনাটি আলাদা। PostgreSQL-এর page-এ ২৪ বাইটের হেডার, প্রতিটি slot (line pointer) ৪ বাইট, আর প্রতিটি সারির নিজস্ব হেডার প্রায় ২৩ বাইট — তাই খুব সরু টেবিলে আসল ডেটার চেয়ে হেডারই বেশি। SQL Server উল্টো দিকে সাজায়: record হেডারের পর থেকে সামনে, আর slot-এর তালিকা page-এর শেষ থেকে পেছনে। ধারণা একই।
কলামের টাইপও page-এর সংখ্যা বদলায়। একই ৫,০০০টি মোবাইল নম্বর একবার INTEGER, একবার TEXT হিসেবে রাখা হলো; page_count-এর পার্থক্যই দ্বিতীয় টেবিলের আকার:
CREATE TABLE a_int (id INTEGER PRIMARY KEY, phone INTEGER);
WITH RECURSIVE s(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM s WHERE x < 5000)
INSERT INTO a_int SELECT x, 1711000000 + x FROM s;
PRAGMA page_count;
CREATE TABLE b_text (id INTEGER PRIMARY KEY, phone TEXT);
WITH RECURSIVE s(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM s WHERE x < 5000)
INSERT INTO b_text SELECT x, '0' || (1711000000 + x) FROM s;
PRAGMA page_count;
SELECT typeof(a.phone), a.phone, typeof(b.phone), b.phone, length(b.phone)
FROM a_int a JOIN b_text b USING (id) WHERE id = 1;প্রথমবার 17, দ্বিতীয়বার 42: a_int নিয়েছে ১৬টি page (১৭ − schema page), b_text ২৫টি — প্রায় দেড় গুণ। শেষ সারি integer | 1711000001 | text | 01711000001 | 11. পূর্ণসংখ্যাটি ৪ বাইটে ধরে, লেখাটি ১১ বাইট। কিন্তু এখান থেকে “ফোন নম্বর INTEGER-এ রাখুন” শিক্ষা নেবেন না! নম্বরের শুরুর 0 হারিয়ে গেছে, আর ফোন নম্বর দিয়ে কেউ যোগ-বিয়োগ করে না — এটি লেখা, TEXT-ই ঠিক (পর্ব ১৩)। শিক্ষাটি হলো: টাইপ আর আকার page-এর সংখ্যা, আর page-এর সংখ্যা I/O ঠিক করে — তাই সঠিকতার পর আকারও ভাবুন।
মুছলে কী হয়? সারি মুছলে তার জায়গা page-এর ভেতরে গর্ত হয়; পুরো page খালি হলেই কেবল সেটি freelist-এ যায়। আর ফাইল নিজে থেকে ছোট হয় না:
PRAGMA page_count;
DELETE FROM readings WHERE id % 2 = 0;
PRAGMA page_count;
PRAGMA freelist_count;
DELETE FROM readings WHERE id <= 2500;
PRAGMA page_count;
PRAGMA freelist_count;
VACUUM;
PRAGMA page_count;
PRAGMA freelist_count;শুরুতে 17. জোড় id মুছলে “২৫০০টি সারি বদলেছে”, কিন্তু page_count 17-ই, freelist মাত্র 1 — প্রতিটি page-এর অর্ধেক মুছেছে, page প্রায় কোনোটিই পুরো খালি হয়নি (গাছ পুনর্বিন্যাসে একটি মুক্ত হয়েছে)। তারপর id ≤ ২৫০০ মুছলে (১২৫০টি সারি) সামনের page-গুলো পুরো খালি: freelist 9, তবু ফাইল ১৭ page. VACUUM ফাইলটি নতুন করে লেখে: page_count 6, freelist 0. PostgreSQL-এর VACUUM নামে একই হলেও কাজ আলাদা — মৃত সারির জায়গা পুনর্ব্যবহারের জন্য চিহ্নিত করে, ফাইল সাধারণত ছোট করে না; ছোট করতে লাগে VACUUM FULL, যা টেবিল lock করে।
৬ · বড় মান, আর row বনাম column store
একটি record যদি page-এর চেয়েই বড় হয় — ধরুন একটি লম্বা নোটিশ, একটি JSON বা ছবি? তখন record-এর একটি অংশ page-এ থাকে, বাকিটা আলাদা page-এর শিকলে: overflow page. SQLite আর InnoDB এভাবে কাজ করে। PostgreSQL-এর কৌশলের নাম TOAST: সারি মোটামুটি ২ KB ছাড়ালে বড় মানগুলো আগে compress করা হয়, তাতেও না কুলোলে টুকরো করে একটি আলাদা TOAST টেবিলে রাখা হয়, আর মূল সারিতে থাকে শুধু একটি ছোট সূত্র।
CREATE TABLE notice (id INTEGER PRIMARY KEY, body TEXT);
INSERT INTO notice VALUES (1, 'অফিস বন্ধ');
PRAGMA page_count;
INSERT INTO notice VALUES (2, printf('%.*c', 20000, 'x'));
PRAGMA page_count;
SELECT id, length(body) AS chars FROM notice;প্রথমে 2, ২০,০০০ অক্ষরের সারির পর 6; শেষে 1 | 9 আর 2 | 20000. বাড়তি ৪টি page-ই overflow-এর শিকল। হিসাবটিও মেলে: ৪ KB page-এ প্রতিটি overflow page ৪০৯২ বাইট বহন করে (প্রথম ৪ বাইট পরের page-এর নম্বর), ৪ × ৪০৯২ = ১৬,৩৬৮ বাইট শিকলে, বাকি প্রায় ৩.৬ KB (record-এর হেডারসহ) মূল leaf page-এ। মজার পরিণাম: যে কোয়েরি শুধু id চায়, তাকে এই সারির জন্য overflow পড়তে হয় না; কিন্তু body চাইলে শিকলের প্রতিটি page আলাদা I/O. তাই বড় লেখা বা ফাইল আলাদা টেবিলে, বা ডেটাবেসের বাইরে রেখে শুধু ঠিকানা রাখার রীতি প্রচলিত।
Row store বনাম column store
এতক্ষণের সব নকশা row store: একটি সারির সব কলাম পাশাপাশি, একই page-এ। একটি অর্ডার পুরোটা পড়তে বা লিখতে — অর্থাৎ OLTP-এর কাজে — এটি আদর্শ। কিন্তু প্রশ্নটি যদি হয় “গত পাঁচ বছরে প্রতি মাসের মোট বিক্রি”? দরকার শুধু দুটি কলাম, অথচ row store-কে প্রতিটি সারির সব কলাম পড়তে হয়। Column store উল্টো: প্রতিটি কলাম আলাদা ধারায়, তাই দুটি কলাম মানে শুধু সেই দুই ধারা পড়া; আর একই ধরনের মান পাশাপাশি থাকায় compression দারুণ কাজ করে। দাম: একটি সারি ঢোকাতে প্রতিটি কলামের ধারায় লিখতে হয়।
একই তিনটি সারি, দুই রকম সাজানো
Row-oriented vs column-oriented layout
Row store: [1, 2025-09-01, Dhaka, 17400 │ 2, 2025-09-03, Khulna, 1750 │ 3, …] Column store: id: [1 │ 2 │ 3] date: [2025-09-01 │ 2025-09-03 │ 2025-09-10] city: [Dhaka │ Khulna │ Dhaka] amount: [17400 │ 1750 │ 1780] SUM(amount) → row store: সব page / column store: শুধু amount
ধারণাটি পুরোনো — ১৯৯০-এর দশকেই MonetDB আর Sybase IQ-র মতো column-ভিত্তিক সিস্টেম ছিল, আর ২০০৫ সালের C-Store গবেষণা (Michael Stonebraker ও সহকর্মীরা) একে নতুন করে জনপ্রিয় করে। আজ ClickHouse, DuckDB, Parquet ফাইল-ফরম্যাট আর বড় cloud warehouse-গুলো column-ভিত্তিক। SQL Server-এর columnstore index-এর মতো মিশ্র ব্যবস্থাও আছে।
৭ · Buffer pool — মেমোরিতে page-এর কপি
ডিস্ক এত ধীর বলে DBMS মেমোরির একটি বড় অংশ নিজের জন্য রাখে: buffer pool. পর্ব ৩-এর buffer manager এটি চালায়। buffer pool হলো একই আকারের অনেকগুলো ঘর — frame — প্রতিটিতে একটি page-এর কপি ধরে। PostgreSQL-এ এর আকার shared_, InnoDB-তে innodb_ — দুটিরই default মাত্র ১২৮ MB, আর আসল সার্ভারে সাধারণত অনেক বড় করে দেওয়া হয়। প্রতিটি frame-এর সাথে কিছু হিসাব:
Page table
একটি hash table: কোন page কোন frame-এ। “page 42 আছে?” — এক ঝলকে উত্তর। (OS-এর virtual memory-র page table থেকে আলাদা জিনিস।)
Pin count
এই মুহূর্তে কতজন page-টি ব্যবহার করছে। ০-এর বেশি হলে page-টি সরানো নিষেধ।
Dirty bit
মেমোরির কপি বদলেছে কিন্তু ডিস্কে লেখা হয়নি। এমন page সরানোর আগে ডিস্কে লিখতেই হবে।
Replacement policy
সব frame ভরা থাকলে নতুন page-এর জন্য কোনটি সরবে — LRU, clock বা আরও উন্নত নিয়ম।
কোনো অংশ (ধরুন একটি scan) page চাইলে buffer manager এই ধাপে কাজ করে: page table-এ খোঁজে; থাকলে (hit) pin count বাড়িয়ে frame-এর ঠিকানা দেয় — কোনো I/O নেই। না থাকলে (miss) policy একটি pin count ০ frame বেছে নেয়; সেটি dirty হলে আগে ডিস্কে লেখে; তারপর নতুন page পড়ে, page table হালনাগাদ করে, pin = ১ দিয়ে ফেরত দেয়। ব্যবহারকারী কাজ শেষে unpin করে, আর page বদলে থাকলে dirty চিহ্ন দেয়। নিচে ৩টি frame-এর একটি ছোট buffer pool, LRU (least recently used — যে page সবচেয়ে বেশি সময় ধরে ব্যবহৃত হয়নি, সেটি সরানো) নিয়মে:
| F0 | |
| F1 | |
| F2 | |
| LRU ক্রম (পুরোনো → নতুন) | |
| ফল | |
| মোট I/O |
- ঠান্ডা শুরু। pool খালি, তাই প্রথম অনুরোধই miss. page 1 ডিস্ক থেকে F0-এ আসে, page table-এ লেখা হয় 1→F0.
- আরেকটি miss. page 2 আসে F1-এ। LRU ক্রমে 2 এখন সবচেয়ে নতুন।
- pool ভরে গেল। page 3 আসে F2-এ। এরপর থেকে প্রতিটি miss-এ কাউকে সরাতে হবে।
- প্রথম hit. page 1 আগে থেকেই আছে — ডিস্কে যেতে হলো না। ব্যবহারের সময় pin count ১, তাই এই মুহূর্তে কেউ একে সরাতে পারত না; শেষে unpin. LRU ক্রমে 1 এখন সবার শেষে (সবচেয়ে নতুন)।
- শিকার বাছাই। page 4 লাগবে। ক্রমের শুরুতে page 2 — সবচেয়ে দীর্ঘ সময় অব্যবহৃত, pin ০, পরিষ্কার। তাই লেখা ছাড়াই সরে যায়। UPDATE-এর পর 4-এর dirty bit চালু।
- সরানো page আবার লাগল। একটু আগে সরানো page 2-ই আবার চাওয়া হলো — আবার I/O. এবার শিকার page 3.
- আবার hit. page 1 বারবার ব্যবহৃত হচ্ছে বলে LRU তাকে রেখে দিচ্ছে — LRU-র মূল বাজি: সম্প্রতি ব্যবহৃত page শিগগির আবার লাগবে।
- Dirty শিকার। page 5-এর জন্য শিকার page 4, কিন্তু সেটি dirty — আগে ডিস্কে লিখতে হয়, তারপর নতুন page পড়া: একটি miss-এ দুটি I/O. শেষ হিসাব ৮টি অনুরোধে ২টি hit, ৭টি I/O.
বাস্তবে hit ratio মাপা DBA-র প্রতিদিনের কাজ। OLTP সার্ভারে গরম ডেটার প্রায় সবই buffer pool-এ থাকার কথা; hit ratio অনেকটা পড়ে গেলে হয় pool ছোট, নয়তো কোনো কোয়েরি অপ্রয়োজনে বিশাল scan করছে। PostgreSQL-এ EXPLAIN (ANALYZE, BUFFERS) একটি কোয়েরির জন্য shared hit (pool থেকে পাওয়া) আর read (pool-এর বাইরে থেকে আনা) page গুনে দেখায়:
SHOW block_size; -- 8192
SHOW shared_buffers; -- default: 128MB
SELECT ctid, * FROM orders LIMIT 3; -- ctid যেমন (0,1), (0,2): block আর slot
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE customer_id = 1;৮ · কোন page যাবে — LRU, clock আর sequential flooding
সত্যিকারের LRU-র একটি সমস্যা: প্রতিটি hit-এ page-টিকে তালিকার শেষে সরাতে হয়, আর হাজারো thread একসাথে সেই তালিকা বদলাতে গেলে lock-এর লড়াই বাধে। তাই অনেক সিস্টেম একটি সস্তা আনুমানিক রূপ ব্যবহার করে — clock (second chance). frame-গুলো একটি বৃত্তে, প্রতিটির একটি reference bit; hit হলে শুধু bit = ১, তালিকা নড়ে না। শিকার খুঁজতে একটি কাঁটা (hand) ঘোরে: bit ১ হলে ০ করে এগিয়ে যায় (“আরেকটি সুযোগ”), bit ০ পেলে সেটিই শিকার।
| F0 | |
| F1 | |
| F2 | |
| কাঁটা | |
| hit |
- A আসে। ফাঁকা F0-এ, bit ১. কাঁটা এগিয়ে F1-এ।
- B আসে। F1-এ, bit ১. কাঁটা F2-এ।
- C আসে। F2-এ; pool ভরা, কাঁটা ঘুরে F0-এ।
- A-র hit. শুধু bit ১ করা — কোনো তালিকা সরানো নেই। LRU-র চেয়ে এটাই clock-এর সাশ্রয়।
- D-র জন্য পুরো এক পাক। সবার bit ১, তাই কাঁটা সবাইকে ০ করে ঘুরে আবার F0-এ এসে A-কে সরায় — অথচ A সদ্য ব্যবহৃত! সবার bit ১ হলে clock আসলে FIFO-র মতো আচরণ করে; এটাই আনুমানিকতার দাম। D বসে F0-এ, কাঁটা F1-এ।
- B-র hit. B-এর bit আবার ১.
- দ্বিতীয় সুযোগ কাজে লাগল। E-র জন্য কাঁটা F1-এ B-কে পায় bit ১ নিয়ে — সদ্য ব্যবহৃত, তাই বাঁচে (bit ০). F2-এর C ব্যবহৃত হয়নি, bit ০ — সে সরে। ৭টি অনুরোধে ২টি hit; একই অনুরোধে LRU পেত ১টি (D-র সময় B-কে সরিয়ে)। আনুমানিক নিয়ম কখনো ভালো, কখনো খারাপ।
PostgreSQL clock-এর একটি রূপ ব্যবহার করে (clock sweep): bit-এর বদলে একটি usage count, যা প্রতিটি ব্যবহারে বাড়ে (সর্বোচ্চ ৫) আর কাঁটার প্রতি পাকে এক কমে — ফলে বারবার ব্যবহৃত page বেশি পাক টিকে থাকে।
Sequential flooding — একটি scan যখন সব ভাসিয়ে নেয়
LRU-র সবচেয়ে বিখ্যাত দুর্বলতা দেখা দেয় বড় একটানা scan-এ। ধরুন টেবিলে ৪টি page, buffer pool-এ ৩টি frame, আর একটি রিপোর্ট টেবিলটি দুবার পড়ে: ১ ২ ৩ ৪ ১ ২ ৩ ৪. LRU যাকে সরায়, ঠিক সেটিই পরের অনুরোধ। তুলনার জন্য পাশে উল্টো নিয়ম MRU (most recently used — সবচেয়ে সাম্প্রতিকটি সরানো):
| LRU frame | |
| LRU hit | |
| MRU frame | |
| MRU hit |
- শুরু। দুই নিয়মেই প্রথম তিনটি অনুরোধ ঠান্ডা miss — এখানে কোনো নিয়মের দোষ নেই।
- দ্বিতীয় page. এখনো ফাঁকা frame আছে; দুজনেরই miss.
- pool ভরা। এরপর প্রতিটি miss-এ কাউকে সরাতে হবে।
- প্রথম সিদ্ধান্ত। page 4-এর জন্য LRU সবচেয়ে পুরোনো 1-কে সরায়; MRU সদ্য পড়া 3-কে সরায়, আর 1, 2 রেখে দেয়।
- দ্বিতীয় পাক। scan আবার page 1 চায়। LRU একটু আগেই 1-কে সরিয়েছে — miss, আর এবার 2-কে সরায়, যা ঠিক পরের অনুরোধ। MRU-র কাছে 1 আছে — hit.
- প্যাটার্ন স্পষ্ট। LRU প্রতিবার যে page সরায়, পরের অনুরোধ ঠিক সেটিই। MRU আবার hit.
- MRU-রও miss. page 3 MRU আগে সরিয়েছিল; এবার সে 2-কে সরায়।
- ফল। LRU ৮-এ ০ — page-এর সংখ্যা frame-এর চেয়ে একটিও বেশি হলে একটানা পুনরাবৃত্ত scan-এ LRU কখনো hit পায় না। একে বলে sequential flooding. বাস্তবে আরও খারাপ দিক: একবারের scan buffer pool-এর অন্য গরম page-গুলোকেও ভাসিয়ে নেয়।
শুধু MRU-তে চলে যাওয়া সমাধান নয় — সাধারণ OLTP-তে MRU ভয়ংকর। বাস্তব সিস্টেম scan-কে আলাদা করে সামলায়:
- PostgreSQL: buffer pool-এর এক-চতুর্থাংশের চেয়ে বড় টেবিলের sequential scan একটি ছোট ring buffer (২৫৬ KB) ব্যবহার করে — scan নিজের ছোট বৃত্তেই page ঘোরায়, বাকি pool অক্ষত।
- InnoDB: LRU তালিকার মাঝখানে নতুন page ঢোকায় (midpoint insertion): তালিকার শেষ প্রায় ৩/৮ অংশ “পুরোনো” উপতালিকা। নতুন পড়া page সেখানে বসে, আর অন্তত কিছু সময় (default ১ সেকেন্ড) পরে আবার ব্যবহৃত হলে তবেই “নতুন” অংশে ওঠে।
- LRU-K আর 2Q: শুধু শেষ ব্যবহার নয়, শেষ K-টি ব্যবহারের সময় দেখা (LRU-2 সবচেয়ে প্রচলিত); বা নতুন page-কে আগে একটি ছোট পরীক্ষামূলক সারিতে রাখা, দ্বিতীয়বার লাগলে তবেই মূল pool-এ তোলা। একবার পড়া scan-এর page মূল pool-এ পৌঁছায়ই না।
নিচের ছোট JavaScript প্রোগ্রাম ৪টি frame-এ LRU আর একটি সরল 2Q-ধাঁচের নিয়ম পাশাপাশি চালায়: ৩টি গরম page (A, B, C) বারবার, তারপর ৬ page-এর একটি scan (1–6), তারপর আবার গরম page. প্রতিটি অনুরোধে H = hit, . = miss. অনুরোধের তালিকা বদলে নিজে পরীক্ষা করুন।
function lru(refs, n) { // pool[0] = least recently used
const pool = []; let out = "";
for (const p of refs) {
const i = pool.indexOf(p); out += i >= 0 ? "H" : ".";
if (i >= 0) pool.splice(i, 1); else if (pool.length === n) pool.shift();
pool.push(p);
}
return out;
}
function twoQ(refs, n) { // 1 probation frame, n-1 main frames, ids of pages seen once
let probe = null, out = ""; const main = [], seen = [];
for (const p of refs) {
const m = main.indexOf(p), hit = m >= 0 || probe === p; out += hit ? "H" : ".";
if (m >= 0) { main.splice(m, 1); main.push(p); }
else if (!hit && seen.includes(p)) { seen.splice(seen.indexOf(p), 1); if (main.length === n - 1) main.shift(); main.push(p); }
else if (!hit) { if (probe) { seen.push(probe); if (seen.length > n) seen.shift(); } probe = p; }
}
return out;
}
const refs = [..."ABCABCABC", ..."123456", ..."ABCABC"]; // hot pages, a scan, hot again
console.log("refs " + refs.join(""));
for (const [name, f] of [["LRU", lru], ["2Q", twoQ]]) { const s = f(refs, 4); console.log(name.padEnd(5), s, "hits:", s.split("H").length - 1); }আউটপুট তিন লাইন:
refs ABCABCABC123456ABCABC LRU ...HHHHHH.........HHH hits: 9 2Q .....HHHH......HH.HHH hits: 9
মোট hit সমান, কিন্তু কোথায় hit, তা দেখুন। scan-এর পর LRU-তে A, B, C তিনটিই miss — scan সবাইকে ভাসিয়ে দিয়েছে। 2Q-তে শুধু C miss: A আর B মূল pool-এ ছিল, আর scan-এর page-গুলো একবার করে এসে পরীক্ষামূলক frame থেকেই বিদায় নিয়েছে। দাম শুরুতে: 2Q-তে নতুন গরম page মূল pool-এ উঠতে দ্বিতীয় অনুরোধ লাগে, তাই প্রথম পাকে বেশি miss. বাস্তবে scan হাজারো page-এর আর গরম ডেটা দিনের পর দিন একই — তখন এই সুরক্ষার মূল্য অনেক।
৯ · Dirty page লেখা, WAL আর OS-এর page cache
Dirty page কখন ডিস্কে যাবে? শুধু শিকার হওয়ার সময় লিখলে হঠাৎ একটি কোয়েরি ধীর হয়ে যায়, আর crash হলে মেমোরির সব পরিবর্তন হারায়। তাই DBMS-এর পেছনে একটি প্রক্রিয়া (PostgreSQL-এ background writer আর checkpointer, InnoDB-তে page cleaner) ধীরে ধীরে dirty page লিখে রাখে, যাতে শিকার বাছাইয়ের সময় পরিষ্কার frame পাওয়া যায়।
কিন্তু commit-এর সময় সব বদলানো page লিখে ফেললেই তো নিরাপদ? সেটি random লেখার পাহাড় — প্রতিটি ছোট transaction-এ এখানে-ওখানে দশটি page. সমাধান write-ahead log (WAL): commit-এর সময় শুধু পরিবর্তনের বিবরণ একটি log ফাইলের শেষে একটানা (sequential!) লেখা হয় আর fsync দিয়ে ডিস্কে নিশ্চিত করা হয়; data page পরে, সুবিধামতো। আর buffer manager-এর একটি কঠোর নিয়ম: একটি dirty page ডিস্কে লেখার আগে, তার পরিবর্তনের log record অবশ্যই ডিস্কে পৌঁছাতে হবে। crash হলে log থেকে সব আবার গড়া যায়। পুরো গল্প — checkpoint আর ARIES — পর্ব ৪৮-এ।
OS-এর page cache আর double buffering
অপারেটিং সিস্টেমও নিজের একটি cache রাখে: সাধারণ read() দিয়ে পড়া ফাইলের অংশ মেমোরিতে — OS page cache. DBMS নিজের buffer pool-ও রাখলে একই page মেমোরিতে দুবার থাকতে পারে: double buffering. এর দুটি সমাধানের পথ:
OS-কে পাশ কাটানো
Linux-এ O_DIRECT দিয়ে ফাইল খুললে পড়া-লেখা OS cache এড়িয়ে সরাসরি ডিস্কে যায়। InnoDB-র প্রচলিত সুপারিশ এটাই; তখন buffer pool-কে RAM-এর বড় অংশ দেওয়া হয়।
OS-এর সাথে ভাগাভাগি
PostgreSQL সাধারণ buffered I/O ব্যবহার করে আর OS cache-এর ওপরও ভরসা রাখে; তাই প্রচলিত পরামর্শ shared_buffers RAM-এর প্রায় এক-চতুর্থাংশ, বাকিটা OS cache-এর জন্য।
আরও গভীরে: ১৬ KB-র একটি InnoDB page লেখার মাঝখানে বিদ্যুৎ গেলে ডিস্কে অর্ধেক নতুন, অর্ধেক পুরোনো page থাকতে পারে — torn page, কারণ ডিস্ক বা OS লেখার পারমাণবিক নিশ্চয়তা দেয় ছোট এককে। InnoDB তাই প্রতিটি page আগে একটি doublewrite এলাকায়, তারপর আসল জায়গায় লেখে; PostgreSQL checkpoint-এর পর কোনো page প্রথমবার বদলালে পুরো page-টিই WAL-এ লেখে (full_page_writes). দুটিই একই ব্যর্থতার দুই রকম প্রতিকার, আর দুটিরই দাম বাড়তি লেখা — I/O cost model-এ অদৃশ্য, অথচ লেখা-ভারী সিস্টেমে খুব বাস্তব।
পরের পর্বগুলোর জন্য I/O cost model
পর্ব ৪০–৪৩-এ খরচের হিসাব এই ভাষায় হবে: টেবিল R-এ B(R)টি page, T(R)টি সারি, buffer pool-এ Mটি frame. full scan-এর খরচ B(R); B+ tree-এ একটি key খোঁজা ≈ উচ্চতা h; আর কোনো অ্যালগরিদম “M frame-এ কী ধরে”, তা দিয়েই sort আর join-এর কৌশল বদলায়। মনে রাখবেন মডেলের সরলীকরণগুলো: random আর sequential I/O-কে সমান ধরা হয়, CPU বাদ, আর buffer pool-এ আগে থেকে থাকা page প্রায়ই উপেক্ষিত। আসল optimizer (পর্ব ৪৩) random page-এ বেশি দাম ধরে — PostgreSQL-এ random_-এর default ৪, seq_ ১.
১০ · AI-এর যুগে স্টোরেজ আর পারফরম্যান্সের প্রশ্ন
“ডেটাবেস ধীর, কনফিগারেশন ঠিক করে দাও” — AI আত্মবিশ্বাসের সাথে সেটিং-এর তালিকা দেয়, কিন্তু প্রায়ই আপনার RAM, ডিস্ক, সংস্করণ বা কাজের ধরন না জেনে। সাধারণ ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| সিস্টেম গুলিয়ে ফেলা: PostgreSQL-এ InnoDB-র সেটিং, page size ভুল | সেটিং কাজই করে না বা ভুল সিদ্ধান্ত | SHOW, SHOW VARIABLES, PRAGMA দিয়ে আসল মান দেখুন |
| অস্তিত্বহীন প্যারামিটারের নাম বানানো | সার্ভার চালু হয় না বা নীরবে উপেক্ষা | অফিসিয়াল ডকুমেন্টেশনে নামটি খুঁজুন |
| buffer pool-কে প্রায় সব RAM দেওয়া | OS আর সংযোগের জন্য মেমোরি নেই, swap | মোট RAM, অন্য প্রক্রিয়া আর O_DIRECT কি না হিসাব করুন |
| index ছাড়াই “RAM বাড়ান” | full scan তবু চলে, flooding | EXPLAIN আর buffer hit/read মাপুন |
| SQLite-এর ফল (dynamic typing, rowid) সব DBMS-এ খাটে বলা | অন্য সিস্টেমে ভুল প্রত্যাশা | প্রতিটি দাবি কোন সিস্টেমের, জিজ্ঞেস করুন |
AI-কে সংখ্যা আর প্রমাণ দিন, তারপর প্রমাণসহ উত্তর চান:
AI-এর পরামর্শ চালু করার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Slotted page কী, কেন? — হেডার আর slot array সামনে, record শেষ থেকে; record page-এর ভেতরে সরলেও RID (page, slot) বদলায় না।
- Buffer pool-এ pin count আর dirty bit-এর কাজ? — pin > ০ হলে page সরানো যায় না; dirty হলে সরানোর আগে ডিস্কে লিখতে হয় (আগে তার WAL).
- Sequential flooding কী? — frame-এর চেয়ে বড় টেবিলের বারবার scan-এ LRU প্রতিবার ঠিক পরের দরকারি page-টিই সরায়, hit শূন্য।
- Clustered আর non-clustered সংগঠনের পার্থক্য? — clustered-এ সারি নিজেই key-র ক্রমে (InnoDB), heap-এ ক্রম নেই আর index আলাদা (PostgreSQL).
- HDD-তে random পড়া ধীর কেন? — seek time আর rotational latency মিলে কয়েক ms; sequential-এ শুধু transfer time.
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১OS তো নিজেই page cache রাখে, আর mmap দিয়ে পুরো ফাইল মেমোরির মতো ব্যবহার করা যায়। তাহলে বেশির ভাগ বড় DBMS নিজের buffer pool বানাতে এত কষ্ট করে কেন?
প্রশ্নটি পুরোনো। ১৯৮১ সালে Michael Stonebraker “Operating System Support for Database Management” প্রবন্ধে যুক্তি দেন, OS-এর সাধারণ cache ডেটাবেসের প্রয়োজন মেটায় না। প্রথম কারণ জ্ঞান: OS জানে না কোন page একটি বড় scan-এর (একবারই লাগবে) আর কোনটি B+ tree-এর root (বারবার লাগবে)। DBMS জানে, তাই ring buffer বা midpoint insertion-এর মতো নিয়ম বানাতে পারে।
দ্বিতীয় আর সবচেয়ে জরুরি কারণ লেখার ক্রম। WAL-এর নিয়ম বলে, data page-এর আগে তার log record ডিস্কে যেতে হবে। OS-এর cache নিজের সুবিধামতো যেকোনো সময় dirty page ডিস্কে লিখে দিতে পারে — DBMS-কে না জানিয়ে। mmap-এ এই নিয়ন্ত্রণ আরও কম। তৃতীয় কারণ ব্যর্থতা সামলানো: ২০১৮ সালে PostgreSQL-এর ডেভেলপাররা দেখেন, Linux-এ fsync ব্যর্থ হলে পরের fsync “সফল” বলতে পারে, অথচ লেখা হারিয়ে গেছে। এরপর PostgreSQL fsync ব্যর্থ হলে সরাসরি থেমে WAL থেকে recover করার নীতি নেয়।
mmap নিয়ে ২০২২ সালে Andrew Crotty, Viktor Leis আর Andrew Pavlo-র প্রবন্ধ “Are You Sure You Want to Use MMAP in Your Database Management System?” সরাসরি সতর্ক করে: ব্যর্থতার পরিস্থিতি, TLB shootdown আর page table-এর প্রতিযোগিতায় দ্রুত NVMe ডিস্কে mmap-ভিত্তিক নকশা পিছিয়ে পড়ে। বাস্তব উদাহরণও আছে — MongoDB-র পুরোনো MMAPv1 ইঞ্জিনের জায়গা নেয় নিজস্ব cache-যুক্ত WiredTiger.
তবে উল্টো দিকও আছে। LMDB-র মতো কিছু ছোট, পড়া-প্রধান ইঞ্জিন mmap দিয়ে সরলতা আর গতি পায়। PostgreSQL নিজের buffer pool রাখলেও OS cache-এর ওপর আংশিক ভরসা করে, যার দাম double buffering. আর SQLite নিজের ছোট page cache রেখে বাকিটা OS-কে ছাড়ে — একটি ফোন অ্যাপের জন্য সেটিই যুক্তিসংগত।
মূল উপলব্ধি: নিজস্ব buffer pool মানে নিয়ন্ত্রণ — কোন page থাকবে আর কখন কোন ক্রমে ডিস্কে যাবে। সঠিকতার (WAL) জন্য এই নিয়ন্ত্রণ অপরিহার্য, গতির জন্য প্রায়ই লাভজনক।
প্র ০২PostgreSQL ৮ KB, InnoDB ১৬ KB, SQLite ৪ KB. page-এর “সঠিক” আকার বলে কিছু আছে কি? বড় আর ছোট page-এর লাভ-ক্ষতি কী?
সঠিক আকার নেই, আছে বিনিময়। বড় page-এর পক্ষে: একটি I/O-তে বেশি ডেটা, তাই একটানা scan-এ কম অনুরোধ; B+ tree-এর প্রতিটি node-এ বেশি key, তাই গাছ চওড়া আর নিচু — একটি খোঁজায় কম স্তর; আর প্রতি page-এর হেডারের অনুপাতে খরচ কমে। HDD-র যুগে যখন প্রতিটি অনুরোধে seek-এর বড় দাম, তখন এটি আরও জরুরি ছিল।
ছোট page-এর পক্ষে: OLTP-তে বেশির ভাগ কোয়েরি একটি বা কয়েকটি সারি চায়। ১৬ KB page-এ একটি ১০০ বাইটের সারির জন্য বাকি ১৫.৯ KB বাড়তি পড়া আর buffer pool-এ জায়গা দখল। লেখাতেও তাই: একটি সারি বদলালে পুরো page dirty, পুরো page ডিস্কে লিখতে হয় — SSD-তে write amplification বাড়ে। আর বেশি সারি এক page-এ মানে একই page নিয়ে বেশি thread-এর প্রতিযোগিতা।
আরেকটি সূক্ষ্ম দিক নিরাপত্তা। ডিস্ক সাধারণত ৫১২ বাইট বা ৪ KB এককে লেখার পারমাণবিক নিশ্চয়তা দেয়। DBMS-এর page তার চেয়ে বড় হলে crash-এ torn page হতে পারে, তাই InnoDB-র doublewrite বা PostgreSQL-এর full page write — বাড়তি লেখা। SQLite-এর ৪ KB default আংশিকভাবে এ কারণেই আধুনিক ডিস্ক আর OS-এর page-এর সাথে মেলে।
বাস্তবে সিস্টেমগুলো তাই নমনীয়তা দেয়: InnoDB-তে ডেটাবেস বানানোর সময় ৪ থেকে ৬৪ KB, Oracle-এ tablespace ধরে আলাদা block size, SQLite-এ PRAGMA দিয়ে ফাইল ধরে। বিশ্লেষণধর্মী column store-গুলো আরও বড় একক ব্যবহার করে — Parquet-এর row group প্রায়ই অনেক MB — কারণ সেখানে কাজই হলো বিপুল একটানা পড়া।
মূল উপলব্ধি: page-এর আকার পড়ার ধরনের সাথে মেলাতে হয় — ছোট, ছড়ানো পড়ায় ছোট page; বড়, একটানা পড়ায় বড় page. আর ডিস্কের পারমাণবিক লেখার এককও হিসাবের অংশ।
প্র ০৩Jim Gray-এর বিখ্যাত “five-minute rule” কী বলে? RAM বাড়ানো আর ডিস্ক বাড়ানোর মধ্যে সিদ্ধান্ত নিতে এই নিয়ম কীভাবে ভাবতে শেখায়?
১৯৮৭ সালে Jim Gray আর Gianfranco Putzolu একটি সরল প্রশ্ন করেন: একটি page মেমোরিতে রেখে দেওয়া সস্তা, নাকি প্রতিবার ডিস্ক থেকে আনা? মেমোরিতে রাখার দাম সেই page-এর জায়গার RAM-এর দাম। প্রতিবার আনার দাম ডিস্কের “অনুরোধ করার ক্ষমতা”র একটি অংশ — একটি ডিস্ক সেকেন্ডে সীমিত সংখ্যক random অনুরোধ নিতে পারে, তাই বেশি অনুরোধ মানে বেশি ডিস্ক কেনা।
দুটি সমান করলে একটি সীমা-সময় পাওয়া যায়: page-টি যদি এই সময়ের চেয়ে ঘন ঘন ব্যবহৃত হয়, মেমোরিতে রাখা সস্তা; বিরল হলে ডিস্কে রাখা। সূত্রটির আকার মোটামুটি: সীমা-সময় = (প্রতি MB RAM-এ কতগুলো page ÷ প্রতি ডিস্কে সেকেন্ডে কত অনুরোধ) × (একটি ডিস্কের দাম ÷ প্রতি MB RAM-এর দাম). সেই সময়ের দামে ফলটি এসেছিল প্রায় পাঁচ মিনিট — তাই নাম।
নিয়মটির শক্তি সংখ্যায় নয়, ভাবনার কাঠামোয়। গত কয়েক দশকে RAM আর ডিস্ক দুটোরই দাম কমেছে, কিন্তু ভিন্ন হারে, তাই Gray নিজেই ১৯৯৭ সালে, আর পরে অন্য গবেষকেরা SSD-যুগে হিসাবটি নতুন করে কষেছেন। SSD সেকেন্ডে অনেক বেশি অনুরোধ নিতে পারে বলে সীমা-সময় ছোট হয়ে আসে — অর্থাৎ কম গরম ডেটা SSD-তে রেখে দেওয়াই সাশ্রয়ী।
ব্যবহারিক শিক্ষা: “সব ডেটা RAM-এ রাখুন” আর “RAM লাগবে না, SSD যথেষ্ট” — দুটিই চরম। আপনার ডেটার কতটা অংশ ঘন ঘন ব্যবহৃত, তা মাপুন (buffer hit ratio, কোন টেবিল কত পড়া হয়)। গরম অংশ buffer pool-এ ধরার মতো RAM কিনুন, ঠান্ডা ইতিহাস সস্তা ডিস্কে থাকুক। cloud-এ এই হিসাব প্রতি মাসের বিলে সরাসরি দেখা যায়।
মূল উপলব্ধি: মেমোরি আর ডিস্কের সিদ্ধান্ত একটি অর্থনৈতিক হিসাব — ব্যবহারের ঘনত্ব বনাম দাম। যন্ত্র বদলায়, প্রশ্নের কাঠামো একই থাকে।
প্র ০৪InnoDB সারি রাখে primary key ধরে সাজানো গাছে, PostgreSQL রাখে heap-এ আর index আলাদা। একটি অ্যাপের জন্য এই পার্থক্য কখন বাস্তবে টের পাওয়া যায়?
Primary key দিয়ে খোঁজা আর key-এর পরিসর পড়ায় InnoDB-র clustered নকশা চমৎকার: গাছে নামলেই সারি, আর পাশাপাশি key-এর সারি একই page-এ। কিন্তু secondary index-এ থাকে primary key-র মান, তাই secondary index দিয়ে খুঁজলে দুবার গাছে নামতে হয়। আরেকটি ফাঁদ: primary key যদি এলোমেলো হয় (যেমন random UUID), নতুন সারি গাছের যেকোনো জায়গায় ঢোকে — page split, অর্ধেক-ভরা page আর buffer pool-এ ছড়ানো লেখা। এজন্য InnoDB-তে ক্রমবর্ধমান key (AUTO_INCREMENT বা সময়-ক্রমের UUID) সুপারিশ করা হয়।
PostgreSQL-এ সব index সরাসরি heap-এর ctid দেখায়, তাই secondary index-এ এক ধাপ কম। কিন্তু MVCC-র কারণে UPDATE আসলে সারির নতুন সংস্করণ লেখে, নতুন ctid-এ; তখন প্রতিটি index-এ নতুন ঠিকানা লেখার প্রয়োজন হতে পারে। PostgreSQL এটি কমাতে HOT update ব্যবহার করে: কোনো indexed কলাম না বদলালে আর একই page-এ জায়গা থাকলে নতুন সংস্করণ একই page-এ বসে, index ছুঁতে হয় না — তাই টেবিলে fillfactor কমিয়ে ফাঁকা জায়গা রাখার কৌশল।
এই পার্থক্য বিখ্যাত হয় ২০১৬ সালে, যখন Uber-এর ইঞ্জিনিয়ারিং দল একটি ব্লগে লেখে তারা কিছু কাজ PostgreSQL থেকে MySQL-এ সরিয়েছে, অন্যতম কারণ হিসেবে অনেক index থাকা টেবিলে UPDATE-এর লেখার বিস্তার আর replication-এর আচরণ। PostgreSQL সম্প্রদায় এর অনেক যুক্তির জবাবও দেয়। শিক্ষাটি কোনো DBMS-এর জয়-পরাজয় নয়: storage-এর নকশা ঠিক করে কোন কাজের ধরন সস্তা।
আরও দুটি পার্থক্য: PostgreSQL-এ VACUUM মৃত সংস্করণ সরায়, InnoDB পুরোনো সংস্করণ রাখে undo log-এ (পর্ব ৪৭)। আর পুরো টেবিল scan-এ heap সাধারণত সরল ও দ্রুত, clustered গাছে leaf-গুলো ডিস্কে সবসময় পাশাপাশি না-ও থাকতে পারে।
মূল উপলব্ধি: clustered সংগঠন primary key-ভিত্তিক পড়ায় জেতে, heap আর আলাদা index হালনাগাদ-ধরন আর secondary খোঁজায় অন্য রকম দাম দেয়। নিজের অ্যাপ কোন কাজ বেশি করে, তা দিয়েই বিচার করুন।
প্র ০৫একটি সংস্থার দিনের লেনদেন চলে row store-এ, আর রাতে বিশ্লেষণের জন্য ডেটা যায় column store-এ। একই ডেটার দুই রকম সাজানো কেন? একটি সিস্টেম দুটোই ভালো পারে না কেন?
দুই কাজের পড়ার ধরন উল্টো। দিনের কাজে (OLTP) প্রশ্ন হলো “অর্ডার 1001-এর সব তথ্য”, “এই গ্রাহকের ব্যালান্স ১০০ টাকা কমাও” — একটি সারির সব কলাম, বারবার, লাখো ছোট লেনদেনে। row store-এ সারিটি এক page-এ, এক I/O-তে আসে, এক page-এ লেখা হয়। বিশ্লেষণে (OLAP) প্রশ্ন হলো “গত তিন বছরে জেলা আর মাস অনুযায়ী বিক্রি” — কোটি সারি, কিন্তু মাত্র তিন-চারটি কলাম। row store-কে প্রতিটি সারির সব কলাম পড়তে হয়; ২০টি কলামের টেবিলে হয়তো ৯০ শতাংশ পড়াই অপ্রয়োজনীয়।
Column store এখানে তিনভাবে জেতে। শুধু দরকারি কলাম পড়ে; একই টাইপের, প্রায়ই পুনরাবৃত্ত মান (জেলার নাম, তারিখ) পাশাপাশি থাকায় run-length বা dictionary compression চমৎকার কাজ করে, তাই কম বাইট; আর একই কলামের হাজারো মানের ওপর CPU একসাথে একই হিসাব চালাতে পারে (vectorized execution). ২০০৫ সালের C-Store গবেষণা এই সুবিধাগুলো জোরালোভাবে দেখায়, আর পরে Vertica-র মতো বাণিজ্যিক সিস্টেম আসে।
কিন্তু একটি নতুন অর্ডার ঢোকাতে column store-কে প্রতিটি কলামের আলাদা ধারায় লিখতে হয়, compress করা অংশ নতুন করে গড়তে হয় — ছোট, ঘন ঘন লেখায় এটি দামি। তাই অনেক column store নতুন সারি আগে একটি ছোট row-ধাঁচের এলাকায় জমিয়ে পরে দলবদ্ধভাবে column আকারে সাজায়। অন্য দিকে row store-গুলোও column-এর সুবিধা আনছে: SQL Server-এর columnstore index, Oracle-এর in-memory column store, PostgreSQL-এর extension. যে সিস্টেম দুটোই একসাথে করার দাবি করে, তাকে HTAP বলা হয়।
তবু অনেক সংস্থায় ETL দিয়ে আলাদা warehouse রাখার আরেকটি কারণ বিচ্ছিন্নতা: একটি ভারী বিশ্লেষণী কোয়েরি যেন দিনের লেনদেনের buffer pool ভাসিয়ে না দেয় — sequential flooding-এর বড় রূপ। পর্ব ৫৫-এ এই নকশা বিস্তারিত।
মূল উপলব্ধি: storage layout একটি বাজি — কোন প্রশ্ন বেশি আসবে তার ওপর। সারি ধরে সাজালে সারি সস্তা, কলাম ধরে সাজালে কলাম; দুই কাজের জন্য দুই সাজানো প্রায়ই সবচেয়ে সৎ সমাধান।
অনুশীলন
একটি কোয়েরি লেখা, একটি হাতে trace, একটি page-এর হিসাব, একটি I/O-র তুলনা।
-
কোয়েরি লিখুন: একটি নতুন ডেটাবেসে
students(id INTEGER PRIMARY KEY, name TEXT)বানিয়ে recursive CTE দিয়ে ১০,০০০টি সারি ঢোকান, যেখানে name হলো'Student-'আর id জোড়া লাগানো লেখা। তারপর একটি কোয়েরিতে page_count, ফাইলের মোট বাইট আর প্রতি সারিতে গড় বাইট (মোট বাইট ÷ ১০,০০০, এক দশমিক পর্যন্ত) দেখান। (অংশ ৩-এর যেকোনো এডিটরে লিখে চালাতে পারেন।)CREATE TABLE students (id INTEGER PRIMARY KEY, name TEXT); WITH RECURSIVE s(x) AS (SELECT 1 UNION ALL SELECT x + 1 FROM s WHERE x < 10000) INSERT INTO students SELECT x, 'Student-' || x FROM s; SELECT p.page_count, p.page_count * z.page_size AS bytes, round(p.page_count * z.page_size / 10000.0, 1) AS bytes_per_row FROM pragma_page_count() p, pragma_page_size() z;দুটি খুঁটিনাটি:
10000.0দিয়ে ভাগ, নইলে পূর্ণসংখ্যার ভাগে দশমিক হারায় (পর্ব ১৬)। আর এই “প্রতি সারিতে বাইট”-এ schema page, root page আর page-এর ভেতরের ফাঁকা জায়গাও ধরা — তাই এটি সারির আসল আকারের চেয়ে একটু বেশি। SQLite-এ এটি চালালে page_count আসে51, মোট বাইট208896, প্রতি সারিতে20.9. -
হাতে trace করুন: ৩টি frame-এর buffer pool, LRU নিয়ম, শুরুতে খালি। অনুরোধ: 4, 1, 4, 2, 3, 1, 4. page 2 আসার পর একটি UPDATE তাকে dirty করেছে। প্রতিটি অনুরোধে hit না miss, কে সরল, শেষে কোন page-গুলো থাকে, আর মোট কতটি ডিস্ক I/O?
4 miss [4] → 1 miss [4 1] → 4 hit [1 4] → 2 miss [1 4 2], 2 dirty → 3 miss, LRU 1 সরে [4 2 3] → 1 miss, LRU 4 সরে [2 3 1] → 4 miss, LRU 2 সরে — dirty, তাই আগে লেখা [3 1 4].
মোট ১টি hit, ৬টি miss; শেষে pool-এ 3, 1, 4. I/O = ৬টি পড়া + ১টি লেখা = ৭. লক্ষ করুন page 1 আর 4 দুবার করে পড়তে হয়েছে — ৩টি frame-এ ৪টি আলাদা page পালা করে লাগলে LRU বারবার ঠিক পরের দরকারি page-টিকেই সরায়।
-
Page-এর হিসাব: একটি ৮ KB (৮১৯২ বাইট) page-এ হেডার ২৪ বাইট, প্রতিটি slot ৪ বাইট, আর প্রতিটি সারি (নিজস্ব হেডারসহ) ১২০ বাইট। alignment উপেক্ষা করে: (ক) এক page-এ সর্বোচ্চ কয়টি সারি? (খ) ১০ লাখ সারির টেবিলে কয়টি page, ফাইল প্রায় কত MB? (গ) HDD-তে sequential ২০০ MB/s হারে full scan কত সময়, আর প্রতিটি page ১০ ms-এর random পড়া হলে কত?
(ক) প্রতি সারিতে ১২০ + ৪ = ১২৪ বাইট; (৮১৯২ − ২৪) ÷ ১২৪ = ৮১৬৮ ÷ ১২৪ = ৬৫.৮৭ → ৬৫টি (৬৫ × ১২৪ = ৮০৬০ ≤ ৮১৬৮, কিন্তু ৬৬ × ১২৪ = ৮১৮৪ > ৮১৬৮)।
(খ) ⌈১০,০০,০০০ ÷ ৬৫⌉ = ১৫,৩৮৫টি page; ১৫,৩৮৫ × ৮১৯২ = ১২,৬০,৩৩,৯২০ বাইট ≈ ১২০ MB (১ MB = ১০২৪ × ১০২৪ বাইট ধরে)।
(গ) sequential: ১২৬ মিলিয়ন বাইট ÷ ২০০ মিলিয়ন বাইট/s ≈ ০.৬৩ সেকেন্ড. random: ১৫,৩৮৫ × ১০ ms ≈ ১৫৪ সেকেন্ড — প্রায় ২৪০ গুণ। এই কারণেই optimizer অনেক সারি মেলার আশঙ্কা থাকলে index-এর random পড়ার বদলে full scan বেছে নেয় (পর্ব ৪১, ৪৩)।
-
I/O তুলনা: একটি টেবিল ৪,০৯৬টি page জুড়ে। I/O cost model-এ বলুন: (ক) heap file-এ unique key খুঁজতে গড়ে আর সবচেয়ে খারাপ ক্ষেত্রে কত I/O; (খ) key ধরে sorted file-এ binary search-এ সর্বোচ্চ কত; (গ) sorted file-এ মাঝখানে একটি নতুন সারি ঢোকাতে মোটামুটি কত; (ঘ) উচ্চতা ৩-এর clustered B+ tree-এ একটি খোঁজা কত।
- (ক) গড়ে (৪০৯৬ + ১)/২ ≈ ২,০৪৮, সবচেয়ে খারাপ ৪,০৯৬ (মান নেই বা শেষ page-এ)।
- (খ) log₂ ৪০৯৬ = ১২টি page.
- (গ) খোঁজা ১২, তারপর গড়ে অর্ধেক ফাইল পড়া আর লেখা: ২,০৪৮ + ২,০৪৮ — মোট প্রায় ৪,১০৮.
- (ঘ) ৩টি page (root, internal, leaf) — আর root ও internal page সাধারণত buffer pool-এ থাকায় বাস্তবে প্রায়ই মাত্র ১টি ডিস্ক I/O.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমBuffer pool ভরা, আর অনুরোধ করা page-টি নেই। Buffer manager-এর কাজগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- page table-এ খুঁজে দেখা যে page-টি নেই
- replacement policy দিয়ে pin count ০ এমন একটি শিকার frame বাছা
- শিকার dirty হলে — তার log record ডিস্কে আছে নিশ্চিত করে — page-টি ডিস্কে লেখা
- নতুন page ডিস্ক থেকে সেই frame-এ পড়া
- page table হালনাগাদ করে pin count ১ দিয়ে frame-এর ঠিকানা ফেরত দেওয়া
SQLite-এ একটি নতুন ডেটাবেসে PRAGMA page_size; চালালে সাধারণত কোন মান আসে (৩.১২ সংস্করণ থেকে)?
- 1024
- 8192
- 4096
- 16384
Slotted page-এ একটি record page-এর ভেতরে অন্য অফসেটে সরানো হলো (compaction). Index-এ রাখা record-এর ঠিকানা (page, slot) কেন বদলাতে হয় না?
- compaction আসলে কোনো record সরায় না
- ঠিকানায় slot নম্বর থাকে, আর শুধু সেই slot-এর ভেতরের অফসেট বদলায়
- compaction-এর পর DBMS সব index নতুন করে বানায়
- record id আসলে primary key-র মান
একটি buffer pool-এ ৩টি frame, LRU নিয়ম, শুরুতে খালি। ৪ page-এর একটি টেবিল পরপর তিনবার পুরো scan করা হলো (১ ২ ৩ ৪ ১ ২ ৩ ৪ ১ ২ ৩ ৪)। মোট কয়টি hit?
- ০
- ৬
- ৯
- ১২
Buffer pool ভরা। চারটি frame-এর অবস্থা নিচে। নতুন page-এর জন্য কোনটিকে শিকার হিসেবে বেছে নেওয়া সম্ভব, কোনো বাড়তি লেখা ছাড়াই?
- pin count ২, পরিষ্কার
- pin count ১, dirty
- pin count ০, dirty
- pin count ০, পরিষ্কার
৭২০০ rpm-এর একটি HDD-তে একটি random ৮ KB পড়ার সময়ের বড় অংশ কোথায় যায়?
- আসল বাইট পড়ায় (transfer time)
- head সরানো (seek) আর থালা ঘোরার অপেক্ষায় (rotational latency)
- CPU-তে page-টি পড়ে বোঝায়
- page-টি RAM-এ কপি করায়
একটি টেবিলে ২০টি কলাম আর কয়েক কোটি সারি। প্রায় সব কোয়েরি দুই-তিনটি কলামের ওপর SUM আর GROUP BY. কোন storage layout সাধারণত সবচেয়ে উপযুক্ত?
- row-ভিত্তিক heap file
- primary key ধরে clustered row store
- column store
- প্রতিটি সারি overflow page-এ রাখা
InnoDB-তে একটি টেবিলের primary key আছে, আর একটি secondary index আছে email কলামে। email দিয়ে একটি সারি খুঁজলে কী ঘটে?
- secondary index থেকে primary key পাওয়া যায়, তারপর clustered index-এ নেমে সারি
- secondary index থেকে সরাসরি heap-এর (page, slot)-এ
- index থাকলেও পুরো টেবিল scan করতে হয়
- secondary index-এর leaf-এ পুরো সারি থাকে, তাই এক গাছেই শেষ
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- ডিস্ক ধীর, তাই I/O গুনুন। RAM ~১০০ ns, SSD কয়েক দশ µs, HDD-র random পড়া কয়েক ms; HDD-তে sequential random-এর চেয়ে শতগুণ সস্তা, SSD-তে লেখার write amplification.
- Page লেনদেনের একক: PostgreSQL ৮ KB, InnoDB ১৬ KB, SQLite ৪ KB. ফাইল = page-এর সারি; heap, sorted বা clustered — খোঁজা আর ঢোকানোর I/O খরচ আলাদা।
- Slotted page: হেডার আর slot array সামনে, record শেষ থেকে; RID = (page, slot), compaction-এও স্থির। NULL bitmap, overflow আর TOAST বড় ও ফাঁকা মান সামলায়।
- Buffer pool: frame, page table, pin count (০ না হলে সরানো নিষেধ), dirty bit (সরানোর আগে লেখা, তারও আগে WAL). LRU বা clock বেছে নেয় কে যাবে।
- Sequential flooding: বড় scan LRU-কে ব্যর্থ করে; ring buffer, midpoint insertion, LRU-K/2Q সুরক্ষা দেয়। OS cache-এর সাথে double buffering এড়াতে O_DIRECT বা সাবধানী মাপ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪০ · Index — B+ tree, hash আর bitmapপরবর্তী পর্বকম I/O-তে সারি খোঁজার কাঠামো।
- পর্ব ০৩ · DBMS-এর গঠনফিরে দেখুনstorage manager আর buffer manager কোথায় বসে।
- পর্ব ৩৮ · Denormalizationফিরে দেখুনpage-পড়ার হিসাব দিয়ে সিদ্ধান্ত।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- কম্পিউটার ফান্ডামেন্টালস · মেমোরির স্তরসম্পর্কিত কোর্সcache, RAM আর ডিস্কের সিঁড়ি বিস্তারিত।
- DSA · B-tree আর B+ treeসম্পর্কিত কোর্সpage-ভিত্তিক গাছ কেন এত নিচু।
- DSA · Hash tableসম্পর্কিত কোর্সbuffer pool-এর page table-এর ভেতরের কাঠামো।