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

MVCC আর isolation level — anomaly থেকে serializable

MVCC and Isolation Levels — Read Phenomena, Snapshot Isolation, Write Skew and Serializable Snapshot Isolation

Taught in Bangla, this lesson covers isolation levels and MVCC: dirty reads, non-repeatable reads and phantoms, lost updates, read skew and write skew, the four SQL standard isolation levels and their critique, multiversion concurrency control, how InnoDB, Oracle and SQLite differ, snapshot isolation and serializable snapshot isolation, what each DBMS really provides, and choosing a level in application code.

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

  • দুই-কলামের timeline-এ ছয়টি anomaly ধাপে ধাপে — কোনটি কখন ঘটে, কে ঠেকায়
  • SQL standard-এর চারটি isolation level, তার টেবিল, আর Berenson ও সহকর্মীদের ১৯৯৫ সালের সমালোচনা
  • MVCC-র ভেতরে: PostgreSQL-এর xmin/xmax, visibility-র নিয়ম, VACUUM; InnoDB আর Oracle-এর undo — লাইভ SQL-এ version মডেল করে
  • Snapshot isolation-এর গ্যারান্টি, write skew-এর প্রমাণ, আর SSI কীভাবে তা ধরে
  • কোন DBMS আসলে কী দেয়, আর বাস্তব অ্যাপে কোন level আর কোন প্যাটার্ন বাছবেন

১ · একসাথে অনেকে — isolation কেন একটি দর-কষাকষি

পর্ব ৪৪-এ দেখেছেন, ACID-এর “I” মানে isolation: একসাথে চলা transaction-গুলো যেন একে অপরের অর্ধেক কাজ দেখে বিভ্রান্ত না হয়। পর্ব ৪৫-এ এর সবচেয়ে কড়া রূপের সংজ্ঞা পেয়েছেন — serializable: একসাথে চলার ফল এমন হতে হবে, যেন transaction-গুলো কোনো একটি ক্রমে একটির পর একটি চলেছে। আর পর্ব ৪৬-এ দেখেছেন, lock আর two-phase locking দিয়ে কীভাবে সেটি নিশ্চিত করা যায়।

কমলাপুর স্টেশনে ঈদের আগে টিকিট কাউন্টার। একটিই জানালা খোলা রাখলে কখনো একই আসন দুজনকে বিক্রি হবে না — কিন্তু লাইন যাবে প্ল্যাটফর্ম পেরিয়ে। দশটি জানালা খুললে লাইন ছোট, কিন্তু দুই কাউন্টারের কর্মী একই মুহূর্তে খাতায় দেখলেন “ঙ-১২ খালি”, দুজনেই বিক্রি করলেন। Isolation level ঠিক এই প্রশ্ন: কতটা সমান্তরাল কাজ চলবে, আর বিনিময়ে কোন ধরনের ভুলের ঝুঁকি মেনে নেবেন।

পুরো serializable-এর দাম আছে। lock-ভিত্তিক ব্যবস্থায় পাঠক লেখকের জন্য, লেখক পাঠকের জন্য অপেক্ষা করে; একটি বড় রিপোর্ট-কোয়েরি পুরো টেবিল পড়লে সেই সময়ে কেউ লিখতে পারে না, আর deadlock-ও বাড়ে। তাই ১৯৭০-এর দশক থেকেই DBMS-গুলো কয়েকটি দুর্বল ধাপ দিয়েছে। প্রতিটি ধাপ কিছু নির্দিষ্ট ভুল ঠেকায় আর কিছু ঘটতে দেয়। এই ভুলগুলোর নাম anomaly (বা phenomenon) — এই পর্বের প্রথম কাজ এগুলো চেনা।

পুরো পর্বে একটি ছোট টেবিল ধরে নিন: acct(id, owner, bal) — অ্যাকাউন্ট নম্বর, মালিক আর ব্যালান্স (টাকা)। শুরুতে রহিমের (id 1) আর করিমের (id 2) প্রত্যেকের ১,০০০ টাকা। দুটি transaction-কে বলব T1 আর T2; timeline-এ সময় ওপর থেকে নিচে চলে।

২ · তিনটি ক্লাসিক read anomaly — dirty, non-repeatable আর phantom

SQL standard (SQL-92) তিনটি “ঘটনা” দিয়ে isolation level সংজ্ঞায়িত করেছে। নিচের ডায়াগ্রামে তিনটিই — প্রতিটি তিন ধাপে। বাঁ কলামে পাঠক T1, ডান কলামে লেখক T2. জায়গা বাঁচাতে SQL সংক্ষেপে: SELECT bal মানে SELECT bal FROM acct WHERE id = 1, অর্থাৎ রহিমের ব্যালান্স পড়া।

সময়T1 · রিপোর্টT2 · টাকা তোলে
১—BEGIN; UPDATE রহিমের bal = ৫০০ (commit হয়নি)
২SELECT bal → 500—
৩৫০০ ধরে রিপোর্ট ছাপা হলোROLLBACK → আবার ১,০০০
সময়T1 · রিপোর্টT2 · টাকা তোলে
১BEGIN; SELECT bal → 1000—
২—UPDATE bal = ৭০০; COMMIT
৩SELECT bal → 700—
সময়T1 · রিপোর্টT2 · নতুন অ্যাকাউন্ট
১BEGIN; bal ≥ ১,০০০ গুনল → 2—
২—INSERT সুমন, ১,৫০০; COMMIT
৩একই শর্তে আবার গুনল → 3—
অবস্থা
anomaly
commit হওয়া মান
T1 যা দেখল
সবচেয়ে দুর্বল যে level ঠেকায়
  1. খসড়া লেখা। T2 রহিমের ব্যালান্স ৫০০ করেছে, কিন্তু commit করেনি — এটি এখনো খসড়া। commit হওয়া মান এখনো ১,০০০.
  2. Dirty read. T1 সেই খসড়া মান ৫০০ পড়ে ফেলল। একে বলে dirty read — অন্যের commit-না-হওয়া লেখা পড়া। শুধু READ UNCOMMITTED এটি ঘটতে দেয়।
  3. কখনো না-থাকা মান। T2 ROLLBACK করল; ৫০০ টাকা কখনো ডেটাবেসে “সত্যি” ছিল না। অথচ T1-এর রিপোর্টে ৫০০ ছাপা হয়ে গেছে। READ COMMITTED থেকে ওপরের সব level এটি ঠেকায়।
  4. প্রথম পড়া। নতুন দৃশ্য: T1 একটি transaction-এর ভেতরে রহিমের ব্যালান্স পড়ল — ১,০০০, commit হওয়া সঠিক মান।
  5. মাঝে অন্যের commit. T2 ব্যালান্স ৭০০ করে commit করল। এটি নিয়মমাফিক, খসড়া নয়।
  6. Non-repeatable read. T1 একই সারি আবার পড়ে পেল ৭০০ — একই transaction-এ একই প্রশ্নের দুই উত্তর। এটি non-repeatable read (fuzzy read). READ COMMITTED এটি ঘটতে দেয়; REPEATABLE READ ঠেকায়।
  7. শর্ত দিয়ে গোনা। T1 গুনল কতজনের ব্যালান্স কমপক্ষে ১,০০০ — ২ জন (রহিম আর করিম, এই দৃশ্যে আবার ১,০০০ করে)।
  8. নতুন সারি। T2 সুমনের নতুন অ্যাকাউন্ট খুলল ১,৫০০ টাকায়, commit করল। পুরোনো কোনো সারি বদলায়নি।
  9. Phantom. একই শর্তে আবার গুনে T1 পেল ৩. আগের পড়া সারিগুলো একই আছে, কিন্তু শর্ত মানে এমন একটি নতুন সারি “ভূতের মতো” হাজির — phantom read. সারি-ভিত্তিক lock একে ঠেকাতে পারে না, কারণ যে সারি তখনো ছিল না, তাতে lock দেওয়া যায় না। standard অনুযায়ী শুধু SERIALIZABLE এটি পুরোপুরি ঠেকায়।

লক্ষ করুন, তিনটি ঘটনাই পড়ার সমস্যা, আর ক্রমশ সূক্ষ্ম: প্রথমটিতে মিথ্যা মান, দ্বিতীয়টিতে একটি সত্য মান বদলে যাওয়া, তৃতীয়টিতে শর্তের ফল বদলে যাওয়া। phantom ঠেকাতে lock-ভিত্তিক DBMS শুধু সারিতে নয়, শর্তে বা index-এর ফাঁকে (range) lock দেয় — পর্ব ৪৬-এর predicate lock বা MySQL-এর next-key lock, যা নিয়ে অংশ ৮-এ আবার আসব।

৩ · লেখার anomaly — lost update, read skew আর write skew

standard-এর তিনটি ঘটনার বাইরেও বাস্তবে আরও তিনটি ভুল প্রায়ই দেখা যায়। প্রথম দুটি নিচের ডায়াগ্রামে; উদাহরণে দোকানের একটি পণ্যের মজুত (stock) ৫টি, আর রহিম–করিমের যৌথ দুটি অ্যাকাউন্ট A আর B, প্রতিটিতে ৫০০ টাকা।

সময়T1 · কাউন্টার ১T2 · কাউন্টার ২
১SELECT stock → 5SELECT stock → 5
২অ্যাপে ৫ − ১ = ৪; UPDATE stock = ৪; COMMIT—
৩—অ্যাপে ৫ − ১ = ৪; UPDATE stock = ৪; COMMIT
সময়T1 · মোট হিসাবT2 · A থেকে B-তে ১০০
১BEGIN; A পড়ল → 500—
২—A = ৪০০, B = ৬০০; COMMIT
৩B পড়ল → 600; মোট ১,১০০—
অবস্থা
anomaly
ডেটাবেসে
সঠিক উত্তর
যা ঘটল
  1. একই পুরোনো মান। দুই কাউন্টার প্রায় একই মুহূর্তে মজুত পড়ল — দুজনেই ৫. কোনো খসড়া পড়া হয়নি, তাই এটি dirty read নয়।
  2. প্রথম লেখা। কাউন্টার ১ অ্যাপের ভেতরে হিসাব করে ৪ লিখল আর commit করল। এখন পর্যন্ত সব ঠিক।
  3. Lost update. কাউন্টার ২-ও তার পুরোনো দেখা ৫ থেকে ৪ লিখল। দুটি ঘড়ি বিক্রি হলো, মজুত কমল একটি — প্রথম লেখাটি মুছে গেল। এটি lost update. READ COMMITTED এটি ঠেকায় না, কারণ দুজনেই commit হওয়া মানই পড়েছে।
  4. প্রথম অর্ধেক। নতুন দৃশ্য: T1 দুই অ্যাকাউন্টের মোট বের করবে। আগে A পড়ল — ৫০০.
  5. মাঝখানে স্থানান্তর। T2 A থেকে ১০০ টাকা B-তে পাঠিয়ে commit করল। T2 নিজে নিখুঁত: মোট এখনো ১,০০০.
  6. Read skew. T1 এবার B পড়ল — নতুন মান ৬০০. পুরোনো A আর নতুন B মিলিয়ে মোট ১,১০০, যে অবস্থা ডেটাবেসে কোনো মুহূর্তেই ছিল না। এটি read skew — দুটি সম্পর্কিত মান দুই ভিন্ন মুহূর্ত থেকে পড়া। প্রতিটি পড়া আলাদাভাবে commit হওয়া মান, তাই READ COMMITTED এটি ঘটতে দেয়।

Lost update একটি সংযোগেই অনুকরণ করে দেখুন। এই এডিটর পাতার দোকান ডেটাবেস ব্যবহার করে — products(product_id, name, category_id, price, stock); product 110 হলো Smart Watch, মজুত ৫টি। দুই “সেশন”-এর অ্যাপ যা পড়ে মনে রাখে, তা রাখা হয়েছে একটি অস্থায়ী টেবিল app_mem-এ:

CREATE TEMP TABLE app_mem (session TEXT, seen INTEGER);
-- দুই সেশন একই মুহূর্তে মজুত পড়ে মনে রাখল
INSERT INTO app_mem SELECT 'S1', stock FROM products WHERE product_id = 110;
INSERT INTO app_mem SELECT 'S2', stock FROM products WHERE product_id = 110;
SELECT * FROM app_mem;
-- প্রত্যেকে নিজের দেখা মান থেকে ১ কমিয়ে লিখল
UPDATE products SET stock = (SELECT seen - 1 FROM app_mem WHERE session = 'S1')
WHERE product_id = 110;
UPDATE products SET stock = (SELECT seen - 1 FROM app_mem WHERE session = 'S2')
WHERE product_id = 110;
SELECT product_id, name, stock FROM products WHERE product_id = 110;

app_mem-এ S1 | 5 আর S2 | 5; দুটি UPDATE-এর প্রতিটিতে ১টি সারি বদলেছে; শেষ ফল 110 | Smart Watch | 4. দুটি বিক্রি, মজুত কমেছে একটি। এখানে কোনো ডেটাবেস-ত্রুটি নেই — প্রতিটি স্টেটমেন্ট নিজে সঠিক। ভুলটি read-modify-write ধাঁচে: মান পড়ে অ্যাপে হিসাব, তারপর হিসাবের ফল লেখা। মাঝের ফাঁকে অন্য কেউ লিখলে সেই লেখা হারায়। অংশ ৯-এ এর এক লাইনের প্রতিকার দেখবেন।

Write skew — দুজনেই নিয়ম মেনেছেন, তবু নিয়ম ভাঙল

ষষ্ঠ anomaly সবচেয়ে সূক্ষ্ম। একটি হাসপাতালের নিয়ম: রাতের shift-এ কমপক্ষে একজন ডাক্তার on call থাকবেন। ডা. অ্যালিস আর ডা. বব দুজনেই on call, দুজনেই অসুস্থ বোধ করছেন। প্রত্যেকে অ্যাপে “ছুটি নিন” চাপলেন; অ্যাপ প্রথমে গোনে কতজন on call, ২ হলে নিজের সারিতে on_call = 0 লেখে। দুটি transaction একসাথে চললে দুজনেই ২ দেখেন, দুজনেই নিজের আলাদা সারি বদলান — শেষে on call শূন্য। এটি write skew: দুজনে একই তথ্য পড়ে ভিন্ন ভিন্ন সারিতে লেখেন, আর দুই লেখা মিলে একটি নিয়ম ভাঙে যা কোনো একটি লেখা একা ভাঙত না।

একই গল্প ব্যাংকে: রহিম–করিমের যৌথ দুটি অ্যাকাউন্ট, নিয়ম “দুটির যোগফল কখনো ঋণাত্মক নয়”, প্রতিটিতে ৫০০. রহিম A থেকে ৯০০ আর করিম একই সময়ে B থেকে ৯০০ তুলতে চান। প্রত্যেকের transaction দেখে মোট ১,০০০ ≥ ৯০০, তাই অনুমতি দেয়। ফল A = −৪০০, B = −৪০০, মোট −৮০০. lost update-এর সাথে পার্থক্যটি মনে রাখুন: lost update-এ দুজন একই সারিতে লেখে; write skew-এ ভিন্ন সারিতে। এই পার্থক্যের কারণেই অংশ ৭-এর snapshot isolation প্রথমটি ঠেকায়, দ্বিতীয়টি পারে না।

৪ · SQL standard-এর চারটি level — আর তার সমালোচনা

SQL-92 standard চারটি isolation level দিয়েছে, আর প্রতিটির সংজ্ঞা দিয়েছে কোন ঘটনা ঘটতে পারবে না তা দিয়ে। স্টেটমেন্টটি সব DBMS-এ মোটামুটি এক:

SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;   -- পরের transaction-এর জন্য
-- PostgreSQL-এ BEGIN-এর সাথেই:
BEGIN ISOLATION LEVEL SERIALIZABLE;
LevelDirty readNon-repeatable readPhantom
READ UNCOMMITTEDঘটতে পারেঘটতে পারেঘটতে পারে
READ COMMITTEDঘটে নাঘটতে পারেঘটতে পারে
REPEATABLE READঘটে নাঘটে নাঘটতে পারে
SERIALIZABLEঘটে নাঘটে নাঘটে না

টেবিলটি পরীক্ষায় সবচেয়ে বেশি আসে — মুখস্থ রাখুন। standard অনুযায়ী ডিফল্ট level SERIALIZABLE, যদিও বাস্তবের প্রায় কোনো বড় DBMS-এর ডিফল্ট সেটি নয় (অংশ ৮)। standard আরও বলে, SERIALIZABLE মানে শুধু তিনটি ঘটনার অনুপস্থিতি নয় — ফল কোনো serial ক্রমের সমান হতে হবে।

সমালোচনা, ১৯৯৫। Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton, Elizabeth O'Neil আর Patrick O'Neil SIGMOD সম্মেলনে প্রকাশ করেন “A Critique of ANSI SQL Isolation Levels”. তাঁদের মূল কথাগুলো: (১) standard-এর ইংরেজি সংজ্ঞা দুইভাবে পড়া যায় — একটি “কড়া” ব্যাখ্যা (anomaly আসলে ঘটল কি না) আর একটি “বিস্তৃত” ব্যাখ্যা (ঘটার মতো পরিস্থিতি তৈরি হলো কি না); কড়া ব্যাখ্যায় তিনটি ঘটনা নিষেধ করলেও ফল serializable হওয়ার নিশ্চয়তা আসে না, তাই তাঁরা বিস্তৃত ব্যাখ্যার পক্ষে যুক্তি দেন। (২) একটি মৌলিক ঘটনা বাদ পড়েছে — dirty write (অন্যের commit-না-হওয়া লেখার ওপর লেখা), যা প্রতিটি level-এ নিষিদ্ধ হওয়া উচিত। (৩) lost update, read skew আর write skew-এর মতো ঘটনা তালিকায় নেই। (৪) সবচেয়ে বড় কথা, তখনকার কয়েকটি বাণিজ্যিক DBMS (বিশেষত Oracle) যে ব্যবস্থা দিত, সেটি চারটির কোনোটিতেই খাপ খায় না। তাঁরা তার নাম দেন snapshot isolation.

সমালোচনার পর বিস্তৃত তালিকাটি দাঁড়ায় এরকম (lock-ভিত্তিক REPEATABLE READ ধরে):

LevelDirty readLost updateNon-rep. / read skewPhantomWrite skew
READ COMMITTEDনাহতে পারেহতে পারেহতে পারেহতে পারে
REPEATABLE READ (lock)নানানাহতে পারেনা
Snapshot isolationনানানাsnapshot-পড়ায় না*হতে পারে
SERIALIZABLEনানানানানা

* একই শর্তে আবার পড়লে snapshot isolation নতুন সারি দেখায় না, তাই standard-এর অর্থে phantom নেই; কিন্তু phantom-ঘটিত write skew (যেমন “এই সময়ে কোনো booking নেই” দেখে দুজনেই নতুন সারি ঢোকানো) ঘটতে পারে। Berenson ও সহকর্মীরা দেখান, snapshot isolation আর lock-ভিত্তিক REPEATABLE READ একে অপরের চেয়ে কড়া নয় — প্রতিটি এমন কিছু ঠেকায় যা অন্যটি দেয়। পরে Atul Adya-র ১৯৯৯ সালের PhD গবেষণা isolation-কে lock বা প্রয়োগ-পদ্ধতি থেকে আলাদা করে, শুধু transaction-গুলোর পারস্পরিক নির্ভরতার গ্রাফ দিয়ে সংজ্ঞায়িত করে — আধুনিক আলোচনার ভাষা অনেকটাই সেখান থেকে।

৫ · MVCC — একটি সারির অনেক version

lock-ভিত্তিক ব্যবস্থার বড় কষ্ট: পাঠক আর লেখক একে অপরকে আটকায়। MVCC (multi-version concurrency control) সমাধান দেয় একটি সরল ভাবনায়: সারি বদলালে পুরোনোটি মোছা নয় — নতুন একটি version বানানো। তাহলে যে পাঠক আগে শুরু করেছে, সে পুরোনো version পড়তে থাকে; লেখক নতুনটি লেখে; কেউ কারও জন্য দাঁড়ায় না। দুই লেখক একই সারিতে লিখতে চাইলে অবশ্য একজনকে অপেক্ষা করতে হয় — MVCC লেখা-লেখার সংঘাত দূর করে না।

বিশ্ববিদ্যালয়ের নোটিশ বোর্ডে ফলাফলের তালিকা। সংশোধন হলে পুরোনো কাগজ ছিঁড়ে ফেলা হয় না — নতুন কাগজ পাশে সাঁটা হয়, ওপরে তারিখ। যে শিক্ষার্থী সকাল ১০টার তালিকা দেখে হিসাব করছেন, তিনি সেটিই দেখতে থাকেন; দুপুরে যিনি এলেন, তিনি নতুনটি দেখেন। সবাই পুরোনো কাগজ দেখা শেষ করলে পিয়ন সেটি সরিয়ে ফেলেন। MVCC-তে সেই “কোন মুহূর্তের তালিকা দেখছি” হলো snapshot, আর পিয়ন হলো VACUUM বা purge.

Snapshot-এর সংজ্ঞা: একটি নির্দিষ্ট মুহূর্তে যে transaction-গুলো commit হয়ে গেছে, তাদের সেট। নিয়ম: একটি transaction শুধু সেই version-গুলো দেখে, যা তার snapshot-এর কোনো transaction লিখেছে (সাথে নিজের লেখা), আর যা snapshot-এর কোনো transaction মুছে বা বদলে ফেলেনি। snapshot কখন নেওয়া হয়, তা-ই level ঠিক করে: READ COMMITTED-এ প্রতিটি স্টেটমেন্টের শুরুতে নতুন snapshot, আর REPEATABLE READ বা snapshot isolation-এ পুরো transaction-এ একটিই।

PostgreSQL: xmin, xmax আর visibility

PostgreSQL প্রতিটি transaction-কে একটি ক্রমবর্ধমান নম্বর দেয় — transaction ID বা xid. টেবিলের প্রতিটি version-এ (PostgreSQL-এর ভাষায় tuple) দুটি লুকানো কলাম থাকে: xmin — কোন xid এই version তৈরি করেছে, আর xmax — কোন xid একে মুছেছে বা নতুন version দিয়ে বদলেছে (না হলে খালি)। UPDATE আসলে “পুরোনোটির xmax-এ নিজের xid লেখো + নতুন version ঢোকাও, xmin = নিজের xid”. আর commit বা abort-এর খবর থাকে আলাদা একটি commit log-এ (pg_xact). Visibility-র নিয়ম, সরল রূপে:

একটি version snapshot S-এ দেখা যায়, যদি এবং কেবল যদি

(ক) xmin-এর transaction S-এর ভেতরে (S নেওয়ার আগে commit হয়েছে) বা xmin = নিজের xid; এবং (খ) xmax খালি, বা xmax-এর transaction S-এর বাইরে (তখনো চলছে, পরে commit হয়েছে, অথবা abort হয়েছে)।

