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

Concurrency control — lock, 2PL, deadlock আর timestamp

Concurrency Control — Locks, Two-Phase Locking, Deadlocks, Timestamp Ordering and Optimistic Methods

Taught in Bangla, this lesson covers concurrency control: shared and exclusive locks and the lock manager, two-phase locking and its strict and rigorous variants, lock granularity, deadlocks and the wait-die and wound-wait prevention schemes, timestamp ordering and optimistic concurrency control, and how real systems handle phantoms with SELECT FOR UPDATE, SKIP LOCKED and SQLite's locking.

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

  • Lost update কেন হয়, আর S/X lock, compatibility matrix ও lock table কীভাবে কাজ করে
  • Two-phase locking, lock point আর 2PL যে conflict serializability দেয় তার প্রমাণ; strict ও rigorous 2PL
  • Granularity আর intention lock (IS, IX, SIX)
  • Deadlock: wait-for graph, চক্র খোঁজা, victim, timeout, wait-die আর wound-wait
  • Timestamp ordering, Thomas write rule, optimistic control, FOR UPDATE / SKIP LOCKED আর SQLite-এর lock

১ · সমস্যা — শেষ সিটটি দুজনের হাতে

পর্ব ৪৪-এ দেখেছেন, ACID-এর “I” বা isolation বলে: একসাথে চলা transaction-গুলোর ফল এমন হবে, যেন তারা একে একে চলেছে। পর্ব ৪৫-এ (Schedule আর serializability) শিখেছেন কোন মিশ্র ক্রম বা schedule “ঠিক” — যেটি conflict serializable. কিন্তু DBMS কীভাবে নিশ্চিত করে যে শুধু ঠিক schedule-ই ঘটবে? সেই নিয়মের নাম concurrency control, আর এই পর্ব তার প্রধান পদ্ধতিগুলো নিয়ে।

ঈদের আগে কমলাপুর স্টেশন। সুবর্ণ এক্সপ্রেসের শেষ একটি সিট বাকি। ১ নম্বর আর ২ নম্বর কাউন্টারের দুই কর্মী একই সময়ে কম্পিউটারে দেখলেন “খালি সিট: ১”. দুজনেই টিকিট কাটলেন, দুজনেই লিখলেন “খালি সিট: ০”. খাতায় সংখ্যা ঠিক দেখাচ্ছে, অথচ ট্রেনে উঠবেন দুজন যাত্রী, সিট একটি।

R = পড়া, W = লেখাfree = খালি সিট

            
অবস্থা
free (ডেটাবেসে)
A-র হাতে
B-র হাতে
বিক্রি টিকিট
  1. শুরু। ডেটাবেসে খালি সিট ১টি। দুই কাউন্টারে প্রায় একই মুহূর্তে দুজন যাত্রী।
  2. A পড়ে। কাউন্টার A-র transaction free পড়ে, পায় ১. মানটি এখন A-র নিজের মেমরিতে।
  3. B-ও পড়ে। A এখনো লেখেনি, তাই B-ও পায় ১. দুজনের হাতে একই পুরোনো মান।
  4. A লেখে। A হিসাব করে ১ − ১ = ০, লেখে, টিকিট #1 ছাপে। এ পর্যন্ত সব ঠিক।
  5. B লেখে — হারানো আপডেট। B-ও তার হাতের পুরোনো ১ থেকে হিসাব করে ০ লেখে। A-র বিয়োগটি চাপা পড়ল। সংখ্যা দেখতে ঠিক, কিন্তু টিকিট ২টি — lost update.
  6. Lock দিয়ে। A পড়ার আগেই free-এর ওপর exclusive lock নেয়। B-র lock-অনুরোধ অপেক্ষায় থাকে যতক্ষণ না A COMMIT করে। তারপর B পড়ে ০ — বিক্রি হয় না। শেষ ফল যেন A আর B একে একে চলেছে।

সমস্যার মূলে “পড়ো, হিসাব করো, লেখো” (read-modify-write) ধাঁচ — পড়া আর লেখার মাঝে অন্য কেউ ঢুকে পড়ে, একে বলে race condition. পাতার SQLite-এ একটিই সংযোগ, তাই সত্যিকারের দুই transaction চালানো যায় না; নিচের এডিটরে দুই কর্মীর “মনে রাখা” মানটি একটি ছোট টেবিল seen-এ রেখে একই ঘটনা ঘটানো হয়েছে। train টেবিলে একটি ট্রেন (id, name, free_seats), আর ticket-এ বিক্রি হওয়া টিকিট (id, train_id, clerk).

-- দুই কর্মী একই মুহূর্তে পড়লেন
INSERT INTO seen SELECT 'A', free_seats FROM train WHERE id = 1;
INSERT INTO seen SELECT 'B', free_seats FROM train WHERE id = 1;
-- দুজনেই দেখলেন 1 > 0: টিকিট কাটলেন, (পড়া মান − 1) লিখলেন
INSERT INTO ticket (train_id, clerk) SELECT 1, clerk FROM seen WHERE v > 0;
UPDATE train SET free_seats = (SELECT v - 1 FROM seen WHERE clerk = 'A');
UPDATE train SET free_seats = (SELECT v - 1 FROM seen WHERE clerk = 'B');
SELECT free_seats, (SELECT COUNT(*) FROM ticket) AS tickets FROM train;

-- প্রতিকার: পড়া, যাচাই আর লেখা একটি বাক্যে
UPDATE train SET free_seats = 1; DELETE FROM ticket;
UPDATE train SET free_seats = free_seats - 1 WHERE id = 1 AND free_seats > 0;
UPDATE train SET free_seats = free_seats - 1 WHERE id = 1 AND free_seats > 0;
SELECT free_seats FROM train;

প্রথম অংশের ফল 0 | 2: খালি সিট ০, টিকিট ২টি — ঠিক ওপরের ছবি। দ্বিতীয় অংশে শর্তসহ একটি UPDATE প্রথমবার ১টি সারি বদলেছে, দ্বিতীয়বার ০টি সারি বদলেছে; শেষে free_seats 0. অ্যাপ “০টি সারি” দেখে বোঝে সিট শেষ। কাজটি নিরাপদ, কারণ DBMS একটি UPDATE চলার সময় সেই সারিতে নিজেই exclusive lock রাখে — পড়া আর লেখার মাঝে ফাঁক নেই। কিন্তু সব কাজ এক বাক্যে আঁটে না: transfer-এ দুটি সারি, যাচাইয়ে কয়েকটি টেবিল। তখন DBMS-এর সাধারণ ব্যবস্থাগুলো লাগে।

ব্যবস্থাগুলো তিন পরিবারের। Pessimistic: সংঘাত হবে ধরে নিয়ে আগেই lock (অংশ ২–৭)। Timestamp: প্রতিটি transaction-এর একটি জন্মক্রম, আর সেই ক্রম ভাঙলেই বাতিল। Optimistic: সংঘাত বিরল ধরে নিয়ে নির্বিঘ্নে কাজ, শেষে যাচাই (অংশ ৮)। চতুর্থ পরিবার MVCC — একই ডেটার কয়েকটি সংস্করণ রাখা — পর্ব ৪৭-এর বিষয়।

২ · Lock — shared আর exclusive, আর lock manager

একটি lock হলো কোনো ডেটা-অংশের (সারি, page বা টেবিল) ওপর একটি transaction-এর সাময়িক অধিকার। মৌলিক দুই রকম:

Shared (S)

Shared lock: পড়ার অধিকার। একাধিক transaction একই ডেটায় একসাথে S রাখতে পারে — লাইব্রেরির একটি বই অনেকে একসাথে দেখতে পারেন।

Exclusive (X)

Exclusive lock: পড়া ও লেখার অধিকার। X থাকলে অন্য কেউ সেই ডেটায় S বা X কোনোটিই পায় না — বইটিতে কেউ সংশোধন লিখছেন, তখন আর কারও হাতে নয়।

কোন lock কোনটির পাশে চলে, তা একটি compatibility matrix-এ লেখা থাকে:

ধরে আছে ↓ / চাইছে →SX
S✓ মঞ্জুর✗ অপেক্ষা
X✗ অপেক্ষা✗ অপেক্ষা

এই হিসাব রাখে DBMS-এর lock manager. তার মূল তথ্য-কাঠামো lock table: একটি hash table (DSA কোর্সের hash table পর্ব), যার key হলো ডেটা-অংশের পরিচয়, আর মান একটি queue — অনুরোধগুলো আসার ক্রমে, প্রতিটিতে লেখা কোন transaction, কোন mode, মঞ্জুর (granted) না অপেক্ষায় (waiting). নিয়ম তিনটি:

  • নতুন অনুরোধ মঞ্জুর হয় যদি সে queue-তে থাকা সব granted lock-এর সাথে compatible হয় এবং তার আগে কেউ অপেক্ষায় না থাকে; নইলে queue-র শেষে দাঁড়ায়।
  • Unlock হলে অনুরোধটি মুছে queue-র মাথা থেকে যতগুলো সম্ভব অপেক্ষমাণকে মঞ্জুর করা হয়।
  • Abort হলে সেই transaction-এর সব অনুরোধ মুছে যায় — তাই প্রতিটি transaction-এর lock-গুলোর একটি তালিকাও রাখা হয়।

প্রথম নিয়মের “আগে কেউ অপেক্ষায় নেই” অংশটি গুরুত্বপূর্ণ। ধরুন একটি সিটে কয়েকজন পড়ছে (S), একজন লিখতে চায় (X, অপেক্ষায়)। নতুন পাঠকদের সরাসরি S দিলে পাঠকের স্রোত কখনো থামবে না, আর লেখক অনন্তকাল দাঁড়িয়ে থাকবে — এর নাম starvation. queue-র ক্রম মানলে তা হয় না। নিচের এডিটরে lock table আর matrix দুটিই সাধারণ টেবিল:

-- প্রতিটি item-এর queue, আসার ক্রমে
SELECT item, txn, mode, status FROM lock_table ORDER BY item, seq;
-- নতুন অনুরোধ: T5 চায় seat_9-এ S. কোনো granted lock-এর সাথে সংঘাত?
SELECT CASE WHEN EXISTS (
         SELECT 1 FROM lock_table l JOIN compat c ON c.held = l.mode AND c.req = 'S'
         WHERE l.item = 'seat_9' AND l.status = 'granted' AND c.ok = 0)
       THEN 'wait' ELSE 'grant' END AS t5_decision;
-- T1 আর T2 COMMIT করল: তাদের lock মুছে queue-র মাথাকে মঞ্জুর
DELETE FROM lock_table WHERE txn IN ('T1', 'T2');
UPDATE lock_table SET status = 'granted' WHERE item = 'seat_7' AND txn = 'T3';
SELECT item, txn, mode, status FROM lock_table ORDER BY item, seq;

