Foundationপ্রথম নীতি থেকে
LEVEL 8লেসন ১/১৯অ্যাডভান্সড১ ঘণ্টা

Storage Engine — file, page, slotted page, tuple

Storage Engine: Files, Pages, Slotted Pages and Tuples

Database table আসলে fixed-size page-এর file — PostgreSQL-এর ৮ KB page খুলে দেখি: header, line pointer array, উল্টো দিক থেকে বাড়া tuple, slotted-page design-এর যুক্তি, আর প্রতিটা row-র লুকানো overhead।

এই লেসন শেষে আপনি পারবেন

  • PostgreSQL table-র disk-জীবনের hierarchy ব্যাখ্যা করতে পারবেন — relfilenode file, ৮ KB page, ১ GB segment — আর 'এক row = এক file' মডেল কেন অসম্ভব তার economics দেখাতে পারবেন
  • Slotted page-এর anatomy আঁকতে পারবেন — ২৪-byte header, ItemId array, উল্টো প্রান্ত থেকে বাড়া tuple region — আর line pointer indirection কেন অপরিহার্য তার যুক্তি trace করতে পারবেন
  • একটা tuple-র লুকানো overhead হিসাব করতে পারবেন — ২৩-byte tuple header, null bitmap, MAXALIGN padding — naive math বনাম pg_relation_size-এর ফারাক ব্যাখ্যা করে
  • pageinspect extension দিয়ে live page খুলে lp_off/lp_len, null bitmap আর raw tuple byte চিনতে পারবেন
  • TOAST-এর সিদ্ধান্ত-গাছ বর্ণনা করতে পারবেন — কখন compression, কখন out-of-line storage, threshold ঠিক কোথায়

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

এই module-এর driving question-টা মনে আছে? — বিদ্যুৎ চলে গেলেও আমার committed transaction টিকে থাকে কীভাবে? উত্তর magic-এ না; লুকিয়ে আছে disk-এ byte কোন আকৃতিতে শোয়া তাতে — সেই আকৃতিই আজকের বিষয়, সবচেয়ে নিচের তলা থেকে। প্রশ্ন করুন: INSERT INTO users VALUES (...) চালালে আপনার row disk-এ কোথায় থাকে? Filesystem-এ কি হাজার হাজার ছোট file — প্রতি row-এ একটা? না। বাস্তবতা গোছানো: database = file-দের collection, প্রতিটা file = fixed-size page- দের সারি (PostgreSQL-এ ৮ KB), page-এর ভেতরে জন্মায় tuple — আপনার row-র disk-রূপ। আজকের journey:

  • কেন row-per-file নয় — filesystem-এর লুকানো ভাড়া
  • Heap file-এর layout পরিবার আর PostgreSQL-এর directory কাঠামো
  • Page anatomy: header, ItemId array, দুই প্রান্ত থেকে বাড়া tuple — slotted page; tuple header-র ২৩ byte-এ MVCC-র প্রথম ইঙ্গিত; TOAST-ও দেখা হবে
  • ১০ লাখ ছোট row ঢুকিয়ে overhead নিজের চোখে মাপা

মূল ধারণা

এক row = এক file কেন নয়

ধরুন আমরা সরল পথ নিলাম: প্রতিটা row একটা আলাদা OS file — user_1042.dat। ছোট dataset-এ চলে। কিন্তু:

খরচকারণ
inode ফুরানোপ্রতিটা file inode খায় — ১০ কোটি row = ১০ কোটি inode
Directory explosionএক directory-তে লাখ file — lookup O(n), backup অচল
open()/close() প্রতি rowপ্রতি read-এ syscall + path resolution — µs × কোটি query
Fragmentation৪ KB block-এ ৪০-byte row — disk space-এর ৯৯% অপচয়

Filesystem metadata-র ভাড়াই এত বড় যে design-টা জন্মাতেই মরে। তাই সব serious engine-এর সমাধান এক: অল্প কয়েকটা বড় file, ভেতরে নিজের মতো ভাগ করা। Filesystem শুধু বড় chunk ধরে রাখে; row-mapping-এর বুদ্ধিটা engine নিজে করে।

