Как да управляваме паралелността в моделите на Django

За по-добро преживяване при четене, разгледайте тази статия на моя уебсайт.

Дните на настолните системи, обслужващи единични потребители, отдавна са отминали - уеб приложенията днес обслужват милиони потребители едновременно. С много потребители идва широк спектър от нови проблеми - проблеми с едновременността.

В тази статия ще представя два подхода за управление на паралелността в моделите на Django.

Снимка на Денис Невожай

Проблемът

За да демонстрираме често срещани проблеми със съвместимостта, ще работим върху модела на банкова сметка:

акаунт за клас (модели. Модел):
    id = models.AutoField (
        primary_key = Вярно е,
    )
    потребител = models.ForeignKey (
        Потребителят,
    )
    баланс = модели.IntegerField (
        подразбиране = 0,
    )

За да започнем, ще приложим наивен метод за депозит и ще изтеглим методи за акаунт:

депозит (самостоятелно, сума):
    self.balance + = сума
    self.save ()
def теглене (самостоятелно, сума):
    ако сума> самобаланс:
        повдигане на грешки.InsufficientFunds ()
    self.balance - = сума
    self.save ()

Това изглежда достатъчно невинно и може дори да премине единични тестове и тестове за интеграция на localhost. Но какво се случва, когато двама потребители извършват действия в един и същ акаунт едновременно?

  1. Потребител A извлича акаунта - балансът е 100 $.
  2. Потребителят B извлича акаунта - балансът е 100 $.
  3. Потребител B изтегля 30 $ - балансът се актуализира до 100 $ - 30 $ = 70 $.
  4. Потребител A депозити 50 $ - балансът се актуализира до 100 $ + 50 $ = 150 $.

Какво се е случило тук?

Потребител B поиска да изтегли 30 $, а потребител A депозиран 50 $ - очакваме салдото да бъде 120 $, но в крайна сметка завършихме със 150 $.

Защо се случи?

На стъпка 4, когато потребител A актуализира баланса, сумата, която той е запазил в паметта, е застояла (потребител Б вече беше изтеглил 30 $).

За да не се случи тази ситуация, ние трябва да се уверим, че ресурсът, върху който работим, не е променен, докато работим върху него.

Песимистичен подход

Песимистичният подход диктува, че трябва да заключите ресурса изключително, докато не приключите с него. Ако никой друг не може да придобие заключване на обекта, докато работите върху него, можете да сте сигурни, че обектът не е бил променен.

За да придобием заключване на ресурс, ние използваме заключване на база данни по няколко причини:

  1. (релационни) бази данни са много добри за управление на брави и поддържане на последователност.
  2. Базата данни е най-ниското ниво, в което се осъществява достъп до данните - придобиването на заключването на най-ниското ниво ще защити данните и от други процеси, променящи данните. Например директни актуализации в DB, ​​cron задания, задачи за почистване и т.н.
  3. Приложението Django може да работи на множество процеси (например работници). Поддържането на брави на ниво приложение ще изисква много (ненужна) работа.

За да заключим обект в Django, използваме select_for_update.

Нека използваме песимистичния подход, за да внесем сигурен депозит и да изтеглим:

@classmethod
депозит за дефиниране (cls, id, сума):
   сaction.atomic ():
       сметка = (
           cls.objects
           .select_for_update ()
           .get (ID = Id)
       )
      
       account.balance + = сума
       account.save ()
    връщане на сметка
@classmethod
def изтегляне (cls, id, сума):
   сaction.atomic ():
       сметка = (
           cls.objects
           .select_for_update ()
           .get (ID = Id)
       )
      
       ако account.balance <сума:
           повдигане на грешки.Несъвместими фондове ()
       account.balance - = сума
       account.save ()
  
   връщане на сметка

Какво имаме тук:

  1. Използваме select_for_update в нашия набор от заявки, за да кажем на базата данни да заключи обекта, докато транзакцията не бъде извършена.
  2. Заключването на ред в базата данни изисква транзакция с база данни - ние използваме декоратора на транзакция.atomic () на Django за обхват на транзакцията.
  3. Ние използваме classmethod вместо метод на инстанция - за да придобием ключалката, която трябва да кажем на базата данни, за да я заключим. За да постигнем това, ние трябва да бъдем тези, които извличат обекта от базата данни. Когато работим върху себе си, обектът вече е извлечен и нямаме никаква гаранция, че той е бил заключен.
  4. Всички операции в акаунта се изпълняват в рамките на транзакцията с база данни.

Нека да видим как сценарият от по-рано е предотвратен с новата ни реализация:

  1. Потребител A моли да изтегли 30 $:
    - Потребител A придобива заключване на акаунта.
    - Балансът е 100 $.
  2. Потребител Б моли да депозира 50 $:
    - Опит за придобиване на заключване на акаунта се проваля (заключва се от потребител A).
    - Потребител B изчаква ключалката да се освободи.
  3. Потребител отнема 30 $:
    - Балансът е 70 $.
    - Заключване на потребител А в акаунта се освобождава.
  4. Потребител B придобива заключване на акаунта.
    - Балансът е 70 $.
    - Нов баланс е 70 $ + 50 $ = 120 $.
  5. Заключването на потребител B в акаунта се освобождава, салдото е 120 $.