প্রথম ফলে seat_7-এর queue: T1 S granted, T2 S granted, T3 X waiting, T4 S waiting; আর seat_9-এ T3 X granted. লক্ষ করুন, T4-এর S আগের দুটি S-এর সাথে compatible, তবু অপেক্ষায় — কারণ তার আগে T3 দাঁড়িয়ে। T5-এর সিদ্ধান্ত wait: seat_9-এ T3-এর X আছে, আর matrix-এ X–S = 0. T1 আর T2 চলে যাওয়ার পর শেষ ফল: seat_7 | T3 | X | granted, seat_7 | T4 | S | waiting (X-এর পাশে S চলে না), seat_9 | T3 | X | granted. আসল lock manager একই কাজ করে মেমরিতে, ভাগ-করা hash table-এ।

৩ · শুধু lock যথেষ্ট নয় — two-phase locking

প্রতিটি পড়া-লেখার আগে lock নিলেই কি সব ঠিক? না। কাজ শেষ হওয়ামাত্র lock ছেড়ে দিলে অন্য transaction মাঝপথের অবস্থা দেখে ফেলে। উদাহরণ: হিসাব A-তে ১,০০০ আর B-তে ২,০০০ টাকা। T1 A থেকে B-তে ১০০ টাকা পাঠায়; T2 দুই হিসাবের যোগফল দেখায় — সঠিক উত্তর সবসময় ৩,০০০.

X(·), S(·) = lock নেওয়া; U(·) = ছাড়াA + B সবসময় 3000 হওয়া উচিত

            
হিসাব
ডেটাবেসে A
ডেটাবেসে B
T2 দেখল
নিয়ম
  1. দুটি transaction. প্রতিটি পড়া বা লেখা নিজের lock-এর ভেতরে, কাজ শেষ হলেই lock ছাড়া — দেখতে নিরাপদ।
  2. T1 অর্ধেক কাজ করে lock ছাড়ে। A থেকে ১০০ কাটা হলো (৯০০), A-র lock ছেড়ে দেওয়া হলো। B-তে টাকা এখনো যোগ হয়নি।
  3. T2 মাঝখানে ঢোকে। A-র lock ফাঁকা, তাই T2 পড়ে ৯০০; B-র lock-ও ফাঁকা, পড়ে পুরোনো ২,০০০. প্রতিটি পড়া বৈধ lock-এর ভেতরেই।
  4. ভুল যোগফল। T2 দেখায় ২,৯০০ — ১০০ টাকা “হাওয়া”। পর্ব ৪৫-এর precedence graph-এ T1 → T2 (A-তে T1-এর লেখা আগে) আর T2 → T1 (B-তে T2-র পড়া আগে) — চক্র, তাই schedule-টি conflict serializable নয়।
  5. 2PL-এ T1. T1 আগে A আর B দুটিরই lock নেয়; শেষ lock নেওয়ার মুহূর্তটি তার lock point. T2-র S(A) অনুরোধ অপেক্ষায় পড়ে।
  6. তারপর ছাড়া। T1 দুটি লেখা শেষ করে lock ছাড়ে। এখন T2 পায় A = ৯০০, B = ২,১০০ — যোগফল ৩,০০০.
  7. Lock point-এর ক্রম। T1-এর lock point (t2) T2-র (t7) আগে, আর schedule-টি ঠিক serial T1 → T2-এর সমতুল্য। এটি কাকতাল নয় — নিচের উপপাদ্য।

Two-phase locking (2PL) প্রতিটি transaction-কে দুই পর্বে ভাগ করে। Growing phase-এ সে শুধু lock নিতে পারে, ছাড়তে পারে না। প্রথমবার কোনো lock ছাড়ামাত্র শুরু হয় shrinking phase — তখন শুধু ছাড়া, নতুন lock আর নয়। দুই পর্বের সীমানা, অর্থাৎ শেষ lock পাওয়ার মুহূর্ত, হলো lock point.

উপপাদ্য ও প্রমাণ

দাবি: সব transaction 2PL মানলে প্রতিটি schedule conflict serializable, আর সমতুল্য serial ক্রম হলো lock point-এর ক্রম।

প্রমাণ: পর্ব ৪৫-এর precedence graph নিন। ধরুন একটি তীর Tᵢ → Tⱼ আছে। তাহলে কোনো ডেটা Q-তে Tᵢ-এর একটি কাজ Tⱼ-এর একটি বিরোধী কাজের আগে, আর অন্তত একটি লেখা। দুটি কাজের lock compatible নয়, তাই Tⱼ নিজের lock পায় Tᵢ Q-এর lock ছাড়ার পরে। Tᵢ lock ছেড়েছে মানে সে shrinking phase-এ — তার lock point আগেই পেরিয়েছে। আর Tⱼ তখন lock নিচ্ছে মানে সে growing phase-এ — তার lock point এখনো সামনে। তাই LP(Tᵢ) < LP(Tⱼ). এখন ধরুন graph-এ চক্র আছে: T₁ → T₂ → … → Tₖ → T₁. তাহলে LP(T₁) < LP(T₂) < … < LP(T₁) — অসম্ভব। তাই graph acyclic, schedule conflict serializable, আর lock point অনুযায়ী সাজালেই একটি topological order — সমতুল্য serial ক্রম। ∎

দুটি সতর্কতা। প্রথমত, 2PL যথেষ্ট শর্ত, প্রয়োজনীয় নয়: কিছু conflict serializable schedule 2PL দিয়ে তৈরি হয় না। দ্বিতীয়ত, 2PL deadlock ঠেকায় না, আর মূল রূপে cascading abort-ও ঠেকায় না — পরের অংশগুলো এই দুই ফাঁক নিয়ে।

ইতিহাস এক নজরে। ১৯৭৬ সালে IBM-এর Kapali Eswaran, Jim Gray, Raymond Lorie আর Irving Traiger Communications of the ACM-এ “The Notions of Consistency and Predicate Locks in a Database System” প্রকাশ করেন। সেখানেই two-phase locking, তার serializability-র প্রমাণ আর phantom সমস্যার কথা (অংশ ৯) প্রথম স্পষ্টভাবে আসে। একই দল System R-এ granularity আর intention lock-এর ধারণা দেয় (অংশ ৫)। Jim Gray পরে ১৯৯৮ সালে transaction processing-এ অবদানের জন্য Turing পুরস্কার পান।

৪ · Strict আর rigorous 2PL — cascading abort ঠেকানো

মূল 2PL-এ shrinking phase-এ X lock ছেড়ে দেওয়া যায় COMMIT-এর আগেই। বিপদটি দেখুন: T1 A-তে লিখে lock ছাড়ল; T2 সেই অপাকা মান পড়ল; তারপর T1 কোনো কারণে abort করল। T2 যে মান পড়েছে তা আর কখনো ছিলই না — তাই T2-কেও abort করতে হবে, আর T2-র লেখা যে পড়েছে তাকেও। এই শিকলের নাম cascading abort. আরও খারাপ: T2 যদি ইতিমধ্যে COMMIT করে থাকে, তাকে ফেরানো যায় না — schedule-টি recoverable-ও নয়।

Strict 2PL

Strict 2PL: 2PL, সাথে সব X lock ধরে রাখা হয় COMMIT বা abort পর্যন্ত। কেউ অপাকা লেখা পড়তে পারে না, তাই cascading abort হয় না, আর undo করাও সহজ (পর্ব ৪৮)।

Rigorous 2PL

Rigorous (strong strict) 2PL: সব lock, S-সহ, COMMIT পর্যন্ত। তখন lock point কার্যত COMMIT-এর মুহূর্ত, তাই serial ক্রম = COMMIT-এর ক্রম — distributed সিস্টেমে খুব কাজের।

বাস্তবের lock-ভিত্তিক DBMS প্রায় সবাই strict বা rigorous রূপ চালায়, আর lock নেওয়াটা হয় নিজে থেকে: আপনি শুধু UPDATE লেখেন, DBMS X lock নেয়, COMMIT-এ সব ছাড়ে। অ্যাপের দিক থেকে এর বড় শিক্ষা: transaction যত লম্বা, lock তত বেশিক্ষণ ধরা। transaction-এর ভেতরে ব্যবহারকারীর ক্লিকের অপেক্ষা, ধীর API কল বা ইমেইল পাঠানো রাখবেন না।

Lock upgrade আর downgrade

একটি transaction প্রায়ই আগে পড়ে, পরে ঠিক করে লিখবে কি না। তখন S থেকে X-এ upgrade করা হয় — এটি নতুন অধিকার নেওয়া, তাই শুধু growing phase-এ চলে। উল্টো, X থেকে S-এ downgrade অধিকার ছাড়া, তাই শুধু shrinking phase-এ। upgrade-এর একটি পরিচিত ফাঁদ: দুটি transaction একই সারিতে S ধরে আছে, দুজনেই X চায় — প্রত্যেকে অন্যের S ছাড়ার অপেক্ষায়, চিরকাল। এটি conversion deadlock. প্রতিকার: লেখার ইচ্ছা থাকলে শুরু থেকেই লেখার lock নেওয়া — SQL-এ SELECT … FOR UPDATE (অংশ ৯), আর SQL Server-এ এর জন্য আলাদা update (U) lock আছে, যা S-এর সাথে চলে কিন্তু আরেকটি U-এর সাথে নয়।

৫ · Granularity — সারি, page না পুরো টেবিল?

কিসের ওপর lock — একটি সারি, একটি page, পুরো টেবিল, না পুরো ডেটাবেস? এটিই lock granularity. সূক্ষ্ম (সারি) lock-এ বেশি concurrency — দুজন আলাদা সিট একসাথে বেচতে পারেন — কিন্তু লাখো সারি ছুঁলে লাখো lock, মেমরি আর সময় দুটিই বেশি। মোটা (টেবিল) lock একটিই, কিন্তু সবাইকে লাইনে দাঁড় করায়।

দুটি একসাথে চাইলে সমস্যা: T1 orders-এর একটি সারিতে X ধরে আছে। T2 পুরো orders টেবিলে S চায় (রিপোর্টের জন্য)। lock manager কীভাবে জানবে টেবিলের ভেতরে কোথাও X আছে? সব সারির lock খুঁজে দেখা অসম্ভব রকম ধীর। উত্তর: multiple-granularity locking, যেখানে গাছের ওপরের গিঁটে একটি “ইচ্ছার নোটিশ” রাখা হয় — intention lock:

  • IS (intention shared): নিচে কোথাও S নেব।
  • IX (intention exclusive): নিচে কোথাও X (বা S) নেব।
  • SIX = S + IX: পুরো গিঁট পড়ব, আর নিচে কিছু অংশ বদলাব — যেমন পুরো টেবিল পড়ে কয়েকটি সারি সংশোধন।

