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

Transaction আর isolation level — anomaly, locking আর MVCC

Transactions & Isolation Levels — Anomalies, Locking & MVCC

Taught in Bangla, this lesson covers ACID from a designer's view, six anomalies from dirty writes to write skew and phantoms, what each isolation level prevents and how PostgreSQL, MySQL, Oracle and SQLite differ under the same names, two-phase locking, deadlocks, MVCC, snapshot isolation and SSI with an interleaving simulator, app-level patterns, transaction scope, retries and choosing a level per workload.

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

  • নকশাকারের চোখে ACID — কোন অক্ষর কোন সমস্যার উত্তর, আর ACID-এর consistency কেন CAP-এর consistency নয়
  • ছয়টি anomaly — dirty write, dirty read, non-repeatable read ও read skew, lost update, write skew আর phantom — বাস্তব উদাহরণে
  • চারটি isolation level কোনটি কী ঠেকায়, আর PostgreSQL, MySQL, Oracle ও SQLite-এ একই নামের আলাদা মানে
  • 2PL, deadlock detection, MVCC, snapshot isolation আর SSI — interleaving simulator চালিয়ে নিজের চোখে
  • অ্যাপের প্যাটার্ন, transaction-এর পরিধি, retry, আর কোন কাজে কোন level বেছে নেবেন

১ · Transaction কেন — শেষ সিট, দুজন যাত্রী আর ACID-এর চার প্রতিশ্রুতি

ঈদের আগে রাত ঠিক ১২টায় একটি কাল্পনিক বাস কোম্পানির অনলাইন টিকিট খুলল। ঢাকা–রাজশাহী রাতের বাসে বাকি আছে একটিমাত্র সিট, B4. রাহিম মোবাইল অ্যাপে আর করিম ওয়েবসাইটে প্রায় একই মিলিসেকেন্ডে “কিনুন” চাপলেন। Load balancer দুটি অনুরোধ দুটি আলাদা app server-এ পাঠাল। দুই সার্ভারই database-এ জিজ্ঞেস করল “B4 খালি?” — দুজনেই উত্তর পেল “হ্যাঁ”। তারপর দুজনেই লিখল “B4 বিক্রি হয়েছে”। দুজনের ফোনেই টিকিট, বাসে সিট একটি।

এর নাম race condition: ফল নির্ভর করছে কে কখন কোন ধাপে পৌঁছাল তার ওপর। একা চালালে কোডটি নির্ভুল, একসাথে দুজন চালালেই ভুল — তাই টেস্টে প্রায় ধরা পড়ে না, অথচ ঈদের রাতের ভিড়ে প্রতি মিনিটে ঘটে।

রেলস্টেশনের দুটি কাউন্টার, মাঝখানে দেয়ালে একটি সিট-চার্ট। দুই কর্মী একসাথে চার্টে তাকালেন, দুজনেই দেখলেন ১২ নম্বর খালি, দুজনেই নিজের যাত্রীর টিকিট ছাপলেন, তারপর চার্টে দাগ দিতে গেলেন। সমাধান দুই রকম হতে পারে। এক, চার্টে তাকানোর আগেই কলমটি হাতে নিতে হবে, আর কলম একটিই (lock). দুই, দাগ দেওয়ার মুহূর্তে আবার দেখতে হবে ঘরটি এখনো ফাঁকা কি না (শর্তসহ লেখা)। এই পর্বের পুরোটাই এই দুই ভাবনার বিস্তার।

Transaction হলো কয়েকটি পড়া আর লেখাকে একটি একক কাজ হিসেবে ধরা: BEGIN থেকে COMMIT পর্যন্ত। কাজটি হয় পুরোটা টেকে (commit), নয় পুরোটা মুছে যায় (rollback). Database এই একককে ঘিরে চারটি প্রতিশ্রুতি দেয়, যাদের আদ্যক্ষর মিলে ACID. নিচের ছবিতে দেখুন এগুলো একটি সাধারণ backend-এর কোথায় কাজ করে।

একটি booking অনুরোধ database পর্যন্ত — কোথায় কোন যন্ত্র

Clients → load balancer → app servers (T1, T2) → database: lock manager, MVCC, WAL → replica

দুটি transaction একই database-এ: lock manager, MVCC আর WAL যাত্রী ১ · রাহিম মোবাইল অ্যাপ যাত্রী ২ · করিম ওয়েবসাইট load balancer (পর্ব ১৪) app server A T1: B4 খালি? app server B T2: B4 খালি? Database primary connection pool থেকে · BEGIN … COMMIT lock manager S/X lock, অপেক্ষার লাইন MVCC সারির পুরোনো ও নতুন version WAL · write-ahead log commit-এর আগে পরিবর্তন log-এ (পর্ব ১৯) disk · fsync replica (পর্ব ২২)
ছবি ২৪.১ — দুই সার্ভার একই সারি নিয়ে একসাথে কাজ করছে। কে কার অপেক্ষা করবে, ঠিক করে lock manager; পাঠককে পুরোনো version দেখায় MVCC; commit টেকসই করে WAL.

নকশাকারের চোখে ACID — কোন অক্ষর কোন প্রশ্নের উত্তর

অক্ষরকোন সমস্যার উত্তরনকশাকারের প্রশ্ন
A · Atomicityব্যর্থতায় আধাআধি কাজ না থাকা: টাকা কাটা হলো অথচ ledger-এ লেখা নেই — অসম্ভবকোন লেখাগুলো একসাথে টিকবে বা মুছবে? সব কি একই database-এ? (না হলে পর্ব ২৫)
C · Consistencyঅ্যাপের invariant রক্ষা: ব্যালান্স ঋণাত্মক নয়, একটি সিট একবারই বিক্রি, ledger-এর যোগফল = ব্যালান্সনিয়মগুলো কী? কোনটি constraint-এ যাবে, কোনটি অ্যাপের যুক্তিতে?
I · Isolationএকসাথে চলা transaction-এর মাঝে আড়াল: কে কার অসমাপ্ত বা সদ্য-শেষ কাজ দেখবেকোন anomaly আমার ক্ষতি করে? কোন level বা প্যাটার্ন ঠেকায়, দাম কত?
D · DurabilityCommit-এর পরে crash হলেও ডেটা না হারানোWAL আর fsync কেমন (পর্ব ১৯)? Commit কি replica-তে পৌঁছানোর আগেই “সফল”? (পর্ব ২২)

দুটি ভুল ধারণা আগেই ভাঙা দরকার। এক, atomicity ব্যর্থতার বিষয়, একসাথে চলার নয় — “মাঝখানে কেউ ঢুকবে না” হলো isolation-এর কাজ। দুই, consistency-র বেশিরভাগটাই আপনার দায়িত্ব: database শুধু আপনার দেওয়া constraint (CHECK, UNIQUE, foreign key) পাহারা দেয়। “দিনে ২৫,০০০ টাকার বেশি পাঠানো যাবে না” — এমন নিয়ম রাখে আপনার transaction-এর যুক্তি, আর ভিড়ের মধ্যে সেই যুক্তি টিকবে কি না, তা ঠিক করে isolation.

ACID-এর C আর CAP-এর C এক জিনিস নয়। ACID-এর consistency মানে অ্যাপের নিয়ম রক্ষা। CAP-এর consistency (linearizability) মানে একাধিক কপি থাকলেও সবাই সর্বশেষ মানটি দেখবে, যেন কপি একটিই (পর্ব ৪০-এ বিস্তারিত)। একটি একক-সার্ভার database পুরো ACID দিতে পারে, অথচ তার asynchronous replica থেকে পড়লে পুরোনো মান আসতে পারে — দুই “consistency” দুই আলাদা প্রশ্নের উত্তর।

Transaction-এর ধারণাটি ১৯৭০-এর দশকে IBM-এর System R গবেষণার সময় রূপ পায়। ১৯৮১ সালে Jim Gray তাঁর “The Transaction Concept: Virtues and Limitations” প্রবন্ধে ধারণাটি গুছিয়ে লেখেন। আর ১৯৮৩ সালে Theo Härder ও Andreas Reuter recovery নিয়ে এক survey-তে ACID শব্দটি চালু করেন। Gray পরে ১৯৯৮ সালে Turing Award পান। এর গভীর তত্ত্ব আছে DBMS কোর্সের Transaction আর ACID পর্বে; এখানে আমরা দেখব একজন নকশাকারকে কী কী সিদ্ধান্ত নিতে হয়।

২ · Anomaly-র তালিকা — dirty read থেকে lost update

একসাথে চলা transaction-গুলোর ফল যদি এমন হয়, যা কোনো একটি-একটি-করে (serial) ক্রমে চালালে কখনো হতো না, তাকে বলে anomaly. চেহারা চিনলে production-এর bug report পড়েই কারণ আন্দাজ করা যায়। উদাহরণে একটি কাল্পনিক mobile wallet: রাহিমের ব্যালান্স ১,০০০ টাকা।

Dirty write আর dirty read — অসমাপ্ত কাজ দেখা বা তার ওপর লেখা

Dirty write: T1 ব্যালান্সে কিছু লিখেছে কিন্তু commit করেনি, তার ওপরেই T2 লিখে দিল। এরপর T1 rollback করলে কোন মান ফিরবে? উত্তর নেই — তাই প্রায় সব database সব level-এ এটি নিষেধ করে, একটি সারিতে একজন লেখকের lock দিয়ে।

Dirty read: রাহিম ৭০০ টাকা cash-out শুরু করলেন (T1), ব্যালান্স হলো ৩০০, কিন্তু commit হয়নি। ঠিক তখন একটি fraud-check রিপোর্ট (T2) পড়ল ৩০০ আর রাহিমকে “কম ব্যালান্স” তালিকায় তুলল। তারপর এজেন্টের যন্ত্রে সমস্যা, T1 rollback — ব্যালান্স আবার ১,০০০। রিপোর্টটি এমন একটি সংখ্যার ওপর সিদ্ধান্ত নিল, যা কখনো সত্যি হয়নি।

Non-repeatable read আর read skew — দুই মুহূর্তের মিশ্র ছবি

Non-repeatable read: একই transaction-এ একই সারি দুবার পড়ে দুই রকম মান — মাঝখানে অন্য কেউ বদলে commit করেছে। এর বড় ভাই read skew: রাহিমের সঞ্চয়ী হিসাবে ১,০০০ আর ওয়ালেটে ১,০০০, মোট ২,০০০। দিনের শেষের হিসাব-মেলানো রিপোর্ট (T2) আগে সঞ্চয় পড়ল — ১,০০০। তারপর T1 সঞ্চয় থেকে ওয়ালেটে ৫০০ সরিয়ে commit করল। এবার T2 ওয়ালেট পড়ল — ১,৫০০। রিপোর্টে মোট ২,৫০০ — প্রতিটি সংখ্যা কোনো এক মুহূর্তে সত্যি, দুটি একসাথে কখনো নয়। Backup, export আর হিসাব-মেলানোয় এটি মারাত্মক।

Lost update — সবচেয়ে চেনা production bug

দুজন একই পুরোনো মান পড়েন, অ্যাপে হিসাব করেন, তারপর লেখেন — পরের লেখা আগেরটিকে নিঃশব্দে মুছে দেয়। রাহিম অ্যাপে ৭০০ টাকা cash-out (T1) আর একই সময়ে এজেন্ট পয়েন্টে ৬০০ টাকা উত্তোলন (T2) করলেন। নিচের সময়রেখায় প্রতিটি পড়া (R), লেখা (W) আর commit (C) দেখুন।

দুটি transaction-এর সময়রেখা — lost update

T1 = app cash-out 700, T2 = agent withdrawal 600, starting balance 1000 (READ COMMITTED, values computed in the app)

Lost update: T2 পুরোনো 1000 থেকে হিসাব করে T1-এর 300 মুছে দিল T1 · অ্যাপ DB-তে commit হওয়া T2 · এজেন্ট 1000 300 400 R 1000W 300C R 1000W 400C মোট বেরোল 1300, ব্যালান্স 400 — T1-এর লেখা হারাল সময় →
ছবি ২৪.২ — দুজনেই সত্যিকারের, commit হওয়া মান পড়েছেন, তাই READ COMMITTED এখানে কিছুই আটকায় না। সমস্যা হলো T2-এর সিদ্ধান্ত (১,০০০ ≥ ৬০০) এমন একটি মানের ওপর, যা লেখার মুহূর্তে আর সত্যি নেই।

এবার একই ঘটনা ধাপে ধাপে দেখুন। প্রতিটি ধাপে কে কী জানে আর invariant — “ব্যালান্স = ১,০০০ − মোট বেরোনো টাকা” — টিকে আছে কি না, খেয়াল করুন।

  1. T1 পড়েR bal = 1000অ্যাপ, ৭০০
  2. T2 পড়েR bal = 1000এজেন্ট, ৬০০
  3. T1 লেখেW 300, C১,০০০ − ৭০০
  4. T2 লেখেW 400, C১,০০০ − ৬০০
