Jak řídit souběžnost v modelech Django

Pro lepší zážitek ze čtení si přečtěte tento článek na mém webu.

Dny stolních systémů sloužících jednotlivým uživatelům jsou již dávno pryč - webové aplikace dnes slouží milionům uživatelů současně. S mnoha uživateli přichází celá řada nových problémů - souběžných problémů.

V tomto článku uvedu dva přístupy k řízení souběžnosti v modelech Django.

Foto: Denys Nevozhai

Problém

Abychom demonstrovali běžné problémy se souběžností, budeme pracovat na modelu bankovního účtu:

třída účet (models.Model):
    id = models.AutoField (
        primární_key = True,
    )
    user = models.ForeignKey (
        Uživatel,
    )
    balance = models.IntegerField (
        default = 0,
    )

Pro začátek implementujeme naivní vklad a metody výběru pro instanci účtu:

def vklad (vlastní, částka):
    self.balance + = částka
    self.save ()
def výběr (vlastní, částka):
    pokud částka> self.balance:
        zvýšení chyb. nedostatečné fondy ()
    self.balance - = částka
    self.save ()

To se zdá dost nevinné a mohlo by to dokonce projít jednotkovými testy a integračními testy na localhost. Co se však stane, když dva uživatelé provedou akce na stejném účtu současně?

  1. Uživatel A načte účet - zůstatek je 100 $.
  2. Uživatel B načte účet - zůstatek je 100 $.
  3. Uživatel B vybere 30 $ - zůstatek je aktualizován na 100 $ - 30 $ = 70 $.
  4. Uživatel A vloží 50 $ - zůstatek je aktualizován na 100 $ + 50 $ = 150 $.

Co se tu stalo?

Uživatel B požádal o výběr 30 $ a uživatel A uložil 50 $ - očekáváme, že zůstatek bude 120 $, ale nakonec jsme skončili 150 $.

Proč se to stalo?

V kroku 4, když uživatel A aktualizoval zůstatek, byla částka, kterou uložil v paměti, zastaralá (uživatel B již stáhl 30 $).

Abychom této situaci zabránili, musíme zajistit, aby se zdroj, na kterém pracujeme, nezměnil, zatímco na tom pracujeme.

Pesimistický přístup

Pesimistický přístup vyžaduje, abyste zdroj zamkli výhradně, dokud s ním neskončíte. Pokud nikdo nemůže získat zámek objektu, zatímco na něm pracujete, můžete si být jisti, že se objekt nezměnil.

K získání zámku na prostředku používáme zámek databáze z několika důvodů:

  1. (relační) databáze jsou velmi dobré při správě zámků a udržování konzistence.
  2. Databáze je nejnižší úrovní, ve které jsou data přístupná - získání zámku na nejnižší úrovni bude chránit data před jinými procesy modifikujícími data. Například přímé aktualizace v databázi DB, úlohy cron, úkoly čištění atd.
  3. Aplikace Django může běžet na více procesech (např. Pracovníků). Udržování zámků na úrovni aplikace bude vyžadovat spoustu (zbytečné) práce.

K uzamčení objektu v Djangu používáme select_for_update.

Využíváme pesimistický přístup k zavedení bezpečného vkladu a výběr:

@classmethod
def vklad (cls, id, množství):
   s transaction.atomic ():
       account = (
           cls.objects
           .select_for_update ()
           .get (id = id)
       )
      
       account.balance + = částka
       account.save ()
    návratový účet
@classmethod
def výběr (cls, id, množství):
   s transaction.atomic ():
       account = (
           cls.objects
           .select_for_update ()
           .get (id = id)
       )
      
       if account.balance <částka:
           zvýšit chyby.Nekázané fondy ()
       account.balance - = částka
       account.save ()
  
   návratový účet

Co tady máme:

  1. Použijeme select_for_update v našem querysetu, abychom řekli databázi, aby objekt zamkla, dokud nebude transakce dokončena.
  2. Uzamčení řádku v databázi vyžaduje databázovou transakci - pro rozsah transakce používáme dekoratérské transakce Django.atomic ().
  3. Místo metody instance používáme metodu class - k získání zámku je třeba, abychom databázi sdělili, aby ji uzamkla. Abychom toho dosáhli, musíme být ti, kteří objekt stahují z databáze. Při práci na vlastním objektu je objekt již vyvolán a nemáme žádnou záruku, že byl uzamčen.
  4. Všechny operace na účtu jsou prováděny v rámci transakce databáze.

Podívejme se, jak s naší novou implementací zabrání scénář z předchozího:

  1. Uživatel A žádá o výběr 30 $:
    - Uživatel A získá zámek na účtu.
    - Zůstatek je 100 $.
  2. Uživatel B požaduje vklad 50 $:
    - Pokus o získání zámku na účtu selhal (uzamčen uživatelem A).
    - Uživatel B čeká na uvolnění zámku.
  3. Uživatel A výběr 30 $:
    - Zůstatek je 70 $.
    - Zámek uživatele A na účtu je uvolněn.
  4. Uživatel B získá na účtu zámek.
    - Zůstatek je 70 $.
    - Nový zůstatek je 70 $ + 50 $ = 120 $.
  5. Zámek uživatele B na účtu je uvolněn, zůstatek je 120 $.