প্রোটোকল: lock নেওয়া হয় ওপর থেকে নিচে (ডেটাবেস → টেবিল → page → সারি), ছাড়া হয় নিচ থেকে ওপরে। কোনো গিঁটে S বা IS নিতে হলে তার মাথার গিঁটে IS বা IX থাকতে হবে; X, IX বা SIX নিতে হলে মাথায় IX বা SIX. আর পুরো ব্যাপারটি 2PL মেনে। এখন T1 সারিতে X নেওয়ার আগে টেবিলে IX নিয়েছে; T2-র টেবিল-S অনুরোধ টেবিলের গিঁটেই IX-এর সাথে সংঘাত দেখে — একটি তুলনায় উত্তর। পাঁচটি mode-এর পূর্ণ matrix নিচের এডিটরে, ডেটা হিসেবে:

-- সারি = যে lock ধরা আছে, কলাম = নতুন অনুরোধ
SELECT g.held,
  MAX(CASE g.req WHEN 'IS'  THEN g.ok END) AS "IS",
  MAX(CASE g.req WHEN 'IX'  THEN g.ok END) AS "IX",
  MAX(CASE g.req WHEN 'S'   THEN g.ok END) AS "S",
  MAX(CASE g.req WHEN 'SIX' THEN g.ok END) AS "SIX",
  MAX(CASE g.req WHEN 'X'   THEN g.ok END) AS "X"
FROM gm g JOIN modes m ON m.m = g.held
GROUP BY g.held ORDER BY MIN(m.rk);
-- টেবিলে IX ধরা: কোন অনুরোধ এখনই মঞ্জুর?
SELECT req FROM gm WHERE held = 'IX' AND ok = '✓';

ফলের পাঁচ সারি: IS | ✓ ✓ ✓ ✓ ✗, IX | ✓ ✓ ✗ ✗ ✗, S | ✓ ✗ ✓ ✗ ✗, SIX | ✓ ✗ ✗ ✗ ✗, X | ✗ ✗ ✗ ✗ ✗. মনে রাখার উপায়: দুটি IX একসাথে চলে, কারণ দুজন আলাদা সারি বদলাতে পারে — আসল সংঘাত থাকলে তা ধরা পড়বে সারির স্তরে। কিন্তু IX আর S চলে না: একজন পুরোটা স্থির চায়, অন্যজন নিচে কিছু বদলাতে চায়। শেষ প্রশ্নের উত্তর IS আর IX — অর্থাৎ কেউ সারি বদলাতে থাকলে অন্যরা অন্য সারি পড়তে বা বদলাতে পারে, কিন্তু পুরো টেবিলে S বা X পাবে না।

বাস্তবে: InnoDB টেবিলে IS/IX আর সারিতে S/X রাখে — LOCK TABLES … WRITE-এর মতো টেবিল-স্তরের অনুরোধ এই IX-এর সাথে মেলায়। SQL Server একটি টেবিলে খুব বেশি সারি-lock জমলে (নথিপত্র অনুযায়ী মোটামুটি ৫,০০০-এর কাছাকাছি) সেগুলোকে একটি টেবিল-lock-এ বদলে ফেলতে পারে — lock escalation; মেমরি বাঁচে, concurrency কমে। InnoDB আর PostgreSQL escalation করে না।

৬ · Deadlock — দুই transfer, উল্টো দিকে

রহিমের হিসাবে ৫,০০০ আর করিমের হিসাবে ৩,০০০ টাকা। একই মুহূর্তে T1 রহিম থেকে করিমকে ৫০০ পাঠায়, আর T2 করিম থেকে রহিমকে ৩০০. দুজনেই strict 2PL মেনে চলে:

R = রহিম, K = করিমWFG = wait-for graph

            
অবস্থা
রহিম (R)
করিম (K)
অপেক্ষায়
WFG-তে চক্র?
  1. T1 রহিমকে ধরে। টাকা কাটার জন্য T1 রহিমের সারিতে X lock পায়। wait-for graph-এ এখনো কোনো তীর নেই।
  2. T2 করিমকে ধরে। একই সময়ে T2 করিমের সারিতে X পায়। দুটি আলাদা সারি — কোনো সংঘাত নেই।
  3. T1 আটকায়। T1 রহিম থেকে ৫০০ কাটে (৪,৫০০; * মানে অপাকা), তারপর করিমের সারি চায় — কিন্তু সেটি T2-র কাছে। T1 অপেক্ষায়; graph-এ তীর T1 → T2 (“T1 অপেক্ষা করছে T2-র জন্য”)।
  4. T2-ও আটকায় — চক্র। T2 করিম থেকে ৩০০ কাটে, তারপর রহিমের সারি চায় — যা T1-এর কাছে। তীর T2 → T1. দুজনেই অন্যজনের অপেক্ষায়, কেউ কখনো এগোবে না: deadlock.
  5. Detector জট খোলে। DBMS graph-এ চক্র পায়, আর একটি victim বাছে — এখানে T2. T2 rollback হয়: করিমের অপাকা ২,৭০০ মুছে আবার ৩,০০০, lock মুক্ত। T1 করিমের lock পায়।
  6. দুজনেরই কাজ শেষ। T1 করিমকে ৫০০ দিয়ে (৩,৫০০) COMMIT করে। অ্যাপ T2-কে নতুন করে চালায়: করিম ৩,২০০, রহিম ৪,৮০০. মোট ৮,০০০ — শুরুতেও তাই ছিল।

Wait-for graph-এর প্রতিটি গিঁট একটি চলমান transaction, আর তীর Tᵢ → Tⱼ মানে Tᵢ এমন একটি lock-এর অপেক্ষায় যা Tⱼ-এর কাছে আছে। deadlock আছে যদি এবং কেবল যদি graph-এ চক্র থাকে। Deadlock detection মানে নিয়মিত (বা প্রতিটি নতুন অপেক্ষার সময়) এই graph-এ চক্র খোঁজা — DFS দিয়ে O(V + E) সময়ে, DSA কোর্সের DFS পর্বের cycle detection. পর্ব ২৫-এর recursive CTE দিয়েও কাজটি করা যায়:

WITH RECURSIVE path(start, node, route) AS (
  SELECT waiter, holder, waiter || '→' || holder FROM waits
  UNION ALL
  SELECT p.start, w.holder, p.route || '→' || w.holder
  FROM path p JOIN waits w ON w.waiter = p.node
  WHERE p.node <> p.start                          -- চক্র বন্ধ হলে থামো
    AND (w.holder = p.start OR instr(p.route, w.holder) = 0)
)
SELECT p.route AS cycle, t.rows_changed AS victim_cost
FROM path p JOIN txn t ON t.id = p.start
WHERE p.node = p.start
ORDER BY t.rows_changed;

পাঁচটি তীর: T1 → T2 → T3 → T1 একটি চক্র, আর T4 → T1, T5 → T4 শুধু অপেক্ষার শিকল। ফলে তিনটি সারি — একই চক্র তিন জায়গা থেকে শুরু: T2→T3→T1→T2 | 3, T3→T1→T2→T3 | 40, T1→T2→T3→T1 | 120. T4 আর T5 কোনো চক্রে নেই — তারা অপেক্ষায়, কিন্তু deadlock-এ নয়; চক্র ভাঙলে তারাও এগোবে। ORDER BY-এর প্রথম সারিটিই victim: T2, যে মাত্র ৩টি সারি বদলেছে, তাই তাকে ফেরানো সবচেয়ে সস্তা। (instr দিয়ে route-এ নাম খোঁজা এখানে চলে কারণ নামগুলো T1–T5; বাস্তবে visited-সেট লাগে।)

Victim বাছাইয়ের মাপকাঠি: কতটা কাজ করেছে (কত সারি বদলেছে, log কতটা), কতগুলো lock ধরে আছে, শেষ হতে আর কত বাকি, আর আগে কতবার victim হয়েছে — শেষটি না দেখলে একই transaction বারবার বলি হয়ে starvation-এ পড়ে। rollback পুরোটা হতে পারে, বা একটি savepoint পর্যন্ত আংশিক। বাস্তব সিস্টেম:

  • PostgreSQL: একটি lock-এর জন্য deadlock_timeout (সাধারণত ১ সেকেন্ড) অপেক্ষার পর চক্র খোঁজে; পেলে একটিকে বাতিল করে ত্রুটি দেয় deadlock detected (SQLSTATE 40P01).
  • MySQL/InnoDB: প্রতিটি অপেক্ষার সাথে সাথে খোঁজে (innodb_deadlock_detect, সাধারণত চালু), আর “ছোট” transaction-টি — যে কম সারি বদলেছে — rollback করে; ত্রুটি 1213.

Timeout আরও সরল পথ: নির্দিষ্ট সময়ের বেশি অপেক্ষা করলে নিজেকেই বাতিল (InnoDB-র innodb_lock_wait_timeout, সাধারণত ৫০ সেকেন্ড; PostgreSQL-এর lock_timeout, সাধারণত বন্ধ)। graph লাগে না, কিন্তু সময় বেশি হলে deadlock অনেকক্ষণ টেকে, কম হলে নিছক ধীর transaction-ও বাতিল হয়।

অ্যাপের দিক থেকে সবচেয়ে কার্যকর প্রতিকার: সবসময় একই ক্রমে lock নেওয়া। দুই transfer-ই যদি আগে ছোট id-র হিসাব, তারপর বড়টি lock করে, তাহলে কেউ কারও “উল্টো দিকে” অপেক্ষা করতে পারে না — চক্রের জন্য অন্তত একটি তীর বড় থেকে ছোটর দিকে লাগে। তবু deadlock-ত্রুটি ধরে পুরো transaction আবার চালানোর কোড রাখুন:

BEGIN;
-- দুই হিসাব সবসময় id-র ক্রমে lock: (7 → 3) হোক বা (3 → 7)
SELECT id, balance FROM account WHERE id IN (3, 7) ORDER BY id FOR UPDATE;
UPDATE account SET balance = balance - 500 WHERE id = 7;
UPDATE account SET balance = balance + 500 WHERE id = 3;
COMMIT;
-- অ্যাপ: 40P01 (PostgreSQL) বা 1213 (MySQL) পেলে ছোট বিরতি দিয়ে আবার চেষ্টা

৭ · Deadlock ঠেকানো — wait-die আর wound-wait

ধরা না পড়া পর্যন্ত অপেক্ষার বদলে deadlock আগেই অসম্ভব করা যায়। দুটি ক্লাসিক পদ্ধতি (Rosenkrantz, Stearns ও Lewis, ১৯৭৮) প্রতিটি transaction-কে শুরুর সময় একটি timestamp দেয় — ছোট সংখ্যা মানে বয়সে বড় (আগে শুরু)। Tᵢ যখন এমন lock চায় যা Tⱼ-এর কাছে আছে:

যে চাইছেwait-diewound-wait
বড় (পুরোনো)অপেক্ষা করেছোটকে “আহত” করে — ছোটটি abort
ছোট (নতুন)“মরে” — নিজে abortঅপেক্ষা করে