ওয়ালেট: শুরুর ব্যালান্স ১,০০০ টাকা
DB-তে commit হওয়া ব্যালান্স
T1-এর হাতে থাকা মান
T2-এর হাতে থাকা মান
মোট বেরোনো টাকা
Invariant: ব্যালান্স = 1000 − বেরোনো
  1. শুরু। ব্যালান্স ১,০০০, কেউ কিছু পড়েনি। নিয়ম: “ব্যালান্স ≥ উত্তোলন হলে তবেই টাকা দেওয়া হবে” — অ্যাপের কোডে লেখা, একা চালালে নির্ভুল।
  2. T1 পড়ল ১,০০০। অ্যাপের সার্ভার SELECT balance চালিয়ে ১,০০০ পেল, memory-তে রাখল। READ COMMITTED-এ সাধারণ SELECT কোনো lock নেয় না।
  3. T2-ও পড়ল ১,০০০। এজেন্টের অনুরোধ অন্য সার্ভারে, একই সারি। এটিও commit হওয়া মান — dirty read নয়।
  4. T1 লিখল ৩০০ আর commit করল। অ্যাপ হিসাব করল ১,০০০ ≥ ৭০০, তাই SET balance = 300. ৭০০ টাকা বেরোল, ব্যালান্স ৩০০ — invariant ঠিক আছে।
  5. T2 লিখল ৪০০। T2-এর হাতে এখনো পুরোনো ১,০০০; হিসাব ১,০০০ ≥ ৬০০, তাই SET balance = 400. Database আপত্তি করল না — সারিটি তখন কারও lock-এ নেই, আর লেখাটি দেখতে স্বাভাবিক। T1-এর ৩০০ নিঃশব্দে মুছে গেল।
  6. ফল: ৭০০ টাকা “সৃষ্টি” হলো। মোট বেরোল ১,৩০০, ব্যালান্স ৪০০; সঠিক হলে T2 প্রত্যাখ্যাত হতো (৩০০ < ৬০০)। প্রতিকার — শর্তসহ UPDATE, FOR UPDATE, version কলাম বা snapshot-এর abort — অংশ ৫ ও ৬-এ।

৩ · Write skew আর phantom — যখন দুজন দুটি আলাদা সারিতে লেখেন

Lost update-এ দুজন একই সারিতে লেখেন, তাই database চাইলে সংঘাত দেখতে পায়। আরও সূক্ষ্ম অসুখ write skew: দুজন একই তথ্য পড়েন, কিন্তু লেখেন আলাদা সারিতে। কেউ কারও লেখা মোছেন না, অথচ দুই লেখা মিলে নিয়ম ভাঙে।

ক্লাসিক উদাহরণ একটি হাসপাতালের (কাল্পনিক) রাতের shift. নিয়ম: অন্তত একজন ডাক্তার on call থাকবেন। অ্যালিস আর বব দুজনেই on call, দুজনেরই একটু অসুস্থ লাগছে, আর দুজনে প্রায় একসাথে অ্যাপে “ছুটি চাই” চাপলেন। প্রত্যেকের transaction গুনল: on call ২ জন — “আমি ছুটি নিলেও একজন থাকবেন।” অ্যালিসের transaction অ্যালিসের সারি বদলাল, ববেরটি ববের সারি। দুটিই commit — on call শূন্য।

Write skew-এর ছাঁচ
  1. একটি শর্ত পড়া — “কতজন on call?”, “সিট B4-এ কোনো booking আছে?”, “এই username কি নেওয়া?”
  2. ফলের ভিত্তিতে সিদ্ধান্ত — “যথেষ্ট আছে, তাই এগোই।”
  3. এমন একটি লেখা, যা ওই শর্তের ফলই বদলে দেয় — কিন্তু অন্যজন যে সারিতে লিখছেন, সেখানে নয়।

Snapshot isolation-ও এখানে হারে, কারণ সে শুধু দেখে দুজন একই সারিতে লিখেছেন কি না:

নিয়ম: alice + bob ≥ 1 (অন্তত একজন on call)
DB-তে commit হওয়া
T1 (অ্যালিস)-এর snapshot
T2 (বব)-এর snapshot
Write set
On call (commit হওয়া)
  1. শুরু। অ্যালিস আর বব দুজনেই on call (১ মানে on call). নিয়মটি কোনো একটি সারির নয় — দুটি সারি মিলিয়ে একটি গণনার।
  2. T1 শুরু, snapshot নিল। Snapshot isolation-এ প্রতিটি transaction শুরুর মুহূর্তের একটি স্থির ছবি থেকে পড়ে। অ্যালিসের transaction গুনল ২ — ছুটি দেওয়া যায়।
  3. T2 শুরু, একই ছবি। ববের transaction-ও একই অবস্থার snapshot পেল, গুনল ২। দুজনের সিদ্ধান্তই একা একা নির্ভুল।
  4. T1 লিখল alice = 0. T1-এর write set {alice}; commit-এর আগে কেউ দেখে না, T2-এর snapshot-এ অ্যালিস এখনো ১।
  5. T2 লিখল bob = 0. T2-এর write set {bob} — দুই write set-এ কোনো মিল নেই।
  6. T1 commit করল। First-committer-wins দেখে: T1-এর snapshot-এর পরে কেউ alice-এ লিখে commit করেছে কি? না, তাই সফল। On call ১ — এখনো ঠিক।
  7. T2 commit করল — এবং সফল! T2-এর snapshot-এর পরে কেউ bob-এ লেখেনি, তাই snapshot isolation-এর কাছে সব ঠিক। ফল: on call শূন্য, নিয়ম ভাঙল — এটিই write skew.
  8. SERIALIZABLE (SSI) হলে কী হতো। SSI মনে রাখে কে কী পড়েছে: T1 পড়েছে bob, যা T2 লিখেছে; T2 পড়েছে alice, যা T1 লিখেছে — একটি চক্র। তাই T2-এর commit-এ ত্রুটি 40001, T2 abort. T2 আবার চালালে নতুন snapshot-এ গণনা ১, ছুটি নামঞ্জুর। On call ১ — নিয়ম রক্ষা।

Phantom — যে সারি এখনো নেই, তাকে lock করবেন কীভাবে?

শুরুর বাসের সিটের গল্পটি write skew-এর আরও কঠিন রূপ। দুই সার্ভার খুঁজল SELECT … WHERE trip_id = 7 AND seat_no = 'B4' — কোনো সারি নেই। তারপর দুজনেই নতুন সারি INSERT করল। একটি শর্তের খোঁজের ফল অন্যের লেখায় বদলে যাওয়াকে বলে phantom. Lock দেওয়ার মতো কোনো সারিই তখন ছিল না, তাই সারি-স্তরের lock অকেজো।

একই ছাঁচ: একই username দুজনের, একই মিটিং রুম একই সময়ে দুবার, বা দুটি যৌথ হিসাবের “মোট ব্যালান্স ≥ ০” ভাঙা। প্রতিকার তিন দিকে। Lock-ভিত্তিক database শর্ত বা index-এর পরিসরে lock নেয় (predicate lock, InnoDB-এর next-key lock). SSI খোঁজের পরিসরও মনে রাখে। আর নকশাকারের সবচেয়ে সহজ হাতিয়ার: নিয়মটি schema-তে লেখা — UNIQUE (trip_id, seat_no) (অংশ ৬)।

দুই ডাক্তারের উদাহরণটি Martin Kleppmann-এর Designing Data-Intensive Applications (২০১৭) বইয়ের মাধ্যমে বহুল পরিচিত। ভেতরের তত্ত্ব আছে DBMS কোর্সের Schedule আর serializability পর্বে, আর anomaly-গুলোর আরও উদাহরণ MVCC আর isolation level পর্বে।

৪ · Isolation level — কোনটি কী ঠেকায়, আর নামের ফাঁদ

পুরো isolation (সব সময় serializable) দামি, তাই SQL standard (SQL-92) চারটি ধাপ দিয়েছে। Isolation level যত ওপরে, তত কম anomaly, কিন্তু তত বেশি অপেক্ষা বা abort. Standard ধাপগুলো সংজ্ঞায়িত করেছে মাত্র তিনটি ঘটনা দিয়ে — dirty read, non-repeatable read আর phantom. বাস্তবের database আর পরের গবেষণা মিলিয়ে আরও পূর্ণ ছবিটি এমন (সাধারণ বাস্তবায়ন ধরে):

LevelDirty readNon-repeatable read / read skewLost updateWrite skewPhantom
READ UNCOMMITTEDঘটতে পারেঘটতে পারেঘটতে পারেঘটতে পারেঘটতে পারে
READ COMMITTEDঠেকায়ঘটতে পারেঘটতে পারে*ঘটতে পারেঘটতে পারে
REPEATABLE READ / snapshotঠেকায়ঠেকায়Snapshot isolation-এ ঠেকায় (abort); InnoDB-এর RR-এ নয়*ঘটতে পারেSnapshot-এর পড়ায় দেখা যায় না, তবু phantom-ঘটিত write skew থাকে
SERIALIZABLEঠেকায়ঠেকায়ঠেকায়ঠেকায়ঠেকায়

* অ্যাপে পড়ে, হিসাব করে, তারপর মান লিখলে। SET x = x − 1-এর মতো এক বাক্যের atomic update-এ READ COMMITTED-ও নিরাপদ (অংশ ৬)। আর dirty write প্রায় সব বাস্তব database সব level-এই ঠেকায়।

একই নাম, আলাদা আচরণ — database-ভেদে

এখানেই নকশাকারের সবচেয়ে বড় ফাঁদ। Standard-এর ভাষা অস্পষ্ট ছিল, তাই প্রতিটি database নিজের যন্ত্র অনুযায়ী নামগুলো বসিয়েছে। লেখার সময়ের সাধারণ চিত্র (সংস্করণভেদে বদলাতে পারে — নিজের সংস্করণের documentation দেখুন):

Databaseডিফল্টনামের আড়ালে যা পান
PostgreSQLREAD COMMITTEDREAD UNCOMMITTED চাইলেও READ COMMITTED; REPEATABLE READ আসলে snapshot isolation (একই সারিতে সংঘাতে ত্রুটি 40001); SERIALIZABLE হলো SSI (9.1 সংস্করণ থেকে)
MySQL (InnoDB)REPEATABLE READসাধারণ SELECT প্রথম পড়ার মুহূর্তের snapshot দেখে; কিন্তু UPDATE, DELETE আর locking read সর্বশেষ commit হওয়া মানে কাজ করে, next-key lock নিয়ে। একই সারির সংঘাতে abort হয় না, অপেক্ষার পরে লেখে — তাই অ্যাপে হিসাব করা lost update সম্ভব
OracleREAD COMMITTEDযাকে SERIALIZABLE বলা হয়, তা বহুল-আলোচিতভাবে snapshot isolation — write skew সম্ভব
SQL ServerREAD COMMITTED (lock দিয়ে)বিকল্প হিসেবে row version-ভিত্তিক READ COMMITTED আর আলাদা SNAPSHOT level; SERIALIZABLE key-range lock দিয়ে
SQLiteSERIALIZABLEপুরো database-এ একসাথে একজনই লেখক; লেখাগুলো লাইনে দাঁড়ায়, তাই এই পাতার লাইভ SQL-এ একসাথে চলার anomaly দেখানো যায় না

ফল: MySQL থেকে PostgreSQL-এ সরালে একই REPEATABLE READ-এ যেখানে দ্বিতীয় লেখক নীরবে লিখত, এখন ত্রুটি আসবে আর retry লাগবে।

১৯৯৫ সালে Hal Berenson, Phil Bernstein, Jim Gray, Jim Melton আর Elizabeth ও Patrick O’Neil প্রকাশ করেন “A Critique of ANSI SQL Isolation Levels”. তাঁরা দেখান, standard-এর তিনটি ঘটনায় dirty write, lost update, read skew আর write skew বাদ পড়েছে, আর snapshot isolation সংজ্ঞায়িত করে দেখান যে এটি standard-এর কোনো ঘরে ঠিক বসে না। পুরো টেবিল আর সমালোচনার খুঁটিনাটি আছে DBMS কোর্সের MVCC আর isolation level পর্বে।

নকশাকারের নিয়ম

Level-এর নাম দেখে নয়, আপনার database-এর আসল আচরণ দেখে সিদ্ধান্ত নিন। দুটি session খুলে একটি সহজ পরীক্ষা চালান: দুজনে একই সারি পড়ুন, একজন লিখে commit করুন, তারপর অন্যজন লিখুন — ত্রুটি আসে, অপেক্ষা করে, নাকি নীরবে লেখে? এই দশ মিনিটের পরীক্ষা বহু দিনের অনুমানের চেয়ে বেশি বলে।

৫ · ভেতরের যন্ত্র — two-phase locking, MVCC আর SSI

Two-phase locking (2PL) — আগে lock, শেষে ছাড়া

