Transaction ও Distributed Transaction

ACID, isolation level, Saga, Outbox

রোডম্যাপের মাস ৬পড়তে প্রায় ১১ মিনিট

Rahim, Karim কে ৫০০ টাকা পাঠালো। দুইটা কাজ: Rahim এর account থেকে ৫০০ কাটা, Karim এর account এ ৫০০ যোগ। প্রথমটা হলো, দ্বিতীয়টার আগেই server crash করলো। ৫০০ টাকা বাতাসে মিলিয়ে গেল। Transaction এই সমস্যার সমাধান: কয়েকটা কাজকে একটা অবিভাজ্য একক বানানো, হয় সব হবে, নয়তো একটাও না। এক database এ এটা সহজ। কয়েকটা database বা service জুড়ে হলে এটা distributed system এর সবচেয়ে কঠিন সমস্যাগুলোর একটা।

#ACID

অক্ষর মানে উদাহরণ
Atomicity সব বা কিছুই না টাকা কাটা আর যোগ একসাথে, বা কোনোটাই না
Consistency নিয়ম কখনো ভাঙে না Balance কখনো ঋণাত্মক হবে না (CHECK constraint)
Isolation একসাথে চলা transaction একে অপরের মাঝখানের অবস্থা দেখে না দুইটা transfer একই balance নিয়ে গোলমাল করবে না
Durability "সফল" বললে সেটা স্থায়ী, এখনই বিদ্যুৎ গেলেও Commit মানে disk এ (WAL এ) লেখা

(CAP এর C আর ACID এর C আলাদা জিনিস! CAP এর C হলো replica গুলোর মিল, ACID এর C হলো data এর নিয়ম মানা।)

#Isolation: সবচেয়ে সূক্ষ্ম অংশ

একসাথে অনেক transaction চলে। সম্পূর্ণ আলাদা রাখা (যেন একটার পর একটা চলছে) সবচেয়ে নিরাপদ কিন্তু ধীর। তাই database গুলো কয়েকটা isolation level দেয়, আর প্রতিটা কিছু ধরনের অদ্ভুত ঘটনা অনুমোদন করে।

#Lost update: সবচেয়ে সাধারণ bug

Transaction ১ (৳২০০ কাটো) Transaction ২ (৳৩০০ কাটো) balance ১০০০ পড়লো: ১০০০ পড়লো: ১০০০ লিখলো: ১০০০ - ২০০ = ৮০০ লিখলো: ১০০০ - ৩০০ = ৭০০ balance ৭০০ ✗ সঠিক হতো ৫০০ ৳২০০ এর কাটা হারিয়ে গেল
দুজনই পুরনো মান পড়ে নিজের হিসাব করে লিখলো। দ্বিতীয়জন প্রথমজনের বদল মুছে দিলো।

Python এ এই ভুল খুব সহজে হয়:

Python
# ❌ পড়ো, Python এ হিসাব করো, লেখো
wallet = await db.get(Wallet, user_id)
wallet.balance -= amount
await db.commit()

সমাধানগুলো:

SQL
-- ১. Database কেই হিসাব করতে দাও (atomic update)
UPDATE wallets SET balance = balance - 200 WHERE user_id = 1 AND balance >= 200;
-- কয়টা row বদলালো দেখো: ০ হলে টাকা যথেষ্ট ছিল না

-- ২. Pessimistic lock: পড়ার সময়ই row আটকাও
SELECT balance FROM wallets WHERE user_id = 1 FOR UPDATE;
-- অন্য কেউ এই row এ FOR UPDATE করতে চাইলে অপেক্ষা করবে
Python
# ৩. Optimistic lock: version দিয়ে
# wallets এ একটা version column
result = await db.execute(
    update(Wallet)
    .where(Wallet.user_id == uid, Wallet.version == wallet.version)   # যা পড়েছিলাম সেটাই কি এখনো?
    .values(balance=wallet.balance - amount, version=wallet.version + 1)
)
if result.rowcount == 0:
    raise ConflictError("অন্য কেউ ইতিমধ্যে বদলেছে, আবার চেষ্টা করো")