Heap file — table-এর ঘরবাড়ি

PostgreSQL-এ একটা table disk-এ এমনভাবে থাকে:

$PGDATA/base/16384/16385        ← database OID / table-এর relfilenode
16385      ← মূল file (segment)
16385.1    ← দ্বিতীয় segment (১ GB ছাড়লে)
16385_fsm  ← free-space map — কোন page-এ কত ফাঁকা
16385_vm   ← visibility map — visibility সংকেত (পরের লেসনে দরকার!)
  • Segment প্রতিটা সর্বোচ্চ ১ GB — 32-bit offset-এর ঐতিহাসিক উত্তরাধিকার
  • File ভেতরে ভাগে ৮ KB page-এ — একমাত্র I/O unit; ৫০ byte চাইলেও ৮ KB পড়ে
  • Layout-টার নাম heap file: tuple-রা order-এ নেই, যেখানে জায়গা সেখানে ঢোকে। Engine-দের layout পরিবার:
Layoutভাবনাদোষ
Append-onlyশুধু শেষে লেখো, কখনো update নয়পুরনো version জমে — vacuum/compaction লাগে
Linked pagesপ্রতি page-এ next/prev pointertraversal ধীর, free-space খোঁজা ব্যথা
Tablespace dir + FSMফাঁকা-জায়গা map আলাদা structure-এmetadata বাড়তি — তবে insert-path দ্রুত

PostgreSQL তিনটার সংকর: append-style heap (update-এ নতুন tuple), FSM দিয়ে জায়গা-খোঁজা, directory-তে segment-ভাগ।

Page anatomy — ৮ KB-র নকশা

offset 0                                    offset 8191
┌───────────────────────────────────────────────┐
│ PageHeader (২৪ B)                             │ pd_lsn, pd_lower, pd_upper…
│  ItemId[0] → (off,len)                        │
│  ItemId[1] → (off,len)          ↓ নিচে বাড়ে   │ line pointer array
│  …                                            │
├───────────────────────────────────────────────┤ ← pd_lower
│              ফাঁকা জায়গা (free space)           │
├───────────────────────────────────────────────┤ ← pd_upper
│            tuple C (সবচেয়ে নতুন)               │
│            tuple B                            │ ↑ উপরে বাড়ে
│            tuple A (সবচেয়ে পুরনো)              │
└───────────────────────────────────────────────┘
Slotted page — দুই প্রান্ত থেকে মুখোমুখি বাড়া দুই region, মাঝের ফাঁকাটাই page-এর নিঃশ্বাসের রুম।

চারটা নাম: PageHeader — ২৪ byte (pd_lsn — WAL teaser!, checksum, flags, cursor-দ্বয় pd_lower/pd_upper); ItemId / line pointer — ৪ byte: lp_off + lp_len + flags (normal/dead/unused); tuple region — উল্টোদিকে বাড়ে; free space = pd_upper − pd_lower。

Slotted page কেন — indirection-এর যুক্তি

সরল alternative: tuple-রা পাশাপাশি, index সরাসরি byte-offset রাখে। এবার UPDATE name = name || '!' — tuple বড়, পুরনো জায়গায় ধরে না → relocate। সঙ্গে সঙ্গে index-এর সব pointer ভাঙে, বাকি tuple-রাও shift-বাধ্য (বিশাল memcpy), concurrent reader-রা ভাঙা pointer পড়ছে।

Slotted page-এর সমাধান: pointer কেউ tuple address রাখে না — সবাই slot-number রাখে। Tuple সরলে শুধু ItemId-র lp_off বদলায়, ৪ byte; ctid/index entry স্থির। এই indirection-ই update হওয়া page-কে বাঁচায় — memory allocator লেসনের fragmentation পাঠের ডান হাত।

Tuple header — ২৩ byte-এ গল্প

আপনার int id, varchar name row disk-এ শুধু data না — সামনে tuple header, ~২৩ byte:

FieldSizeকাজ
t_xmin4যে transaction এই tuple বানাল — MVCC-র জন্মসনদ
t_xmax4যে transaction delete/update করল (0 = জীবিত)
t_cid/t_combocid4command id — এক transaction-এর ভেতরের ক্রম
t_ctid6নিজের ঠিকানা (page, offset); update-এ নতুন tuple-এর দিকে মোড় নেয়
t_infomask22attribute count + flags
t_infomask2null bitmap আছে? has-varwidth? frozen?
t_bits[]0–nnull bitmap — column-per-bit, দরকার হলেই

দুটো বিষয় এখনই খেয়াল করুন। এক: xmin/xmax — MVCC-র seed; row “deleted” মানে আসলে xmax ভরা — মৃতদেহ পরে vacuum সরায়। দুই: null bitmap — NULL-রা জায়গা নেয় bit-এ, bitmap আসে table-এ প্রথম NULL ঢোকার পরেই, তার পরেই padding।

TOAST — যখন row page-এ ধরে না

Page ৮ KB, তাহলে ৫ MB-র PDF blob কোথায়? উত্তর TOAST — The Oversized-Attribute Storage Technique। Value ~২ KB crossing-এ: (১) আগে compress, (২) তবু বড় হলে টুকরো করে আলাদা toast table-এ — সেখানেও slotted-page জগৎই! (৩) main tuple-এ থাকে মাত্র ১৮ byte-র toast pointer। ফলে main page ছোট — SELECT count(*) চলতে গিয়ে বিশাল text column-এর byte-ও engine ছোঁয় না।

ভেতরে কী ঘটছে

Alignment padding — নীরব জায়গা-খোর

Memory allocation লেসনের পাঠ এখানেও: CPU aligned access চায়। PostgreSQL page-ও তাই MAXALIGN = 8 byte-এ চলে — int ৪-এ, bigint ৮-এ বসে। ফলাফল: column order পর্যন্ত size বদলে দেয়:

CREATE TABLE bad  (a smallint, b bigint, c smallint);  -- 2+pad6+8+2+pad ≈ 24 B
CREATE TABLE good (b bigint, a smallint, c smallint);  -- 8+2+2+pad4    ≈ 16 B
-- ১ কোটি row-তে ফারাক ~৮০ MB — শুধু column সাজিয়ে!

Naive math (৫–১০ byte data) আর বাস্তব (২৩-byte header + padding + varlena ≈ ৪০+ byte) — ফারাকটাই experiment-এ মাপবেন। Rule of thumb: aligned fixed-width বড়গুলো আগে, ছোটগুলো পরে, string-রা শেষে。

Sequential scan পুরো page কেন পড়ে

SELECT * FROM orders WHERE amount < 50 — index না থাকলে engine প্রতিটা page পড়ে, প্রতিটা tuple বিচারে তোলে। অদ্ভুত অপচয় নয়: page-রা contiguous — OS readahead/prefetch চালু করে, kernel একাই পরের page আনতে থাকে; sequential per-byte দাম random-এর ভগ্নাংশ — “wasted” বাকি অংশও প্রায় বিনামূল্যে।

আরেক teaser: tuple-র physical ঠিকানা ctid — (block, offset); index-রা এটাই ধরে রাখে, আর এই ঠিকানাই পরের দুই লেসনে বারবার ফিরবে।

উদাহরণ

একটা ৮ KB page-এর হাতে-বসানো hexdump

নতুন table t(id int, tag char(4))-এ ৩টা row ঢোকানোর পরে page 0-এর প্রথম bytes (xxd আউটপুট; LSN-জাতীয় মান run-ভেদে বদলাবে):

00000000: a8d4 1500 0000 0000 0000 0000 2400 881f   pd_lsn … lower=36 upper=8072
00000010: 0020 0420 0000 0000 d89f 4200 b09f 4200   special=8192 ver=0x2004 ItemId[0..1]
00000020: 889f 4200                                 ItemId[2]

Byte-by-byte পাঠ:

