সেশন ১৭

Distributed Transactions, Saga, 2PC - টাকা কাটলো, কিন্তু রুম মিললো না

এই সেশনের লক্ষ্য: কয়েকটা service আর database জুড়ে একটা কাজকে কীভাবে "হয় সব, নয়তো কিছুই না" রাখা যায়, তা বোঝা। Two-phase commit কীভাবে কাজ করে আর কোথায় আটকে যায়, saga আর compensating transaction দিয়ে কীভাবে অর্ধেক হওয়া কাজ ফিরিয়ে আনা যায়, আর outbox pattern দিয়ে event যাতে না হারায়, সেটা শেখা।

০০ যে প্রশ্নগুলোর উত্তর খুঁজবো

সেশন ০৫ এ আমরা transaction দেখেছিলাম। কয়েকটা কাজকে একসাথে বেঁধে ফেলা হয়, যাতে হয় সবগুলো হয়, নয়তো একটাও না। কিন্তু সেখানে সব কাজ ছিলো একটাই database এর ভেতরে।

বড় system এ একটা কাজ প্রায়ই কয়েকটা service আর কয়েকটা database ছুঁয়ে যায়। তখন “হয় সব, নয়তো কিছুই না” নিয়মটা কে রক্ষা করবে? এই সেশনে আমরা একটা ছোট travel booking এর গল্প ধরে এই প্রশ্নের উত্তর খুঁজবো। শেষে আপনি বুঝবেন, বাস্তব system অর্ধেক হওয়া কাজ কীভাবে নিরাপদে ফিরিয়ে আনে।

  • ১ Flight, payment আর hotel তিনটা আলাদা service এ। একটা local transaction কেন যথেষ্ট নয়? (distributed transaction)
  • ২ সবাইকে আগে "পারবেন তো?" জিজ্ঞেস করে তারপর commit করলে কেমন হয়? (2PC, coordinator)
  • ৩ 2PC এর blocking problem টা কী, আর বাস্তবে 2PC কেন কম চলে?
  • ৪ Saga কী? (local transaction এর chain)
  • ৫ মাঝপথে একটা step fail করলে আগের কাজগুলো কীভাবে ফেরানো হয়? (compensating transaction)
  • ৬ Saga র step গুলো কে চালাবে? (choreography বনাম orchestrator)
  • ৭ Database এ লেখা আর event পাঠানো, দুটো একসাথে নিশ্চিত করবো কীভাবে? (outbox pattern)
  • ৮ এই পুরো সময় user screen এ কী দেখবে? (pending, confirmed, refunded)

০১ একটা local transaction কেন যথেষ্ট নয়?

ডিসেম্বরের এক বৃহস্পতিবার, রাত সাড়ে এগারোটা। শিরিন অনেক দিন ধরে বাচ্চাদের কথা দিয়ে রেখেছেন, এবার শীতের ছুটিতে সমুদ্র দেখাবেন। “চলো ঘুরি” site এ তিনি চারজনের জন্য ঢাকা থেকে কক্সবাজারের flight ticket বাছলেন, সাথে তিন রাতের জন্য একটা sea-view room। Card দিয়ে ৳৪২,০০০ pay করলেন। কয়েক সেকেন্ড পর ব্যাংকের SMS এলো: টাকা কেটেছে। ঠিক তখনই screen এ লাল অক্ষরে লেখা উঠলো, “Hotel booking failed”।

রাতেই support এ ticket এলো: “টাকা কেটে নিলেন, অথচ hotel নেই। এখন বাচ্চাদের কী বলবো?” Ticket টা গিয়ে পড়লো backend engineer রাইসার কাছে। রাইসা log খুলে দেখলো, কোনো server crash করেনি। প্রতিটা service ঠিক সেটাই করেছে, যা তাকে করতে বলা হয়েছিলো। তবু একটা পরিবারের ৳৪২,০০০ আটকে আছে।

তিনটা service, তিনটা database

“চলো ঘুরি” একটা বড় application নয়, কয়েকটা ছোট service এর সমষ্টি। প্রতিটা service এর নিজের database আছে, আর অন্য কেউ সেই database এ সরাসরি হাত দেয় না। এই নিয়মকে বলে database per service (সেশন ১১)।

local transaction local transaction local transaction শিরিন "Book" চাপলেন 4 জন · ঢাকা → কক্সবাজার Booking Service তিনটা service কে ডাকে Flight 4টা seat ধরে রাখে Payment ৳42,000 কাটে Hotel 3 রাতের room flight_db payment_db hotel_db
প্রতিটা service এর নিজের database আছে। প্রতিটা dashed বাক্স একটা আলাদা local transaction। তিনটা database কে একসাথে ঘিরে রাখার মতো কোনো একক transaction নেই।

প্রতিটা service নিজের database এ খুব ভালো transaction চালাতে পারে। Flight service চারটা seat একসাথে hold করে, হয় চারটাই, নয়তো একটাও না। কিন্তু এই transaction এর সীমানা ঐ একটা database পর্যন্তই। Flight এর database জানে না payment এর database এ কী হচ্ছে। তিনটাকে একসাথে বাঁধার মতো কোনো BEGIN ... COMMIT নেই।