পদ্ধতি কখন
Atomic update (SET x = x - 1) সহজ counter, balance; প্রথম পছন্দ
Pessimistic (FOR UPDATE) সংঘর্ষ প্রায়ই হয়, জটিল যুক্তি (একসাথে কয়েকটা row)
Optimistic (version) সংঘর্ষ কম, অপেক্ষা করাতে চাও না (যেমন document edit)

#Isolation level

Level কী ঠেকায় PostgreSQL এ
Read uncommitted প্রায় কিছুই না নেই (read committed এর মতো আচরণ)
Read committed অন্যের commit না হওয়া data দেখা (dirty read) Default
Repeatable read একই transaction এ একই row দুইবার পড়ে আলাদা মান আছে (snapshot)
Serializable সব অদ্ভুততা; যেন একটার পর একটা চলেছে আছে, conflict এ একটাকে ব্যর্থ করে, retry লাগে

Default (read committed) এ lost update হতে পারে, তাই উপরের কৌশল লাগে। Serializable সবচেয়ে নিরাপদ কিন্তু retry এর code লিখতে হয়।

#Deadlock

Transaction ১ row A আটকে row B চায়; transaction ২ row B আটকে row A চায়। দুজনই চিরকাল অপেক্ষা। Database এটা ধরে একটাকে বাতিল করে। এড়ানোর উপায়: সবসময় একই ক্রমে lock নাও (যেমন ছোট id আগে), transaction ছোট রাখো, transaction এর ভেতরে network call (payment API) করো না।

#Distributed transaction: কয়েক জায়গা জুড়ে

এখন ধরো order দিতে তিনটা আলাদা service, প্রতিটার নিজের database:

  1. Order service: order তৈরি
  2. Payment service: টাকা কাটা
  3. Inventory service: stock কমানো

এক database এর BEGIN ... COMMIT এখানে চলে না।

#2PC (Two-Phase Commit)

একজন coordinator সবাইকে জিজ্ঞেস করে "প্রস্তুত?" (prepare), সবাই হ্যাঁ বললে "commit", কেউ না বললে সবাইকে "rollback"।

সমস্যা: coordinator মাঝপথে মরে গেলে সবাই lock ধরে আটকে থাকে; ধীর; সব অংশগ্রহণকারীকে 2PC সমর্থন করতে হয়। তাই microservice আর বাইরের API (payment gateway) এর জগতে প্রায় ব্যবহার হয় না।

#Saga: ছোট ছোট ধাপ, ভুল হলে উল্টো ধাপ

প্রতিটা ধাপ নিজের database এ সাধারণ local transaction। কোনো ধাপ ব্যর্থ হলে আগের সফল ধাপগুলো উল্টে দেওয়ার কাজ (compensating action) চালানো হয়।

Order তৈরি status: pending Stock আটকাও reserve টাকা কাটো ব্যর্থ ✗ Confirm Stock ছেড়ে দাও compensate Order বাতিল compensate ব্যর্থ হলে উল্টো ক্রমে
উপরের লাইন সফল পথ। Payment ব্যর্থ হলে নিচের লাইন: আগে stock ছাড়া, তারপর order বাতিল।

Saga চালানোর দুই ধরন:

  • Orchestration: একটা কেন্দ্রীয় "পরিচালক" (order service বা আলাদা workflow engine) ধাপে ধাপে বলে কী করতে হবে। Flow এক জায়গায় দেখা যায়, বোঝা সহজ। (Temporal, AWS Step Functions এর মতো tool এই কাজের।)
  • Choreography: কোনো পরিচালক নেই, প্রতিটা service event শুনে নিজের কাজ করে ("OrderCreated" শুনে inventory stock আটকায়, "StockReserved" শুনে payment টাকা কাটে...)। আলগা সংযোগ, কিন্তু পুরো flow কোথাও এক জায়গায় লেখা নেই, debug কঠিন।

