mukles.
All posts
  • PostgreSQL
  • Database
  • Backend

PostgreSQL Internals, Part 4: Architecture আর Query Lifecycle

একটা connection খোলা থেকে result পাওয়া পর্যন্ত PostgreSQL-এর ভেতরে কোন process কী করে, shared buffers আর dirty page কী, আর একটা query কোন কোন ধাপ পেরিয়ে চলে, সেটা এই পর্বে বুঝব।

Md Mukles Ali13 min read

এটা PostgreSQL Internals সিরিজের Part 4। Part 1 · Part 2 · Part 3 · Part 4 · Part 5

আগের তিন পর্বে আমরা data-র দিক থেকে PostgreSQL দেখেছি: heap, page, tuple, MVCC, index। কিন্তু একটা প্রশ্ন এখনও বাকি। আমাদের application যখন একটা query পাঠায়, তখন সেটা কে গ্রহণ করে, কে চালায়, আর page গুলো কোথায় পড়া বা লেখা হয়?

এই পর্বে দুটো জিনিস দেখব:

  1. Architecture: PostgreSQL-এর process আর memory কীভাবে সাজানো।
  2. Query Lifecycle: একটা SQL string কোন কোন ধাপ পেরিয়ে result হয়ে ফেরত আসে।

Application connect করলে কী হয়?

ধরা যাক একটা NestJS application এই query পাঠাচ্ছে:

await db.query(
  'SELECT * FROM users WHERE id = $1',
  [10],
);

Query পাঠানোর আগে application-কে PostgreSQL server-এর সাথে একটা connection খুলতে হয় (সাধারণত TCP, port 5432)। এই connection প্রথমে গ্রহণ করে Postmaster।

Postmaster

Postmaster হলো PostgreSQL server-এর main process। Server start করলে এটাই প্রথমে চালু হয়। এর কাজ incoming connection গ্রহণ করা, আর বাকি সব process চালু করা ও দেখাশোনা করা।

তবে Postmaster নিজে কোনো client-এর SQL চালায় না। প্রতিটা নতুন connection-এর জন্য সে একটা নতুন backend process তৈরি (fork) করে, আর সেই connection-এর সব কাজ ওই backend-কে দিয়ে দেয়।

Client A ──┐                 ┌──→ Backend Process A
Client B ──┼──→ Postmaster ──┼──→ Backend Process B
Client C ──┘     (fork)      └──→ Backend Process C

এরপর থেকে Client A সরাসরি Backend A-এর সাথেই কথা বলে। Authentication, query parse করা, plan বানানো, execute করা, result পাঠানো, সব কাজই সেই connection-এর backend process করে।

এক connection মানে এক backend process, আর query-র আসল কাজটা করে সেই backend process।

Thread না, process

অনেক database বা application server একটা process-এর ভেতরে অনেকগুলো thread চালায়। PostgreSQL তা করে না। প্রতিটা backend একটা আলাদা OS process।

এর একটা ভালো দিক হলো isolation: একটা backend crash করলে সেটা অন্য backend-এর memory নষ্ট করতে পারে না। কিন্তু খরচও আছে। প্রতিটা process-এর নিজের memory লাগে (sort, hash ইত্যাদির জন্য work_mem সহ), আর process তৈরি করতে সময় লাগে।

তাই ধরা যাক ১০টা NestJS instance, প্রতিটা ১০০টা connection খুলে রাখল। তাহলে PostgreSQL-এ ১,০০০টা backend process থাকবে। এর বেশিরভাগ হয়তো বেশিরভাগ সময় idle বসে আছে, কিন্তু memory আর CPU scheduling-এর খরচ ঠিকই হচ্ছে। এই কারণেই max_connections এর একটা সীমা আছে (default 100)।

Connection pooling কেন দরকার