acct, id = 1 — সব version✗ = abort হওয়া xid

            
কে কী দেখে
S1 = {100}
S2 = {100, 101}
নিজের লেখা
বাতিল হয়ে যাওয়া version
  1. প্রথম version. xid 100 সারিটি ১,০০০ টাকায় ঢুকিয়ে commit করল: v1, xmin = 100. এখন একটি snapshot S1 নেওয়া হলো — এতে শুধু 100 আছে। S1 দেখে v1.
  2. UPDATE মানে নতুন version. xid 101 ব্যালান্স ৭০০ করছে। v1 মোছেনি — শুধু তার xmax-এ 101 বসেছে; নতুন v2-এর xmin = 101. S1-এ 101 নেই, তাই S1-এর চোখে v1-এর xmax “এখনো ঘটেনি” আর v2 অদৃশ্য — S1 আগের মতোই ১,০০০ দেখে, অপেক্ষা ছাড়াই। xid 101 নিজে নিজের লেখা v2 দেখে।
  3. Commit-এর পর নতুন snapshot. 101 commit হলো। নতুন snapshot S2 = {100, 101} দেখে v2 (৭০০) — v1-এর xmax 101 S2-এর ভেতরে, তাই v1 মুছে-যাওয়া। কিন্তু পুরোনো S1 এখনো v1 দেখে: এটিই repeatable read. v1 এখন কারও কাছে বাতিল নয়, কারণ S1 এখনো তাকে চায়।
  4. আরেকটি UPDATE. xid 102 ব্যালান্স ৭৫০ করছে: v2-এর xmax = 102, নতুন v3. S1 দেখে v1, S2 দেখে v2, 102 নিজে দেখে v3 — একই মুহূর্তে একই সারির তিনটি ভিন্ন মান, তিনজনের কাছে।
  5. Abort. 102 ROLLBACK করল। কোনো ডেটা মুছতে হলো না — commit log-এ 102 “aborted” লেখাই যথেষ্ট। v3-এর xmin abort হওয়া, তাই v3 কারও কাছে দৃশ্যমান নয়; v2-এর xmax abort হওয়া, তাই v2 আবার জীবিত। এখন নেওয়া যেকোনো নতুন snapshot = {100, 101} দেখে v2. ROLLBACK এত দ্রুত হওয়ার কারণ এটিই।
  6. VACUUM. S1 শেষ হলো। এখন v1-কে কোনো চলমান snapshot দেখতে পারে না (তার xmax 101 সবার snapshot-এ), আর v3 abort-এর লেখা — দুটিই dead tuple. VACUUM এদের জায়গা ফেরত নেয়। S1 চালু থাকলে v1 মোছা যেত না — দীর্ঘ transaction কেন ক্ষতিকর, তা এখানেই।

এবার নিয়মটি নিজের হাতে SQL-এ চালান। তিনটি ছোট টেবিল মডেল করে: acct_v(id, bal, xmin, xmax) — সব version; tx(xid, status) — commit log (100, 101, 103 committed; 102 running; 104 aborted); আর snap(snap, xid) — প্রতিটি snapshot-এ কোন xid আছে। S1 = {100}, S2 = {100, 101}, S3 = {100, 101, 103}, আর T102 হলো চলমান transaction 102-এর নিজের দৃষ্টি = {100, 101} + নিজে। xid 103 সুমনের নতুন অ্যাকাউন্ট (id 3) খুলেছে, আর 104 করিমের ব্যালান্স ০ করতে গিয়ে abort হয়েছে:

SELECT * FROM acct_v ORDER BY id, xmin;
-- প্রতিটি snapshot কোন version দেখে (visibility-র নিয়ম)
SELECT s.snap,
       GROUP_CONCAT(v.id || ':' || v.bal, '  ') AS visible_rows
FROM (SELECT DISTINCT snap FROM snap) s
JOIN acct_v v
  ON v.xmin IN (SELECT xid FROM snap WHERE snap = s.snap)
 AND (v.xmax IS NULL
      OR v.xmax NOT IN (SELECT xid FROM snap WHERE snap = s.snap))
GROUP BY s.snap ORDER BY s.snap;

প্রথম ফলে ছয়টি version, যেমন 1 | 1000 | 100 | 101 আর 2 | 0 | 104 | NULL. দ্বিতীয় ফল: S1 | 1:1000 2:1000, S2 | 1:700 2:1000, S3 | 1:700 2:1000 3:500, T102 | 1:750 2:1000. লক্ষ করুন তিনটি জিনিস। (১) কোনো snapshot করিমের ০ দেখে না — 104 abort হয়েছে, তাই কোনো snap-এ নেই; আর তার xmax 104 snapshot-এর বাইরে, তাই পুরোনো ১,০০০ জীবিত। (২) সুমনের সারি (3) শুধু S3-এ — S1 বা S2 যতবারই গুনুক, নতুন সারি দেখবে না: snapshot-এ phantom নেই। (৩) T102 নিজের খসড়া ৭৫০ দেখে, অন্য কেউ দেখে না — dirty read অসম্ভব। (GROUP_CONCAT-এর ক্রম SQLite-এ নিশ্চিত নয়; এখানে ঢোকানোর ক্রমে এসেছে।)

VACUUM, dead tuple আর xid wraparound

পুরোনো version জমতে থাকে; কেউ না সরালে টেবিল ফুলে যায় (bloat), আর scan-কে অকারণে মৃত সারি পেরোতে হয়। PostgreSQL-এ VACUUM সেই version সরায় যা আর কোনো চলমান snapshot দেখতে পারে না; সাধারণত autovacuum পেছনে নিজে চালায়। পর্ব ৩৯-এ দেখা VACUUM-এর এটিই কারণ। একই মডেলে দেখুন কোন version মোছা যায়:

-- S1 (সবচেয়ে পুরোনো snapshot) শেষ হলো
DELETE FROM snap WHERE snap = 'S1';
SELECT v.id, v.bal, v.xmin, v.xmax,
  CASE
    WHEN v.xmin IN (SELECT xid FROM tx WHERE status = 'aborted') THEN 'dead'
    WHEN v.xmax IS NOT NULL AND NOT EXISTS (
         SELECT 1 FROM (SELECT DISTINCT snap FROM snap) s
         WHERE v.xmax NOT IN (SELECT xid FROM snap WHERE snap = s.snap))
      THEN 'dead'
    ELSE 'needed'
  END AS vacuum_says
FROM acct_v v ORDER BY v.id, v.xmin;

DELETE-এ ১টি সারি বদলেছে; তারপর ছয়টি সারির মধ্যে দুটি dead: 1 | 1000 | 100 | 101 (xmax 101 এখন বাকি সব snapshot-এ — কেউ আর v1 দেখবে না) আর 2 | 0 | 104 | NULL (abort হওয়া transaction-এর লেখা)। বাকি চারটি needed. প্রথম লাইনটি (DELETE) মুছে আবার চালান: এবার শুধু 2 | 0 dead, কারণ S1 এখনো v1 চায়। ঘণ্টার পর ঘণ্টা খোলা একটি transaction — হয়তো কেউ psql-এ BEGIN লিখে বাড়ি চলে গেছেন — এভাবেই পুরো ডেটাবেসের VACUUM আটকে রাখে।

আরেকটি সূক্ষ্ম কাজও VACUUM-এর। PostgreSQL-এর xid ৩২-বিটের, তাই প্রায় ৪০০ কোটি নম্বরের পর আবার ঘুরে আসে; তুলনা হয় বৃত্তাকারে — প্রতিটি xid-এর “অতীত” প্রায় ২০০ কোটি, “ভবিষ্যৎ” প্রায় ২০০ কোটি। খুব পুরোনো version-কে সময়মতো freeze (সবার কাছে চিরকাল দৃশ্যমান বলে চিহ্নিত) না করলে একদিন তার xmin হঠাৎ “ভবিষ্যতের” মনে হবে আর সারিটি অদৃশ্য হবে — একে বলে transaction ID wraparound. এটি ঠেকাতে autovacuum নিয়মিত freeze করে, আর বিপদসীমার কাছে গেলে PostgreSQL নতুন xid দেওয়াই বন্ধ করে দেয়। ব্যস্ত ডেটাবেসে autovacuum বন্ধ রাখা তাই বিপজ্জনক।

৬ · InnoDB, Oracle আর SQLite — অন্য পথে একই লক্ষ্য

PostgreSQL নতুন version টেবিলেই নতুন জায়গায় লেখে, পুরোনোটি সেখানেই থাকে। MySQL-এর InnoDB আর Oracle উল্টো পথ নেয়: সারি জায়গাতেই বদলায় (in-place), আর পুরোনো মান যায় একটি আলাদা জায়গায়।

InnoDB: undo log আর read view

প্রতিটি সারিতে লুকানো দুটি ঘর: শেষ লেখকের transaction ID আর একটি roll pointer. UPDATE নতুন মান জায়গায় লেখে, আর পুরোনো মান রাখে undo log-এ; roll pointer সেখানে নির্দেশ করে — এভাবে পেছনের দিকে একটি version-শিকল। একটি consistent read তৈরি করে read view: তখন কোন transaction-গুলো চলছিল তার তালিকা। সারির লেখক read view-এর চোখে অদৃশ্য হলে InnoDB undo-শিকল ধরে পেছনে গিয়ে দৃশ্যমান version বানায়। আর কারও আর দরকার নেই এমন undo রেকর্ড মোছে পেছনের purge থ্রেড।

Oracle: undo segment

Oracle-এ একই ধারণা: পুরোনো মান undo segment-এ। প্রতিটি কোয়েরি একটি SCN (system change number) মুহূর্তের ডেটা দেখে — Oracle-এর ভাষায় read consistency. একটি দীর্ঘ কোয়েরির দরকারি পুরোনো undo যদি ইতোমধ্যে নতুন লেখা দিয়ে ঢাকা পড়ে, কোয়েরিটি ব্যর্থ হয় পরিচিত ORA-01555 “snapshot too old” ত্রুটিতে।

SQLite: WAL-এ snapshot

SQLite সারি-স্তরের version রাখে না, কিন্তু WAL mode-এ (পর্ব ৪৮-এর write-ahead log) একটি পাঠক শুরুর মুহূর্তের WAL-সীমা পর্যন্তই পড়ে — পুরো ডেটাবেসের একটি snapshot. একসাথে একজনই লেখক; পুরোনো snapshot-ওয়ালা পাঠক লিখতে গেলে ব্যর্থ হয়। ফলে SQLite-এর transaction সবসময় serializable.

দুই নকশার দাম আলাদা জায়গায়। PostgreSQL-এ পড়া সরল — দরকারি version টেবিলেই — কিন্তু প্রতিটি UPDATE একটি নতুন tuple আর সাধারণত প্রতিটি index-এ নতুন entry (PostgreSQL-এর HOT update কিছু ক্ষেত্রে এড়ায়), আর VACUUM-এর কাজ। InnoDB-তে নতুন মান জায়গায়, তাই টেবিল কম ফোলে, কিন্তু পুরোনো snapshot-এর পাঠককে undo-শিকল বেয়ে পেছনে যেতে হয়; একটি দীর্ঘ transaction purge আটকালে undo জমে (MySQL-এ “history list length” বাড়ে) আর সেই শিকল লম্বা হয়। লক্ষ্য একই, ঝামেলা ভিন্ন।

পাতার SQL রানার একটিই সংযোগ, তাই দুই সেশন দেখাতে পারে না। নিচের Python প্রোগ্রাম একটি অস্থায়ী ফাইলে SQLite ডেটাবেস খোলে WAL mode-এ, আর দুটি আলাদা সংযোগ (দুই সেশন) দিয়ে একই timeline চালায়। isolation_level=None মানে BEGIN আর COMMIT আমরা নিজে লিখব; timeout=0 মানে lock পেলে অপেক্ষা না করে সাথে সাথে ত্রুটি:

import sqlite3, tempfile, os

