PythonDjangoArchitektura

Transaction boundaries w Django – gdzie naprawdę kończy się spójność

W Django `transaction.atomic()` nie domyka całej spójności systemu. Zobacz, gdzie kończą się gwarancje ACID i jak domknąć granice: `on_commit`, outbox, locki i idempotencja.

Transaction boundaries w Django – gdzie naprawdę kończy się spójność

Krótka odpowiedź

W Django granica spójności kończy się dużo wcześniej, niż sugeruje sama obecność transaction.atomic().

atomic chroni operacje SQL w obrębie jednej transakcji na jednym połączeniu do jednej bazy. Nie gwarantuje atomowości dla kolejek, emaili, webhooków, innych baz ani systemów zewnętrznych.

Jeśli projektujesz proces biznesowy jakby wszystko było w jednej transakcji ACID, wcześniej czy później dostaniesz "half-commit": dane zapisane w DB, ale brak eventu, albo odwrotnie.

1) Gdzie realnie kończy się transaction.atomic()

Najkrócej: na granicy pojedynczej transakcji SQL.

To oznacza, że atomic:

  • gwarantuje wspólny commit/rollback dla operacji DB w danym bloku,
  • wspiera zagnieżdżenia przez savepointy,
  • nie obejmuje side effectów poza bazą.

Nie oznacza natomiast, że cały use case jest spójny end-to-end.

Typowy błąd myślenia

"Skoro mam atomic, to mogę w środku wysłać event i wszystko będzie spójne".

Nie. Event może pójść, a transakcja może zostać wycofana. Albo transakcja się zatwierdzi, a publikacja eventu padnie po drodze.

2) ATOMIC_REQUESTS: wygoda, która bywa drogim skrótem

ATOMIC_REQUESTS opakowuje request transakcją. W małym systemie może to zmniejszyć liczbę błędów.

W systemie o większym ruchu koszt rośnie:

  • dłuższy czas życia transakcji,
  • więcej locków trzymanych przez logikę HTTP,
  • spadek throughputu przy endpointach mieszanych read/write.

Dobre pytanie architektoniczne brzmi: "czy naprawdę potrzebuję transakcji na cały request, czy tylko na krytyczną sekcję zapisu?".

3) Commit SQL to nie commit procesu biznesowego

Najwięcej incydentów spójności pochodzi z side effectów wykonywanych przed finalnym commitem.

Minimalny bezpiecznik: transaction.on_commit

python
from django.db import transaction


def create_invoice_and_enqueue(invoice_data):
    with transaction.atomic():
        invoice = Invoice.objects.create(**invoice_data)

        transaction.on_commit(
            lambda: publish_invoice_created(invoice_id=invoice.id)
        )

    return invoice

Ten wzorzec gwarantuje, że callback uruchomi się dopiero po skutecznym commicie DB.

Ale to nadal nie jest pełna gwarancja dostarczenia eventu. Jeśli worker/broker padnie po commicie, potrzebujesz retry i idempotencji po stronie konsumenta.

4) Izolacja i locki: spójność kontra współbieżność

W READ COMMITTED nadal możliwe są klasyczne race condition, jeśli dwa requesty aktualizują ten sam rekord na podstawie starego odczytu.

Masz trzy typowe strategie:

  1. select_for_update dla sekcji krytycznych i krótkich transakcji.
  2. Optimistic locking (np. pole version) dla ścieżek o wysokiej konkurencji.
  3. Ograniczenia bazodanowe (UNIQUE, CHECK, FK) jako ostatnia linia obrony.
python
from django.db import transaction


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

        if product.available_qty < qty:
            raise ValueError("Insufficient stock")

        product.available_qty -= qty
        product.save(update_fields=["available_qty"])

Trade-off jest prosty: im mocniejsze locki, tym wyższa spójność lokalna i niższa równoległość.

5) Granica procesu: Celery i integracje zewnętrzne

Gdy use case przechodzi przez broker i worker, kończy się iluzja jednej transakcji.

Dlatego w procesach wysokiego ryzyka lepiej stosować outbox:

  • w tej samej transakcji zapisujesz stan domeny i rekord outbox,
  • osobny publisher czyta outbox i publikuje eventy,
  • konsumenci są idempotentni (np. idempotency key + deduplikacja).

To nie daje "idealnej" natychmiastowej spójności, ale daje kontrolowalną i audytowalną spójność finalną.

6) Multi-DB: jedna aplikacja, wiele granic transakcyjnych

Jeśli operujesz na więcej niż jednej bazie, Django nie daje automatycznie globalnego 2PC dla całego use case.

W praktyce oznacza to, że musisz projektować proces jako serię kroków z kompensacją lub finalną synchronizacją stanu.

Wysokopoziomowa decyzja:

  • albo trzymasz krytyczny write-path w jednej bazie,
  • albo akceptujesz eventual consistency i projektujesz retry/kompensację.

Macierz decyzji

Opcja A: sam atomic

Dobra gdy:

  • jeden write model,
  • brak side effectów poza DB,
  • niskie ryzyko biznesowe przy sporadycznym retry.

Opcja B: atomic + on_commit

Dobra gdy:

  • musisz wywołać side effect po commicie,
  • dopuszczasz krótkie opóźnienie,
  • masz podstawowy retry.

Opcja C: outbox + idempotentni konsumenci

Dobra gdy:

  • proces wpływa na finanse, billing, inventory, compliance,
  • musi być audytowalny,
  • awarie częściowe nie mogą niszczyć danych.

Checklista produkcyjna

  1. Czy krytyczny use case zapisuje dane w jednej bazie?
  2. Czy wszystkie side effecty wychodzą dopiero po commicie?
  3. Czy konsumenci eventów są idempotentni?
  4. Czy masz metryki lagu outbox i liczby retry?
  5. Czy weryfikujesz race condition testami równoległości?
  6. Czy istnieje runbook dla naprawy "half-commit"?

Finalny werdykt

W Django spójność nie kończy się na atomic, tylko na granicy procesu, który musi domknąć transakcję biznesową.

Baza daje atomowość lokalną. Architektura systemu musi dowieźć spójność globalną.

Jeśli rozdzielisz te dwie warstwy świadomie, incydenty "niby commit, ale nie działa" przestaną być codziennością.