সমাধান হলো connection pooling। প্রতিটা request-এর জন্য নতুন connection না খুলে কয়েকটা connection খোলা রাখা হয় আর বিভিন্ন request সেগুলো পালা করে ব্যবহার করে।

  • Application-level pool: pg-এর Pool, TypeORM বা Prisma-র connection pool। একটা app instance নির্দিষ্ট সংখ্যক connection reuse করে।
  • External pooler: PgBouncer-এর মতো tool app আর database-এর মাঝে বসে। অনেক client connection-কে অল্প কয়েকটা আসল PostgreSQL connection-এ ভাগ করে চালায়।

Process-per-connection model-এর কারণে PostgreSQL-এ connection সস্তা না, তাই production-এ pooling প্রায় সবসময় দরকার।

Shared Memory আর Shared Buffers

Backend process গুলো আলাদা হলেও প্রায়ই একই data লাগে। ধরা যাক তিনটা backend-এরই users table-এর Page 50 দরকার। প্রত্যেকে যদি নিজে disk থেকে page টা পড়ে, তাহলে একই 8 KB তিনবার পড়া হবে। এটা অপচয়।

তাই PostgreSQL server start হওয়ার সময় একটা shared memory এলাকা তৈরি করে, যেটা সব backend দেখতে পায়। এর সবচেয়ে বড় অংশ shared buffers। এটা RAM-এ রাখা page-এর একটা cache। Part 1-এ দেখা heap আর index-এর 8 KB page গুলো এখানেই এসে বসে।

             Shared Memory (RAM)
        ┌─────────────────────────┐
        │     Shared Buffers      │
        │   Page 10   Page 50     │
        │   Page 83   Page 120    │
        └────────────▲────────────┘
                     │
         ┌───────────┼───────────┐
     Backend A   Backend B   Backend C
                     │
               miss হলে ↓
                Data Files (Disk)

কোনো backend-এর একটা page লাগলে সে আগে shared buffers-এ খোঁজে:

  • Buffer hit: page টা আগে থেকেই shared buffers-এ আছে। সরাসরি সেখান থেকে পড়ে নেয়।
  • Buffer miss / read: page টা নেই। তখন data file থেকে পড়ে shared buffers-এ রাখা হয়, তারপর ব্যবহার করা হয়।

Backend A Page 50 প্রথমবার disk থেকে আনলে Backend B পরে সেটা সরাসরি RAM থেকেই পায়।

Backend process আলাদা হলেও shared buffers-এর মাধ্যমে তারা একই page share করে, তাই একই page বারবার disk থেকে পড়তে হয় না।

এটা আমরা নিজের চোখে দেখতে পারি:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM users WHERE id = 10;

Output-এ এমন একটা লাইন থাকবে:

Buffers: shared hit=3 read=1

shared hit=3 মানে ৩টা block shared buffers-এই পাওয়া গেছে। read=1 মানে ১টা block বাইরে থেকে পড়ে আনতে হয়েছে। একটা ছোট বিষয় খেয়াল রাখা দরকার: "read" মানেই সবসময় physical disk read না। PostgreSQL file পড়ে OS-এর মাধ্যমে, আর OS-এর নিজেরও page cache আছে। তাই অনেক সময় এই read OS cache থেকেই চলে আসে।

আগের পর্বগুলোতে "বেশি page পড়লে বেশি I/O" বলেছিলাম। এখন সেটার architecture-এর দিকের মানে পরিষ্কার: বেশি page মানে বেশি buffer access, আর miss হলে বাইরে থেকে read।

UPDATE করলে Dirty Page

এবার একটা write দেখি:

UPDATE users
SET name = 'Mukles Ali'
WHERE id = 10;

ধরা যাক row টা Page 20-তে আছে। Backend process যা করে:

  1. Page 20 shared buffers-এ আনে (যদি আগে থেকে না থাকে)।
  2. RAM-এর ওই page-এ পরিবর্তন করে। Part 2-এ দেখেছি, PostgreSQL পুরোনো tuple overwrite করে না। পুরোনো version-এর xmax set করে আর একটা নতুন tuple version লেখে। জায়গা থাকলে নতুন version-টাও একই page-এ যায়।
  3. কিন্তু disk-এর data file-এ Page 20 এখনও পুরোনো অবস্থায় আছে।
      RAM (Shared Buffers)            Disk (Data File)

      Page 20                         Page 20
      ┌──────────────────────┐        ┌──────────────────────┐
      │ old: Mukles (xmax)   │        │ Mukles               │
      │ new: Mukles Ali      │        │                      │
      └──────────────────────┘        └──────────────────────┘
          DIRTY PAGE        ── পরে write ──→