path = os.path.join(tempfile.mkdtemp(), "bank.db")
db = sqlite3.connect(path)
print("journal mode:", db.execute("PRAGMA journal_mode=WAL").fetchone()[0])
db.execute("CREATE TABLE acct (id INTEGER PRIMARY KEY, owner TEXT, bal INTEGER)")
db.executemany("INSERT INTO acct VALUES (?, ?, ?)", [(1, "Rahim", 1000), (2, "Karim", 1000)])
db.commit(); db.close()

t1 = sqlite3.connect(path, isolation_level=None, timeout=0)   # session 1
t2 = sqlite3.connect(path, isolation_level=None, timeout=0)   # session 2
bal = lambda c: c.execute("SELECT bal FROM acct WHERE id = 1").fetchone()[0]

t1.execute("BEGIN")
print("T1 first read     :", bal(t1))      # snapshot starts here
t2.execute("BEGIN")
t2.execute("UPDATE acct SET bal = bal - 300 WHERE id = 1")
print("T1 while T2 open  :", bal(t1))      # no dirty read
t2.execute("COMMIT")
print("T1 after T2 commit:", bal(t1))      # same snapshot, same value
print("T2 after commit   :", bal(t2))
try:
    t1.execute("UPDATE acct SET bal = bal + 50 WHERE id = 1")
except sqlite3.OperationalError as e:
    print("T1 write fails    :", e.sqlite_errorname)
t1.execute("ROLLBACK")
print("T1 new transaction:", bal(t1))

আউটপুট: journal mode: wal, তারপর T1 তিনবার পড়ে 1000, 1000, 1000; T2 after commit : 700; T1 write fails : SQLITE_BUSY_SNAPSHOT; আর শেষে T1 new transaction: 700. প্রথম পড়াতেই T1-এর snapshot তৈরি হয়। T2 লেখার সময় T1 আটকায়নি (পাঠক লেখকের জন্য অপেক্ষা করে না), খসড়া ৭০০-ও দেখেনি, আর T2 commit করার পরও T1 পুরোনো snapshot-এ ১,০০০ দেখেছে — repeatable read. কিন্তু সেই পুরোনো snapshot থেকে লিখতে গেলে SQLite সোজা না বলে দেয়, কারণ T1-এর দেখা পৃথিবী আর বর্তমান নেই; লিখতে দিলে lost update হতো। ROLLBACK করে নতুন transaction শুরু করলে T1 নতুন snapshot-এ ৭০০ দেখে।

৭ · Snapshot isolation-এর গ্যারান্টি, write skew আর SSI

Snapshot isolation (SI)-এর দুই নিয়ম: (১) প্রতিটি transaction তার শুরুর snapshot থেকে পড়ে; (২) দুটি একসাথে চলা transaction একই সারিতে লিখলে শুধু একটি commit করতে পারে। দ্বিতীয় নিয়মের দুই রূপ: Berenson-এর first-committer-wins — যে আগে commit করে সে জেতে; আর PostgreSQL-এর বাস্তব রূপ first-updater-wins — দ্বিতীয় লেখক প্রথমজনের row lock-এর জন্য অপেক্ষা করে, প্রথমজন commit করলে দ্বিতীয়জন ত্রুটি পায় could not serialize access due to concurrent update.

কেন SI প্রথম পাঁচটি anomaly ঠেকায় — সংক্ষিপ্ত যুক্তি। snapshot একটি স্থির সেট: শুধু শুরুর আগে commit হওয়া transaction-গুলো। প্রতিটি পড়া সেই সেটের লেখা version ফেরত দেয়। তাই (ক) খসড়া কখনো সেটে নেই — dirty read নেই; (খ) একই সারি বা একই শর্ত বারবার পড়লে একই সেট থেকে একই উত্তর — non-repeatable read আর snapshot-পড়ায় phantom নেই; (গ) দুটি সম্পর্কিত সারি একই মুহূর্ত থেকে পড়া — read skew নেই; (ঘ) lost update-এ দুজনেই একই সারিতে লেখে, যা নিয়ম (২) অনুযায়ী অসম্ভব — একজন বাতিল হয়।

কেন write skew ফসকে যায় — প্রমাণ। ডাক্তারদের উদাহরণে T1 (অ্যালিস) পড়ে দুই সারি, লেখে শুধু অ্যালিসের সারি; T2 (বব) পড়ে দুই সারি, লেখে শুধু ববের সারি। লেখার সেট আলাদা, তাই নিয়ম (২) কিছু বলে না — দুজনেই commit. এখন পর্ব ৪৫-এর precedence graph আঁকুন। T1 ববের সারির পুরোনো version পড়েছে, যা T2 পরে বদলেছে: তাই কোনো সমতুল্য serial ক্রমে T1-কে T2-এর আগে থাকতে হবে (T1 → T2). একইভাবে T2 অ্যালিসের পুরোনো সারি পড়েছে যা T1 বদলেছে: T2 → T1. গ্রাফে চক্র, তাই কোনো serial ক্রম এই ফল দিতে পারে না — schedule-টি serializable নয়। এই ধরনের প্রান্ত, “একজন যা পড়েছে, অন্যজন পরে তার নতুন version লিখেছে”, হলো rw-antidependency. ২০০৫ সালে Alan Fekete ও সহকর্মীরা প্রমাণ করেন: SI-তে প্রতিটি অ-serializable চক্রে পরপর দুটি rw-antidependency প্রান্ত থাকে, যাদের দুই প্রান্তের transaction একসাথে চলেছে — একে বলে “dangerous structure”.

এই উপপাদ্যই serializable snapshot isolation (SSI)-এর ভিত্তি। Michael Cahill, Uwe Röhm আর Alan Fekete ২০০৮ সালে SIGMOD-এ দেখান: SI চালিয়ে যান, শুধু চলমান transaction-গুলোর rw-dependency নজরে রাখুন; কোনো transaction-এ ঢোকা আর বেরোনো দুটি rw-প্রান্ত দেখা দিলে একটি transaction বাতিল করুন। কোনো অপেক্ষা নেই, পাঠক এখনো লেখককে আটকায় না। PostgreSQL 9.1 (২০১১) থেকে SERIALIZABLE level এভাবেই চলে — Kevin Grittner আর Dan Ports-এর কাজ। পড়া ট্র্যাক করতে PostgreSQL একটি বিশেষ, আটকায় না এমন predicate lock নেয় (pg_locks-এ mode SIReadLock).

সময়T1 · অ্যালিসT2 · বব
১BEGIN; on call গুনল → 2BEGIN; on call গুনল → 2
২নিজের সারিতে on_call = 0নিজের সারিতে on_call = 0
৩COMMIT ✓
৪—retry: BEGIN; গুনল → 1; ছুটি নামঞ্জুর
অবস্থা
level
rw-প্রান্ত
on call
  1. একই snapshot. দুজনেই transaction শুরু করে গুনলেন — on call ২ জন। নিয়ম মানা যায়, তাই প্রত্যেকে ছুটির সিদ্ধান্ত নিলেন।
  2. আলাদা সারিতে লেখা। অ্যালিস নিজের সারি, বব নিজের সারি বদলালেন। একই সারিতে লেখা নেই, তাই কেউ কারও lock-এ আটকালেন না। কিন্তু প্রত্যেকে এমন সারি পড়েছেন যা অন্যজন বদলাচ্ছেন: T1 → T2 আর T2 → T1 — দুটি rw-antidependency.
  3. SI-তে দুজনেই commit. REPEATABLE READ (PostgreSQL-এ যা snapshot isolation) শুধু একই সারিতে লেখার সংঘাত দেখে। দুটি commit-ই সফল।
  4. ফল: নিয়ম ভাঙল। on call এখন শূন্য। কোনো transaction একা নিয়ম ভাঙেনি; দুটি মিলে ভেঙেছে। precedence graph-এ চক্র — serializable নয়।
  5. SSI-তে একই খেলা। এবার দুজনেই SERIALIZABLE-এ। পড়াগুলো SIRead lock রাখে, তাই লেখার সময় PostgreSQL rw-প্রান্তগুলো দেখে। অ্যালিস আগে commit করলেন ✓; ববের commit-এ ধরা পড়ল dangerous structure — ERROR: could not serialize access due to read/write dependencies among transactions — একটি serialization failure (SQLSTATE 40001). ঠিক কোন স্টেটমেন্টে ত্রুটি আসবে, তা সময়ের ওপর নির্ভর করে; প্রায়ই commit-এ।
  6. Retry. ববের অ্যাপ ত্রুটিটি চিনে পুরো transaction নতুন করে চালাল। নতুন snapshot-এ অ্যালিসের ছুটি দৃশ্যমান: on call ১ জন। অ্যাপের নিজের নিয়মই এবার ছুটি নামঞ্জুর করল।
  7. ফল: নিয়ম টিকল। on call ১ জন। SSI কোনো ব্যবসায়িক নিয়ম জানে না; সে শুধু নিশ্চিত করে, ফলটি কোনো serial ক্রমের সমান। serial-ভাবে চললে দ্বিতীয়জন ১ দেখতেনই — তাই নিয়মটি নিজে থেকেই রক্ষা পায়।

SQLite-এ একই নাটক দেখুন, retry loop-সহ। SQLite-এ একসাথে একজনই লেখক, আর পুরোনো snapshot থেকে লেখা নিষিদ্ধ — তাই write skew এখানে অসম্ভব, শুধু কে আগে লিখল তার ওপর নির্ভর করে কোনজন retry করবে:

import sqlite3, tempfile, os

path = os.path.join(tempfile.mkdtemp(), "hospital.db")
db = sqlite3.connect(path)
db.execute("PRAGMA journal_mode=WAL")
db.execute("CREATE TABLE doctors (name TEXT PRIMARY KEY, on_call INTEGER)")
db.executemany("INSERT INTO doctors VALUES (?, 1)", [("Alice",), ("Bob",)])
db.commit(); db.close()

alice = sqlite3.connect(path, isolation_level=None, timeout=0)
bob = sqlite3.connect(path, isolation_level=None, timeout=0)
count = lambda c: c.execute("SELECT COUNT(*) FROM doctors WHERE on_call = 1").fetchone()[0]
leave = lambda c, who: c.execute("UPDATE doctors SET on_call = 0 WHERE name = ?", (who,))

alice.execute("BEGIN"); bob.execute("BEGIN")
print("Alice sees on call:", count(alice), "| Bob sees:", count(bob))
leave(alice, "Alice"); alice.execute("COMMIT")
try:
    leave(bob, "Bob")
except sqlite3.OperationalError as e:
    print("Bob's UPDATE:", e.sqlite_errorname)
    bob.execute("ROLLBACK")

def go_off_duty(con, who, tries=3):          # the retry loop
    for attempt in range(1, tries + 1):
        try:
            con.execute("BEGIN")
            n = count(con)
            if n < 2:
                con.execute("ROLLBACK")
                return f"attempt {attempt}: refused, {n} on call"
            leave(con, who); con.execute("COMMIT")
            return f"attempt {attempt}: off duty"
        except sqlite3.OperationalError:
            con.execute("ROLLBACK")
    return "gave up"

print("Bob retries ->", go_off_duty(bob, "Bob"))
print("final:", alice.execute("SELECT * FROM doctors ORDER BY name").fetchall())

আউটপুট চার লাইন: Alice sees on call: 2 | Bob sees: 2, Bob's UPDATE: SQLITE_BUSY_SNAPSHOT, Bob retries -> attempt 1: refused, 1 on call আর final: [('Alice', 0), ('Bob', 1)]. দুজনেই ২ দেখেছিলেন, ঠিক যেমন ডায়াগ্রামে। অ্যালিস commit করার পর ববের snapshot পুরোনো, তাই তাঁর লেখা প্রত্যাখ্যাত। go_off_duty হলো যেকোনো SERIALIZABLE অ্যাপের আদর্শ retry loop: পুরো transaction — পড়া, সিদ্ধান্ত, লেখা — শুরু থেকে আবার, সীমিত কয়েকবার। শুধু ব্যর্থ UPDATE-টি আবার পাঠালে হতো না, কারণ সিদ্ধান্তটি পুরোনো পড়ার ওপর দাঁড়িয়ে ছিল। PostgreSQL-এ ধরতে হয় SQLSTATE 40001 (আর deadlock-এর 40P01).