প্রথম পথটি সোজা: পড়ার আগে shared lock (S), লেখার আগে exclusive lock (X). একই সারিতে অনেকের S একসাথে চলে, কিন্তু X থাকলে আর কেউ কিছু পায় না। Two-phase মানে একটি transaction প্রথমে শুধু lock নেয় (growing), আর একবার কোনো lock ছাড়লে আর নতুন lock নেয় না (shrinking). বাস্তবে প্রায় সবাই strict 2PL চালায়: সব lock commit বা abort পর্যন্ত ধরে রাখা।

কেউ ধরে আছে →
নতুন চাওয়া ↓
কিছু নেইS (পড়া)X (লেখা)
S (পড়া)পাবেপাবেঅপেক্ষা
X (লেখা)পাবেঅপেক্ষাঅপেক্ষা

১৯৭৬ সালে Eswaran, Gray, Lorie আর Traiger প্রমাণ করেন, সব transaction 2PL মানলে ফল কোনো serial ক্রমের সমান হয়। তাঁরা phantom-এর সমস্যাও তখনই দেখান, আর সমাধান হিসেবে শর্তের ওপর predicate lock প্রস্তাব করেন। দাম: পাঠক লেখককে আটকায়, লেখক পাঠককে, আর অপেক্ষা থেকে আসে deadlock.

Two-phase locking আর two-phase commit সম্পূর্ণ আলাদা। 2PL হলো একটি database-এর ভেতরে একসাথে চলা transaction-কে সাজানোর lock-নিয়ম। Two-phase commit (2PC) হলো একাধিক database বা সার্ভিসকে একসাথে commit বা abort করানোর protocol — পর্ব ২৫-এর বিষয়। ইন্টারভিউতে দুটি গুলিয়ে ফেলা খুব চেনা ভুল।

Deadlock — চক্রে আটকে থাকা, আর database কীভাবে খোলে

ঢাকার চার রাস্তার মোড়ে চার দিক থেকে ঢোকা চারটি গাড়ি, প্রত্যেকে সামনেরটির অপেক্ষায় — কেউ নড়ে না। Database-এ: T1 হিসাব ৭ lock করে হিসাব ৯ চায়, T2 হিসাব ৯ ধরে হিসাব ৭ চায়। কে কার অপেক্ষায়, তা একটি wait-for graph: তীর A → B মানে “A, B-এর lock-এর অপেক্ষায়”। গ্রাফে চক্র মানেই deadlock. Database মাঝে মাঝে (PostgreSQL-এ ডিফল্টভাবে প্রায় ১ সেকেন্ড অপেক্ষার পরে) চক্র খোঁজে, আর একটি “victim” বেছে rollback করে। অ্যাপ পায় একটি ত্রুটি — PostgreSQL-এ 40P01, MySQL-এ 1213 — আর তার কাজ পুরো transaction আবার চালানো।

নিচের simulator-এ চারটি টাকা পাঠানোর transaction প্রত্যেকে দুটি হিসাব lock করে। প্রথমে “যে হিসাব থেকে টাকা যাবে, সেটি আগে”, তারপর “ছোট নম্বরের হিসাব আগে” নিয়মে:

// Four money transfers; each must lock two accounts. T1 started first, T4 last.
const TX = { T1: ["acc7", "acc9"], T2: ["acc9", "acc4"], T3: ["acc4", "acc7"], T4: ["acc2", "acc7"] };
function waitFor(order) {                         // edge A -> B: "A waits for a lock B holds"
  const holder = {}, edge = {};
  const ask = (t, acc) => (holder[acc] ? (edge[t] = holder[acc]) : (holder[acc] = t));
  for (const [t, accs] of Object.entries(TX)) ask(t, order(accs)[0]);          // round 1: first lock
  for (const [t, accs] of Object.entries(TX)) if (!edge[t]) ask(t, order(accs)[1]); // round 2
  return edge;
}
function findCycle(edge) {                        // each T waits for at most one holder here
  for (const start of Object.keys(edge)) {
    const path = []; let t = start;
    while (t && !path.includes(t)) { path.push(t); t = edge[t]; }
    if (t) return path.slice(path.indexOf(t));
  }
  return null;
}
const plans = {
  "lock 'from' account first": (a) => a,
  "lock lower account id first": (a) => [...a].sort((x, y) => +x.slice(3) - +y.slice(3)),
};
for (const [name, order] of Object.entries(plans)) {
  const edge = waitFor(order), cyc = findCycle(edge);
  console.log(name + "\n  wait-for: " + Object.entries(edge).map(([a, b]) => a + "->" + b).join("  "));
  if (!cyc) { console.log("  no cycle: no deadlock, the waits drain one by one\n"); continue; }
  const victim = cyc.reduce((a, b) => (+b.slice(1) > +a.slice(1) ? b : a));   // youngest
  console.log("  cycle: " + cyc.concat(cyc[0]).join(" -> ") + "  => DEADLOCK");
  console.log("  victim: " + victim + " (youngest) is rolled back; the app must retry it\n");
}

আউটপুট:

lock 'from' account first
  wait-for: T1->T2  T2->T3  T3->T1  T4->T1
  cycle: T1 -> T2 -> T3 -> T1  => DEADLOCK
  victim: T3 (youngest) is rolled back; the app must retry it

lock lower account id first
  wait-for: T3->T2  T2->T1  T4->T1
  no cycle: no deadlock, the waits drain one by one

প্রথম নিয়মে T1 → T2 → T3 → T1 চক্র: T1 হিসাব ৭ ধরে ৯ চায়, T2 হিসাব ৯ ধরে ৪, T3 হিসাব ৪ ধরে ৭। T4 চক্রে নেই, শুধু T1-এর পেছনে। Victim T3, চক্রের সবচেয়ে নতুন — সাধারণত কম কাজ নষ্ট হয়। T3 সরলে T2, তারপর T1, তারপর T4 শেষ করে।

দ্বিতীয় নিয়মে সবাই একই ক্রমে lock চায়: প্রত্যেকে শুধু নিজের ধরা lock-এর চেয়ে বড় নম্বরের lock-এর অপেক্ষা করে, তাই চক্র হতেই পারে না। এটিই lock ordering, deadlock প্রতিরোধের সবচেয়ে কাজের নিয়ম। Detection-এর তত্ত্ব আছে Operating Systems কোর্সের Deadlock পর্বে, আর গ্রাফে চক্র খোঁজার সাধারণ পদ্ধতি DSA কোর্সের DFS পর্বে।

MVCC আর snapshot isolation — পাঠক লেখককে আটকায় না

দ্বিতীয় পথ: সারির ওপর লেখা না বসিয়ে প্রতিটি লেখায় একটি নতুন version তৈরি করা। পুরোনো version কিছুক্ষণ রয়ে যায়। প্রতিটি transaction (বা প্রতিটি বাক্য) একটি snapshot পায় — কোন version তার কাছে দৃশ্যমান, তার নিয়ম। এই হলো MVCC (multi-version concurrency control). ফল: পাঠক লেখককে আটকায় না, লেখক পাঠককে আটকায় না। দীর্ঘ রিপোর্টের সময়ও বিক্রি চলে, আর রিপোর্ট একটি মুহূর্তেরই হিসাব দেখে।

Snapshot isolation-এ লেখার সংঘাত ধরা হয় first-committer-wins নিয়মে: দুজন একই সারিতে লিখলে যে আগে commit করে সে টেকে, অন্যজন abort. তাই lost update আপনাআপনি ধরা পড়ে, কিন্তু write set আলাদা হলে (অংশ ৩) write skew থেকে যায়। পুরোনো version-গুলো পরিষ্কার করতে হয় (PostgreSQL-এ VACUUM, InnoDB-তে purge) — অংশ ৭-এ এর দাম দেখবেন। xmin, xmax আর visibility-র খুঁটিনাটি DBMS কোর্সের MVCC অংশে।

Serializable snapshot isolation (SSI) — snapshot-এর গতি, serializable-এর নিশ্চয়তা

২০০৮ সালে Michael Cahill, Uwe Röhm আর Alan Fekete একটি পথ দেখান: snapshot isolation রেখেই কে কী পড়েছে আর কে তা পরে লিখেছে, সেই “পড়া-তারপর-লেখা” নির্ভরতাগুলো চলার সময় খেয়াল রাখা। কোনো transaction-এর একদিকে একটি ঢোকা আর অন্যদিকে একটি বেরোনো এমন নির্ভরতা থাকলে — বিপজ্জনক কাঠামো — একজনকে abort করা। ডাক্তারদের ঘটনায় ঠিক তা-ই ঘটে। PostgreSQL ২০১১ সালের 9.1 সংস্করণে তাদের SERIALIZABLE এভাবে বানায়। দাম: lock-এর অপেক্ষা নেই, কিন্তু কখনো নির্দোষ transaction-ও abort হয় (false positive), তাই retry বাধ্যতামূলক।

চারটি mode পাশাপাশি — interleaving simulator

এবার তিনটি ঘটনা — ওয়ালেট, ডাক্তার আর সিট B4 — চারটি mode-এ চালান। প্রতিটি transaction-এর তিন ধাপ: পড়া (r), লেখা (w), commit (c); SCHED বলে কোন ধাপ কখন। খেলনা-মডেলে none-এ লেখা সঙ্গে সঙ্গে সবার চোখে; read committed-এ শুধু commit হওয়া মান পড়া যায়; snapshot-এ শুরুর ছবি আর first-committer-wins; serializable (2PL)-এ commit পর্যন্ত ধরা S/X lock, আর সিটের খোঁজে একটি পরিসর-lock (B4-range, next-key lock-এর মতো)।

const SCHED = ["1r", "2r", "1w", "1c", "2w", "2c"];  // toy model: whose step next (read, write, commit)
const AMT = [700, 600], DOC = ["alice", "bob"], nB4 = (v) => Object.values(v).filter((x) => x === "B4").length;
const SC = {
  "wallet: lost update": { init: { bal: 1000 }, rk: () => ["bal"], wk: () => ["bal"],
    run: (v, i) => (v.bal >= AMT[i] ? { bal: v.bal - AMT[i] } : {}),
    ok: (s, done) => s.bal >= 0 && s.bal === 1000 - done.reduce((t, i) => t + AMT[i], 0) },
  "doctors: write skew": { init: { alice: 1, bob: 1 }, rk: () => DOC, wk: (i) => [DOC[i]],
    run: (v, i) => (v.alice + v.bob >= 2 ? { [DOC[i]]: 0 } : {}), ok: (s) => s.alice + s.bob >= 1 },
  "seat B4: double booking": { init: {}, rk: () => ["B4-range"], wk: (i) => ["bk" + (i + 1), "B4-range"],
    run: (v, i) => (nB4(v) ? {} : { ["bk" + (i + 1)]: "B4" }), ok: (s) => nB4(s) <= 1 } };
function sim(sc, mode) {
  let com = { ...sc.init }, cur = { ...com }, clock = 0; const ver = {}, L = {}, note = [], done = [];
  const snap = [], start = [], plan = [{}, {}], st = ["run", "run"], q = [[], []];
  const lk = (k) => (L[k] ||= { S: new Set(), X: null }), log = (m) => note.includes(m) || note.push(m);
  const can = (i, k, x) => [null, i].includes(lk(k).X) && (!x || [...lk(k).S].every((j) => j === i));
  const free = (i) => Object.values(L).forEach((l) => { l.S.delete(i); if (l.X === i) l.X = null; });
  const abort = (i, why) => { st[i] = "aborted"; q[i] = []; free(i); log(`T${i + 1} aborted: ${why}`); };
  function step(i, op) { if (mode === "snapshot" && !snap[i]) [snap[i], start[i]] = [{ ...com }, clock];
    const ks = op === "r" ? sc.rk(i) : op === "w" && Object.keys(plan[i]).length ? sc.wk(i) : [];
    if (mode.includes("2PL")) { if (!ks.every((k) => can(i, k, op === "w"))) { log(`T${i + 1} waits`); return false; }
      ks.forEach((k) => (op === "w" ? (lk(k).X = i) : lk(k).S.add(i))); }         // hold until commit
    if (op === "r") plan[i] = sc.run(mode === "none" ? cur : mode === "snapshot" ? snap[i] : com, i);
    if (op === "w" && mode === "none") cur = { ...cur, ...plan[i] };          // visible at once
    if (op !== "c") return true; const keys = Object.keys(plan[i]);
    if (mode === "snapshot" && keys.some((k) => (ver[k] || 0) > start[i])) return abort(i, "first-committer-wins"), true;
    clock++; keys.forEach((k) => (ver[k] = clock)); com = { ...com, ...plan[i] };
    st[i] = "done"; if (keys.length) done.push(i); free(i); return true; }
  for (const s of SCHED) { const i = +s[0] - 1; if (st[i] === "run") q[i].push(s[1]);
    for (let moved = true; moved; ) { moved = false;
      for (const j of [0, 1]) while (q[j].length && step(j, q[j][0])) { q[j].shift(); moved = true; }
      if (q[0].length && q[1].length) { log("deadlock"); abort(1, "victim"); moved = true; } } }
  const fin = mode === "none" ? cur : com;
  return mode.padEnd(19) + JSON.stringify(fin).padEnd(26) + (sc.ok(fin, done) ? "OK     " : "BROKEN ") + note.join("; "); }