এক লাইনে: wait-die-এ শুধু বড়রা অপেক্ষা করে; wound-wait-এ শুধু ছোটরা। abort হওয়া transaction নতুন করে শুরু হয় পুরোনো timestamp নিয়েই.

ts ছোট = বয়সে বড়Q-এর X lock T2-র কাছে

            
সিদ্ধান্ত
অনুরোধ
wait-die
wound-wait
  1. তিন transaction. T1 সবচেয়ে আগে শুরু (ts ৫), T3 সবার শেষে (ts ১৫)। Q-এর lock এখন T2-র হাতে।
  2. বড় চাইছে ছোটর কাছে। wait-die-এ বড় T1 ধৈর্য ধরে অপেক্ষা করে। wound-wait-এ T1 সরাসরি T2-কে বাতিল করায় (preemptive) — T2 পরে ts ১০ নিয়ে আবার শুরু হবে।
  3. ছোট চাইছে বড়র কাছে। wait-die-এ ছোট T3 অপেক্ষা করার অধিকার পায় না — নিজে abort. wound-wait-এ ছোটর অপেক্ষা বৈধ, তাই T3 অপেক্ষা করে।
  4. চক্র অসম্ভব। wait-die-এ অপেক্ষার প্রতিটি তীর ছোট ts থেকে বড় ts-এর দিকে; যে শিকলে সংখ্যা শুধু বাড়ে, সেটি শুরুর বিন্দুতে ফিরতে পারে না। wound-wait-এ সব তীর উল্টো দিকে — একই যুক্তি।
  5. Starvation নেই। ফেরত আসা transaction পুরোনো ts রাখে, আর নতুনরা আরও বড় ts পায়। তাই একসময় সে সবচেয়ে পুরোনো হয়, আর তখন তাকে কেউ বাতিল করতে পারে না।
  6. খরচ। wait-die-এ একটি নতুন transaction একই lock-এর জন্য বারবার মরতে পারে; wound-wait-এ সাধারণত কম rollback. দুটিতেই এমন abort ঘটে যেখানে আসলে deadlock হতো না — নিরাপত্তার দাম।

নিয়মগুলো এত সরল যে একটি CASE-এই লেখা যায়। req টেবিলের প্রতিটি সারি একটি অনুরোধ: কে চাইছে আর তার ts, কার কাছে lock আর তার ts:

SELECT requester || '(' || ts_req || ') → ' || holder || '(' || ts_hold || ')' AS request,
  CASE WHEN ts_req < ts_hold THEN 'wait'
       ELSE requester || ' dies' END AS wait_die,
  CASE WHEN ts_req < ts_hold THEN holder || ' wounded'
       ELSE 'wait' END AS wound_wait
FROM req;

চারটি সারি: T1(5) → T2(10) | wait | T2 wounded, T3(15) → T2(10) | T3 dies | wait, T2(10) → T1(5) | T2 dies | wait, T1(5) → T3(15) | wait | T3 wounded. প্রতিটি সারিতে দুই নিয়মের ঠিক একটি অপেক্ষা করায়, অন্যটি কাউকে বাতিল করে। পার্থক্য কাকে: wait-die সবসময় চাইছে যে ছোট, তাকে মারে; wound-wait সবসময় ধরে আছে যে ছোট, তাকে। আরও দুটি প্রতিরোধের পথ: conservative 2PL — শুরুতেই সব lock একসাথে নেওয়া, না পেলে কোনোটিই নয় (কী কী লাগবে আগে জানতে হয়); আর no-wait — lock না পেলেই abort, অপেক্ষা একেবারেই নয়।

৮ · Lock ছাড়া — timestamp ordering আর optimistic control

Timestamp ordering

Timestamp ordering (TO) কাউকে অপেক্ষা করায় না। প্রতিটি transaction-এর ts থেকেই serial ক্রম আগে ঠিক: ছোট ts আগে। প্রতিটি ডেটা Q-এর সাথে দুটি সংখ্যা থাকে — W-ts(Q): যে সবচেয়ে বড় ts-এর transaction Q লিখেছে, আর R-ts(Q): যে সবচেয়ে বড় ts-এর transaction Q পড়েছে। নিয়ম হলো, যে কাজ এই ক্রমে “দেরিতে পৌঁছেছে”, তাকে বাতিল করা:

কাজশর্তফল
Tᵢ read(Q)TS(Tᵢ) < W-ts(Q)Tᵢ rollback — তার “ভবিষ্যতের” লেখা পড়তে হতো
Tᵢ read(Q)নইলেপড়া; R-ts(Q) = max(R-ts, TS(Tᵢ))
Tᵢ write(Q)TS(Tᵢ) < R-ts(Q)Tᵢ rollback — নতুন কেউ পুরোনো মান পড়ে ফেলেছে
Tᵢ write(Q)TS(Tᵢ) < W-ts(Q)Tᵢ rollback (Thomas write rule: লেখাটি শুধু বাদ)
Tᵢ write(Q)নইলেলেখা; W-ts(Q) = TS(Tᵢ)

প্রতিটি সংঘাতের কাজ ts-এর ক্রমেই ঘটে, তাই precedence graph-এর সব তীর ছোট ts থেকে বড় ts-এর দিকে — চক্র অসম্ভব, schedule conflict serializable. কেউ অপেক্ষা করে না, তাই deadlock-ও নেই। দাম: অনেক rollback, লম্বা transaction-এর বারবার বাতিল হওয়ার ঝুঁকি, আর অপাকা লেখা পড়া আটকাতে বাড়তি ব্যবস্থা না রাখলে cascading abort.

Thomas write rule (Robert Thomas, ১৯৭৯) চতুর্থ সারিটি নরম করে। T1 (ts ১) Q লিখতে এলো, কিন্তু T2 (ts ২) আগেই লিখে ফেলেছে, আর কেউ T1-এর মান পড়েনি (নইলে তৃতীয় সারিতেই ধরা পড়ত)। serial ক্রম T1 → T2-এ T1-এর লেখা এমনিতেই T2-র লেখার নিচে চাপা পড়ত — তাই লেখাটি চুপচাপ বাদ দিলেই হলো, rollback লাগে না। এতে এমন schedule-ও মেলে যা conflict serializable নয় কিন্তু view serializable (পর্ব ৪৫)।

Optimistic concurrency control

H. T. Kung আর John Robinson-এর ১৯৮১ সালের প্রস্তাব, optimistic concurrency control (OCC), সংঘাতকে বিরল ধরে নেয়। প্রতিটি transaction তিন পর্বে চলে:

  1. Read: lock ছাড়াই পড়ে, সব লেখা নিজের ব্যক্তিগত খাতায় (workspace) রাখে; কী পড়েছে (read set) আর কী লিখবে (write set) টুকে রাখে।
  2. Validation: COMMIT-এর আগে যাচাই — এর মধ্যে যারা COMMIT করেছে, তাদের write set-এর সাথে আমার read set-এর কোনো মিল আছে? মিল থাকলে আমি পুরোনো ডেটা দেখেছি — abort, আবার চেষ্টা।
  3. Write: যাচাই পেরোলে খাতার লেখাগুলো ডেটাবেসে বসানো।

validation-এর ক্রমই serial ক্রম। সংঘাত কম হলে OCC lock-এর খরচ আর অপেক্ষা পুরো বাঁচায়; সংঘাত বেশি হলে বারবার পুরো কাজ ফেলে দিতে হয়। ঠিক এই চিন্তা অ্যাপে খুব প্রচলিত — optimistic locking: সারিতে একটি version কলাম, আর লেখার সময় শর্ত “আমি যে version পড়েছিলাম, সেটিই এখনো আছে তো?” product টেবিলে চালের মজুদ (id, name, stock, version):

-- দুই অ্যাপ-সেশন একই সারি পড়ল: stock 10, version 3
SELECT id, stock, version FROM product WHERE id = 1;
-- সেশন ১: 2 বস্তা বিক্রি
UPDATE product SET stock = 10 - 2, version = version + 1
WHERE id = 1 AND version = 3;
-- সেশন ২: 5 বস্তা বিক্রি, কিন্তু তার হাতে পুরোনো version 3
UPDATE product SET stock = 10 - 5, version = version + 1
WHERE id = 1 AND version = 3;
SELECT id, stock, version FROM product WHERE id = 1;
-- সেশন ২ নতুন করে পড়ে (8, version 4), আবার চেষ্টা
UPDATE product SET stock = 8 - 5, version = version + 1
WHERE id = 1 AND version = 4;
SELECT id, stock, version FROM product WHERE id = 1;

প্রথম SELECT 1 | 10 | 3. সেশন ১-এর UPDATE ১টি সারি বদলেছে; সেশন ২-এর ০টি সারি বদলেছে — version এখন ৪, তাই শর্ত মেলেনি, আর তার পুরোনো হিসাব (১০ − ৫) লেখাই হয়নি। মাঝের SELECT 1 | 8 | 4. অ্যাপ “০ সারি” দেখে বোঝে অন্য কেউ আগে লিখেছে, নতুন করে পড়ে আবার চেষ্টা করে: ১টি সারি বদলেছে, শেষ ফল 1 | 3 | 5 — দুই বিক্রিই হিসাবে আছে। version-এর শর্ত না থাকলে শেষ মান হতো ৫ — lost update. Java-র JPA-তে @Version (দেখুন Spring Data JPA) ঠিক এই কাজটিই নিজে করে।

৯ · বাস্তব সিস্টেমে — phantom, FOR UPDATE, SKIP LOCKED আর SQLite

Phantom আর range lock

সারির lock শুধু যে সারি আছে তাকে আটকায়। T1 হিসাব করে “CSE বিভাগের সব শিক্ষকের মোট বেতন”, আর সেই সারিগুলোতে S নেয়। এর মধ্যে T2 CSE-তে একজন নতুন শিক্ষক INSERT করে — নতুন সারি, তাতে কারও lock নেই। T1 আবার হিসাব করলে নতুন একজন হাজির: phantom. সমাধান হলো শর্তটিকেই lock করা — predicate lock (“dept = 'CSE' মানে এমন যেকোনো সারি”) — কিন্তু দুটি predicate মিলে যায় কি না যাচাই সাধারণভাবে কঠিন আর দামি। তাই বাস্তবে index-এর একটি পরিসর lock করা হয়। InnoDB REPEATABLE READ-এ next-key lock নেয়: একটি index-রেকর্ড আর তার ঠিক আগের ফাঁক (gap lock). ধরুন age-এর index-এ 10, 20, 30 আছে; WHERE age BETWEEN 15 AND 25 FOR UPDATE মোটামুটি (10, 20] আর (20, 30] আটকায় — তখন age 18 বা 22-এর INSERT অপেক্ষা করে। PostgreSQL এই পথে না গিয়ে MVCC আর SERIALIZABLE-এ বিশেষ predicate lock ব্যবহার করে — পর্ব ৪৭-এ।