Saga এর বাস্তবতা:

  • মাঝের অবস্থা বাইরে দেখা যায় (order "pending")। এটা ACID এর isolation না। UI তে এই অবস্থা সামলাতে হয় ("payment যাচাই হচ্ছে...")।
  • Compensate সবসময় নিখুঁত "undo" না: পাঠানো SMS ফেরত আনা যায় না, শুধু "বাতিল" SMS পাঠানো যায়।
  • প্রতিটা ধাপ আর compensate idempotent হতে হবে, কারণ retry হবে।

#Outbox pattern: database আর queue একসাথে

খুব সাধারণ একটা সমস্যা:

Python
await db.commit()                          # ১. order save হলো
await queue.publish("OrderCreated", ...)   # ২. এখানে crash করলে event কখনো যাবে না

উল্টো ক্রমে করলে: event গেল, কিন্তু commit ব্যর্থ, তখন এমন order এর event যেটা নেই। Database আর queue দুইটা আলাদা system, একটা transaction এ বাঁধা যায় না।

সমাধান: Outbox। Event টা queue তে না পাঠিয়ে একই database transaction এ একটা outbox table এ লেখো। আলাদা একটা process সেই table পড়ে queue তে পাঠায়, তারপর "পাঠানো হয়েছে" চিহ্ন দেয়।

Order service একটাই database transaction orders: INSERT outbox: INSERT event Relay process পড়ে Queue
Order আর event হয় দুটোই save হবে, নয়তো কোনোটাই না। Relay নিশ্চিত করে event একসময় queue তে যাবেই (at least once)।
Python
async with db.begin():
    order = Order(...)
    db.add(order)
    db.add(OutboxEvent(type="OrderCreated", payload={"order_id": order.id, ...}))
# relay: প্রতি সেকেন্ডে unsent event পড়ে publish করে, তারপর sent_at বসায়

Relay ক্র্যাশ করে আবার একই event পাঠাতে পারে, তাই consumer কে idempotent হতে হয় (event id দেখে duplicate বাদ)। আরও উন্নত রূপ: database এর পরিবর্তনের log থেকে সরাসরি event বানানো (CDC, Debezium)।

#সারাংশ: কোন পরিস্থিতিতে কী

পরিস্থিতি উপায়
এক database এ কয়েকটা বদল সাধারণ transaction (BEGIN/COMMIT)
একসাথে কয়েকজন একই row বদলাচ্ছে Atomic update, FOR UPDATE, বা optimistic version
Database এ save + event/job পাঠানো Outbox
কয়েকটা service জুড়ে ব্যবসার flow Saga (সাধারণত orchestration) + idempotency
বাইরের API (payment gateway) সহ Saga + reconciliation (মাঝে মাঝে মিলিয়ে দেখা)

#অনুশীলন

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

Lost update কী, আর সবচেয়ে সহজ সমাধান কী?

দুইটা transaction একই পুরনো মান পড়ে নিজের হিসাব করে লেখে, একটার বদল আরেকটা মুছে দেয়। সহজ সমাধান: database কে হিসাব করতে দেওয়া (UPDATE ... SET balance = balance - 200), Python এ পড়ে হিসাব না করা।

Saga তে compensating action কী?

আগের একটা সফল ধাপকে ব্যবসার দিক থেকে উল্টে দেওয়ার কাজ, যেমন আটকানো stock ছেড়ে দেওয়া বা কাটা টাকা ফেরত দেওয়া। পরের কোনো ধাপ ব্যর্থ হলে উল্টো ক্রমে এগুলো চলে।

Order save করার পর সরাসরি queue তে event পাঠালে কী সমস্যা, আর Outbox কীভাবে সমাধান করে?

Save আর publish এর মাঝে crash হলে event হারায়, বা উল্টো ক্রমে event যায় কিন্তু save ব্যর্থ হয়। Outbox এ event টা একই database transaction এ table এ লেখা হয়, তাই order আর event একসাথে save হয়; পরে আলাদা process সেটা queue তে পাঠায়।

Deadlock কীভাবে কমাবে?

সব জায়গায় একই ক্রমে lock নেওয়া, transaction ছোট রাখা, আর transaction এর ভেতরে ধীর কাজ (বাইরের API call) না করা।

চিহ্ন দিলে sidebar আর রোডম্যাপে এই অধ্যায়ের station ভরে যাবে।