برای مهار شرایط رقابتی مثل دوبار خرج کردن، کم و زیاد شدن ناگهانی موجودی یا به‌روزرسانی گم‌شده، باید تراکنش‌ها را درست قفل کنید. راه حل عملی در Django استفاده همزمان از atomic و select_for_update است: atomic مرز تراکنش را روشن می‌کند و select_for_update ردیف‌های در معرض تغییر را تا پایان تراکنش قفل می‌کند.

چه مسئله‌ای را حل می‌کنیم و چرا قفل لازم است؟

وقتی چند درخواست یا worker همزمان یک رکورد را می‌خوانند و بعد تغییر می‌دهند، بدون قفل ممکن است:

  • به‌روزرسانی گم‌شده (Lost Update): تغییر دیرتر تغییر قبلی را می‌پوشاند.
  • دوبار خرج کردن (Double-Spend): دو تراکنش همزمان از یک منبع کسر می‌کنند.
  • ناسازگاری خواندن: تصمیم روی داده‌ای گرفته می‌شود که بلافاصله بعد از آن عوض می‌شود.

atomic تضمین می‌کند مجموعه عملیات یا کامل انجام شود یا هیچ. select_for_update ردیف‌ها را با قفل ردیفی (row-level lock) نگه می‌دارد تا دیگری نتواند همزمان آنها را تغییر دهد.

دو ابزار کلیدی در Django

transaction.atomic()

atomic یک بلوک تراکنشی می‌سازد. هر خطایی که از بلوک خارج شود باعث rollback می‌شود. بلوک‌ها می‌توانند تو در تو باشند؛ در این حالت Django از savepoint استفاده می‌کند.

  • حداقل کنید چه مدت داخل بلوک هستید؛ عملیات کند مثل شبکه را بیرون بگذارید.
  • اگر استثنا را داخل بلوک می‌گیرید و می‌خواهید ادامه دهید، مطمئن شوید rollback مورد نیاز اعمال می‌شود یا استثنا را دوباره بالا بدهید.

select_for_update()

این کوئری‌ست که ردیف‌های انتخاب‌شده را تا پایان تراکنش قفل می‌کند. نکات مهم:

  • استفاده از select_for_update خارج از atomic عملا بی‌اثر است، چون قفل بعد از همان statement آزاد می‌شود.
  • گزینه‌های nowait=True و skip_locked=True در صورت پشتیبانی پایگاه داده رفتار قفل‌گیری را کنترل می‌کنند: اولی بلافاصله خطا می‌دهد اگر نتواند قفل بگیرد؛ دومی ردیف قفل‌شده را رد می‌کند.
  • در سناریوهای چند ردیفی، ترتیب قفل‌گیری ثابت (مانند مرتب‌سازی بر اساس pk) برای پیشگیری از بن‌بست حیاتی است.

سناریو ۱: انتقال وجه بین دو حساب با قفل‌گیری منظم

هدف: اگر کاربر A به B پول می‌فرستد، هنگام کاهش و افزایش موجودی هیچ تراکنش دیگری نتواند همان ردیف‌ها را دستکاری کند.

from decimal import Decimal
import time
from django.db import transaction, DatabaseError
from django.db.models import F
from django.core.exceptions import ValidationError
from django.db import models

class Account(models.Model):
    owner = models.CharField(max_length=100)
    balance = models.DecimalField(max_digits=12, decimal_places=2)
    updated_at = models.DateTimeField(auto_now=True)