SSI-এর দাম। rw-dependency ট্র্যাক করতে মেমরি আর CPU লাগে; আর পদ্ধতিটি রক্ষণশীল — আসলে serializable এমন কিছু schedule-ও বাতিল হয় (false positive). contention বেশি হলে retry বাড়ে। কিছু কৌশল সাহায্য করে: transaction ছোট রাখা, শুধু-পড়ার কাজকে READ ONLY ঘোষণা করা (PostgreSQL-এ SERIALIZABLE READ ONLY DEFERRABLE এমন snapshot-এর জন্য অপেক্ষা করে যেখানে কখনো বাতিল হবে না), আর সঠিক index — যাতে predicate lock পুরো টেবিলে না ছড়ায়।

৮ · কোন DBMS আসলে কী দেয়

একই নামের level ভিন্ন DBMS-এ ভিন্ন জিনিস। নিচের টেবিল প্রচলিত সংস্করণের নথিভুক্ত আচরণের সারসংক্ষেপ — সংস্করণ আর সেটিং অনুযায়ী খুঁটিনাটি বদলায়, তাই নিজের DBMS-এর documentation মিলিয়ে নিন।

DBMSডিফল্টযা আসলে পান
PostgreSQLREAD COMMITTEDREAD UNCOMMITTED চাইলেও READ COMMITTED (dirty read কখনো নয়); READ COMMITTED-এ প্রতিটি স্টেটমেন্টের নতুন snapshot; REPEATABLE READ = snapshot isolation (একই সারিতে সংঘাতে 40001); SERIALIZABLE = SSI (9.1 থেকে)
MySQL (InnoDB)REPEATABLE READসাধারণ SELECT transaction-এর প্রথম পড়ার read view থেকে; কিন্তু UPDATE, DELETE আর SELECT … FOR UPDATE সর্বশেষ commit হওয়া version পড়ে আর next-key lock (সারি + আগের ফাঁক) নেয়। ফলে এটি খাঁটি SI নয়: অ্যাপে হিসাব করা read-modify-write-এ lost update সম্ভব। SERIALIZABLE-এ (autocommit বন্ধ থাকলে) সাধারণ SELECT-ও shared lock নেয়
SQL ServerREAD COMMITTED (lock)ডিফল্টে পড়ার সময় shared lock — পাঠক লেখককে আটকায়। ডেটাবেসে READ_COMMITTED_SNAPSHOT ON (RCSI) দিলে READ COMMITTED row version (tempdb-এর version store) থেকে পড়ে; আলাদা SNAPSHOT level = SI (চালু করতে ALLOW_SNAPSHOT_ISOLATION); SERIALIZABLE = key-range lock
OracleREAD COMMITTEDশুধু READ COMMITTED আর SERIALIZABLE (সাথে READ ONLY); SERIALIZABLE আসলে snapshot isolation — সংঘাতে ORA-08177; write skew সম্ভব
SQLiteSERIALIZABLEসবসময় serializable: ডেটাবেস-স্তরের lock, একসাথে একজন লেখক। rollback-journal mode-এ লেখক commit করার সময় পাঠকদের বাদ দেয়; WAL mode-এ পাঠক snapshot দেখে, লেখককে আটকায় না

দুটি পরিণতি মনে রাখুন। এক, একটি অ্যাপ MySQL থেকে PostgreSQL-এ (বা উল্টো) সরালে একই REPEATABLE READ লেখা থাকলেও আচরণ বদলায় — PostgreSQL ত্রুটি দিয়ে retry চাইবে যেখানে MySQL নীরবে সর্বশেষ মানে লিখত। দুই, Oracle-এ বা পুরোনো PostgreSQL-এ (9.1-এর আগে) “SERIALIZABLE” লিখে যাঁরা নিশ্চিন্ত ছিলেন, তাঁরা আসলে snapshot isolation পেতেন — write skew সহ।

৯ · বাস্তবে level বাছাই আর অ্যাপের প্যাটার্ন

বাস্তবের বেশিরভাগ ওয়েব আর মোবাইল অ্যাপ ডিফল্ট READ COMMITTED-এ চলে, আর নির্দিষ্ট জায়গায় নিরাপত্তা যোগ করে। মূল কৌশল: সংঘাতকে এমন জায়গায় আনুন, যেখানে ডেটাবেস নিজে তা দেখতে পায় — একই সারিতে, বা একটি constraint-এ। চারটি প্যাটার্ন:

১. Atomic UPDATE — পড়া আর লেখা এক স্টেটমেন্টে

অংশ ৩-এর lost update-এর প্রতিকার: অ্যাপে হিসাব না করে ডেটাবেসকেই হিসাব করতে দিন, আর শর্তটি WHERE-এ রাখুন। একই Smart Watch (মজুত ৫); সাথে Power Bank (103), যার মজুত ০:

-- দুটি আলাদা বিক্রি, প্রতিটি ১টি করে
UPDATE products SET stock = stock - 1 WHERE product_id = 110 AND stock >= 1;
UPDATE products SET stock = stock - 1 WHERE product_id = 110 AND stock >= 1;
SELECT name, stock FROM products WHERE product_id = 110;
-- ৩টির অর্ডার, তারপর আরও ১টি
UPDATE products SET stock = stock - 3 WHERE product_id = 110 AND stock >= 3;
UPDATE products SET stock = stock - 1 WHERE product_id = 110 AND stock >= 1;
-- মজুত নেই এমন পণ্য
UPDATE products SET stock = stock - 1 WHERE product_id = 103 AND stock >= 1;
SELECT product_id, name, stock FROM products WHERE product_id IN (103, 110);

প্রথম দুটি UPDATE-এর প্রতিটিতে ১টি সারি বদলেছে, মজুত Smart Watch | 3 — দুটি বিক্রি, দুটি কম, কিছু হারায়নি। ৩টির অর্ডারে ১টি সারি বদলেছে (মজুত ০); তারপরের দুটিতে ০টি সারি বদলেছে — শর্ত মেলেনি, তাই বিক্রি হয়নি। শেষ ফল 103 | Power Bank | 0 আর 110 | Smart Watch | 0. অ্যাপ শুধু দেখবে কয়টি সারি বদলাল: ১ মানে বিক্রি সফল, ০ মানে “স্টক শেষ”। এটি সমান্তরালেও নিরাপদ কেন? PostgreSQL-এর READ COMMITTED-এ দ্বিতীয় UPDATE প্রথমটির row lock-এর জন্য অপেক্ষা করে, তারপর সারির নতুন version-এ WHERE আবার যাচাই করে; InnoDB-ও UPDATE-এ সর্বশেষ version পড়ে। আর এই টেবিলে CHECK (stock >= 0) থাকায় WHERE ভুলে গেলেও মজুত ঋণাত্মক হতো না — ত্রুটি আসত।

২. UNIQUE constraint — দুবার booking অসম্ভব

ট্রেনের আসন: “আসনটি খালি কি না দেখো, তারপর INSERT” — দুই transaction একসাথে খালি দেখে দুজনেই ঢোকাতে পারে (phantom-ঘটিত write skew). প্রতিকার: নিয়মটি schema-তে লিখুন। seat_booking(trip_id, seat_no, passenger)-এ (trip_id, seat_no) primary key:

INSERT INTO seat_booking VALUES (501, 'B4', 'Rahim');
-- করিমও B4 চান: নীরবে বাদ, অ্যাপ 0 rows দেখে অন্য আসন দেখাবে
INSERT INTO seat_booking VALUES (501, 'B4', 'Karim') ON CONFLICT DO NOTHING;
INSERT INTO seat_booking VALUES (501, 'B5', 'Karim') ON CONFLICT DO NOTHING;
SELECT * FROM seat_booking ORDER BY seat_no;
-- ON CONFLICT ছাড়া: ত্রুটি
INSERT INTO seat_booking VALUES (501, 'B4', 'Sumon');

প্রথম INSERT-এ ১টি সারি বদলেছে, করিমের B4-এ ০টি সারি বদলেছে, B5-এ আবার ১টি; তালিকা 501 | B4 | Rahim আর 501 | B5 | Karim. শেষ লাইন ইচ্ছে করে ত্রুটি দেয়: UNIQUE constraint failed: seat_booking.trip_id, seat_booking.seat_no. সমান্তরালে কেন নিরাপদ: unique index-এ একই key দুবার ঢোকানো যায় না; দ্বিতীয় transaction প্রথমটির ফলের জন্য অপেক্ষা করে, প্রথমটি commit করলে ত্রুটি পায় — যেকোনো isolation level-এ। সময়ের পরিসরের booking-এ (হলরুম ১০টা–১২টা) PostgreSQL-এর exclusion constraint একই কাজ করে: কোনো দুটি সারির সময় যেন না মেশে।

৩. সংঘাতকে একটি সারিতে আনা — ডাক্তারদের shift

write skew-এর মূল সমস্যা: দুজন ভিন্ন সারিতে লেখেন। যদি দুজনকেই একটি সাধারণ সারিতে লিখতে হয়, সংঘাত দৃশ্যমান হয় — একে বলে materializing the conflict. shift(shift_id, on_call_count) টেবিলে on call-এর সংখ্যা রাখা, সাথে CHECK (on_call_count >= 1); ছুটি নিতে হলে একই transaction-এ এই সারিও কমাতে হয়:

-- অ্যালিসের ছুটি
BEGIN;
UPDATE shift SET on_call_count = on_call_count - 1 WHERE shift_id = 1;
UPDATE doctors SET on_call = 0 WHERE name = 'Alice';
COMMIT;
SELECT d.name, d.on_call, s.on_call_count FROM doctors d JOIN shift s USING (shift_id);
-- ববের ছুটি
BEGIN;
UPDATE shift SET on_call_count = on_call_count - 1 WHERE shift_id = 1;
UPDATE doctors SET on_call = 0 WHERE name = 'Bob';
COMMIT;

অ্যালিসের transaction সফল; তালিকা Alice | 0 | 1 আর Bob | 1 | 1. ববের প্রথম UPDATE-এ ত্রুটি: CHECK constraint failed: on_call_count >= 1; অ্যাপ ROLLBACK করে “ছুটি নামঞ্জুর” দেখাবে। সমান্তরালে কী হতো? দুজনেই shift-এর একই সারিতে লিখছেন। READ COMMITTED-এ দ্বিতীয়জন প্রথমজনের lock-এর জন্য অপেক্ষা করেন, তারপর নতুন মান ১ থেকে ০ করতে গিয়ে CHECK-এ ব্যর্থ; snapshot isolation-এ একই সারির সংঘাতে serialization error. দুই ক্ষেত্রেই write skew আর নেই। (এখানে doctors আর shift মিলিয়ে রাখার দায়িত্ব অ্যাপের; পর্ব ২৯-এর trigger দিয়েও করা যায়।)

৪. SELECT … FOR UPDATE — যা পড়লেন তাতে lock

যখন নিয়মটি constraint-এ লেখা কঠিন, তখন যে সারিগুলোর ওপর সিদ্ধান্ত নিচ্ছেন সেগুলো পড়ার সময়েই lock নিন। SELECT … FOR UPDATE সর্বশেষ commit হওয়া version পড়ে আর সেই সারিগুলোতে row lock নেয়; অন্য কেউ একই সারি FOR UPDATE-এ পড়তে বা বদলাতে চাইলে অপেক্ষা করে। SQLite-এ এই সিনট্যাক্স নেই (সেখানে পুরো ডেটাবেস-স্তরের lock), তাই তুলনার জন্য:

BEGIN;
SELECT name FROM doctors
WHERE shift_id = 1 AND on_call = 1
FOR UPDATE;                 -- দুই ডাক্তারের সারিই lock
-- অ্যাপ গুনল: ২ জন, তাই অনুমতি
UPDATE doctors SET on_call = 0 WHERE name = 'Alice';
COMMIT;                     -- বব এতক্ষণ অপেক্ষায় ছিলেন; এখন ১ জন দেখবেন

সতর্কতা: FOR UPDATE শুধু বিদ্যমান সারি lock করে। “কোনো সারি নেই” দেখে সিদ্ধান্ত (যেমন খালি আসন বা ফাঁকা সময়) এতে রক্ষা পায় না — সেখানে UNIQUE বা exclusion constraint, বা SERIALIZABLE লাগে। MySQL-এর next-key lock index-এর ফাঁকেও lock দিয়ে এই phantom-এর একটা অংশ ঠেকায়, তবে শুধু সঠিক index থাকলে।

বাছাইয়ের নিয়ম

READ COMMITTED + ওপরের প্যাটার্ন — বেশিরভাগ CRUD অ্যাপের জন্য। REPEATABLE READ / snapshot — দীর্ঘ রিপোর্ট বা export, যেখানে সব সংখ্যা একই মুহূর্তের হতে হবে (read skew চলবে না)। SERIALIZABLE + retry loop — টাকা-পয়সা বা নিয়ম অনেক সারি জুড়ে (ব্যালান্সের যোগফল, on call-এর সংখ্যা), আর প্রতিটি জায়গায় আলাদা lock ভাবা কঠিন। READ UNCOMMITTED প্রায় কখনো নয়। আর যে level-ই বাছুন, অ্যাপকে 40001 আর deadlock-এর ত্রুটি ধরে পুরো transaction আবার চালাতে শেখান — এবং retry যেন দুবার টাকা না কাটে (idempotency key, পর্ব ৪৪)।

আরও গভীরে: MVCC-র ধারণা পুরোনো — David Reed-এর ১৯৭৮ সালের MIT PhD গবেষণা আর Bernstein ও Goodman-এর ১৯৮৩ সালের multiversion তত্ত্ব। PostgreSQL ১৯৯৯ সালের 6.5 সংস্করণে টেবিল-স্তরের lock ছেড়ে MVCC নেয়। Distributed ডেটাবেসেও একই প্রশ্ন: Google Spanner ঘড়ির অনিশ্চয়তা সীমিত রেখে (TrueTime) সব জায়গায় একটি সাধারণ snapshot সময় দেয়, আর CockroachDB-র ডিফল্ট level SERIALIZABLE. পর্ব ৫৪-এ distributed transaction-এ ফিরব।

১০ · AI-এর যুগে isolation আর concurrency

AI-কে “একটি টাকা পাঠানোর API লেখো” বললে প্রায়ই সুন্দর, চালানো যায় এমন কোড আসে — যা একজন ব্যবহারকারীর পরীক্ষায় নিখুঁত, আর একসাথে একশো অনুরোধে ভাঙে। concurrency-র ভুল একক পরীক্ষায় ধরা পড়ে না। সাধারণ ভুল:

AI-এর ভুলফলকীভাবে ধরবেন
SELECT করে অ্যাপে হিসাব, তারপর UPDATE (read-modify-write)lost update, স্টকের চেয়ে বেশি বিক্রিatomic UPDATE + affected rows, বা FOR UPDATE
“আগে চেক করো, তারপর INSERT” — constraint ছাড়াdouble booking, duplicateUNIQUE/exclusion constraint আছে কি না দেখুন
SERIALIZABLE দিল, কিন্তু retry নেইব্যস্ত সময়ে ব্যবহারকারী ত্রুটি দেখে40001 ধরে পুরো transaction আবার চালানো হয় কি না
শুধু ব্যর্থ স্টেটমেন্ট retryপুরোনো পড়ার ওপর ভুল সিদ্ধান্তretry-র ভেতরে পড়া থেকে commit পর্যন্ত সব আছে কি না
সব DBMS-এ REPEATABLE READ একই ধরে নেওয়াMySQL আর PostgreSQL-এ ভিন্ন আচরণDBMS আর সংস্করণ উল্লেখ করে প্রশ্ন করুন, নথি মেলান
transaction-এর ভেতরে HTTP কল বা ব্যবহারকারীর অপেক্ষাদীর্ঘ transaction, lock আর VACUUM আটকেBEGIN থেকে COMMIT-এর মাঝে কী কী আছে পড়ুন

AI-কে concurrency-র প্রশ্নটি স্পষ্ট করে দিন, আর সমান্তরাল পরীক্ষা চেয়ে নিন:

আমার DBMS PostgreSQL 16, ডিফল্ট READ COMMITTED. টেবিল: wallet(user_id PK, balance NUMERIC CHECK (balance >= 0)), transfer(id PK, from_user, to_user, amount, created_at). একটি transfer ফাংশন লেখো যা একসাথে শত শত অনুরোধেও সঠিক থাকে। ১. কোন anomaly (lost update, write skew, deadlock) এখানে ঘটতে পারে, আগে বলো। ২. কোন isolation level আর কোন কৌশল (atomic UPDATE, FOR UPDATE, constraint) বাছলে, কেন — বলো। ৩. দুই অ্যাকাউন্ট সবসময় একই ক্রমে (ছোট user_id আগে) lock করো, যাতে deadlock কম হয়। ৪. SQLSTATE 40001 আর 40P01 পেলে পুরো transaction সীমিতবার retry করো; একই অনুরোধ দুবার এলে টাকা যেন দুবার না কাটে। ৫. দুটি সমান্তরাল সংযোগ দিয়ে একটি পরীক্ষা লেখো যা মোট টাকা আগে-পরে সমান কি না যাচাই করে। কোনো কলাম বা টেবিলের নাম অনুমান করবে না।

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

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

  • চারটি isolation level আর কোনটি কোন anomaly ঠেকায়? — RU কিছুই না; RC dirty read; RR তার সাথে non-repeatable read; SERIALIZABLE phantom-ও।
  • Non-repeatable read আর phantom-এর পার্থক্য? — প্রথমটিতে একই সারির মান বদলায়; দ্বিতীয়টিতে শর্ত মানে এমন সারির সেট বদলায় (নতুন সারি ঢোকে বা বেরোয়)।
  • MVCC-র মূল সুবিধা? — পুরোনো version রেখে পাঠক আর লেখক একে অপরকে আটকায় না; দাম হলো পুরোনো version সরানোর কাজ (VACUUM/purge).
  • Snapshot isolation কি serializable? — না; write skew ঘটতে দেয়। SSI rw-dependency ধরে একটি transaction বাতিল করে serializable করে।
  • একসাথে অনেক অর্ডারে মজুত ঋণাত্মক হওয়া কীভাবে ঠেকাবেন? — UPDATE … SET stock = stock − n WHERE id = ? AND stock >= n, affected rows দেখে; সাথে CHECK (stock >= 0).

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

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

প্র ০১SQL standard isolation level-কে কেন “কোন anomaly ঘটবে না” দিয়ে সংজ্ঞায়িত করল, lock দিয়ে নয়? আর এই পথ কেন বিভ্রান্তি তৈরি করল?

ইতিহাসের শুরু lock দিয়েই। ১৯৭৬ সালে Jim Gray ও সহকর্মীদের “Granularity of Locks and Degrees of Consistency” প্রবন্ধে “degree 0 থেকে 3” consistency-র সংজ্ঞা ছিল কোন lock কতক্ষণ ধরে রাখা হয় তা দিয়ে — যেমন পড়ার lock সাথে সাথে ছেড়ে দিলে এক ধাপ, transaction শেষ পর্যন্ত রাখলে আরেক ধাপ। তখনকার প্রায় সব DBMS lock-ভিত্তিক ছিল, তাই এটি স্বাভাবিক ছিল।

standard কমিটি চেয়েছিল সংজ্ঞাটি প্রয়োগ-নিরপেক্ষ হোক: কোনো DBMS lock ছাড়া অন্য পদ্ধতিতে একই গ্যারান্টি দিতে চাইলে যেন পারে। তাই SQL-92 লিখল ব্যবহারকারী কী দেখবে না — dirty read, non-repeatable read, phantom. লক্ষ্যটি ভালো, কিন্তু ইংরেজি বাক্যে লেখা ঘটনাগুলো নিখুঁত ছিল না। Berenson ও সহকর্মীরা ১৯৯৫ সালে দেখান, “phantom ঘটবে না” কথাটি “ফলে ভুল দেখা যাবে না” (কড়া ব্যাখ্যা) আর “এমন পরিস্থিতিই তৈরি হবে না যেখানে ভুল হতে পারত” (বিস্তৃত ব্যাখ্যা) — দুইভাবে পড়া যায়, আর কড়া ব্যাখ্যায় তিনটি ঘটনা নিষিদ্ধ করেও serializable না-হওয়া schedule পাওয়া যায়। dirty write তালিকায়ই ছিল না।

সবচেয়ে বাস্তব সমস্যাটি দেখা দিল MVCC-তে। snapshot isolation তিনটি ঘটনাই ঠেকায় (snapshot-পড়ায় phantom-ও), তাই আক্ষরিক ব্যাখ্যায় সেটি “SERIALIZABLE” দাবি করতে পারে — Oracle ঠিক তাই করেছিল, আর PostgreSQL 9.1-এর আগে। অথচ write skew-এ ফল serializable নয়। অর্থাৎ standard-এর চেকলিস্ট পাস করেও standard-এর নিজের মূল প্রতিশ্রুতি — “কোনো serial ক্রমের সমান” — ভাঙা যায়।

পরে Atul Adya (১৯৯৯) দুটির মাঝখানের পথ দেখান: প্রয়োগ-নিরপেক্ষ, কিন্তু নিখুঁত — transaction-গুলোর পড়া-লেখার নির্ভরতার গ্রাফে কোন ধরনের চক্র নিষিদ্ধ, তা দিয়ে প্রতিটি level. আজকের গবেষণা আর পরীক্ষার সরঞ্জাম অনেকটাই এই ভাষায় কথা বলে।

মূল উপলব্ধি: “কী ঘটবে না” দিয়ে সংজ্ঞা দিলে তালিকাটি সম্পূর্ণ আর নিখুঁত হতে হয়; SQL-92-এর তালিকা দুটিই ছিল না। তাই কোনো DBMS-এর level-এর নাম নয়, তার আসল গ্যারান্টি জানুন।

প্র ০২Oracle বহু বছর snapshot isolation-কে “SERIALIZABLE” নামে দিয়েছে, আর বিশাল ব্যাংক ও এয়ারলাইন সিস্টেম তাতে চলেছে। write skew থাকলে সেগুলো ভাঙেনি কেন? তাহলে SSI-র দরকার কী?

প্রথম কারণ, write skew-এর জন্য একটি বিশেষ গঠন লাগে: দুটি transaction একই তথ্য পড়ে, সেই পড়ার ভিত্তিতে ভিন্ন সারিতে লেখে, আর একটি নিয়ম দুই সারি মিলিয়ে সংজ্ঞায়িত। বহু সাধারণ লেনদেন এমন নয় — একটি অ্যাকাউন্টের ব্যালান্স কমানো, একটি অর্ডার লেখা — সেখানে সংঘাত একই সারিতে, আর SI তা ধরে। ২০০৫ সালে Fekete, Liarokapis, O'Neil, O'Neil আর Shasha দেখান, প্রচলিত TPC-C benchmark-এর সব transaction SI-তেও serializable ফল দেয়; সাথে তাঁরা একটি পদ্ধতি দেন — অ্যাপের transaction-গুলোর একটি static dependency graph এঁকে বিপজ্জনক জোড়া খুঁজে বের করা, আর সেখানে একটি কৃত্রিম লেখা যোগ করে সংঘাতকে একই সারিতে আনা (অংশ ৯-এর materializing).

