পাঠ ২৯ · ৫১-এর মধ্যে · মডিউল ৭
Home / Courses / System Design / ইভেন্ট সোর্সিং ও CQRS

ইভেন্ট সোর্সিং ও CQRS

Event sourcing & CQRS
৯ মিনিট পড়া উচ্চ · Advanced Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • Event Sourcing কীভাবে state-কে "সংরক্ষিত তথ্য" থেকে "ইভেন্টের replay থেকে derive করা মান"-এ পরিণত করে
  • CQRS কীভাবে write ও read পাথকে আলাদা করে, এবং কেন এটি Event Sourcing-এর প্রাকৃতিক সঙ্গী
  • Projection ধারণা — ইভেন্ট থেকে read model তৈরি করা
  • Python দিয়ে একটি ইভেন্ট-সোর্সড ব্যাংক অ্যাকাউন্ট বানানো, যেখানে replay ও incrementally-updated read model উভয়েই একই ব্যালেন্সে একমত হয়

১ · Event Sourcing — স্টেট নয়, ইতিহাস সংরক্ষণ করা

সাধারণ ডেটাবেস ডিজাইনে (DBMS কোর্স) আমরা শুধু বর্তমান স্টেট সংরক্ষণ করি — যেমন একটি অ্যাকাউন্টের ব্যালেন্স কলামে সরাসরি "৳১৩০" লেখা থাকে। প্রতিটি নতুন লেনদেনে সেই মানটি ওভাররাইট হয়ে যায়, এবং পুরনো মান চিরতরে হারিয়ে যায়। Event SourcingEvent Sourcingবর্তমান স্টেট সরাসরি সংরক্ষণ না করে সেই স্টেটে পৌঁছাতে ঘটা প্রতিটি ইভেন্টের সম্পূর্ণ ক্রমানুসারে সংরক্ষণ করা — বর্তমান স্টেট প্রয়োজনমতো সেই ইভেন্টগুলো replay করে গণনা করা হয়। এর বদলে প্রতিটি ঘটে যাওয়া ইভেন্ট ("deposit ৳100", "withdraw ৳30") ক্রমানুসারে চিরস্থায়ীভাবে সংরক্ষণ করে — বর্তমান ব্যালেন্স দরকার হলে সেই ইভেন্টগুলো শুরু থেকে "replay" (ভাঁজ করে/fold করে) করে বের করা হয়।

কেন এটি মূল্যবান

এই পদ্ধতিতে একটি সম্পূর্ণ audit trail স্বাভাবিকভাবেই তৈরি হয় — কখন, কী পরিমাণ, কোন ক্রমে প্রতিটি পরিবর্তন হয়েছে তা কখনো হারায় না। এমনকি ইতিহাসের যেকোনো নির্দিষ্ট মুহূর্তের স্টেটও পুনর্গঠন করা যায় — শুধু সেই মুহূর্ত পর্যন্তকার ইভেন্টগুলো replay করলেই হয়। ব্যাংকিং, ইনভেন্টরি, বা যেকোনো সিস্টেম যেখানে "কী হয়েছিল" জানা "এখন কী আছে" জানার চেয়ে কম গুরুত্বপূর্ণ নয়, সেখানে এই মডেল স্বাভাবিকভাবেই মানানসই।

২ · CQRS — Write Model ও Read Model আলাদা করা

CQRSCQRS (Command Query Responsibility Segregation)একটি সিস্টেমের "লেখার পাথ" (কমান্ড, যা বিজনেস রুল যাচাই করে ইভেন্ট তৈরি করে) কে "পড়ার পাথ" (কোয়েরি, যা প্রায়ই ডিনরমালাইজড read model থেকে সরাসরি পড়ে) থেকে আলাদা করা। স্বীকার করে যে একটি অ্যাপ্লিকেশনের লেখা ও পড়ার চাহিদা প্রায়ই খুব ভিন্ন। Write model বিজনেস রুল যাচাই করে এবং নতুন ইভেন্ট অ্যাপেন্ড করে ("balance withdraw করার আগে যথেষ্ট টাকা আছে কিনা যাচাই করো")। Read model সাধারণত একটি ডিনরমালাইজড, প্রি-কম্পিউটেড ভিউ — যা দ্রুত পড়ার জন্য অপ্টিমাইজড, প্রায়ই ইভেন্ট থেকে একটি projection (প্রক্ষেপণ) হিসেবে তৈরি হয়।

দুটো মডেল আলাদা থাকায় প্রতিটিকে স্বাধীনভাবে স্কেল ও অপ্টিমাইজ করা যায় — লেখা কম হলেও পড়া অনেক বেশি হতে পারে (L02-এর read:write ratio ধারণার সাথে সংযোগ), তাই read model-কে আলাদাভাবে ক্যাশ (L19) বা রেপ্লিকেট (L16) করা যায়, write model-এর ওপর প্রভাব না ফেলেই।

Command (deposit/withdraw) Event Store events list replay() Write Model projection Read Model Query
Command → Event Store → (replay করে Write Model যাচাই, projection দিয়ে Read Model তৈরি) — উভয় মডেলই একই ইভেন্ট থেকে আসে, তাই শেষ পর্যন্ত একমত হতে বাধ্য।