SELECT … FOR UPDATE, NOWAIT আর SKIP LOCKED

“পড়ো, তারপর লেখো” ধাঁচে পড়ার সময়েই লেখার lock নেওয়ার উপায় SELECT … FOR UPDATE — অংশ ৪-এর conversion deadlock এড়ায়। FOR SHARE শুধু পড়ার lock নেয় (অন্যরা বদলাতে পারে না, পড়তে পারে)। lock না পেলে অপেক্ষার বদলে NOWAIT সাথে সাথে ত্রুটি দেয়, আর SKIP LOCKED lock-ধরা সারি বাদ দিয়ে বাকিগুলো দেয়। শেষটি job queue-র জন্য চমৎকার: দশটি worker একসাথে “পরের কাজটি” চাইলে প্রত্যেকে আলাদা কাজ পায়, কেউ কারও জন্য দাঁড়ায় না। PostgreSQL 9.5 আর MySQL 8.0 থেকে SKIP LOCKED আছে (এখানে চালানো যায় না):

-- প্রতিটি worker: একটি কাজ দাবি করা
BEGIN;
SELECT id, task FROM jobs
WHERE status = 'queued'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;          -- অন্য worker যেটি ধরেছে, সেটি বাদ
UPDATE jobs SET status = 'running', worker = 'w1' WHERE id = :id;
COMMIT;

SELECT * FROM seat WHERE id = 7 FOR UPDATE NOWAIT;  -- ধরা থাকলে সাথে সাথে ত্রুটি
SELECT * FROM seat WHERE id = 7 FOR SHARE;          -- MySQL 8.0-এর আগে: LOCK IN SHARE MODE

SQLite: একসাথে একজন লেখক

SQLite সারি বা টেবিল নয়, পুরো ডেটাবেস ফাইল lock করে। পুরোনো rollback-journal পদ্ধতিতে অনেক পাঠক একসাথে চলে (SHARED lock); একজন লেখক RESERVED lock নেয়, আর COMMIT-এর মুহূর্তে সব পাঠক সরে যাওয়ার অপেক্ষা করে EXCLUSIVE পায়। তাই FOR UPDATE-এর দরকারই নেই — সমর্থনও নেই। সাধারণ BEGIN (DEFERRED) প্রথম লেখার আগে কোনো লেখার lock নেয় না; BEGIN IMMEDIATE শুরুতেই নেয়। WAL mode-এ (write-ahead log, পর্ব ৪৮) পাঠক আর লেখক কেউ কাউকে আটকায় না: পাঠক তার শুরুর মুহূর্তের ছবি দেখে, লেখক নতুন পাতাগুলো আলাদা ফাইলে জমায় — তবে লেখক তখনো একজনই।

BEGIN IMMEDIATE;               -- শুরুতেই লেখার অধিকার
SELECT free_seats FROM train WHERE id = 1;
UPDATE train SET free_seats = free_seats - 1
WHERE id = 1 AND free_seats > 0;
COMMIT;
SELECT free_seats FROM train;
PRAGMA journal_mode;
-- অন্য DBMS-এর অভ্যাস SQLite-এ চলে না:
SELECT free_seats FROM train WHERE id = 1 FOR UPDATE;

BEGIN IMMEDIATE থেকে COMMIT পর্যন্ত সব সফল: SELECT 1, UPDATE ১টি সারি বদলেছে, পরের SELECT 0. PRAGMA journal_mode-এর ফল delete — SQLite-এর ডিফল্ট rollback-journal পদ্ধতি, যেখানে COMMIT-এর মুহূর্তে লেখক আর পাঠক একে অপরকে আটকায়; ফাইলের ডেটাবেসে PRAGMA journal_mode=WAL একবার দিলেই স্থায়ী হয়। শেষ লাইনে ইচ্ছাকৃত ত্রুটি: near "FOR": syntax error — SQLite-এ FOR UPDATE নেই, কারণ লেখক একজনই। একটি সংযোগে দুই লেখকের সংঘাত দেখানো যায় না, তাই নিচে Python দিয়ে একই ফাইলে দুটি সংযোগ।

SQLite-এ job queue চালাতে SKIP LOCKED লাগে না: “খোঁজা আর দাবি” একটি UPDATE-এ লিখলে দুই worker কখনো একই কাজ পায় না, কারণ একটি লেখা শেষ না হলে আরেকটি শুরুই হয় না। RETURNING (পর্ব ১৪) দাবি-করা কাজটি সাথে সাথে ফেরত দেয়:

-- worker w1: খোঁজা আর দাবি এক বাক্যে
UPDATE jobs SET status = 'running', worker = 'w1'
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, task;
-- worker w2: একই বাক্য, পরের কাজ
UPDATE jobs SET status = 'running', worker = 'w2'
WHERE id = (SELECT id FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1)
RETURNING id, task;
SELECT id, task, status, worker FROM jobs;

w1 পায় 1 | send OTP SMS, w2 পায় 2 | make invoice PDF. শেষ ফল: 1 | send OTP SMS | running | w1, 2 | make invoice PDF | running | w2, 3 | email receipt | queued | NULL. SQLite-এ এটি নিরাপদ, কারণ পুরো ফাইলে একজন লেখক। PostgreSQL-এর READ COMMITTED-এ একই বাক্য চললে দুই worker-এর subquery একই সারি বাছতে পারে; দ্বিতীয়জন অপেক্ষার পর হয় একই কাজ আবার দাবি করে, নয়তো (বাইরের WHERE-এ status যাচাই থাকলে) খালি হাতে ফেরে — সেখানে SKIP LOCKED-ই পরিষ্কার পথ।

এবার সত্যিকারের দুটি সংযোগ — একটি অস্থায়ী ফাইলে, timeout=0 দিয়ে, যাতে lock না পেলে অপেক্ষা না করে সাথে সাথে database is locked আসে (Python-এর sqlite3 সাধারণত ৫ সেকেন্ড অপেক্ষা করে; দেখুন Python ও SQLite):

import sqlite3, tempfile, os
path = os.path.join(tempfile.mkdtemp(), "rail.db")
a = sqlite3.connect(path, timeout=0, isolation_level=None)   # clerk A
b = sqlite3.connect(path, timeout=0, isolation_level=None)   # clerk B
a.execute("CREATE TABLE seat (id INTEGER PRIMARY KEY, free INTEGER)")
a.execute("INSERT INTO seat VALUES (1, 1)")
def run(name, conn, sql):
    try: r = conn.execute(sql).fetchall(); print(name, sql, "->", r)
    except sqlite3.OperationalError as e: print(name, sql, "-> ERROR:", e)

run("A", a, "BEGIN IMMEDIATE")              # A takes the write lock now
run("A", a, "UPDATE seat SET free = 0")
run("B", b, "SELECT free FROM seat")        # reading is still allowed
run("B", b, "BEGIN IMMEDIATE")              # a second writer is refused
run("A", a, "COMMIT")
print("--- deferred BEGIN: both read first, then both try to write")
run("A", a, "BEGIN"); run("B", b, "BEGIN")
run("A", a, "SELECT free FROM seat"); run("B", b, "SELECT free FROM seat")
run("A", a, "UPDATE seat SET free = 1")
run("B", b, "UPDATE seat SET free = 1")
run("A", a, "COMMIT")                       # A must wait for B's read lock
run("B", b, "ROLLBACK"); run("A", a, "COMMIT")

প্রথম অংশ: A-র লেখা চলাকালে B পড়তে পারে, আর দেখে পুরোনো পাকা মান [(1,)]; কিন্তু B BEGIN IMMEDIATE -> ERROR: database is locked — দ্বিতীয় লেখক নিষেধ। দ্বিতীয় অংশটি SQLite-এর নিজস্ব deadlock: দুজনেই DEFERRED BEGIN করে পড়েছে (দুজনের হাতে SHARED), A লেখার lock পেয়েছে, B-র UPDATE পায় database is locked. এরপর A COMMIT -> ERROR: database is locked — A-কে COMMIT-এর আগে B-র SHARED সরার অপেক্ষা করতে হয়। B rollback করলে তবেই A-র COMMIT সফল। এ কারণেই SQLite-এ যে transaction লিখবে, সেটি BEGIN IMMEDIATE দিয়ে শুরু করা ভালো — প্রথম ধাপেই অপেক্ষা বা ব্যর্থতা, মাঝপথে আটকে যাওয়া নয়।

WAL mode-এ একই দুই সংযোগ:

import sqlite3, tempfile, os
path = os.path.join(tempfile.mkdtemp(), "rail.db")
a = sqlite3.connect(path, timeout=0, isolation_level=None)   # writer
b = sqlite3.connect(path, timeout=0, isolation_level=None)   # reader
print("mode:", a.execute("PRAGMA journal_mode=WAL").fetchone()[0])
a.execute("CREATE TABLE seat (id INTEGER PRIMARY KEY, free INTEGER)")
a.execute("INSERT INTO seat VALUES (1, 1)")
def run(name, conn, sql):
    try: r = conn.execute(sql).fetchall(); print(name, sql, "->", r)
    except sqlite3.OperationalError as e: print(name, sql, "-> ERROR:", e)

run("B", b, "BEGIN"); run("B", b, "SELECT free FROM seat")   # B's snapshot
run("A", a, "BEGIN IMMEDIATE"); run("A", a, "UPDATE seat SET free = 0")
run("B", b, "SELECT free FROM seat")        # reader not blocked by writer
run("A", a, "COMMIT")                       # writer not blocked by reader
run("B", b, "SELECT free FROM seat")        # same snapshot: still old value
run("B", b, "UPDATE seat SET free = free - 1")   # stale snapshot cannot write
run("B", b, "ROLLBACK"); run("B", b, "SELECT free FROM seat")

mode: wal. B transaction শুরু করে পড়ে [(1,)] — এই মুহূর্তের ছবিটি B-র। A লিখছে, তবু B পড়ে; A COMMIT করে, B তখনো খোলা — কেউ কাউকে আটকায়নি। COMMIT-এর পরও B দেখে [(1,)]: তার ছবি পুরোনো, কিন্তু নিজের ভেতরে সামঞ্জস্যপূর্ণ। এবার B সেই পুরোনো ছবির ভিত্তিতে লিখতে চায়: ERROR: database is locked. lock এখন ফাঁকা, তবু ব্যর্থ — কারণ B-র ছবির পরে ডেটা বদলেছে, আর পুরোনো ছবিতে লেখা দিলে lost update হতো (SQLite-এর ভাষায় SQLITE_BUSY_SNAPSHOT; অপেক্ষা করে লাভ নেই, transaction নতুন করে শুরু করতে হয়)। ROLLBACK-এর পর B দেখে নতুন মান [(0,)]. এই “পুরোনো ছবি থেকে পড়া” ধারণাটিই MVCC — পরের পর্বে।

