এই পর্বে যা শিখবেন
- Transaction কী, আর তার পাঁচটি অবস্থা — active থেকে committed বা aborted পর্যন্ত
- ACID-এর প্রতিটি অক্ষর, একটি করে ব্যর্থতার গল্প দিয়ে — আর কোন নিশ্চয়তা কে দেয়
- BEGIN, COMMIT, ROLLBACK, SAVEPOINT, ROLLBACK TO আর RELEASE — লাইভ SQL-এ
- Autocommit, SQLite-এর DEFERRED/IMMEDIATE/EXCLUSIVE, statement-level atomicity আর transactional DDL
- অ্যাপে transaction-এর নকশা: দীর্ঘ transaction-এর ক্ষতি, retry, idempotency key, আর BASE-এর প্রথম পরিচয়
১ · Transaction কী — কাজের একটি অবিভাজ্য একক
Rahim তাঁর bKash থেকে Karim-কে ২,০০০ টাকা পাঠালেন। ডেটাবেসের দিক থেকে এটি অন্তত দুটি আলাদা লেখা: Rahim-এর ব্যালান্স ২,০০০ কমানো, আর Karim-এর ব্যালান্স ২,০০০ বাড়ানো। সাথে হয়তো একটি লেনদেনের রেকর্ড। এখন ভাবুন, প্রথম লেখাটির পরই সার্ভারের বিদ্যুৎ চলে গেল। Rahim-এর টাকা কাটা গেছে, Karim কিছু পাননি — দুই হাজার টাকা স্রেফ হাওয়া। উল্টো ক্রমে লিখলেও বিপদ: Karim পেলেন, Rahim-এর কাটা হলো না — টাকা শূন্য থেকে তৈরি।
এই সমস্যার সমাধানই transaction: কয়েকটি read আর write অপারেশনের একটি দল, যাকে DBMS একটি logical unit of work হিসেবে দেখে। দলটির সব কাজ একসাথে পাকা হবে, নয়তো কোনোটিই হবে না। পর্ব ৩-এ যে transaction manager আর recovery manager-এর নাম শুনেছিলেন, এই মডিউলে তাদের ভেতরে ঢুকবেন।
জমি রেজিস্ট্রির কথা ভাবুন। বিক্রেতার নাম খতিয়ান থেকে কাটা, ক্রেতার নাম বসানো, দলিলে সই আর সাক্ষী, রেজিস্ট্রারের সিল — সব মিলে একটি কাজ। সিল পড়ার আগে কোনো ধাপে ঝামেলা হলে পুরো দলিল বাতিল; অর্ধেক-রেজিস্ট্রি বলে কিছু নেই। আর সিল পড়ার পর অফিসে আগুন লাগলেও রেকর্ড-বইয়ে মালিকানা বদল থেকে যায়। Transaction-এর COMMIT ঠিক ওই সিল।
তত্ত্বের বইয়ে একটি transaction লেখা হয় তার read আর write অপারেশনের ক্রম হিসেবে। A আর B দুটি ডেটা আইটেম (এখানে দুটি ব্যালান্স) হলে টাকা পাঠানো T₁:
T1: read(A)
A := A - 2000
write(A)
read(B)
B := B + 2000
write(B)
commitread(X) ডেটাবেস থেকে X-এর মান transaction-এর নিজের মেমরিতে আনে; write(X) সেই মান ডেটাবেসে ফেরত দেয় (আগে buffer pool-এ, পরে ডিস্কে — পর্ব ৩৯)। এই সরল খাতাই পর্ব ৪৫-এ schedule আর serializability-র ভাষা হবে। লক্ষ করুন, write(A)-এর পর আর write(B)-এর আগে ডেটাবেস একটি “ভুল” অবস্থায় থাকে — মোট টাকা ২,০০০ কম। Transaction-এর পুরো প্রতিশ্রুতি হলো: এই মধ্যবর্তী অবস্থা কেউ দেখবে না, আর এই অবস্থায় আটকে থাকা যাবে না।
এই পর্বের লাইভ SQL চলে একটি ছোট ওয়ালেট ডেটাবেসে। wallet টেবিলে তিনটি হিসাব: id, owner (মালিক) আর balance (টাকা, পূর্ণসংখ্যা) — Rahim ৫,০০০, Karim ১,২০০, Shapla ৩০০; মোট ৬,৫০০. balance-এ একটি CHECK constraint আছে: balance >= 0. transfers টেবিলে প্রতিটি লেনদেনের রেকর্ড: from_id, to_id (দুটিই wallet-এর foreign key), amount (> 0) আর idem_key (UNIQUE; অংশ ৯-এ কাজে লাগবে)। প্রতিটি এডিটর নতুন করে এই ডেটা বানায় — “নমুনা ডেটাবেস” বাক্সে পুরো স্ক্রিপ্ট দেখতে পাবেন।
২ · Transaction-এর পাঁচটি অবস্থা
প্রতিটি transaction তার জীবনে কয়েকটি নির্দিষ্ট অবস্থার মধ্য দিয়ে যায়। Silberschatz, Korth আর Sudarshan-এর পাঠ্যবই থেকে প্রচলিত মডেলটিতে পাঁচটি অবস্থা: active, partially committed, committed, failed আর aborted. committed বা aborted — এই দুটির যেকোনো একটিতে পৌঁছালে transaction-টি “terminated” — শেষ। নিচের ডায়াগ্রামে দুটি পথই দেখুন: Rahim → Karim ২,০০০ টাকা সফলভাবে, আর Shapla → Karim ৫০০ টাকা ব্যর্থভাবে (Shapla-র আছে মাত্র ৩০০)।
| transaction | |
| অবস্থা | |
| অন্যরা দেখে | |
| মোট টাকা (পাকা) |
- Active. BEGIN-এর পর থেকে transaction active — স্টেটমেন্টগুলো চলছে। Rahim-এর হিসাব থেকে ২,০০০ কাটা হয়েছে, কিন্তু সেটি এখনো খসড়া; অন্য transaction সাধারণত এটি দেখে না (কতটা দেখে, তা isolation level-এর ওপর নির্ভর করে — অংশ ৭)।
- Partially committed. শেষ স্টেটমেন্টও সফল, আর অ্যাপ COMMIT পাঠিয়েছে। কিন্তু “পাকা” হওয়ার আগে DBMS-কে নিশ্চিত হতে হয় যে পরিবর্তনের বিবরণ (log) স্থায়ী স্টোরেজে পৌঁছেছে। এই মুহূর্তে crash হলে transaction-টি এখনো ব্যর্থ হতে পারে।
- Committed. commit record ডিস্কে লেখা শেষ — এই বিন্দু থেকে পরিবর্তন স্থায়ী, আর অ্যাপ “সফল” উত্তর পায়। অন্যরা এখন নতুন ব্যালান্স দেখে: ৩,০০০, ৩,২০০, ৩০০. মোট এখনো ৬,৫০০.
- নতুন transaction, active. Shapla Karim-কে ৫০০ পাঠাতে চান। প্রথম লেখা — Karim +৫০০ — সফল। দ্বিতীয় লেখাটি Shapla-র ব্যালান্স থেকে ৫০০ কাটার চেষ্টা।
- Failed. ৩০০ − ৫০০ = −২০০ — CHECK constraint ভাঙল। স্বাভাবিকভাবে আর এগোনো যাবে না। একই অবস্থায় পৌঁছানো যায় অন্য কারণেও: deadlock, বিদ্যুৎ চলে যাওয়া, অ্যাপের নিজের ROLLBACK, বা partially committed অবস্থায় log লিখতে না পারা।
- Aborted. DBMS আগের মানগুলো ফিরিয়ে দেয় (undo) — Karim-এর +৫০০ মুছে যায়, ডেটাবেস BEGIN-এর আগের অবস্থায়। এরপর দুই পথ: সমস্যা সাময়িক হলে (deadlock) restart — নতুন transaction হিসেবে আবার; যুক্তিগত হলে (টাকা নেই) kill — ব্যবহারকারীকে জানানো।
দুটি সূক্ষ্ম কথা। এক, “committed” অবস্থাটি এক-দিকের দরজা: একবার commit হলে সেই transaction আর ROLLBACK করা যায় না। ভুল হলে উল্টো কাজের একটি নতুন transaction লাগে — হিসাবরক্ষণে যাকে বলে compensating entry, যেমন ভুল করে পাঠানো টাকা ফেরতের আলাদা লেনদেন। দুই, ডায়াগ্রামের পাশের টেবিলে দেখুন, “পাকা” মোট টাকা কখনো বদলায়নি। মধ্যবর্তী অবস্থাগুলো ছিল, কিন্তু শুধু transaction-এর নিজের ভেতরে।
৩ · Atomicity — হয় সব, নয় কিছুই না
১৯৮৩ সালে Theo Härder আর Andreas Reuter একটি প্রবন্ধে transaction-এর চারটি গুণকে একটি সংক্ষিপ্ত নাম দেন: ACID. তার আগে, ১৯৮১ সালে Jim Gray “The Transaction Concept: Virtues and Limitations” প্রবন্ধে atomicity, consistency আর durability-র কথা লিখেছিলেন। প্রথম অক্ষর Atomicity: transaction-এর সব কাজ একসাথে কার্যকর হয়, নয়তো একটিও না। গ্রিক “atomos” মানে যা ভাঙা যায় না।
Atomicity-র শত্রু তিন রকম: স্টেটমেন্টের ত্রুটি (constraint ভাঙা, শূন্য দিয়ে ভাগ), অ্যাপের সিদ্ধান্ত (“টাকা নেই, বাতিল করো”), আর crash (বিদ্যুৎ, OS, ডিস্ক)। প্রথম দুটিতে DBMS চালু থাকে, তাই সরাসরি ফেরানো যায়। তৃতীয়টি কঠিন: crash-এর সময় কিছু পরিবর্তন হয়তো ডিস্কে পৌঁছে গেছে। কৌশলটি হলো undo log: কোনো পরিবর্তন ডিস্কে যাওয়ার আগে তার আগের মান একটি log-এ লিখে রাখা। Crash-এর পর DBMS চালু হলে log পড়ে দেখে, কোন transaction-এর commit record নেই — তাদের সব পরিবর্তন আগের মান দিয়ে মুছে দেয়। এই crash recovery-র বিস্তারিত (WAL, checkpoint, ARIES) পর্ব ৪৮-এ। এখন দেখুন, transaction থাকা আর না থাকায় কী তফাত:
| transaction ছাড়া | |
| transaction-সহ | |
| recovery-র কাজ |
- শুরু। দুটি হিসাব, মোট ৬,২০০ টাকা (Shapla-কে এই গল্পে বাদ রাখা হলো)। বাঁ দিকে অ্যাপ BEGIN ছাড়া দুটি UPDATE পাঠায়; ডান দিকে একটি transaction-এর ভেতরে।
- প্রথম লেখা। বাঁ দিকে autocommit: UPDATE শেষ হওয়ামাত্র সেটি নিজেই একটি পূর্ণ transaction হিসেবে পাকা — Rahim ৩,০০০. ডান দিকে একই UPDATE খসড়া, আর log-এ লেখা হয় “T7 Rahim-এর মান ৫,০০০ থেকে ৩,০০০”. DBMS চাইলে page-টি ডিস্কে লিখেও ফেলতে পারে — আগের মান log-এ আছে বলে ভয় নেই।
- Crash. দুই দিকেই Karim-এর লেখা আর হয়নি, মেমরির সব কিছু হারিয়েছে। পার্থক্য শুধু ডিস্কে কী আছে তা নিয়ে।
- Recovery. বাঁ দিকে DBMS-এর চোখে সব ঠিক আছে — একটি সফল, পাকা UPDATE. ২,০০০ টাকা উধাও, আর কোনো log বলে না যে এটি একটি বড় কাজের অর্ধেক ছিল। ডান দিকে log-এ T7-এর commit record নেই, তাই undo: Rahim আবার ৫,০০০. মোট ৬,২০০.
- পুরো transaction. অ্যাপ আবার চেষ্টা করে। দুটি UPDATE, তারপর COMMIT. DBMS commit record ডিস্কে পাকা করে (fsync, অংশ ৭) তারপরই “সফল” বলে।
- Commit-এর পরে crash. ধরুন data page-গুলো তখনো শুধু মেমরিতে ছিল। চালু হয়ে DBMS log-এ দেখে T8 committed, তাই log থেকে পরিবর্তনগুলো আবার করে (redo). Undo দেয় atomicity, redo দেয় durability — একই log দুই কাজে।
এটি শুধু ছবি নয়। নিচের Python প্রোগ্রামটি একটি আলাদা প্রক্রিয়া (child process) চালায়, যে Rahim-এর টাকা কেটেই os._exit(1) দিয়ে হঠাৎ মারা যায় — বিদ্যুৎ চলে যাওয়ার মতো, কোনো পরিষ্কার-করা ছাড়া। তারপর মূল প্রোগ্রাম ডেটাবেস ফাইলটি আবার খোলে। একবার autocommit-এ, একবার transaction-এর ভেতরে:
import sqlite3, subprocess, sys, tempfile, os
CHILD = """
import sqlite3, sys, os
con = sqlite3.connect(sys.argv[1], isolation_level=None)
if sys.argv[2] == "txn": con.execute("BEGIN")
con.execute("UPDATE wallet SET balance = balance - 2000 WHERE id = 1")
os._exit(1) # power cut: Karim's credit never runs
"""
def trial(mode):
path = os.path.join(tempfile.mkdtemp(), "wallet.db")
con = sqlite3.connect(path, isolation_level=None)
con.execute("CREATE TABLE wallet (id INTEGER PRIMARY KEY, balance INTEGER)")
con.execute("INSERT INTO wallet VALUES (1, 5000), (2, 1200)")
con.close()
subprocess.run([sys.executable, "-c", CHILD, path, mode])
con = sqlite3.connect(path) # restart after the crash
rows = [r[0] for r in con.execute("SELECT balance FROM wallet ORDER BY id")]
print(f"{mode:10} after crash: {rows} total {sum(rows)}")
trial("autocommit")
trial("txn")আউটপুট দুই লাইন: autocommit after crash: [3000, 1200] total 4200 আর txn after crash: [5000, 1200] total 6200. isolation_level=None দিলে Python-এর sqlite3 নিজে থেকে কোনো BEGIN পাঠায় না, তাই প্রথম ক্ষেত্রে UPDATE-টি সাথে সাথে পাকা — ২,০০০ টাকা হারাল। দ্বিতীয় ক্ষেত্রে COMMIT কখনো আসেনি, তাই পরিবর্তনটি টেকেনি। একটি সততার কথা: SQLite-এর ডিফল্ট rollback journal মোডে COMMIT-এর আগে সাধারণত মূল ফাইলে কিছুই লেখা হয় না, তাই এখানে undo-র কাজ সামান্য। কিন্তু খসড়া মেমরিতে না ধরলে SQLite কিছু page আগেই ফাইলে লেখে, আর তখন journal-এ রাখা আগের মান দিয়ে পরের বার ফাইল খোলার সময় ফেরত আনে — ঠিক ডায়াগ্রামের ডান দিকের মতো।
৪ · SQL-এ transaction — BEGIN, COMMIT আর ROLLBACK
তিনটি কমান্ডেই মূল কাজ: BEGIN (বা স্ট্যান্ডার্ড SQL-এর START TRANSACTION) transaction শুরু করে; COMMIT সব পরিবর্তন পাকা করে; ROLLBACK সব বাতিল করে BEGIN-এর আগের অবস্থায় ফেরায়। প্রথমে একটি সফল টাকা পাঠানো:
BEGIN;
UPDATE wallet SET balance = balance - 2000 WHERE id = 1;
UPDATE wallet SET balance = balance + 2000 WHERE id = 2;
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 2, 2000);
COMMIT;
SELECT * FROM wallet;
SELECT SUM(balance) AS total FROM wallet;রানার প্রতিটি স্টেটমেন্টের ফল আলাদা দেখায়: ✓ BEGIN, দুটি ✓ UPDATE — ১টি সারি বদলেছে, ✓ INSERT — ১টি সারি বদলেছে, ✓ COMMIT. তারপর ব্যালান্স: Rahim 3000, Karim 3200, Shapla 300; মোট 6500 — শুরুর মতোই। তিনটি লেখা একটি একক হিসেবে পাকা হয়েছে।
এবার Shapla Karim-কে ৫০০ পাঠাতে চান, যদিও তাঁর আছে ৩০০. অ্যাপটি আগে Karim-এর হিসাবে যোগ করে, তারপর Shapla-র থেকে কাটে (ক্রমটি ইচ্ছে করে “ভুল”, যাতে প্রথম লেখাটি সফল হয়):
BEGIN;
UPDATE wallet SET balance = balance + 500 WHERE id = 2;
UPDATE wallet SET balance = balance - 500 WHERE id = 3;
COMMIT;ফল: ✓ BEGIN, ✓ UPDATE — ১টি সারি বদলেছে (Karim এখন খসড়ায় ১,৭০০), তারপর ত্রুটি (স্টেটমেন্ট ৩): CHECK constraint failed: balance >= 0. এই পাতার রানার প্রথম ত্রুটিতেই থেমে যায়, তাই COMMIT আর চলেনি, আর পরের রানে ডেটাবেস নতুন করে তৈরি হয়। কিন্তু আসল ডেটাবেসে কী অবস্থা? SQLite শুধু ব্যর্থ স্টেটমেন্টটি ফেরায় — একে বলে statement-level atomicity — আর transaction খোলা থাকে, Karim-এর +৫০০ খসড়াসহ। অ্যাপ যদি ত্রুটি উপেক্ষা করে COMMIT পাঠায়, Karim ৫০০ টাকা পাবেন যা কারও থেকে কাটা হয়নি! তাই নিয়ম: transaction-এর ভেতরে যেকোনো ত্রুটিতে অ্যাপ ROLLBACK পাঠাবে।
এখানে দুই স্তরের atomicity আলাদা করে চিনুন। Statement-level: একটি স্টেটমেন্ট — ধরুন ১০০ সারির UPDATE — হয় পুরোটা, নয়তো কিছুই না; প্রায় সব আধুনিক DBMS এটি সবসময় দেয়। Transaction-level: কয়েকটি স্টেটমেন্টের পুরো দল হয় পুরোটা, নয়তো কিছুই না; এটি পেতে আপনাকে BEGIN … COMMIT লিখতে হয়, আর ত্রুটিতে ROLLBACK. ত্রুটির পর DBMS-গুলোর আচরণও আলাদা:
-- PostgreSQL: একটি ত্রুটিতেই পুরো transaction "aborted"
BEGIN;
UPDATE wallet SET balance = balance + 500 WHERE id = 2;
UPDATE wallet SET balance = balance - 500 WHERE id = 3;
-- ERROR: new row for relation "wallet" violates check constraint "wallet_balance_check"
SELECT * FROM wallet;
-- ERROR: current transaction is aborted, commands ignored until end of transaction block
COMMIT; -- PostgreSQL উত্তর দেয় ROLLBACK — কিছুই পাকা হয় না
-- MySQL (InnoDB): শুধু ব্যর্থ স্টেটমেন্টটি ফেরে, transaction খোলা থাকে
START TRANSACTION;
UPDATE wallet SET balance = balance + 500 WHERE id = 2;
UPDATE wallet SET balance = balance - 500 WHERE id = 3; -- ERROR 3819 (CHECK, 8.0.16+)
ROLLBACK; -- অ্যাপকে নিজে পাঠাতে হবেPostgreSQL-এর কঠোরতা নিরাপদ: ভুল করে আধা-কাজ COMMIT হওয়ার উপায় নেই। SQLite আর MySQL নমনীয়: ব্যর্থ স্টেটমেন্ট বাদ দিয়ে আপনি চাইলে অন্য পথে এগোতে পারেন — কিন্তু তখন দায়িত্বটি পুরোপুরি আপনার। একটি ব্যতিক্রম মনে রাখুন: MySQL-এ deadlock হলে InnoDB পুরো transaction-ই ফেরায়।
ত্রুটির অপেক্ষা না করে অ্যাপ নিজেও বাতিলের সিদ্ধান্ত নিতে পারে। একটি প্রচলিত কৌশল শর্তযুক্ত UPDATE: WHERE balance >= 500 জুড়ে দিলে টাকা না থাকলে কোনো সারি বদলায় না, আর অ্যাপ “০টি সারি” দেখে ROLLBACK পাঠায়:
BEGIN;
UPDATE wallet SET balance = balance + 500 WHERE id = 2;
UPDATE wallet SET balance = balance - 500
WHERE id = 3 AND balance >= 500;
SELECT * FROM wallet; -- transaction-এর ভেতর থেকে দেখা
ROLLBACK;
SELECT * FROM wallet; -- ROLLBACK-এর পরেদ্বিতীয় UPDATE-এ ✓ UPDATE — ০টি সারি বদলেছে — কোনো ত্রুটি নয়, শুধু শর্ত মেলেনি। প্রথম SELECT-এ Karim 1700: একটি transaction নিজের খসড়া পরিবর্তন সবসময় নিজে দেখে। ✓ ROLLBACK-এর পর দ্বিতীয় SELECT-এ আবার 5000 | 1200 | 300. একটি সুবিধা: শর্ত আর লেখা একই স্টেটমেন্টে, তাই “আগে SELECT করে দেখি টাকা আছে কি না, তারপর UPDATE” — এর মাঝখানে অন্য কেউ টাকা তুলে নেওয়ার ফাঁক থাকে না (অংশ ৭-এর lost update).
৫ · SAVEPOINT — transaction-এর ভেতরে ফেরার চিহ্ন
কখনো পুরো transaction বাতিল করতে চান না, শুধু শেষ কয়েকটি কাজ। একটি অনলাইন মুদি দোকানের অর্ডারে পাঁচটি আইটেম; একটির স্টক নেই। পুরো অর্ডার বাতিলের বদলে শুধু সেই আইটেমটি বাদ দিলেই হয়। এর জন্য SAVEPOINT: transaction-এর ভেতরে একটি নাম-দেওয়া চিহ্ন।
SAVEPOINT name— এখানে একটি চিহ্ন বসানো।ROLLBACK TO name— চিহ্নের পরের সব পরিবর্তন বাতিল করা; transaction চলতে থাকে, আর চিহ্নটিও থেকে যায় (আবার ফেরা যায়)।RELEASE name— চিহ্নটি তুলে নেওয়া; তার পরের পরিবর্তনগুলো মূল transaction-এর অংশ হয়ে যায়। এটি COMMIT নয় — পরে ROLLBACK হলে এগুলোও বাতিল হবে।
এই এডিটরে নতুন তিনটি টেবিল: products (name, stock; CHECK stock ≥ 0) — Rice 5kg ১০টি, Hilsa ১টি, Lentils 1kg ৫টি; orders (id, customer); আর order_items (order_id, product, qty).
BEGIN;
INSERT INTO orders VALUES (1, 'Rahim');
SAVEPOINT item_rice;
INSERT INTO order_items VALUES (1, 'Rice 5kg', 2);
UPDATE products SET stock = stock - 2 WHERE name = 'Rice 5kg' AND stock >= 2;
RELEASE item_rice;
SAVEPOINT item_hilsa;
INSERT INTO order_items VALUES (1, 'Hilsa', 3);
UPDATE products SET stock = stock - 3 WHERE name = 'Hilsa' AND stock >= 3;
ROLLBACK TO item_hilsa; -- ০টি সারি বদলেছে, তাই এই আইটেম বাদ
RELEASE item_hilsa;
COMMIT;
SELECT * FROM order_items;
SELECT * FROM products;Rice-এর UPDATE-এ ১টি সারি, Hilsa-র UPDATE-এ ০টি সারি বদলেছে (স্টক মাত্র ১)। রানার savepoint-এর নাম বড় হাতে দেখায় — ✓ SAVEPOINT ITEM_RICE, ✓ ROLLBACK TO, ✓ RELEASE ITEM_HILSA — নাম case-insensitive, তাই এতে সমস্যা নেই। শেষ ফল: order_items-এ শুধু 1 | Rice 5kg | 2; স্টক Rice 8, Hilsa 1, Lentils 5. Hilsa-র order_items সারিটি ROLLBACK TO-তে মুছে গেছে, কিন্তু অর্ডার আর Rice টিকে আছে। বাস্তব অ্যাপে এখানে ব্যবহারকারীকে জানানো হতো “Hilsa স্টকে নেই”.
Savepoint একটির ভেতরে আরেকটি বসানো যায় — তখন এগুলো একটি stack-এর মতো আচরণ করে। নিচের ডায়াগ্রামে তিন স্তরের চিহ্ন আর দুই রকম ROLLBACK TO দেখুন:
| savepoint stack | |
| খসড়ায় আইটেম | |
| পাকা (অন্যরা দেখে) |
- BEGIN. transaction শুরু, অর্ডারের মূল সারি খসড়ায়। এখনো কোনো savepoint নেই।
- প্রথম চিহ্ন s1. তার পরে Rice ×২ যোগ। ROLLBACK TO s1 করলে শুধু Rice যাবে, অর্ডার থাকবে।
- s1-এর ভেতরে s2. gift wrap যোগ। চিহ্নগুলো এখন একটির ওপর আরেকটি — stack-এর মতো।
- s2-এর ভেতরে s3. Hilsa ×৩, অথচ স্টক মাত্র ১ — অ্যাপ বুঝল এটি বাদ দিতে হবে।
- ROLLBACK TO s3. শুধু s3-এর পরের কাজ, অর্থাৎ Hilsa, মুছল। লক্ষ করুন s3 নিজে stack-এ থেকে যায়; স্ট্যান্ডার্ড SQL, PostgreSQL আর SQLite তিনটিতেই এটি একই নিয়ম।
- ROLLBACK TO s1. s1-এর পরের সব — Rice, gift wrap — মুছল, আর s1-এর পরে বসানো চিহ্ন s2, s3 নিজেরাও মুছে গেল। ভেতরের চিহ্নে আর ফেরা যাবে না।
- শেষ। Lentils যোগ, RELEASE s1, তারপর COMMIT. শুধু COMMIT-এর পরেই অন্যরা অর্ডারটি দেখে। savepoint কখনো আলাদা করে কিছু পাকা করে না — পাকা করে একমাত্র বাইরের COMMIT.
Savepoint-এর একটি বড় ব্যবহার আসে driver আর ORM থেকে (পর্ব ৪৯–৫০)। বহু ORM “nested transaction” বলতে আসলে savepoint বোঝায় — বাইরের transaction চলার সময় ভেতরের transaction() ব্লক একটি SAVEPOINT বসায়, ব্যর্থ হলে ROLLBACK TO. আর PostgreSQL-এ, যেখানে একটি ত্রুটি পুরো transaction নষ্ট করে, ঝুঁকির স্টেটমেন্টের আগে savepoint বসিয়ে ত্রুটির পর ROLLBACK TO দিলে transaction বাঁচানো যায়। SQLite-এ একটি বাড়তি নিয়ম: transaction-এর বাইরে SAVEPOINT দিলে সেটি নিজেই একটি transaction শুরু করে, আর সেই বাইরের savepoint-এর RELEASE মানে COMMIT.
৬ · Consistency — constraint DBMS-এর, invariant অ্যাপের
ACID-এর Consistency সবচেয়ে বেশি ভুল বোঝা অক্ষর। সংজ্ঞা: একটি transaction ডেটাবেসকে একটি সঠিক অবস্থা থেকে আরেকটি সঠিক অবস্থায় নেয়। কিন্তু “সঠিক” কে ঠিক করে? দুই স্তর আছে:
DBMS যা নিশ্চিত করে
ঘোষিত integrity constraint — PRIMARY KEY, UNIQUE, NOT NULL, FOREIGN KEY, CHECK (পর্ব ৬, ১৩)। কোনো transaction এগুলো ভেঙে COMMIT হতে পারে না; ভাঙার চেষ্টা স্টেটমেন্টেই আটকায়, বা DEFERRABLE হলে COMMIT-এর সময়।
অ্যাপ যা ঠিক করে
ব্যবসার invariant — “প্রতিটি লেনদেনে মোট টাকা অপরিবর্তিত”, “অর্ডারের মোট = আইটেমের যোগফল”, “একটি সিটে একজনই যাত্রী”. এর অনেকগুলো CHECK দিয়ে লেখা যায় না; transaction-এর কোড নিজেই সঠিক হতে হয়।
ধরুন একটি কোডে ভুল: Rahim থেকে ৭০০ কাটা হলো, কিন্তু Shapla-কে দেওয়া হলো ৭০ (একটি শূন্য বাদ পড়েছে)। কোনো constraint ভাঙে না:
SELECT SUM(balance) AS total_before FROM wallet;
BEGIN;
UPDATE wallet SET balance = balance - 700 WHERE id = 1;
UPDATE wallet SET balance = balance + 70 WHERE id = 3; -- bug: 70, not 700
INSERT INTO transfers (from_id, to_id, amount) VALUES (1, 3, 700);
COMMIT;
SELECT SUM(balance) AS total_after,
6500 - SUM(balance) AS missing FROM wallet;
SELECT id, owner, balance FROM wallet WHERE balance < 0;প্রথমে 6500. সব স্টেটমেন্ট সফল, ✓ COMMIT. তারপর 5870 | 630 — ৬৩০ টাকা উধাও। শেষ কোয়েরি (কোনো সারি নেই): কোনো ব্যালান্স ঋণাত্মক নয়, CHECK অক্ষত, foreign key অক্ষত। DBMS তার সব প্রতিশ্রুতি রেখেছে; ভেঙেছে অ্যাপের invariant. এই কারণে আর্থিক সিস্টেমে নিয়মিত reconciliation কোয়েরি চলে — মোট যোগফল আর লেনদেনের খাতা মিলিয়ে দেখা, ঠিক এই “missing” কলামের মতো।
Invariant রক্ষার কিছু কাজ DBMS-কে দেওয়া যায়: CHECK, trigger (পর্ব ২৯), বা নকশা বদলে। যেমন double-entry হিসাবে প্রতিটি লেনদেন দুটি সারি — একটি −৭০০, একটি +৭০০ — আর “প্রতিটি লেনদেনের সারিগুলোর যোগফল শূন্য” নিয়মটি একটি কোয়েরিতে যাচাই করা সহজ। তবু শেষ বিচারে অর্থের নিয়ম জানে ব্যবসা, DBMS নয়।
কেন A, I আর D মিলে C রক্ষা করে — একটি ছোট প্রমাণ
ধরুন I একটি invariant (যেমন মোট টাকা = ৬,৫০০), আর প্রতিটি transaction T-এর কোড এই অর্থে সঠিক: I মানা কোনো অবস্থা থেকে শুরু করে T একা শেষ পর্যন্ত চললে শেষের অবস্থাও I মানে। দাবি: T₁, T₂, …, Tₙ যেকোনো ক্রমে একটির পর একটি (serial) চললে প্রতিটি পাকা অবস্থাই I মানে।
প্রমাণ (n-এর ওপর induction): n = ০ হলে শুরুর অবস্থা, যা ধরে নেওয়া হয়েছে সঠিক। ধরুন প্রথম k টির পর অবস্থা Sk I মানে। Tk+1 Sk থেকে একা চলে — কারণ serial ক্রমে তার মাঝে অন্য কেউ নেই — তাই কোডের সঠিকতা অনুযায়ী Sk+1-ও I মানে। ∎
প্রমাণটি তিনটি গোপন অনুমানের ওপর দাঁড়িয়ে, আর প্রতিটি অনুমান ACID-এর একটি অক্ষর। “শেষ পর্যন্ত চললে” — মাঝপথে থেমে গেলে মধ্যবর্তী অবস্থা (৬,৫০০ − ২,০০০) I ভাঙে; atomicity নিশ্চিত করে যে অর্ধেক-চলা transaction-এর কোনো চিহ্ন থাকে না। “একা চলে” — আসলে হাজারো transaction একসাথে চলে; isolation নিশ্চিত করে যে ফল কোনো একটি serial ক্রমের সমান, তাই প্রমাণটি খাটে (পর্ব ৪৫-এ serializability ঠিক এই সংজ্ঞা)। “পাকা অবস্থা” — crash-এর পর যা ফিরে আসে, তা যেন শেষ committed অবস্থাই হয়; durability. তাই অনেকে বলেন, C আসলে লক্ষ্য, আর A, I, D তার উপায়।
৭ · Isolation আর Durability — একসাথে চলা আর crash-এ টিকে থাকা
Isolation: যেন একটির পর একটি
Isolation-এর আদর্শ রূপ: অনেক transaction একসাথে চললেও ফল এমন হবে যেন তারা কোনো এক ক্রমে একটির পর একটি চলেছে। কেন একটির পর একটি চালালেই হয় না? কারণ তাতে একটি ধীর transaction সবাইকে আটকে রাখত — মাসের শেষে বেতনের দিনে লাখো লেনদেন এক লাইনে দাঁড়াত। তাই DBMS কাজগুলো মিশিয়ে চালায় (concurrency control), আর নিশ্চিত করে যে মেশানো ফল নিরাপদ। নিরাপদ না হলে কী ঘটে, তার সবচেয়ে পরিচিত উদাহরণ lost update:
| T1-এর b | |
| T2-এর c | |
| ডেটাবেসে Rahim |
- T1 পড়ে। এজেন্টের দোকানে Rahim ১,০০০ টাকা cash out করছেন। অ্যাপ ব্যালান্স পড়ল: ৫,০০০.
- T2-ও পড়ে। একই মুহূর্তে Rahim-এর একটি বিল-পেমেন্টের অনুরোধ এল। সেটিও পড়ল ৫,০০০ — T1 এখনো লেখেনি।
- T1 লেখে। অ্যাপের মেমরিতে ৫,০০০ − ১,০০০ = ৪,০০০; লিখে COMMIT. ডেটাবেসে এখন ৪,০০০.
- T2 লেখে। T2-এর হাতে এখনো পুরোনো ৫,০০০; ৫,০০০ − ৫০০ = ৪,৫০০ লিখে COMMIT. T1-এর লেখা চাপা পড়ল — Rahim ১,০০০ টাকা নগদ পেলেন, অথচ হিসাব থেকে কাটা গেছে মাত্র ৫০০.
- Serial ক্রমে ফল। দুটি ক্রমের যেকোনোটিতে ৩,৫০০. মেশানো ক্রমটি কোনো serial ক্রমের সমান নয় — isolation ভেঙেছে। প্রতিকার: পড়া আর লেখা একটি UPDATE-এ (DBMS সারিটি lock রেখে কাজ করে), বা
SELECT … FOR UPDATE, বা কঠোরতর isolation level — পর্ব ৪৬–৪৭.
Isolation কতটা কঠোর হবে, তা ঠিক করে isolation level. SQL স্ট্যান্ডার্ড চারটি স্তর সংজ্ঞায়িত করে তিনটি anomaly দিয়ে; lost update চতুর্থ একটি পরিচিত সমস্যা। শুধু পরিচয় এখানে — সংজ্ঞা, উদাহরণ আর কোন স্তর কোনটি ঠেকায়, তা পর্ব ৪৭-এ:
| Anomaly | এক লাইনে |
|---|---|
| Dirty read | অন্যের এখনো-commit-না-হওয়া খসড়া পড়া — যা পরে ROLLBACK হয়ে “কখনো ছিল না” হতে পারে। |
| Non-repeatable read | একই সারি দুবার পড়লে দুই মান, কারণ মাঝে অন্য কেউ সেটি বদলে commit করেছে। |
| Phantom | একই শর্তে দুবার খুঁজলে দ্বিতীয়বার নতুন সারি “ভূতের মতো” হাজির — মাঝে কেউ INSERT করেছে। |
| Lost update | দুজন একই পুরোনো মান পড়ে লিখলে একজনের লেখা হারায় (ওপরের ডায়াগ্রাম)। |
ডিফল্ট স্তর DBMS ভেদে আলাদা: PostgreSQL, Oracle আর SQL Server-এ Read Committed, MySQL-এর InnoDB-তে Repeatable Read, আর SQLite কার্যত Serializable — কারণ একসাথে একজনই লিখতে পারে। এক ডেটাবেসে দুটি সংযোগ খুলে নিজেই দেখুন: A-র খসড়া B দেখে কি না, আর B লিখতে চাইলে কী হয়:
import sqlite3, tempfile, os
path = os.path.join(tempfile.mkdtemp(), "wallet.db")
a = sqlite3.connect(path, isolation_level=None) # we send BEGIN/COMMIT ourselves
b = sqlite3.connect(path, isolation_level=None, timeout=0)
a.execute("CREATE TABLE wallet (id INTEGER PRIMARY KEY, balance INTEGER)")
a.execute("INSERT INTO wallet VALUES (1, 5000), (2, 1200)")
def show(con, who):
rows = [r[0] for r in con.execute("SELECT balance FROM wallet ORDER BY id")]
print(who, rows, "total", sum(rows))
a.execute("BEGIN")
a.execute("UPDATE wallet SET balance = balance - 2000 WHERE id = 1")
a.execute("UPDATE wallet SET balance = balance + 2000 WHERE id = 2")
show(a, "A inside its transaction:")
show(b, "B before A commits: ")
try:
b.execute("UPDATE wallet SET balance = balance + 1 WHERE id = 1")
except sqlite3.OperationalError as e:
print("B tries to write: ", e)
a.execute("COMMIT")
show(b, "B after A commits: ")আউটপুট চার লাইন: A inside its transaction: [3000, 3200] total 6200; B before A commits: [5000, 1200] total 6200; B tries to write: database is locked; B after A commits: [3000, 3200] total 6200. A নিজের খসড়া দেখে, B দেখে না — dirty read নেই। B যেকোনো মুহূর্তে পড়ুক, মোট সবসময় ৬,২০০: হয় পুরো পুরোনো অবস্থা, নয় পুরো নতুন। A লিখতে শুরু করার পর B লিখতে গেলে SQLite সাথে সাথে database is locked দেয় (timeout=0 দেওয়ায় অপেক্ষা করেনি; বাস্তবে কয়েক সেকেন্ডের timeout দেওয়া হয়)। এটিই SQLite-এর isolation-এর মূল কৌশল: পুরো ডেটাবেসে একজন লেখক।
Durability: COMMIT মানে প্রতিশ্রুতি
Durability: একবার “সফল” উত্তর দিলে সেই পরিবর্তন crash, বিদ্যুৎ চলে যাওয়া বা OS-এর হঠাৎ থেমে যাওয়াতেও টিকে থাকবে। মেমরি বিদ্যুৎ গেলে মুছে যায়, তাই commit-এর আগে কিছু একটা স্থায়ী স্টোরেজে পৌঁছাতেই হবে। প্রতিটি পরিবর্তিত data page ছড়ানো জায়গায় লিখতে গেলে ধীর হয়; তাই প্রায় সব DBMS-এর কৌশল write-ahead log (WAL): পরিবর্তনের ছোট বিবরণ একটি log ফাইলের শেষে পরপর লেখা, আর COMMIT-এর সময় শুধু সেই log ডিস্কে নিশ্চিত করা। data page পরে ধীরেসুস্থে লেখা হয়; crash হলে log থেকে redo.
“ডিস্কে নিশ্চিত করা” কথাটি সূক্ষ্ম। write() সাধারণত শুধু OS-এর cache-এ লেখে; ডিস্কে পৌঁছানো নিশ্চিত করে fsync. ডিস্কের নিজের cache থাকলে সেটিও যেন মিথ্যা না বলে — তাই সার্ভারের ডিস্কে প্রায়ই ব্যাটারি-ব্যাকড cache. fsync দামি, তাই DBMS-গুলো ইচ্ছামতো দাম কমানোর সুইচ দেয় — আর সেই সুইচ মানে durability-র একটু ছাড়:
| DBMS | সেটিং | ছাড় দিলে কী হারাতে পারে |
|---|---|---|
| PostgreSQL | synchronous_commit (ডিফল্ট on) | off দিলে crash-এ শেষ কয়েকটি commit হারাতে পারে, কিন্তু ডেটাবেস অসংগত হয় না |
| MySQL InnoDB | innodb_flush_ (ডিফল্ট 1) | 0 বা 2 দিলে মোটামুটি শেষ এক সেকেন্ডের commit |
| SQLite | PRAGMA synchronous | WAL মোডে NORMAL দিলে বিদ্যুৎ গেলে শেষ কয়েকটি commit; OFF দিলে ফাইল নষ্টও হতে পারে |
প্রতিটি commit-এ একটি করে fsync হলে প্রতি সেকেন্ডে কত commit সম্ভব, তা ডিস্কের fsync-গতিতে বাঁধা। তাই DBMS-গুলো একসাথে আসা কয়েকটি commit-এর log একটি fsync-এ লেখে — group commit. WAL, fsync আর recovery-র পুরো যন্ত্র পর্ব ৪৮-এ। আর একটি সীমা মনে রাখুন: durability একটি যন্ত্রের ভেতরের প্রতিশ্রুতি। পুরো সার্ভার পুড়ে গেলে বাঁচায় backup আর replication (পর্ব ৫২)।
৮ · Autocommit, SQLite-এর BEGIN mode আর transactional DDL
BEGIN না লিখলে কী হয়? প্রায় সব DBMS তখন autocommit মোডে: প্রতিটি স্টেটমেন্ট নিজেই একটি পূর্ণ transaction — শুরু, চালানো, পাকা। এই পাতার রানারও তাই। দুটির তফাত দেখুন:
-- ক. স্পষ্ট transaction: ROLLBACK কাজ করে
BEGIN;
UPDATE wallet SET balance = balance - 1000 WHERE id = 1;
ROLLBACK;
SELECT balance FROM wallet WHERE id = 1;
-- খ. autocommit: UPDATE শেষ হতেই পাকা
UPDATE wallet SET balance = balance - 1000 WHERE id = 1;
SELECT balance FROM wallet WHERE id = 1;
ROLLBACK;ক অংশে ROLLBACK-এর পর 5000 — কাটা ফেরত এসেছে। খ অংশে UPDATE-এর পর 4000, তারপর ত্রুটি (স্টেটমেন্ট ৭): cannot rollback - no transaction is active. ফেরানোর মতো কোনো খোলা transaction নেই; UPDATE-টি ইতিমধ্যেই নিজের ক্ষুদ্র transaction-এ পাকা। এই কারণেই ডেটাবেস-কনসোলে ঝুঁকির কাজ (বড় DELETE বা UPDATE) করার আগে অভিজ্ঞরা প্রথমে BEGIN লেখেন — ফল দেখে তারপর COMMIT বা ROLLBACK.
অ্যাপের কোডে ছবিটি আরও জটিল, কারণ driver (অ্যাপ আর DBMS-এর মাঝের লাইব্রেরি) নিজের ডিফল্ট ঠিক করে:
| Driver / টুল | ডিফল্ট আচরণ |
|---|---|
| psql, mysql, sqlite3 কনসোল | autocommit; BEGIN লিখলে transaction |
| JDBC (Java) | autocommit চালু; setAutoCommit(false) দিয়ে transaction, তারপর commit() |
| Python sqlite3 (পুরোনো ডিফল্ট) | INSERT/UPDATE/DELETE-এর আগে নিজে BEGIN পাঠায়; commit() না ডাকলে সংযোগ বন্ধে সব হারায় |
| psycopg (PostgreSQL, Python) | autocommit বন্ধ: প্রথম স্টেটমেন্টেই transaction খোলে, commit() লাগে |
| PHP PDO, Node-এর pg ও mysql2 | প্রতিটি query আলাদা (autocommit); beginTransaction() বা নিজে BEGIN |
দুটি উল্টো বিপদ। Python-এ commit() ভুলে গেলে লেখা নীরবে হারায় — “কোড চলল, কিন্তু টেবিলে কিছু নেই”. Java বা PHP-তে transaction ভুলে গেলে প্রতিটি লেখা আলাদা পাকা, আর মাঝপথের ত্রুটিতে অর্ধেক কাজ থেকে যায়। Python 3.12 থেকে sqlite3-এ একটি স্পষ্ট autocommit অ্যাট্রিবিউট আছে, আর with con: ব্লক সফল হলে commit, exception-এ rollback করে। কোন ভাষায় কীভাবে, তা পর্ব ৪৯-এ।
SQLite-এর তিন রকম BEGIN
SQLite-এ পুরো ডেটাবেসে একজন লেখক, তাই transaction কখন lock নেবে তা গুরুত্বপূর্ণ:
BEGIN DEFERRED(ডিফল্ট): শুরুতে কোনো lock নয়; প্রথম পড়ায় পড়ার lock, প্রথম লেখায় লেখার lock. ঝুঁকি: পড়ার পর লেখার lock নিতে গিয়ে অন্য লেখকের কারণেSQLITE_BUSY— মাঝপথে।BEGIN IMMEDIATE: শুরুতেই লেখার অধিকার নেয়। অন্য লেখক থাকলে BEGIN-এই অপেক্ষা বা BUSY — কাজ শুরুর আগে, যা সামলানো সহজ। লিখবে জানা থাকলে এটিই ভালো।BEGIN EXCLUSIVE: rollback journal মোডে পাঠকদেরও আটকায়; WAL মোডে IMMEDIATE-এর মতোই।
Transactional DDL — পর্ব ৩১-এর স্মরণ
CREATE, ALTER, DROP কি ROLLBACK হয়? Transactional DDL থাকলে হয়। SQLite আর PostgreSQL-এ আছে:
BEGIN IMMEDIATE;
CREATE TABLE audit_log (id INTEGER PRIMARY KEY, note TEXT);
INSERT INTO audit_log (note) VALUES ('fee rule v2');
ALTER TABLE wallet ADD COLUMN frozen INTEGER DEFAULT 0;
SELECT name FROM sqlite_master WHERE type = 'table';
ROLLBACK;
SELECT name FROM sqlite_master WHERE type = 'table';
SELECT * FROM wallet WHERE id = 1;ROLLBACK-এর আগে টেবিলের তালিকা wallet, transfers, audit_log; পরে শুধু wallet, transfers. আর শেষ SELECT-এ কলাম তিনটি — 1 | Rahim | 5000 — frozen কলামটিও উধাও। নতুন টেবিল, তার ডেটা আর ALTER, সবই একসাথে বাতিল। একটি migration (পর্ব ৫০) মাঝপথে ব্যর্থ হলে schema আধা-বদলানো অবস্থায় থাকে না।
MySQL আর Oracle-এ ছবিটি উল্টো: বেশিরভাগ DDL-এর আগে আর পরে implicit commit হয়। ফলে BEGIN; INSERT …; CREATE TABLE …; ROLLBACK; লিখলে INSERT-টিও পাকা হয়ে যায় — CREATE TABLE চলার আগেই DBMS চুপচাপ COMMIT করেছে। MySQL 8.0-এর “atomic DDL” একটি একক DDL স্টেটমেন্টকে পূর্ণ বা শূন্য করে, কিন্তু কয়েকটি স্টেটমেন্ট মিলে একটি transaction করে না। SQL Server-এ বেশিরভাগ DDL transaction-এর ভেতরে ফেরানো যায়। PostgreSQL-এও ব্যতিক্রম আছে: CREATE DATABASE, VACUUM আর CREATE INDEX CONCURRENTLY transaction ব্লকের ভেতরে চলে না।
৯ · অ্যাপে transaction-এর নকশা — ছোট রাখা, retry আর idempotency
দীর্ঘ transaction কেন ক্ষতিকর
একটি transaction যতক্ষণ খোলা, ততক্ষণ সে কিছু না কিছু ধরে রাখে। দীর্ঘ transaction-এর তিনটি দাম:
- Lock ধরে রাখা: যে সারিগুলো সে লিখেছে, সেগুলোর lock commit পর্যন্ত থাকে (পর্ব ৪৬)। অন্য সবাই অপেক্ষায় — একটি জনপ্রিয় পণ্যের স্টক-সারি এক মিনিট আটকে থাকলে পুরো দোকান থমকে যায়। SQLite-এ তো পুরো ডেটাবেসই।
- পুরোনো সংস্করণ জমা: PostgreSQL আর InnoDB MVCC ব্যবহার করে (পর্ব ৪৭) — পাঠকদের জন্য সারির পুরোনো সংস্করণ রেখে দেয়। সবচেয়ে পুরোনো খোলা transaction-টি হয়তো সেগুলো দেখতে চাইবে, তাই পরিষ্কারের কাজ (PostgreSQL-এ VACUUM, InnoDB-তে purge) সেগুলো মুছতে পারে না। ঘণ্টার পর ঘণ্টা খোলা একটি transaction-এ টেবিল ফুলে ওঠে — bloat — আর সব কোয়েরি ধীর হয়।
- ব্যর্থতার দাম: দীর্ঘ transaction ব্যর্থ হলে ROLLBACK-ও দীর্ঘ, আর conflict-এ পড়ার সম্ভাবনা বেশি।
ব্যবহারকারীর ভাবনার সময় transaction-এর ভেতরে নয়। ট্রেনের টিকিট বুকিংয়ে আসন বাছাইয়ের পাতা খুলেই BEGIN, আর ব্যবহারকারী পেমেন্ট শেষ করলে COMMIT — এই নকশা সবচেয়ে প্রচলিত ভুলগুলোর একটি। ব্যবহারকারী চা খেতে উঠলে lock দশ মিনিট আটকে থাকে। সঠিক পথ: আসনটি একটি ছোট transaction-এ “hold, ১০ মিনিট পর্যন্ত” চিহ্ন দিয়ে commit; পেমেন্টের পর আরেকটি ছোট transaction-এ “sold”; মেয়াদ পেরোলে একটি কাজ hold তুলে দেয়। PostgreSQL-এ idle_in_ দিয়ে অলস খোলা transaction নিজে বন্ধ করানো যায়।
Retry: ব্যর্থতা স্বাভাবিক
একসাথে অনেক transaction চললে কিছু ব্যর্থ হবেই — দোষ ছাড়াই। দুটি transaction একে অপরের lock-এর অপেক্ষায় পড়লে deadlock; DBMS একটিকে বাতিল করে (PostgreSQL SQLSTATE 40P01, MySQL ত্রুটি 1213). Serializable স্তরে DBMS নিরাপদ ক্রম নিশ্চিত করতে না পারলে serialization failure দেয় (SQLSTATE 40001). এদের সঠিক প্রতিক্রিয়া: পুরো transaction শুরু থেকে আবার — শুধু শেষ স্টেটমেন্ট নয়, কারণ আগের পড়াগুলো হয়তো এখন পুরোনো।
for attempt in 1 .. 5:
try:
BEGIN
... সব read আর write ...
COMMIT
return success
except (deadlock 40P01/1213, serialization failure 40001):
ROLLBACK
wait random(10ms .. 10ms × 2^attempt) # সবাই একসাথে আবার না আসে
except other error:
ROLLBACK
raise # যুক্তির ত্রুটি: retry নয়
raise "অনেকবার ব্যর্থ — পরে চেষ্টা করুন"একটি শর্ত: retry নিরাপদ শুধু তখনই, যখন transaction-এর ভেতরে ডেটাবেসের বাইরের কোনো কাজ নেই — SMS পাঠানো, অন্য API ডাকা। নইলে প্রতি retry-তে গ্রাহক আরেকটি SMS পাবেন। বাইরের কাজ COMMIT-এর পরে, বা পর্ব ৩৮-এর outbox pattern দিয়ে।
Idempotency key — একই অনুরোধ দুবার এলে
সবচেয়ে কঠিন ব্যর্থতা: অ্যাপ COMMIT পাঠাল, আর ঠিক তখন নেটওয়ার্ক কেটে গেল। Transaction কি হয়েছে? অ্যাপ জানে না। আবার পাঠালে টাকা দুবার কাটতে পারে; না পাঠালে হয়তো একবারও কাটেনি। সমাধান idempotency key: ক্লায়েন্ট প্রতিটি অনুরোধে একটি অনন্য id পাঠায়, আর সার্ভার সেটি লেনদেনের সাথে UNIQUE কলামে লেখে — একই transaction-এর ভেতরে। Stripe-এর মতো পেমেন্ট API এই কৌশলের জন্য Idempotency-Key নামের একটি header নেয়।
-- প্রথম অনুরোধ
BEGIN;
INSERT INTO transfers (from_id, to_id, amount, idem_key)
VALUES (1, 3, 500, 'req-7f3a');
UPDATE wallet SET balance = balance - 500 WHERE id = 1;
UPDATE wallet SET balance = balance + 500 WHERE id = 3;
COMMIT;
SELECT id, balance FROM wallet;
-- উত্তর হারিয়ে গেল; ক্লায়েন্ট একই key নিয়ে আবার পাঠাল
BEGIN;
INSERT INTO transfers (from_id, to_id, amount, idem_key)
VALUES (1, 3, 500, 'req-7f3a');প্রথম transaction সফল: ব্যালান্স 4500 | 1200 | 800. দ্বিতীয়বার প্রথম স্টেটমেন্টেই ত্রুটি (স্টেটমেন্ট ৮): UNIQUE constraint failed: transfers.idem_key — টাকা কাটার UPDATE-এ পৌঁছানোর আগেই। আসল অ্যাপ এই ত্রুটি ধরে ROLLBACK পাঠায়, তারপর আগের লেনদেনটি পড়ে ক্লায়েন্টকে সেই একই উত্তর দেয়: “সফল, লেনদেন নম্বর …”. কৌশলটির শক্তি একটি জায়গায়: key লেখা আর টাকা সরানো একই transaction-এ, তাই atomicity নিশ্চিত করে দুটি একসাথে হয় বা একসাথে হয় না। key আলাদা transaction-এ লিখলে ফাঁক ফিরে আসে।
সামনে: ACID বনাম BASE
বহু সার্ভারে ছড়ানো ডেটাবেসে (পর্ব ৫৪) প্রতিটি transaction-এ সব কপির সম্মতি নেওয়া ধীর, আর নেটওয়ার্ক ভাগ হলে অসম্ভবও হতে পারে। তাই কিছু সিস্টেম ইচ্ছে করে ছাড় দেয় — BASE: Basically Available, Soft state, Eventually consistent. সব সময় উত্তর দেবে, কিন্তু কিছু সময় কপিগুলো ভিন্ন মান দেখাতে পারে। নামটি রসায়নের রসিকতা — অ্যাসিডের বিপরীতে ক্ষার। কোথায় কোনটি মানায়, CAP উপপাদ্যসহ, পর্ব ৫৪-এ।
আরও গভীরে: Durability-র কথা “commit record ডিস্কে” বললেও বাস্তবে প্রশ্নটি কঠিন। ২০১৮ সালে PostgreSQL-এর ডেভেলপাররা আবিষ্কার করেন, Linux-এ fsync ব্যর্থ হলে কিছু ক্ষেত্রে কার্নেল সেই dirty page “পরিষ্কার” ধরে নেয়, তাই পরের fsync সফল দেখালেও ডেটা ডিস্কে নাও থাকতে পারে — এই “fsyncgate” আলোচনার পর PostgreSQL fsync ব্যর্থ হলে নিজেকে বন্ধ করে recovery চালানোর নীতি নেয়। গবেষণাতেও (যেমন ২০১৪ সালের OSDI-তে Pillai ও সহকর্মীদের “All File Systems Are Not Created Equal”) দেখা গেছে, অনেক অ্যাপ file system-এর আচরণ নিয়ে ভুল অনুমান করে crash-এ ডেটা হারাতে পারে।
১০ · AI-এর যুগে transaction
“bKash-এর মতো টাকা পাঠানোর API লিখে দাও” — AI কয়েক সেকেন্ডে কোড দেয়, যা চলে, পরীক্ষায় পাস করে, আর প্রথম ব্যস্ত দিনে টাকা হারায়। Transaction-এর ভুল একা চালানোয় ধরা পড়ে না; ধরা পড়ে একসাথে অনেক অনুরোধ আর ব্যর্থতায়। AI-এর প্রচলিত ভুল:
| AI-এর ভুল | ফল | কীভাবে ধরবেন |
|---|---|---|
| দুটি UPDATE, কোনো BEGIN নেই (autocommit driver-এ) | মাঝপথের ত্রুটিতে আধা-কাজ পাকা | দ্বিতীয় UPDATE-এ ইচ্ছে করে ত্রুটি ঘটিয়ে মোট যোগফল দেখুন |
| আগে SELECT balance, অ্যাপে তুলনা, পরে UPDATE | lost update, ঋণাত্মক ব্যালান্স | দুটি সংযোগে একসাথে চালান; শর্তযুক্ত UPDATE বা FOR UPDATE চান |
| exception ধরে কিন্তু ROLLBACK নেই | খোলা transaction, পরে ভুল COMMIT বা lock আটকে থাকা | প্রতিটি ত্রুটির পথে ROLLBACK আছে কি না পড়ুন |
| transaction-এর ভেতরে HTTP/SMS ডাকা | দীর্ঘ lock, retry-তে দ্বিগুণ SMS | বাইরের কাজ COMMIT-এর পরে সরান |
| retry নেই, বা শুধু শেষ স্টেটমেন্টের retry | deadlock-এ ব্যবহারকারী ত্রুটি দেখে, বা ভুল ফল | পুরো transaction-এর retry আর backoff চান |
| MySQL-এ DDL-কে ROLLBACK-যোগ্য ধরে নেওয়া | migration আধা-সম্পন্ন | DBMS-এর নাম আর সংস্করণ বলে দিন |
AI-কে transaction-এর শর্তগুলো স্পষ্ট করে দিন:
AI-এর লেখা transaction কোড চালু করার আগে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- ACID-এর চারটি গুণ উদাহরণসহ? — টাকা পাঠানো: দুটি লেখা একসাথে (A), মোট টাকা আর constraint অক্ষত (C), একসাথে চলা অনুরোধ যেন পরপর (I), সফল হলে crash-এও টিকে থাকে (D).
- Transaction-এর অবস্থাগুলো আঁকুন। — active → partially committed → committed; active বা partially committed → failed → aborted; aborted-এর পর restart বা kill.
- কোন গুণ কোন অংশ নিশ্চিত করে? — atomicity আর durability: recovery manager (log, undo/redo); isolation: concurrency control; consistency: constraint-এর জন্য DBMS, বাকিটা অ্যাপের কোড।
- ROLLBACK আর ROLLBACK TO SAVEPOINT-এর পার্থক্য? — প্রথমটি পুরো transaction বাতিল করে শেষ করে; দ্বিতীয়টি শুধু savepoint-এর পরের কাজ ফেরায়, transaction চলতে থাকে।
- Autocommit কী? — BEGIN ছাড়া প্রতিটি স্টেটমেন্ট নিজেই একটি transaction হিসেবে সাথে সাথে commit হওয়া; বেশিরভাগ টুল আর driver-এর ডিফল্ট।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Jim Gray-র ১৯৮১ সালের প্রবন্ধে isolation আলাদা গুণ হিসেবে ছিল না; ১৯৮৩ সালে Härder আর Reuter ACID নাম দেন। অনেকে বলেন, এই চারটি অক্ষর সমান মাপের নয় — বিশেষ করে C. কেন?
Gray-র মূল চিন্তা এসেছিল ব্যবসার চুক্তি থেকে: একটি চুক্তি হয় পুরোপুরি হয়, নয়তো হয়ই না, আর একবার সই হলে টিকে থাকে। সেখান থেকে atomicity আর durability. Consistency ছিল চুক্তির নিয়ম মানা। একসাথে অনেক transaction চলার সমস্যা তখনও সুপরিচিত ছিল — IBM-এ Gray নিজে lock আর consistency-র স্তর নিয়ে ১৯৭০-এর দশকে কাজ করেছিলেন — কিন্তু চার অক্ষরের সংক্ষিপ্ত রূপটি আসে Härder ও Reuter-এর ১৯৮৩ সালের recovery-বিষয়ক প্রবন্ধে। Gray ১৯৯৮ সালে transaction processing-এ অবদানের জন্য Turing Award পান।
চারটি অক্ষর আসলে দুই জাতের। A, I আর D হলো DBMS-এর যন্ত্র: log আর undo দিয়ে atomicity, lock বা MVCC দিয়ে isolation, WAL আর fsync দিয়ে durability. এগুলো কোনো অ্যাপ না জেনেই দেওয়া যায়। কিন্তু C-এর অর্থ নির্ভর করে “সঠিক অবস্থা” কী তার ওপর, আর সেটি জানে অ্যাপ। DBMS ঘোষিত constraint রক্ষা করে, তার বেশি নয়। অংশ ৬-এর প্রমাণে দেখেছেন, transaction-এর কোড সঠিক হলে A, I, D মিলে C রক্ষা হয় — C একটি ফল, আলাদা যন্ত্র নয়।
Martin Kleppmann তাঁর Designing Data-Intensive Applications বইয়ে (২০১৭) এই কথাটি সরাসরি লিখেছেন: consistency আসলে অ্যাপের গুণ, ডেটাবেসের নয় — C অক্ষরটি ACID-এ ঠিক মানায় না; সেখানে তিনি Joe Hellerstein-এর একটি মন্তব্যও উল্লেখ করেন যে অক্ষরটি নামটি জমাতেই যোগ হয়েছিল। আরেকটি বিভ্রান্তি: distributed system-এর “consistency” (CAP-এর C, পর্ব ৫৪) সম্পূর্ণ আলাদা ধারণা — সব কপি একই মান দেখায় কি না। একই শব্দ, তিন প্রসঙ্গে তিন অর্থ: ACID-এর C, CAP-এর C, আর replication-এর consistency.
পরীক্ষায় এই সূক্ষ্মতা কাজে লাগে: “consistency কে নিশ্চিত করে?” — উত্তরে শুধু “DBMS” লিখলে অর্ধেক ঠিক।
মূল উপলব্ধি: ACID-এর তিনটি অক্ষর DBMS-এর প্রতিশ্রুতি, একটি অক্ষর আপনার দায়িত্ব। ডেটাবেস নিয়ম রক্ষা করে, কিন্তু নিয়ম লেখেন আপনি।
প্র ০২অ্যাপ COMMIT পাঠানোর পর উত্তর আসার আগেই নেটওয়ার্ক কেটে গেল। ACID কি এখানে অ্যাপকে কোনো নিশ্চয়তা দেয়? “Exactly once” লেনদেন কি সম্ভব?
ACID ডেটাবেসের ভেতরের নিশ্চয়তা: transaction-টি হয় পুরো হয়েছে, নয়তো একটুও না। কিন্তু কোনটি হয়েছে, তা জানানোর বার্তাটি নেটওয়ার্কে হারাতে পারে। অ্যাপের দিক থেকে তিনটি সম্ভাবনা: COMMIT ডেটাবেসে পৌঁছায়নি; পৌঁছে পাকা হয়েছে কিন্তু উত্তর হারিয়েছে; বা পৌঁছেছিল কিন্তু ব্যর্থ হয়েছে। এই অনিশ্চয়তা কোনো ডেটাবেস দূর করতে পারে না — এটি দূরের দুটি পক্ষের যোগাযোগের মৌলিক সীমা, যা “two generals problem” নামে পরিচিত: অবিশ্বস্ত বার্তাবাহক দিয়ে দুই পক্ষ কখনো নিশ্চিতভাবে একমত হতে পারে না যে দুজনেই জানে।
তাই বাস্তব সিস্টেম “exactly once delivery”-র বদলে দুটি জিনিস জোড়া লাগায়: at least once — নিশ্চিত না হলে আবার পাঠানো — আর idempotent processing — একই অনুরোধ বহুবার এলেও ফল একবারের সমান। দুটি মিলে কার্যত “exactly once effect”. idempotency key এর হাতিয়ার: ক্লায়েন্ট প্রতিটি লেনদেনে একটি অনন্য id তৈরি করে, retry-তে একই id পাঠায়; সার্ভার id আর লেনদেন একই transaction-এ লেখে।
নকশায় কয়েকটি সূক্ষ্ম বিষয় আছে। key-টি তৈরি হবে ক্লায়েন্টে, প্রথম চেষ্টার আগে — সার্ভারে তৈরি হলে retry নতুন key পাবে। একই key-তে ভিন্ন amount এলে সেটি ত্রুটি হিসেবে ফেরানো উচিত, নীরবে আগের ফল নয়। key কতদিন রাখা হবে, তা ঠিক করতে হয় — Stripe-এর মতো API গুলো নির্দিষ্ট সময় পর্যন্ত key মনে রাখে বলে জানায়। আর দুটি একই key একসাথে এলে UNIQUE constraint-ই নিশ্চিত করে যে একটিই জিতবে — এখানে isolation আর constraint একসাথে কাজ করে।
মোবাইল ব্যাংকিংয়ে “লেনদেন প্রক্রিয়াধীন” বার্তা আর পরে “স্ট্যাটাস দেখুন” বোতামও একই সমস্যার মানবিক সমাধান: অনিশ্চিত হলে আগে জিজ্ঞেস করা, অন্ধভাবে আবার না পাঠানো।
মূল উপলব্ধি: ACID ডেটাবেসের দেয়ালের ভেতরে সত্য বলে, দেয়ালের বাইরের বার্তা নয়। সেই ফাঁক বন্ধ করে idempotency — retry নিরাপদ করার নকশা।
প্র ০৩একটি দল MySQL-এ একটি migration চালাল: নতুন টেবিল, পুরোনো টেবিলে কলাম যোগ, আর ডেটা কপি। তৃতীয় ধাপে ত্রুটি। একই কাজ PostgreSQL-এ হলে কী হতো, আর MySQL-এ নিরাপদে migration চালানোর কৌশল কী?
MySQL-এ প্রতিটি DDL-এর আগে আর পরে implicit commit হয়। তাই প্রথম দুই ধাপ — CREATE TABLE আর ALTER TABLE — চলার সাথে সাথে পাকা। তৃতীয় ধাপের ডেটা-কপি একটি transaction-এ থাকলে সেটি ফেরে, কিন্তু schema থেকে যায় আধা-বদলানো: নতুন টেবিল আছে, নতুন কলাম আছে, ডেটা নেই। migration টুল তখন বিভ্রান্ত — আবার চালালে “table already exists”. MySQL 8.0-এর atomic DDL এখানে আংশিক সাহায্য করে: একটি ALTER মাঝপথে crash করলে সেই একটি স্টেটমেন্ট অর্ধেক অবস্থায় থাকে না। কিন্তু কয়েকটি স্টেটমেন্টের দল একসাথে ফেরে না।
PostgreSQL-এ পুরো migration একটি BEGIN … COMMIT-এ রাখা যায়। তৃতীয় ধাপে ত্রুটি হলে ROLLBACK নতুন টেবিল আর কলাম দুটিই মুছে দেয় — যেন কিছুই হয়নি; ঠিক এই পর্বের SQLite উদাহরণের মতো। এই সুবিধার জন্যই অনেক migration টুল PostgreSQL-এ প্রতিটি migration স্বয়ংক্রিয়ভাবে transaction-এ মোড়ে। তবে সতর্কতা আছে: ALTER TABLE বড় টেবিলে কড়া lock নেয়, আর transaction যত দীর্ঘ, lock তত দীর্ঘ — পুরো অ্যাপ আটকে যেতে পারে। আর CREATE INDEX CONCURRENTLY transaction-এর ভেতরে চলেই না।
MySQL-এ কৌশলগুলো প্রকৌশলের: প্রতিটি migration-এ একটি মাত্র DDL; প্রতিটি ধাপ idempotent — CREATE TABLE IF NOT EXISTS, কলাম আছে কি না দেখে ALTER — যাতে আবার চালানো নিরাপদ; আর পর্ব ২৩-এর expand-contract: আগে নতুন কাঠামো যোগ (পুরোনো কোড তখনো চলে), তারপর ডেটা সরানো, শেষে পুরোনো অংশ মোছা — প্রতিটি ধাপ আলাদা deploy. বড় টেবিলে gh-ost বা pt-online-schema-change-এর মতো online schema change টুলও প্রচলিত, যারা ছায়া-টেবিলে কাজ করে শেষে নাম বদলায়। আর প্রতিটি migration চালানোর আগে staging-এ আসল মাপের ডেটার কপিতে একবার পরীক্ষা করা — সব DBMS-এ — সবচেয়ে সস্তা বিমা।
মূল উপলব্ধি: transactional DDL migration-কে atomic করে, কিন্তু সব DBMS তা দেয় না। যেখানে দেয় না, সেখানে atomicity-র জায়গা নেয় idempotent আর ছোট ছোট ধাপ।
প্র ০৪একটি PostgreSQL সার্ভারে কোনো ভারী কোয়েরি নেই, তবু দিন দিন টেবিলের আকার বাড়ছে আর কোয়েরি ধীর হচ্ছে। শেষে দেখা গেল, একটি রিপোর্টিং সংযোগ তিন দিন ধরে “idle in transaction”. একটি অলস transaction কীভাবে এত ক্ষতি করে?
PostgreSQL-এর MVCC-তে UPDATE পুরোনো সারি মোছে না; নতুন সংস্করণ লেখে, পুরোনোটি “মৃত” চিহ্নিত হয় — কিন্তু তখনই নয়, যতক্ষণ কোনো খোলা transaction সেটি দেখতে পারে। VACUUM মৃত সংস্করণ সরায়, তবে শুধু সেগুলো যা সবচেয়ে পুরোনো খোলা transaction-এর snapshot-এরও আগের। তিন দিন আগে খোলা একটি transaction মানে গত তিন দিনের সব পুরোনো সংস্করণ রক্ষা করা — হয়তো সে কখনো সেগুলো পড়বে না, কিন্তু DBMS তা জানে না। ব্যস্ত টেবিলে প্রতিটি UPDATE একটি করে মৃত সারি রেখে যায়; টেবিল আর index ফুলে ওঠে, scan-এ বেশি page পড়তে হয়, cache-এ কম ডেটা ধরে।
আরও গভীর বিপদ transaction ID wraparound. PostgreSQL-এর transaction id ৩২-বিটের, তাই প্রায় ২০০ কোটি পর একটি চক্র ঘোরে; পুরোনো সারিগুলোকে সময়মতো “freeze” না করলে ডেটাবেস নিজেকে রক্ষা করতে নতুন লেখা বন্ধ করে দেয়। দীর্ঘ খোলা transaction এই freeze-কেও আটকাতে পারে। এমন ঘটনায় পরিচিত কোম্পানির প্রকাশ্য postmortem আছে — যেমন ২০১৫ সালে Sentry-র বিভ্রাট নিয়ে লেখা।
InnoDB-তে ছবিটি সমান্তরাল: পুরোনো সংস্করণ থাকে undo log-এ, আর দীর্ঘ transaction-এ purge থেমে “history list length” বাড়ে; পাঠকদের পুরোনো সংস্করণ খুঁজতে দীর্ঘ শিকল পেরোতে হয়।
কারণ প্রায়ই নিরীহ: একটি GUI টুল বা অ্যাপের ফ্রেমওয়ার্ক autocommit বন্ধ রেখে একটি SELECT চালাল, তারপর সংযোগটি খোলা রেখে দিল — psycopg-এর মতো driver-এ এটি খুব সহজে ঘটে। প্রতিকার: pg_stat_activity-তে xact_start পুরোনো সংযোগ খোঁজা, idle_in_ আর statement_timeout সেট করা, রিপোর্টিং কাজ replica-তে পাঠানো, আর পড়ার জন্যও transaction ঠিকমতো বন্ধ করা।
মূল উপলব্ধি: transaction-এর দাম শুধু সে যা করে তাতে নয়, সে যতক্ষণ খোলা থাকে তাতেও। একটি কিছু-না-করা transaction পুরো ডেটাবেসের পরিষ্কারের কাজ থামিয়ে দিতে পারে।
প্র ০৫২০০০-এর দশকের শেষে NoSQL আন্দোলন ACID-কে “স্কেলের শত্রু” বলেছিল। অথচ পরে Google Spanner বিশ্বজুড়ে ছড়ানো transaction আনল, আর MongoDB ২০১৮ সালে বহু-document transaction যোগ করল। দোলকটি ফিরে এল কেন?
প্রথম দোলের কারণ বাস্তব ছিল। ওয়েবের বিশাল কোম্পানিগুলোর ডেটা একটি সার্ভারে আর ধরছিল না। বহু সার্ভারে ছড়ানো ডেটায় একটি transaction কয়েকটি মেশিন ছুঁলে তাদের সবার সম্মতি লাগে (two-phase commit), যা ধীর, আর একটি মেশিন সাড়া না দিলে সবাই অপেক্ষায়। Amazon-এর Dynamo (২০০৭) আর তার উত্তরসূরিরা সিদ্ধান্ত নিল: সবসময় লেখা নেওয়া যাবে, কপিগুলো পরে মিলবে — BASE. শপিং কার্টের মতো কাজে, যেখানে মাঝে মাঝে একটি আইটেম দুবার দেখা গেলেও ক্ষতি সামান্য, এটি যুক্তিসংগত। তবে BASE শব্দটি এর আগেই, ১৯৯০-এর দশকের শেষে Eric Brewer ও সহকর্মীদের লেখায় এসেছিল।
কিন্তু ছাড়ের দাম দিতে হলো অ্যাপের ডেভেলপারদের। প্রতিটি দল নিজের কোডে conflict মেলানো, আধা-কাজ পরিষ্কার আর অসংগত ডেটা সামলানো শুরু করল — অর্থাৎ ডেটাবেসের বদলে অ্যাপে transaction-এর দুর্বল সংস্করণ লেখা। Google-এর Spanner প্রবন্ধে (২০১২) লেখকরা মোটামুটি এমন যুক্তি দেন: transaction-এর অতিব্যবহারে performance-এর সমস্যা হলে তা ধরে সমাধান করা সহজ, কিন্তু transaction না থাকলে প্রতিটি প্রোগ্রামারকে সারাক্ষণ তার অভাবের চারপাশে কোড লিখতে হয়। Spanner atomic clock আর GPS-ভিত্তিক TrueTime দিয়ে সময়ের অনিশ্চয়তা সীমিত রেখে বিশ্বজুড়ে ছড়ানো serializable transaction দেয়।
এরপর CockroachDB, YugabyteDB-এর মতো “NewSQL” সিস্টেম একই পথে এল, আর MongoDB ৪.০-তে বহু-document ACID transaction যোগ করল। শিক্ষাটি হলো, প্রশ্নটি কখনো “ACID না BASE” ছিল না, বরং “কোন ডেটার জন্য কতটা নিশ্চয়তা, আর কী দামে”. টাকার হিসাব, স্টক আর আসন বুকিংয়ে কঠোর নিশ্চয়তা; লাইক-গণনা, ভিউ-কাউন্ট আর সুপারিশে eventual consistency প্রায়ই যথেষ্ট। একটি সিস্টেমের ভেতরেই দুটি একসাথে থাকে।
মূল উপলব্ধি: transaction-এর জটিলতা কোথাও না কোথাও থাকবেই — ডেটাবেসে, নয়তো প্রতিটি অ্যাপের কোডে। শিল্প শেষে বুঝেছে, একবার ডেটাবেসে ভালোভাবে বানানো হাজারবার অ্যাপে বানানোর চেয়ে নিরাপদ।
অনুশীলন
একটি transaction লেখা, একটি savepoint-এর ফল আগে বলা, একটি crash-এর ফল হিসাব, আর একটি নকশা ঠিক করা।
-
কোয়েরি লিখুন: এই পর্বের wallet টেবিলে (Rahim ৫,০০০, Karim ১,২০০, Shapla ৩০০; CHECK balance ≥ 0) Karim থেকে Shapla-কে ৮০০ টাকা পাঠানোর একটি transaction লিখুন, যাতে টাকা না থাকলে কোনো ত্রুটি ছাড়াই কিছু না বদলায়, আর transfers টেবিলে রেকর্ড শুধু সফল হলেই থাকে। শেষে মোট টাকা যাচাই করুন। অংশ ৪-এর এডিটরে লিখে চালাতে পারেন।
BEGIN; UPDATE wallet SET balance = balance - 800 WHERE id = 2 AND balance >= 800; -- ১টি সারি বদলালে এগোন UPDATE wallet SET balance = balance + 800 WHERE id = 3; INSERT INTO transfers (from_id, to_id, amount) VALUES (2, 3, 800); COMMIT; -- প্রথম UPDATE ০ সারি দিলে এখানে ROLLBACK SELECT SUM(balance) AS total FROM wallet; -- 6500কাটার শর্তটি প্রথমে, কারণ বাকি সব তার ওপর নির্ভর করে; অ্যাপ প্রথম UPDATE-এর বদলানো সারির সংখ্যা দেখে — ১ হলে বাকি দুটি আর COMMIT, ০ হলে ROLLBACK. এখানে Karim-এর ১,২০০ আছে, তাই ফল Karim ৪০০, Shapla ১,১০০, মোট ৬,৫০০. শুধু SQL দিয়ে এই “যদি” লেখা যায় না — সিদ্ধান্তটি অ্যাপের, বা পর্ব ২৮-এর stored procedure-এর।
-
ফল আগে বলুন (trace): খালি টেবিল t(x)-এ এই ক্রম চলল:
BEGIN; INSERT 1; SAVEPOINT a; INSERT 2; SAVEPOINT b; INSERT 3; ROLLBACK TO a; INSERT 4; SAVEPOINT c; INSERT 5; RELEASE c; ROLLBACK TO a; INSERT 6; COMMIT;শেষে t-তে কোন মানগুলো থাকবে? মাঝপথেROLLBACK TO bলিখলে কী হতো?ধাপে ধাপে: {1} → a → {1,2} → b → {1,2,3} → ROLLBACK TO a: {1}, b মুছে গেল, a থাকল → {1,4} → c → {1,4,5} → RELEASE c: 5 থাকে, a-র অধীনে → ROLLBACK TO a: আবার {1} — 4 আর 5 দুটিই গেল → {1,6} → COMMIT. শেষ ফল: 1 আর 6.
প্রথম ROLLBACK TO a-র পর
ROLLBACK TO bলিখলে ত্রুটি — SQLite-এno such savepoint: b— কারণ a-তে ফেরার সময় তার পরে বসানো b মুছে গেছে। শিক্ষা: RELEASE কিছু পাকা করে না; বাইরের চিহ্নে ফিরলে তার ভেতরের RELEASE করা কাজও যায়। -
Crash-এর ফল হিসাব করুন: autocommit-এ চলা একটি অ্যাপ বেতনের দিনে ১০০ জন কর্মীর প্রত্যেকের হিসাবে ১৫,০০০ টাকা যোগ করে, আর প্রতিটি যোগের পর কোম্পানির হিসাব থেকে ১৫,০০০ কাটে — একটি করে আলাদা UPDATE. ৪৩তম কর্মীর হিসাবে যোগের ঠিক পরে সার্ভার বন্ধ হলো। (ক) চালু হলে কোম্পানির হিসাব আর কর্মীদের মোটে কত তফাত? (খ) পুরো কাজ একটি transaction-এ হলে? (গ) প্রতিটি কর্মীর জন্য একটি করে ছোট transaction হলে?
- (ক) ৪৩ জন টাকা পেয়েছেন, কিন্তু কাটা হয়েছে ৪২ বার। কর্মীদের মোট বেড়েছে ৪৩ × ১৫,০০০ = ৬,৪৫,০০০, কোম্পানি থেকে কমেছে ৪২ × ১৫,০০০ = ৬,৩০,০০০. মোট টাকা ১৫,০০০ বেশি — শূন্য থেকে তৈরি। আর কারা পেয়েছেন, তা খুঁজতে হবে হাতে।
- (খ) COMMIT হয়নি, তাই recovery সব undo করে — কেউ পাননি, কিছু কাটা হয়নি; invariant অক্ষত। পুরো কাজ আবার চালাতে হবে। দাম: ২০০টি লেখার একটি বড় transaction, যা চলার সময় সব হিসাব lock রাখে।
- (গ) প্রতিটি transaction-এ একজনের যোগ আর কোম্পানির কাটা। ৪২টি committed, ৪৩তমটি undo. invariant অক্ষত, আর আবার শুরু করা যায় ৪৩তম থেকে — যদি অ্যাপ জানে কে কে পেয়েছেন। তাই প্রতিটি transaction-এ একটি payout রেকর্ড (কর্মী, মাস, UNIQUE) লিখুন — idempotency key-র মতো, যাতে পুরো কাজ আবার চালালেও কেউ দুবার না পান।
-
নকশা ঠিক করুন: একটি সিনেমা হলের টিকিট অ্যাপে কোডটি এমন: BEGIN; আসনটি
SELECT … FOR UPDATE; ব্যবহারকারীকে পেমেন্ট পাতায় পাঠানো; পেমেন্ট gateway-র উত্তরের অপেক্ষা (গড়ে ২ মিনিট); আসন “sold”; SMS পাঠানো; COMMIT. তিনটি সমস্যা চিহ্নিত করে নতুন নকশা দিন।সমস্যা: (১) ব্যবহারকারীর ভাবনা আর পেমেন্টের সময় transaction-এর ভেতরে — আসনের lock মিনিটের পর মিনিট, পাশের আসন খুঁজতে আসা অন্যরাও আটকে যেতে পারেন, আর PostgreSQL-এ পুরোনো সংস্করণ জমে। (২) SMS transaction-এর ভেতরে — COMMIT ব্যর্থ হলে বা retry হলে SMS চলে গেছে অথচ টিকিট নেই, বা দুবার SMS. (৩) পেমেন্ট সফল হয়ে COMMIT-এর আগে সার্ভার পড়ে গেলে টাকা কাটা, আসন নেই — আর পুনরায় চেষ্টায় দ্বিগুণ পেমেন্টের ঝুঁকি।
নতুন নকশা: T₁ (মিলিসেকেন্ড): আসন available থাকলে
status = 'held', hold_until = now + 10 min, booking_idবসিয়ে COMMIT. পেমেন্ট transaction-এর বাইরে, booking_id-কে idempotency key হিসেবে gateway-তে পাঠিয়ে। T₂: gateway-র উত্তর (বা webhook) এলেWHERE booking_id = ? AND status = 'held'শর্তে sold করে COMMIT. SMS COMMIT-এর পরে, বা outbox টেবিলে লিখে আলাদা কর্মী দিয়ে। একটি নির্ধারিত কাজ মেয়াদোত্তীর্ণ hold ফিরিয়ে দেয়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি সফল টাকা-পাঠানো transaction-এর ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- অ্যাপ BEGIN পাঠায়; transaction active
- পাঠানোর হিসাব থেকে কাটা, আগের মান log-এ
- পাওয়ার হিসাবে যোগ; শেষ স্টেটমেন্ট সফল
- অ্যাপ COMMIT পাঠায়; transaction partially committed
- commit record স্থায়ী স্টোরেজে পৌঁছায়; committed
- অ্যাপ “সফল” উত্তর পায়
একটি ব্যাংক অ্যাপ এক হিসাব থেকে টাকা কেটে অন্য হিসাবে যোগ করার ঠিক মাঝখানে সার্ভার বন্ধ হলো। চালু হওয়ার পর কোনো হিসাবেই পরিবর্তন নেই। ACID-এর কোন গুণ এটি নিশ্চিত করল?
- Durability
- Atomicity
- Isolation
- Consistency
SQLite-এ এই ক্রম চলল: BEGIN; INSERT INTO t VALUES (1); SAVEPOINT s; INSERT INTO t VALUES (2); RELEASE s; ROLLBACK; — শেষে টেবিল t-তে কোন সারি থাকবে (শুরুতে t খালি)?
- শুধু 2
- 1 আর 2
- কোনো সারি নয়
- শুধু 1
MySQL (InnoDB)-এ: START TRANSACTION; INSERT INTO logs VALUES (1); CREATE TABLE tmp (x INT); ROLLBACK; — শেষে কী অবস্থা?
- logs-এ সারিটি আছে, আর tmp টেবিলও আছে
- দুটিই বাতিল — কিছুই বদলায়নি
- logs-এ সারিটি আছে, tmp টেবিল নেই
- CREATE TABLE ত্রুটি দেয়, তাই কিছুই হয় না
wallet টেবিলে CHECK (balance >= 0) আছে। একটি কোডে টাকা পাঠানোর সময় কাটা হয় ৭০০ কিন্তু যোগ হয় ৭০; কোনো ব্যালান্স ঋণাত্মক হয় না, transaction commit হয়। এ বিষয়ে কোন কথাটি ঠিক?
- DBMS COMMIT আটকে দেবে, কারণ consistency ভেঙেছে
- এটি atomicity-র ব্যর্থতা
- এটি isolation-এর ব্যর্থতা, তাই SERIALIZABLE দিলেই সমাধান
- সব constraint মানা হয়েছে; ভেঙেছে অ্যাপের invariant, যা DBMS জানে না
একটি পেমেন্ট সার্ভিসে COMMIT-এর পর উত্তর পৌঁছানোর আগে নেটওয়ার্ক কেটে যায়, আর ক্লায়েন্ট অনুরোধটি আবার পাঠায়। দ্বিগুণ কাটা ঠেকানোর সবচেয়ে নির্ভরযোগ্য উপায় কোনটি?
- isolation level SERIALIZABLE করা
- ক্লায়েন্টের timeout বাড়ানো
- ক্লায়েন্টের পাঠানো idempotency key একই transaction-এ UNIQUE কলামে লেখা
- ক্লায়েন্টকে কখনো retry করতে না দেওয়া
SQLite-এ একটি transaction নিশ্চিতভাবে লিখবে। পরে মাঝপথে database is locked এড়াতে কীভাবে শুরু করা ভালো?
- BEGIN IMMEDIATE
- BEGIN DEFERRED
- BEGIN না দিয়ে autocommit-এ চালানো
- SAVEPOINT দিয়ে শুরু করা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Transaction = কাজের অবিভাজ্য একক। অবস্থা: active → partially committed → committed, অথবা failed → aborted (তারপর restart বা kill). commit-এর পর ফেরানো যায় না — শুধু উল্টো লেনদেন।
- A, I, D যন্ত্র; C লক্ষ্য। log আর undo দিয়ে atomicity, concurrency control দিয়ে isolation, WAL আর fsync দিয়ে durability; constraint রক্ষা DBMS-এর, invariant অ্যাপের।
- SQL: BEGIN/START TRANSACTION, COMMIT, ROLLBACK; SAVEPOINT, ROLLBACK TO (চিহ্ন থাকে), RELEASE (commit নয়)। ত্রুটির পর SQLite আর MySQL শুধু স্টেটমেন্ট ফেরায়, PostgreSQL পুরো transaction aborted করে — সব ক্ষেত্রে অ্যাপ ROLLBACK পাঠাবে।
- ডিফল্ট জানুন: autocommit-এ প্রতিটি স্টেটমেন্ট আলাদা transaction; driver-ভেদে ডিফল্ট আলাদা। SQLite-এ লেখার transaction BEGIN IMMEDIATE দিয়ে। DDL: PostgreSQL ও SQLite-এ ফেরানো যায়, MySQL ও Oracle-এ implicit commit.
- অ্যাপের নকশা: transaction ছোট, ভেতরে ব্যবহারকারীর অপেক্ষা বা নেটওয়ার্ক কল নয়; deadlock আর serialization failure-এ পুরো transaction-এর retry; পেমেন্টে idempotency key একই transaction-এ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৫ · Schedule আর serializability — conflict আর viewপরবর্তী পর্বisolation-এর গাণিতিক সংজ্ঞা।
- পর্ব ৪৩ · Query optimization — cost, statistics আর planফিরে দেখুনমডিউল ৬-এর শেষ পর্ব।
- পর্ব ৩১ · SQL-এর উপভাষাসম্পর্কিতtransactional DDL আর implicit commit — DBMS ভেদে।
- ডেটাবেস (DBMS) ও SQL · কোর্স হোম৬৭টি পর্বপুরো কোর্সের রোডম্যাপ — ১০টি মডিউল।
- Python · SQLite দিয়ে ডেটাবেসসম্পর্কিত কোর্সcommit(), rollback আর
with con:— Python থেকে transaction. - Java · JDBC দিয়ে ডেটাবেসসম্পর্কিত কোর্সsetAutoCommit(false), commit() আর rollback().