Next.js
۹ دقیقه مطالعهجلوگیری از Race Condition در فروشگاه با Prisma و PostgreSQL
با یک جریان واقعی خرید، بهروزرسانی اتمیک موجودی، constraint دیتابیس، تراکنش، idempotency و callback پرداخت را بررسی کن.

ممکن است جریان checkout در تست تککاربره درست به نظر برسد، اما زیر درخواستهای همزمان موجودی را بیش از حد بفروشد. راهکار این است که قواعد حیاتی را با عملیات اتمیک و constraintهای دیتابیس تضمین کنیم، نه با توالی خواندن و نوشتن در کد برنامه.
ممکن است جریان checkout در تست تککاربره درست به نظر برسد، اما زیر درخواستهای همزمان موجودی را بیش از حد بفروشد. راهکار این است که قواعد حیاتی را با عملیات اتمیک و constraintهای دیتابیس تضمین کنیم، نه با توالی خواندن و نوشتن در کد برنامه.
درک Race Condition
فرض کن از یک کالا فقط یک عدد باقی مانده است. دو درخواست موجودی را میخوانند، هر دو نتیجه میگیرند یک عدد موجود است و هر دو سفارش میسازند. مشکل از آنجاست که خواندن و کمکردن موجودی دو عملیات جدا هستند و تضمین همزمانی مشترکی ندارند.
اتمیککردن بهروزرسانی
import { Prisma } from '@prisma/client';
export async function reserveStock(productId: string, quantity: number) {
if (!Number.isInteger(quantity) || quantity < 1) throw new Error('Invalid quantity');
return prisma.$transaction(async (tx) => {
const result = await tx.product.updateMany({
where: { id: productId, stock: { gte: quantity } },
data: { stock: { decrement: quantity } },
});
if (result.count !== 1) throw new Error('Insufficient stock');
return tx.order.create({
data: { status: 'PENDING', items: { create: { productId, quantity } } },
});
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
}از بهروزرسانی اتمیک شرطی استفاده کن: موجودی فقط وقتی کم شود که هنوز بزرگتر یا مساوی تعداد درخواستی باشد. تعداد ردیفهای تغییرکرده را بررسی کن؛ صفر یعنی رزرو شکست خورده است. رزرو موجودی و ساخت سفارش را در یک تراکنش قرار بده تا در صورت خطا هر دو rollback شوند.
ایزولیشن و retry
سطح Serializable میتواند تراکنشهای متعارض را تشخیص دهد، اما ممکن است یکی را لغو کند. کد production باید خطای serialization را با سیاست retry محدود مدیریت کند. سطح ایزولیشن مناسب به مدل داده و دیتابیس بستگی دارد؛ برای قوانین سادهتر، بهروزرسانی شرطی اتمیک و constraintها شاید کافی باشند.
پرداخت را بیرون تراکنش طولانی نگه دار
تا وقتی منتظر پاسخ درگاه پرداخت هستی، تراکنش دیتابیس را باز نگه ندار. ابتدا سفارش PENDING بساز و پرداخت را آغاز کن؛ سپس callback درگاه را سمت سرور اعتبارسنجی کن. تا قبل از بررسی امضا، مبلغ، ارز و شناسه سفارش، callback را ورودی قابل اعتماد فرض نکن.
ایمنکردن callbackهای تکراری
export async function confirmPayment(providerEventId: string, orderId: string) {
return prisma.$transaction(async (tx) => {
// providerEventId should have a UNIQUE constraint in the database.
const event = await tx.paymentEvent.create({
data: { providerEventId, orderId },
});
await tx.order.update({
where: { id: event.orderId },
data: { status: 'PAID' },
});
});
}درگاه ممکن است callback را دوباره ارسال کند. شناسه یکتای رویداد یا کلید idempotency را ذخیره کن و پردازش تکراری را بیخطر بساز. unique constraint از الگوی «اول بررسی کن، بعد درج کن» قویتر است؛ چون دو درخواست همزمان ممکن است هر دو بررسی را با موفقیت رد کنند.
برنامهریزی برای خطاهای نیمهکاره
مشخص کن اگر پرداخت موفق شد ولی سرور پیش از ثبت نتیجه timeout کرد چه میشود؛ یا اگر پرداخت بعد از رزرو موجودی ناموفق بود چه اتفاقی میافتد. طراحی مقاوم ممکن است به انقضای رزرو، job تطبیق وضعیت و انتقالهای مشخص مثل PENDING، PAID، FAILED و CANCELLED نیاز داشته باشد.
تستکردن invariantها
خرید همزمان آخرین کالا، callback تکراری، timeout درگاه، retry تراکنش و شکست ساخت سفارش را تست کن. بعد از هر تست invariantهای دیتابیس را بررسی کن، نه فقط status HTTP را که مرورگر دریافت میکند.