def transfer_funds(from_id: int, to_id: int, amount: Decimal, max_retries: int = 3) -> None:
    if amount <= 0:
        raise ValidationError("مبلغ نامعتبر است.")

    # برای کاهش احتمال بن‌بست، ترتیب قفل‌گیری را ثابت می‌کنیم
    lock_order = sorted([from_id, to_id])

    for attempt in range(max_retries):
        try:
            with transaction.atomic():
                # قفل ردیف‌های دخیل تا پایان تراکنش
                accounts = (Account.objects
                            .select_for_update()
                            .filter(pk__in=lock_order)
                            .in_bulk())

                src = accounts.get(from_id)
                dst = accounts.get(to_id)

                if not src or not dst:
                    raise ValidationError("حساب مبدا یا مقصد پیدا نشد.")

                if src.balance < amount:
                    raise ValidationError("موجودی کافی نیست.")

                # به‌روزرسانی‌ها با F expression تا در سطح پایگاه داده اتمی باشند
                Account.objects.filter(pk=src.pk).update(balance=F('balance') - amount)
                Account.objects.filter(pk=dst.pk).update(balance=F('balance') + amount)
            return  # موفق
        except DatabaseError as e:
            # برخورد با بن‌بست یا خطاهای لحظه‌ای؛ تلاش مجدد با backoff نمایی
            if attempt == max_retries - 1:
                raise
            time.sleep(0.05 * (2 ** attempt))

چرا این الگو امن است؟ چون:

  • atomic تضمین می‌کند هر دو به‌روزرسانی با هم انجام شوند یا هیچ‌کدام.
  • select_for_update روی هر دو ردیف اجرا می‌شود؛ همزمان هیچ تراکنش دیگری نمی‌تواند این حساب‌ها را تغییر دهد.
  • ترتیب ثابت در قفل‌گیری (مرتب بر اساس pk) از بن‌بست‌های متقابل جلوگیری می‌کند.
  • F expression به پایگاه داده می‌گوید «balance = balance ± amount» و از خواندن-نوشتن نرم‌افزاری ناامن جلوگیری می‌کند.

چگونه نتیجه را راستی‌آزمایی کنیم؟

  1. دو درخواست همزمان برای انتقال از یک حساب به دو مقصد مختلف بفرستید.
  2. مجموع موجودی حساب‌ها قبل و بعد یکسان بماند و هیچ مبلغی «گم» نشود.
  3. در صورت شبیه‌سازی فشار، خطاهای موقتی (مانند بن‌بست) با تلاش مجدد رفع شوند.

الگوی جایگزین: به‌روزرسانی شرطی بدون قفل صریح (خوش‌بینانه)

اگر تنها شرط شما «کم‌نشدن موجودی زیر صفر» است، می‌توانید با یک UPDATE شرطی و بدون select_for_update کار را جلو ببرید. این رویکرد قفل‌گیری صریح ندارد و معمولا ارزان‌تر است، اما در سناریوهای پیچیده‌تر به دقت بیشتری نیاز دارد.

from django.db import transaction
from django.db.models import F

def debit_then_credit(src_id: int, dst_id: int, amount: Decimal) -> None:
    if amount <= 0:
        raise ValidationError("مبلغ نامعتبر است.")

    with transaction.atomic():
        # کسر موجودی فقط اگر کافی باشد
        updated = (Account.objects
                   .filter(pk=src_id, balance__gte=amount)
                   .update(balance=F('balance') - amount))
        if updated == 0:
            raise ValidationError("موجودی کافی نیست یا رکورد پیدا نشد.")

        # افزایش موجودی مقصد
        Account.objects.filter(pk=dst_id).update(balance=F('balance') + amount)

مزیت: سرعت و سادگی. محدودیت: اگر لازم است چند ردیف را با هم قفل کنید یا تصمیم‌های پیچیده بر اساس داده فعلی بگیرید، select_for_update مطمئن‌تر است. همچنین وقتی همزمان روی مبدا و مقصد عملیات دیگری در جریان است، قفل‌گیری صریح ریسک بن‌بست یا ناسازگاری را کاهش می‌دهد (به شرط ترتیب قفل‌گیری ثابت).

سناریو ۲: رزرو موجودی با صف همزمان و skip_locked/nowait

برای workerهایی که روی یک صف مشترک کار می‌کنند، می‌توانید ردیف‌های آزاد را قفل و پردازش کنید، و ردیف‌های قفل‌شده را موقتا کنار بگذارید تا بن‌بست یا انتظار طولانی نداشته باشید. این گزینه‌ها فقط در صورت پشتیبانی پایگاه داده کاربرد دارند.

from django.db import transaction

class Inventory(models.Model):
    sku = models.CharField(max_length=50, unique=True)
    available = models.IntegerField()