for (const [name, sc] of Object.entries(SC)) { console.log(`\n${name}   (schedule ${SCHED.join(" ")})`);
  for (const m of ["none", "read committed", "snapshot", "serializable (2PL)"]) console.log("  " + sim(sc, m)); }

আউটপুট:

wallet: lost update   (schedule 1r 2r 1w 1c 2w 2c)
  none               {"bal":400}               BROKEN
  read committed     {"bal":400}               BROKEN
  snapshot           {"bal":300}               OK     T2 aborted: first-committer-wins
  serializable (2PL) {"bal":300}               OK     T1 waits; T2 waits; deadlock; T2 aborted: victim

doctors: write skew   (schedule 1r 2r 1w 1c 2w 2c)
  none               {"alice":0,"bob":0}       BROKEN
  read committed     {"alice":0,"bob":0}       BROKEN
  snapshot           {"alice":0,"bob":0}       BROKEN
  serializable (2PL) {"alice":0,"bob":1}       OK     T1 waits; T2 waits; deadlock; T2 aborted: victim

seat B4: double booking   (schedule 1r 2r 1w 1c 2w 2c)
  none               {"bk1":"B4","bk2":"B4"}   BROKEN
  read committed     {"bk1":"B4","bk2":"B4"}   BROKEN
  snapshot           {"bk1":"B4","bk2":"B4"}   BROKEN
  serializable (2PL) {"bk1":"B4"}              OK     T1 waits; T2 waits; deadlock; T2 aborted: victim

ওয়ালেট: none আর read committed-এ ৪০০ — ছবি ২৪.২-এর lost update. Snapshot-এ T2 commit-এর সময় দেখে bal তার snapshot-এর পরে বদলেছে, তাই abort; ফল ৩০০। 2PL-এ দুজনেই S নিয়ে পড়েছে, তারপর দুজনেই X চায় — deadlock, victim T2, ফল ৩০০।

ডাক্তার: snapshot-ও এবার BROKEN — alice আর bob দুটি আলাদা key, তাই first-committer-wins কিছু দেখে না। শুধু 2PL বাঁচায়, কারণ দুজনের S lock দুটি সারিতেই। সিট B4: bk1 আর bk2 দুটি নতুন সারি, তাই snapshot আবার অন্ধ; 2PL বাঁচায় কারণ খোঁজটি B4-range-এ S নিয়েছে আর insert সেখানে X চায়। Range lock না থাকলে 2PL-ও এখানে হারত — phantom-এর আসল শিক্ষা।

2PL তিনবারই সঠিক, কিন্তু তিনবারই deadlock আর একটি abort — নিরাপত্তার দাম অপেক্ষা আর retry. বাস্তব database আরও সূক্ষ্ম; মডেলটি শুধু মূল পার্থক্য দেখায়। অনুশীলন ৪-এ SCHED বদলে আগে অনুমান করুন।

৬ · অ্যাপ-স্তরের প্যাটার্ন — সংঘাতকে database-এর চোখের সামনে আনা

বাস্তবের বেশিরভাগ ওয়েব আর মোবাইল backend ডিফল্ট READ COMMITTED-এ চলে, আর বিপজ্জনক জায়গাগুলোতে আলাদা করে নিরাপত্তা যোগ করে। সব প্যাটার্নের পেছনে একটিই ভাবনা: সংঘাতকে এমন জায়গায় আনুন, যেখানে database নিজে তা দেখতে পায় — একই সারিতে, একই বাক্যে, বা একটি constraint-এ। নিচের লাইভ SQL চলে SQLite-এ, যেখানে লেখকেরা এমনিতেই লাইনে দাঁড়ায়; তাই এগুলো একসাথে চলা দেখায় না — দেখায় সঠিক বাক্যটি কেমন, আর প্রতিটির পরে কয়টি সারি বদলাল।

ক · শর্তসহ atomic update — পড়া, যাচাই আর লেখা এক বাক্যে

Lost update হয় কারণ অ্যাপ পড়ে, তারপর memory-তে হিসাব করে লেখে। Atomic update-এ হিসাবটি database-কেই দিন, আর শর্তটি WHERE-এ রাখুন। Database এক বাক্যের ভেতরে সারিটি lock করে সর্বশেষ মান দেখে, তাই দুজনের মাঝে কেউ ঢুকতে পারে না।

-- One bus trip with 2 seats left. CHECK is the last line of defence.
CREATE TABLE trips (trip_id INTEGER PRIMARY KEY,
                    seats_left INTEGER NOT NULL CHECK (seats_left >= 0));
INSERT INTO trips VALUES (7, 2);
-- WRONG: both servers read 2, computed 2 - 1 in the app, and wrote 1
UPDATE trips SET seats_left = 1 WHERE trip_id = 7;
UPDATE trips SET seats_left = 1 WHERE trip_id = 7;
SELECT seats_left AS after_naive FROM trips WHERE trip_id = 7;
UPDATE trips SET seats_left = 2 WHERE trip_id = 7;   -- reset
-- RIGHT: one atomic statement each, the guard inside WHERE
UPDATE trips SET seats_left = seats_left - 1 WHERE trip_id = 7 AND seats_left > 0;
UPDATE trips SET seats_left = seats_left - 1 WHERE trip_id = 7 AND seats_left > 0;
UPDATE trips SET seats_left = seats_left - 1 WHERE trip_id = 7 AND seats_left > 0;
SELECT seats_left AS after_atomic FROM trips WHERE trip_id = 7;

ভুল পথে দুটি UPDATE-এর প্রতিটিতে ১টি সারি বদলেছে, আর after_naive = 1 — দুটি টিকিট বিক্রি হলো, কমল একটি সিট। সঠিক পথে প্রথম দুটি UPDATE-এ ১টি সারি বদলেছে, তৃতীয়টিতে ০টি সারি বদলেছে — শর্ত seats_left > 0 মেলেনি; শেষে after_atomic = 0. অ্যাপ দেখবে কয়টি সারি বদলাল: ১ মানে সিট পাওয়া, ০ মানে “সিট শেষ”। সমান্তরালে নিরাপদ, কারণ PostgreSQL-এর READ COMMITTED-এ দ্বিতীয় UPDATE প্রথমটির row lock-এর অপেক্ষা করে, তারপর নতুন version-এ WHERE আবার যাচাই করে; InnoDB-ও UPDATE-এ সর্বশেষ version পড়ে। আর CHECK থাকায় শর্ত ভুলে গেলেও সংখ্যা ঋণাত্মক হতো না।

খ · SELECT … FOR UPDATE — যা পড়লেন, তাতেই lock

যখন সিদ্ধান্তটি এক বাক্যে আঁটে না — যেমন ব্যালান্স, দৈনিক সীমা আর ঝুঁকির নিয়ম মিলিয়ে দেখা — তখন পড়ার সময়েই সারিটি lock করুন। SELECT … FOR UPDATE সর্বশেষ commit হওয়া মান পড়ে আর সারিতে লেখার lock নেয়; অন্য কেউ একই সারি FOR UPDATE-এ পড়তে বা বদলাতে চাইলে অপেক্ষা করে। এটি pessimistic পথ — আগে থেকেই ধরে নেওয়া যে সংঘাত হবে।

BEGIN;
SELECT balance, daily_sent FROM wallets WHERE id = 42 FOR UPDATE;  -- row locked
-- app checks: balance >= 600 AND daily_sent + 600 <= limit, risk rules ...
UPDATE wallets SET balance = balance - 600, daily_sent = daily_sent + 600 WHERE id = 42;
INSERT INTO ledger (wallet_id, amount, request_id) VALUES (42, -600, 'req-b1');
COMMIT;                        -- the other withdrawal was waiting; now it sees the new row

তিনটি সতর্কতা। এক, lock commit পর্যন্ত থাকে, তাই ভেতরে network call বা ব্যবহারকারীর অপেক্ষা নয় (অংশ ৭)। দুই, কয়েকটি সারি lock করলে সবখানে একই ক্রমে — নইলে deadlock. তিন, FOR UPDATE শুধু বিদ্যমান সারি lock করে; “কোনো সারি নেই” দেখে নেওয়া সিদ্ধান্ত (খালি সিট) এতে রক্ষা পায় না। Job queue-তে PostgreSQL আর MySQL 8-এর FOR UPDATE SKIP LOCKED অন্যের ধরা সারি এড়িয়ে পরেরটি নেয় (পর্ব ৩৭)।

গ · Version কলাম — optimistic concurrency

উল্টো পথ: lock না নিয়ে সবাইকে পড়তে দিন, কিন্তু লেখার মুহূর্তে যাচাই করুন যে পড়ার পরে কেউ বদলায়নি। প্রতিটি সারিতে একটি version সংখ্যা থাকে; লেখা হয় শুধু তখনই, যখন version এখনো সেটিই। এটিই optimistic concurrency — একটি compare-and-set. পর্ব ৮-এ দেখেছেন API-র স্তরে একই কাজ ETag আর If-Match দিয়ে (412 উত্তর)। এখানে দেখুন ভেতরের database-এ, ধাপে ধাপে:

wallets সারি id = 42 · A তোলে ৭০০, B তোলে ৬০০
সারি (balance, version)
Server A যা পড়েছে
Server B যা পড়েছে
শেষ UPDATE-এর ফল
Invariant
  1. A পড়ল (১,০০০, v3). কোনো lock নেই, শুধু মান আর version মনে রাখা।
  2. B-ও পড়ল (১,০০০, v3). অংশ ২-এর মতোই দুজনের হাতে একই পুরোনো মান — এখনো কিছু খারাপ হয়নি।
  3. A লিখল শর্তসহ। UPDATE … SET balance = 300, version = version + 1 WHERE id = 42 AND version = 3 — শর্ত মিলল, ১টি সারি বদলেছে. সারি এখন (৩০০, v4).
  4. B-র একই চেষ্টা — শূন্য সারি। B পাঠাল SET balance = 400 … WHERE version = 3. Version এখন ৪, তাই ০টি সারি বদলেছে. Lost update হলো না; সংঘাতটি ধরা পড়ল আর অ্যাপ তা জানল।
  5. B নতুন করে পড়ল আর নিজে সিদ্ধান্ত নিল। নতুন মান (৩০০, v4); ৩০০ < ৬০০, তাই উত্তোলন প্রত্যাখ্যাত — “অপর্যাপ্ত ব্যালান্স”। ২০০ টাকার উত্তোলন হলে B লিখত WHERE version = 4, সারি হতো (১০০, v5). Retry সীমিত রাখুন — ৩ থেকে ৫ বার।
CREATE TABLE wallets (id INTEGER PRIMARY KEY,
                      balance INTEGER NOT NULL CHECK (balance >= 0),
                      version INTEGER NOT NULL);
INSERT INTO wallets VALUES (42, 1000, 3);
-- Server A and server B both read: balance 1000, version 3.
-- A withdraws 700 and writes only if the version is still 3:
UPDATE wallets SET balance = 300, version = version + 1 WHERE id = 42 AND version = 3;
-- B withdraws 600 using the same old read:
UPDATE wallets SET balance = 400, version = version + 1 WHERE id = 42 AND version = 3;
SELECT id, balance, version FROM wallets;
-- B re-reads (300, v4). A 200 withdrawal would now pass:
UPDATE wallets SET balance = 100, version = version + 1 WHERE id = 42 AND version = 4;
SELECT id, balance, version FROM wallets;

A-র UPDATE-এ ১টি সারি বদলেছে, B-র একই শর্তের UPDATE-এ ০টি সারি বদলেছে. মাঝের SELECT দেখায় 42 | 300 | 4 — A-র লেখা অক্ষত; নতুন version ধরে পরের লেখায় আবার ১টি সারি বদলেছে, শেষে 42 | 100 | 5. কম সংঘাতের জায়গায় (প্রোফাইল বা অ্যাডমিন সেটিং) এটি সস্তা, lock ধরে রাখে না; খুব ব্যস্ত সারিতে বারবার ০ সারি আর retry — সেখানে ক-এর atomic update ভালো।

ঘ · UNIQUE constraint — নিয়মটি schema-তে লেখা

Phantom-ঘটিত double booking-এর সবচেয়ে নির্ভরযোগ্য সমাধান: নিয়মটি database-কে বলে দেওয়া। একই ট্রিপের একই সিট দুবার — এটি একটি unique constraint. Unique index-এ একই key দুবার ঢোকানো যায় না, যেকোনো isolation level-এ: দ্বিতীয় insert প্রথমটির ফলের অপেক্ষা করে, প্রথমটি commit করলে ত্রুটি পায়।

-- Without a constraint the database cannot know a seat is sold once.
CREATE TABLE bookings_naive (trip_id INTEGER, seat_no TEXT, passenger TEXT);
INSERT INTO bookings_naive VALUES (7, 'B4', 'Rahim');
INSERT INTO bookings_naive VALUES (7, 'B4', 'Karim');  -- both "saw" B4 free
SELECT trip_id, seat_no, COUNT(*) AS sold FROM bookings_naive GROUP BY trip_id, seat_no;
-- With the rule written into the schema:
CREATE TABLE bookings (id INTEGER PRIMARY KEY, trip_id INTEGER NOT NULL,
                       seat_no TEXT NOT NULL, passenger TEXT NOT NULL,
                       UNIQUE (trip_id, seat_no));