৩ · Python দিয়ে ইভেন্ট-সোর্সড ব্যাংক অ্যাকাউন্ট

নিচের কোডে events নামে একটি লিস্টে সব ("deposit", amount)/("withdraw", amount) টাপল সংরক্ষিত হয়। replay(events) ফাংশনটি write-model-স্টাইলে পুরো ইতিহাস থেকে ব্যালেন্স গণনা করে, আর একটি আলাদা read_model ডিকশনারি CQRS-স্টাইলে প্রতিটি ইভেন্ট ঘটার সাথে সাথেই ইনক্রিমেন্টালি আপডেট হয়। শেষে দুটো পদ্ধতি একই ফলাফলে একমত হয় কিনা যাচাই করা হয়।

Python
events = []
read_model = {"balance": 0}   # CQRS read model — প্রতিটি ইভেন্টে সাথে সাথে আপডেট হয়

def append_event(event_type, amount):
    events.append((event_type, amount))
    # read model ইনক্রিমেন্টালি আপডেট (projection)
    if event_type == "deposit":
        read_model["balance"] += amount
    elif event_type == "withdraw":
        read_model["balance"] -= amount

def replay(events_list):
    # write-model derivation — শুরু থেকে সব ইভেন্ট fold করে ব্যালেন্স বের করা
    balance = 0
    for event_type, amount in events_list:
        if event_type == "deposit":
            balance += amount
        elif event_type == "withdraw":
            balance -= amount
    return balance


append_event("deposit", 100)
append_event("deposit", 50)
append_event("withdraw", 30)
append_event("deposit", 20)
append_event("withdraw", 10)

print("সব ইভেন্ট:")
for e in events:
    print(" ", e)

replayed_balance = read_model_balance = None
replayed_balance = replay(events)
read_model_balance = read_model["balance"]

print()
print("replay() দিয়ে (write-model) ব্যালেন্স:", replayed_balance)
print("read_model (CQRS) ব্যালেন্স:          ", read_model_balance)
print("দুটো মিলছে কি?", replayed_balance == read_model_balance)

    
কোডটি কী প্রমাণ করছে

ইভেন্টের ক্রম: +১০০, +৫০, -৩০, +২০, -১০ — মোট ব্যালেন্স ১৩০ হওয়া উচিত (১০০+৫০-৩০+২০-১০ = ১৩০)। কোডে replay(events) পুরো ইভেন্ট-লিস্ট থেকে শুরু থেকে গণনা করে ১৩০ বের করে (write model-এর কাজ), আর read_model["balance"] প্রতিটি ইভেন্ট ঘটার সাথে সাথেই আপডেট হয়ে একই ১৩০-এ পৌঁছায় (read model/projection-এর কাজ)। দুটো সম্পূর্ণ ভিন্ন পথে গণনা হলেও একই উৎস (events) থেকে আসায় তারা সবসময় একমত হতে বাধ্য — এটাই CQRS-এর মূল প্রতিশ্রুতি।

৪ · ট্রেড-অফ

Event Sourcing ও CQRS জটিলতা বাড়ায় — এখন দুটো মডেল রক্ষণাবেক্ষণ করতে হয়, এবং বাস্তব ডিস্ট্রিবিউটেড সিস্টেমে read model সাধারণত write model-এর তুলনায় সামান্য দেরিতে (eventual consistency, L04) আপডেট হয় — একটি লেখার ঠিক পরপরই read model-এ তা প্রতিফলিত নাও হতে পারে। বিনিময়ে সিস্টেম পায় সম্পূর্ণ audit log, ইতিহাসের যেকোনো মুহূর্তের স্টেট পুনর্গঠনের ক্ষমতা, এবং write/read পাথ স্বাধীনভাবে স্কেল করার সুবিধা — যা উচ্চ-স্কেল, অডিট-সংবেদনশীল সিস্টেমে (ব্যাংকিং, ইনভেন্টরি, অর্ডার প্রসেসিং) প্রায়ই এই বাড়তি জটিলতার মূল্য দেয়।

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

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

প্র ০১ ইভেন্টের সংখ্যা কয়েক মিলিয়নে পৌঁছালে প্রতিবার পুরো ইতিহাস replay করে বর্তমান স্টেট বের করা কেন অকার্যকর হয়ে যেতে পারে, এবং বাস্তব সিস্টেম এটি কীভাবে সমাধান করে?

প্রতিটি রিড রিকোয়েস্টে লক্ষ লক্ষ ইভেন্ট replay করা ধীরগতির এবং ব্যয়বহুল হয়ে যাবে। বাস্তব সিস্টেম এর সমাধান করে snapshot রেখে — পর্যায়ক্রমে (যেমন প্রতি ১,০০০ ইভেন্টে) বর্তমান স্টেট একটি স্ন্যাপশট হিসেবে সংরক্ষণ করা হয়, এবং পরবর্তী replay শুরু থেকে নয় বরং সর্বশেষ স্ন্যাপশট থেকে করা হয় — তারপর শুধু তার পরের নতুন ইভেন্টগুলো replay করা হয়। এটি ঠিক CQRS-এর read model-এরই একটি বিশেষ রূপ।