RAM-এ এভাবে পরিবর্তিত হয়েছে কিন্তু এখনও data file-এ লেখা হয়নি, এমন page-কে বলে dirty page। পরে যখন page টা data file-এ লেখা হয় (flush), তখন সেটা আবার clean হয়ে যায়।

Dirty page থাকে RAM-এ, shared buffers-এর ভেতরে। মানে modify হয়েছে, কিন্তু data file-এ এখনও লেখা হয়নি।

প্রতি UPDATE-এ সাথে সাথে disk-এ লেখে না কেন?

কারণ এতে performance নষ্ট হবে। একই page-এ এক সেকেন্ডে হয়তো একশোটা update হয়। প্রতিবার পুরো 8 KB page random জায়গায় disk-এ লিখলে I/O অনেক বেড়ে যাবে। RAM-এ জমিয়ে রেখে পরে একবারে লিখলে একশোটা পরিবর্তন একটা write-এই চলে যায়।

তবু একসময় disk-এ লিখতেই হবে কেন?

দুটো কারণে:

  • Shared buffers সীমিত (default মাত্র 128 MB, production-এ সাধারণত RAM-এর ২৫% এর মতো রাখা হয়)। নতুন page আনতে হলে কোনো পুরোনো page সরাতে হয়। সরানো page টা dirty হলে আগে সেটা disk-এ লিখতে হবে, নাহলে পরিবর্তনটা হারিয়ে যাবে।
  • Crash recovery-র সময় সীমিত রাখা। Data file যত পুরোনো থাকবে, crash-এর পরে তত বেশি কাজ replay করতে হবে।

তাহলে প্রশ্ন আসে: RAM-এ নতুন data আর disk-এ পুরোনো data, এর মাঝে হঠাৎ power চলে গেলে কী হবে? এর উত্তর WAL (Write-Ahead Log)। Data page লেখার আগেই পরিবর্তনের একটা log record durable করা হয়, আর COMMIT সফল হয় সেই WAL disk-এ পৌঁছানোর পরে, data page-এর জন্য অপেক্ষা না করেই। WAL কীভাবে কাজ করে সেটা Part 5-এ বিস্তারিত দেখব। এখানে শুধু এটুকু মনে রাখলেই হবে: dirty page দেরিতে লেখা নিরাপদ, কারণ পরিবর্তনটা আগেই WAL-এ লেখা হয়ে গেছে।

Background Processes: Dirty Page কে লেখে?

Backend process-এর কাজ user-এর query চালানো। Dirty page disk-এ লেখার কাজ মূলত কয়েকটা background process করে। এরা কোনো client-এর SELECT বা UPDATE চালায় না, database-এর পেছনের দেখাশোনার কাজ করে।