সেই রাতে আসলে কী হয়েছিলো

রাইসা log থেকে ঘটনাটা মিনিট-সেকেন্ড ধরে সাজালো:

23:31:02.120 ১. Flight: 4টা seat hold ✓ commit হয়ে গেছে 23:31:02.410 ২. Payment: ৳42,000 কাটা ✓ commit হয়ে গেছে 23:31:03.050 ৩. Hotel: শেষ room টা নেই ✗ fail ফল: ৳42,000 কাটা, seat আটকে আছে কিন্তু থাকার জায়গা নেই
এক সেকেন্ডের মধ্যে তিনটা step চললো। প্রথম দুইটা নিজের database এ commit হয়ে গেছে, তাই তৃতীয়টা fail করলেও ওরা নিজে থেকে আগের অবস্থায় ফেরে না। System টা অর্ধেক কাজ করে থেমে গেছে।

Booking service আগে flight service কে ডাকলো, seat hold হলো। তারপর payment service কে ডাকলো, টাকা কাটা হলো। দুটোই নিজের database এ commit হয়ে গেলো। এরপর hotel service কে ডাকতেই উত্তর এলো, শেষ sea-view room টা দুই সেকেন্ড আগে অন্য কেউ নিয়ে নিয়েছে।

Hotel এর fail করা স্বাভাবিক ঘটনা। আসল সমস্যা হলো, আগের দুটো commit করা কাজ নিজে থেকে ফিরে আসে না। কাজটা তিন ভাগের দুই ভাগ হয়ে থেমে গেছে।

আসল সমস্যা

একাধিক service বা database জুড়ে ছড়ানো একটা কাজকে বলে distributed transaction। আমরা চাই এই কাজটাও “হয় সব, নয়তো কিছুই না” নিয়ম মানুক। কিন্তু একটা database এর transaction অন্য database পর্যন্ত পৌঁছায় না। তাই এই নিয়ম রক্ষা করার দায়িত্ব এখন আমাদের নিজেদের design এর।

এই সমস্যার দুটো বিখ্যাত সমাধান আছে। একটা হলো সবাইকে আগে থেকে রাজি করিয়ে একসাথে commit করা (2PC)। অন্যটা হলো একটা একটা করে commit করা, আর দরকার হলে উল্টো কাজ করে ফেরানো (saga)। দুটোই দেখবো।

০২ সবাইকে আগে “পারবেন তো?” জিজ্ঞেস করে তারপর commit করলে কেমন হয়?

বন্ধুদের picnic এর analogy

ধরুন, আপনি পাঁচ বন্ধুর picnic এর আয়োজক। বাস ভাড়া করার আগে আপনি সবাইকে ফোন করে জিজ্ঞেস করেন, শুক্রবার আসা যাবে কিনা। সাথে বলে দেন, যে আসতে পারবে, সে যেন দিনটা আটকে রাখে আর অন্য কোনো plan না করে। প্রত্যেকে হয় বলে “হ্যাঁ, রাখলাম”, নয়তো বলে “না, পারবো না”।

পাঁচজনই হ্যাঁ বললে আপনি বাস ভাড়া করেন, আর সবাইকে জানান: “Final, শুক্রবার যাচ্ছি।” একজনও না বললে সবাইকে জানান: “বাতিল, শুক্রবার এখন খালি।” খেয়াল করুন, হ্যাঁ বলার পর কেউ আর ঐ শুক্রবারে অন্য কিছু ঠিক করতে পারে না। সে আপনার শেষ কথার অপেক্ষায় থাকে।

Two-phase commit (2PC)

Database এর জগতে এই কৌশলের নাম two-phase commit, সংক্ষেপে 2PC। এখানে picnic এর আয়োজকের ভূমিকা নেয় একটা coordinator। Coordinator হলো সেই component, যে পুরো distributed transaction টা চালায় আর শেষ সিদ্ধান্ত নেয়। আমাদের গল্পে booking service ই coordinator। Flight, payment আর hotel হলো participant।

PHASE 1 · PREPARE PHASE 2 · COMMIT Coordinator সবাইকে: "prepare?" Flight lock নেয় Payment lock নেয় Hotel lock নেয় vote: YES vote: YES vote: YES কাজ তৈরি, কিন্তু এখনো commit না Coordinator 3টাই YES: "commit" Flight lock ছাড়ে Payment lock ছাড়ে Hotel lock ছাড়ে commit ✓ commit ✓ commit ✓ একটাও NO হলে সবাইকে "abort"
Phase 1 এ coordinator সবাইকে জিজ্ঞেস করে, কাজটা নিশ্চিতভাবে করা যাবে কিনা। প্রতিটা participant কাজটা তৈরি রাখে, lock ধরে রাখে, আর YES বা NO vote দেয়। সবাই YES দিলে তবেই phase 2 তে commit হয়।

2PC থাকলে শিরিনের সেই রাতটা অন্যরকম হতো। Prepare এর সময়েই hotel NO vote দিতো, কারণ room নেই। Coordinator তখন সবাইকে abort করতে বলতো। Payment কখনো commit হতো না, তাই শিরিনের টাকাও কাটতো না।