offset 0–7   : pd_lsn = A8 D4 15 00 00 00 00 00 → WAL position 0/15D4A88
offset 8–9   : pd_checksum = 0x0000 (checksum off হলে 0)
offset 10–11 : pd_flags = 0x0000
offset 12–13 : pd_lower = 0x0024 = 36 → 24(header) + 3×4(ItemId) — ঠিক তিন slot
offset 14–15 : pd_upper = 0x1F88 = 8072 → tuple region এখান থেকে শুরু
offset 16–17 : pd_special = 0x2000 = 8192 (heap page-এ সবসময় pagesize-ই)
offset 18–19 : pd_pagesize_version = 04 20 → little-endian 0x2004 = 8192|layout-4 ✓
offset 20–23 : pd_prune_xid = 0x00000000

ItemId[0] @24 (0x18) = D8 9F 42 00 → word 0x429FD8 (LE):
   lp_off = word & 0x7FFF = 8152 · flags = (word>>15)&3 = 1 (normal) · lp_len = word>>17 = 33
ItemId[1] @28 = B0 9F 42 00 → off=8112, len=33   ·   ItemId[2] @32 = 88 9F 42 00 → off=8072

lp_len ৩৩ byte = t_hoff ২৪ (২৩ header + pad) + int ৪ + char(4) varlena ৫; placement MAXALIGN-এ spacing ঠিক ৪০ (= MAXALIGN(33))। তিনটা tuple শেষপ্রান্ত থেকে উল্টোদিকে বেড়েছে, pd_upper ঠিক শেষ tuple-এর মাথায় — নকশা হুবহু মিলল。

pageinspect — SQL দিয়ে একই খোঁচা

হাতে hexdump না করে extension দিয়েও page খোলা যায়:

CREATE EXTENSION pageinspect;
SELECT * FROM page_header(get_raw_page('t', 0));
SELECT lp, lp_off, lp_len, t_xmin, t_ctid,
       tuple_data_split('t', t_data, t_infomask, t_infomask2, t_bits)
FROM heap_page_items(get_raw_page('t', 0));

Output (সংক্ষেপে):

 lp | lp_off | lp_len | t_xmin | t_ctid |       tuple_data_split
----+--------+--------+--------+--------+------------------------------
  1 |   8152 |     33 |    751 | (0,1)  | {"\x00000001","abcd"}
  2 |   8112 |     33 |    751 | (0,2)  | {"\x00000002","efgh"}
  3 |   8072 |     33 |    751 | (0,3)  | {"\x00000003","ijkl"}

t_data-র প্রথম ৪ byte-ই id, পরেরটা tag — tuple_data_split header কেটে data আলাদা করে দেখাচ্ছে।

ছবিতে

insert A,B,C,D,E,F:            delete B,E:                compact/vacuum:
┌──────────────┐               ┌──────────────┐           ┌──────────────┐
│ Id[0]→A      │               │ Id[0]→A      │           │ Id[0]→A      │
│ Id[1]→B      │               │ Id[1]=DEAD   │           │ Id[1]→C      │
│ Id[2]→C      │               │ Id[2]→C      │           │ Id[2]→D      │
│ Id[3]→D      │               │ Id[3]→D      │           │ Id[3]→F      │
│ Id[4]→E      │               │ Id[4]=DEAD   │           │ Id[4]=unused │
│ Id[5]→F      │               │ Id[5]→F      │           │ Id[5]=unused │
│·····free·····│               │·····free·····│           │·····free·····│
│F E D C B A←──│ tuple end     │F · D · C · A │           │F D C A←──────│ reclaimed
└──────────────┘               └──────────────┘           └──────────────┘
lower→, ←upper                 গর্ত থাকে, free-space       byte-move হয় এখানে,
                               টুকরো টুকরো — FSM জানে      কিন্তু Id নম্বর stable!
Page-এর জীবন — insert-এ দুই প্রান্ত কাছে আসে, delete-এ মাঝে গর্ত পড়ে (slotted page তবু স্থির):
row: id int4 + name varchar(20) = 'Rahim'  →  বাস্তবে ~৪৪ byte