INSERT INTO bookings (trip_id, seat_no, passenger) VALUES (7, 'B4', 'Rahim');
INSERT INTO bookings (trip_id, seat_no, passenger) VALUES (7, 'B5', 'Karim');
INSERT INTO bookings (trip_id, seat_no, passenger) VALUES (8, 'B4', 'Sumi');
SELECT trip_id, seat_no, passenger FROM bookings ORDER BY trip_id, seat_no;
-- Karim tries B4 on trip 7 again (this error is the point):
INSERT INTO bookings (trip_id, seat_no, passenger) VALUES (7, 'B4', 'Karim');

Constraint ছাড়া টেবিলে দুটি insert-ই সফল, আর গণনা দেখায় 7 | B4 | 2 — একটি সিট দুবার বিক্রি। Constraint-সহ টেবিলে তিনটি insert সফল: 7 | B4 | Rahim, 7 | B5 | Karim, 8 | B4 | Sumi (অন্য ট্রিপের B4 আলাদা সিট)। শেষ লাইনটি ইচ্ছে করে ত্রুটি দেয়: UNIQUE constraint failed: bookings.trip_id, bookings.seat_no. অ্যাপ এটি ধরে দেখাবে “সিটটি এইমাত্র বিক্রি হয়েছে” — API-তে সাধারণত 409 Conflict (পর্ব ৮)। নীরবে বাদ চাইলে ON CONFLICT DO NOTHING; সময়ের পরিসরের booking-এ PostgreSQL-এর exclusion constraint.

ঙ · সংঘাতকে একটি সারিতে আনা — materializing the conflict

যখন নিয়মটি UNIQUE-এ লেখা যায় না, তখন এমন একটি সাধারণ সারি তৈরি করুন, যা সংঘাতে থাকা সবাইকে লিখতে হয়। একে বলে materializing the conflict. দুটি পরিচিত রূপ:

  • আগে থেকে বানানো সিটের সারি: প্রতিটি ট্রিপ তৈরির সময়ই প্রতিটি সিটের একটি সারি — seats(trip_id, seat_no, status, held_until). Booking মানে UPDATE seats SET status = 'held' … WHERE trip_id = 7 AND seat_no = 'B4' AND status = 'free' — phantom এখন একটি সাধারণ বিদ্যমান সারির atomic update. “১০ মিনিট সিট ধরে রাখা, তারপর পেমেন্ট” এভাবেই হয় (পর্ব ৬০)।
  • গণনার সারি: ডাক্তারদের shift-এর একটি সারি shifts(shift_id, on_call_count CHECK (on_call_count >= 1)). ছুটি নিতে হলে একই transaction-এ এটিও কমাতে হয়; দুজনের লেখা এখন একই সারিতে, তাই lock বা first-committer-wins সংঘাতটি দেখে। DBMS কোর্সে এটি লাইভ SQL-এ চালিয়ে দেখতে পারেন।

৭ · Transaction-এর পরিধি — ছোট রাখা, দীর্ঘ transaction-এর দাম আর retry

Level বাছাইয়ের মতোই জরুরি transaction-এর ভেতরে কী রাখবেন। মূল নিয়ম: transaction-এর ভেতরে শুধু database-এর কাজ, আর যত কম সময়। পেমেন্ট গেটওয়েতে call, SMS পাঠানো, অন্য সার্ভিসের API, বা ব্যবহারকারীর “নিশ্চিত করুন” চাপার অপেক্ষা — এগুলোর কোনোটিই নয়।

একটি ব্যস্ত সারির throughput-এর সীমা — হিসাব

একটি সারিতে একবারে একজনই লেখার lock ধরতে পারেন। প্রতিটি transaction lock ধরে রাখে h সেকেন্ড। Little-এর সূত্রে (পর্ব ৪) ভেতরে গড় সংখ্যা L = λ × h, আর L কখনো ১-এর বেশি হতে পারে না। তাই:

ওই সারিতে সর্বোচ্চ λ = 1 ÷ h transaction প্রতি সেকেন্ডে

ধরা যাক একটি ফ্ল্যাশ সেলে একটি জনপ্রিয় পণ্যের মজুতের সারি। Lock থাকে ৪ ms (UPDATE আর commit): সর্বোচ্চ ১ ÷ ০.০০৪ = ২৫০টি বিক্রি প্রতি সেকেন্ডে। এবার কেউ transaction-এর ভেতরেই পেমেন্ট গেটওয়ে ডাকলেন, যা গড়ে ৩০০ ms নেয়: সীমা নামল ১ ÷ ০.৩০৪ ≈ ৩.৩টিতে — প্রায় ৭৬ গুণ কম। আর এই সীমার কাছাকাছি গেলে অপেক্ষা বিস্ফোরিত হয় (পর্ব ৪-এর ρ → ১)। এটিই lock contention.

দীর্ঘ transaction কী কী ভাঙে

  • Lock-এর লাইন: খোলা transaction-এর lock-এর পেছনে অন্যরা দাঁড়ায়; throughput নামে, অপেক্ষারত অনুরোধ connection pool ফুরিয়ে দেয় (পর্ব ১৮-এর ধসের চক্র)।
  • MVCC-র ফোলা: সবচেয়ে পুরোনো খোলা snapshot যতক্ষণ আছে, তার দেখার মতো পুরোনো version মোছা যায় না। PostgreSQL-এ মৃত সারি জমে টেবিল আর index ফোলে, VACUUM কাজ করতে পারে না; InnoDB-তে undo-র ইতিহাস লম্বা হয় আর পুরোনো snapshot-এর পড়া ধীর হয়। একটি ভুলে-যাওয়া “idle in transaction” session — যে BEGIN করে আর কিছু করছে না — ঘণ্টার পর ঘণ্টা এটি ঘটাতে পারে।
  • বেশি abort: যত বেশি সময় খোলা, তত বেশি সংঘাত — snapshot-এ abort, SSI-তে false positive.

প্রতিকার: সব প্রস্তুতি (validation, দাম হিসাব, বাইরের call) transaction-এর বাইরে, তারপর ছোট একটি transaction-এ শুধু পড়া-যাচাই-লেখা। বাইরের কাজ commit-এর পরে, নির্ভরযোগ্যভাবে — একই transaction-এ একটি outbox সারি লিখে পরে পাঠানো (পর্ব ২৫)। আর database-কে সীমা দিন: PostgreSQL-এ statement_timeout, lock_timeout আর idle_in_transaction_session_timeout-এর মতো সেটিং আছে (নাম আর ডিফল্ট সংস্করণভেদে দেখে নিন)।

Retry — পুরো transaction, সীমিত বার, idempotent ভাবে

Snapshot isolation, SSI বা deadlock — সবগুলোই শেষে অ্যাপকে একটি ত্রুটি দেয়: “আবার চেষ্টা করুন”। PostgreSQL-এ serialization failure-এর SQLSTATE 40001, deadlock 40P01; MySQL-এ deadlock 1213. সঠিক retry-র তিনটি শর্ত:

  1. পুরো transaction আবার, শুরু থেকে। শুধু ব্যর্থ বাক্যটি নয় — কারণ আগের পড়াগুলোই পুরোনো হয়ে গেছে।
  2. সীমিত বার, অপেক্ষা বাড়িয়ে। ৩ থেকে ৫ বার, exponential backoff আর jitter সহ, নইলে ব্যস্ত সারিতে retry নিজেই ভিড় বাড়ায় (পর্ব ১৮, পর্ব ৪৮)।
  3. বাইরের প্রভাব idempotent. ভেতরে SMS বা গেটওয়ে-call থাকলে retry তা দুবার করবে; commit-এর মাঝে connection কাটলে ফল অজানা — UNIQUE কলামে একটি idempotency key দ্বিতীয় চেষ্টাকে চেনায় (পর্ব ৩৪; আর এই পর্বের ভাবনার প্রশ্ন ৫)।
for attempt in 1..4:
    try:
        BEGIN ISOLATION LEVEL SERIALIZABLE
        ... read, check, write (no network calls in here) ...
        INSERT INTO outbox (...)          -- SMS / webhook sent later, once
        COMMIT
        return OK
    except error with SQLSTATE 40001 or 40P01:
        ROLLBACK
        sleep(random(0, 20ms * 2^attempt))   -- backoff with jitter
    except connection lost during COMMIT:
        look up the idempotency key: did it commit? never guess
return "busy, please try again"

৮ · কোন কাজে কোন level — payment, analytics, counter আর booking

সব কাজ একই level-এ চালানোর দরকার নেই। প্রতিটি কাজের প্রশ্ন আলাদা: কোন anomaly এখানে ক্ষতি করে, আর তার দাম কে দেবে? সারণিটি শুরুর বিন্দু, চূড়ান্ত উত্তর নয়।

কাজযে anomaly ক্ষতি করেযুক্তিসঙ্গত পছন্দ
ওয়ালেট থেকে টাকা পাঠানো, পেমেন্টLost update, write skew (একাধিক হিসাবের সীমা)শর্তসহ atomic update বা FOR UPDATE, প্রতিটি নড়াচড়া একটি ledger সারি, idempotency key; নিয়ম অনেক সারি জুড়ে হলে SERIALIZABLE + retry
সিট, রুম বা slot bookingPhantom-ঘটিত double bookingUNIQUE constraint বা আগে থেকে বানানো সিটের সারি; সময়সীমাসহ “hold”; বাকিটা পর্ব ৬০-এ
মজুত (inventory)Lost update, ঋণাত্মক মজুতSET stock = stock − n WHERE stock >= n আর CHECK; READ COMMITTED যথেষ্ট
View বা like counterLost update, আর একটি সারিতে contentionAtomic increment; খুব ব্যস্ত হলে sharded counter, বা memory-তে জমিয়ে প্রতি সেকেন্ডে একবার লেখা
রিপোর্ট, export, analyticsRead skew — দুই মুহূর্তের মিশ্র হিসাবRead-only snapshot transaction, সম্ভব হলে replica-তে (lag মনে রেখে, পর্ব ২২)
প্রোফাইল বা অ্যাডমিন সেটিং এডিটLost update (দুজন একই ফর্ম)Version কলাম, API-তে ETag আর If-Match (পর্ব ৮)
নতুন অ্যাকাউন্টে email বা usernamePhantom — দুজনের একই নামUNIQUE constraint, ত্রুটিকে “নামটি নেওয়া হয়ে গেছে”-তে অনুবাদ
Background job queueদুই worker একই কাজ নেওয়াFOR UPDATE SKIP LOCKED বা শর্তসহ status update (পর্ব ৩৭)

মুদি দোকানের খাতার কথা ভাবুন। বাকি লেখার সময় খাতা দোকানির হাতে — একজন লেখেন, আরেকজন অপেক্ষা করেন (lock). মাস শেষে মোট বাকি যোগ করতে ছেলে খাতার ফটোকপি নিয়ে বসে, যাতে দোকান চলে আর যোগফলও এক মুহূর্তের হয় (snapshot). আর তাকের প্রতিটি বয়ামে লেবেল — “চিনি: ১ কেজির বেশি একজনকে নয়” — হলো constraint. একই দোকানে তিন রকম কৌশল, কাজ অনুযায়ী।

একটি database ছাড়িয়ে গেলে

এই পর্বের সব নিশ্চয়তা একটি database-এর ভেতরের। ডেটা কয়েকটি shard-এ ভাগ হলে (পর্ব ২৩), বা টাকা এক সার্ভিসে আর টিকিট আরেকটিতে থাকলে, একটি BEGIN … COMMIT দুই জায়গা ঢাকে না — তখন two-phase commit, saga বা outbox (পর্ব ২৫)। কপিগুলোর মাঝে “সবাই সর্বশেষ মান দেখবে কি” প্রশ্নটি consistency model-এর (পর্ব ৪০)। Database-এর বাইরে একটি কাজ একবারই চালাতে distributed lock আর fencing token (পর্ব ৪৩)। আর পুরো টিকিট বুকিং আর পেমেন্টের নকশা পর্ব ৬০-এ।