flowchart TB
  CLIENT["Client connection"] --> PROC["Backend process<br/>প্রতি connection-এ একটা"]

  subgraph SHMEM["Shared Memory — সব backend একসাথে ব্যবহার করে"]
    BP["Shared Buffers<br/>8 KB page-এর cache<br/>heap ও index page"]
    WALBUF["WAL Buffers<br/>disk-এ যাওয়ার আগের staging"]
    LOCK["Lock table<br/>row, table, advisory lock"]
  end

  PROC -->|"page পড়ে / বদলায়"| BP
  PROC -->|"page বদলানোর আগে<br/>WAL record লেখে"| WALBUF
  PROC -.->|"row/table ধরার আগে"| LOCK

  subgraph BG["Background processes"]
    BGW["Background Writer<br/>dirty page আগেভাগে লেখে"]
    CKPT["Checkpointer<br/>checkpoint_timeout বা<br/>max_wal_size হলে"]
    WALW["WAL Writer<br/>WAL buffer flush করে"]
    AV["Autovacuum<br/>launcher + workers"]
  end

  BP -.->|"dirty pages"| BGW
  BP -->|"সব dirty page flush"| CKPT
  WALBUF --> WALW
  AV -.->|"dead tuple পরিষ্কার"| BP

  subgraph DISK["Disk"]
    HEAP["Heap files<br/>8 KB page, unordered rows"]
    WALF["WAL segments<br/>pg_wal/, 16 MB করে"]
    IDX["Index files<br/>B-Tree, GIN, GiST, BRIN"]
    TOAST["TOAST<br/>বড় value আলাদা রাখা"]
  end

  BGW --> HEAP
  CKPT --> HEAP
  WALW --> WALF
  CKPT -.->|"এর আগের WAL<br/>recycle করা যায়"| WALF
  HEAP ~~~ IDX
  HEAP ~~~ TOAST

  classDef proc fill:#2c3e50,stroke:#1a252f,color:#fff
  classDef mem fill:#0284c7,stroke:#075985,color:#fff
  classDef wal fill:#d97706,stroke:#92400e,color:#fff
  classDef bg fill:#7c3aed,stroke:#5b21b6,color:#fff
  classDef disk fill:#64748b,stroke:#475569,color:#fff
  class PROC proc
  class BP,LOCK mem
  class WALBUF,WALF wal
  class BGW,CKPT,WALW,AV bg
  class HEAP,IDX,TOAST disk

Background Writer

Background writer অল্প অল্প করে, বারবার কাজ করে। Shared buffers-এর মধ্যে যেসব dirty page শিগগিরই সরানোর সম্ভাবনা আছে, সেগুলো সে আগেভাগে disk-এ লিখে clean করে রাখে।

এর উদ্দেশ্য backend process-এর কাজ হালকা রাখা। Backend-এর নতুন page আনার জন্য buffer দরকার হলে সে যেন সাথে সাথে একটা clean buffer পায়। কোনো dirty page-কে তখন নিজে disk-এ লিখতে গিয়ে query যেন আটকে না থাকে।

Checkpointer

Checkpointer নির্দিষ্ট সময় পরপর (default checkpoint_timeout = 5 মিনিট) অথবা অনেক WAL জমে গেলে (max_wal_size) একটা checkpoint করে। তখন সেই মুহূর্ত পর্যন্ত যত dirty page আছে, সব data file-এ লিখে দেয়।

Checkpoint শেষ হওয়ার পর PostgreSQL নিশ্চিত হতে পারে যে এই point পর্যন্ত সব পরিবর্তন data file-এ পৌঁছে গেছে। তাই crash-এর পরে recovery শুরু হয় শেষ checkpoint থেকে, একদম শুরু থেকে না। এর আগের WAL আর দরকার হয় না। এর বিস্তারিত Part 5-এ আছে।

একটা ছোট উদাহরণ দিয়ে দুজনের পার্থক্য দেখা যাক:

Dirty: Page 10, 20, 30, 40, 50

Background writer (চলমান অবস্থায়):
  Page 10 → disk   (clean)
  Page 20 → disk   (clean)

Checkpoint এলো:
  Page 30, 40, 50 → disk   (সব clean)
  → এই point থেকে recovery শুরু করা যাবে

Background writer ধীরে ধীরে কিছু dirty page লিখে normal কাজ smooth রাখে, আর checkpointer নির্দিষ্ট সময় পরপর সব dirty page লিখে একটা recovery point তৈরি করে।

Autovacuum Launcher আর Workers

Part 2-এ দেখেছি UPDATE আর DELETE dead tuple রেখে যায়, আর VACUUM সেই জায়গা আবার ব্যবহারযোগ্য করে। প্রতিটা table-এ নিজে হাতে VACUUM চালানো বাস্তবে সম্ভব না। তাই PostgreSQL-এ একটা autovacuum launcher process আছে। এটা table-এর activity statistics দেখে কোথায় কাজ দরকার সেটা ঠিক করে, তারপর autovacuum worker process চালু করে (default সর্বোচ্চ ৩টা একসাথে, autovacuum_max_workers)।

