فرض کنید فقط یک عدد از یه محصول توی انبار باقی مونده و دقیقاً همون لحظه دو کاربر روی دکمه خرید میزنن. Request اول موجودی رو میخونه و میبینه عدد ۱ هست؛ Request دوم هم چند میلی‌ثانیه بعد همون مقدار ۱ رو میبینه. هر دو نتیجه میگیرن محصول موجوده، هر دو سفارش رو ثبت میکنن و ناگهان یه محصولی که فقط یه عدد ازش داشتید، به دو نفر فروخته شده!

این مشکل از اون باگ‌هاییه که روی سیستم خودتون شاید هیچ‌وقت نبینید، ولی وقتی سایت واقعی ترافیک بگیره خودش رو نشون میده. اسم این اتفاق Race Condition هست؛ یعنی چند عملیات تقریباً همزمان دارن روی یه داده مشترک تصمیم میگیرن و نتیجه نهایی به ترتیب اجرای اون‌ها وابسته میشه.

اینجاست که select_for_update() در Django به درد میخوره.

مشکل از Query معمولی شروع میشه

وقتی با ORM جنگو یه Product رو میگیریم، معمولاً فقط مقدار فعلی رکورد از دیتابیس خونده میشه. اگه دو Request همزمان این کار رو انجام بدن، هیچ تضمینی وجود نداره که Request دوم صبر کنه تا اولی تغییراتش رو تموم کنه.

مثلاً هر دو Request موجودی ۱ رو میبینن، هر دو شرط stock > 0 رو پاس میکنن و بعد هر کدوم موجودی رو کم میکنن.

مشکل اینه که هر Request فقط چیزی رو میبینه که در لحظه خوندن داخل دیتابیس وجود داشته، نه اینکه یه Request دیگه همزمان داره همون رکورد رو تغییر میده.

select_for_update دقیقاً چه کاری میکنه؟

select_for_update() رکورد انتخاب‌شده رو برای مدت Transaction قفل میکنه. یعنی وقتی Request اول محصول رو میگیره، Request دوم نمیتونه همون رکورد رو برای Update در اختیار بگیره تا Transaction اول تموم بشه.

وقتی Request اول موجودی رو از ۱ به ۰ تغییر داد و Transaction تموم شد، Request دوم اجازه ادامه پیدا میکنه. این بار وقتی محصول رو میخونه، موجودی ۰ هست و سفارش دوم رد میشه.

یه نمونه ساده:

from django.db import transaction
from django.http import JsonResponse
from .models import Product, Order

def buy_product(request, product_id):
    with transaction.atomic():
        product = (
            Product.objects
            .select_for_update()
            .get(id=product_id)
        )

        if product.stock <= 0:
            return JsonResponse(
                {"error": "Product is out of stock"},
                status=400
            )

        product.stock -= 1
        product.save(update_fields=["stock"])

        Order.objects.create(
            user=request.user,
            product=product
        )

    return JsonResponse({"success": True})

نکته مهم اینه که select_for_update() باید داخل Transaction استفاده بشه. قفل رکورد تا پایان transaction.atomic() نگه داشته میشه و بعد از Commit یا Rollback آزاد میشه.

چرا transaction.atomic به تنهایی کافی نیست؟

ممکنه بگید ما که قبلاً transaction.atomic() داشتیم، پس چرا باز به select_for_update() نیاز داریم؟

Atomic بودن تضمین میکنه عملیات داخل Transaction یا کامل انجام بشه یا Rollback بشه، ولی به تنهایی جلوی این رو نمیگیره که دو Transaction همزمان مقدار یکسانی رو بخونن.

یعنی دو Request میتونن هر دو موجودی ۱ رو بخونن و بعد هرکدوم تغییر خودشون رو اعمال کنن.

select_for_update() یه مرحله اضافه انجام میده: رکورد رو قفل میکنه تا بقیه Transactionهایی که میخوان همون رکورد رو تغییر بدن، منتظر بمونن.

فقط فروشگاه نیست

این مشکل تقریباً هرجایی که یه منبع محدود داریم دیده میشه. مثلاً دو نفر میخوان آخرین صندلی یه پرواز رو رزرو کنن، دو درخواست همزمان از یه کیف پول پول کم میکنن یا چند Worker میخوان وضعیت یه Job مشترک رو تغییر بدن.

در تمام این سناریوها یه الگوی مشترک داریم: اول مقدار رو میخونیم، بر اساس اون تصمیم میگیریم و بعد تغییرش میدیم.

هرجا چنین الگویی وجود داره و چند Request میتونن همزمان اجرا بشن، Race Condition رو باید جدی بگیرید.

Lock هم رایگان نیست

بعد از دیدن قدرت select_for_update() ممکنه وسوسه بشید روی هر Query یه Lock بندازید، ولی این کار میتونه Performance رو خراب کنه.

وقتی یه رکورد Lock شده، Requestهای دیگه ممکنه مجبور بشن منتظر بمونن. اگه Transaction شما طولانی باشه، مثلاً وسطش به API خارجی Request بزنید یا پردازش سنگین انجام بدید، Lock هم مدت بیشتری باقی میمونه و صف Requestها بزرگ‌تر میشه.

پس Transaction باید تا جای ممکن کوتاه باشه: رکورد رو Lock کنید، بررسی لازم رو انجام بدید، دیتابیس رو Update کنید و سریع Transaction رو ببندید.

Deadlock رو هم دست‌کم نگیرید

وقتی چند Transaction روی چند رکورد Lock میگیرن، در طراحی‌های پیچیده‌تر احتمال Deadlock هم وجود داره. یعنی Transaction اول منتظر رکوردیه که Transaction دوم قفل کرده و Transaction دوم هم منتظر رکوردیه که اولی در اختیار داره.

دیتابیس معمولاً یکی از Transactionها رو متوقف میکنه تا این وضعیت حل بشه، ولی بهتره ترتیب گرفتن Lockها در کد قابل پیش‌بینی و یکسان باشه.

برای سناریوی ساده موجودی محصول معمولاً داستان خیلی پیچیده نیست، ولی در سیستم‌های مالی یا رزرو چندمرحله‌ای باید به این موضوع هم توجه کنید.

جمع بندی

Race Condition از اون باگ‌هاییه که کد شما ممکنه ماه‌ها بدون مشکل کار کنه و بعد فقط با رسیدن دو Request در زمان نامناسب خودش رو نشون بده. تو سیستم‌هایی مثل فروشگاه، رزرو و کیف پول، نتیجه چنین باگی میتونه فروش بیش از موجودی یا خراب شدن اطلاعات مالی باشه.

select_for_update() در کنار transaction.atomic() کمک میکنه رکورد حساس برای مدت کوتاهی قفل بشه و Requestهای دیگه قبل از تصمیم‌گیری، نتیجه آخرین Transaction رو ببینن.

فقط یادتون باشه Lock رو جایی استفاده کنید که واقعاً رقابت روی داده وجود داره و Transaction رو هم تا جای ممکن کوتاه نگه دارید.

وقتی فقط یه کالا مونده، سیستم باید مطمئن بشه واقعاً فقط یه نفر میتونه صاحبش بشه!