আরও গভীরে: Atul Adya-র ১৯৯৯ সালের MIT গবেষণা isolation level-গুলোকে lock-এর ভাষা ছাড়াই, শুধু transaction-গুলোর নির্ভরতার গ্রাফ দিয়ে সংজ্ঞায়িত করে — আধুনিক আলোচনার ভিত্তি এটি। Martin Kleppmann-এর Hermitage প্রকল্প (২০১৪ থেকে) নানা database-এর প্রতিটি level-এ কোন anomaly আসলে ঘটে, তা পরীক্ষা করে প্রকাশ করেছে; Jepsen-এর প্রকাশিত বিশ্লেষণগুলোও একাধিক database-এ দাবি আর বাস্তবের ফারাক দেখিয়েছে। আর SSI-এর PostgreSQL বাস্তবায়নের অভিজ্ঞতা Dan Ports ও Kevin Grittner-এর ২০১২ সালের VLDB প্রবন্ধে বর্ণিত।

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা system design ইন্টারভিউর পরীক্ষায় এই ধরনের প্রশ্ন আসতে পারে:

  • ACID-এর C আর CAP-এর C কি এক? না। ACID-এর C = অ্যাপের নিয়ম ও constraint রক্ষা; CAP-এর C = linearizability, অর্থাৎ সব কপি থেকে সর্বশেষ মান।
  • Snapshot isolation কোন anomaly ঠেকায় না? Write skew (phantom-ঘটিত write skew সহ), কারণ সে শুধু একই সারিতে দুজনের লেখা দেখে।
  • Lost update ঠেকানোর তিনটি উপায়? শর্তসহ atomic update, SELECT … FOR UPDATE, version কলাম (বা snapshot isolation-এর স্বয়ংক্রিয় abort).
  • একটি সারিতে প্রতিটি transaction lock ধরে ৫ ms — ওই সারিতে সর্বোচ্চ কত transaction/সেকেন্ড? ১ ÷ ০.০০৫ = ২০০।

৯ · AI-এর যুগে transaction-এর নকশা যাচাই

“সিটে double booking হচ্ছে, কী করব?” — AI প্রায়ই বলে “Redis-এ distributed lock, Kafka-তে queue, microservice-এ ভাগ।” অথচ উত্তর প্রায়ই একটি লাইন: UNIQUE (trip_id, seat_no). আবার AI-এর কোড প্রায়ই BEGIN … COMMIT-এর ভেতরে SELECT করে, অ্যাপে হিসাব করে, তারপর UPDATE — READ COMMITTED-এ অংশ ২-এর lost update. সাধারণ ভুলগুলো:

AI-এর ভুলকেন ভুলকীভাবে ধরবেন
“Transaction-এ মুড়ে দিলেই নিরাপদ”ডিফল্ট READ COMMITTED-এ পড়ে-হিসাব-করে-লেখা lost update দেয়দুটি session-এ নিজে চালান, বা অংশ ৫-এর simulator
“REPEATABLE READ phantom আর write skew ঠেকায়”Database-ভেদে আলাদা; snapshot-ভিত্তিক RR write skew ঠেকায় নানিজের সংস্করণের documentation আর দুই session-এর পরীক্ষা
ACID আর CAP-এর consistency গুলিয়ে ফেলাএকটি অ্যাপের নিয়ম, অন্যটি কপিগুলোর সর্বশেষ মান“কোন consistency?” জিজ্ঞেস করুন; পর্ব ৪০
Retry শুধু ব্যর্থ বাক্যের, বা সীমাহীনপুরোনো পড়া থেকে যায়; ব্যস্ত সারিতে ভিড় বাড়ে; বাইরের call দুবারপুরো transaction, সীমিত বার, backoff, idempotency key
Default আর error code বানিয়ে বলাসংস্করণভেদে আলাদা; ভুল code ধরলে retry কখনো চলে নাঅফিসিয়াল documentation; ইচ্ছে করে সংঘাত ঘটিয়ে আসল ত্রুটি দেখা
Database-ই যা পারে তার জন্য distributed lockবাড়তি সিস্টেম, বাড়তি ব্যর্থতা; fencing ছাড়া lock নিজেই অনিরাপদ (পর্ব ৪৩)আগে জিজ্ঞেস করুন: constraint বা atomic update-এ কি হয়?

AI-কে database, সংস্করণ, schema আর নিয়ম বলে দিন, আর প্রতিটি দাবির পরীক্ষা চেয়ে নিন:

তুমি একজন অভিজ্ঞ backend ইঞ্জিনিয়ার। আমাদের database PostgreSQL (সংস্করণ লিখে দিচ্ছি), ডিফল্ট READ COMMITTED. টেবিল: wallets(id PK, balance CHECK (balance >= 0)), ledger(id PK, wallet_id, amount, request_id UNIQUE). নিয়ম: ব্যালান্স কখনো ঋণাত্মক নয়, আর একজন দিনে ২৫,০০০ টাকার বেশি পাঠাতে পারবেন না। Peak-এ একই wallet-এ সেকেন্ডে কয়েকটি অনুরোধ আসতে পারে। ১. আমার এখনকার কোড (নিচে দিলাম) কোন interleaving-এ কোন anomaly দেয়? দুটি transaction-এর ধাপ ক্রমসহ দেখাও। ২. সমাধান দাও প্রথমে নতুন কোনো tool ছাড়াই — atomic update, FOR UPDATE, constraint বা isolation level দিয়ে — আর প্রতিটির দাম বলো। ৩. Retry-র নিয়ম লেখো: কোন SQLSTATE-এ, কতবার, পুরো transaction কি না, আর commit-এর মাঝে connection কাটলে কী হবে। ৪. প্রতিটি দাবির জন্য দুটি psql session-এ চালানোর মতো একটি পরীক্ষা দাও, যাতে আমি নিজে যাচাই করতে পারি। ৫. কোনো default বা error code বললে documentation-এর কোন অংশে আছে বলো; নিশ্চিত না হলে "জানি না" বলো।

AI-এর transaction-নকশা হাতে পেলে — যাচাইয়ের চেকলিস্ট

AI দেয় দ্রুত খসড়া, যাচাইয়ের দায় আপনার।
খারাপ interleaving হাতে লিখুন, দুই session-এ চালিয়ে দেখুন, আর আগে constraint-এর কথা ভাবুন।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। নাম মুখস্থ নয়, কোন interleaving-এ কী ভাঙে, সেটি চোখে দেখতে পারাই লক্ষ্য।

প্র ০১SQL standard তো চারটি isolation level সংজ্ঞায়িত করেছিল — তাহলে ১৯৯৫ সালে Gray-সহ কয়েকজন বিশেষজ্ঞকে কেন একটি “সমালোচনা” লিখতে হলো, আর আজকের নকশাকারের জন্য তার শিক্ষা কী?

SQL-92 level-গুলোকে সংজ্ঞায়িত করেছিল তিনটি “ঘটনা” দিয়ে: কোন level-এ dirty read, non-repeatable read আর phantom ঘটতে পারবে না। ধারণাটি সহজ ছিল, কিন্তু দুটি গভীর সমস্যা ছিল।

  • ভাষার অস্পষ্টতা: ঘটনাগুলো লেখা হয়েছিল ইংরেজি বাক্যে। Berenson ও সহকর্মীরা দেখান, বাক্যগুলোর একটি “সরু” ব্যাখ্যা আর একটি “চওড়া” ব্যাখ্যা হয়। সরু ব্যাখ্যায় কিছু স্পষ্টত ভুল schedule-ও SERIALIZABLE-এর পরীক্ষা পাস করে যায়।
  • যে anomaly-গুলো বাদ পড়েছে: dirty write, lost update, read skew আর write skew — এই পর্বের অর্ধেক তালিকা — standard-এর ঘটনায় ছিলই না। অথচ এগুলোই production-এ টাকা হারায়।

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

নকশাকারের জন্য শিক্ষা তিনটি। এক, “আমরা REPEATABLE READ-এ আছি” কোনো উত্তর নয়; জিজ্ঞেস করুন “কোন database-এর, কোন সংস্করণের REPEATABLE READ, আর সেখানে write skew হয় কি?” দুই, যে invariant আপনার ব্যবসার জন্য জরুরি, তার জন্য একটি নির্দিষ্ট প্যাটার্ন বা constraint বেছে নিন, level-এর নামের ভরসায় না থেকে। তিন, দুই session-এর ছোট পরীক্ষা লিখে রাখুন, যাতে database বদলালে বা সংস্করণ বাড়ালে আবার চালানো যায়।

মূল উপলব্ধি: নামগুলো ইতিহাসের দুর্ঘটনা, প্রতিশ্রুতি নয়। Isolation-কে ভাবুন “কোন anomaly ঘটতে পারে” দিয়ে, আর আপনার database-এর আসল আচরণ নিজে মেপে নিন।

প্র ০২SERIALIZABLE-ই যদি সবচেয়ে নিরাপদ, তাহলে বেশিরভাগ জনপ্রিয় database-এর ডিফল্ট কেন READ COMMITTED বা REPEATABLE READ? কিছু নতুন database কেন উল্টো পথে হেঁটেছে?

কারণটি মূলত ঐতিহাসিক আর অর্থনৈতিক। ১৯৮০ আর ’৯০-এর দশকে SERIALIZABLE মানে ছিল lock-ভিত্তিক 2PL, predicate বা range lock-সহ। একটি দীর্ঘ রিপোর্ট মানেই হাজারো সারিতে shared lock, আর তার পেছনে সব লেখা দাঁড়িয়ে। তাই database নির্মাতারা ডিফল্ট রাখলেন দুর্বল কিন্তু দ্রুত level, আর দায়িত্ব দিলেন প্রোগ্রামারকে — কোথায় FOR UPDATE বা constraint লাগবে, তিনি ঠিক করবেন।

আজও SERIALIZABLE-এর দাম আছে, যদিও রূপ বদলেছে:

  • Lock-ভিত্তিক হলে: অপেক্ষা, deadlock আর পড়া-লেখার পারস্পরিক বাধা।
  • SSI হলে: অপেক্ষা নেই, কিন্তু পড়ার হিসাব রাখার খরচ আর মাঝে মাঝে নির্দোষ transaction-এর abort. ব্যস্ত সারিতে abort বাড়লে retry-তে কাজ নষ্ট।
  • অ্যাপের দাম: প্রতিটি transaction-এর চারপাশে retry loop, আর retry-নিরাপদ বাইরের প্রভাব। পুরোনো কোডবেসে এটি কঠিন।

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

বাস্তবের মাঝামাঝি পথটি তাই প্রায়ই এমন: ডিফল্ট READ COMMITTED, বেশিরভাগ কাজ atomic update আর constraint-এ। আর যে কয়েকটি transaction-এর নিয়ম অনেক সারি জুড়ে — যেমন একাধিক হিসাবের মোট সীমা — শুধু সেগুলোতে SERIALIZABLE আর retry. Level transaction-প্রতি বেছে নেওয়া যায়, পুরো database-এর জন্য একটিই হতে হবে না।

মূল উপলব্ধি: ডিফল্ট level একটি পুরোনো দামের হিসাব, আপনার ব্যবসার নয়। কোথায় ভুলের দাম বেশি, সেখানে কড়া level বা প্যাটার্ন; বাকিখানে গতি।

প্র ০৩“আমাদের database তো strongly consistent” — ACID-এর consistency, isolation-এর serializability আর CAP-এর linearizability কি একই কথার তিন নাম?

না — তিনটি আলাদা প্রশ্নের উত্তর, আর গুলিয়ে ফেললে নকশায় ফাঁক থাকে।

  • ACID-এর consistency: প্রতিটি transaction ডেটাকে একটি বৈধ অবস্থা থেকে আরেকটি বৈধ অবস্থায় নেয় — অ্যাপের invariant আর constraint রক্ষা। এটি মূলত আপনার যুক্তির গুণ; database constraint দিয়ে সাহায্য করে, আর atomicity ও isolation সেই যুক্তিকে ব্যর্থতা ও ভিড়ে টিকিয়ে রাখে।
  • Serializability (isolation): একাধিক সারি ছোঁয়া অনেকগুলো transaction একসাথে চললেও ফল কোনো এক serial ক্রমের সমান। কোন ক্রম, তা বলা নেই — ক্রমটি বাস্তব সময়ের সাথে না মিললেও চলে।
  • Linearizability (CAP-এর C): একটি একক বস্তুর (একটি key বা register) ওপর প্রতিটি কাজ যেন একটি মুহূর্তে ঘটে, আর সেই মুহূর্ত বাস্তব সময়ের ক্রম মানে। একটি লেখা শেষ হলে পরের প্রতিটি পড়া সেটি দেখবে, যে কপি থেকেই পড়ুক।

দুটি উদাহরণ। একটি একক PostgreSQL সার্ভারে SERIALIZABLE চালালেন, কিন্তু রিপোর্ট পড়ছে একটি asynchronous replica থেকে — সেখানে কয়েক সেকেন্ড পুরোনো ডেটা আসতে পারে (পর্ব ২২)। Isolation পুরো, linearizability নেই। উল্টো দিকে, একটি linearizable key-value store-এ প্রতিটি key আলাদাভাবে নিখুঁত, কিন্তু দুটি key একসাথে বদলানোর কোনো transaction নেই। তাই “দুই হিসাবের যোগফল ঠিক থাকবে” — এমন নিয়ম রক্ষা করা যায় না।

দুটি একসাথে চাইলে তার নাম strict serializability: serializable, আর ক্রমটি বাস্তব সময়ও মানে। Google-এর Spanner প্রবন্ধে (২০১২) একে external consistency বলা হয়েছে, আর ঘড়ির অনিশ্চয়তা সীমিত রেখে এটি দেওয়া হয়। পর্ব ৪০-এ consistency model-এর পুরো সিঁড়ি দেখবেন।