┌──────────────────────────────────────────────┐
│ t_xmin(4) t_xmax(4) t_cid(4) t_ctid(6)       │ ← MVCC হিসাবখাতা: ১৮ B
│ t_infomask2(2) t_infomask(2) [t_bits(0)]     │ ← মেটাডেটা: ৪ B
│ ── pad to 8-alignment: ১ B ──                │
│ varlena header(1) + 'Rahim'(5); id int4(4)   │ ← data: ১০ B
│ ── trailing MAXALIGN pad ──                  │
└──────────────────────────────────────────────┘
 data ১০ B, overhead ~৩৪ B — ছোট row-র দুনিয়ায় overhead-ই রাজা
এক row-র disk-বাজেট — 'data'-র চেয়ে গল্পই বেশি:

নিজে চালিয়ে দেখুন

EXPERIMENT

৮ KB page খোলা আর overhead মাপা — pg_relation_size বনাম naive math

PostgreSQL 14+ (psql; Docker caveat নিচে)· ২৫ মিনিট

ধাপ ০ — PostgreSQL না থাকলে: docker run --rm -d --name pg-lab -e POSTGRES_PASSWORD=lab -p 5432:5432 postgres:16, তারপর docker exec -it pg-lab psql -U postgres; নিজের machine-এ থাকলে সরাসরি psql।

ধাপ ১ — tiny-row table, ১০ লাখ row:

CREATE TABLE tiny (id integer, flag boolean);
INSERT INTO tiny SELECT g, g % 2 = 0 FROM generate_series(1, 1000000) g;
ANALYZE tiny;

ধাপ ২ — naive math বনাম বাস্তব (naive: id ৪ B + flag ১ B = ৫ B/row → ৫ MB):

SELECT pg_size_pretty(pg_relation_size('tiny'));       -- data pages
SELECT pg_size_pretty(pg_total_relation_size('tiny')); -- + FSM/VM/toast
SELECT relpages FROM pg_class WHERE relname = 'tiny';  -- কয়টা ৮ KB page?

প্রত্যাশিত: ~35–38 MB, relpages ≈ 4500। Per-row ≈ ৩৬–৩৭ byte — ৫ byte data-র জন্য! ভাঙুন: ২৪-byte header block (২৩+pad) + ItemId ৪ B + page header amortized (২৪ B ÷ ~২২৫ tuple/page) + half-full tail pages।

ধাপ ৩ — page-এর ভেতরে (pageinspect):

CREATE EXTENSION IF NOT EXISTS pageinspect;
SELECT count(*) AS live_items, min(lp_len) AS tuple_bytes,
       round(avg(lp_len)) AS avg_tuple_bytes
FROM heap_page_items(get_raw_page('tiny', 100));
SELECT * FROM page_header(get_raw_page('tiny', 100));

avg_tuple_bytes ≈ ২৯–৩২; free space = pd_upper − pd_lower — ধাপ ২-এর বাজেট-ভাঙার সাথে ItemId + tuple + overhead ≈ ৩৬–৩৭ byte/row-এ মিলবে।

ধাপ ৪ — (optional) raw hexdump: relfilenode বের করুন SELECT pg_relation_filepath('tiny'); — pg_filedump -i -f <path> | head -40 install থাকলে annotate করে দেখায়; না থাকলে example-এর hand-annotated dump-ই পাঠ।

দেখা গেল কী: naive math প্রতি ধাপে হারে — header, ItemId, padding, half-empty tail। Storage engine-এর প্রথম পাঠ: disk-এ ‘শুধু data’ নেই।

এটা কী প্রমাণ করে

১০ লাখ tiny row-এর table naive math-এর চেয়ে ~৩× জায়গা নেয় — overhead-এর হিসাব মিলে যায় pageinspect-এর byte-level report-এর সাথে; page আসলে ঠিক নকশার মতোই ভরে।

নিজে বানান

BUILD IT

SlottedPage — ৪ KB buffer-এ pack/unpack, fragmentation report সহ

Python · ●●●○○
  1. fixed bytearray(PAGE_SIZE) — কোনো Python list নয়, আসল byte-buffer
  2. slot array নিচ থেকে, tuple data উপর থেকে — pd_lower/pd_upper cursor
  3. insert(data: bytes) → slot number; জায়গা না থাকলে compact করে আবার চেষ্টা
  4. delete(slot) → tombstone bit ItemId-তেই; get(slot) dead হলে KeyError
  5. compact(): live tuple-গুলো শেষ প্রান্তে টেনে slot rewrite — Id stable
  6. report(): live/dead bytes, slack, fragmentation % — delete+reinsert cycle-এ ছাপুন