Worker দুটো কাজ করে:

  • VACUUM: dead tuple-এর জায়গা reuse করার উপযোগী করে, table bloat কমিয়ে রাখে।
  • ANALYZE: table-এর data distribution-এর statistics update করে। এই statistics planner ব্যবহার করে, যেটা একটু পরেই দেখব।

এগুলো ছাড়াও আরও background process আছে, যেমন WAL writer, archiver, replication-এর জন্য WAL sender। এই পর্বের জন্য উপরের তিনটা বুঝলেই যথেষ্ট।

Query Lifecycle: SQL থেকে Result

এবার backend process-এর ভেতরে ঢুকি। Backend একটা SQL string পেলে সেটা সরাসরি চালায় না। কয়েকটা ধাপ পেরিয়ে যায়:

flowchart LR
  SQL(["SQL text"]) --> P["Parser<br/>syntax ঠিক আছে?"]
  P --> A["Analyzer<br/>table, column, type"]
  A --> R["Rewriter<br/>view / rule খুলে দেয়"]
  R --> PL["Planner<br/>সবচেয়ে সস্তা plan"]
  PL --> E["Executor<br/>plan চালায়"]
  E --> RES(["Rows"])
  STATS[("Statistics<br/>pg_stats")] -.-> PL

১. Parser: লেখাটা valid SQL কি না

Parser শুধু grammar দেখে। SQL text পড়ে একটা parse tree বানায়:

SELECT
 ├── target: name
 ├── FROM:   users
 └── WHERE:  id = 10

এই ধাপে কোনো table বা column আছে কি না দেখা হয় না। শুধু দেখা হয় লেখাটা SQL-এর নিয়ম মেনে চলছে কি না।

SELEC name FROM users;
-- ERROR: syntax error at or near "SELEC"

২. Analyzer: নামগুলো আসলে আছে কি না

এবার PostgreSQL তার system catalog (pg_class, pg_attribute ইত্যাদি) দেখে প্রতিটা নাম resolve করে। users নামে table আছে কি না, name আর id column আছে কি না, id = 10-এ type আর operator মেলে কি না, এসব এখানে দেখা হয়।

SELECT superhero_name FROM users;
-- Syntax ঠিক, তাই parser পার হয়ে যায়।
-- ERROR: column "superhero_name" does not exist  ← analyzer-এর error

Parser দেখে লেখাটা ঠিক আছে কি না, আর analyzer দেখে লেখায় যাদের নাম আছে তারা আসলে আছে কি না। Analyzer-এর পরে আমরা পাই একটা query tree, যেখানে প্রতিটা reference resolve করা।

৩. Rewriter: View-এর পেছনের query বের করা

সাধারণ query-তে rewriter কিছুই বদলায় না। এর কাজ সবচেয়ে ভালো বোঝা যায় view দিয়ে।

CREATE VIEW active_users AS
SELECT id, name
FROM users
WHERE is_active = true;

View কোনো data রাখে না। active_users নামের পেছনে শুধু এই query-টা সংরক্ষিত থাকে (internally একটা rewrite rule হিসেবে)। এখন কেউ লিখল:

SELECT * FROM active_users WHERE id = 10;

Rewriter দেখে যে active_users একটা view, আর সেই নামের জায়গায় view-এর definition বসিয়ে দেয়। ধারণাগতভাবে query দাঁড়ায়:

SELECT * FROM (
  SELECT id, name FROM users WHERE is_active = true
) AS active_users
WHERE id = 10;

পরের ধাপে planner এই subquery-কে মূল query-র সাথে মিশিয়ে ফেলে। ফলে কার্যত এটা users-এর উপর WHERE is_active = true AND id = 10 হয়ে যায়, আর id-এর index-ও ব্যবহার করা যায়।

Rewriter view-এর নামের পেছনের আসল query বের করে planner-এর জন্য query প্রস্তুত করে।

