system-design-bangla

Design a Highly Concurrent Event Booking System

একটি জনপ্রিয় ইভেন্ট, যেখানে নির্দিষ্ট সংখ্যক সিট থাকে এবং হাজার হাজার মানুষ একসাথে রেজিস্ট্রেশনের চেষ্টা করে। আমাদের উদ্দেশ্য একটি Highly Concurrent Event Booking System ডিজাইন করা, যা একই সিটের জন্য অত্যন্ত বেশি সংখ্যক রিকোয়েস্ট (উদাহরণস্বরূপ: ৫০০+ concurrent requests per seat) সামলাতে পারবে।

Key requirements

Concurrency Handling:

Atomic Seat Booking:

Failure Handling & Retry Mechanism:

আমরা কি শিখবো?

Concurrency Management

Atomic Transactions

Stripe Payment

Error Handling & Retry Logic

সমাধান

ধরে নি, আমাদের ডাটাবেস MySQL। একসাথে হাজারো রিকোয়েস্ট একটি নির্দিষ্ট সিট বুকিং এর জন্য আসতে পারে।

concurrency

আমরা জানি, MySQL নিজে ACID নিশ্চিত করে থাকে।

Concurrency সমস্যা সমাধানের জন্য আমাদেরকে Exclusive Locking(Pessimistic Locking) mechanism বুজতে হবে।

Exclusive Lock হলো একটি লক যা একটি row এর উপর কেবল একটি ট্রানজেকশনকে সম্পূর্ণ control দেয়। আমাদের উদাহরণ অনুযায়ী,

START TRANSACTION;

SELECT * FROM seats WHERE seat_id = 123 AND status = 'available' FOR UPDATE;

UPDATE seats SET status = 'booked', user_id = 456 WHERE seat_id = 123; COMMIT;

এখানে FOR UPDATE; Exclusive Lock/Pessimistic Lock তৈরী করেছে। যার ফলে এখন সেই নির্দিষ্ট row তে Exclusive Lock থাকাকালীন, অন্য কোনো Transaction সেই row টিকে পরিবর্তন করতে পারবে না।

অন্য কোনো ট্রানজেকশন ঐ row-টাকে update/delete করতে চাইলে, সেটা অপেক্ষা করতে হবে যতক্ষণ না বর্তমান ট্রানজেকশন commit/rollback হয়।

অর্থাৎ এক সময়ে একটিমাত্র ট্রানজেকশন সেই row পরিবর্তন করতে পারবে। অর্থাৎ এখানে রেস কন্ডিশন (Race Condition) বা ওভারবুকিং হবে না।

কিভাবে পেমেন্ট প্রসেস করা যায়?

পেমেন্ট প্রসেস না হওয়া পর্যন্ত ইভেন্ট বুকিং সম্পূর্ণ হয় না। এখন পর্যন্ত আমরা যা শিখলাম,

Phase 1

উদ্দেশ্য: seat temporarily reserve করা, payment এর আগে

যখন আমরা প্রোডাকশন এর চিন্তা করব, তখন আমাদের পেমেন্ট প্রসেস নিয়ে চিন্তা করতে হবে,