লক্ষ্য: PostgreSQL-এর page-machinery-র হাড়কাঁটা — indirection, দুই-cursor free space, tombstone, compaction — সব milestone এক struct-দুটোতে:

import struct

PAGE_SIZE = 4096
HEADER = 8                      # mini-header: lower@0, upper@2
SLOT = 4                        # '>HH' → offset, len(+0x8000 tombstone bit)
DEAD = 0x8000

class PageFull(Exception):
    pass

class SlottedPage:
    def __init__(self):
        self.buf = bytearray(PAGE_SIZE)
        self.lower = HEADER          # slot array-র শেষ প্রান্ত
        self.upper = PAGE_SIZE       # tuple region-র শুরু

    def _slot(self, i):
        off, ln = struct.unpack_from('>HH', self.buf, HEADER + i * SLOT)
        return off, ln

    def _set_slot(self, i, off, ln):
        struct.pack_into('>HH', self.buf, HEADER + i * SLOT, off, ln)

    def insert(self, data: bytes) -> int:
        if self.upper - self.lower < SLOT + len(data):
            self.compact()                       # গর্ত সরিয়ে আবার চেষ্টা
            if self.upper - self.lower < SLOT + len(data):
                raise PageFull(f'{len(data)}B needs {SLOT + len(data)}B')
        off = self.upper - len(data)
        self.buf[off:self.upper] = data
        self.upper = off
        sid = (self.lower - HEADER) // SLOT
        self._set_slot(sid, off, len(data))
        self.lower += SLOT
        return sid

    def get(self, sid: int) -> bytes:
        off, ln = self._slot(sid)
        if ln & DEAD:
            raise KeyError(f'slot {sid} deleted')
        return bytes(self.buf[off:off + ln])

    def delete(self, sid: int) -> None:
        off, ln = self._slot(sid)
        if not ln & DEAD:
            self._set_slot(sid, off, ln | DEAD)  # data থাকে, flag-ই মৃত্যুসনদ

    def compact(self):
        """live tuple-গুলো page-এর শেষে টেনে slot-offset rewrite"""
        moved = 0
        top = PAGE_SIZE
        nslots = (self.lower - HEADER) // SLOT
        for i in range(nslots):                   # পুরনো ক্রমেই
            off, ln = self._slot(i)
            if ln & DEAD:
                self._set_slot(i, 0, 0)           # dead slot সম্পূর্ণ মুছি
                continue
            if ln == 0:
                continue
            new_off = top - ln
            if new_off != off:
                self.buf[new_off:top] = self.buf[off:off + ln]
                moved += ln
            self._set_slot(i, new_off, ln & 0x7FFF)
            top = new_off
        self.upper = top
        return moved

    def report(self):
        nslots = (self.lower - HEADER) // SLOT
        live = dead = 0
        for i in range(nslots):
            _, ln = self._slot(i)
            if ln == 0:
                continue
            dead += ln & 0x7FFF if ln & DEAD else 0
            live += (ln & 0x7FFF) if not ln & DEAD else 0
        used = PAGE_SIZE - self.upper
        frag = dead / used * 100 if used else 0.0
        print(f'slots={nslots} live={live}B dead={dead}B '
              f'slack={self.upper - self.lower}B '
              f'fragmentation={frag:.1f}%')

if __name__ == '__main__':
    pg = SlottedPage()
    payload = lambda k: f'record-{k:04d}'.encode() * 3      # 33 B each
    slots = [pg.insert(payload(k)) for k in range(60)]
    print('after 60 inserts:')
    pg.report()
    for s in slots[::3]:                                     # প্রতি তৃতীয়টা মারো
        pg.delete(s)
    print('after deleting every 3rd:')
    pg.report()
    extra = [pg.insert(payload(1000 + k)) for k in range(10)]
    print('after re-inserting 10 new (holes still there):')
    pg.report()
    moved = pg.compact()
    print(f'compact() moved {moved}B:')
    pg.report()
    assert all(pg.get(slots[k]) == payload(k)
               for k in range(60) if k % 3 != 0)            # Id-রা stable!
    assert pg.get(extra[0]) == payload(1000)
    print('survivors readable via same slot numbers ✓')

