mukles.
All posts
  • PostgreSQL
  • Database
  • Backend

PostgreSQL Internals, Part 5: Transaction, Isolation আর WAL

Transaction আর ACID কী, isolation level অনুযায়ী snapshot কীভাবে বদলায়, আর WAL ও checkpoint মিলে PostgreSQL কীভাবে crash-এর পরেও committed data বাঁচিয়ে রাখে, সেটা এখানে বুঝব।

Md Mukles Ali15 min read

এটা PostgreSQL Internals সিরিজের Part 5, শেষ পর্ব। Part 1 · Part 2 · Part 3 · Part 4 · Part 5

আগের পর্বগুলোতে আমরা দেখেছি data disk-এ কীভাবে থাকে, MVCC কীভাবে একাধিক version রাখে, index কীভাবে row খুঁজে দেয়, আর একটা query PostgreSQL-এর ভেতরে কোন পথে চলে। এই পর্বে দুটো প্রশ্নের উত্তর খুঁজব। প্রথমত, একাধিক কাজকে একসাথে "হয় সব, নয় কিছুই না" হিসেবে চালানো যায় কীভাবে, আর একই সময়ে চলা transaction-গুলো একে অপরের কতটুকু দেখতে পায়? দ্বিতীয়ত, COMMIT সফল হওয়ার এক সেকেন্ড পরে server crash করলে data হারায় না কেন?

প্রথম প্রশ্নের উত্তর transaction আর isolation। দ্বিতীয়টার উত্তর WAL।

Transaction কী

ধরা যাক Mukles-এর account থেকে Tanvir-এর account-এ 1000 টাকা পাঠাতে হবে। এর জন্য দুটো কাজ লাগে:

-- 1. Mukles-এর account থেকে 1000 কমাও
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;

-- 2. Tanvir-এর account-এ 1000 বাড়াও
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;

সমস্যা হলো, প্রথম query সফল হওয়ার পর যদি দ্বিতীয়টা চালানোর আগেই server crash করে:

Mukles:  5000 → 4000   ✅
Tanvir:  3000 → ????   ❌ crash

1000 টাকা কোথাও নেই। এক account থেকে কমেছে, অন্যটায় যোগ হয়নি।

এই সমস্যার সমাধান হলো দুটো কাজকে একটা transaction-এর ভেতরে রাখা:

BEGIN;

UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;

COMMIT;

Transaction মানে হলো এক বা একাধিক operation, যেগুলোকে PostgreSQL একটা single unit of work হিসেবে ধরে। সব ঠিকঠাক হলে COMMIT করলে changes স্থায়ী হয়। মাঝপথে সমস্যা হলে ROLLBACK করলে transaction-এর সব change বাতিল হয়ে যায়।

BEGIN
  ↓
Operation 1
  ↓
Operation 2
  ↓
COMMIT   → সব change স্থায়ী
ROLLBACK → সব change বাতিল

একটা ব্যবহারিক কথা: BEGIN ছাড়া চালানো প্রতিটা আলাদা statement-ও PostgreSQL-এ নিজেই একটা ছোট transaction (autocommit)। BEGIN দিয়ে আমরা শুধু transaction-এর সীমানাটা বড় করি।

Transaction কী কী guarantee দেয়, সেটা চারটা অক্ষরে বলা হয়: ACID।

ACID

Atomicity: হয় সব, নয় কিছুই না

Atomicity মানে transaction-এর সব operation একসাথে সফল হবে, নয়তো কোনোটারই কোনো প্রভাব থাকবে না।

টাকা পাঠানোর উদাহরণে প্রথম UPDATE সফল আর দ্বিতীয়টা fail করলে পুরো transaction rollback হবে:

BEGIN
  Mukles -1000   ✅
  Tanvir +1000   ❌ error
        ↓
     ROLLBACK
  Mukles -1000 বাতিল
  Tanvir +1000 বাতিল