def reserve_one_available(sku: str) -> bool:
    with transaction.atomic():
        item = (Inventory.objects
                .select_for_update(skip_locked=True)  # یا nowait=True برای شکست سریع
                .filter(sku=sku, available__gt=0)
                .first())
        if not item:
            return False

        # اطمینان از عدم منفی شدن
        updated = (Inventory.objects
                   .filter(pk=item.pk, available__gt=0)
                   .update(available=F('available') - 1))
        return updated == 1

با skip_locked، اگر ردیفی قبلا قفل باشد، کوئری آن را نادیده می‌گیرد و روی بقیه کار می‌کند. این الگو برای پردازش موازی سفارش‌ها یا رزروها مناسب است.

نکته‌های ضروری و خطاهای رایج

  • select_for_update را همیشه داخل atomic استفاده کنید؛ خارج از تراکنش قفل نگه داشته نمی‌شود.
  • تراکنش‌ها را کوتاه نگه دارید؛ ورود/خروج شبکه، محاسبات سنگین یا لاگ‌گیری زیاد را بیرون بلوک انجام دهید.
  • ترتیب قفل‌گیری یکنواخت باشد (مثلا بر اساس pk). این کار احتمال بن‌بست را به شدت کم می‌کند.
  • در به‌روزرسانی‌های ساده از F expression و شرط‌ها استفاده کنید؛ این کار اغلب نیاز به قفل صریح را کم می‌کند.
  • گزینه‌های nowait/skip_locked به پشتیبانی دیتابیس وابسته‌اند. در صورت عدم پشتیبانی، ممکن است نادیده گرفته شوند یا خطا بدهند؛ رفتار محیط خود را بررسی کنید.
  • استثناها را بی‌هدف نگیرید. اگر داخل atomic خطایی رخ دهد و شما آن را ببلعید، تراکنش ممکن است برای ادامه نامعتبر شود. بهترین کار این استثنا را لاگ و بازپرتاب کنید یا مسیر rollback را شفاف مدیریت کنید.
  • QuerySetها تنبل‌اند. مطمئن شوید select_for_update واقعا قبل از به‌روزرسانی ارزیابی شده است (مثلا با get/first/in_bulk/iteration).
  • از ترکیب خواندن خارج از تراکنش و نوشتن داخل تراکنش روی همان داده پرهیز کنید؛ تصمیم را بر اساس داده قفل‌شده بگیرید.

معیار انتخاب: چه زمانی قفل صریح بگیریم؟

  • به قفل صریح (select_for_update) نیاز دارید اگر: چند ردیف به هم وابسته را با هم تغییر می‌دهید؛ باید تضمین کنید آنچه می‌خوانید تا پایان تراکنش عوض نشود؛ یا با عملیات رقابتی روی همان ردیف‌ها مواجهید.
  • می‌توانید بدون قفل صریح جلو بروید اگر: تغییر شما یک UPDATE ساده با شرط مناسب و F expression است و تصمیم‌گیری پیچیده مبتنی بر مقدار فعلی نیاز ندارید.

بررسی نتیجه و پایش در محیط واقعی

  1. تست همزمانی بنویسید: دو یا چند thread/process همزمان همان عملیات را انجام دهند؛ انتظار شما از نتیجه باید قطعی باشد (مثلا جمع موجودی ثابت بماند).
  2. در لاگ، زمان حضور داخل atomic و نرخ خطاهای تراکنشی (مثل بن‌بست) را رصد کنید و در صورت نیاز backoff و retry را تنظیم کنید.
  3. Load test سبک اجرا کنید؛ اگر صف انتظار قفل‌ها زیاد شد، بلوک‌های تراکنش را کوچک‌تر و ایندکس‌ها را بررسی کنید.

گام بعدی

الگوی قفل‌گیری را برای مدل‌های کلیدی پروژه خود مستند کنید: کجا قفل می‌گیرید، ترتیب قفل چیست و در صورت شکست قفل چه می‌کنید. سپس با تست‌های همزمانی خودکار مطمئن شوید این قراردادها شکسته نمی‌شوند.