Грешка предотвратена!

Какво трябва да знаете за select_for_update:

  • В нашия сценарий потребителят B изчака потребител A да освободи ключалката. Вместо да чакаме можем да кажем на Django да не чака ключалката да се освободи и вместо това да се вдигне DatabaseError. За целта можем да зададем аргумента на nowait на select_for_update на True,… select_for_update (nowait = True).
  • Изберете свързани обекти също са заключени - Когато използвате select_for_update с select_related, свързаните обекти също се заключват.
    Например, ако трябва да изберете_свързани потребителя заедно с акаунта, и потребителят, и акаунтът ще бъдат заключени. Ако по време на депозит например някой се опитва да актуализира първото име, тази актуализация ще се провали, тъй като потребителският обект е заключен.
    Ако използвате PostgreSQL или Oracle, това може да не е проблем скоро благодарение на нова функция в предстоящия Django 2.0. В тази версия select_for_update има опция „от“, за да изрично да посочи коя от таблиците в заявката да се заключи.

В миналото използвах примера на банковата сметка, за да демонстрирам общи модели, които използваме в моделите на Django. Заповядайте в тази статия:

Оптимистичен подход

За разлика от песимистичния подход, оптимистичният подход не изисква заключване на обекта. Оптимистичният подход предполага, че сблъсъците не са много чести и диктуват, че човек трябва само да се увери, че не са направени промени в обекта по време на актуализирането му.

Как можем да приложим подобно нещо с Django?

Първо добавяме колона, за да следим промените, направени в обекта:

версия = модели.IntegerField (
    подразбиране = 0,
)

След това, когато актуализираме обект, се уверяваме, че версията не се е променила:

депозит (самостоятелно, идентификационен номер, сума):
   актуализирано = Account.objects.filter (
       ID = self.id,
       версия = self.version,
   ) .Update (
       баланс = баланс + сума,
       версия = self.version + 1,
   )
   връщане актуализирано> 0
def изтегляне (самостоятелно, идентификационен номер, сума):
   ако self.balance <сума:
       повдигане на грешки.Несъвместими фондове ()
  
   актуализирано = Account.objects.filter (
       ID = self.id,
       версия = self.version,
   ) .Update (
       баланс = баланс - сума,
       версия = self.version + 1,
   )
  
   връщане актуализирано> 0

Нека го разделим:

  1. Ние работим директно върху инстанцията (няма classmethod).
  2. Разчитаме на факта, че версията се увеличава при всяко обновяване на обекта.
  3. Актуализираме само ако версията не се е променила:
    - Ако обектът не е променен откакто го донесохме, тогава обектът се актуализира.
    - Ако е модифициран, заявката ще върне нулеви записи и обектът няма да бъде актуализиран.
  4. Django връща броя на актуализираните редове. Ако „актуализиран“ е нула, това означава, че някой друг е променил обекта от момента, в който го измъкнахме.

Как работи оптимистичното заключване в нашия сценарий:

  1. Потребител Извличане на акаунта - салдото е 100 $, версията е 0.
  2. Потребителят B извлича акаунта - балансът е 100 $, версията е 0.
  3. Потребител Б моли да изтегли 30 $:
    - Балансът е актуализиран до 100 $ - 30 $ = 70 $.
    - Версията е увеличена до 1.
  4. Потребител A моли да депозира 50 $:
    - Изчисленото салдо е 100 $ + 50 $ = 150 $.
    - акаунтът не съществува с версия 0 -> нищо не се актуализира.

Какво трябва да знаете за оптимистичния подход:

  • За разлика от песимистичния подход, този подход изисква допълнително поле и много дисциплина.
    Един от начините за преодоляване на проблема с дисциплината е да се абстрахира това поведение. django-fsm реализира оптимистично заключване, използвайки поле за версия, както е описано по-горе. django-optimistic-lock изглежда правят същото. Не сме използвали нито един от тези пакети, но взехме малко вдъхновение от тях.
  • В среда с много едновременни актуализации този подход може да бъде разточителен.
  • Този подход не защитава от модификации, направени в обекта извън приложението. Ако имате други задачи, които променят данните директно (напр. Не чрез модела), трябва да се уверите, че те използват и версията.
  • Използвайки оптимистичния подход, функцията може да се провали и да върне невярно. В този случай най-вероятно ще искаме да опитаме отново. Използването на песимистичния подход с nowait = False операцията не може да се провали - тя ще изчака заключването да се освободи.

Кой трябва да използвам?

Както всеки голям въпрос, отговорът е „зависи”:

  • Ако вашият обект има много едновременни актуализации, вероятно сте по-добре с песимистичния подход.
  • Ако имате актуализации, случващи се извън ORM (например директно в базата данни), песимистичният подход е по-безопасен.
  • Ако вашият метод има странични ефекти, като например отдалечени обаждания в API или обаждания от ОС, уверете се, че са безопасни. Някои неща, които трябва да вземете предвид - може ли отдалеченото повикване да отнеме много време? Дали идентификацията на отдалеченото повикване (безопасно ли е да се опита отново)?