আরও গভীরে: lock আর latch আলাদা জিনিস। lock রক্ষা করে ডেটার যৌক্তিক অংশকে (সারি, টেবিল), ধরে রাখা হয় transaction-এর শেষ পর্যন্ত, আর deadlock ধরা পড়ে graph দিয়ে; latch রক্ষা করে মেমরির একটি কাঠামোকে (buffer-এর একটি page, B+ tree-র একটি গিঁট), ধরা হয় কয়েক মাইক্রোসেকেন্ড, আর deadlock এড়ানো হয় শুধু কঠোর ক্রম মেনে। জায়গা বাঁচাতে InnoDB সারির lock রাখে প্রতি page-এ একটি bitmap হিসেবে, আর PostgreSQL সারির lock রাখে সারির নিজের header-এ — তাই কোনোটিরই escalation লাগে না। মেমরি-ভিত্তিক বহু-core সিস্টেমে কেন্দ্রীয় lock table নিজেই বাধা হয়ে দাঁড়ায়; Silo (২০১৩)-এর মতো গবেষণা আর SQL Server-এর In-Memory OLTP (Hekaton) তাই optimistic আর বহু-সংস্করণ পদ্ধতির দিকে গেছে।

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

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

AI-এর ভুলফলকীভাবে ধরবেন
SELECT করে অ্যাপে হিসাব, তারপর UPDATE — lock বা শর্ত ছাড়াlost update, ঋণাত্মক মজুদদুই সংযোগ বা থ্রেডে একসাথে চালিয়ে দেখুন
“transaction দিলেই নিরাপদ” ধরে নেওয়াREAD COMMITTED-এ তবু lost updateisolation level আর FOR UPDATE বা শর্তযুক্ত UPDATE আছে কি না দেখুন
বিভিন্ন জায়গায় বিভিন্ন ক্রমে সারি lockভিড়ে deadlockসব পথে lock-এর ক্রম মিলিয়ে দেখুন (যেমন id-র ক্রম)
deadlock বা serialization ত্রুটিতে retry নেইব্যবহারকারী ত্রুটি দেখেন40P01 / 1213 / 40001 ধরার কোড খুঁজুন
transaction-এর ভেতরে API কল বা ব্যবহারকারীর অপেক্ষাlock অনেকক্ষণ ধরা, সবাই আটকেtransaction-এর সীমানা চিহ্নিত করে ভেতরের কাজ গুনুন
SQLite-এ FOR UPDATE বা SKIP LOCKED, PostgreSQL-এ SQLite-এর ধারণাsyntax error বা ভুল নিরাপত্তা-বোধআপনার DBMS আর সংস্করণের নথি মিলিয়ে নিন

AI-কে concurrency-র প্রশ্ন সরাসরি করুন — কে কখন একসাথে চলবে, তা বলে:

আমার ডেটাবেস PostgreSQL 16 (READ COMMITTED). টেবিল: seat(id PK, train_id, status, booked_by). একই ট্রেনের শেষ সিটের জন্য একসাথে কয়েকশো অনুরোধ আসতে পারে। ১. একটি সিট বুক করার SQL লেখো, যাতে দুজন কখনো একই সিট না পায়। FOR UPDATE, SKIP LOCKED বা শর্তযুক্ত UPDATE — কোনটি নিয়েছ আর কেন, বলো। ২. দুটি transaction একসাথে চললে কোন ক্রমে কী lock হবে — একটি সময়রেখা (t1, t2 …) দিয়ে দেখাও। ৩. কোন অবস্থায় deadlock বা serialization ত্রুটি হতে পারে, আর অ্যাপে retry কীভাবে লিখবে, দেখাও। ৪. কোনো কলামের নাম অনুমান করবে না; transaction-এর ভেতরে নেটওয়ার্ক কল রাখবে না।

AI-এর লেখা concurrency-কোড চালু করার আগে — চেকলিস্ট

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

  • 2PL কী, আর কী নিশ্চিত করে? — growing phase-এ শুধু lock নেওয়া, shrinking-এ শুধু ছাড়া; conflict serializability (serial ক্রম = lock point-এর ক্রম), কিন্তু deadlock-মুক্তি নয়।
  • Strict আর rigorous 2PL-এর পার্থক্য? — strict-এ X lock COMMIT পর্যন্ত (cascadeless), rigorous-এ সব lock COMMIT পর্যন্ত (serial ক্রম = COMMIT-এর ক্রম)।
  • Wait-die আর wound-wait? — wait-die-এ বড় অপেক্ষা করে, ছোট abort; wound-wait-এ বড় ছোটকে abort করায়, ছোট অপেক্ষা করে; দুটিতেই পুরোনো timestamp নিয়ে ফেরা।
  • Deadlock কীভাবে ধরা হয়? — wait-for graph-এ চক্র খুঁজে; একটি victim rollback.
  • Optimistic locking অ্যাপে কীভাবে করবেন? — version কলাম; UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?; ০ সারি বদলালে নতুন করে পড়ে আবার চেষ্টা।

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

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

প্র ০১ডিস্ক-ভিত্তিক বাণিজ্যিক DBMS কয়েক দশক ধরে প্রধানত strict 2PL-এ চলেছে, অথচ timestamp ordering আর optimistic পদ্ধতি ১৯৭০–৮০-র দশকেই জানা ছিল। কেন lock জিতেছিল, আর মেমরি-ভিত্তিক বহু-core সিস্টেমে হিসাবটি কেন বদলেছে?

১৯৭০–৯০-এর দশকের যন্ত্রে সবচেয়ে দামি কাজ ছিল ডিস্ক থেকে page পড়া — কয়েক মিলিসেকেন্ড। একটি transaction-এর বেশিরভাগ সময় কাটত ডিস্কের অপেক্ষায়। এই অবস্থায় optimistic পদ্ধতির মূল দুর্বলতা বড় হয়ে দাঁড়ায়: validation-এ ব্যর্থ হলে পুরো কাজ ফেলে দিতে হয়, আর সেই কাজের মধ্যে ছিল অনেকগুলো ধীর ডিস্ক-পড়া। lock-এ সংঘাত হলে শুধু অপেক্ষা, কাজ নষ্ট হয় না। ব্যাংক বা বিমানের বুকিংয়ের মতো কাজে কিছু “গরম” সারিতে সংঘাত বিরল নয়, তাই অপেক্ষাই সস্তা ছিল।

দ্বিতীয় কারণ পূর্বানুমানযোগ্যতা। strict 2PL-এ recovery সহজ (পর্ব ৪৮): কেউ অপাকা মান পড়ে না, তাই undo শুধু নিজের লেখা। System R আর পরে বাণিজ্যিক পণ্যগুলোতে lock manager আর log-ভিত্তিক recovery একসাথে পরিণত হয়েছিল। timestamp ordering-এ লম্বা রিপোর্ট-কোয়েরি বারবার বাতিল হওয়ার ঝুঁকি, যা ব্যবহারকারীর কাছে ব্যাখ্যা করা কঠিন।

মেমরি-ভিত্তিক বহু-core যুগে সমীকরণ উল্টো। ডেটা পুরোটা RAM-এ, একটি transaction শেষ হয় কয়েক মাইক্রোসেকেন্ডে। তখন কেন্দ্রীয় lock table নিজেই সবচেয়ে বড় খরচ: প্রতিটি সারি পড়তেও lock নেওয়া মানে একটি ভাগ-করা কাঠামোয় লেখা, আর অনেক core একই cache line নিয়ে টানাটানি করে। ব্যর্থ transaction আবার চালানো এখন সস্তা, কারণ কাজটিই ছোট। তাই ২০১৩ সালের Silo গবেষণা optimistic পদ্ধতিতে খুব উঁচু throughput দেখায়, আর SQL Server-এর In-Memory OLTP (Hekaton) optimistic বহু-সংস্করণ পদ্ধতি নেয়। আরেকটি ধারা হলো MVCC: পাঠক কোনো lock নেয় না, শুধু লেখকে-লেখকে সংঘাত সামলানো হয় — PostgreSQL, Oracle আর InnoDB-র পড়া এভাবেই চলে।

মূল উপলব্ধি: কোন concurrency পদ্ধতি “ভালো”, তা নির্ভর করে কোন কাজটি দামি তার ওপর — অপেক্ষা, নষ্ট কাজ, না ভাগ-করা কাঠামোয় লেখা। হার্ডওয়্যার বদলালে উত্তরও বদলায়।

প্র ০২PostgreSQL deadlock খোঁজার আগে প্রায় ১ সেকেন্ড অপেক্ষা করে, InnoDB খোঁজে সাথে সাথে — আর MySQL-এর নথি খুব বেশি concurrency-র সিস্টেমে deadlock detection বন্ধ করে শুধু timeout-এর ওপর নির্ভর করার কথাও বলে। এই তিন নকশার পেছনের হিসাব কী?

চক্র খোঁজার খরচ আছে। wait-for graph তৈরি করতে lock manager-এর ভাগ-করা কাঠামো পড়তে হয়, আর সেই সময় অন্য অনুরোধ আটকে থাকতে পারে। প্রশ্ন হলো কখন এই খরচ দেওয়া উচিত।

PostgreSQL-এর যুক্তি: বেশিরভাগ অপেক্ষা deadlock নয় — অন্যজন কয়েক মিলিসেকেন্ডে COMMIT করবে, অপেক্ষা নিজেই মিটবে। প্রতিটি অপেক্ষায় চক্র খুঁজলে প্রায় সবসময় খরচটি বৃথা। তাই একটি lock-এর জন্য deadlock_timeout (সাধারণত ১ সেকেন্ড) অপেক্ষার পরেই খোঁজা। দাম: সত্যিকারের deadlock অন্তত সেই সময় টেকে। নথিতে পরামর্শ আছে মানটি সাধারণ transaction-এর সময়ের চেয়ে বড় রাখতে।

InnoDB-র যুক্তি উল্টো: OLTP-তে deadlock হলে প্রতিটি মুহূর্ত দামি, কারণ আটকে থাকা transaction-গুলোর lock-এর পেছনে আরও অনেকে দাঁড়িয়ে যায়। তাই অপেক্ষা শুরুর সাথে সাথে খোঁজা, আর কম কাজ করা transaction-টি rollback.

কিন্তু একটি “গরম” সারির পেছনে যদি শত শত থ্রেড দাঁড়ায় — ধরুন একটি জনপ্রিয় পণ্যের মজুদ — তাহলে প্রতিটি নতুন অপেক্ষায় বিশাল graph ঘাঁটতে হয়, আর detection নিজেই সিস্টেম ধীর করে দেয়। MySQL-এর নথি এমন অবস্থায় innodb_deadlock_detect বন্ধ করে innodb_lock_wait_timeout-এর ওপর নির্ভর করার কথা বলে। তখন deadlock ভাঙে শুধু timeout-এ — ধীর, কিন্তু অনুমানযোগ্য খরচে।