PostgreSQL-এ একটা মজার আচরণ আছে। Transaction-এর ভেতরে কোনো statement error দিলে transaction টা "aborted" অবস্থায় চলে যায়। এরপর আর কোনো query চলবে না (current transaction is aborted error আসবে), আর শেষে COMMIT লিখলেও PostgreSQL আসলে ROLLBACK করে। অর্থাৎ ভুল করে অর্ধেক কাজ commit হয়ে যাওয়ার সুযোগ নেই।

MVCC-র দিক থেকে ব্যাপারটা আরও সহজ। Part 2-এ দেখেছি UPDATE নতুন tuple version তৈরি করে। Rollback হলে PostgreSQL ওই transaction-কে aborted হিসেবে চিহ্নিত করে, তাই তার তৈরি নতুন version-গুলো কারো কাছে visible হয় না। পরে VACUUM সেগুলো পরিষ্কার করে।

Consistency: rule ভাঙা যাবে না

Consistency মানে transaction-এর আগে database valid অবস্থায় থাকলে, সফল transaction-এর পরেও database-এর সব enforced rule আর constraint মেনে valid অবস্থায় থাকবে।

ধরা যাক table-এ এই constraint আছে:

ALTER TABLE accounts ADD CONSTRAINT balance_non_negative CHECK (balance >= 0);

এখন balance 500 থাকা অবস্থায় 1000 তুলতে গেলে balance হবে -500। PostgreSQL এই UPDATE reject করবে, আর Atomicity-র কারণে পুরো transaction বাতিল হবে।

UNIQUE, CHECK, FOREIGN KEY, NOT NULL, সবগুলোর ক্ষেত্রেই একই কথা: constraint ভাঙলে transaction commit হতে পারবে না। সাধারণত constraint প্রতিটা statement-এর সময়েই check হয়। কোনো constraint DEFERRABLE INITIALLY DEFERRED হিসেবে declare করা থাকলে check টা COMMIT-এর সময় হয়, আর তখন fail করলে COMMIT নিজেই error দেয় এবং transaction rollback হয়।

দুটোর পার্থক্য মনে রাখার উপায়:

Atomicity   → পুরো কাজ হবে, নয়তো কিছুই হবে না
Consistency → কাজ সফল হলে database-এর rule ভাঙবে না

মনে রাখা দরকার, PostgreSQL শুধু সেই rule-গুলোই রক্ষা করতে পারে যেগুলো constraint হিসেবে বলে দেওয়া আছে। Business rule শুধু application code-এ থাকলে সেটা রক্ষা করার দায়িত্ব application-এর।

Isolation: একসাথে চললেও গুলিয়ে যাবে না

Isolation ঠিক করে, একই সময়ে চলা transaction-গুলো একে অপরের কাজ কতটুকু দেখতে পাবে। এটাই ACID-এর সবচেয়ে বড় অংশ, তাই এর জন্য পুরো একটা section আলাদা রেখেছি।

Durability: commit হলে আর হারাবে না

Durability মানে একবার COMMIT সফল হলে, তারপর server crash করলেও সেই change হারাবে না। এটা কীভাবে সম্ভব, সেই উত্তর হলো WAL। এটা নিয়ে আলোচনা করব isolation-এর পরে।

Isolation আর তার সমস্যাগুলো

একটা transaction একা চললে কোনো ঝামেলা নেই। ঝামেলা শুরু হয় যখন অনেকগুলো transaction একই data নিয়ে একসাথে কাজ করে। কী কী ভুল হতে পারে, তার কয়েকটা পরিচিত নাম আছে।

Dirty Read

Transaction A                 Transaction B
-------------                 -------------
BEGIN                         BEGIN
UPDATE balance = 500
(এখনো COMMIT হয়নি)
                              SELECT balance
                              → 500 দেখলে Dirty Read
A ROLLBACK করল

B এমন একটা value দেখল যেটা কখনো commit-ই হয়নি। B যদি এই value দেখে কোনো সিদ্ধান্ত নেয়, সেটা ভুল data-র উপর ভিত্তি করে নেওয়া।