দ্বিতীয় কারণ, অনেক অ্যাপ নিয়মগুলো আগে থেকেই constraint বা lock দিয়ে রক্ষা করত, অথবা ভুলটি বিরল আর ছোট বলে চোখে পড়েনি — কয়েকটি ক্ষেত্রে হাতে ঠিক করা হয়েছে। “ভাঙেনি” আর “ভাঙতে পারে না” এক কথা নয়।

SSI-র দরকার এখানেই। Fekete-র পদ্ধতিতে প্রতিটি transaction-জোড়া মানুষকে বিশ্লেষণ করতে হয়, আর অ্যাপ বদলালে আবার। বড় দলে, দ্রুত বদলানো কোডে কেউ সেই বিশ্লেষণ হালনাগাদ রাখে না। Cahill-এর SSI কাজটি স্বয়ংক্রিয় করে: চালানোর সময় বিপজ্জনক কাঠামো দেখা দিলে DBMS নিজেই একটি transaction বাতিল করে। প্রোগ্রামারকে শুধু একটি সরল নিয়ম মানতে হয় — প্রতিটি transaction একা চললে সঠিক হলে, একসাথে চললেও সঠিক; বিনিময়ে retry লিখতে হয়।

মূল উপলব্ধি: SI-তে নিরাপদ থাকা সম্ভব, কিন্তু তার জন্য প্রতিটি transaction-জোড়া নিয়ে মানুষের যুক্তি লাগে। SSI সেই যুক্তি DBMS-এর হাতে তুলে দেয় — দাম কিছু বাড়তি abort আর retry.

প্র ০৩PostgreSQL নতুন version টেবিলেই লেখে, InnoDB আর Oracle জায়গায় বদলে পুরোনো মান undo-তে রাখে। প্রতিটি নকশার লুকানো খরচ কী, আর কোন কাজের ধরনে কোনটি ভালো?

PostgreSQL-এর পথে প্রতিটি UPDATE আসলে একটি নতুন tuple. ফলে টেবিলে মৃত version জমে, আর সাধারণত প্রতিটি index-এ নতুন entry লাগে — কারণ index নতুন tuple-এর ঠিকানা (পর্ব ৩৯-এর ctid) জানতে হবে। একটি সারির শুধু একটি কলাম বদলালেও দশটি index থাকলে দশটি index-লেখা — write amplification. PostgreSQL 8.3 থেকে HOT update এটি কমায়: index-করা কোনো কলাম না বদলালে আর একই page-এ জায়গা থাকলে নতুন index entry লাগে না। আর VACUUM-কে সব মৃত tuple সরাতে, xid freeze করতে আর বড় টেবিলে পিছিয়ে না পড়তে হয় — অনেক DBA-র মাথাব্যথার উৎস। ২০১৬ সালে Uber-এর প্রকৌশল ব্লগে PostgreSQL থেকে MySQL-এ যাওয়ার কারণ হিসেবে এই write amplification আর replication-এ তার প্রভাব আলোচিত হয়েছিল; PostgreSQL সম্প্রদায় পাল্টা যুক্তিও দিয়েছিল — দুই পক্ষই শিক্ষণীয়।

InnoDB আর Oracle-এর পথে টেবিল কম ফোলে, index-এ নতুন entry লাগে শুধু index-করা কলাম বদলালে। কিন্তু পুরোনো snapshot-এর পাঠককে undo-শিকল বেয়ে পেছনে যেতে হয় — যত বেশি পরিবর্তন, তত লম্বা পথ। একটি দীর্ঘ transaction purge আটকালে InnoDB-র undo জমে আর সব পুরোনো-snapshot পড়া ধীর হয়; Oracle-এ undo-র জায়গা ফুরোলে দীর্ঘ কোয়েরি ORA-01555-এ মারা যায়। ROLLBACK-ও দামি: undo থেকে পুরোনো মান ফেরত লিখতে হয়, যেখানে PostgreSQL-এ abort প্রায় তাৎক্ষণিক।

মোটা দাগে, বারবার একই সারি বদলানো আর অনেক index-ওয়ালা OLTP কাজে in-place + undo প্রায়ই কম লেখে; অনেক পুরোনো snapshot একসাথে পড়া, দ্রুত rollback আর সরল crash recovery-তে PostgreSQL-এর পথের সুবিধা আছে। দুই দিকেই নিয়মিত পরিচর্যা — VACUUM বা purge-এর দিকে নজর, দীর্ঘ transaction এড়ানো — জরুরি।

মূল উপলব্ধি: MVCC বিনামূল্যে নয়; প্রশ্ন শুধু খরচটি কোথায় দেবেন — লেখার সময় আর পরিষ্কারে (PostgreSQL), না পুরোনো পড়া আর rollback-এ (undo-ভিত্তিক)।

প্র ০৪একটি মোবাইল ওয়ালেট কোম্পানির নতুন প্রকৌশলী প্রস্তাব দিলেন: “ঝামেলা এড়াতে সব transaction SERIALIZABLE করে দিই।” এই প্রস্তাবের ভালো-মন্দ কী, আর আপনি কী পরামর্শ দেবেন?

প্রস্তাবের পেছনের চিন্তাটি সঠিক: SERIALIZABLE-এ প্রোগ্রামারকে প্রতিটি anomaly আলাদা ভাবতে হয় না। প্রতিটি transaction একা চললে সঠিক হলে, সবাই একসাথে চললেও সঠিক — এই সরল নিয়ম বড় দলে, যেখানে কেউ পুরো কোডবেস জানে না, অনেক মূল্যবান। CockroachDB-র মতো কিছু আধুনিক ডেটাবেস ঠিক এই যুক্তিতে SERIALIZABLE-কে ডিফল্ট রেখেছে।

কিন্তু দামগুলো স্পষ্ট করে বলতে হবে। এক, retry বাধ্যতামূলক: প্রতিটি কোডপথে 40001 ধরে পুরো transaction আবার চালাতে হবে, আর retry-তে যেন দুবার SMS না যায় বা দুবার টাকা না কাটে — এর জন্য idempotency key. পুরোনো কোডে এটি যোগ করা বড় কাজ। দুই, contention-এর জায়গায় abort বাড়ে: ঈদের আগে একটি জনপ্রিয় মার্চেন্টের অ্যাকাউন্টে হাজারো লেনদেন একসাথে এলে (hot row) SSI-তে অনেকগুলো বাতিল হয়ে আবার আসবে, আর লেটেন্সি লাফ দেবে। তিন, PostgreSQL-এর SSI-তে predicate lock-এর মেমরি আর ট্র্যাকিং-এর CPU লাগে, আর false positive আছে। চার, দীর্ঘ রিপোর্ট-কোয়েরি SERIALIZABLE-এ চললে লেখকদের abort বাড়াতে পারে — তাদের READ ONLY DEFERRABLE বা আলাদা replica-তে পাঠানো ভালো।

আমার পরামর্শ হবে স্তরভিত্তিক। মূল নিয়মগুলো schema-তে: ব্যালান্সে CHECK (balance >= 0), লেনদেন-আইডিতে UNIQUE. সাধারণ ব্যালান্স কমানো atomic UPDATE দিয়ে, যা READ COMMITTED-এও নিরাপদ। যে কাজগুলোর নিয়ম অনেক সারি জুড়ে — দৈনিক লেনদেন-সীমা, পরিবারের যৌথ সীমা — সেগুলো SERIALIZABLE-এ, retry আর idempotency-সহ। আর প্রতিটি পরিবর্তনের পর সমান্তরাল load test, যা মোট টাকা আগে-পরে মেলায়।

মূল উপলব্ধি: SERIALIZABLE সঠিকতার সবচেয়ে সরল চুক্তি, কিন্তু retry আর contention-এর বিল সাথে আসে। নিয়মগুলো constraint-এ আর সংঘাত একই সারিতে আনলে কম জায়গায় সেই বিল দিতে হয়।

প্র ০৫নথিতে লেখা isolation level আর একটি DBMS-এর আসল আচরণ কি সবসময় মেলে? কীভাবে যাচাই করা হয়, আর এ থেকে একজন ডেভেলপারের শিক্ষা কী?

সবসময় মেলে না, আর এ নিয়ে একটি পুরো গবেষণা-ধারা আছে। ২০১৪ সালে Martin Kleppmann “Hermitage” নামে একটি খোলা পরীক্ষা-সংকলন প্রকাশ করেন: অংশ ২–৭-এর মতো দুই-সেশনের timeline হাতে চালিয়ে প্রতিটি জনপ্রিয় DBMS-এর প্রতিটি level-এ কোন anomaly ঘটে, তা টেবিলে লেখা। ফল দেখায়, একই নামের level DBMS ভেদে আলাদা — যেমন অংশ ৮-এর টেবিলের MySQL বনাম PostgreSQL-এর REPEATABLE READ. তাঁর “Designing Data-Intensive Applications” বই (২০১৭) এই আলোচনা বহু ডেভেলপারের কাছে পৌঁছে দেয়; ডাক্তারদের on call উদাহরণটিও সেখান থেকে জনপ্রিয়।

আরও কঠোর পরীক্ষা করে Kyle Kingsbury-র Jepsen: হাজারো এলোমেলো সমান্তরাল transaction চালিয়ে ইতিহাস রেকর্ড করা, তারপর একটি checker দিয়ে Adya-ধাঁচের নির্ভরতার গ্রাফে নিষিদ্ধ চক্র খোঁজা। এই ধরনের বিশ্লেষণে একাধিক ডেটাবেসে দাবির চেয়ে দুর্বল আচরণ ধরা পড়েছে — কখনো documentation-এর ভুল, কখনো আসল bug; যেমন ২০২০ সালে PostgreSQL 12.3-এর বিশ্লেষণে SERIALIZABLE-এ একটি bug পাওয়া যায়, যা পরের সংস্করণে ঠিক করা হয়। বিষয়টি কোনো একটি DBMS-এর দুর্নাম নয়; concurrency-র কোড এতই সূক্ষ্ম যে পরিণত প্রকল্পেও ভুল থাকে।

একজন ডেভেলপারের জন্য শিক্ষা তিনটি। এক, level-এর নাম দিয়ে নয়, নির্দিষ্ট anomaly দিয়ে ভাবুন: “আমার এই কাজে write skew হলে কী ক্ষতি?” দুই, নিজের গুরুত্বপূর্ণ নিয়মগুলোর জন্য ছোট সমান্তরাল পরীক্ষা লিখুন — অংশ ৬ আর ৭-এর Python প্রোগ্রামের মতো দুটি সংযোগ, হাতে সাজানো ক্রম। তিন, যেখানে সম্ভব নিয়মটি constraint-এ রাখুন — constraint-এর যাচাই isolation level-এর সূক্ষ্মতার ওপর অনেক কম নির্ভর করে। DBMS আর driver হালনাগাদ রাখাও এই নিরাপত্তার অংশ।

মূল উপলব্ধি: isolation একটি দাবি, যা পরীক্ষা করা যায় — আর পরীক্ষা করা উচিত। দুই-সেশনের timeline শুধু পড়ার জিনিস নয়, এটি যাচাইয়ের হাতিয়ার।

অনুশীলন