2PC এর প্রতিশ্রুতি

YES vote মানে একটা প্রতিশ্রুতি: “Coordinator যা-ই বলুক, আমি সেটা করতে পারবো।” সবার প্রতিশ্রুতি হাতে না পাওয়া পর্যন্ত কেউ commit করে না। তাই হয় সবাই commit করে, নয়তো কেউই করে না।

বাস্তবে অনেক database 2PC সমর্থন করে। যেমন PostgreSQL এ PREPARE TRANSACTION আর COMMIT PREPARED command আছে। একাধিক database কে একসাথে 2PC তে চালানোর একটা পুরোনো standard ও আছে, নাম XA। তাহলে সব service এ 2PC চালিয়ে দিলেই তো হয়? রাইসাও প্রথমে তাই ভেবেছিলো।

০৩ 2PC এর blocking problem টা কী, আর বাস্তবে 2PC কেন কম চলে?

আয়োজকের ফোন বন্ধ হয়ে গেলে

Picnic এর গল্পে ফিরে যান। পাঁচজনই হ্যাঁ বলেছে, সবাই শুক্রবার আটকে রেখেছে। ঠিক তখন আয়োজকের ফোনের charge শেষ হয়ে গেলো। বন্ধুরা এখন কী করবে? Picnic কি final হয়েছে, নাকি বাতিল? কেউ জানে না। কেউ নিজে থেকে “বাতিল” ধরে নিলো, অথচ আয়োজক বাস ভাড়া করে ফেলেছে। তখন বাসে একজন কম যাবে। তাই সবাই শুক্রবার আটকে রেখে অপেক্ষা করে।

2PC তে ঠিক এটাই হয়:

১. Prepare শেষ: 3 জনই YES ২. Decision পাঠানোর আগেই coordinator crash Flight seat lock, অপেক্ষা Payment balance lock, অপেক্ষা Hotel room lock, অপেক্ষা commit না abort? YES দেওয়ার পর কেউ একা সিদ্ধান্ত নিতে পারে না Coordinator ফিরতে 3 মিনিট × 300 booking/মিনিট ≈ 900 booking আটকে থাকে বা timeout হয়
YES vote দেওয়ার পর participant কথা দিয়ে ফেলেছে। Coordinator না ফেরা পর্যন্ত সে commit ও করতে পারে না, abort ও করতে পারে না। এই পুরো সময় তার lock ধরা থাকে, আর পেছনের সব request অপেক্ষা করে।

YES vote দেওয়ার পর একজন participant আর একা সিদ্ধান্ত নিতে পারে না। কারণ coordinator হয়তো এর মধ্যে অন্য কাউকে commit করতে বলে দিয়েছে। আবার হয়তো বলেনি। Participant এর জানার কোনো উপায় নেই। তাই সে lock ধরে বসে থাকে, যতক্ষণ না coordinator ফেরে। এটাকেই বলে 2PC এর blocking problem।

সংখ্যায় দেখুন। ডিসেম্বরের ছুটির রাতে “চলো ঘুরি” তে প্রতি মিনিটে প্রায় ৩০০টা booking আসে। Coordinator crash করে আবার চালু হতে ৩ মিনিট লাগলে প্রায় ৯০০টা booking এর সময় নষ্ট হয়। আগে থেকে চলা booking গুলো lock ধরে আটকে থাকে। একই flight বা একই hotel এর নতুন booking গুলো সেই lock এর পেছনে দাঁড়িয়ে timeout পায়।

বাস্তবে 2PC আরো তিন জায়গায় আটকে যায়

  1. সবাইকে “prepare” বুঝতে হয়: শিরিনের card এর টাকা কাটে একটা payment gateway। Hotel এর room আসে partner hotel এর API থেকে। এরা অন্য company র system। এরা আপনার coordinator এর “prepare” বার্তা চেনে না, আর আপনার জন্য lock ধরে বসেও থাকবে না। ৩টার মধ্যে একটা participant ও 2PC না বুঝলে পুরো পদ্ধতিটাই চলে না।
  2. Lock অনেকক্ষণ ধরা থাকে: Phase 1 আর phase 2 মিলিয়ে কমপক্ষে দুইবার আসা-যাওয়া লাগে। সবচেয়ে ধীর participant এর উত্তর ৮০০ms এ এলে সবার lock অন্তত ১.৬ সেকেন্ড ধরা থাকে। ভিড়ের রাতে এই সময়টা অনেক বড়।
  3. একজন down হলে সবাই থামে: তিনজনের যেকোনো একজন unavailable হলে কোনো booking ই হয় না। Participant যত বাড়ে, পুরো system এর availability তত কমে।
2PC কোথায় মানায়

2PC ভুল কিছু নয়। একই company র ভেতরে, একই রকম কয়েকটা database এর মধ্যে, যেখানে network দ্রুত আর নির্ভরযোগ্য, সেখানে 2PC ভালো কাজ করে। Google Spanner এর মতো system coordinator কে নিজেই একটা Raft বা Paxos group (সেশন ১৬) বানিয়ে চালায়, যাতে coordinator এর crash এ সব আটকে না যায়। কিন্তু অন্য company র API, ধীর network আর অনেক service এর জগতে 2PC খুব কমই চলে।