মূল উপলব্ধি: কেউ “consistent” বললে জিজ্ঞেস করুন — কোন অর্থে? অ্যাপের নিয়ম, transaction-এর ক্রম, না কপিগুলোর সর্বশেষ মান? প্রতিটির দাম আর যন্ত্র আলাদা।

প্র ০৪একটি transaction কিছুই লিখছে না, শুধু খোলা পড়ে আছে — তবু সেটি কীভাবে পুরো database-কে ধীর করে দিতে পারে?

MVCC-র মূল সুবিধাটিই এখানে দাম হয়ে ফেরে। প্রতিটি লেখা একটি নতুন version বানায়, আর পুরোনো version রাখা হয় যতক্ষণ কোনো খোলা snapshot তা দেখতে পারে। সবচেয়ে পুরোনো খোলা transaction তাই একটি সীমারেখা টানে — তার পরের সব পুরোনো version-কে বাঁচিয়ে রাখতে হয়।

ধরুন রাত ১০টায় একটি analytics script BEGIN করে একটি কোয়েরি চালাল, তারপর bug-এ আটকে গেল — transaction খোলা। পরের ছয় ঘণ্টায়:

  • PostgreSQL-এ: প্রতিটি UPDATE-এর পুরোনো সারি “মৃত” হয়, কিন্তু VACUUM সেগুলো সরাতে পারে না। ব্যস্ত টেবিল আর তার index কয়েক গুণ ফুলে ওঠে, cache-এ কম ধরে, কোয়েরি ধীর হয়। পরে transaction বন্ধ হলেও ফোলা জায়গা সবসময় আপনাআপনি ছোট হয় না।
  • InnoDB-তে: undo log-এর ইতিহাস লম্বা হয় (history list length বাড়ে), purge পিছিয়ে পড়ে, আর পুরোনো snapshot-এর প্রতিটি পড়াকে লম্বা version-শিকল বেয়ে পেছনে যেতে হয়।
  • Lock থাকলে: সেই transaction যদি একটি FOR UPDATE বা একটি লেখাও করে থাকে, তার lock-এর পেছনে অন্যরা দাঁড়ায়; অপেক্ষারত অনুরোধ connection pool দখল করে, আর অ্যাপ সার্ভার একে একে “ধীর” হয়ে পড়ে।

PostgreSQL-এর একটি বাড়তি ঝুঁকিও আছে: transaction ID-গুলো ৩২ বিটের, ঘুরে আসে। পুরোনো সারি সময়মতো “freeze” না হলে wraparound এড়াতে database শেষে নতুন লেখা বন্ধ করে দিতে পারে — একাধিক প্রকাশিত postmortem-এ এমন ঘটনার কথা আছে।

প্রতিকার: transaction ছোট রাখা; idle-in-transaction session-এর জন্য timeout; সবচেয়ে পুরোনো খোলা transaction-এর বয়স, মৃত সারির সংখ্যা আর undo-র দৈর্ঘ্য dashboard-এ রাখা (পর্ব ৫০); আর দীর্ঘ analytics কোয়েরি replica বা আলাদা warehouse-এ — যদিও কিছু সেটিংয়ে replica-র পুরোনো snapshot-ও primary-র cleanup আটকায়।

মূল উপলব্ধি: MVCC-তে “শুধু পড়ছি” মানেও কিছু ধরে রাখা — সময়। Transaction-এর বয়স একটি গুরুত্বপূর্ণ metric, আর সীমাহীন খোলা transaction একটি লুকানো bug.

প্র ০৫Serialization failure বা deadlock-এ পুরো transaction আবার চালালেই কি সমস্যা শেষ? COMMIT পাঠানোর পর উত্তর আসার আগেই connection কেটে গেলে কী করবেন?

Database-এর ভেতরের দিক থেকে retry পরিষ্কার: abort হওয়া transaction-এর কোনো লেখা টেকেনি (atomicity), তাই শুরু থেকে আবার চালানো নিরাপদ — যদি transaction-এর সব কাজ database-এর ভেতরেই থাকে। সমস্যা শুরু হয় তিন জায়গায়।

  • বাইরের প্রভাব: transaction-এর মাঝে SMS গেলে, গেটওয়ে টাকা কাটলে বা অন্য সার্ভিসে বার্তা গেলে rollback সেগুলো ফেরায় না, আর retry আবার পাঠায়। সমাধান: বাইরের কাজ commit-এর পরে, আর নির্ভরযোগ্যভাবে একবার — একই transaction-এ একটি outbox সারি লিখে পরে আলাদা worker দিয়ে পাঠানো (পর্ব ২৫), আর গ্রাহক-সার্ভিসকে idempotency key দেওয়া (পর্ব ৩৪)।
  • অনিশ্চিত commit: COMMIT পাঠানো হলো, তারপর network বা সার্ভার গেল। Database হয়তো commit করেছে, হয়তো করেনি — অ্যাপ জানে না। অন্ধ retry মানে দ্বিগুণ উত্তোলনের ঝুঁকি, retry না করা মানে হারানো অর্ডারের — distributed system-এর মৌলিক অনিশ্চয়তা (two generals সমস্যার আত্মীয়, পর্ব ৩৮)। সমাধান: প্রতিটি ব্যবসায়িক কাজের একটি অনন্য idempotency key, যা transaction-এর ভেতরে UNIQUE কলামে লেখা হয়। Retry-র আগে key দিয়ে খুঁজুন — থাকলে আগের ফলই ফেরত দিন।
  • Retry নিজেই চাপ: একটি ব্যস্ত সারিতে সংঘাত বেশি, তাই abort বেশি, তাই retry বেশি — আর retry আরও সংঘাত আনে। সীমাহীন retry পর্ব ১৮-এর retry storm. সীমিত চেষ্টা, exponential backoff আর jitter, আর বারবার ব্যর্থ হলে নকশা বদল — atomic update বা sharded counter.

একটি সূক্ষ্ম বিষয়ও আছে: retry-তে transaction আবার পড়ে, তাই সিদ্ধান্ত বদলাতে পারে। প্রথম চেষ্টায় যে উত্তোলন অনুমোদিত হতো, দ্বিতীয়বারে তা “অপর্যাপ্ত ব্যালান্স” হতে পারে। এটিই সঠিক আচরণ, কিন্তু UI আর API-কে তা জানাতে হবে।

মূল উপলব্ধি: Retry নিরাপদ হয় তখনই, যখন transaction-এর ভেতরে শুধু database-এর কাজ, আর প্রতিটি ব্যবসায়িক কাজের একটি পরিচয় (idempotency key) আছে। অনিশ্চিত commit-এ কখনো অনুমান নয় — খুঁজে দেখুন।

অনুশীলন