Lock করার পর আমরা সিটের স্টেটাস ১০ মিনিট এর জন্য ‘HELD’ করে দিব, ঐ user এর ID দিয়ে। এতে করে সুবিধা হবে, এটি ইউজারকে পেমেন্ট প্রসেস করার জন্য একটি “Time Window” দেয়, কিন্তু পারমানেন্টলি সিটটি ব্লক করে না।

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function holdSeat(seatId: number, userId: number) {
  try {
    return await prisma.$transaction(async (tx) => {
      // 🔒 ১. সরাসরি SQL কুয়েরি দিয়ে নির্দিষ্ট Row-টি লক করা
      const seats = await tx.$queryRaw<any[]>`
        SELECT status, hold_expires_at
        FROM seats
        WHERE seat_id = ${seatId}
        FOR UPDATE
      `;

      if (seats.length === 0) throw new Error("Seat not found");

      const seat = seats[0];
      const now = new Date();

      // চেক করা হচ্ছে সিটটি কি আদেও খালি নাকি কারো হোল্ড টাইম শেষ হয়েছে?
      const isAvailable =
        seat.status === 'AVAILABLE' ||
        (seat.status === 'HELD' && seat.hold_expires_at && new Date(seat.hold_expires_at) < now);

      if (!isAvailable) {
        throw new Error("Seat already taken or held by someone else");
      }

      // ✏️ ২. সিটটি নির্দিষ্ট সময়ের জন্য রিজার্ভ করা
      await tx.$executeRaw`
        UPDATE seats
        SET status = 'HELD',
            held_by_user = ${userId},
            hold_expires_at = NOW() + INTERVAL 10 MINUTE
        WHERE seat_id = ${seatId}
      `;

      return { success: true, message: "Seat held for 10 minutes" };
    });
  } catch (error) {
    return { success: false, error: error.message };
  }
}

Phase 2 (Payment & Final Booking)

যখন ইউজার পেমেন্ট শুরু করে এবং স্ট্রাইপ (Stripe) থেকে রেসপন্স আসে, তখন আমাদের নিশ্চিত করতে হয় যে সিটটি এখনও সেই ইউজারের জন্যই ‘HELD’ অবস্থায় আছে এবং পেমেন্ট সফল হওয়ার পর যেন সেটি পারমানেন্টলি ‘BOOKED’ হয়ে যায়।

async function finalizeBooking(seatId: number, userId: number, paymentId: string) {
  return await prisma.$transaction(async (tx) => {
    // ১. আবার লক করুন এবং নিশ্চিত করুন সিটটি এখনও এই ইউজারের জন্যই 'HELD' আছে
    const seats = await tx.$queryRaw<any[]>`
      SELECT * FROM seats
      WHERE seat_id = ${seatId}
      AND status = 'HELD'
      AND held_by_user = ${userId}
      AND hold_expires_at > NOW()
      FOR UPDATE
    `;

    if (seats.length === 0) {
      // ১০ মিনিট পার হয়ে গেছে অথবা অন্য কোনো সমস্যা
      throw new Error("Reservation expired or invalid. Please try again.");
    }

    // ২. পেমেন্ট রেকর্ড সেভ করা (Idempotency নিশ্চিত করতে)
    // এখানে paymentId ইউনিক হতে হবে যাতে একই পেমেন্ট দুইবার প্রসেস না হয়
    await tx.payment.create({
      data: {
        paymentId: paymentId,
        userId: userId,
        seatId: seatId,
        status: 'COMPLETED'
      }
    });

    // ৩. সিটটি স্থায়ীভাবে 'BOOKED' করে দেওয়া
    await tx.seats.update({
      where: { seat_id: seatId },
      data: {
        status: 'BOOKED',
        held_by_user: null, // হোল্ড রিলিজ করে দেওয়া
        hold_expires_at: null
      }
    });

    return { success: true, message: "Seat booked successfully!" };
  });
}

যদি ১০ মিনিটের ভিতর পেমেন্ট প্রসেস সম্পন্ন হয়ে যায়, stripe আপনাকে একটি WebHook দিবে। এই WebHook এর মধ্য থেকে আমরা কন্ফার্ম হতে পারবো পেমেন্ট সঠিকভাবে হয়েছে। WebHook আসার পরে finalizeBooking function রান হবে।

ইউজার পেমেন্ট পেজে থাকাকালীন ১০ মিনিট পার হয়ে যেতে পারে। যদি ঠিক ৯ মিনিট ৫৯ সেকেন্ডে পেমেন্ট সাকসেস হয়, আর ১০ মিনিট ০১ সেকেন্ডে আমাদের সার্ভার WebHook টি পায়, তবে লজিক অনুযায়ী সিটটি রিলিজ হয়ে যাওয়ার কথা। অর্থাৎ,