রাইসার system এ payment gateway আর hotel API দুটোই বাইরের। তাই 2PC তার জন্য পথ নয়। তাকে এমন কিছু খুঁজতে হবে, যেখানে কেউ কারো জন্য lock ধরে বসে থাকে না।

০৪ Saga কী?

2PC এর ভাবনা ছিলো আগে সবাইকে রাজি করানো, তারপর একসাথে commit করা। Saga উল্টো পথে হাঁটে। প্রতিটা step নিজের local transaction এ এখনই commit করে, তারপর পরের step কে শুরু করতে বলে। কোনো step fail করলে আগের commit হওয়া step গুলোকে একটা করে উল্টো কাজ দিয়ে ফেরানো হয়।

অর্থাৎ saga হলো local transaction এর একটা chain। ১৯৮৭ সালে Hector Garcia-Molina আর Kenneth Salem একটা গবেষণাপত্রে এই নাম দেন। তাঁরা লম্বা সময় ধরে চলা transaction নিয়ে কাজ করছিলেন, যেখানে এতক্ষণ lock ধরে রাখা সম্ভব নয়।

T1 Flight Service 4টা seat hold, নিজের DB তে commit lock থাকে ~20ms event: FlightHeld T2 Payment Service ৳42,000 কাটে, নিজের DB তে commit lock থাকে ~20ms event: PaymentDone T3 Hotel Service room book, নিজের DB তে commit lock থাকে ~20ms event: HotelBooked Booking CONFIRMED ✓
Saga তে কেউ কারো জন্য lock ধরে অপেক্ষা করে না। প্রতিটা step নিজের database এ সাথে সাথে commit করে, তারপর একটা event পাঠিয়ে পরের step কে শুরু করতে বলে।

খেয়াল করুন, প্রতিটা lock এখন মাত্র কয়েক millisecond এর। Flight service seat hold করে commit করে, সাথে সাথে lock ছেড়ে দেয়। Payment service কী করছে, সেটা নিয়ে flight service এর আর কোনো মাথাব্যথা নেই। কেউ crash করলেও অন্যরা lock ধরে আটকে থাকে না।

বিনিময়ে একটা জিনিস ছাড়তে হয়। Saga চলার মাঝখানে বাইরের লোক অর্ধেক কাজ দেখতে পায়। যেমন seat hold হয়ে গেছে, টাকা কাটা হয়েছে, কিন্তু hotel এখনো হয়নি। একটা database transaction এ এই মাঝের অবস্থা কেউ দেখতে পায় না। এই বৈশিষ্ট্যের নাম isolation, আর saga তে সেটা থাকে না। এর ফল কী, আর কীভাবে সামলাতে হয়, সেটা সেশনের শেষ অংশে (প্রশ্ন ৮) দেখবো।

2PCSaga
Commit কখন হয়সবাই YES দিলে, একসাথেপ্রতিটা step নিজের মতো, সাথে সাথে
Lock কতক্ষণপুরো transaction জুড়ে, coordinator crash এ অনির্দিষ্টকালপ্রতিটা local transaction এর কয়েক millisecond
Fail হলেDatabase নিজে rollback করেআমাদের লেখা উল্টো কাজ চালাতে হয়
মাঝের অবস্থাবাইরে থেকে দেখা যায় নাদেখা যায়, তাই সামলাতে হয়
বাইরের APIPrepare না বুঝলে চলে নাযেকোনো API এর সাথে চলে

০৫ মাঝপথে একটা step fail করলে আগের কাজগুলো কীভাবে ফেরানো হয়?

Saga র প্রতিটা step এর জন্য আগে থেকেই একটা জোড়া কাজ লিখে রাখা হয়। এই উল্টো কাজকে বলে compensating transaction, বা সংক্ষেপে compensation। Flight এর seat hold এর compensation হলো seat release। Payment এর টাকা কাটার compensation হলো refund।

শিরিনের booking এ saga ঠিক এভাবে চলতো:

FORWARD COMPENSATE T1 · Flight 4টা seat hold ✓ T2 · Payment ৳42,000 কাটা ✓ T3 · Hotel room নেই ✗ এর উল্টো কাজ fail হলে C1 · seat release 4টা seat আবার খালি C2 · refund ৳42,000 ফেরত T3 commit হয়নি, তাই C3 লাগে না শেষ অবস্থা: CANCELLED টাকা ফেরতের পথে, seat অন্যদের জন্য খোলা
উপরের সারিতে saga সামনে এগোয়। Hotel fail করতেই saga ঘুরে দাঁড়ায়, আর নিচের সারিতে উল্টো ক্রমে চলে: আগে C2 (টাকা ফেরত), তারপর C1 (seat ছেড়ে দেওয়া)। যে step commit হয়নি, তার কিছু ফেরাতে হয় না।

তিনটা নিয়ম এখানে খেয়াল করার মতো।

Compensation কে idempotent হতেই হবে