৪. Planner/Optimizer: কীভাবে চালাব

SQL বলে কী চাই, কীভাবে আনতে হবে সেটা বলে না। একই query অনেকভাবে চালানো যায়। Part 3-এ দেখা Seq Scan, Index Scan, Index Only Scan, Bitmap Heap Scan সবই এখানে সম্ভাব্য উপায়। JOIN থাকলে কোন table আগে, কোন join algorithm, এসবও ঠিক করতে হয়।

PostgreSQL-এর planner cost-based। প্রতিটা সম্ভাব্য plan-এর একটা আনুমানিক cost হিসাব করে, তারপর সবচেয়ে কম cost-এর plan বেছে নেয়। Cost হিসাব করতে দুটো জিনিস লাগে:

  • Statistics: table-এ কতগুলো row আর page আছে, কোনো column-এ কোন value কতবার আসে (pg_statistic, pg_stats view-এ দেখা যায়)। এগুলো আসে ANALYZE থেকে, যেটা autovacuum নিজে থেকেই চালায়।
  • Cost parameter: যেমন seq_page_cost (default 1.0) আর random_page_cost (default 4.0)। মানে একটা page random জায়গা থেকে পড়া পরপর page পড়ার চেয়ে ধরে নেওয়া হয় চারগুণ খরচের।

দুটো উদাহরণ দেখা যাক। users table-এ ১ কোটি row আছে।

SELECT * FROM users WHERE id = 10;

id-তে B-Tree index আছে আর মাত্র ১টা row মিলবে। Seq Scan করলে হাজার হাজার page পড়তে হবে। Index Scan করলে কয়েকটা index page আর একটা heap page পড়লেই হয়ে যায়। Planner Index Scan বেছে নেবে।

SELECT * FROM users WHERE is_active = true;

ধরা যাক ৯০ লাখ row-ই true, আর is_active-এ index-ও আছে। Index দিয়ে ৯০ লাখ TID বের করে প্রতিটার জন্য heap-এ random ভাবে যাওয়ার চেয়ে পুরো table পরপর পড়ে ফেলা অনেক সস্তা। Planner Seq Scan বেছে নেবে।

Index থাকলেই PostgreSQL index ব্যবহার করবে, এমন না। Planner statistics দেখে আনুমানিক cost হিসাব করে সবচেয়ে সস্তা plan বেছে নেয়।

এখান থেকে একটা ব্যবহারিক শিক্ষাও আছে। Statistics পুরোনো হলে (যেমন বড় bulk import-এর পরে, autovacuum তখনও ANALYZE চালায়নি) planner-এর হিসাব ভুল হয়, আর ভুল plan বেছে নিতে পারে। তখন নিজে হাতে ANALYZE users; চালালে অনেক সময় সমস্যা মিটে যায়।

৫. Executor: Plan চালানো

Planner একটা plan tree দেয়। Executor সেই tree ধরে আসলে কাজটা করে। প্রতিটা node (যেমন Index Scan, Sort, Hash Join) তার নিচের node থেকে একটা একটা করে row টেনে নেয়, নিজের কাজ করে, তারপর উপরের node-কে পাঠায়। একদম উপরের node থেকে row গুলো client-এর কাছে যায়।

Page পড়ার সময় executor shared buffers ব্যবহার করে (hit না হলে read), আর প্রতিটা tuple-এর জন্য MVCC visibility check করে, মানে এই snapshot-এ tuple টা দেখা যাবে কি না। UPDATE চললে এখানেই নতুন tuple version লেখা হয় আর page dirty হয়। এভাবে query lifecycle আবার architecture-এর অংশটার সাথে মিলে যায়।

EXPLAIN দিয়ে Planner আর Executor দেখা

EXPLAIN SELECT * FROM users WHERE id = 10;

EXPLAIN শুধু planner যে plan বেছে নিয়েছে সেটা আর তার আনুমানিক cost দেখায়, query চালায় না।

EXPLAIN ANALYZE SELECT * FROM users WHERE id = 10;