const seats = await tx.$queryRaw<any[]>`
  SELECT * FROM seats
  WHERE seat_id = ${seatId}
  AND status = 'HELD'
  AND held_by_user = ${userId}
  AND hold_expires_at > NOW()
  FOR UPDATE
`;

empty রিটার্ন করবে। সেক্ষেত্রে আমাদের পেমেন্ট রিফান্ড করে দিতে হবে।

যদি ১০ মিনিট পার হয়ে যায় এবং ইউজার পেমেন্ট না করে, তখন কি করা যায়?

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

import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()

async function holdSeat(seatId: number, userId: number) {
  try {
    return await prisma.$transaction(async (tx) => {
      // ১. SQL-এই চেক করছি সিটটি AVAILABLE কি না অথবা HOLD টাইম শেষ কি না
      // যদি শর্ত না মিলে, তবে কোনো Row লক হবে না (Empty Result)
      const seats = await tx.$queryRaw<any[]>`
        SELECT seat_id, status
        FROM seats
        WHERE seat_id = ${seatId}
          AND (status = 'AVAILABLE' OR hold_expires_at < NOW())
        FOR UPDATE
      `;

      // যদি সিটটি অলরেডি অন্য কেউ ভ্যালিডভাবে হোল্ড করে থাকে, তবে Array খালি আসবে
      if (seats.length === 0) {
        throw new Error("Seat is currently unavailable or already held by an active user");
      }

      // ✏️ ২. সরাসরি আপডেট। এখানে আর অ্যাপ লেভেলে Date চেক করার দরকার নেই।
      await tx.$executeRaw`
        UPDATE seats
        SET status = 'HELD',
          held_by_user = ${userId},
          hold_expires_at = NOW() + INTERVAL 10 MINUTE
        WHERE seat_id = ${seatId}
      `;

      return { success: true, message: "Seat successfully held for 10 minutes" };
    });
  } catch (error: any) {
    return { success: false, error: error.message };
  }
}

এখানে যদি ১০ মিনিট পার হয়ে যায়, তবে hold_expires_at < NOW() কন্ডিশনটি True হবে। ফলে নতুন user সিটটি লক করতে পারবে এবং আগের user এর ডেটা overwrite করে দিবে।

Concurrency হ্যান্ডলিং শুধু SQL FOR UPDATE দিলেই শেষ নয় — Connection Pool সঠিকভাবে কনফিগার না করলে আবারো সমস্যা হবে।

কেন Connection Pool দরকার?

MySQL এ innodb_lock_wait_timeout (ডিফল্ট 50s) এর মধ্যে যদি lock release না হয়, transaction error দেবে,

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

তাছাড়া আমাদের এখানে সহজ রাখার জন্য ধরে নিলাম, Deadlock হওয়ার সম্ভাবনা নাই।

Deadlock মূলত তখনই হয় যখন একই Transaction একাধিক row একসাথে লক করে এবং অন্য Transaction গুলো উল্টো অর্ডারে লক করে।

এখন Atomic Seat Booking নিয়ে চিন্তা করা যাক।

উপরে যেহেতু আমরা FOR UPDATE ব্যবহার করেছি সেহেতু,

তাহলে আমাদের Atomic Seat Booking হয়ে গেলো।

৩য় সমস্যা হলো, Failure Handling এবং Retry Mechanism

failure এর ধরণ এরকম হতে পারে,

কিভাবে retry করবো?

এভাবে আমরা সমস্যার সমাধান করতে পারি।

কিন্তু একজন সফটওয়্যার ইঞ্জিনিয়ার হিসেবে আমার কাছে প্রশ্ন আসতে পারে,

কেনো Transaction? সাধারণ আপডেট কোয়েরি তে সমস্যা কি?

UPDATE seats
SET status = 'booked', user_id = 456
WHERE seat_id = 123 AND status = 'available';

তখন প্রসেসটা এরকম হবে,

এই Race Condition এর সমস্যা আমরা Transaction + FOR UPDATE ব্যবহার করে সমাধান করতে পারি।