PostgreSQL কখনো dirty read হতে দেয় না। SQL standard-এ READ UNCOMMITTED নামে একটা level আছে যেখানে dirty read allowed। কিন্তু PostgreSQL-এ এই level চাইলেও সেটা READ COMMITTED-এর মতোই আচরণ করে। কারণটা MVCC: snapshot শুধু committed version দেখায়, তাই uncommitted version চোখেই পড়ে না।

Non-repeatable Read

একই transaction-এ একই row দুইবার পড়লাম। মাঝখানে অন্য transaction সেটা update করে commit করেছে, তাই দুইবার দুই রকম value পেলাম।

Phantom Read

একই condition দিয়ে দুইবার query চালালাম (যেমন WHERE amount > 1000)। মাঝখানে অন্য transaction নতুন row insert করে commit করেছে, তাই দ্বিতীয়বার এমন row দেখা গেল যেটা আগে ছিল না।

Serialization Anomaly

প্রতিটা transaction আলাদাভাবে ঠিক, কিন্তু একসাথে commit হওয়ার পর এমন result তৈরি হলো যেটা transaction-গুলোকে যেকোনো ক্রমে একটার পর একটা চালালে কখনো হতো না। এর উদাহরণ একটু পরে দেখব।

এসব সমস্যা কতটা ঠেকানো হবে, সেটা ঠিক করে isolation level। PostgreSQL-এ কার্যকরভাবে তিনটা level:

READ COMMITTED    ← PostgreSQL default
      ↓
REPEATABLE READ
      ↓
SERIALIZABLE      ← সবচেয়ে strong

এদের মূল পার্থক্য একটা জিনিসে: snapshot কখন নেওয়া হয়। Part 2-এ দেখেছি snapshot ঠিক করে কোন tuple version একটা query-র কাছে visible।

READ COMMITTED: প্রতিটা statement-এ নতুন snapshot

Transaction A                 Transaction B
-------------                 -------------
BEGIN
SELECT balance
→ 1000
                              UPDATE balance = 2000
                              COMMIT ✅
SELECT balance
→ 2000

A একই transaction-এর ভেতরে আছে, তবু দ্বিতীয় SELECT-এ 2000 দেখল। কারণ:

READ COMMITTED-এ প্রতিটা statement শুরু হওয়ার সময় একটা নতুন snapshot নেয়, যেখানে সেই মুহূর্ত পর্যন্ত commit হওয়া সব data থাকে।

প্রথম SELECT  → Snapshot #1 → 1000
        (B commit করল 2000)
দ্বিতীয় SELECT → Snapshot #2 → 2000

তাই READ COMMITTED-এ non-repeatable read আর phantom read দুটোই হতে পারে। বেশিরভাগ web application-এর জন্য এটা যথেষ্ট, আর এজন্যই এটা default।

তবে একটা ফাঁদ আছে। ধরা যাক product-এর stock 1, আর দুটো transaction একসাথে আগে stock পড়ছে, তারপর application code-এ হিসাব করে নতুন value লিখছে:

Transaction A              Transaction B
READ stock → 1             READ stock → 1
"stock আছে"                "stock আছে"
SET stock = 0              SET stock = 0

দুজনেই বিক্রি করে ফেলল, অথচ product ছিল একটা। এই ধরনের ক্ষেত্রে হিসাবটা SQL-এর ভেতরেই করা ভালো, যেমন UPDATE products SET stock = stock - 1 WHERE id = 7 AND stock > 0, অথবা পড়ার সময় SELECT ... FOR UPDATE দিয়ে row lock নেওয়া। READ COMMITTED-এ কোনো UPDATE অন্য transaction-এর lock-এর জন্য অপেক্ষা করলে, সেই transaction commit হওয়ার পর PostgreSQL row-এর latest version-এর উপর WHERE condition আবার যাচাই করে নেয়। তাই প্রথম পদ্ধতিটা নিরাপদ।