প্রত্যাশিত output:

after 60 inserts:
slots=60 live=1980B dead=0B slack=1868B fragmentation=0.0%
after deleting every 3rd:
slots=60 live=1320B dead=660B slack=1868B fragmentation=33.3%
after re-inserting 10 new (holes still there):
slots=70 live=1650B dead=660B slack=1498B fragmentation=28.6%
compact() moved 1650B:
slots=70 live=1650B dead=0B slack=2158B fragmentation=0.0%
survivors readable via same slot numbers ✓

লক্ষ্য করুন দুইটা জিনিস: (১) reinsert করলেও dead bytes জমেই থাকে — allocator শুধু উপরে append করে; PostgreSQL-ও এই কারণেই vacuum-এর ভরসা। (২) compact-এর পরেও বেঁচে থাকা record-রা একই slot number-এ পড়া যায় — indirection-ই promise।

নিজে বাড়ান: (১) update(sid, data) — delete+insert + forwarding slot hint; (২) max-tuple cap (PG-র ~২ KB rule-এর মতো) — বড় payload-এ TOAST-style exception; (৩) header-এ checksum যোগ করে torn-page detect।

বাস্তব সিস্টেমে

বাস্তব জগতে page আর tuple

  • PostgreSQL: আজ যা দেখলাম — ৮ KB page, ১ GB segment, FSM/VM sidecar, TOAST; pg_relation_filepath()-এ disk-ঠিকানা।
  • SQLite: পুরো database এক file, fixed-size ৪ KB page; সবকিছু B-tree page-এ — page-ভিত্তিক চিন্তার সবচেয়ে পরিষ্কার নমুনা।
  • InnoDB: ১৬ KB page; COMPACT row format-এ tuple header + var-length array — slotted ভাবনা একই, রূপ ভিন্ন।
  • Kafka/Parquet: segment file / row-group — ‘ছোট record নয়, বড় contiguous chunk’; batch-read locality-ই king।

Production tip: column order সাজালে (bigint আগে, string/bool শেষে) ১০–১৫% storage বাঁচে — backup window আর cache hit-rate দুটোতেই সুদে ফেরে।

যে ভুলগুলো সবাই করে

“Database-এ প্রতিটা row disk-এ আলাদা file হিসেবে থাকে।”

Filesystem-এর inode/directory ভাড়া scale-এ অসম্ভব — ১০⁸ row = ১০⁸ file। বাস্তবে table = কয়েকটা বড় file-এর ভেতরে page-সারি; row-mapping engine নিজের ItemId-তেই করে; filesystem শুধু ‘বড় byte-array’ service দেয়।

“UPDATE disk-এ শুধু বদলানো field-টাই overwrite করে।”

PostgreSQL না-ই (নতুন tuple লেখে, পুরনোটা xmax-এ মৃত); general সত্যও তা-ই: page-ই I/O unit, এক field-এর জন্যও পুরো page read-modify-write। In-place engine-রাও field-granularity patch করে না — ‘row update = row rewrite’-ই সঠিক model।

“NULL column কিছুই জায়গা নেয় না — একদম ফ্রি।”

Data-area-তে নেয় না ঠিক, কিন্তু null bitmap-এ ১ bit, আর bitmap চালু হয় প্রথম NULL- এই — সব row-এ header-বাজেট বাড়ে; padding geometry-ও বদলাতে পারে। ‘প্রায় ফ্রি’, ‘ফ্রি’ নয়।

“Page size যত বড়, performance তত ভালো — ৩২ KB কেন নয়?”

বড় page = point-query-তে wasted bandwidth, pool-এ কম frame, write amplification; ছোট = বেশি header overhead, কম sequential efficiency। ৮/১৬ KB decades-এর balance point — WAL/recovery geometry-ও জড়ানো।

বুঝেছেন কি না দেখুন