Bug zabránil!

Co potřebujete vědět o select_for_update:

  • V našem scénáři uživatel B čekal, až uživatel A uvolní zámek. Namísto čekání můžeme Djangovi říct, aby nečekal na uvolnění zámku a místo toho zvedl DatabaseError. Za tímto účelem můžeme nastavit argument nowait select_for_update na True,… select_for_update (nowait = True).
  • Vybrat související objekty jsou také uzamčeny - Pokud používáte select_for_update s select_related, související objekty se také zamknou.
    Například, pokud bychom měli vybrat_spřízněné uživatele spolu s účtem, bude uživatel i účet uzamčen. Pokud se například během vkladu někdo pokouší aktualizovat křestní jméno, tato aktualizace selže, protože je uzamčen objekt uživatele.
    Pokud používáte PostgreSQL nebo Oracle, nemusí to být problém brzy díky nové funkci v nadcházejícím Django 2.0. V této verzi má select_for_update možnost „of“, která explicitně uvádí, které tabulky v dotazu se mají zamknout.

V minulosti jsem použil příklad bankovního účtu, abych předvedl běžné vzorce, které používáme v modelech Django. Jste vítáni sledovat v tomto článku:

Optimistický přístup

Na rozdíl od pesimistického přístupu optimistický přístup nevyžaduje zámek na objektu. Optimistický přístup předpokládá, že kolize nejsou příliš běžné, a diktuje, že člověk by měl pouze zajistit, aby nedošlo k žádným změnám objektu v době jeho aktualizace.

Jak můžeme takovou věc realizovat s Djangem?

Nejprve přidáme sloupec, který sleduje změny provedené v objektu:

version = models.IntegerField (
    default = 0,
)

Poté, když aktualizujeme objekt, zajistíme, aby se verze nezměnila:

def vklad (vlastní, id, částka):
   updated = Account.objects.filter (
       id = self.id,
       version = self.version,
   ).Aktualizace(
       zůstatek = zůstatek + částka,
       version = self.version + 1,
   )
   návrat aktualizován> 0
def výběr (vlastní, id, částka):
   pokud self.balance  0

Pojďme to rozebrat:

  1. Působíme přímo na instanci (bez třídy).
  2. Spoléháme na skutečnost, že verze se zvyšuje při každé aktualizaci objektu.
  3. Aktualizujeme pouze v případě, že se verze nezměnila:
    - Pokud nebyl objekt změněn, protože jsme ho načerpali, byl aktualizován.
    - Pokud byl změněn, dotaz vrátí nulové záznamy a objekt nebude aktualizován.
  4. Django vrátí počet aktualizovaných řádků. Pokud je „aktualizováno“ nula, znamená to, že někdo jiný změnil objekt od doby, kdy jsme jej získali.

Jak funguje optimistické zamykání v našem scénáři:

  1. Uživatel Načíst účet - zůstatek je 100 $, verze je 0.
  2. Uživatel B načte účet - zůstatek je 100 $, verze je 0.
  3. Uživatel B žádá o výběr 30 $:
    - Zůstatek je aktualizován na 100 $ - 30 $ = 70 $.
    - Verze se zvyšuje na 1.
  4. Uživatel A požaduje vklad 50 $:
    - Vypočítaný zůstatek je 100 $ + 50 $ = 150 $.
    - Účet neexistuje ve verzi 0 -> nic není aktualizováno.

Co potřebujete vědět o optimistickém přístupu:

  • Na rozdíl od pesimistického přístupu vyžaduje tento přístup další pole a hodně disciplíny.
    Jedním ze způsobů, jak překonat problém disciplíny, je abstraktní chování. django-fsm implementuje optimistické zamykání pomocí pole verze, jak je popsáno výše. Zdá se, že django-optimistický zámek dělá totéž. Nepoužili jsme žádný z těchto balíčků, ale vzali jsme z nich nějakou inspiraci.
  • V prostředí se spoustou souběžných aktualizací může být tento přístup zbytečný.
  • Tento přístup nechrání před změnami provedenými v objektu mimo aplikaci. Pokud máte jiné úkoly, které přímo upravují data (např. Ne prostřednictvím modelu), musíte se ujistit, že také používají verzi.
  • Pomocí optimistického přístupu může funkce selhat a vrátit se false. V tomto případě budeme pravděpodobně chtít operaci zopakovat. Pomocí pesimistického přístupu s nowait = False operace nemůže selhat - bude čekat na uvolnění zámku.

Který z nich bych měl použít?

Jako každá velká otázka, odpověď zní „záleží“:

  • Pokud má váš objekt mnoho souběžných aktualizací, pravděpodobně budete s pesimistickým přístupem lépe.
  • Pokud se aktualizace odehrávají mimo ORM (například přímo v databázi), je pesimistický přístup bezpečnější.
  • Pokud má vaše metoda vedlejší účinky, jako jsou vzdálená volání API nebo volání OS, zkontrolujte, zda jsou bezpečné. Několik věcí, které je třeba zvážit - může vzdálené volání trvat dlouho? Je vzdálené volání idempotentní (bezpečné opakování)?