REPEATABLE READ: পুরো transaction-এ একটাই snapshot

একই scenario, শুধু isolation level বদলে:

BEGIN ISOLATION LEVEL REPEATABLE READ;
Transaction A                 Transaction B
-------------                 -------------
BEGIN (REPEATABLE READ)
SELECT balance
→ 1000   ← এখানে snapshot নেওয়া হলো
                              UPDATE balance = 2000
                              COMMIT ✅
SELECT balance
→ 1000   ← একই snapshot
COMMIT

নতুন transaction
SELECT balance
→ 2000   ← নতুন snapshot

REPEATABLE READ-এ transaction-এর প্রথম statement-এর সময় একটা snapshot নেওয়া হয়, আর transaction শেষ হওয়া পর্যন্ত সব query সেই একই snapshot দেখে। খেয়াল রাখার মতো বিষয়: snapshot টা BEGIN-এর সময় নয়, প্রথম query-র সময় নেওয়া হয়।

B সত্যিই 2000 commit করেছে, কিন্তু A তার snapshot অনুযায়ী 1000-ই দেখবে। A transaction শেষ করে নতুন transaction শুরু করলে তখন 2000 দেখবে।

তাই এখানে non-repeatable read হয় না। SQL standard-এ REPEATABLE READ-এ phantom read allowed, কিন্তু PostgreSQL-এর implementation snapshot-ভিত্তিক বলে PostgreSQL-এ REPEATABLE READ phantom read-ও ঠেকায়।

আরেকটা পার্থক্য: REPEATABLE READ-এ A যদি এমন row update করতে যায় যেটা তার snapshot নেওয়ার পর অন্য কেউ update করে commit করেছে, PostgreSQL পুরনো data-র উপর ভিত্তি করে update হতে দেয় না। তখন error দেয়:

ERROR: could not serialize access due to concurrent update

Application-কে তখন পুরো transaction আবার চালাতে হবে।

দুটো level পাশাপাশি:

READ COMMITTED
প্রতি statement → নতুন snapshot
1000 → (B commits) → 2000

REPEATABLE READ
পুরো transaction → একটাই snapshot
1000 → (B commits) → 1000

SERIALIZABLE: যেন একটার পর একটা চলেছে

SERIALIZABLE গ্যারান্টি দেয় যে concurrent transaction-গুলোর result এমন হবে, যেন সেগুলো কোনো এক ক্রমে একটার পর একটা চলেছে। এটা কোন সমস্যা ঠেকায়, একটা উদাহরণে দেখা যাক।

হাসপাতালের rule: অন্তত একজন doctor সবসময় on-call থাকবেন। এখন দুজন doctor on-call আছেন, আর দুজনেই একই সময়ে ছুটি নিতে চাইছেন:

Doctor A এর transaction        Doctor B এর transaction
-----------------------        -----------------------
COUNT on-call → 2              COUNT on-call → 2
"আরেকজন আছে, ছুটি নিই"         "আরেকজন আছে, ছুটি নিই"
UPDATE নিজের row               UPDATE নিজের row
COMMIT                         COMMIT

ফলাফল: on-call doctor = 0 ❌

দুটো transaction আলাদা row update করেছে, তাই কোনো row-level conflict নেই। REPEATABLE READ-এও এটা ধরা পড়ে না। কিন্তু এদের যেকোনো একটাকে আগে, অন্যটাকে পরে চালালে দ্বিতীয়জন দেখতেন on-call মাত্র একজন, আর ছুটি নিতেন না। এই ধরনের সমস্যাকে বলে write skew।

PostgreSQL-এর SERIALIZABLE level, যেটা Serializable Snapshot Isolation (SSI) দিয়ে implement করা, transaction-গুলো কোন data পড়ছে আর লিখছে তার dependency track করে। এমন pattern দেখলে একটা transaction-কে abort করে:

ERROR: could not serialize access due to read/write dependencies among transactions

SERIALIZABLE ব্যবহার করলে application-কে serialization failure (SQLSTATE 40001) ধরে transaction retry করার জন্য প্রস্তুত থাকতে হবে। এটা bug নয়, এটাই এই level-এর কাজের ধরন। PostgreSQL ভুল result commit হতে দেওয়ার বদলে একজনকে বলে "আবার চেষ্টা করো"।

সব মিলিয়ে PostgreSQL-এ:

                   Dirty   Non-repeatable  Phantom  Serialization
                   Read    Read            Read     Anomaly
READ COMMITTED     না      হতে পারে        হতে পারে  হতে পারে
REPEATABLE READ    না      না              না       হতে পারে
SERIALIZABLE       না      না              না       না

Level যত strong, retry-এর দরকার তত বেশি হতে পারে। তাই default READ COMMITTED রেখে, যেসব জায়গায় সত্যিই দরকার সেখানে উঁচু level ব্যবহার করাই সাধারণ চর্চা।

Durability কেন সহজ নয়

এবার ACID-এর শেষ অক্ষর। ধরা যাক:

BEGIN;
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT; -- success

এক সেকেন্ড পরে power চলে গেল। Restart-এর পর balance অবশ্যই 5000 থাকতে হবে, কারণ PostgreSQL client-কে বলে দিয়েছে commit সফল।

কিন্তু Part 4-এ দেখেছি, UPDATE প্রথমে page-টা Shared Buffers-এ (RAM) modify করে। Page টা তখন dirty, আর disk-এর data file-এ এখনো পুরনো value:

Shared Buffers (RAM):  balance = 5000   ← dirty page
Disk (data file):      balance = 1000   ← এখনো পুরনো

Power গেলে RAM-এর dirty page হারিয়ে যায়। তাহলে 5000 ফেরত আসবে কোথা থেকে?

প্রতিটা commit-এ সব dirty page disk-এ লিখে দেওয়া একটা সমাধান হতে পারত। কিন্তু সেটা ভীষণ ধীর। একটা ছোট UPDATE-এর জন্যও table আর index মিলিয়ে disk-এর নানা জায়গায় ছড়ানো একাধিক 8 KB page লিখতে হতো, মানে অনেকগুলো random write। PostgreSQL এর চেয়ে ভালো উপায় ব্যবহার করে।

WAL: Write-Ahead Log

WAL হলো একটা append-only log, যেখানে PostgreSQL data-র প্রতিটা change-এর একটা record লিখে রাখে। এই file-গুলো থাকে data directory-র pg_wal/ folder-এ, সাধারণত 16 MB-র segment file হিসেবে।

নামের মধ্যেই মূল নিয়মটা আছে:

Write-Ahead rule: কোনো dirty data page disk-এ লেখার আগে, সেই page-এর change বর্ণনা করা WAL record অবশ্যই disk-এ পৌঁছাতে হবে।

একটা UPDATE-এর পুরো যাত্রা:

UPDATE balance 1000 → 5000
        ↓
Shared Buffers-এ page modify (dirty page)
        ↓
WAL record তৈরি (WAL buffer-এ)
        ↓
COMMIT
        ↓
WAL disk-এ flush (fsync)
        ↓
client পায় "COMMIT success"
        ↓
dirty data page পরে কোনো এক সময় disk-এ লেখা হবে

WAL কেন দ্রুত? কারণ WAL শুধু file-এর শেষে যোগ হয়, মানে sequential write। আর commit-এর সময় শুধু WAL flush করলেই হয়, ছড়িয়ে থাকা data page-গুলো নয়। একসাথে commit হওয়া অনেক transaction-এর WAL একবারেই flush হয়ে যেতে পারে।

একটা UPDATE থেকে COMMIT পর্যন্ত:

sequenceDiagram
  participant App
  participant BE as Backend
  participant WB as WAL buffers
  participant SB as Shared buffers
  participant WAL as WAL on disk
  participant DF as Data files
  App->>BE: UPDATE accounts ...
  BE->>WB: WAL record লেখো (আগে)
  BE->>SB: page বদলাও (dirty page)
  App->>BE: COMMIT
  BE->>WB: commit record
  WB->>WAL: flush + fsync
  WAL-->>BE: disk-এ নিরাপদ
  BE-->>App: COMMIT OK
  Note over SB,DF: dirty page পরে লেখা হবে<br/>(background writer / checkpointer)

WAL record লেখা মানেই commit নয়

এখানে একটা গুরুত্বপূর্ণ পার্থক্য প্রায়ই গুলিয়ে যায়।

BEGIN;
UPDATE accounts SET balance = 5000 WHERE id = 1;
-- এই UPDATE-এর WAL record তৈরি হয়েছে
-- কিন্তু COMMIT-এর আগেই crash

UPDATE-এর WAL record আছে, এমনকি disk-এও পৌঁছে থাকতে পারে (WAL buffer ভরে গেলে বা background-এ flush হলে)। তবু transaction টা committed নয়।

Commit নিজেও WAL-এ একটা আলাদা commit record লেখে। Transaction committed কিনা, সেটা ঠিক হয় এই commit record durable হয়েছে কিনা তা দিয়ে।

Case 1:
UPDATE-এর WAL record   ✅
COMMIT record           ❌
CRASH
→ transaction committed নয়, change কারো কাছে visible হবে না

Case 2:
UPDATE-এর WAL record   ✅
COMMIT record           ✅ disk-এ flush হয়েছে
CRASH
→ committed transaction, recovery-তে change ফিরে আসবে

অর্থাৎ:

WAL record → "কী change হয়েছিল" তার তথ্য
COMMIT record → "এই transaction সফলভাবে শেষ হয়েছে"

Default setting synchronous_commit = on-এ commit record disk-এ flush না হওয়া পর্যন্ত PostgreSQL client-কে success জানায় না। কেউ যদি synchronous_commit = off করে, তাহলে commit দ্রুত হয়, কিন্তু crash হলে শেষ মুহূর্তের কয়েকটা "সফল" commit হারিয়ে যেতে পারে। Database corrupt হয় না, তবে durability-র guarantee একটু ঢিলা হয়।

Crash Recovery: WAL replay

Server crash করে restart হলে PostgreSQL প্রথমেই crash recovery চালায়:

Dirty page   ❌ RAM থেকে হারিয়েছে
WAL          ✅ disk-এ আছে
        ↓
PostgreSQL restart
        ↓
WAL পড়ে সব change আবার data page-এ apply (redo)
        ↓
database crash-এর আগের অবস্থায়
        ↓
connection নেওয়া শুরু

Recovery-র সময় PostgreSQL WAL replay করে, মানে WAL-এ লেখা change-গুলো আবার data page-এ apply করে। একটা সূক্ষ্ম বিষয়: PostgreSQL uncommitted transaction-এর change-ও replay করে, কারণ সেগুলোও WAL-এ আছে। কিন্তু সেই transaction-এর কোনো commit record নেই, তাই সেটা aborted হিসেবে গণ্য হয়, আর MVCC-র visibility rule অনুযায়ী তার tuple-গুলো কেউ দেখতে পায় না। পরে VACUUM সেগুলো সরিয়ে দেয়। অর্থাৎ PostgreSQL-এর আলাদা "undo" ধাপ লাগে না, MVCC-ই সেই কাজ করে দেয়।

এখন প্রশ্ন হলো, replay শুরু হবে কোথা থেকে? Database এক বছর ধরে চললে কি এক বছরের WAL replay করতে হবে?

Crash-এর পরে restart হলে:

flowchart LR
  C(["Crash"]) --> S["Restart"]
  S --> F["শেষ checkpoint-এর<br/>redo point খুঁজে বের করো"]
  F --> RP["সেখান থেকে WAL replay"]
  RP --> V{"Transaction-এর<br/>commit record আছে?"}
  V -->|হ্যাঁ| OK["পরিবর্তন visible"]
  V -->|না| INV["MVCC-তে invisible থাকে"]
  OK --> READY(["Database ready"])
  INV --> READY

Checkpoint

ধরা যাক PostgreSQL এক ঘণ্টা ধরে চলছে:

WAL:  1 ── 2 ── 3 ── 4 ── 5 ── 6 ── 7 ── 8 ── 9 ...

Data page যদি কখনোই disk-এ লেখা না হতো, তাহলে recovery-র জন্য একেবারে শুরু থেকে সব WAL রাখতে হতো, আর crash-এর পর সব replay করতে হতো। WAL অসীমভাবে বাড়ত, আর recovery-তে লাগত ঘণ্টার পর ঘণ্টা।

এর সমাধান checkpoint। Part 4-এ দেখা checkpointer process নিয়মিত এই কাজটা করে।

Checkpoint-এর সময় PostgreSQL সেই মুহূর্ত পর্যন্ত Shared Buffers-এর সব dirty page data file-এ লিখে দেয়, তারপর WAL-এ একটা checkpoint record লেখে। এই checkpoint-এর শুরুর WAL position-কে বলে redo point।

                  CHECKPOINT (redo point)
                       ↓
WAL:  1 ─ 2 ─ 3 ─ 4 │ 5 ─ 6 ─ 7 ─ 💥 crash
                     │
      এগুলোর change    │ শুধু এখান থেকে replay
      data file-এ আছে  │

Redo point-এর আগের সব change এখন data file-এ নিরাপদে আছে। তাই crash হলে PostgreSQL শেষ checkpoint-এর redo point থেকে replay শুরু করে, একেবারে শুরু থেকে নয়।

এর দুটো বড় সুবিধা:

  1. Crash recovery-র কাজ সীমিত থাকে। শুধু শেষ checkpoint-এর পরের WAL replay করতে হয়।
  2. পুরনো WAL recycle করা যায়। Redo point-এর আগের WAL segment-গুলো আর crash recovery-র জন্য দরকার নেই, তাই PostgreSQL সেগুলো মুছে দেয় বা নতুন segment হিসেবে আবার ব্যবহার করে। (Replication বা WAL archiving চালু থাকলে সেগুলোর প্রয়োজন মিটে যাওয়া পর্যন্ত WAL রাখা হয়।)

Checkpoint কখন হবে, সেটা মূলত দুটো setting ঠিক করে: checkpoint_timeout (default 5 মিনিট) আর max_wal_size (এতটা WAL জমে গেলে checkpoint)। একসাথে হাজার হাজার page লিখে I/O-তে হঠাৎ চাপ যাতে না পড়ে, সেজন্য checkpoint_completion_target লেখার কাজটা পরের checkpoint পর্যন্ত সময় জুড়ে ছড়িয়ে দেয়।

একটা সহজ তুলনা হলো game-এর save point। Game crash করলে আমরা Level 1 থেকে নয়, শেষ save point থেকে শুরু করি। তুলনাটা পুরোপুরি নিখুঁত নয়, কারণ PostgreSQL save point থেকে শুরু করে WAL replay করে crash-এর ঠিক আগের মুহূর্ত পর্যন্ত পৌঁছে যায়, মাঝের কোনো committed কাজ হারায় না। তবে checkpoint কেন দরকার, সেটা বোঝার জন্য তুলনাটা কাজে লাগে।

WAL বনাম Checkpoint

WAL
→ প্রতিটা change-এর record
→ Durability দেয়: commit হলে crash-এর পরেও ফেরত আনা যায়

Checkpoint
→ dirty page data file-এ লেখার একটা milestone
→ recovery কোথা থেকে শুরু হবে ঠিক করে
→ এর আগের WAL recycle করা যায়

পুরো ছবিটা এক জায়গায়:

UPDATE
  ↓
