فصل ۳: QuerySet حرفه‌ای — سریع، درست و بدون N+1

تراکنش، select_for_update و Manager/QuerySet سفارشی

یا همه، یا هیچ

ثبت یک سفارش چند مرحله دارد: ساخت سفارش، ساخت اقلام، کم کردن موجودی انبار. اگر مرحله‌ی سوم خطا بدهد، نباید سفارشی نیمه‌کاره باقی بماند. transaction.atomic همه‌ی این مراحل را یک واحد می‌کند: یا همه commit می‌شوند یا همه rollback.

کم کردن موجودی بدون فروش بیش از انبار

دو مشتری هم‌زمان آخرین فرش یک طرح را سفارش می‌دهند. هر دو موجودی را «۱» می‌خوانند و هر دو ثبت می‌کنند. select_for_update ردیف‌ها را تا پایان تراکنش قفل می‌کند تا درخواست دوم منتظر بماند و موجودی به‌روز را ببیند:

# apps/orders/services.py
from django.db import transaction
from django.db.models import F

from apps.catalog.models import Carpet
from .models import Order, OrderItem
from .tasks import send_order_sms


class OutOfStock(Exception):
    pass


def place_order(customer, items):
    # items: [{"carpet_id": 3, "quantity": 2}, ...]
    ids = [i["carpet_id"] for i in items]
    with transaction.atomic():
        carpets = Carpet.objects.select_for_update().in_bulk(ids)
        order = Order.objects.create(customer=customer)
        total = 0
        for item in items:
            carpet = carpets[item["carpet_id"]]
            if carpet.stock < item["quantity"]:
                raise OutOfStock(f"موجودی طرح {carpet.name} کافی نیست.")
            OrderItem.objects.create(order=order, carpet=carpet,
                                     quantity=item["quantity"], unit_price=carpet.price)
            Carpet.objects.filter(pk=carpet.pk).update(stock=F("stock") - item["quantity"])
            total += carpet.price * item["quantity"]
        order.total = total
        order.save(update_fields=["total"])
        transaction.on_commit(lambda: send_order_sms.delay(order.pk))
    return order

اگر OutOfStock بالا برود، همه‌چیز rollback می‌شود و پیامکی هم نمی‌رود، چون on_commit فقط بعد از commit موفق اجرا می‌شود.

QuerySet و Manager سفارشی

فیلترهای تکراری را یک بار و با نام معنادار تعریف کنید تا در View، ادمین، API و تست یکسان باشند:

class OrderQuerySet(models.QuerySet):
    def pending(self):
        return self.filter(status=self.model.Status.PENDING)

    def for_user(self, user):
        return self.filter(customer__user=user)

    def with_details(self):
        return self.select_related("customer").prefetch_related("items__carpet")


class Order(models.Model):
    ...
    objects = OrderQuerySet.as_manager()

# استفاده: زنجیرپذیر
Order.objects.for_user(request.user).pending().with_details()

نکته‌هایی که کمتر کسی می‌داند

  • select_for_update() بیرون از atomic خطای TransactionManagementError می‌دهد؛ و روی SQLite هیچ قفلی نمی‌گذارد، پس رفتار هم‌زمانی را فقط روی PostgreSQL یا MySQL آزمایش کنید.
  • اگر داخل atomic یک IntegrityError را با try بگیرید و ادامه دهید، تراکنش خراب است و کوئری بعدی خطا می‌دهد؛ بخش پرخطر را در یک atomic تودرتو (savepoint) بپیچید.
  • select_for_update(skip_locked=True) ردیف‌های قفل‌شده را رد می‌کند؛ پایه‌ی ساخت یک صف کار ساده روی PostgreSQL بدون Redis.
  • ATOMIC_REQUESTS = True در تنظیمات DATABASES هر درخواست را یک تراکنش می‌کند؛ ساده است اما تراکنش را در طول رندر قالب هم باز نگه می‌دارد و قفل‌ها طولانی می‌شوند.
  • Manager پیش‌فرض (اولین Manager تعریف‌شده) را هرگز فیلتر نکنید (مثلاً فقط فعال‌ها)؛ ادمین، روابط معکوس و dumpdata از آن استفاده می‌کنند و رکوردها «ناپدید» می‌شوند. یک Manager دوم مثل active = ActiveManager() بسازید.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.