এই পর্বে যা শিখবেন
- Concurrent request দিয়ে overselling নিজের চোখে দেখতে, আর race condition চিনতে
prisma.$transaction-এর array আর interactive রূপ ঠিক জায়গায় ব্যবহার করতে, আর repository পর্যন্ত transaction client পৌঁছে দিতে- Conditional update,
FOR UPDATE, optimistic locking, unique constraint আর Serializable-এর মধ্যে বাছাই করতে - Deadlock ঘটিয়ে দেখতে, আর lock-এর ক্রম ঠিক করে তা বন্ধ করতে
- Stock ধরা থেকে confirm বা release পর্যন্ত অর্ডারের পথ আর outbox নকশা করতে
১ · শেষ ইলিশটি দুজনের হাতে
পহেলা বৈশাখের আগের রাত। বাজারঘরে ১ কেজির ইলিশ আছে শেষ একটি। রিনা আর রাফি একই মুহূর্তে “অর্ডার করুন” চাপলেন। দুজনের ফোনেই এলো “অর্ডার সফল”। সকালে গুদামের লোক ফোন করলেন: মাছ একটি, রসিদ দুটি। একে বলে overselling, মানে যা আছে তার চেয়ে বেশি বিক্রি করা।
মুদি দোকানে দুই কর্মচারী, হাতে একটি করে ফোন। দুজনেই একসাথে তাকে তাকিয়ে দেখলেন একটি তেলের বোতল আছে। দুজনেই ফোনের ক্রেতাকে বললেন “আছে, পাঠাচ্ছি”। দেখা আর বলার মাঝের ফাঁকেই গোলমাল। কোনো কর্মচারী ভুল দেখেননি, দেখার পরের মুহূর্তে পৃথিবী বদলে গেছে।
কোডে ভুলটি এমন দেখায়, আর প্রথম দেখায় নির্দোষই লাগে:
const p = await prisma.product.findUniqueOrThrow({ where: { id } });
if (p.stock < qty) throw new OutOfStock();
await prisma.product.update({ where: { id }, data: { stock: p.stock - qty } });
return prisma.order.create({ data: { productId: id, buyer, qty, totalPaisa: p.pricePaisa * qty } });একজন ক্রেতা থাকলে এই কোড নিখুঁত। সমস্যা হয় দুজনের ধাপ একে অন্যের ফাঁকে ঢুকলে। এই ফলকে বলে race condition: কার কাজ কখন শেষ হলো, তার ওপর ফল নির্ভর করছে। নিচের ডায়াগ্রামে দুজনের ধাপ পরপর দেখুন।
1Rina: SELECT stock → 12Rafi: SELECT stock → 13Rina: UPDATE stock = 0, order4Rafi: UPDATE stock = 0, order5গুদাম: হিসাব মেলানো
| stock (DB) | |
| রিনা দেখেছেন | |
| রাফি দেখেছেন | |
| order সংখ্যা |
- শুরু। Database-এ
stock = 1, কোনো অর্ডার নেই। দুটি request প্রায় একসাথে server-এ পৌঁছেছে। - রিনা পড়লেন। তাঁর request দেখল stock 1. এই মানটি এখন তাঁর নিজের মেমরিতে একটি কপি। Database কিছুই ধরে রাখেনি।
- রাফিও পড়লেন। রিনা এখনো লেখেননি, তাই রাফিও দেখলেন 1. দুজনের যাচাই-ই পাস।
- রিনা লিখলেন।
1 - 1 = 0, stock হলো 0, অর্ডার ১টি। এ পর্যন্ত সব ঠিক। - রাফি লিখলেন। তাঁর হাতের কপি এখনো 1, তাই তিনিও 0 লিখলেন। Stock তো 0-ই আছে, বাইরে থেকে কিছু বদলাল না, কিন্তু অর্ডার হলো ২টি।
- ফল। Stock 0 দেখে হিসাব ঠিক মনে হয়, আসলে একটি বিক্রি হারিয়ে গেছে। একে বলে lost update. এজন্যই এই বাগ test-এ সহজে ধরা পড়ে না।
এবার একই কাহিনি কোডে। এখানে কোনো ডেটাবেস নেই, একটি object-ই “টেবিল”, আর প্রতিটি await মানে ডেটাবেসে একবার যাওয়া। এটি শেখানোর মডেল। JavaScript-এর event loop এখানে ক্রম নির্দিষ্ট রাখে, তাই আউটপুট প্রতিবার এক। সত্যিকারের ডেটাবেসে ক্রম প্রতি run-এ বদলায়।
// Teaching model: a table in memory. One await = one trip to the database.
const trip = () => new Promise((resolve) => setImmediate(resolve));
const db = { stock: 1, orders: [] };
async function checkThenWrite(name) {
await trip();
const stock = db.stock; // SELECT stock
console.log(`${name}: read stock = ${stock}`);
if (stock < 1) return console.log(`${name}: sold out`);
await trip();
db.stock = stock - 1; // UPDATE stock = 0
db.orders.push(name);
console.log(`${name}: order placed`);
}
async function atomicUpdate(name) {
await trip();
const count = db.stock >= 1 ? 1 : 0; // UPDATE ... WHERE stock >= 1
if (count === 1) { db.stock -= 1; db.orders.push(name); }
console.log(`${name}: count = ${count}`);
}
async function run(title, buy) {
db.stock = 1;
db.orders = [];
console.log(`--- ${title}`);
await Promise.all([buy('Rina'), buy('Rafi')]);
console.log(`stock = ${db.stock}, orders = ${db.orders.length}`);
}
run('check, then write', checkThenWrite)
.then(() => run('one atomic update', atomicUpdate));চালালে প্রথম অংশ ছাপে stock = 0, orders = 2, আর দ্বিতীয় অংশ stock = 0, orders = 1. পার্থক্য একটিই: প্রথমটিতে পড়া আর লেখার মাঝে একটি await আছে, মানে অন্য কারও ঢোকার ফাঁক। দ্বিতীয়টিতে যাচাই আর লেখা এক ধাপে, তাই Rafi: count = 0. পুরো পর্বের মূল কথা এই একটি ফাঁক।
সত্যিকারের PostgreSQL-এ, সত্যিকারের request দিয়ে
মডেল দেখে সন্তুষ্ট হওয়া যাবে না, আসল জিনিস চালাই। Docker ছাড়াই embedded-postgres npm package একটি আসল PostgreSQL server চালাতে পারে (শুধু পরীক্ষার জন্য, আর এর version লেখার সময়ে beta চিহ্নিত)। আগে Prisma-র version দেখে নিই, কারণ latest tag সবসময় stable হয় না:
~/bazarghar$ npm view prisma dist-tags
{ ...
next: '8.0.0-rc.10',
prev: '7.10.1',
latest: '8.0.0-rc.29',
... }
~/bazarghar$ npm install prisma@7.10.1 @prisma/client@7.10.1 @prisma/adapter-pg@7.10.1 pg
~/bazarghar$ npm install express embedded-postgres
লেখার সময়ে latest একটি release candidate, তাই এই পর্বের সব run স্থির সংস্করণ Prisma 7.10.1, PostgreSQL 18.6, Express 5.3.0 আর Node 24 দিয়ে করা (আউটপুট ছোট করা, ফোল্ডার ~/bazarghar). সেটআপের বিস্তার পর্ব ৩৯-এর বিষয়। পরীক্ষাটি এমন: Express server, একটি পণ্য, stock = 1, আর ২০টি POST /orders একসাথে fetch দিয়ে। প্রতিটি কৌশল ১০ বার।
~/bazarghar$ npx tsx src/race.ts
stock=1 buyers=20 trials=10
strategy sold(min..max) oversold-trials finalStock(last)
naive 13..20 10 0
এটি একটি স্থানীয় run-এর ফল, সাধারণ নিয়ম নয়। তবু দেখুন: ১০ বারের ১০ বারই oversell, আর একটি ইলিশের জন্য ১৩ থেকে ২০টি অর্ডার। আর শেষের stock দেখাচ্ছে 0, একদম সুন্দর।
২ · $transaction: দুই রূপ, আর repository পর্যন্ত পৌঁছানো
পর্ব ২৯-এ আমরা একটি ফাঁক রেখে এসেছিলাম: stock কমল, অর্ডার লেখার সময় ভাঙল, ২ প্যাকেট চাল হিসাব থেকে উধাও। সমাধান transaction, মানে কয়েকটি কাজের দল যা হয় সব হবে, নয়তো একটিও না। ACID আর isolation-এর তত্ত্ব আছে Transaction ও ACID পর্বে। এখানে শুধু অ্যাপের কোড।
Prisma Client-এ $transaction-এর দুটি রূপ। Array রূপে আগে থেকে জানা query-গুলোর তালিকা দেন, Prisma সেগুলো ক্রমে একটি transaction-এ চালায়:
const [product, order] = await prisma.$transaction([
prisma.product.update({ where: { id }, data: { stock: { decrement: 1 } } }),
prisma.order.create({ data: { productId: id, buyer: 'rina', qty: 1, totalPaisa: 42000, idempotencyKey: 'k-1' } }),
]);মাঝে কোনো যুক্তি চলে না: প্রথম query-র ফল দেখে দ্বিতীয়টি ঠিক করা যায় না। Interactive transaction-এ একটি async function দেন। Prisma আপনাকে একটি tx দেয়, যা একটি connection-এ বাঁধা:
await prisma.$transaction(async (tx) => {
await tx.product.update({ where: { id }, data: { stock: { decrement: 1 } } });
if (somethingWrong) throw new Error('business rule failed'); // ROLLBACK
await tx.order.create({ data: orderData });
}, { maxWait: 2000, timeout: 5000 }); // defaultsFunction স্বাভাবিকভাবে শেষ হলে commit, আর error ছুড়লে rollback. Isolation level, maxWait আর timeout দ্বিতীয় argument-এ যায়। সত্যিকারের run-এ দুটি রূপই পরীক্ষা করলাম। দ্বিতীয় array-তে order-এর idempotencyKey ইচ্ছা করে একই রাখা হয়েছে, তাই সেটি ভাঙে:
ok: stock 4 order true
second failed: code P2002
Unique constraint failed on the constraint: `Order_idempotencyKey_key`
stock after rollback 4 orders 1
thrown: business rule failed
stock after throw 4
দ্বিতীয় array-র প্রথম query stock কমিয়েছিল, কিন্তু order ভাঙায় সেটিও ফিরে গেল, stock 4-ই থাকল। Interactive রূপেও তাই।
সবচেয়ে সহজ ভুল: callback-এর ভেতরে tx-এর বদলে prisma লেখা। তখন সেই query অন্য connection-এ চলে, transaction-এর বাইরে। Stock 5 থেকে শুরু করে একটি run: tx দিয়ে stock কমল, ভুল করে prisma.order.create, তারপর error.
thrown: payment record failed
stock 5 orders 1
Stock ফিরে এসেছে, কিন্তু অর্ডারটি রয়ে গেছে। TypeScript এই ভুল ধরে না, কারণ দুটোই বৈধ কোড।
Repository পর্যন্ত tx পৌঁছে দেওয়া
পর্ব ২৯-এর টানাপোড়েনটি মনে আছে? Transaction শুরু করে ডেটাবেস, কিন্তু কোন কাজগুলো একসাথে তা জানে service layer. সরল পথ: repository-র প্রতিটি method প্রথম parameter-এ transaction client নেবে, আর service সেটি পাঠাবে।
type Db = Prisma.TransactionClient;
export const orderRepo = {
async reserveStock(db: Db, productId: number, qty: number) {
const r = await db.product.updateMany({
where: { id: productId, stock: { gte: qty } },
data: { stock: { decrement: qty } },
});
return r.count === 1;
},
createOrder(db: Db, data: NewOrder) {
return db.order.create({ data });
},
};
// service: boundary here, SQL in the repository
export function placeOrder(productId: number, buyer: string, qty: number, pricePaisa: number) {
return prisma.$transaction(async (tx) => {
if (!(await orderRepo.reserveStock(tx, productId, qty))) throw new OutOfStock();
return orderRepo.createOrder(tx, { productId, buyer, qty, totalPaisa: pricePaisa * qty });
});
}পর্ব ২৯-এর ফাঁকটি এবার আসল ডেটাবেসে পরীক্ষা করি। চাল ৩ প্যাকেট। রিনা ২টি নিলেন। রাফির অর্ডার লেখার পরে ইচ্ছা করে disk full ছোড়া হলো:
order 2 84000 stock 1
failed: disk full
after failure: stock 1 orders 1
রাফির ১ প্যাকেট আর হারাল না, stock 1-ই থাকল। মোট দাম পয়সায় পূর্ণসংখ্যা: 84000 মানে ৳৮৪০।
Prisma 8 আসছে: লেখার সময়ে Prisma 8 release candidate, আর তার docs অনুযায়ী $transaction থাকবে না। Callback রূপটি হবে db.transaction(async (tx) => …), array রূপের সরাসরি বিকল্প নেই, আর isolationLevel, timeout, maxWait option-ও নেই। এই পর্বের কোড Prisma 7-এর। Version বদলালে সেই version-এর docs আবার পড়ুন। SQL-এর ধারণাগুলো বদলায় না।
৩ · Transaction থাকলেও কেন oversell
স্বাভাবিক ধারণা: naive কোডটি $transaction-এ মুড়ে দিলেই race শেষ। পরীক্ষা করি। প্রথম কোডে শুধু tx বসালাম (txRead), আর আরেকটিতে লেখার সময় decrement (txDecrement):
strategy sold(min..max) oversold-trials finalStock(last)
naive 13..20 10 0
txRead 10..10 10 0
txDecrement 10..10 10 -9
Transaction থাকা সত্ত্বেও ১০ বারের ১০ বারই oversell. কারণ PostgreSQL-এর ডিফল্ট isolation level হলো Read Committed (run-এ SHOW transaction_isolation দেখাল read committed). এই level-এ প্রতিটি statement তার শুরুর মুহূর্তের commit হওয়া ডেটা দেখে। Transaction “সব হবে বা কিছুই না” নিশ্চিত করে, কিন্তু আপনার পড়া মানটিকে আগলে রাখে না। SELECT কোনো lock নেয় না, তাই দশটি transaction একসাথে পড়ল 1, দশটিই যাচাই পাস করল।
txDecrement-এ দেখুন -9. stock - 1 ডেটাবেসে হিসাব হয়, তাই কোনো update হারায়নি: ১ থেকে ১০ বার কমে −৯. এই ক্ষেত্রে অন্তত ভুলটি চোখে পড়ে। আর txRead-এ stock 0, বাগটি লুকিয়ে আছে।
ঠিক ১০ কাকতালীয় নয়। Run-এ Prisma-র connection pool-এর আকার ছিল ১০, আর প্রতিটি interactive transaction একটি connection ধরে রাখে। তাই একসাথে সর্বোচ্চ ১০টি পড়তে পেরেছে। txRead আবার চালালাম ভিন্ন pool দিয়ে (এক run, ২০ ক্রেতা, ১ stock):
pool 1 sold 1..1 oversold in 0 of 10 trials
pool 3 sold 3..3 oversold in 10 of 10 trials
pool 10 sold 10..10 oversold in 10 of 10 trials
pool 20 sold 18..19 oversold in 10 of 10 trials
Pool ১ হলে transaction-গুলো একে একে চলে, বাগ দেখাই যায় না। কম connection-এর test পরিবেশে সব পাস করে, production-এ ফেটে পড়ে। (Pool ২০-এর run-এ কয়েকটি request connection-সীমার error P2037-ও পেয়েছে।)
আরও গভীরে: Read Committed-এ দুটি UPDATE একই সারিতে এলে দ্বিতীয়টি প্রথমটির commit-এর অপেক্ষা করে, তারপর PostgreSQL সারির নতুন মানে WHERE শর্ত আবার যাচাই করে। PostgreSQL docs-এর Transaction Isolation অধ্যায়ে এই আচরণ লেখা আছে। পরের অংশের সমাধান এই পুনঃযাচাইয়ের ওপরই দাঁড়িয়ে। অন্য DBMS-এর আচরণ আলাদা হতে পারে, বিস্তারিত MVCC আর isolation পর্বে।
৪ · সমাধান: যাচাই আর লেখা এক SQL-এ, আর ডেটাবেসের নিয়ম
ফাঁক থাকলে সমস্যা, তাই ফাঁকটিই তুলে দিন। যাচাইটি UPDATE-এর WHERE-এ চলে যাক। এটিই conditional update. Prisma-তে update নয়, updateMany, কারণ এটি কয়টি সারি বদলাল তা count-এ জানায়:
await prisma.$transaction(async (tx) => {
const r = await tx.product.updateMany({
where: { id, stock: { gte: qty } },
data: { stock: { decrement: qty } },
});
if (r.count === 0) throw new OutOfStock(); // HTTP 409
const p = await tx.product.findUniqueOrThrow({ where: { id } });
return tx.order.create({ data: { productId: id, buyer, qty, totalPaisa: p.pricePaisa * qty } });
});Prisma আসলে কী পাঠায়, query log চালু করে দেখা যাক (সত্যিকারের run, log: ['query']):
prisma:query UPDATE "public"."Product" SET "stock" = ("public"."Product"."stock" - $1)
WHERE ("public"."Product"."id" = $2 AND "public"."Product"."stock" >= $3)
count = 1
prisma:query COMMIT
একটিই statement, মান parameter হিসেবে। দ্বিতীয় ক্রেতা সারির lock পেতে অপেক্ষা করে, তারপর নতুন stock 0-তে শর্ত যাচাই হয়, 0 >= 1 মিথ্যা, count হয় 0. একই পরীক্ষা:
strategy sold(min..max) oversold-trials finalStock(last) statuses(all)
atomic 1..1 0 0 {"201":10,"409":190}
১০ বারে ১০ জন সফল, বাকি ১৯০ জন পেলেন 409, কোনো oversell নেই। নিচের ব্লকে SQL-এর গড়নটি নিজে চালান। তবে সৎভাবে বলি: এটি browser-এর ভেতরে SQLite, এখানে কোনো সত্যিকারের concurrency নেই, statement দুটি একটির পর একটি চলে। এটি শুধু SQL-টির আকার আর count-এর ধারণা দেখায়।
-- Rina: the check lives inside the UPDATE
UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
SELECT changes() AS rina_count;
-- Rafi: same statement, stock is already 0
UPDATE products SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
SELECT changes() AS rafi_count;
SELECT name, stock FROM products;rina_count হয় 1, rafi_count হয় 0, আর শেষে stock 0. changes() হলো SQLite-এর দিক থেকে Prisma-র count-এর সমতুল্য। Stock কখনো ঋণাত্মক হয় না, কারণ শর্ত না মিললে সারিটি ছোঁয়াই হয় না।
Unique constraint: দুবার হওয়া ঠেকানো
কিছু race-এ stock নয়, “একই জিনিস দুবার” হওয়াই সমস্যা, যেমন ক্রেতার দুবার ক্লিক। পর্ব ৩৩-এর idempotency key মনে করুন। আগে খুঁজে তারপর লেখা আবার সেই একই পড়ে-যাচাইয়ের ভুল। বরং ডেটাবেসকে বলুন idempotencyKey String? @unique, লিখতে চেষ্টা করুন, আর P2002 ধরুন। একই key-তে পাঁচটি request একসাথে (সত্যিকারের run, stock 5):
try {
const order = await prisma.$transaction(async (tx) => {
/* conditional updateMany, then */
return tx.order.create({ data: { /* ... */ idempotencyKey: key } });
});
return { status: 201, id: order.id };
} catch (e: any) {
if (e.code !== 'P2002') throw e; // transaction already rolled back
const o = await prisma.order.findUniqueOrThrow({ where: { idempotencyKey: key } });
return { status: 200, id: o.id };
}[ { status: 201, id: 3134 }, { status: 200, id: 3134 }, { status: 200, id: 3134 },
{ status: 200, id: 3134 }, { status: 200, id: 3134 } ] (ছোট করা)
stock 4 orders 1
অর্ডার ১টি, stock কমল ১, চারটি duplicate পেল আগের অর্ডারের id. হেরে যাওয়া transaction-এর stock কমানোও ফিরে গেছে।
Version-এর ফাঁদ: AI আর পুরোনো উদাহরণ প্রায়ই লেখে e.meta.target. আমাদের run-এ (Prisma 7.10.1, pg adapter) এটি undefined, constraint-এর নাম পাওয়া গেল e.meta.driverAdapterError.cause.constraint.index-এ। তাই কোন constraint ভাঙল, তা নিজের version-এ চালিয়ে দেখুন।
শেষ পাহারা: CHECK constraint
কোডে ভুল থাকলেও stock যেন ঋণাত্মক না হয়, সেই নিয়ম ডেটাবেসে রাখুন। Prisma schema-তে CHECK লেখা যায় না, তাই হাতে লেখা migration লাগে (migration-এর পথ পর্ব ৪০-এ):
ALTER TABLE "Product" ADD CONSTRAINT "stock_non_negative" CHECK ("stock" >= 0);এবার ভুল কোড txDecrement তিনটি transaction-এ চালালাম (stock 1):
1 ok
2 P2039 Database error. Code: `23514`. Message: `new row for relation "Product"
violates check constraint "stock_non_negative"`
3 P2039 Database error. Code: `23514`. ...
stock 0
এটি সমাধান নয়, পাহারা। Error আসে, ক্রেতা বিরক্ত হন, কিন্তু হিসাব ভাঙে না। Run-এ Prisma-র code ছিল P2039, আর ভেতরে PostgreSQL-এর 23514. Prisma 7-এর error reference-এ constraint ভাঙার জন্য P2004 লেখা আছে, আর P2039 লেখার সময়ে সেই তালিকায় নেই। Prisma-র ২০২৬ সালের এপ্রিলের changelog অনুযায়ী P2039 হলো সাধারণ “Database error”: driver এমন কোনো error দিলে, যার নির্দিষ্ট Prisma code নেই, Prisma সেটিকে এই code-এ মুড়ে দেয়। তাই আসল কারণ বুঝতে ভেতরের database code, এখানে 23514, দেখতে হয়। নির্দিষ্ট code ধরে কোড লেখার আগে নিজের version-এ চালিয়ে দেখুন।
৫ · SELECT … FOR UPDATE: সারিতে তালা
সব কাজ এক UPDATE-এ আঁটে না। ধরুন পড়ার পরে জটিল হিসাব লাগে: ক্রেতার ধরন, ছাড়, সীমা। তখন পড়ার সময়েই সারিটি আটকে দিন। একে বলে pessimistic locking: সংঘাত হবেই ধরে নিয়ে আগেই row lock নেওয়া। Prisma-র query API-তে এর সরাসরি option নেই, তাই raw SQL. $queryRaw tagged template, তাই মান parameter হয়ে যায়, injection-এর ভয় নেই:
await prisma.$transaction(async (tx) => {
const [p] = await tx.$queryRaw<{ id: number; stock: number; pricePaisa: number }[]>`
SELECT id, stock, "pricePaisa" FROM "Product" WHERE id = ${id} FOR UPDATE`;
if (p.stock < qty) throw new OutOfStock();
await tx.product.update({ where: { id }, data: { stock: p.stock - qty } });
return tx.order.create({ data: { productId: id, buyer, qty, totalPaisa: p.pricePaisa * qty } });
});এবার মিলিয়ে দেখুন tx.$queryRaw, prisma.$queryRaw নয়: lock থাকে transaction শেষ হওয়া পর্যন্ত, তাই transaction-এর বাইরে FOR UPDATE অর্থহীন। A 500 ms ধরে lock রাখল, B মাঝখানে এলো। সত্যিকারের run-এর সময়-চিহ্ন:
1ms A got lock, stock 1
55ms B asks for lock
511ms A updated, committing
512ms B got lock, stock 0
1A: SELECT ... FOR UPDATE → 12B: SELECT ... FOR UPDATE ...3A: UPDATE stock = 04A: COMMIT5B: stock 0, sold out, ROLLBACK
| সময় | |
| commit হওয়া stock | |
| তালা কার হাতে | |
| B-র অবস্থা |
- A তালা নিল। Transaction-এর ভেতরে
FOR UPDATEসারিটি পড়ে আর আটকায়। A দেখল stock 1. - B এসে দাঁড়াল। একই সারিতে
FOR UPDATEচাইল। ডেটাবেস উত্তর দিল না, B-র query থেমে রইল। Error নয়, শুধু অপেক্ষা, আর সেই সময়টুকু B একটি connection ধরে আছে। - A লিখল। Stock 0 লেখা হলো, তবে এখনো commit হয়নি, তাই বাকি সবার কাছে এখনো 1.
- A commit করল। মাত্র 1ms পরে (511 থেকে 512) B তালা পেল, আর সে পড়ল নতুন মান 0. এটিই পার্থক্য: সাধারণ
SELECTহলে সে পুরোনো মান দেখতে পারত। - B থামল। Stock 0, তাই অর্ডার নেই। Rollback-এ তালা ছাড়ল। ১টি পণ্য, ১টি অর্ডার।
২০ ক্রেতার পরীক্ষায় forUpdate-এর ফল 1..1, oversell ০ বার। দাম হলো অপেক্ষা: সবাই লাইনে দাঁড়ায়। মনে রাখুন, FOR UPDATE-এর একটি ভাই আছে, FOR UPDATE SKIP LOCKED, যা আটকানো সারি ফেলে পরেরটি ধরে। কাজের queue আর outbox-এ এটি লাগবে (অংশ ৯)। Lock-এর তত্ত্ব আছে Concurrency control পর্বে।
৬ · Lock ছাড়া: version column আর Serializable
Optimistic locking
Optimistic concurrency উল্টোভাবে ভাবে: সংঘাত কম হবে, তাই lock নয়। পড়ার সময় একটি version column-এর মান মনে রাখুন, আর লেখার সময় শর্ত দিন “version এখনো সেটিই”। না মিললে কেউ আগে লিখেছে, আবার চেষ্টা করুন। If-Match আর ETag-এর (পর্ব ৩৩) ডেটাবেস-রূপ এটি।
// Teaching model: optimistic locking with a version column.
const trip = () => new Promise((resolve) => setImmediate(resolve));
const product = { stock: 2, version: 0 };
// UPDATE ... SET stock = stock - 1, version = version + 1
// WHERE version = seen. Returns how many rows changed.
function updateIfVersion(seen) {
if (product.version !== seen) return 0;
product.stock -= 1;
product.version += 1;
return 1;
}
async function buy(name, maxTries = 4) {
for (let attempt = 1; attempt <= maxTries; attempt++) {
await trip();
const { stock, version } = product; // read, no lock
if (stock < 1) return console.log(`${name}: sold out`);
await trip();
if (updateIfVersion(version) === 1) {
return console.log(`${name}: bought on try ${attempt}, version ${version} -> ${version + 1}`);
}
console.log(`${name}: version ${version} is old, retry`);
}
console.log(`${name}: gave up, answer 503`);
}
Promise.all([buy('Rina'), buy('Rafi'), buy('Mina')]).then(() => {
console.log(`stock = ${product.stock}, version = ${product.version}`);
});শেখানোর মডেল, ক্রম নির্দিষ্ট। রিনা প্রথম চেষ্টায় কিনলেন: Rina: bought on try 1, version 0 -> 1. রাফি আর মিনা দেখলেন version পুরোনো। রাফি দ্বিতীয় চেষ্টায় সফল। মিনা তৃতীয়বার পড়ে দেখলেন stock 0, তাই Mina: sold out. শেষে stock = 0, version = 2. Stock কখনো ঋণাত্মক হয়নি, আর কেউ অপেক্ষায় আটকে থাকেনি। দাম হলো বারবার চেষ্টা।
Prisma-তে একই কাজ updateMany আর where: { id, version: p.version } দিয়ে, data-তে version: { increment: 1 }, আর count === 0 হলে retry.
Serializable আর P2034
তৃতীয় পথ: কোড বদলাবেন না, ডেটাবেসকেই বলুন Serializable level-এ চলতে। PostgreSQL তখন সমান্তরাল transaction-গুলো পাহারা দেয়, আর ফল একে একে চলার মতো না হলে একটিকে বাতিল করে। Prisma-তে এই বাতিলের নাম P2034, আর Prisma নিজে retry করে না, সে দায়িত্ব আপনার। Retry করতে হবে পুরো transaction, শুধু শেষ লাইন নয়:
export async function withRetry<T>(fn: () => Promise<T>, attempts = 4): Promise<T> {
for (let i = 1; ; i++) {
try {
return await fn();
} catch (e) {
const retryable = e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2034';
if (!retryable || i === attempts) throw e;
await sleep(20 * 2 ** i * Math.random()); // backoff with jitter
}
}
}
await withRetry(() => prisma.$transaction(async (tx) => {
const p = await tx.product.findUniqueOrThrow({ where: { id } });
if (p.stock < qty) throw new OutOfStock();
await tx.product.update({ where: { id }, data: { stock: p.stock - qty } });
return tx.order.create({ data: orderData });
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }));এখানে exponential backoff আর jitter কাজে লাগে, যাতে সবাই একসাথে আবার ধাক্কা না দেয়। সত্যিকারের run: stock 3, চার ক্রেতা একসাথে।
[ 'ok', 'ok', 'ok', 'OUT_OF_STOCK' ] retried 5
stock 0 orders 3
-- from a separate two-transaction run:
code: 'P2034'
msg: 'Transaction failed due to a write conflict or a deadlock. Please retry your transaction'
কোনটি কখন: একই পরীক্ষায় তুলনা
১ stock-এ সবাই ঠিক ছিল (optimistic আর serializable-ও 1..1). এবার কঠিন পরীক্ষা: একই সারিতে stock 50, ক্রেতা 50, সবার পাওয়ার কথা। ৫ বার করে, এক স্থানীয় run. “ব্যর্থ” মানে retry শেষ করে 503.
| কৌশল | প্রতি পরীক্ষায় বিক্রি (৫০-এর মধ্যে) | মোট retry | ব্যর্থ (২৫০-এর মধ্যে) | ms/পরীক্ষা |
|---|---|---|---|---|
atomic | ৫০ | ০ | ০ | ১৩৪ |
forUpdate | ৫০ | ০ | ০ | ৮৩ |
optimistic, ৫ চেষ্টা | ২৩ থেকে ২৯ | ৯২২ | ১১৯ | ২৩২ |
serializable, ৫ চেষ্টা | ২৮ থেকে ৩২ | ৮৪৭ | ১০০ | ২৭১ |
optimistic, ৪০ চেষ্টা | ৫০ | ১২৬৬ | ০ | ৬০২ |
serializable, ৪০ চেষ্টা | ৫০ | ১০৩৬ | ০ | ৪৯১ |
শিক্ষাটি এই: গরম সারিতে retry-নির্ভর পথ হয় ধীর, নয়তো পণ্য থাকা সত্ত্বেও “ব্যর্থ”। atomic আর forUpdate-এর ms-এর পার্থক্যকে গুরুত্ব দেবেন না; এই এক run-এ এত ছোট ব্যবধান কিছু প্রমাণ করে না, আর atomic কোডে একটি বাড়তি পড়াও ছিল।
| পরিস্থিতি | প্রথম পছন্দ | কেন |
|---|---|---|
| Stock, ব্যালেন্স, সিট: এক সারির সংখ্যা | Conditional update | এক SQL, retry নেই, সবচেয়ে সরল |
| পড়ার পরে জটিল সিদ্ধান্ত, এক বা অল্প সারি | FOR UPDATE | ক্রমে চলে, retry লাগে না, তবে অপেক্ষা হয় |
| দুবার হওয়া ঠেকানো | Unique constraint | ডেটাবেসের নিশ্চয়তা, কোনো lock ধরে রাখা নয় |
| Admin দুজন একই পণ্যের ফর্ম সম্পাদনা, সংঘাত কম, মানুষ জড়িত | Version column | ফর্ম খোলা অবস্থায় lock রাখা অসম্ভব, “কেউ বদলেছে” জানানো যায় |
| নিয়মটি অনেক সারি জুড়ে, যেমন “দিনের মোট ছাড় সীমার মধ্যে” | Serializable + retry | এক সারির lock এই নিয়ম ধরতে পারে না |
৭ · Deadlock: উল্টো ক্রমের দুই কার্ট
রিনার কার্টে আগে চাল, পরে ডাল। রাফির কার্টে আগে ডাল, পরে চাল। দুজনের transaction প্রতিটি পণ্যের stock কমাচ্ছে, মানে সারি আটকাচ্ছে। রিনা চালের সারি ধরে ডালের জন্য অপেক্ষা করছেন, রাফি ডালের সারি ধরে চালের জন্য। কেউ ছাড়বে না। সরু গলিতে দুই রিকশা মুখোমুখি, কেউ পিছোবে না।
PostgreSQL এটি ধরে ফেলে: lock-এর অপেক্ষা deadlock_timeout (এখানে ডিফল্ট 1s) পেরোলে সে যাচাই করে, চক্র পেলে একটি transaction-কে বাতিল করে। সত্যিকারের run:
A locked chal
B locked dal
A locked dal
ms 1119
A fulfilled
B rejected code: 'P2034'
msg: 'Transaction failed due to a write conflict or a deadlock. Please retry your transaction'
stock [ { slug: 'chal-5kg', stock: 99 }, { slug: 'dal-1kg', stock: 99 } ]
সময় লাগল ১.১ সেকেন্ডের বেশি, যদিও কাজ ছিল ১০০ ms-এর। আর আগের অংশের সেই P2034-ই এখানে। Prisma-র বার্তাতেই লেখা “write conflict or a deadlock”, তাই আগের withRetry এখানেও কাজ করে। কিন্তু আসল ওষুধ হলো lock ordering: সবাই একই ক্রমে lock নেবে। মডেলে দেখুন:
// Teaching model: row locks with deadlock detection. Fixed order, no database.
const trip = () => new Promise((resolve) => setImmediate(resolve));
let holder, queue;
function lock(tx, row) {
if (!holder[row]) { holder[row] = tx; return Promise.resolve(); }
if (Object.values(queue).some((w) => w.tx === holder[row])) {
return Promise.reject(new Error(`${tx} aborted: deadlock on ${row}`));
}
return new Promise((resolve) => { queue[tx] = { tx, row, resolve }; });
}
function releaseAll(tx) {
for (const row of Object.keys(holder)) if (holder[row] === tx) delete holder[row];
for (const w of Object.values(queue)) if (!holder[w.row]) {
holder[w.row] = w.tx; delete queue[w.tx]; w.resolve();
}
}
async function cart(tx, rows) {
try {
for (const row of rows) {
await lock(tx, row);
console.log(`${tx} locked ${row}`);
await trip();
}
console.log(`${tx} commit`);
} catch (e) { console.log(e.message); }
releaseAll(tx);
}
async function run(title, a, b) {
holder = {}; queue = {};
console.log(`--- ${title}`);
await Promise.all([cart('A', a), cart('B', b)]);
}
run('opposite order', ['chal', 'dal'], ['dal', 'chal'])
.then(() => run('sorted order', ['chal', 'dal'], ['chal', 'dal']));শেখানোর মডেল। প্রথম অংশে B aborted: deadlock on chal: A আর B একে অন্যের অপেক্ষায়, তাই একজনকে বাতিল করা হলো, A শেষ করল। দ্বিতীয় অংশে দুজনেই আগে chal: A locked chal, A locked dal, A commit, তারপর B. B-কে অপেক্ষা করতে হলো ঠিকই, কিন্তু চক্র হলো না। মডেলের ডেটাবেস সঙ্গে সঙ্গে ধরে; PostgreSQL ধরে deadlock_timeout-এর পরে।
বাস্তব কোডে এটি এক লাইন: items.sort((a, b) => a.productId - b.productId). ১০টি কার্ট, অর্ধেক উল্টো ক্রমে, প্রতিটিতে ২ পণ্য, সত্যিকারের PostgreSQL-এ:
{ sorted: false, ok: 3, deadlockAborts: 7, other: null, ms: 6142 }
{ sorted: true, ok: 10, deadlockAborts: 0, other: null, ms: 313 }
আরেক run-এ অক্রমে ফল ছিল ok: 5, deadlockAborts: 5, ms: 3178, মানে সংখ্যাটি বদলায়। ক্রমে সাজালে দুই run-এই ০ deadlock, প্রায় ৩১৫ ms. একটি sort এই দুই run-এ সময় প্রায় ১০ থেকে ২০ গুণ কমাল, আর ব্যর্থতা শূন্য করল।
৮ · Transaction ছোট রাখুন, আর অর্ডারের পুরো পথ
Prisma-র docs স্পষ্ট বলে: transaction ছোট রাখুন, আর ভেতরে network request এড়িয়ে চলুন। কেন? Transaction খোলা মানে তিনটি জিনিস আটকে আছে: একটি pool connection, ধরা সারির lock, আর (ভাঙলে) পুরো কাজ। Payment gateway ধীর হলে আপনার ডেটাবেসও ধীর।
Gateway-কে ৩০০ ms-এর ঘুম দিয়ে নকল করা হলো (আসল সেবা নয়)। একই সারিতে ১০ ক্রেতা, সত্যিকারের run:
{ gateway: 'inside transaction', buyers: 10, totalMs: 3050 }
{ gateway: 'after commit', buyers: 10, totalMs: 313 }
হিসাবটি সহজ। সারিটি যতক্ষণ ধরা, ততক্ষণ পরের ক্রেতা বসে। তাই গরম সারিতে:
1 ÷ 0.3 s ≈ 3.3 / s, তাই 10 জনে 10 × 0.3 s = 3 s
মাপা ৩০৫০ ms এই হিসাবের সাথে মিলে যায়। এবার pool-এর দিক। Pool ৫, ২০ request, gateway ৮০০ ms, দুটি সারি পালা করে, maxWait ডিফল্ট ২০০০ ms:
{ gateway: 'inside', pool: 5, requests: 20, ok: 9, errs: { P2028: 11 }, ms: 4077 }
{ gateway: 'after commit', pool: 5, requests: 20, ok: 20, errs: {}, ms: 850 }
৯ সংখ্যাটিও হিসাব করা যায়। শুরুতে ৫টি connection পায়। দুটি সারি, তাই প্রতি ০.৮ s-এ ২টি শেষ হয়ে ২টি connection ছাড়ে। ০.৮ s আর ১.৬ s-এ ২টি করে নতুন ঢোকে: ৫ + ২ + ২ = ৯. পরের সুযোগ ২.৪ s-এ, কিন্তু বাকি ১১টি ২ s-এই maxWait পেরিয়ে P2028 পেয়েছে। ৯টি কাজ দুই সারিতে: ৫ পালা × ০.৮ s = ৪ s, মাপা ৪০৭৭ ms.
Timeout-এর দিকটিও দেখুন। Transaction ভেতরে ৬ সেকেন্ড ঘুমালে (ডিফল্ট timeout ৫০০০ ms), আর pool পূর্ণ থাকলে:
code: 'P2028', timeout: 5000, timeTaken: 6005
msg: 'Transaction API error: A query cannot be executed on an expired transaction. ...'
stock 5 orders 0
code: 'P2028'
msg: 'Transaction API error: Unable to start a transaction in the given time.'
একই P2028, দুটি আলাদা ঘটনা। প্রথমটি সময় পেরিয়েছে, stock ফিরে গেছে। দ্বিতীয়টিতে connection-ই মেলেনি। timeout বাড়িয়ে পার পাওয়া প্রলোভন, কিন্তু আসল ওষুধ transaction ছোট করা।
নিয়ম: Transaction-এর ভেতরে payment gateway, email, SMS বা অন্য কোনো HTTP call নয়। আরেকটি কারণও আছে: Rollback ডেটাবেসকে ফেরায়, কিন্তু গ্রাহকের কাটা টাকা ফেরায় না। bKash বা কার্ড, যেকোনো gateway-এর জন্যই এই কথা সত্য।
অর্ডারের পথ: তিনটি ছোট transaction, মাঝে টাকা
তাহলে টাকা নেওয়া আর stock ধরা জুড়বেন কীভাবে? উত্তর stock reservation: একটি বড় transaction নয়, একাধিক ছোট transaction, মাঝে অর্ডারের অবস্থা PENDING. হোটেলের রিসেপশনে ঘর “ধরে রাখা” হয়, অগ্রিম এলে নিশ্চিত, না এলে ১৫ মিনিট পরে ছেড়ে দেওয়া।
অর্ডারের পথ আর transaction-এর সীমা
Three short transactions, no transaction around the payment
const confirm = (id: number) => prisma.$transaction(async (tx) => {
const r = await tx.order.updateMany({ where: { id, status: 'PENDING' }, data: { status: 'PAID' } });
if (r.count === 0) return false; // already decided
await tx.outboxEvent.create({ data: { type: 'order.paid', payload: { orderId: id } } });
return true;
});
const release = (id: number, productId: number, qty: number) => prisma.$transaction(async (tx) => {
const r = await tx.order.updateMany({ where: { id, status: 'PENDING' }, data: { status: 'CANCELLED' } });
if (r.count === 0) return false; // never return stock twice
await tx.product.update({ where: { id: productId }, data: { stock: { increment: qty } } });
return true;
});লক্ষ করুন: অবস্থার বদলও conditional update. চাল ৩ প্যাকেট দিয়ে সত্যিকারের run:
reserve rina 2 -> stock 1 PENDING
reserve rafi 1 -> stock 0 PENDING
reserve mina 1 -> OUT_OF_STOCK
pay rina ok -> confirm true stock 0
pay rafi declined -> release true stock 1
late webhook for rafi -> confirm false stock 1
repeat webhook rina -> confirm false outbox 1
একই webhook দুবার এলেও outbox-এ ১টি event. Payment gateway-এর webhook-এর পূর্ণ পথ পর্ব ৬৫-এ, আর মেয়াদ-শেষের sweeper চালানো পর্ব ৬২-তে। এবার সবচেয়ে কঠিন race: ঠিক মেয়াদ শেষের মুহূর্তে confirm আর release একসাথে। ২০০টি অর্ডারে দুটিই একসাথে চালালাম:
{ confirmed: 100, released: 100, both: 0, neither: 0, paid: 100, stock: 100, expectedStock: 100 }
একটি অর্ডারেও দুটি একসাথে জেতেনি, stock ঠিক ১০০. (এই run-এ যে আগে পৌঁছেছে সে-ই জিতেছে, আর ক্রম সমান ভাগে সাজানো ছিল বলে ১০০-১০০।)
কোড যা সমাধান করে না: confirm false আসার অর্থ হতে পারে টাকা কাটা হয়েছে, কিন্তু অর্ডার ইতিমধ্যে বাতিল। তখন ডেটা ঠিক আছে, ক্রেতার টাকা নেই। এই ক্ষেত্রে ফেরত দেওয়া বা হাতে দেখার পথ রাখতেই হবে, চুপ করে ফেরা চলবে না।
৯ · Service পেরোলে transaction নেই: outbox
অর্ডার ডেটাবেসে, আর email পাঠায় অন্য একটি service. Microservices হোক বা শুধু একটি message broker, ডেটাবেসের COMMIT বাইরের জিনিসকে ছুঁতে পারে না। দুই জায়গায় পরপর লেখাকে বলে dual write, আর এতে দুটির একটি হারাতে পারে। সত্যিকারের run: ৩টি অর্ডার, প্রথম publish-এ broker অচল।
dual write: order 1 saved, publish failed: broker unreachable
dual write: orders 3 events 2
সমাধান transactional outbox: বাইরে পাঠানোর বদলে event-টি একই transaction-এ নিজের ডেটাবেসেরই একটি টেবিলে লিখুন। অর্ডার আর event এখন এক ডেটাবেসে, তাই একসাথে হয় বা একসাথে যায়। আলাদা একটি relay পরে সেটি পাঠায়।
- Order service
txদুই সারি লেখে - PostgreSQL
COMMITঅর্ডার + outbox - Relay
SKIP LOCKEDbatch ধরে - Broker
publishবার্তা পৌঁছায় - Email service
event idduplicate বাদ
INSERT Order id=101 status=PENDING INSERT OutboxEvent id=7 order.created publishedAt=NULL
Order id=101
OutboxEvent id=7 publishedAt=NULL
SELECT id FROM "OutboxEvent"
WHERE "publishedAt" IS NULL
ORDER BY id LIMIT 5
FOR UPDATE SKIP LOCKED
publish(event 7) ok
-- crash here: publishedAt still NULL --
UPDATE ... SET "publishedAt" = now()
INSERT INTO processed_events (event_id) VALUES (7);
-- unique violation? already done, skip
- একই transaction-এ দুটি লেখা। অর্ডার আর outbox-এর সারি একসাথে লেখা হচ্ছে। Broker-এর সাথে এখনো কোনো কথা হয়নি, তাই এই ধাপে network নেই।
- Commit. হয় দুটিই আছে, নয়তো একটিও নেই। “অর্ডার আছে, event নেই” অবস্থাটি আর সম্ভব নয়। Server এখানে বন্ধ হলেও event টেবিলে নিরাপদ।
- Relay ধরল। একটি আলাদা process অপ্রকাশিত সারি নেয়।
SKIP LOCKEDমানে দুটি relay চললে একে অন্যের সারি এড়িয়ে যায়, অপেক্ষা করে না। - Publish, তারপর চিহ্ন। Broker-এ পাঠিয়ে
publishedAtবসে। এই দুইয়ের মাঝে crash হলে পরের বার সারিটি আবার পাঠানো হবে। Event হারাবে না, কিন্তু দুবার যেতে পারে। - Consumer নিজেকে বাঁচায়। এই নিশ্চয়তার নাম at-least-once delivery. তাই consumer-কে হতে হবে idempotent: event id unique constraint-এ রেখে duplicate বাদ দেওয়া, পর্ব ৩৩-এর একই কৌশল।
সত্যিকারের run: ৩০টি অর্ডার, দুটি relay একসাথে, আর একটি relay প্রথম batch পাঠিয়ে চিহ্ন দেওয়ার আগে crash করল।
R1 relay crashed before marking
{ events: 30, sends: 35, uniqueEvents: 30, duplicates: 5,
perRelay: { R1: 15, R2: 20 }, unpublished: 0 }
৩০টি event-এর একটিও হারায়নি, কিন্তু ৫টি দুবার গেছে, ঠিক crash করা batch-টি। Relay যদিও publish-এর সময় একটি transaction খোলা রাখে, সেই lock শুধু outbox-এর সারিতে, ক্রেতার stock-এর সারিতে নয়। Batch ছোট রাখুন। Polling-এর বদলে কেউ কেউ ডেটাবেসের change log পড়ে (change data capture), সে আরেক ট্রেড-অফ।
Saga: কাজটি যদি কয়েকটি service জুড়ে হয় (stock service, payment service, delivery service), তখন প্রতিটি নিজের ছোট transaction করে, আর ভাঙলে আগেরগুলো compensating action দিয়ে উল্টায়। উপরের release আসলে এরই ছোট রূপ। একে বলে saga. Two-phase commit কেন এখানে কম চলে, saga-য় isolation কেন নেই, এসব আছে Distributed transaction পর্বে। Broker-এর জন্য দেখুন Message queue.
পরীক্ষা ও ইন্টারভিউতে:
- Transaction থাকলে race হয় না, ঠিক? না। Read Committed-এ পড়া মান আটকানো থাকে না, তাই পড়ে-যাচাই-লেখা এখনো oversell করে।
- Lost update কীভাবে ঠেকাবেন?
stock = stock - 1ধরনের atomic update,FOR UPDATE, version column বা Serializable. - Optimistic আর pessimistic-এর পার্থক্য? Pessimistic আগে lock নেয়, optimistic লেখার সময় যাচাই করে retry করে। সংঘাত বেশি হলে প্রথমটি, কম হলে দ্বিতীয়টি।
- Deadlock ঠেকানোর সহজ উপায়? সব transaction-এ একই ক্রমে lock নেওয়া, যেমন id অনুযায়ী সাজিয়ে, আর ব্যর্থ হলে retry.
- Outbox কী সমস্যা মেটায়? Dual write. Event একই ডেটাবেস transaction-এ লেখা হয়, relay পরে পাঠায়, আর delivery at-least-once.
১০ · AI-এর যুগে: concurrency-র কোড যাচাই
AI-এর লেখা অর্ডার কোড প্রায়ই পরিষ্কার দেখায়, আর এক ব্যবহারকারীর test-এ পাস করে। ঠিক এই কারণেই এই জায়গাটি বিপজ্জনক: বাগ দেখা যায় শুধু চাপে।
| AI-এর ভুল | ফল | যা করবেন |
|---|---|---|
$transaction-এ মুড়ে “race ঠিক হলো” বলা | Read Committed-এ oversell | Concurrent test চালান, শুধু কোড পড়বেন না |
Callback-এ prisma লেখা, tx নয় | Rollback-এর পরও সারি থেকে যায় | ভেতরের প্রতিটি লাইন খুঁজে দেখুন |
| Transaction-এর ভেতরে gateway বা email | Lock দীর্ঘ, P2028, টাকা ফেরে না | Reserve, commit, তারপর call |
timeout বাড়িয়ে error “ঠিক” করা | সমস্যা লুকায় | Transaction ছোট করুন |
পুরোনো error code বা e.meta.target ধরা | Error ধরাই পড়ে না | নিজের version-এ ভুল ঘটিয়ে দেখুন |
$executeRawUnsafe-এ মান জোড়া | SQL injection | Tagged template $queryRaw |
| Prisma 8 RC বা পুরোনো version-এর API মিশিয়ে ফেলা | Method নেই বা আচরণ ভিন্ন | Lockfile-এর version আর সেই docs দেখুন |
AI-কে “ঠিক করে দাও” না বলে পরীক্ষা লিখতে বলুন। সে যুক্তি দিলে বিশ্বাস নয়, আউটপুট দেখুন:
AI-এর transaction কোড হাতে পেলে
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।
প্র ০১Serializable-ই যদি সবচেয়ে নিরাপদ, তাহলে PostgreSQL-এর ডিফল্ট Read Committed কেন?
কারণটি ঐতিহাসিক আর ব্যবহারিক, দুটোই। SQL standard চারটি isolation level সংজ্ঞায়িত করে, আর ১৯৯৫ সালে Berenson ও সহলেখকদের “A Critique of ANSI SQL Isolation Levels” প্রবন্ধ দেখায় যে সেই সংজ্ঞায় ফাঁক আছে, lost update-এর মতো anomaly স্পষ্টভাবে ধরা পড়ে না। এই প্রবন্ধই snapshot isolation-কে আলোচনায় আনে।
দীর্ঘদিন সত্যিকারের serializable মানে ছিল ব্যাপক lock, ফলে পড়া-লেখা একে অন্যকে আটকে রাখত। অনেক অ্যাপ সেই দাম দিতে চাইত না। ২০০৮ সালে Michael Cahill ও সহলেখকেরা Serializable Snapshot Isolation প্রস্তাব করেন: পড়া আটকায় না, সন্দেহজনক নির্ভরতার ছক দেখলে একটি transaction বাতিল হয়। PostgreSQL এই ধারণা ৯.১ সংস্করণে (২০১১) যুক্ত করে, যা Ports ও Grittner-এর প্রবন্ধে বর্ণিত।
কিন্তু নিরাপত্তা বিনামূল্যে আসেনি। Serializable-এ যেকোনো transaction বাতিল হতে পারে, তাই প্রতিটি লেখার পথে retry লাগবে, আর সেই retry-কে নিরাপদ হতে হবে। আমাদের run-এই দেখলেন: ৫ চেষ্টায় ২৫০-এর মধ্যে ১০০টি ব্যর্থ, পণ্য থাকা সত্ত্বেও। Read Committed-এর সুবিধা হলো এটি প্রায় কখনো এমন বাতিল করে না, আর বেশিরভাগ কোড কাজ চালিয়ে যায়।
দুটি পথই সৎ। অনেক অভিজ্ঞ প্রকৌশলী সরল conditional update আর constraint-কে যথেষ্ট মনে করেন। অন্যরা বলেন, ডিফল্ট Serializable রেখে retry কেন্দ্রীয়ভাবে লেখা ভালো, কারণ মানুষ race-এর সব পথ ভেবে উঠতে পারে না। দ্বিতীয় যুক্তিটি বিশেষ করে সত্য যেখানে নিয়মটি অনেক সারি জুড়ে।
মূল উপলব্ধি: ডিফল্ট level কোনো নিরাপত্তার রায় নয়, এটি একটি পুরোনো দর-কষাকষির ফল। আপনার কোন level-এ কোড চলছে, সেটি জানা আর তার জন্য নকশা করা আপনার দায়িত্ব।
প্র ০২এক লাখ মানুষ এক মিনিটে ৫০০টি পণ্যের জন্য ঝাঁপিয়ে পড়লে conditional update কি যথেষ্ট?
সঠিকতার দিক থেকে হ্যাঁ, oversell হবে না। সমস্যা গতির। একটি সারিতে সব লেখা এক লাইনে দাঁড়ায়, কারণ সারির lock একবারে একজনের। Transaction ৫ ms হলে সেই সারিতে সর্বোচ্চ প্রায় ২০০টি প্রতি সেকেন্ডে, আর ২০ ms হলে ৫০টি। এই সংখ্যা হিসাবের, মাপা নয়। এই গরম সারিকে অনেকে hot row বলেন। Connection pool ছোট হলে অপেক্ষার লাইন দ্রুত ভরে যায়, আর P2028-এর মতো ব্যর্থতা শুরু হয়।
উপায় কয়েকটি, প্রতিটির দাম আছে। Queue: সব request একটি সারিতে ফেলে একজন কর্মী একে একে সামলায়। ক্রেতা তাৎক্ষণিক উত্তর পান না, “আপনার অর্ডার প্রক্রিয়াধীন” দেখেন। পর্ব ৬২ দেখুন। ভাগ করা stock: ৫০০টি পণ্যকে ধরুন ১০টি সারিতে ৫০টি করে ভাগ করা হলো। Request এলোমেলো একটি সারিতে যায়, তাই lock-এর চাপ ভাগ হয়। কিন্তু কোনো ভাগ খালি হলে অন্য ভাগ খুঁজতে হয়, আর শেষের দিকে ভুল “শেষ” দেখানোর সম্ভাবনা থাকে। Redis-এ পাল্লা: একটি দ্রুত atomic counter সামনে থাকে, আর সেখানে জেতা request-ই ডেটাবেসে যায়। তখন দুই জায়গার মিল রাখা নতুন ঝামেলা, cache আর ডেটাবেসের ফাঁক পর্ব ৪৪-এ দেখবেন। সামনের দরজা: rate limit আর অপেক্ষার কক্ষ দিয়ে ডেটাবেসে পৌঁছানো request কমানো, যাতে বটগুলো সব না খেয়ে নেয়।
সতর্কতা: অনেক ছোট শপে এসব লাগে না। প্রতি সেকেন্ডে দশটি অর্ডারে conditional update-ই যথেষ্ট, আর ভবিষ্যতের ভয়ে জটিলতা আনা নিজেই একটি ভুল। আগে মাপুন, পরে বদলান।
মূল উপলব্ধি: সঠিকতা আর গতি আলাদা প্রশ্ন। আগে সরল উপায়ে সঠিক করুন, তারপর মাপা সংখ্যা দেখে ঠিক করুন কোথায় ভাগ, queue বা cache আসবে।
প্র ০৩দুই service-এর ডেটাবেসে একটি বড় transaction চালানো যায় না কেন? Two-phase commit তো আছে।
Two-phase commit বহুদিন ধরে আছে, আর কিছু ডেটাবেস ও message broker একে সমর্থনও করে। কিন্তু এর দাম বড়। Prepare ধাপের পরে প্রতিটি অংশগ্রহণকারী coordinator-এর উত্তরের অপেক্ষায় lock ধরে বসে থাকে। Coordinator অচল হলে তারা “in-doubt” অবস্থায় আটকে যায়। মানে একটি service-এর ব্যর্থতা অন্যের সারি আটকে রাখে, যে স্বাধীনতার জন্য service ভাগ করা হয়েছিল, তা-ই হারায়। এই পর্বের অংশ ৮-এর শিক্ষা এখানে আরও বড় আকারে: network call-এর ওপাশে lock ধরে থাকা।
তার ওপর, payment gateway বা SMS সেবার মতো বাইরের সিস্টেম আপনার coordinator-এর কথা শোনে না। টাকা কাটাকে “prepare” করে রাখার কোনো সাধারণ পথ নেই।
এই বাস্তবতার পুরোনো চিন্তা আছে। ১৯৮৭ সালে Garcia-Molina ও Salem “Sagas” প্রবন্ধে দেখান, দীর্ঘ কাজকে ছোট ছোট transaction-এ ভেঙে আর প্রতিটির উল্টো কাজ রেখে সামলানো যায়। ২০০৭ সালে Pat Helland-এর “Life beyond Distributed Transactions” বলে, বড় মাপের সিস্টেমে অ্যাপকে ধরে নিতে হবে যে সব জায়গায় একসাথে atomic কিছু নেই, আর বার্তা একাধিকবার আসতে পারে। Outbox আর idempotent consumer সেই চিন্তারই দৈনন্দিন রূপ।
দাম এখানেও আছে। মাঝের সময়ে সিস্টেম “আধা-অবস্থায়” থাকে: অর্ডার PENDING, টাকা নেওয়া হয়নি। অন্য কেউ সেই সময়ে দেখলে পুরোনো বা আধা ডেটা দেখবে। Saga-য় isolation নেই। তাই UI-কে সৎভাবে “প্রক্রিয়াধীন” দেখাতে হয়। আর ছোট মনোলিথে এসব অপ্রয়োজনীয়: এক ডেটাবেসে থাকলে সাধারণ transaction-ই অনেক ভালো, পর্ব ৪-এর monolith আলোচনা মনে করুন।
মূল উপলব্ধি: Service ভাগ করার সিদ্ধান্ত মানে transaction ছেড়ে দেওয়ার সিদ্ধান্ত। সেই দাম না বুঝে ভাগ করলে সরল সমস্যা জটিল হয়ে ওঠে।
প্র ০৪Stock কখন ধরবেন: কার্টে রাখার সময়, নাকি checkout-এ? আর সবসময়ই কি oversell খারাপ?
কার্টে রাখার সময় ধরলে ক্রেতা নিশ্চিন্ত, কিন্তু মানুষ কার্ট ভরে চলে যান। Stock অকারণে আটকে থাকে, আর কেউ ইচ্ছা করে বা bot দিয়ে সব পণ্য কার্টে তুলে বিক্রি বন্ধ করে দিতে পারে। এটি একধরনের সেবা-ব্যাহত আক্রমণ। Checkout-এ ধরলে এই ঝুঁকি কম, তবে ক্রেতা পণ্য বেছে এসে দেখতে পারেন শেষ হয়ে গেছে। তাই অনেক দোকান মাঝের পথ নেয়: “এখন পেমেন্টে” ধাপে ১০ থেকে ১৫ মিনিটের জন্য ধরে রাখা, একজনের একসাথে কতটি ধরা যাবে তার সীমা, আর মেয়াদ শেষে স্বয়ংক্রিয় মুক্তি।
মেয়াদের দৈর্ঘ্যও একটি সিদ্ধান্ত। ছোট হলে ধীর গতির mobile banking ব্যবহারকারীর টাকা কাটার পরে অর্ডার বাতিল হতে পারে, আর সেই ফেরতের ঝামেলা। বড় হলে stock আটকে থাকে। Gateway সাধারণত কত সময়ে উত্তর দেয়, তা মেপে ঠিক করা উচিত, অনুমানে নয়।
দ্বিতীয় প্রশ্নটি আরও মজার। Oversell সবসময় বাগ নয়, কখনো কখনো ব্যবসায়িক নীতি। বিমান সংস্থাগুলো বহুদিন ধরে ইচ্ছা করেই আসনের চেয়ে বেশি টিকিট বিক্রি করে, কারণ কিছু যাত্রী আসেন না, আর এর জন্য ক্ষতিপূরণের নিয়মও থাকে। কোনো কোনো দোকান “অগ্রিম অর্ডার” নেয়, আর পণ্য আসতে দেরি হলে জানিয়ে দেয়। Pat Helland আর অন্যরা এই ধারণাকে “apology-oriented” ভাবনা বলে আলোচনা করেছেন: কখনো কখনো ক্ষমা চাওয়া আর ক্ষতিপূরণ দেওয়া, কড়া নিশ্চয়তার চেয়ে সস্তা।
তবে পার্থক্য হলো সচেতনতা। “আমরা ২% বেশি নিই, আর এই নীতিতে ক্ষতিপূরণ দিই” একটি সিদ্ধান্ত। “কোডের ফাঁকে ১০টি অর্ডার ঢুকে গেছে” একটি দুর্ঘটনা। বাজারঘরের তাজা মাছে দ্বিতীয়টি মানে রাগী ক্রেতা।
মূল উপলব্ধি: Stock-এর নিয়ম কোডের প্রশ্ন হওয়ার আগে ব্যবসার প্রশ্ন। আগে নীতি ঠিক করুন, তারপর সেই নীতি কোডে কঠোরভাবে প্রয়োগ করুন।
প্র ০৫Service-এ tx পাঠানো কি repository-র আড়াল ভেঙে দেয়? আরও ভালো নকশা আছে?
পর্ব ২৯-এ service জানত না ডেটা কোথায় থাকে। এখন সে prisma.$transaction ডাকছে, আর Prisma.TransactionClient টাইপ repository-র সবখানে। সত্যিই আড়াল কিছুটা ফুটো হলো। পথ কয়েকটি।
Unit of Work: Martin Fowler-এর Patterns of Enterprise Application Architecture (২০০২)-এ এই ধাঁচ বর্ণিত। Service-কে একটি uow.run(async (repos) => …) দেওয়া হয়, ভেতরে repository-গুলো transaction-বাঁধা অবস্থায় আসে। Service Prisma-র নাম জানে না, test-এ নকল uow বসানো যায়। দাম হলো একটি বাড়তি স্তর।
Repository-তে বড় method: placeOrder পুরোটাই repository-তে। সরল, কিন্তু ব্যবসার নিয়ম SQL-এর পাশে চলে যায়, যা স্তর ভাগের উদ্দেশ্যের বিপরীত।
AsyncLocalStorage: Node-এর এই সুবিধায় transaction client লুকিয়ে context-এ রাখা যায়, repository নিজে তুলে নেয়। Signature পরিষ্কার থাকে, কিছু library এমন দেয়। কিন্তু ভেতরে জাদু: কোন কোড transaction-এ চলছে, তা পড়ে বোঝা যায় না, আর আগের tx-বনাম-prisma ভুলের মতো সমস্যা খুঁজে পাওয়া কঠিন হয়।
সবচেয়ে সরল: এই পর্বের মতো parameter দিয়ে পাঠানো। Explicit, টাইপ-সহ, আর ছোট দলে যথেষ্ট। আবার মনে রাখুন, Prisma 8-এর docs অনুযায়ী API-র নাম বদলাচ্ছে। সব কোডে $transaction ছড়িয়ে থাকলে সেই বদল বেশি জায়গা ছোঁবে, আর একটি ছোট withTransaction সুবিধা থাকলে বদলাতে হবে এক জায়গায়।
একটি সীমাও টানুন: Transaction-এর সীমা service-এ, কিন্তু সীমা যত বড় হবে তত ঝুঁকি। Nested service call-এর মধ্যে অজান্তে long transaction তৈরি হওয়া সাধারণ ভুল। এজন্য অনেক দল নিয়ম করে: একটি transaction একটি use case-এ, আর তার ভেতরে শুধু ডেটাবেস।
মূল উপলব্ধি: নিখুঁত আড়াল নেই। সবচেয়ে কম জাদুর এমন পথ বেছে নিন, যাতে পড়েই বোঝা যায় কোন কাজগুলো একসাথে।
অনুশীলন
হিসাব, ফল অনুমান, কোড বিচার আর নিজে চালিয়ে দেখা।
-
হিসাব: একটি জনপ্রিয় পণ্যের সারিতে প্রতিটি transaction সারিটি ২০০ ms ধরে রাখে, কারণ ভেতরে একটি বাইরের call আছে। ৩০০ জন একসাথে কিনছেন, সবার জন্য পণ্য আছে। (ক) কত সময়ে সবাই শেষ হবেন? (খ) বাইরের call সরিয়ে lock ৫ ms করলে? (গ) প্রতি সেকেন্ডের সীমা দুই ক্ষেত্রে কত?
এক সারিতে কাজ একে একে চলে, তাই মোট সময় ≈ সংখ্যা × ধরে রাখার সময়।
(ক) ৩০০ × ০.২ s = ৬০ s. (খ) ৩০০ × ০.০০৫ s = ১.৫ s. (গ) ১ ÷ ০.২ = ৫/s, আর ১ ÷ ০.০০৫ = ২০০/s, মানে ৪০ গুণ।
এটি উপরের সীমার আদর্শ হিসাব। বাস্তবে connection অপেক্ষা আর
maxWait-এর জন্য (ক)-তে অনেকে শুরুই করতে পারবেন না, এই পর্বের pool-এর run-এ যেমন ১১টিP2028এলো। -
ফল অনুমান করুন: Stock 3. A আর B দুজনেই Read Committed-এ ২টি করে চাইছে, আর ক্রমটি এমন: A পড়ে, B পড়ে, A লেখে ও commit করে, B লেখে ও commit করে। তিন রকম কোডে শেষে stock আর অর্ডারের সংখ্যা কত? (১)
stock: p.stock - 2(২) পড়ে যাচাই, তারপর{ decrement: 2 }(৩) শুধুupdateManywithstock: { gte: 2 }(১) দুজনেই 3 পড়েছে, দুজনেই 1 লেখে। Stock 1, অর্ডার ২, বিক্রি ৪টি, অথচ stock বলছে ২টি গেছে। Lost update.
(২) দুজনেই যাচাই পাস, দুটি decrement. 3 − 2 − 2 = Stock −1, অর্ডার ২. CHECK constraint থাকলে B ভাঙত।
(৩) A: 3 ≥ 2, stock 1, count 1. B: নতুন মান 1 ≥ 2 মিথ্যা, count 0. Stock 1, অর্ডার ১. একমাত্র সঠিক ফল।
-
নকশা বিচার: AI নিচের কোড দিল। অন্তত চারটি সমস্যা ধরুন, আর কীভাবে লিখবেন বলুন।
await prisma.$transaction(async (tx) => { for (const item of cart.items) { const p = await tx.product.findUnique({ where: { id: item.id } }); if (p.stock >= item.qty) { await tx.product.update({ where: { id: item.id }, data: { stock: p.stock - item.qty } }); } } const pay = await gateway.charge(cart.totalPaisa); await prisma.order.create({ data: { buyer, status: pay.ok ? 'PAID' : 'FAILED' } }); await sendEmail(buyer); }, { timeout: 60000 });- পড়ে যাচাই, তারপর লেখা: Read Committed-এ oversell.
updateManywithgte, আরcount === 0হলে থামা। - Stock না থাকলে চুপচাপ এগোয়: শর্ত মিথ্যা হলে error নেই, অর্ডার তবু হয়।
- Cart-এর ক্রম: সাজানো নয়, তাই deadlock হতে পারে। Id অনুযায়ী সাজান।
- Gateway আর email transaction-এর ভেতরে: lock আর connection ধরা থাকে, আর rollback টাকা ফেরায় না। Reserve, commit, charge, তারপর confirm বা release, আর email outbox দিয়ে।
prisma.order.create:txনয়, তাই অর্ডার transaction-এর বাইরে।timeout: 60000: উপসর্গ ঢাকা। Transaction ছোট করলে এটি লাগেই না।- Idempotency নেই: দুবার ক্লিকে দুবার টাকা। পর্ব ৩৩-এর key আর unique constraint.
- পড়ে যাচাই, তারপর লেখা: Read Committed-এ oversell.
-
নিজে চালান: একটি ছোট Express আর Prisma প্রজেক্টে
embedded-postgresদিয়ে (অথবা আপনার নিজের PostgreSQL-এ) এই পর্বেরtxReadroute বানান। ২০টি concurrentfetchপাঠান। তারপরPrismaPg-এmax: 1দিয়ে আবার চালান। কী হবে আগে অনুমান করুন। Oversell কি সমাধান হলো?লেখকের একটি run-এ
max: 1-এsold 1..1, oversell ০ বার; ডিফল্ট ১০-এ10..10. আপনার সংখ্যা আলাদা হতে পারে, তবে ধরন একই হওয়ার কথা।এটি সমাধান নয়। এক connection মানে transaction-গুলো একে একে চলছে, মানে পুরো ডেটাবেস কাজ এক লাইনে, আর ২টি server instance চালালেই আবার race. আর transaction ছাড়া naive কোড
max: 1-এও মার খেতে পারে, কারণ তার প্রতিটি query আলাদাভাবে connection নেয়। এই অনুশীলনের শিক্ষা হলো test পরিবেশের pool আর production-এর pool এক না হলে বাগ লুকিয়ে থাকে।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমঅনলাইন শপে একটি অর্ডারের নিরাপদ ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- Transaction-এ শর্তসহ stock কমানো আর PENDING অর্ডার লেখা
- Commit করে transaction শেষ করা
- Transaction-এর বাইরে payment gateway-তে টাকা নেওয়া
- ফল অনুযায়ী নতুন transaction-এ confirm বা release
- Confirm-এর সাথে লেখা outbox event relay দিয়ে পাঠানো
PostgreSQL-এর ডিফল্ট Read Committed-এ একটি transaction-এর ভেতরে আগে SELECT stock, যাচাই, তারপর UPDATE. ১ stock-এ দুই ক্রেতা একসাথে এলে কী হতে পারে?
- Transaction থাকায় oversell অসম্ভব
- দুজনেই stock 1 পড়ে দুটি অর্ডার হতে পারে
- PostgreSQL একটি transaction-কে serialization error দিয়ে বাতিল করবেই
- SELECT দুটি deadlock করবে
Prisma-তে stock-এর race ঠেকানোর সবচেয়ে সরল পথ কোনটি, যেখানে একটি সারির সংখ্যাই বিষয়?
- আগে
findUnique, তারপরupdate - যাচাই আলাদা রেখে শুধু
decrementব্যবহার updateMany-তেstock: { gte: qty }শর্ত, তারপরcountদেখা- Transaction-এর
timeoutবাড়ানো
Prisma 7-এ Serializable transaction বাতিল হলে বা deadlock হলে কোন error code আসে, আর করণীয় কী?
P2034; পুরো transaction অ্যাপ থেকে সীমিতবার retryP2002; শেষ লাইনটি আবার চালানোP2028; timeout বাড়ানো- কোনো code নয়, Prisma নিজেই retry করে
একটি transaction-এ ক্রেতা A আগে চাল, পরে ডালের সারি আটকায়, আর B আগে ডাল, পরে চাল। প্রায়ই deadlock হচ্ছে। সবচেয়ে ভালো মূল সমাধান কোনটি?
- Transaction-এর
timeoutবাড়ানো - Connection pool বড় করা
- শুধু retry রাখা, কারণ deadlock স্বাভাবিক
- কার্টের পণ্য id অনুযায়ী সাজিয়ে সবার জন্য একই ক্রমে lock নেওয়া
অর্ডারের সময় payment gateway-কে database transaction-এর ভেতরে ডাকা খারাপ কেন?
- Prisma এটি নিষিদ্ধ করেছে, কোড চলবেই না
- Lock আর connection দীর্ঘ সময় আটকে থাকে, আর rollback টাকা ফেরায় না
- Gateway transaction-এর ভেতরে ধীর হয়ে যায়
- এতে ডেটাবেসের ডেটা অবশ্যই নষ্ট হয়
Transactional outbox ব্যবহারে relay একটি event পাঠিয়ে publishedAt বসানোর আগে crash করল। কী হবে, আর consumer-এর কী করা দরকার?
- Event হারিয়ে যাবে; consumer-এর কিছু করার নেই
- Event ঠিক একবারই পৌঁছাবে, কোনো বাড়তি কাজ নেই
- Event আবার যেতে পারে; consumer-কে idempotent হতে হবে
- অর্ডারের transaction rollback হয়ে যাবে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- পড়ে যাচাই, তারপর লেখা race-এর আসল রূপ; transaction একা Read Committed-এ এটি বন্ধ করে না।
- প্রথম পছন্দ conditional update (
updateManyআরcount), দুবার ঠেকাতে unique constraint, শেষ পাহারায় CHECK. - জটিল সিদ্ধান্তে
FOR UPDATE, কম সংঘাতে version column, অনেক সারির নিয়মে Serializable +P2034retry. - Deadlock-এর ওষুধ একই ক্রমে lock; transaction ছোট, ভেতরে network নয়।
- অর্ডার: reserve, pay, confirm বা release. Service পেরোলে outbox আর idempotent consumer.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৪ · Cache — Redis, cache-aside আর invalidationপরবর্তী পর্বডেটা নিরাপদ হলো, এবার বারবার পড়া পাতা Redis দিয়ে দ্রুত করবেন।
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ।
- পর্ব ২৯ · Backend-এর গঠনআগের ভিত্তিController, service আর repository, যেখান থেকে transaction-এর প্রশ্নটি এসেছে।
- পর্ব ৩৩ · Status code আর idempotencyআগের ভিত্তিIdempotency key, 409 আর নিরাপদ retry.
- Transaction ও ACIDতত্ত্বAtomicity, consistency, isolation আর durability.
- MVCC আর isolationগভীরে যানAnomaly, snapshot আর SSI.
- Distributed transactionপাশের ট্র্যাক2PC, saga আর outbox-এর পূর্ণ আলোচনা।