একটি জনপ্রিয় ইভেন্ট, যেখানে নির্দিষ্ট সংখ্যক সিট থাকে এবং হাজার হাজার মানুষ একসাথে রেজিস্ট্রেশনের চেষ্টা করে। আমাদের উদ্দেশ্য একটি Highly Concurrent Event Booking System ডিজাইন করা, যা একই সিটের জন্য অত্যন্ত বেশি সংখ্যক রিকোয়েস্ট (উদাহরণস্বরূপ: ৫০০+ concurrent requests per seat) সামলাতে পারবে।
Concurrency Handling:
একই সময়ে হাজারো ইউজার একটি সিট বুক করার চেষ্টা করবে।
রেস কন্ডিশন (Race Condition) বা ওভারবুকিং যেন না হয়, তা নিশ্চিত করতে হবে।
Atomic Seat Booking:
বুকিং প্রক্রিয়াটি এমনভাবে করতে হবে যাতে একবারে কেবল একজনই সফল হয়।
সিট বুকিং হবে “first-come, first-served” ভিত্তিতে, transaction যেন atomic হয়।
Failure Handling & Retry Mechanism:
Concurrency Management
একসাথে হাজারো রিকোয়েস্ট কিভাবে হ্যান্ডেল করতে হয়
কীভাবে এক সিটকে একাধিক ইউজার বুক করতে না দিয়ে কনফ্লিক্ট এড়াতে হয়
Atomic Transactions
Atomicity কী এবং কেন এটা গুরুত্বপূর্ণ
SQL বা NoSQL ডেটাবেসে কীভাবে atomic বুকিং অপারেশন বানাতে হয়
Stripe Payment
Error Handling & Retry Logic
Request fail হলে কীভাবে retry করা হবে
Idempotency কীভাবে implement করতে হয়
ধরে নি, আমাদের ডাটাবেস MySQL। একসাথে হাজারো রিকোয়েস্ট একটি নির্দিষ্ট সিট বুকিং এর জন্য আসতে পারে।
আমরা জানি, 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) বা ওভারবুকিং হবে না।
কিভাবে পেমেন্ট প্রসেস করা যায়?
পেমেন্ট প্রসেস না হওয়া পর্যন্ত ইভেন্ট বুকিং সম্পূর্ণ হয় না। এখন পর্যন্ত আমরা যা শিখলাম,
User সিট নির্বাচন করেছে
Transaction শুরু
Lock row
যখন আমরা প্রোডাকশন এর চিন্তা করব, তখন আমাদের পেমেন্ট প্রসেস নিয়ে চিন্তা করতে হবে,
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 };
}
}
যখন ইউজার পেমেন্ট শুরু করে এবং স্ট্রাইপ (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 সঠিকভাবে কনফিগার না করলে আবারো সমস্যা হবে।
হাজারো ইউজার একসাথে রিকোয়েস্ট পাঠালে, প্রত্যেক রিকোয়েস্ট যদি আলাদা আলাদা নতুন নতুন DB কানেকশন open করে থাকে, MySQL তাড়াতাড়ি overheat হয়ে যাবে।
Connection Pool আসলে কিছু কানেকশন আগেভাগেই খোলা রাখে, যেগুলো অ্যাপ্লিকেশন reuse করতে পারে।
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 এর ধরণ এরকম হতে পারে,
Transaction অপেক্ষা করতে গিয়ে timeout হয়ে যায়।
DB connection drop, network latency, or pool exhaustion
সিট বুক করার চেষ্টা করবে Transaction দিয়ে।
একবারে না হলে retry function লিখবো (timeout হলে)।
কোডে idempotency মান্য করতে হবে। মানে ইতোমধ্যে বুক হয়ে গেছে কি না তা বুজতে হবে।
Retry এর মাঝে exponential backoff দিয়ে DB-কে চাপ কমাবে।
সর্বোচ্চ retry-র পরও fail হলে → error দিবে।
সিট যদি আগে থেকেই বুক করা থাকে → শুরুতেই fail রিটার্ন করবে।
এভাবে আমরা সমস্যার সমাধান করতে পারি।
কিন্তু একজন সফটওয়্যার ইঞ্জিনিয়ার হিসেবে আমার কাছে প্রশ্ন আসতে পারে,
কেনো Transaction? সাধারণ আপডেট কোয়েরি তে সমস্যা কি?
UPDATE seats
SET status = 'booked', user_id = 456
WHERE seat_id = 123 AND status = 'available';
এখানে কোনো START TRANSACTION নেই
কোনো SELECT … FOR UPDATE নেই
তখন প্রসেসটা এরকম হবে,
MySQL চেষ্টা করবে row টা আপডেট করতে, যদি status = ‘available’ থাকে।
একসাথে একাধিক request এলে সবাই status চেক করার চেষ্টা করতে পারে।
তবে UPDATE নিজে row-level lock — তাই একবারে একটিই update হবে।
কিন্তু race condition এড়ানো যায় না, যদি আগে SELECT ব্যবহার করে চেক করেন।
এই Race Condition এর সমস্যা আমরা Transaction + FOR UPDATE ব্যবহার করে সমাধান করতে পারি।