অ্যাপের দিক থেকে শিক্ষা: এই সেটিংয়ের চেয়ে অনেক বেশি কাজ করে নকশা — একই ক্রমে lock, ছোট transaction, গরম সারি এড়ানো (যেমন একটি মোট-সংখ্যার বদলে কয়েকটি ভাগ-করা সারি), আর সবসময় retry.

মূল উপলব্ধি: deadlock ধরার কৌশল হলো দুই খরচের দর-কষাকষি — খোঁজার খরচ বনাম আটকে থাকার খরচ। কোনটি বড়, তা কাজের ধরনের ওপর নির্ভর করে।

প্র ০৩একটি মোবাইল ওয়ালেটে একটি বড় সুপারশপ চেইনের মার্চেন্ট-হিসাবে অফারের দিনে প্রতি সেকেন্ডে শত শত পেমেন্ট আসে। প্রতিটি পেমেন্ট সেই একটি balance সারি UPDATE করে। কেন সিস্টেম ধীর হয়, আর lock-এর তত্ত্ব কী সমাধান দেখায়?

strict 2PL-এ প্রতিটি পেমেন্ট-transaction মার্চেন্টের সারিতে X lock নেয় আর COMMIT পর্যন্ত ধরে রাখে। X-এর সাথে কিছুই compatible নয়, তাই ওই সারির সব লেখা একে একে চলে — যত core বা সার্ভারই থাকুক, ওই এক সারির throughput সর্বোচ্চ “১ ÷ একটি transaction-এর lock ধরে রাখার সময়”। ধরুন transaction-এ নেটওয়ার্ক, log লেখা (পর্ব ৪৮) আর অন্যান্য যাচাই মিলে ৫ মিলিসেকেন্ড lock ধরা থাকে; তাহলে সেকেন্ডে বড়জোর ২০০টি পেমেন্ট, বাকিরা lock-এর queue-তে। এই সারিটি একটি hot row.

তত্ত্ব থেকে তিনটি দিক। এক, lock ধরে রাখার সময় কমানো: transaction-এর ভেতরে শুধু ডেটাবেসের কাজ, আর গরম সারির UPDATE যতটা সম্ভব শেষের দিকে — তাহলে lock ধরা থাকে সবচেয়ে কম সময়। দুই, সংঘাতের জায়গা ভাগ করা: একটি balance-এর বদলে ধরুন ১৬টি “উপ-balance” সারি, প্রতিটি পেমেন্ট এলোমেলোভাবে একটিতে যোগ করে, আর আসল balance হলো যোগফল। এখন সংঘাত ১৬ ভাগে ভাগ হলো। তিন, লেখার ধরন বদলানো: balance-এ UPDATE না করে প্রতিটি পেমেন্ট একটি ledger টেবিলে নতুন সারি হিসেবে INSERT — নতুন সারিতে কারও সাথে সংঘাত নেই — আর নির্দিষ্ট বিরতিতে একটি কাজ যোগফল হালনাগাদ করে। হিসাববিজ্ঞানের double-entry খাতাও আসলে এই “যোগ করো, মুছো না” ধারণা।

প্রতিটির দাম আছে। ভাগ-করা সারিতে “এখনই balance কত” জানতে যোগ করতে হয়; ledger-এ balance একটু পিছিয়ে থাকতে পারে, তাই “balance-এর বেশি খরচ করা যাবে না” ধরনের নিয়ম আলাদা যত্নে রাখতে হয়। মার্চেন্টের হিসাবে টাকা আসে — সেখানে পিছিয়ে থাকা চলে; গ্রাহকের হিসাব থেকে টাকা যায় — সেখানে কঠোর যাচাই লাগে। কোনো নির্দিষ্ট প্রতিষ্ঠান ঠিক কোন নকশা ব্যবহার করে, তা তাদের প্রকাশিত তথ্য ছাড়া বলা যায় না; ওপরেরগুলো সাধারণ প্রকৌশল-কৌশল।

মূল উপলব্ধি: lock-ভিত্তিক সিস্টেমে একটি সারির গতি সীমিত তার lock ধরে রাখার সময় দিয়ে। সমাধান আসে lock ছোট করা, সংঘাত ভাগ করা বা সংঘাতহীন লেখার নকশা থেকে — শুধু বড় সার্ভার থেকে নয়।

প্র ০৪Eswaran ও সহকর্মীরা ১৯৭৬ সালেই phantom-এর সমাধান হিসেবে predicate lock প্রস্তাব করেছিলেন। তবু প্রায় কোনো DBMS সাধারণ predicate lock বানায়নি; বানিয়েছে index-range বা next-key lock. কেন? আর এর পার্শ্বপ্রতিক্রিয়া কী?

Predicate lock-এর ধারণা সুন্দর: “dept = 'CSE' AND salary > 50000” শর্তটিকেই lock করা, যাতে আজ নেই কিন্তু কাল ঢুকতে পারে এমন সারিও ধরা পড়ে। কিন্তু দুটি lock সংঘাত করে কি না জানতে প্রশ্ন করতে হয়: এমন কোনো সারি কি সম্ভব, যা দুটি শর্তই মানে? সাধারণ শর্তে এটি যুক্তিবিদ্যার satisfiability সমস্যা — সাধারণভাবে কঠিন, আর প্রতিটি নতুন অনুরোধে সব চলমান predicate-এর সাথে তুলনা করতে হয়। কয়েক হাজার transaction-এর সিস্টেমে তা অচল।

বাস্তব সমাধান একটি চতুর অনুমান: শর্তের বদলে index-এর একটি পরিসর lock করা। index সাজানো, তাই “age 15 থেকে 25” মানে index-এর একটি নির্দিষ্ট টুকরো, আর সংঘাত যাচাই মানে শুধু টুকরোগুলোর মিল দেখা। IBM-এর C. Mohan-এর ARIES/KVL (১৯৯০) এই key-value locking-এর একটি প্রভাবশালী রূপ দেয়; InnoDB-র next-key lock একই ধারার। কিন্তু অনুমানটি মোটা দাগের: যা লাগে তার চেয়ে বেশি আটকায়। শর্তের কলামে index না থাকলে InnoDB-কে পুরো টেবিল scan করতে হয়, আর যা ছোঁয় সবকিছুর ওপর next-key lock — কার্যত পুরো টেবিল আটকে যায়।

পার্শ্বপ্রতিক্রিয়াগুলো বাস্তবে পরিচিত। REPEATABLE READ-এ দুটি transaction একই ফাঁকে gap lock নিতে পারে (gap lock পরস্পর সংঘাত করে না), তারপর দুজনেই সেই ফাঁকে INSERT করতে চাইলে deadlock — ডেভেলপারদের কাছে “কোনো সারিই তো ছিল না, তবু deadlock!” ধাঁধা। অনেক দল তাই READ COMMITTED-এ যায়, যেখানে InnoDB সাধারণ খোঁজে gap lock প্রায় নেয় না — বদলে phantom-এর সুরক্ষা হারায়।

PostgreSQL ভিন্ন পথ নিয়েছে। ৯.১ থেকে তার SERIALIZABLE হলো Serializable Snapshot Isolation, যা একধরনের predicate lock (SIREAD) রাখে — কিন্তু এগুলো কাউকে আটকায় না, শুধু বিপজ্জনক নির্ভরতার নকশা খোঁজে, আর পেলে একটি transaction বাতিল করে। অর্থাৎ predicate lock ফিরে এসেছে, তবে “অপেক্ষা করাও”-র বদলে “নজর রাখো”-র ভূমিকায় (পর্ব ৪৭)।

মূল উপলব্ধি: তত্ত্বের নিখুঁত সমাধান প্রায়ই বাস্তবে অচল; প্রকৌশল তখন নিরাপদ দিকের একটি অনুমান বেছে নেয়, আর সেই অনুমানের পার্শ্বপ্রতিক্রিয়া চিনে রাখাই দক্ষতা।

প্র ০৫একটি ওয়েব অ্যাপে দোকানের কর্মী পণ্যের ফর্ম খুলে পাঁচ মিনিট ধরে দাম আর বিবরণ বদলান, তারপর Save চাপেন। এর মধ্যে আরেকজন একই পণ্য বদলে ফেলতে পারেন। ফর্ম খোলার সময় SELECT … FOR UPDATE দিয়ে সারিটি lock করে রাখা কেন ভুল, আর সঠিক নকশা কী?

FOR UPDATE-এর lock থাকে transaction-এর ভেতরে, আর transaction থাকে একটি ডেটাবেস-সংযোগে। ওয়েবে ফর্ম খোলা আর Save চাপা দুটি আলাদা HTTP অনুরোধ, প্রায়ই আলাদা সার্ভার-প্রসেস আর connection pool-এর (পর্ব ৪৯) আলাদা সংযোগে। পাঁচ মিনিট একটি transaction আর তার সংযোগ খোলা রাখা মানে pool-এর একটি সংযোগ আটকে রাখা, সেই সারির সব লেখককে পাঁচ মিনিট দাঁড় করানো, আর কর্মী যদি ব্রাউজার বন্ধ করে চলে যান — lock কখন ছাড়বে? timeout পর্যন্ত। strict 2PL-এর শিক্ষা এখানে সরাসরি খাটে: lock ধরার সময় যত লম্বা, concurrency তত কম। আর মানুষের চিন্তার সময় ডেটাবেসের হিসাবে অসীম।

সঠিক নকশা optimistic locking. ফর্ম খোলার সময় সারির সাথে version (বা updated_at) পড়ে ফর্মে লুকিয়ে রাখা হয়। Save-এর সময় UPDATE … SET …, version = version + 1 WHERE id = ? AND version = ?. ০ সারি বদলালে অ্যাপ জানে কেউ এর মধ্যে বদলেছে — তখন কর্মীকে দেখানো হয় “এই পণ্যটি অন্য কেউ বদলেছেন; নতুন রূপটি দেখে আবার সংরক্ষণ করুন”, সম্ভব হলে দুই রূপের পার্থক্যসহ। ডেটাবেসের lock থাকে শুধু UPDATE-এর কয়েক মিলিসেকেন্ড।

একই ধারণা HTTP-তেও আছে: সার্ভার একটি সংস্করণ-চিহ্ন (ETag) পাঠায়, ক্লায়েন্ট লেখার সময় If-Match header-এ সেটি ফেরত দেয়, আর না মিললে সার্ভার 412 Precondition Failed জানায়। Git-এর push-ও একই রকম: আপনার শাখা পুরোনো হলে push প্রত্যাখ্যান, আগে নতুনটি টেনে আনতে হয়।