1

Slotted page-এর চারটা region আর দুই cursor-এর নাম বলুন। Free space হিসাব?

স্মরণ

Region: PageHeader (২৪ B), ItemId array, free space, tuple region। Cursor: pd_lower/pd_upper; free space = pd_upper − pd_lower — insert-এ lower ৪ বাড়ে, upper tuple-size কমে; ছুঁয়ে গেলে page full।

2

Index entry সরাসরি tuple-র byte-offset রাখলে UPDATE-এ ঠিক কী ভাঙত? Slotted page কোন ৪-byte খরচে সেটা রোধ করে?

যুক্তি

Tuple relocate হলে সব direct offset ভুল — ঠিক রাখতে পরবর্তী সব tuple-ও shift করতে হতো: বিশাল memcpy + race। Slotted page-এ সবাই slot-number ধরে; relocate-এ শুধু ওই slot-এর lp_off (৪ B) বদলায় — external pointer stable。

3

Table (id int4, active bool) — এক row-র disk-বাজেট header/data/padding/ItemId সহ। ১০ লাখ row মোট কত MB?

প্রয়োগ

Header ২৩ + int4 ৪ + bool ১ = ২৮ → maxalign-৮-এ ৩২ B + ItemId ৪ = ৩৬ B/row; page overhead মিলিয়ে ~৩৭ B × ১০⁶ ≈ ৩৫–৪০ MB — experiment-এর ~৩৬–৩৭ B/row-এর সাথে মেশে, naive ৫ MB নয়।

4

(a smallint, b bigint, c smallint) বনাম (b bigint, a smallint, c smallint) — data area-তে পার্থক্য কত, কোন alignment নিয়মে?

যুক্তি

প্রথম: a@0(2)+pad৬+b@8(8)+c@16(2)+tail-pad৬ = ২৪ B; দ্বিতীয়: b@0(8)+ a@8(2)+c@10(2)+tail-pad৪ = ১৬ B। নিয়ম: natural alignment + tuple MAXALIGN- 8-এ শেষ। ৮ B/row × কোটি row = শত শত MB।

5

TOAST কেন compress-আগে-out-of-line-পরে নয় — উল্টো ক্রম? Main page ছোট রাখা কার জন্য জরুরি?

যুক্তি

Compress সস্তা CPU, out-of-line write দামি I/O — compress-ই যথেষ্ট হলে toast-table I/O এড়ানো যায়। Main page ছোট রাখা জরুরি কারণ scan/index heap-fetch প্রতিটা tuple ছোঁয় — inline blob থাকলে count(*)-ও শত শত MB touch করত।

6

Workload: ৯৫% query WHERE device_id = ? AND ts BETWEEN ? AND ? (few rows), ৫% nightly full-scan analytics। আজকের পাঠ থেকে তিনটা concrete design সিদ্ধান্ত।

ডিজাইন

(১) Column order: aligned fixed-width আগে, বড় text/json শেষে — hot pages ঘন। (২) বড় payload TOAST-able — full-scan-এ main pages ছোট। (৩) Hot point-path-এর index ভাবুন (পরের লেসনগুলো), কিন্তু index-ও page খায় — ingest-এ count সামলে।

এরপর কী

এরপর কী

Disk-এ সব গোছানো: page-ভরা file, slotted page, tuple ctid-ঠিকানায় শোয়া। কিন্তু প্রতি query-তে disk ছোঁয়া অসহ্য — HDD-তে ১০ ms, SSD-তেও ১০০ µs, RAM ১০০ ns। তাই engine disk-কে RAM-এ ভাড়া দেয় — buffer pool। পরের লেসনে:

  • Double buffering — database OS pagecache-কে কেন বিশ্বাস করে না
  • pin/unpin + dirty flag — buffer manager-এর contract
  • LRU-র বিপদ, CLOCK-এর second chance — PostgreSQL ring buffer
  • Hit-rate math: cold-vs-warm query-র হাজারগুণ ফারাকের ব্যাখ্যা

আজকের ৮ KB page-টাই পরের লেসনের currency — frame-এ ভাড়া বসবে।

আরও পড়ুন