প্রতিটি interleaving কাগজে দুই কলামে (T1, T2) লিখে নিন — anomaly চোখে পড়বে। শেষ প্রশ্নে আগে অনুমান, তারপর simulator চালিয়ে মেলান।

  1. Anomaly চিনুন: প্রতিটি ঘটনার anomaly-র নাম বলুন, আর কোন সবচেয়ে নিচের isolation level বা কোন প্যাটার্ন এটি ঠেকায়। (ক) T1 একটি হিসাবে ৫০০ টাকা জমা করল কিন্তু commit করেনি; T2 সেই হিসাবের ব্যালান্স পড়ে গ্রাহককে SMS-এ জানাল; তারপর T1 rollback হলো। (খ) গ্রাহকের সঞ্চয় ১,০০০ আর ওয়ালেট ১,০০০। Audit T2 সঞ্চয় পড়ল ১,০০০; T1 সঞ্চয় থেকে ওয়ালেটে ৫০০ সরিয়ে commit করল; T2 ওয়ালেট পড়ল ১,৫০০। (গ) দুজন অ্যাডমিন একই পণ্যের এডিট ফর্ম খুললেন; একজন দাম বদলে save করলেন, অন্যজন বিবরণ বদলে save করলেন — ফর্ম পুরো সারিটি লেখে। (ঘ) দুজন একসাথে খুঁজলেন “রুম ৩-এ সকাল ১০টা–১১টায় কোনো বুকিং আছে?”, দুজনেই শূন্য পেয়ে নতুন সারি insert করলেন।
    • (ক) Dirty read. T2 এমন মান পড়ল, যা কখনো commit হয়নি; গ্রাহক ভুল SMS পেলেন। READ COMMITTED-ই যথেষ্ট, যা প্রায় সব database-এর ডিফল্ট। সাথে শিক্ষা: SMS-এর মতো বাইরের কাজ commit-এর পরে।
    • (খ) Read skew (non-repeatable read-এর বহু-সারির রূপ)। T2 দেখল মোট ২,৫০০, যা কখনো ছিল না। READ COMMITTED ঠেকায় না; REPEATABLE READ বা snapshot ঠেকায় — দুটি পড়াই একই snapshot থেকে, মোট ২,০০০।
    • (গ) Lost update. দুজনই পুরোনো সারি পড়েছেন, দ্বিতীয় save প্রথমজনের দাম মুছে দিল। প্রতিকার: version কলাম (ফর্মে version রেখে WHERE version = ?, ০ সারি হলে “অন্য কেউ বদলেছেন, আবার খুলুন”), API-তে ETag আর If-Match (পর্ব ৮)। ওয়েব ফর্মে পড়া আর লেখা দুটি আলাদা transaction, তাই কোনো isolation level এটি দেখে না — version কলামই আসল উত্তর।
    • (ঘ) Phantom-ঘটিত write skew (double booking). কোনো সারি ছিল না, তাই সারির lock বা snapshot কাজে আসে না। SERIALIZABLE (predicate/range lock বা SSI) ঠেকায়; সহজ নকশা: আগে থেকে বানানো slot-এর সারি (room_id, slot) আর atomic update, বা UNIQUE (room_id, slot_start) যদি slot নির্দিষ্ট মাপের হয়; বিভিন্ন দৈর্ঘ্যের সময়ের জন্য PostgreSQL-এর exclusion constraint.
  2. Level আর প্যাটার্ন বেছে নিন: তিনটি কাজের জন্য isolation level আর প্যাটার্ন প্রস্তাব করুন, কারণসহ। (ক) একটি কাল্পনিক mobile wallet-এ একজন থেকে আরেকজনে টাকা পাঠানো, দিনে সর্বোচ্চ ২৫,০০০ টাকার নিয়মসহ। (খ) প্রতি রাতে বিক্রির একটি রিপোর্ট, যা ২০ মিনিট ধরে কয়েকটি বড় টেবিল পড়ে আর মোট মিলিয়ে দেখে। (গ) একটি জনপ্রিয় ভিডিওর view counter: peak-এ সেকেন্ডে ২,০০০টি বৃদ্ধি; প্রতিটি atomic increment একটি সারির lock প্রায় ২ ms ধরে রাখে (commit সহ)। একটি সারি কি যথেষ্ট? না হলে কয়টি ভাগ লাগবে, যদি প্রতিটি ভাগের ব্যস্ততা ৫০%-এর মধ্যে রাখতে চান?
    • (ক) টাকা পাঠানো: READ COMMITTED-এ এক transaction: প্রেরকের সারি SELECT … FOR UPDATE (দৈনিক সীমা আর ব্যালান্স একসাথে দেখতে), অথবা শর্তসহ atomic update — WHERE balance >= amount AND daily_sent + amount <= 25000. প্রাপকের ব্যালান্স balance = balance + amount. দুজনের সারি সবসময় ছোট id আগে lock (deadlock প্রতিরোধ)। একই transaction-এ দুটি ledger সারি আর UNIQUE idempotency key. দৈনিক সীমা যদি একাধিক wallet জুড়ে হয় (একজনের কয়েকটি wallet), তাহলে সেই transaction-গুলো SERIALIZABLE + retry, বা একটি “ব্যক্তির দৈনিক হিসাব” সারিতে সংঘাত আনা।
    • (খ) রাতের রিপোর্ট: একটি read-only REPEATABLE READ বা snapshot transaction — সব টেবিল একই মুহূর্তের, তাই read skew নেই, আর MVCC-তে লেখাগুলোও থামে না। সম্ভব হলে replica-তে (primary-র ভার কমে), তবে replica কয়েক সেকেন্ড পিছিয়ে থাকতে পারে, আর ২০ মিনিটের snapshot পুরোনো version আটকে রাখে (প্র ০৪)। রিপোর্ট কিছু লেখে না, তাই SERIALIZABLE লাগবে না।
    • (গ) View counter — হিসাব: একটি সারির সর্বোচ্চ = ১ ÷ ০.০০২ = ৫০০টি বৃদ্ধি প্রতি সেকেন্ডে, অথচ দরকার ২,০০০ — একটি সারি চলবে না। কমপক্ষে ২,০০০ ÷ ৫০০ = ৪টি ভাগ, কিন্তু তখন প্রতিটি ১০০% ব্যস্ত, অপেক্ষা বিস্ফোরিত (পর্ব ৪)। ৫০% ব্যস্ততায় প্রতিটি ভাগ সর্বোচ্চ ২৫০/সেকেন্ড নিতে পারে, তাই ২,০০০ ÷ ২৫০ = ৮টি ভাগ: video_views(video_id, shard 0–7, n), প্রতিটি বৃদ্ধি একটি এলোমেলো shard-এ, আর পড়ার সময় ৮টি সারির SUM. READ COMMITTED আর atomic increment যথেষ্ট। বিকল্প: Redis-এর মতো in-memory counter-এ জমিয়ে প্রতি সেকেন্ডে একবার লেখা — কয়েক সেকেন্ড পুরোনো সংখ্যা এখানে চলে।
  3. SQL লিখুন: wallets(id, balance CHECK (balance >= 0)) আর ledger(id, wallet_id, amount, request_id UNIQUE) টেবিলে একটি উত্তোলনের transaction লিখুন, যা একসাথে চললেও কখনো ব্যালান্স ছাড়িয়ে টাকা দেবে না, আর ব্যালান্স কমলে তবেই ledger-এ একটি সারি লিখবে। ১,০০০ টাকার wallet-এ আগে ৭০০ (request req-a1), তারপর ৬০০ (request req-b1) চালিয়ে দেখান।

    শর্তটি WHERE-এ রাখুন, আর ledger-এর সারি লিখুন শুধু তখন, যখন UPDATE সত্যিই একটি সারি বদলেছে। SQLite-এ changes() আগের বাক্যে বদলানো সারির সংখ্যা দেয়:

    CREATE TABLE wallets (id INTEGER PRIMARY KEY,
                          balance INTEGER NOT NULL CHECK (balance >= 0));
    CREATE TABLE ledger (id INTEGER PRIMARY KEY, wallet_id INTEGER NOT NULL,
                         amount INTEGER NOT NULL, request_id TEXT NOT NULL UNIQUE);
    INSERT INTO wallets VALUES (42, 1000);
    BEGIN;                                   -- withdrawal 700, request req-a1
    UPDATE wallets SET balance = balance - 700 WHERE id = 42 AND balance >= 700;
    INSERT INTO ledger (wallet_id, amount, request_id)
      SELECT 42, -700, 'req-a1' WHERE changes() = 1;
    COMMIT;
    BEGIN;                                   -- withdrawal 600, request req-b1
    UPDATE wallets SET balance = balance - 600 WHERE id = 42 AND balance >= 600;
    INSERT INTO ledger (wallet_id, amount, request_id)
      SELECT 42, -600, 'req-b1' WHERE changes() = 1;
    COMMIT;
    SELECT balance, (SELECT COUNT(*) FROM ledger) AS ledger_rows,
           (SELECT SUM(amount) FROM ledger) AS ledger_sum FROM wallets WHERE id = 42;

    প্রথম transaction-এ UPDATE আর INSERT দুটিতেই ১টি সারি বদলেছে. দ্বিতীয়টিতে UPDATE-এ ০টি সারি বদলেছে (৩০০ < ৬০০), তাই INSERT-এও ০টি সারি বদলেছে. শেষ ফল 300 | 1 | -700 — ব্যালান্স আর ledger মিলে যায় (১,০০০ − ৭০০ = ৩০০)। বাস্তব অ্যাপে ০ সারি দেখে ROLLBACK করে “অপর্যাপ্ত ব্যালান্স” ফেরত দেবেন। PostgreSQL-এ একই কাজ এক বাক্যে: WITH w AS (UPDATE … RETURNING id) INSERT INTO ledger … SELECT … FROM w. আর request_id UNIQUE থাকায় একই অনুরোধের retry ledger-এ দ্বিতীয় সারি লিখতে গিয়ে ত্রুটি পায় — অ্যাপ তখন পুরো transaction rollback করে (দ্বিতীয়বার কাটা টাকাও ফেরে) আর প্রথম ফলটি ফেরত দেয়। অনেক দল ledger-এর INSERT আগে আর শর্তসহ UPDATE পরে রাখে, যাতে duplicate ধরা পড়ে ব্যালান্সে হাত দেওয়ার আগেই; UPDATE ০ সারি দিলে তখনও ROLLBACK.

  4. আউটপুট অনুমান করুন: অংশ ৫-এর isolation_sim.js-এ SCHED বদলে ["1r", "1w", "2r", "1c", "2w", "2c"] করুন — T2 এখন পড়ে T1-এর লেখার পরে কিন্তু commit-এর আগে। তিনটি ঘটনার প্রতিটির চারটি mode-এ শেষ অবস্থা, OK/BROKEN আর নোট কী হবে? কোথাও কি “none” read committed-এর চেয়ে ভালো ফল দেবে — আর সেটি কি সত্যিই ভালো? আগে লিখে রাখুন, তারপর চালিয়ে মেলান।
    wallet: lost update   (schedule 1r 1w 2r 1c 2w 2c)
      none               {"bal":300}               OK
      read committed     {"bal":400}               BROKEN
      snapshot           {"bal":300}               OK     T2 aborted: first-committer-wins
      serializable (2PL) {"bal":300}               OK     T2 waits
    
    doctors: write skew   (schedule 1r 1w 2r 1c 2w 2c)
      none               {"alice":0,"bob":1}       OK
      read committed     {"alice":0,"bob":0}       BROKEN
      snapshot           {"alice":0,"bob":0}       BROKEN
      serializable (2PL) {"alice":0,"bob":1}       OK     T2 waits
    
    seat B4: double booking   (schedule 1r 1w 2r 1c 2w 2c)
      none               {"bk1":"B4"}              OK
      read committed     {"bk1":"B4","bk2":"B4"}   BROKEN
      snapshot           {"bk1":"B4","bk2":"B4"}   BROKEN
      serializable (2PL) {"bk1":"B4"}              OK     T2 waits
    • none: তিনবারই OK! T2 T1-এর commit-না-হওয়া লেখা পড়ে (dirty read) — ব্যালান্স ৩০০ দেখে ৬০০ দেয় না, alice ০ দেখে ববের ছুটি দেয় না, B4 বিক্রি দেখে বুক করে না। কিন্তু এটি ভাগ্য: T1 পরে rollback হলে T2 একটি মিথ্যা মানের ওপর সিদ্ধান্ত নিত। Dirty read ঠিক ফল দিয়েছে বলে সেটি নিরাপদ নয়।
    • read committed: তিনবারই BROKEN — T2 পড়ে সর্বশেষ commit হওয়া মান, অর্থাৎ T1-এর আগের অবস্থা, তারপর T1 commit করে, তারপর T2 নিজের পুরোনো সিদ্ধান্ত লেখে।
    • snapshot: ওয়ালেটে T2-এর snapshot নেওয়া হয় T1-এর commit-এর আগে, তাই commit-এর সময় first-committer-wins abort করে (৩০০, OK). ডাক্তার আর সিটে আগের মতোই BROKEN — write set আলাদা।
    • serializable (2PL): T1-এর X lock থাকায় T2-এর পড়া অপেক্ষা করে (T2 waits), T1 commit করলে T2 নতুন মান পড়ে আর ঠিক সিদ্ধান্ত নেয়। এবার কোনো deadlock নেই, কারণ T2 কোনো lock ধরার আগেই অপেক্ষায় পড়েছে।

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

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

ক্রমVersion কলাম (optimistic concurrency) দিয়ে একটি wallet থেকে উত্তোলনের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📖 সারি পড়া: ব্যালান্স আর version (যেমন ১,০০০ আর v3)
  2. 🧮 অ্যাপে যাচাই: ব্যালান্স ≥ উত্তোলনের পরিমাণ কি না
  3. ✍️ UPDATE … WHERE id = 42 AND version = 3 — নতুন ব্যালান্স আর version + 1
  4. 🔎 বদলানো সারির সংখ্যা দেখা: ০ হলে নতুন করে পড়ে সীমিত বার আবার চেষ্টা
  5. ✅ ১ হলে একই transaction-এ ledger সারি লিখে commit

একটি হিসাবে T1 ৫০০ টাকা জমা করেছে কিন্তু এখনো commit করেনি। এর মধ্যে একটি রিপোর্ট সেই হিসাবের নতুন ব্যালান্স পড়ল। তারপর T1 rollback হলো। রিপোর্টটি কোন anomaly-র শিকার?

  • Lost update
  • Dirty read
  • Write skew
  • Phantom

PostgreSQL-এর ডিফল্ট READ COMMITTED-এ দুটি app server একই wallet-এর ব্যালান্স ১,০০০ পড়ল। একজন অ্যাপে হিসাব করে SET balance = 300 লিখে commit করল, তারপর অন্যজন SET balance = 400 লিখে commit করল। শেষে কী হবে?

  • দ্বিতীয় UPDATE ত্রুটি দেবে, ব্যালান্স ৩০০ থাকবে
  • Database নিজে বুঝে ব্যালান্স −৩০০ করবে
  • ব্যালান্স ৪০০ — প্রথম লেখাটি নিঃশব্দে হারাবে
  • দুজনের deadlock হবে

অ্যালিস আর বব দুজনেই on call; নিয়ম হলো অন্তত একজন থাকবেন। Snapshot isolation-এ দুজনের transaction একসাথে গুনল ২, তারপর প্রত্যেকে নিজের সারিতে on_call = 0 লিখল। কোনটি এই সমস্যা ঠেকায়?

  • First-committer-wins নিয়ম
  • READ COMMITTED-এ নামিয়ে আনা
  • Connection pool বড় করা
  • SERIALIZABLE, বা দুজনকেই একটি সাধারণ shift সারিতে লিখতে বাধ্য করা

একটি ফ্ল্যাশ সেলে একটি পণ্যের মজুতের সারিতে প্রতিটি বিক্রির transaction গড়ে ২০ ms ধরে lock রাখে। ওই সারিতে সেকেন্ডে সর্বোচ্চ কতগুলো বিক্রি সম্ভব?

  • ২০টি
  • ৫০টি
  • ২০০টি
  • ১,০০০টি

UPDATE wallets SET balance = 300, version = version + 1 WHERE id = 42 AND version = 7 চালানোর পর ফল এলো “০টি সারি বদলেছে”। id = 42 সারিটি আছে। এর সবচেয়ে সম্ভাব্য অর্থ আর অ্যাপের করণীয় কী?

  • পড়ার পরে কেউ সারিটি বদলেছে; নতুন করে পড়ে আবার সিদ্ধান্ত নিতে হবে
  • Database-এ একটি bug, আবার একই বাক্য চালালেই হবে
  • Deadlock হয়েছে, transaction বাতিল
  • শর্ত থেকে version বাদ দিয়ে আবার চালানো উচিত

একটি অ্যাপ MySQL (InnoDB, REPEATABLE READ) থেকে PostgreSQL (REPEATABLE READ)-এ সরানো হলো। দুটি transaction একই সারি পড়ে একই সারিতে লিখলে আচরণে কোন পার্থক্য সবচেয়ে বেশি চোখে পড়তে পারে?

  • কোনো পার্থক্য নেই, নাম তো একই
  • PostgreSQL-এ dirty read শুরু হবে
  • PostgreSQL পরের লেখককে serialization error (40001) দেবে, যেখানে MySQL সাধারণত অপেক্ষা করে লিখে দিত — তাই এখন retry লাগবে
  • PostgreSQL REPEATABLE READ-এ write skew-ও ঠেকাবে

শব্দকোষ

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

মনে রাখুন

  1. ACID-এর A ব্যর্থতার জন্য, I একসাথে চলার জন্য, D crash-এর জন্য, আর C আপনার invariant — CAP-এর C (linearizability) নয়।
  2. Anomaly: dirty write/read, non-repeatable read ও read skew, lost update, write skew আর phantom. ডিফল্ট READ COMMITTED অ্যাপে হিসাব করা lost update ঠেকায় না; snapshot isolation write skew ঠেকায় না।
  3. Level-এর নাম database-ভেদে আলাদা — PostgreSQL-এর REPEATABLE READ = snapshot isolation, InnoDB-এর RR আলাদা, Oracle-এর “SERIALIZABLE” = snapshot. দুই session-এ নিজে পরীক্ষা করুন।
  4. যন্ত্র: 2PL (S/X lock, deadlock, lock ordering), MVCC/SI (পাঠক-লেখক আলাদা, first-committer-wins), SSI (চক্র দেখে abort). 2PL ≠ 2PC.
  5. প্যাটার্ন: শর্তসহ atomic update, FOR UPDATE, version কলাম, UNIQUE, সংঘাতকে একটি সারিতে আনা। Transaction ছোট (ব্যস্ত সারিতে সীমা 1 ÷ h), retry পুরো transaction-এর, সীমিত বার, idempotency key সহ।

পরের পর্বে: এতক্ষণ সব ঘটেছে একটি database-এর ভেতরে। কিন্তু টাকা যদি এক সার্ভিসে আর টিকিট আরেক সার্ভিসে থাকে? পর্ব ২৫-এ দেখবেন distributed transaction — two-phase commit, তার ব্যর্থতার ধরন, saga-র ক্ষতিপূরণ আর outbox pattern.

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

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