এবার একটা কঠিন case ভাবুন। Refund worker gateway কে ৳৪২,০০০ ফেরত দেওয়ার request পাঠালো। Gateway টাকা ফেরত দিলো। কিন্তু worker নিজের database এ “refund হয়েছে” লেখার আগেই crash করলো। Queue এর নিয়মে (সেশন ০৮) job টা আবার ফিরে আসবে, আর আরেকজন worker refund চালাবে। Refund idempotent না হলে শিরিন ৳৮৪,০০০ ফেরত পাবেন। শিরিন খুশি হবেন, কিন্তু company র ক্ষতি ৳৪২,০০০।

সেশন ০৮ এ শিখেছিলাম, একটা কাজ একাধিকবার করলেও একবার করার মতো ফল হলে তাকে বলে idempotent। Compensation এর বেলায় এটা ঐচ্ছিক নয়, বাধ্যতামূলক। কারণ compensation retry হবেই।

function refundPayment(bookingId):
    key = bookingId + ":refund"              # "B-2071:refund"

    if refunds.exists(key):
        return "already done"                # দ্বিতীয়বার এলে কিছুই করে না

    # একই key gateway কেও পাঠানো হয়, তাই gateway ও দুইবার টাকা ফেরত দেয় না
    gateway.refund(paymentId, 42000, idempotencyKey = key)
    refunds.insert(key, status = "DONE")
    return "done"

এখানে booking ID থেকে বানানো B-2071:refund হলো idempotency key। Worker নিজে check করে, আবার gateway কেও একই key পাঠায়। ফলে crash যেখানেই হোক, টাকা একবারই ফেরত যায়।

আরেকটা কথা। সাধারণ step fail করতে পারে, কিন্তু compensation কে শেষ পর্যন্ত সফল হতেই হবে। Hotel না পাওয়া মেনে নেওয়া যায়, কিন্তু refund না হওয়া মেনে নেওয়া যায় না। তাই compensation fail করলে বারবার retry হয়। কয়েকবার চেষ্টার পরও না হলে job টা DLQ (সেশন ০৮) তে যায়, আর একজন মানুষ হাতে ঠিক করেন।

Step এর ক্রম বুদ্ধি করে সাজান

যে step ফেরানো সবচেয়ে কঠিন বা সবচেয়ে কষ্টের, সেটা saga র একদম শেষে রাখুন। রাইসার team পরে ক্রমটা বদলালো। এখন শুরুতে card এ শুধু ৳৪২,০০০ আটকে রাখা হয় (authorization hold), টাকা কাটা হয় না। তারপর flight, তারপর hotel। সব হয়ে গেলে একদম শেষে আটকে রাখা টাকাটা আসলে কাটা হয়। কোনো step fail করলে hold টা ছেড়ে দিলেই হয়, ৫-৭ দিনের refund এর ঝামেলা থাকে না।

০৬ Saga র step গুলো কে চালাবে?

Saga র প্রতিটা step কে কেউ না কেউ শুরু করতে হয়, আর fail হলে compensation চালাতে হয়। এই কাজটা দুইভাবে ভাগ করা যায়।

CHOREOGRAPHY ORCHESTRATION FlightHeld PaymentDone HotelFailed Refunded Flight Payment Hotel পুরো flow কারো একার মাথায় নেই Saga Orchestrator কোন step কখন, state লিখে রাখে Flight ১. hold Payment ২. charge Hotel ৩. book প্রতিটা service শুধু উত্তর দেয়; fail হলে orchestrator নিজেই C2, C1 চালায়
বাঁ দিকে প্রতিটা service আগের জনের event শুনে নিজের কাজ করে, আর fail এর event উল্টো দিকে যায়। ডান দিকে একজন orchestrator ক্রম ঠিক করে, প্রতিটা service কে ডাকে, আর কোথায় কী হলো তা নিজের database এ লিখে রাখে।
ChoreographyOrchestration
সুবিধাকোনো কেন্দ্রীয় component নেই; ২-৩ step এর ছোট flow তে সহজপুরো flow এক জায়গায় পড়া যায়; booking কোথায় আটকে আছে তা এক query তে জানা যায়
DownsideStep বাড়লে কে কার event শোনে, তা বোঝা কঠিন; fail এর পথ খুঁজে বের করা কষ্টেরOrchestrator নিজে একটা বাড়তি service, তাকেও চালাতে আর সামলাতে হয়
কখন বেছে নেবেনকয়েকটা step, সহজ নিয়মঅনেক step, টাকার লেনদেন, জটিল compensation

রাইসার team orchestration বেছে নিলো। কারণটা খুব সাধারণ। Support এর মানুষ রাতে জানতে চান, “Booking B-2071 এখন কোন ধাপে?” Orchestrator এর database এ প্রতিটা saga র অবস্থা লেখা থাকে, তাই এক query তেই উত্তর মেলে।

একটা ভয় মনে আসতে পারে। Orchestrator ও তো crash করতে পারে, তাহলে কি 2PC এর মতোই সব আটকে যাবে? না। Orchestrator কারো lock ধরে রাখে না। প্রতিটা service নিজের কাজ commit করে ফেলেছে। Orchestrator restart হয়ে নিজের database থেকে দেখে নেয় কোন saga কোন ধাপে ছিলো, তারপর সেখান থেকে আবার চালায়। বাস্তবে Temporal, AWS Step Functions আর Camunda এর মতো tool এই কাজটাই করে।