EXPLAIN ANALYZE query টা আসলেই execute করে, তারপর প্রতিটা node-এর আসল সময় আর আসল row সংখ্যা দেখায়। আনুমানিক rows আর আসল rows-এর মধ্যে বড় পার্থক্য থাকলে বুঝতে হবে statistics-এ সমস্যা আছে। একটা সতর্কতা: EXPLAIN ANALYZE UPDATE ... সত্যিই data বদলে দেয়, তাই এমন কিছু পরীক্ষা করতে চাইলে BEGIN; ... ROLLBACK;-এর ভেতরে চালাতে হয়।

পুরো ছবিটা একসাথে

SELECT * FROM users WHERE id = 10 পাঠালে শুরু থেকে শেষ পর্যন্ত যা হয়:

  1. NestJS-এর pool থেকে একটা connection নেওয়া হয়। Connection টা প্রথমবার খোলার সময় Postmaster একটা backend process fork করেছিল।
  2. Backend query-টা parse করে, analyze করে, rewrite করে।
  3. Planner statistics দেখে ঠিক করে যে Index Scan সবচেয়ে সস্তা।
  4. Executor index আর heap page shared buffers থেকে পড়ে (না থাকলে data file থেকে আনে), visibility check করে, row ফেরত পাঠায়।
  5. এদিকে background-এ background writer আর checkpointer dirty page disk-এ লেখে, আর autovacuum dead tuple পরিষ্কার করে ও statistics update রাখে।

একটা query-র যাত্রা এক নজরে:

sequenceDiagram
  participant App as NestJS (pool)
  participant BE as Backend process
  participant SB as Shared Buffers
  participant Disk as Data files
  App->>BE: SELECT * FROM users WHERE id = 10
  BE->>BE: Parse → Analyze → Rewrite → Plan (Index Scan)
  BE->>SB: index page আর heap page চাই
  alt page cache-এ আছে
    SB-->>BE: page (hit)
  else cache-এ নেই
    SB->>Disk: page পড়ো
    Disk-->>SB: 8 KB page
    SB-->>BE: page
  end
  BE->>BE: MVCC visibility check
  BE-->>App: row

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

  1. একটা app ৫০০টা direct connection খুলে রাখলে PostgreSQL server-এ কী ঘটে, আর এর সমাধান কী?
  2. একটা UPDATE সফলভাবে শেষ হলো। ঠিক তখন পরিবর্তিত page টা কোথায় আছে, আর সেটাকে কী বলে?
  3. SELECT unknown_col FROM users; কোন ধাপে fail করবে? আর active_users view-এর query কোন ধাপে underlying table-এর query-তে বদলে যায়?

উত্তর: (১) ৫০০টা আলাদা backend OS process তৈরি হবে, প্রতিটার নিজের memory খরচ আছে, আর max_connections পার হয়ে যেতে পারে। সমাধান হলো app-level pool বা PgBouncer দিয়ে connection pooling। (২) Page টা RAM-এ, shared buffers-এ আছে। Data file-এ এখনও লেখা হয়নি, তাই এটা একটা dirty page। পরে background writer বা checkpointer এটা disk-এ লিখবে, আর ততক্ষণ পরিবর্তনটা WAL দিয়ে সুরক্ষিত। (৩) Syntax ঠিক আছে, তাই parser পার হয়ে যাবে। Fail করবে analyzer-এ, কারণ column টা resolve করা যায় না। আর view-এর definition বসানোর কাজটা করে rewriter।

পরের পর্বে

এই পর্বে আমরা দেখলাম PostgreSQL-এর process আর memory কীভাবে সাজানো, আর একটা query কোন ধাপগুলো পেরিয়ে চলে। দুটো জায়গায় আমরা WAL আর checkpoint-এর কথা বলে থেমে গেছি। Part 5-এ সেখান থেকে শুরু করব: transaction আর isolation level কীভাবে কাজ করে, আর WAL কীভাবে নিশ্চিত করে যে COMMIT হওয়া data crash-এর পরেও হারায় না।

Working on something similar?

I'm happy to talk through architecture, AI features or production issues.