একটি কোয়েরি লেখা, একটি timeline হাতে বিশ্লেষণ, একটি সমান্তরাল ফল আগে বলা, একটি নকশা।

  1. কোয়েরি লিখুন: অংশ ৫-এর মডেলে (acct_v(id, bal, xmin, xmax) আর snap(snap, xid)) একটি কোয়েরি লিখুন যা snapshot 'S2'-এর চোখে সব অ্যাকাউন্টের মোট ব্যালান্স দেয়। তারপর একই কোয়েরি 'S3'-এর জন্য। উত্তর কত হওয়া উচিত, আগে অনুমান করুন। (অংশ ৫-এর প্রথম এডিটরে চালাতে পারেন।)
    SELECT SUM(v.bal) AS total
    FROM acct_v v
    WHERE v.xmin IN (SELECT xid FROM snap WHERE snap = 'S2')
      AND (v.xmax IS NULL
           OR v.xmax NOT IN (SELECT xid FROM snap WHERE snap = 'S2'));

    S2 দেখে রহিম ৭০০ আর করিম ১,০০০ — মোট ১,৭০০. 'S2'-কে 'S3' করলে সুমনের ৫০০ যোগ হয়: ২,২০০. লক্ষ করুন, S2-এর হিসাব যতবারই চালান, xid 103 পরে commit হলেও ১,৭০০ — snapshot-এ phantom নেই। চলমান 102-এর ৭৫০ কোনোটিতেই আসে না।

  2. হাতে বিশ্লেষণ (trace): x = ১০০ আর y = ১০০ দিয়ে শুরু। T1: r(x) → ১০০; T2: w(x = 50), w(y = 150), COMMIT; T1: r(y), COMMIT. (ক) READ COMMITTED-এ T1 y-এর কত পড়ে, আর x + y কত দেখে? এটি কোন anomaly? (খ) snapshot isolation-এ? (গ) precedence graph-এ চক্র আছে কি?

    (ক) READ COMMITTED-এ প্রতিটি স্টেটমেন্ট নতুন snapshot নেয়, তাই y = ১৫০; T1 দেখে x + y = ১০০ + ১৫০ = ২৫০, অথচ আসল যোগফল সবসময় ২০০ — এটি read skew. (খ) SI-তে T1-এর snapshot শুরুর মুহূর্তের, তাই y = ১০০, যোগফল ২০০ — সঠিক। (গ) READ COMMITTED-এর ইতিহাসে: T1 x-এর পুরোনো মান পড়েছে যা T2 বদলেছে → T1 আগে (T1 → T2); T1 y-এর নতুন মান পড়েছে যা T2 লিখেছে → T2 আগে (T2 → T1). চক্র আছে, তাই serializable নয়। SI-তে দ্বিতীয় প্রান্তটি নেই (T1 পুরোনো y পড়েছে, তাও T1 → T2) — চক্র নেই, সমতুল্য ক্রম T1, T2.

  3. ফল আগে বলুন: PostgreSQL-এ products-এ product 110-এর মজুত ১. দুটি সেশন প্রায় একই সময়ে UPDATE products SET stock = stock - 1 WHERE product_id = 110 AND stock >= 1; চালায়, তারপর COMMIT. (ক) READ COMMITTED-এ প্রতিটি সেশন কয়টি সারি বদলায়, শেষে মজুত কত? (খ) দুজনেই REPEATABLE READ-এ, আর দুজনেই UPDATE-এর আগে একটি SELECT চালিয়ে snapshot নিয়েছে — দ্বিতীয়জনের কী হয়?

    (ক) প্রথম সেশন সারিটি lock করে মজুত ০ করে। দ্বিতীয়টি সেই lock-এর জন্য অপেক্ষা করে; প্রথমটি commit করলে PostgreSQL দ্বিতীয়টির জন্য সারির নতুন version-এ WHERE আবার যাচাই করে — stock >= 1 এখন মিথ্যা, তাই UPDATE 0. ফল: প্রথমটি ১ সারি, দ্বিতীয়টি ০ সারি, মজুত ০ — সঠিক, কোনো ত্রুটি নেই। (খ) দ্বিতীয়টি অপেক্ষা করে, কিন্তু প্রথমটি commit করার পর সে আর নতুন version ব্যবহার করতে পারে না — তার snapshot পুরোনো; তাই ত্রুটি পায় could not serialize access due to concurrent update (SQLSTATE 40001). অ্যাপকে transaction আবার চালাতে হবে; আবার চালালে ০ সারি। দুই level-এই মজুত ঋণাত্মক হয় না — পার্থক্য শুধু “নীরবে ০ সারি” বনাম “ত্রুটি, তারপর retry”.

  4. নকশা করুন: একটি কমিউনিটি সেন্টারের হল ভাড়ার অ্যাপ। নিয়ম: একই হলে কোনো দুটি booking-এর সময় মিশতে পারবে না। অ্যাপটি এখন “ওই সময়ে কোনো booking আছে কি না SELECT করো, না থাকলে INSERT” করে, READ COMMITTED-এ। (ক) কোন anomaly-তে double booking হবে? (খ) FOR UPDATE কেন যথেষ্ট নয়? (গ) দুটি সঠিক সমাধান দিন।

    (ক) দুই সেশন একসাথে দেখে “কোনো সারি নেই”, দুজনেই নতুন সারি ঢোকায় — phantom-ঘটিত write skew. এমনকি snapshot isolation-ও এটি ঠেকায় না, কারণ দুজনে ভিন্ন (নতুন) সারি লেখে। (খ) FOR UPDATE শুধু বিদ্যমান সারি lock করে; “কোনো সারি নেই” অবস্থায় lock করার মতো কিছু নেই। (গ) ১. PostgreSQL-এ exclusion constraint: EXCLUDE USING gist (hall_id WITH =, tstzrange(start_at, end_at) WITH &&) (btree_gist extension লাগে) — যেকোনো isolation level-এ দ্বিতীয় INSERT ত্রুটি দেয়। ঘণ্টাভিত্তিক slot হলে আরও সহজ: (hall_id, slot_start)-এ UNIQUE. ২. পুরো কাজটি SERIALIZABLE-এ, retry loop-সহ — SSI SELECT-এর predicate পড়া আর অন্যের INSERT-এর rw-dependency ধরে একটি transaction বাতিল করবে। বিকল্প হিসেবে প্রতিটি হলের একটি সারিকে আগে FOR UPDATE করে “তালা” হিসেবে ব্যবহার করা যায় (materializing the conflict).

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

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

ক্রমPostgreSQL-এ একটি সারি UPDATE থেকে পুরোনো version-এর জায়গা ফেরত পাওয়া পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Transaction একটি নতুন xid পায়
  2. পুরোনো version-এর xmax-এ সেই xid লেখা হয়
  3. নতুন version ঢোকে, যার xmin = সেই xid
  4. COMMIT — commit log-এ xid “committed” হয়
  5. পুরোনো version দেখতে পারে এমন সব snapshot শেষ হয়
  6. VACUUM পুরোনো version-এর জায়গা ফেরত নেয়

T2 একটি সারির মান বদলাল কিন্তু commit করেনি। T1 সেই নতুন মান পড়ল। পরে T2 ROLLBACK করল। T1-এর পড়াটিকে কী বলে?

  • Dirty read
  • Non-repeatable read
  • Phantom read
  • Lost update

Snapshot isolation-এ নিচের কোন anomaly ঘটতে পারে?

  • Dirty read
  • Non-repeatable read
  • Lost update
  • Write skew

PostgreSQL-এ SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED দিলে transaction আসলে কেমন আচরণ করে?

  • অন্যের commit-না-হওয়া মানও দেখায়
  • READ COMMITTED-এর মতো
  • Snapshot isolation-এর মতো
  • ত্রুটি দেয়, কারণ level-টি সমর্থিত নয়

PostgreSQL-এ একটি version-এর xmin = 200 (committed), xmax = 205. একটি snapshot নেওয়া হয়েছিল যখন 200 commit হয়ে গেছে কিন্তু 205 তখনো চলছিল। পরে 205 commit করেছে। সেই snapshot কি এই version দেখে?

  • না, কারণ xmin 200 snapshot-এর বাইরে
  • না, কারণ 205 এখন commit হয়ে গেছে
  • হ্যাঁ, কারণ xmax-এর transaction snapshot-এর বাইরে
  • না, কারণ xmax খালি নয়

READ COMMITTED-এ একসাথে অনেক অর্ডার এলেও পণ্যের মজুত যেন ঋণাত্মক না হয় — সবচেয়ে সরল আর নিরাপদ পদ্ধতি কোনটি?

  • UPDATE … SET stock = stock − n WHERE id = ? AND stock >= n, আর বদলানো সারির সংখ্যা দেখা
  • আগে SELECT stock, অ্যাপে stock − n হিসাব করে UPDATE … SET stock = সেই মান
  • Isolation level READ UNCOMMITTED করা
  • মজুত অ্যাপের cache-এ রেখে সেখানে কমানো

দুই ডাক্তার একই সময়ে দেখলেন on call ২ জন, তারপর প্রত্যেকে নিজের সারিতে on_call = 0 লিখলেন। REPEATABLE READ-এ (snapshot isolation) দুটিই commit হলো। কোন পরিবর্তন write skew-টি ঠেকাবে, যেকোনো isolation level-এ?

  • doctors.name-এ একটি index যোগ করা
  • REPEATABLE READ থেকে READ COMMITTED-এ নামা
  • একটি shift সারিতে on call-এর সংখ্যা রেখে CHECK (on_call_count >= 1) দেওয়া, আর ছুটিতে সেটিও কমানো
  • Autocommit চালু রাখা

শব্দকোষ

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

মনে রাখুন

  1. ছয়টি anomaly: dirty read (খসড়া পড়া), non-repeatable read (একই সারির মান বদল), phantom (শর্তের সারি-সেট বদল), lost update (একই সারিতে দুজনের লেখা, একটি হারায়), read skew (দুই মুহূর্তের মিশ্র পড়া), write skew (ভিন্ন সারিতে লেখা, মিলিত নিয়ম ভাঙে)।
  2. Standard-এর টেবিল: RU কিছুই ঠেকায় না, RC dirty read, RR তার সাথে non-repeatable read, SERIALIZABLE phantom-ও; Berenson ও সহকর্মীরা (১৯৯৫) দেখান, সংজ্ঞাটি অস্পষ্ট আর snapshot isolation এতে খাপ খায় না।
  3. MVCC: version রেখে পাঠক-লেখক কেউ কাউকে আটকায় না। PostgreSQL-এ xmin/xmax আর snapshot দিয়ে visibility, VACUUM দিয়ে dead tuple সরানো আর xid wraparound ঠেকানো; InnoDB আর Oracle-এ undo আর purge.
  4. SI বনাম SSI: snapshot isolation write skew দেয়, কারণ লেখার সেট আলাদা হলে সংঘাত দেখে না; SSI (Cahill ২০০৮, PostgreSQL 9.1+) পরপর দুটি rw-dependency ধরে একজনকে বাতিল করে — অ্যাপে retry loop বাধ্যতামূলক।
  5. বাস্তবে: একই নামের level DBMS ভেদে আলাদা; বেশিরভাগ অ্যাপ READ COMMITTED + atomic UPDATE, UNIQUE/CHECK/exclusion constraint আর FOR UPDATE দিয়ে চলে; বহু-সারির টাকা-পয়সার নিয়মে SERIALIZABLE + retry.

পরের পর্বে: একসাথে চলার সমস্যা দেখা হলো। কিন্তু commit-এর ঠিক পরে বিদ্যুৎ চলে গেলে? পর্ব ৪৮-এ recovery — log, write-ahead logging, checkpoint আর ARIES, আর কেন commit হওয়া লেখা কখনো হারায় না।

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

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