কখন pessimistic ঠিক? যখন সংঘাত ঘন আর আবার চেষ্টা দামি, আর lock ধরা থাকে অল্প সময় একটি transaction-এর ভেতরে — যেমন সার্ভারের একটি অনুরোধেই সিট খুঁজে বুক করা। মানুষের চিন্তার সময় জুড়ে কখনো নয়।

মূল উপলব্ধি: lock হলো স্বল্পমেয়াদি হাতিয়ার; মানুষের সময়-মাপের “দাবি” রাখতে version-যাচাই লাগে। কোন স্তরে সংঘাত সামলাচ্ছেন — ডেটাবেস, অ্যাপ না HTTP — তা আগে ঠিক করুন।

অনুশীলন

একটি কোয়েরি লেখা, একটি schedule যাচাই, দুটি নিয়ম হাতে চালানো।

  1. কোয়েরি লিখুন: অংশ ২-এর lock_table (item, txn, mode, status, seq) আর compat (held, req, ok) টেবিল নিন। একটি কোয়েরি লিখুন, যা প্রতিটি waiting অনুরোধের পাশে বলে, সেটি এখনই মঞ্জুরযোগ্য কি না — অর্থাৎ একই item-এর সব granted lock-এর সাথে compatible কি না (queue-র ক্রম আপাতত উপেক্ষা করে)। অংশ ২-এর এডিটরে চালাতে পারেন।
    SELECT w.item, w.txn, w.mode,
           CASE WHEN EXISTS (SELECT 1 FROM lock_table g
                             JOIN compat c ON c.held = g.mode AND c.req = w.mode
                             WHERE g.item = w.item AND g.status = 'granted' AND c.ok = 0)
                THEN 'no' ELSE 'yes' END AS grantable
    FROM lock_table w
    WHERE w.status = 'waiting'
    ORDER BY w.seq;

    মূল ডেটায় ফল: seat_7 | T3 | X | no (দুটি S-এর সাথে X চলে না) আর seat_7 | T4 | S | yes. দ্বিতীয়টিই শিক্ষা: শুধু compatibility দেখলে T4 এখনই পেত, কিন্তু queue-র ক্রম মানলে T4-কে T3-এর পেছনে দাঁড়াতে হয় — নইলে T3-এর starvation হতে পারত।

  2. হাতে যাচাই করুন: T1: S(A) R(A) X(B) U(A) R(B) W(B) U(B); T2: S(A) R(A) S(B) U(A) U(B); T3: X(A) W(A) U(A) X(B) W(B) U(B). (ক) কোনটি 2PL মানে? (খ) যেগুলো মানে, তাদের lock point কোথায়?

    (ক) T1 মানে: সব lock নেওয়া (S(A), X(B)) প্রথম unlock-এর আগে। T2 মানে: S(A), S(B), তারপর শুধু unlock. T3 মানে না: U(A)-এর পর X(B) — shrinking phase-এ নতুন lock. (খ) T1-এর lock point X(B) নেওয়ার মুহূর্ত, T2-র S(B). T1 strict 2PL-ও নয়: X(B) ছাড়া হয়েছে, কিন্তু COMMIT-এর কথা নেই — strict হতে হলে U(B) আসবে COMMIT-এর পরে। T3-কে 2PL বানাতে X(B) নিতে হবে U(A)-এর আগে।

  3. ফল আগে বলুন: TS(T1) = 10, TS(T2) = 20, TS(T3) = 30. অনুরোধ তিনটি: (১) T3 চায় এমন lock যা T1-এর কাছে; (২) T1 চায় যা T2-র কাছে; (৩) T2 চায় যা T3-র কাছে। wait-die আর wound-wait-এ প্রতিটিতে কী ঘটে?
    • (১) T3 ছোট, T1 বড়। wait-die: T3 abort (মরে)। wound-wait: T3 অপেক্ষা করে।
    • (২) T1 বড়, T2 ছোট। wait-die: T1 অপেক্ষা করে। wound-wait: T2 abort (আহত)।
    • (৩) T2 বড়, T3 ছোট। wait-die: T2 অপেক্ষা করে। wound-wait: T3 abort.

    যাচাই: wait-die-এর অপেক্ষাগুলো T1 → T2 আর T2 → T3 — ts শুধু বাড়ছে, চক্র নেই। wound-wait-এর একমাত্র অপেক্ষা T3 → T1 — ছোট থেকে বড়।

  4. হাতে চালান (trace): TS(T1) = 1, TS(T2) = 2; শুরুতে Q-এর R-ts = W-ts = 0. কাজের ক্রম: r1(Q), w2(Q), w1(Q), r2(Q). মৌলিক timestamp ordering-এ আর Thomas write rule-এ প্রতিটি ধাপের পর R-ts, W-ts আর সিদ্ধান্ত লিখুন।
    • r1(Q): 1 ≥ W-ts 0 → পড়া; R-ts = 1.
    • w2(Q): 2 ≥ R-ts 1 আর 2 ≥ W-ts 0 → লেখা; W-ts = 2.
    • w1(Q): 1 ≥ R-ts 1 (পাস), কিন্তু 1 < W-ts 2. মৌলিক TO: T1 rollback. Thomas write rule: লেখাটি বাদ, T1 চলতে থাকে — serial T1 → T2-এ এই লেখা এমনিতেই T2-র নিচে চাপা পড়ত।
    • r2(Q): 2 ≥ W-ts 2 → পড়া; R-ts = 2. T2 পড়ে নিজের লেখা মানটি।

    মূল schedule-টি conflict serializable নয়: r1 → w2 থেকে তীর T1 → T2, আর w2 → w1 থেকে T2 → T1 — চক্র। Thomas rule-এ পুরোনো লেখা w1 বাদ পড়ায় যা থাকে, তা serial T1 → T2-এর view-সমতুল্য।

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

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

ক্রমStrict 2PL মেনে চলা একটি transaction-এর ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. BEGIN — transaction শুরু
  2. প্রথম সারি পড়তে S lock নেওয়া
  3. শেষ যে lock লাগবে তা নেওয়া — lock point
  4. COMMIT-এর log লেখা
  5. সব lock একসাথে ছাড়া

একটি সারিতে T1 shared (S) lock ধরে আছে। এখন T2 চায় S lock আর T3 চায় exclusive (X) lock, আর কোনো অপেক্ষমাণ অনুরোধ নেই — T2 আগে এসেছে। কী ঘটবে?

  • T2 আর T3 দুজনেই অপেক্ষা করবে
  • T2 সাথে সাথে পাবে, T3 অপেক্ষা করবে
  • T3 সাথে সাথে পাবে, T2 অপেক্ষা করবে
  • দুজনেই সাথে সাথে পাবে

সব transaction (মৌলিক) two-phase locking মেনে চললে কোনটি নিশ্চিত?

  • কখনো deadlock হবে না
  • কখনো cascading abort হবে না
  • প্রতিটি schedule conflict serializable
  • সব transaction সর্বোচ্চ গতিতে একসাথে চলবে

Strict 2PL কীভাবে cascading abort ঠেকায়?

  • লেখার (X) lock COMMIT বা abort পর্যন্ত ধরে রেখে, যাতে কেউ অপাকা মান পড়তে না পারে
  • শুরুতেই সব lock একসাথে নিয়ে
  • timestamp দেখে ছোট transaction-কে abort করিয়ে
  • পড়ার lock তাড়াতাড়ি ছেড়ে দিয়ে

TS(T1) = 10 আর TS(T2) = 20 (ছোট সংখ্যা = পুরোনো)। T2 এমন একটি lock চায় যা T1-এর কাছে আছে। wait-die নিয়মে কী ঘটে?

  • T2 অপেক্ষা করে
  • T1 abort হয়, T2 lock পায়
  • T2 abort হয় আর নতুন timestamp নিয়ে ফেরে
  • T2 abort হয় আর একই timestamp নিয়ে ফেরে

Multiple-granularity locking-এ T1 orders টেবিলের একটি সারি বদলাচ্ছে, তাই টেবিলে IX আর সারিতে X ধরে আছে। T2 পুরো orders টেবিল পড়তে টেবিলে S চায়। কী ঘটবে?

  • T2 সাথে সাথে S পায়, কারণ IX কোনো আসল lock নয়
  • T2 অপেক্ষা করে, কারণ টেবিল-স্তরে IX আর S compatible নয়
  • lock manager সব সারির lock একে একে দেখে সিদ্ধান্ত নেয়
  • T2-কে S-এর বদলে IS দেওয়া হয় আর সে পুরো টেবিল পড়ে

একটি অ্যাপ optimistic locking ব্যবহার করে: UPDATE product SET stock = 7, version = 5 WHERE id = 1 AND version = 4. DBMS জানায় “০টি সারি বদলেছে”, অথচ id = 1-এর সারি আছে। এর সবচেয়ে সম্ভাব্য অর্থ আর ঠিক প্রতিক্রিয়া কী?

  • সারিটি মুছে গেছে; নতুন করে INSERT করা উচিত
  • version-এর শর্ত বাদ দিয়ে আবার UPDATE চালানো উচিত
  • অন্য কেউ আগে বদলেছে; সারিটি আবার পড়ে নতুন version দিয়ে আবার চেষ্টা
  • ডেটাবেসে ত্রুটি হয়েছে; সার্ভার রিস্টার্ট করা উচিত

শব্দকোষ

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

মনে রাখুন

  1. Lost update হয় “পড়ো-হিসাব-লেখো”-র মাঝে অন্যজন ঢুকলে; সমাধান শর্তযুক্ত একক UPDATE, lock, বা version-যাচাই।
  2. S আর X: শুধু S–S compatible. lock table হলো item → granted/waiting queue; queue-র ক্রম মানা starvation ঠেকায়।
  3. 2PL (growing, তারপর shrinking) conflict serializability দেয় — serial ক্রম lock point-এর ক্রম; strict 2PL X lock COMMIT পর্যন্ত রেখে cascading abort ঠেকায়, rigorous সব lock.
  4. Granularity: intention lock (IS, IX, SIX) ওপরের গিঁটে ইচ্ছা জানায়; ওপর থেকে নিচে নেওয়া, নিচ থেকে ওপরে ছাড়া।
  5. Deadlock = wait-for graph-এ চক্র: ধরা আর victim বাতিল করা, বা wait-die / wound-wait-এ ঠেকানো, বা অ্যাপে একই ক্রমে lock নেওয়া আর retry রাখা। timestamp ordering আর optimistic control অপেক্ষা ছাড়াই কাজ করে, বাতিলের দামে; SQLite-এ একজন লেখক, BEGIN IMMEDIATE আর WAL.

পরের পর্বে: পাঠককে lock ছাড়াই পড়তে দেওয়ার কৌশল — একই সারির কয়েকটি সংস্করণ। পর্ব ৪৭-এ MVCC, snapshot, আর SQL-এর isolation level: কোন level কোন anomaly ঠেকায়।

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

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