০৭ Database এ লেখা আর event পাঠানো, দুটো একসাথে নিশ্চিত করবো কীভাবে?

নতুন design চালুর দুই সপ্তাহ পর রাইসা আরেকটা অদ্ভুত booking পেলো। টাকা কাটা হয়েছে, কিন্তু saga hotel এর ধাপে কখনো পৌঁছায়নি। Booking টা চুপচাপ PENDING হয়ে পড়ে আছে।

খুঁজে দেখা গেলো, payment service এর কাজ দুই ধাপের। প্রথমে নিজের database এ payment row commit করে। তারপর message broker এ PaymentDone event পাঠায়। (Message broker মানে Kafka বা RabbitMQ এর মতো software, যেটা event এক service থেকে অন্য service এ পৌঁছে দেয়।) সেদিন ঠিক এই দুই ধাপের মাঝখানে একটা deploy হয়েছিলো, আর process টা বন্ধ হয়ে গিয়েছিলো।

আগে DB, পরে PUBLISH আগে PUBLISH, পরে DB ১. payment row commit ৳42,000 কাটা হলো ২. publish এর আগেই crash PaymentDone হারিয়ে গেলো টাকা কাটা, hotel step শুরুই হয় না ১. PaymentDone publish Hotel service শুনে ফেললো ২. DB commit fail করলো ভুয়া event টাকা কাটেনি, তবু room book হলো
Database আর message broker দুটো আলাদা system। দুই জায়গায় আলাদা করে লিখলে (dual write) মাঝখানে crash এর সুযোগ থাকে। যে ক্রমেই লিখুন, একটা হয় আর অন্যটা হয় না।

দুটো আলাদা system এ আলাদা করে লেখাকে বলে dual write। Database আর broker এর মধ্যে কোনো যৌথ transaction নেই। তাই যে ক্রমেই লিখুন, মাঝখানে crash হলে একটা হবে, অন্যটা হবে না। আগে database এ লিখলে event হারায়। আগে event পাঠালে এমন event চলে যায়, যার পেছনে আসলে কোনো কাজ হয়নি।

সমাধান: outbox pattern

বুদ্ধিটা সহজ। Event টা broker এ না পাঠিয়ে আগে নিজের database এই লিখে রাখুন। Payment service এর database এ outbox নামে একটা table থাকে। Payment row আর outbox row একই local transaction এ লেখা হয়:

BEGIN;
  UPDATE payments
     SET status = 'PAID'
   WHERE booking_id = 'B-2071';

  INSERT INTO outbox (event_id, event_type, payload, sent)
  VALUES ('evt-9f3a', 'PaymentDone',
          '{"booking": "B-2071", "amount": 42000}', false);
COMMIT;   -- দুটো লেখা একসাথে টেকে, নয়তো একসাথে বাতিল হয়

একটাই database, একটাই transaction। তাই commit হলে দুটো row ই থাকে, rollback হলে একটাও থাকে না। “টাকা কাটা হলো অথচ event নেই” অবস্থা আর তৈরি হতে পারে না।

এরপর একটা আলাদা process, যাকে বলে outbox relay, outbox table থেকে না-পাঠানো event গুলো পড়ে broker এ publish করে। তারপর row টাকে “sent” চিহ্ন দেয়।

একটাই local transaction Payment Service ৳42,000 charge সফল payments status = PAID outbox PaymentDone, sent=no commit হলে দুটো row ই থাকে, rollback হলে একটাও না পড়ে Outbox Relay poll বা CDC publish, তারপর sent=yes Message Broker যেমন Kafka at-least-once deliver Hotel Service idempotent consumer
Event টা broker এ না পাঠিয়ে আগে নিজের database এর outbox table এ লেখা হয়, payment row এর সাথে একই transaction এ। আলাদা একটা relay পরে outbox পড়ে event publish করে। Crash হলেও event টা database এ নিরাপদে থাকে।

Relay দুইভাবে outbox পড়তে পারে:

একটা জিনিস মাথায় রাখুন। Relay event publish করলো, কিন্তু “sent” চিহ্ন দেওয়ার আগেই crash করলো। Restart এর পর সে একই event আবার পাঠাবে। অর্থাৎ outbox এর delivery ও at-least-once (সেশন ০৮)। তাই hotel service কে idempotent consumer হতে হবে, যাতে একই PaymentDone দুইবার এলেও দুইটা room book না হয়। Consumer এর দিকে এই কাজ গুছিয়ে করার কৌশল (inbox pattern) সেশন ১৮ তে দেখবো।

Outbox এর মূল কথা

দুটো system এ একসাথে লেখার চেষ্টা করবেন না। একটাই database এ, একটাই transaction এ দুটো লিখুন। বাইরের দুনিয়ায় পাঠানোর কাজটা পরে আলাদা একজন করবে, আর দরকার হলে বারবার চেষ্টা করবে।

০৮ এই পুরো সময় user screen এ কী দেখবে?