প্র ০২ Event Sourcing কি সবসময় CQRS-এর সাথে একত্রে ব্যবহার করতেই হবে, নাকি একটি অন্যটি ছাড়াও ব্যবহার করা যায়?

না, বাধ্যতামূলক নয় — তবে তারা একে অপরকে খুব স্বাভাবিকভাবে পরিপূরক করে। Event Sourcing ছাড়াও CQRS ব্যবহার করা যায় (শুধু write DB ও read DB আলাদা রেখে, ইভেন্ট-লগ ছাড়াই)। আবার CQRS ছাড়াও Event Sourcing ব্যবহার করা যায় (শুধু ইভেন্ট সংরক্ষণ করে, কিন্তু একটিমাত্র মডেল দিয়েই লেখা-পড়া দুটোই সামলে)। তবে একসাথে ব্যবহার করলে সবচেয়ে বেশি সুবিধা পাওয়া যায় — ইভেন্ট স্টোর স্বাভাবিকভাবেই CQRS-এর read model তৈরির জন্য একটি নির্ভরযোগ্য, পুনরায়-চালানো-যোগ্য (replayable) উৎস হিসেবে কাজ করে।

প্র ০৩ এই পাঠের কোড উদাহরণে read model-কে ইভেন্ট ঘটার সাথে সাথেই সিঙ্ক্রোনাসভাবে আপডেট করা হয়েছে। বাস্তব ডিস্ট্রিবিউটেড সিস্টেমে এটি প্রায়ই অ্যাসিনক্রোনাস কেন?

বাস্তব সিস্টেমে write ও read model প্রায়ই সম্পূর্ণ ভিন্ন সার্ভিস/ডেটাবেসে থাকে, এবং তাদের সংযোগকারী মাধ্যম সাধারণত একটি মেসেজ কিউ বা event bus (L22, L23) — write model একটি ইভেন্ট emit করে, read model সেটি এসিঙ্ক্রোনাসভাবে কনজিউম করে নিজের প্রজেকশন আপডেট করে। এটি write path-কে read model-এর গতির ওপর নির্ভরশীল না করে দ্রুত রাখে, কিন্তু এর মূল্য হলো একটি সংক্ষিপ্ত সময়ের জন্য read model সাময়িকভাবে পুরনো (stale) থাকতে পারে — এটাই CQRS-এর eventual consistency ট্রেড-অফ।

অনুশীলন

  1. কোড বাড়ান: উপরের কোডে একটি নতুন ইভেন্ট টাইপ "interest" যোগ করুন যা ব্যালেন্সের ৫% যোগ করে (deposit-এর মতোই আচরণ, কিন্তু নির্দিষ্ট amount না দিয়ে বর্তমান ব্যালেন্সের ৫% হিসাব করে)। replay() ও append_event() উভয়ে এই লজিক যোগ করে নিশ্চিত করুন দুটো এখনও একমত হচ্ছে।

    replay()-তে elif event_type == "interest": balance += balance * 0.05 এবং append_event()-এর ভেতরে elif event_type == "interest": read_model["balance"] += read_model["balance"] * 0.05 যোগ করলেই চলে। মূল শিক্ষা — যেকোনো নতুন ইভেন্ট টাইপের জন্য replay লজিক ও incremental-update লজিক দুই জায়গাতেই সমান্তরালভাবে মেইনটেইন করতে হয়, নাহলে write model ও read model অসঙ্গতিপূর্ণ হয়ে যেতে পারে — বাস্তব সিস্টেমে এই ডুপ্লিকেশন এড়াতে প্রায়ই একটি একক "apply(event, state)" ফাংশন লেখা হয় যা replay ও প্রজেকশন উভয় জায়গাতেই পুনর্ব্যবহার করা হয়।

  2. চিন্তা করুন: Event Sourcing-এ কেন "withdraw ৳500 করো" ইভেন্টটি ব্যালেন্স নেগেটিভ করে ফেললেও তা মুছে ফেলা বা বদলানো ঠিক নয় — বরং কী করা উচিত?

    Event Sourcing-এর মূলনীতি হলো ইভেন্ট লগ কখনো পরিবর্তন (mutate) করা হয় না — এটি ইতিহাসের একটি স্থায়ী, নির্ভরযোগ্য রেকর্ড। ভুল সংশোধনের সঠিক উপায় হলো একটি নতুন ক্ষতিপূরণমূলক ইভেন্ট যোগ করা (যেমন "withdraw_reversed ৳500") — ঠিক যেভাবে L28-এর SAGA প্যাটার্নে compensating transaction কাজ করে। এতে audit trail অক্ষত থাকে — ভুলটিও ইতিহাসের অংশ হিসেবে থেকে যায়, শুধু এর সংশোধনও রেকর্ড হয়।

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

আগের পাঠ
SAGA প্যাটার্ন