یا همه، یا هیچ
ثبت یک سفارش چند مرحله دارد: ساخت سفارش، ساخت اقلام، کم کردن موجودی انبار. اگر مرحلهی سوم خطا بدهد، نباید سفارشی نیمهکاره باقی بماند. 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()بسازید.