Shared Buffers-এ dirty page
  ↓
WAL record
  ↓
COMMIT → commit record সহ WAL disk-এ flush → client-কে success
  ↓
... সময় যায় ...
  ↓
CHECKPOINT → dirty page data file-এ, পুরনো WAL recycle
  ↓
crash হলে → শেষ redo point থেকে WAL replay

WAL নিশ্চিত করে কোনো committed change হারাবে না। Checkpoint নিশ্চিত করে সেই নিশ্চয়তার খরচ, মানে WAL-এর আকার আর recovery-র সময়, সীমার মধ্যে থাকবে।

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

  1. READ COMMITTED-এ একটা transaction একই row দুইবার পড়ল। মাঝখানে অন্য transaction value বদলে commit করেছে। দ্বিতীয়বার কোন value আসবে? REPEATABLE READ হলে কী হতো?
  2. একটা transaction-এর UPDATE-এর WAL record disk-এ আছে, কিন্তু commit record নেই। Crash-এর পর এই change কি দেখা যাবে?
  3. Checkpoint না থাকলে কোন দুটো সমস্যা হতো?

উত্তর: (1) READ COMMITTED-এ নতুন value, কারণ প্রতিটা statement নতুন snapshot নেয়। REPEATABLE READ-এ পুরনো value, কারণ পুরো transaction একই snapshot দেখে। (2) না। Commit record ছাড়া transaction committed নয়। Replay হলেও MVCC-র কারণে সেই change invisible থাকবে। (3) WAL কখনো recycle করা যেত না, অসীমভাবে বাড়ত। আর crash recovery-তে একেবারে শুরু থেকে সব WAL replay করতে হতো।

সিরিজের সারাংশ

এই পর্বের মধ্য দিয়ে PostgreSQL Internals সিরিজ শেষ হলো। পাঁচটা পর্বে আমরা যা দেখলাম:

  • Part 1: Heap, Page আর Tuple: table আসলে 8 KB page-এর একটা heap file। প্রতিটা page-এ line pointer দিয়ে tuple খুঁজে পাওয়া যায়, আর CTID বলে দেয় একটা tuple কোন page-এর কোন slot-এ আছে।
  • Part 2: MVCC আর VACUUM: UPDATE পুরনো row মুছে না দিয়ে নতুন version তৈরি করে। xmin/xmax আর snapshot ঠিক করে কে কোন version দেখবে, আর VACUUM dead tuple-এর জায়গা আবার ব্যবহারযোগ্য করে।
  • Part 3: Index কীভাবে কাজ করে: B-Tree index কীভাবে TID ধরে heap-এ পৌঁছে দেয়, আর Index Scan, Index Only Scan ও Bitmap Heap Scan-এর মধ্যে পার্থক্য কী।
  • Part 4: Architecture আর Query Lifecycle: postmaster, প্রতি connection-এ একটা backend process, shared buffers, background process-গুলো, আর parser থেকে executor পর্যন্ত একটা query-র যাত্রা।
  • Part 5: Transaction, Isolation আর WAL: ACID, snapshot-এর lifetime দিয়ে isolation level-এর পার্থক্য, আর WAL ও checkpoint দিয়ে durability।

এই পাঁচটা টুকরো আসলে একটাই ছবির অংশ। Page আর tuple হলো ভিত্তি, MVCC সেই tuple-এর একাধিক version রাখে, isolation level ঠিক করে কোন snapshot দিয়ে সেই version দেখা হবে, index দ্রুত সঠিক tuple-এ পৌঁছায়, আর WAL নিশ্চিত করে এই সবকিছু crash-এর পরেও টিকে থাকবে। পরের বার কোনো query ধীর চললে, table অকারণে বড় হতে থাকলে, বা concurrent request-এ অদ্ভুত bug দেখা দিলে, এই ছবিটা মাথায় রাখলে কারণ খুঁজে পাওয়া অনেক সহজ হবে।

Working on something similar?

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