2PC তে মাঝের অবস্থা কেউ দেখতো না। Saga তে দেখে, কারণ প্রতিটা step আলাদা সময়ে commit হয়। এক step থেকে আরেক step এ যেতে কয়েক শ millisecond লাগে। Refund এ লাগে আরো বেশি। তাই user কে মিথ্যা না বলে, প্রতিটা অবস্থাকে একটা সৎ নাম দিতে হয়।

শিরিন Book চাপলেন PENDING "Booking হচ্ছে, একটু অপেক্ষা" 3টা step ই সফল CONFIRMED SMS: ticket + hotel নিশ্চিত কোনো step fail REFUNDING compensation চলছে REFUNDED "৳42,000 ফেরত শুরু হয়েছে" ব্যাংকে পৌঁছাতে 5-7 কর্মদিবস
Saga শেষ হতে কয়েক সেকেন্ড লাগে, refund আরো বেশি। তাই booking এর প্রতিটা অবস্থার একটা নাম থাকে, আর screen এ শিরিন ঠিক সেই অবস্থাটাই দেখেন। সব step শেষ হওয়ার আগে কখনো CONFIRMED দেখানো হয় না।

সবচেয়ে বড় ভুল হলো saga শেষ হওয়ার আগেই “Booking confirmed!” দেখিয়ে ফেলা। তাহলে compensation চললে user এর কাছে মনে হবে, system নিশ্চিত করা booking কেড়ে নিয়েছে। “এখনো হচ্ছে” বলা অনেক বেশি সম্মানের।

নতুন design এ শিরিনের রাত

নতুন design চালুর পর একই রকম একটা রাতে শিরিন আবার কক্সবাজারের booking দিলেন। এবারও শেষ room টা অন্য কেউ নিয়ে নিলো। কিন্তু এবার card এ টাকা শুধু আটকে ছিলো, কাটা হয়নি। দুই সেকেন্ডের মধ্যে orchestrator flight এর seat ছেড়ে দিলো, card এর hold তুলে নিলো, আর SMS পাঠালো: “দুঃখিত, এই hotel এ room নেই। আপনার কোনো টাকা কাটা হয়নি। কাছাকাছি আরো তিনটা hotel দেখবেন?” শিরিন পাশের একটা hotel বেছে নিলেন। Support এ এবার কোনো ticket আসেনি।

০৯ মগজে প্রেশার দিন

আগে নিজে ভাবুন। তারপর উত্তর খুলে দেখুন: বেশিরভাগ মানুষ কীভাবে ভাবে, সেই চিন্তায় কী বাদ পড়ে, আর আসল কথাটা কোথায়।

প্রশ্ন ১: সব service এ 2PC দিলেই তো সব সমস্যা মিটে যায়। Saga র এত ঝামেলা কেন?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“2PC একটু slow, কারণ দুইবার আসা-যাওয়া লাগে। Speed এর জন্য মানুষ saga ব্যবহার করে।”

Speed একটা কারণ ঠিকই, কিন্তু সবচেয়ে বড় কারণ নয়।

যে জায়গাটা যোগ করা দরকার

আসল সমস্যা দুটো। প্রথমত, blocking: YES দেওয়ার পর coordinator crash করলে participant রা lock ধরে অনির্দিষ্টকাল অপেক্ষা করে, আর পেছনের সব request আটকে যায়। দ্বিতীয়ত, বাইরের system: payment gateway বা partner hotel এর API “prepare” বোঝে না, তাই তাদের 2PC তে আনাই যায় না। Saga প্রতিটা step এ সাথে সাথে commit করে, কারো জন্য lock ধরে না, আর যেকোনো API এর সাথে চলে। বিনিময়ে compensation লিখতে হয়, আর মাঝের অবস্থা সামলাতে হয়।

Interview তে বলার মতো

একই company র কয়েকটা database এর মধ্যে, দ্রুত network এ 2PC চলতে পারে। অনেক service, বাইরের API আর লম্বা সময়ের কাজে saga বেছে নিন।

প্রশ্ন ২: Compensation মানে তো rollback, তাই না?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“হ্যাঁ। কোনো step fail করলে আগের step গুলো undo করা হয়। এটাই rollback।”

কাজের দিক থেকে কাছাকাছি, কিন্তু তিনটা গুরুত্বপূর্ণ পার্থক্য বাদ পড়েছে।

যে জায়গাটা যোগ করা দরকার

Rollback এ database কাজটা এমনভাবে মুছে দেয়, যেন কখনো হয়নি। Compensation হলো একটা নতুন, আলাদা কাজ। আগের কাজটা ঘটে গেছে, আর বাইরের দুনিয়া সেটা দেখেও ফেলেছে: card statement এ লাইন আছে, SMS চলে গেছে। তাই compensation আগের প্রভাব কাটায়, ইতিহাস মোছে না।

দ্বিতীয় পার্থক্য: compensation retry হবেই, তাই সেটাকে idempotent হতে হয়। তৃতীয় পার্থক্য: compensation কে শেষ পর্যন্ত সফল হতেই হয়। Retry, তারপর DLQ, তারপর মানুষের হাত, কিন্তু refund বাদ দেওয়া চলে না।

মনে রাখার উপায়

Rollback হলো খাতার পাতা ছিঁড়ে ফেলা। Compensation হলো খাতায় নতুন লাইনে উল্টো হিসাব লেখা।

প্রশ্ন ৩: "Outbox এর দরকার নেই। DB commit এর পর event publish করবো, publish fail করলে retry করবো।" এতে সমস্যা কোথায়?
উত্তর দেখুন আগে নিজে ভাবুন

সবাই যেভাবে ভাবে

“Publish এর চারপাশে একটা retry loop দিলেই হলো। Broker কিছুক্ষণ down থাকলেও retry তে event চলে যাবে।”

Broker down থাকার case টা এতে সামলানো যায়। কিন্তু সবচেয়ে বিপজ্জনক case টা বাদ পড়েছে।

যে জায়গাটা যোগ করা দরকার

Retry loop টা চলে process এর memory তে। DB commit আর publish এর মাঝখানে process টা নিজেই crash করলে, বা deploy এর সময় বন্ধ হলে, retry করার মতো কেউ আর থাকে না। Event টা কোথাও লেখা ছিলো না, তাই চিরদিনের জন্য হারিয়ে যায়। রাইসার PENDING হয়ে পড়ে থাকা booking টা ঠিক এভাবেই তৈরি হয়েছিলো।

Outbox event টাকে payment row এর সাথে একই transaction এ disk এ লিখে ফেলে। Process crash করলেও event টা database এ থাকে। Relay পরে সেটা খুঁজে পায়, আর পাঠিয়ে দেয়।

মূল নিয়ম

যে event হারালে টাকা বা data এর গরমিল হয়, সেটা আগে নিজের database এ, একই transaction এ লিখুন। পাঠানোর কাজ পরে।

১০ যে টার্মগুলো শিখলাম

টার্মমানে
Distributed transactionএকাধিক service বা database জুড়ে ছড়ানো একটা কাজ, যেটাকে “হয় সব, নয়তো কিছুই না” রাখতে হয়।
Two-phase commit (2PC)আগে সবাইকে prepare করিয়ে vote নেওয়া, সবাই YES দিলে তবেই একসাথে commit করার পদ্ধতি।
Coordinator2PC তে যে component সবাইকে prepare আর commit/abort বার্তা পাঠায়, আর শেষ সিদ্ধান্ত নেয়।
Participant2PC তে যে database বা service নিজের অংশের কাজ করে আর vote দেয়।
Lockএকটা row এর উপর আটকানো চিহ্ন। Lock থাকা অবস্থায় অন্য transaction সেই row বদলাতে পারে না।
Blocking problemYES দেওয়ার পর coordinator crash করলে participant রা lock ধরে অনির্দিষ্টকাল অপেক্ষা করে।
SagaLocal transaction এর একটা chain। প্রতিটা step সাথে সাথে commit করে, fail হলে আগের step গুলো উল্টো কাজে ফেরানো হয়।
Compensating transactionSaga র একটা commit হওয়া step এর প্রভাব কাটানোর নতুন কাজ, যেমন টাকা কাটার বিপরীতে refund।
ChoreographySaga চালানোর যে পদ্ধতিতে কোনো পরিচালক নেই, প্রতিটা service event শুনে নিজে চলে (সেশন ১০)।
Orchestratorযে component saga র step গুলো ক্রমে চালায়, অবস্থা লিখে রাখে, আর fail হলে compensation চালায়।
Dual writeDatabase আর message broker এর মতো দুটো আলাদা system এ আলাদা করে লেখা, যেখানে মাঝখানে crash হলে একটা হয়, অন্যটা হয় না।
Outbox patternEvent কে broker এ না পাঠিয়ে আগে business data র সাথে একই local transaction এ outbox table এ লেখা।
Outbox relayযে process outbox table থেকে event পড়ে broker এ publish করে।
CDC (change data capture)Database এর নিজের change log পড়ে প্রতিটা পরিবর্তনের খবর সাথে সাথে পাওয়ার কৌশল।
Authorization holdCard এ টাকা কাটা নয়, শুধু আটকে রাখা। পরে কাটা হয়, অথবা ছেড়ে দেওয়া হয়।

১১ শর্ট সামারি

  1. একটা database এর transaction অন্য database পর্যন্ত পৌঁছায় না। তাই কয়েকটা service জুড়ে চলা কাজ (distributed transaction) মাঝপথে fail করলে টাকা কাটা অথচ room নেই, এমন অর্ধেক অবস্থা তৈরি হয়।
  2. 2PC আগে সবাইকে prepare করিয়ে vote নেয়, সবাই YES দিলে তবেই commit করে। কিন্তু coordinator crash করলে সবাই lock ধরে আটকে থাকে, আর বাইরের API গুলো 2PC বোঝেই না।
  3. Saga প্রতিটা step এ সাথে সাথে local commit করে। কোনো step fail করলে আগের step গুলোর compensation উল্টো ক্রমে চলে, আর সেই compensation কে idempotent হতে হয়।
  4. Outbox pattern event কে business data র সাথে একই transaction এ লিখে রাখে, যাতে crash এ event না হারায়। আর user কে PENDING, CONFIRMED, REFUNDED এর মতো সৎ অবস্থা দেখাতে হয়।

১২ মনে রাখুন