paint-brush
Python – Django: ви ніколи не повинні вкладати це в транзакціїза@mta
Нова історія

Python – Django: ви ніколи не повинні вкладати це в транзакції

за Michael T. Andemeskel17m2025/03/04
Read on Terminal Reader

Надто довго; Читати

Транзакції є центральними для програм Django, але якщо ви додасте неправильний код у транзакції, ви можете спричинити збій!
featured image - Python – Django: ви ніколи не повинні вкладати це в транзакції
Michael T. Andemeskel HackerNoon profile picture


Зміст

  • Підсумок частини 1 - Глибоке занурення в транзакції Django
  • TLDR, Резюме
  • Як трансакції можуть зашкодити нашим програмам?
  • в
  • Ризикований
  • Вийти
  • Далі - як команди PSQL блокують одна одну
  • Джерела

Підсумок частини 1 - Глибоке занурення в транзакції Django

У попередній публікації ми дізналися, що відбувається, коли викликається функція, оформлена transaction.atomic , і що відбувається with transaction.atomic() . Підсумовуючи:


  1. З’єднання з БД створюється або отримується.
  2. Створюється транзакція, наприклад, BEGIN; надсилається до БД (якщо БД є PSQL або іншим варіантом SQL).
  3. З цього моменту, доки функція не існує або оператор with завершиться (чи з помилкою, чи успішно), ми будемо в транзакції, і БД чекатиме на нас.
  4. Це означає, що (принаймні для БД ACID, таких як PSQL) транзакція блокуватиме таблиці та рядки, які вона змінює.
  5. Коли виконується операція Django DB, DB захоплює відповідне блокування та запобігає будь-яким конфліктним операціям у цій таблиці чи рядку.
  6. Це може спричинити тайм-аут інших з’єднань через очікування зняття блокування.
  7. Якщо операція завершується помилкою або виникає помилка виконання, БД відкочує всю транзакцію та знімає блокування.
  8. Якщо вся транзакція виконана успішно, усі зміни фіксуються та доступні для інших підключень до БД.
  9. Замки розпущені.


Тепер ми обговоримо, що включати в транзакцію, а чого уникати. Через наведену нижче поведінку транзакцій певні операції є небезпечними, якщо їх помістити в блок транзакцій.


  • Транзакції зберігаються на блокуваннях, доки транзакція не завершиться або не завершиться.
  • Транзакції скасовують усі свої операції з БД, коли вони виявляються невдалими.
  • Запит на транзакції блокується, щойно виконується операція з БД.

TLDR, Резюме

в

  • Зворотні операції з БД . Якщо операцію з БД не можна скасувати й транзакція завершується помилкою, БД залишиться в поганому стані, оскільки транзакція намагатиметься автоматично відкотити зміни, але вони є незворотними, тому не вдасться.
  • Необхідні пов’язані операції з БД . Ми не можемо створити новий запис банківського рахунку без запису користувача, тому нам потрібно створити обидва в одній транзакції.
  • Зворотна та пов’язана бізнес-логіка – обчислення загального балансу клієнта після створення нового запису на рахунку з депозитом (навіть його можна винести з транзакції за допомогою розумного моделювання та координації).

Ризикований

  • Повільні запити . Отримання даних зазвичай відбувається швидко, за винятком цих трьох сценаріїв. Ці запити сповільнять транзакцію та подовжать час утримання блокувань, що матиме негативний вплив на інших користувачів.
  • Операції над кількома таблицями . Транзакція з операціями над кількома таблицями може заблокувати кожну таблицю, доки вона не буде виконана. Це особливо поширене в міграціях Django — ще одна причина, щоб міграції були невеликими та зосередженими на одній або кількох таблицях одночасно.
  • Міграція даних . Оскільки транзакції зберігаються на блокуванні з моменту виконання запиту до моменту збою або завершення транзакції, міграція, яка виконується для кожного рядка в таблиці, призведе до блокування всієї таблиці шляхом запобігання читанню або запису в кожному рядку.
  • (Лише для PSQL і SQLite*) Зміна таблиць або стовпців - ці операції вимагають найсуворішої форми блокувань і, отже, запобігають читанню/запису всієї таблиці. Ці операції найімовірніше призведуть до збою.

Вийти

  • Незворотні операції - все в транзакції має бути оборотним, якщо транзакцію відкотити; якщо ми додаємо виклик API до транзакції, його не можна скасувати.
  • Блокування викликів . Оскільки транзакції перешкоджають виконанню всіх інших запитів у таблицях/рядках, які змінює транзакція, будь-який код, який збільшує тривалість транзакції, призведе до блокування БД, викликаючи тайм-аути та невідповідь у програмах, залежних від БД.


Продовжуйте читати альтернативи та приклади коду.

Як трансакції можуть зашкодити нашим програмам?

Основний ризик полягає в тому, що транзакції утримують блокування, доки вони не будуть виконані, щоб запобігти конфліктним операціям над таблицями та рядками та дозволити транзакції бути оборотною - це важливо, щоб зробити операції БД у транзакції атомарними. Це означає, що тривала транзакція, яка працює з кількома таблицями або кількома критичними, може призвести до збоїв через блокування та запобігання читанню/запису в ці таблиці/рядки.


По суті, якщо ми розмістимо неправильний код у блоці транзакції, ми можемо ефективно знищити БД, заблокувавши всі інші підключення до БД від виконання операцій над нею.


Вторинний ризик полягає в тому, що транзакції повинні бути оборотними, і ОЧІКУЄТЬСЯ, що вони будуть оборотними. БД автоматично скасовує кожну операцію, якщо в транзакції виникає помилка. Таким чином, операції БД, які ми вставляємо в транзакцію, повинні бути оборотними - здебільшого нам не потрібно турбуватися про це з PSQL. Але як щодо інших кодів?


Часто, коли ми змінюємо наші дані, нам потрібно виконувати наступні завдання, як-от запуск подій, оновлення служб, надсилання push-сповіщень тощо. Ці завдання НЕ можна відмінити – ми не можемо скасувати надсилання події, запиту чи сповіщення. Якщо виникає помилка, зміни даних скасовуються, але ми вже надіслали push-повідомлення з повідомленням: «Ваш звіт створено; натисніть тут, щоб переглянути його». Що відбувається, коли користувач або інші служби діють на основі цієї неправдивої інформації? Буде каскад невдач. Таким чином, будь-який код, який не можна скасувати, не повинен бути в транзакції, інакше ми ризикуємо залишити нашу систему в поганому стані, коли в транзакції станеться помилка.

в

  • Зворотні операції з БД . Якщо операцію з БД неможливо скасувати й транзакція завершується помилкою, БД залишиться в поганому стані, оскільки транзакція намагатиметься автоматично відкотити зміни, але вони є незворотними, тому не вдасться.
  • Необхідні пов’язані операції з БД . Ми не можемо створити новий запис банківського рахунку без запису користувача, тому нам потрібно створити обидва в одній транзакції.
  • Зворотна та пов’язана бізнес-логіка – обчислення загального балансу клієнта після створення нового запису на рахунку з депозитом (навіть його можна винести з транзакції за допомогою розумного моделювання та координації).

Ризикований

Це речі, які, залежно від того, скільки даних обробляється та трафіку БД, можуть спричинити збої через надто тривале утримання блокувань. Усе це добре, якщо не займає надто багато часу.


  • Повільні запити — отримання даних зазвичай відбувається швидко, за винятком цих трьох сценаріїв. Ці запити сповільнять транзакцію та подовжать час утримання блокувань, що матиме негативний вплив на інших користувачів.

    • Приклади
      • Запити по неіндексованим стовпцям
      • Запити на великі таблиці
      • Приєднується
     @transaction.atomic def process_large_order_report(start_date, end_date, min_order_value=1000): # Complex query with multiple joins and aggregations large_orders = Order.objects.filter( created_at__range=(start_date, end_date), total_amount__gte=min_order_value, status='completed' ).select_related( 'customer', 'shipping_address', 'billing_address' ).prefetch_related( 'items__product__category', 'items__product__supplier' ).annotate( item_count=Count('items'), total_weight=Sum('items__product__weight'), discount_percentage=F('discount_amount') * 100 / F('total_amount') ).filter( # Additional complex filtering Q(customer__user__is_active=True) & (Q(items__product__category__name='Electronics') | Q(items__product__category__name='Furniture')) & ~Q(shipping_address__country='US') ).order_by('-total_amount') # do the transactional work with the large_orders queryset
    • Альтернатива
      • Виконуйте ці запити до та поза трансакцією.
 # fixed def process_large_order_report(start_date, end_date, min_order_value=1000): # Complex query with multiple joins and aggregations large_orders = Order.objects.filter( created_at__range=(start_date, end_date), total_amount__gte=min_order_value, status='completed' ).select_related( 'customer', 'shipping_address', 'billing_address' ).prefetch_related( 'items__product__category', 'items__product__supplier' ).annotate( item_count=Count('items'), total_weight=Sum('items__product__weight'), discount_percentage=F('discount_amount') * 100 / F('total_amount') ).filter( # Additional complex filtering Q(customer__user__is_active=True) & (Q(items__product__category__name='Electronics') | Q(items__product__category__name='Furniture')) & ~Q(shipping_address__country='US') ).order_by('-total_amount') # Start the transaction block with transaction.atomic(): # do the transactional work with the large_orders queryset


  • Операції над кількома таблицями — транзакція з операціями над кількома таблицями може блокувати кожну таблицю, доки вона не буде виконана. Це особливо поширене в міграціях Django — ще одна причина, щоб міграції були невеликими та зосередженими на одній або кількох таблицях одночасно.


    • Приклади
      • Див. Зміна структури таблиці або стовпця
     class Migration(migrations.Migration): dependencies = [("migrations", "0001_initial")] # too many operations operations = [ migrations.RemoveField("Author", "age"), migrations.AddField("Author", "rating", models.IntegerField(default=0)), migrations.AlterField("Book", "price", models.DecimalField(max_digits=5, decimal_places=2)), ]
    • Альтернатива
      • Розділіть транзакцію на кілька менших транзакцій і об’єднайте їх у зворотні виклики oncommit.
 # fixed # 1st migration class Migration(migrations.Migration): dependencies = [("migrations", "0001_initial")] operations = [ migrations.RemoveField("Author", "age"), ] # 2nd migration class Migration(migrations.Migration): dependencies = [("migrations", "0002_initial")] operations = [ migrations.AddField("Author", "rating", models.IntegerField(default=0)), ] # 3rd migration class Migration(migrations.Migration): dependencies = [("migrations", "0003_initial")] operations = [ migrations.AlterField("Book", "price", models.DecimalField(max_digits=5, decimal_places=2)), ]


  • Міграція даних . Оскільки транзакції зберігаються на блокуванні з моменту виконання запиту до моменту збою або завершення транзакції, міграція, яка виконується з кожним рядком таблиці, призведе до блокування всієї таблиці шляхом запобігання читанню або запису в кожному рядку.

     def migrate_user_profiles(): # Get all users with legacy profiles users_with_profiles = User.objects.filter( legacy_profile__isnull=False ).select_related('legacy_profile') # Process all users in a single transaction with transaction.atomic(): # Track progress total = users_with_profiles.count() print(f"Migrating {total} user profiles...") # Process each user for i, user in enumerate(users_with_profiles): if i % 100 == 0: print(f"Processed {i}/{total} profiles") legacy = user.legacy_profile legacy.update_new_user_profile()
    • Альтернатива
      • Оберніть транзакцію навколо окремих операцій, а не всього перенесення. Оновлюючи кожен рядок у транзакції, ми затримуємо блокування лише на короткий період часу.
 # fixed def migrate_user_profiles(): # Get all users with legacy profiles users_with_profiles = User.objects.filter( legacy_profile__isnull=False ).select_related('legacy_profile') # Process all users in a single transaction # Track progress total = users_with_profiles.count() print(f"Migrating {total} user profiles...") # Process each user for i, user in enumerate(users_with_profiles): if i % 100 == 0: print(f"Processed {i}/{total} profiles") with transaction.atomic(): legacy = user.legacy_profile legacy.update_new_user_profile()


  • ( Лише для PSQL і SQLite *) Зміна таблиць або стовпців — ці операції вимагають найсуворішої форми блокувань і, отже, запобігають читанню/запису всієї таблиці. Ці операції найімовірніше призведуть до збою.
    • приклад
      • Змінити колонку
      • Змінити таблицю
    • Альтернатива
      • Запустіть їх пізніше. Ці запити необхідні, АЛЕ нам не потрібно виконувати їх у робочий час. Найкраща політика тут полягає в тому, щоб зменшити ризик збою, визначивши, наскільки важливою є таблиця, оцінивши, скільки часу може зайняти міграція, виконавши міграцію, коли БД має найменший трафік, і підготувавши план відкату.

      • Скоротіть час, який займає транзакція. Це можна зробити шляхом розділення таблиці та запуску міграції на окремих розділах. Розділи PSQL і Django


*Django лише обертає транзакції навколо міграцій для PSQL і SQLite.

 class Migration(migrations.Migration): dependencies = [("migrations", "0001_initial")] # this migration, if on a large table, can slow down and block other operations # do it later operations = [ migrations.RemoveField("Users", "middle_name"), ]


Вийти

  • Незворотні операції — все в транзакції має бути оборотним, якщо транзакцію буде відкочено; якщо ми додаємо виклик API до транзакції, його не можна скасувати.

    • Приклади
      • Додавання події до черги — запуск інших подій
      • Надсилання виклику API — оновлення статусу, ініціювання завдань тощо.
     def transaction(user_data, user_files): with transaction.atomic(): user = User.objects.create(**user_data) async_notification_service.send_email(user.email, "You can login now!") Account.objects.create(user=user, balance=0) # rest of user creation proccess
    • Альтернативи
      • Виконайте важливу бізнес-логіку, яка має відбутися у зворотному виклику транзакції oncommit. Зворотний виклик oncommit ЗАВЖДИ викликається після успішної транзакції, і всі оновлення з транзакції доступні в зворотному виклику oncommit.
      • Ми також можемо зробити операцію оборотною, тобто створити спосіб видалення подій або надсилання виклику API скасування — це нетривіальне завдання для команди, яка володіє чергою подій або API. Я не рекомендую це.
 def transaction(user_data, user_files): with transaction.atomic(): user = User.objects.create(**user_data) Account.objects.create(user=user, balance=0) # rest of user creation proccess # the transaction is still in progress, so it can still be rolled back, it is not # committed until the transaction block is exited, so putting the notification here # is not a good idea - especially if the job starts immediately tries to read the data # this creates a race condition async_notification_service.send_email(user.email, "You can login now!") def transaction(user_data, user_files): with transaction.atomic(): user = User.objects.create(**user_data) Account.objects.create(user=user, balance=0) # rest of user creation proccess transaction.on_commit(partial(async_notification_service.send_email, user.email, "You can login now!"))


  • Блокування викликів — оскільки транзакції перешкоджають роботі всіх інших запитів із таблицями/рядками, які змінює транзакція, будь-який код, який збільшує тривалість транзакції, призведе до блокування БД, викликаючи тайм-аути та невідповідь у програмах, залежних від БД.


    • Приклади
      • Мережні виклики — надсилання запитів до API для отримання даних для введення в транзакцію.
      • Залежно від реєстратора, який ми використовуємо, бібліотека може надсилати журнали, коли ми перебуваємо в транзакції, або, якщо ми використовуємо datadog, він матиме інший процес для збереження журналів, але ми все одно платимо за збереження журналів у пам’яті, доки пакетне завдання не збереже їх у файл.
      • Дискові операції — завантаження CSV для вставки в таблицю або експорт таблиці в CSV.
      • Завдання, важкі для процесора — Матричне множення або будь-яке важке перетворення математики/даних блокує ЦП та всі інші операції — Глобальне блокування інтерпретатора Python змушує кожен потік використовувати ЦП по одному, а Django обробляється окремо (кожна операція в транзакції виконується послідовно).
     def transaction(user_data, user_files): with transaction.atomic(): user = User.objects.create(**user_data) for file_data in user_files: # transaction waits for this upload and so do all other connections that need access to table/rows the transaction # uses url = Cloudinary.upload_file(file_data['data']) Files.objects.create(**file_data['meta_data'], user=user, url=url) Account.objects.create(user=user, balance=0) # rest of user creation proccess
    • Альтернативи
      • Підготуйте дані перед транзакцією — заздалегідь завантажте дискові/мережеві дані. Коли дані будуть готові, виконайте з ними транзакцію.
      • Використовуйте дані-заповнювачі — створіть набір очевидних даних-заповнювачів (не можна сплутати їх із даними про виробництво), які легко знайти та ідентифікувати. Завантажте його перед транзакцією та використовуйте.
      • Генерувати дані — якщо існують обмеження унікальності полів, які не дозволяють використовувати дані заповнювачів.
      • Це ризиковано — це призведе до непередбачуваних збоїв через природу псевдовипадкових алгоритмів — тому використовуйте хороший випадковий алгоритм із хорошим початковим значенням, щоб уникнути раптових збоїв.
      • Видаліть або змініть обмеження. Якщо обмеження не дозволяють безпечно створювати записи, у нашій схемі є проблема. Зробіть обмеження залежними від стовпця стану, який вказує, коли запис завершено та який слід відстежувати.
 # not bad def transaction(user_data, user_files): user = None with transaction.atomic(): user = User.objects.create(**user_data) Account.objects.create(user=user, balance=0) # rest of user creation proccess for file_data in user_files: url = Cloudinary.upload_file(file_data['data']) Files.objects.create(**file_data['meta_data'], user=user, url=url) # best fix from functools import partial def transaction(user_data, user_files): user = None with transaction.atomic(): user = User.objects.create(**user_data) Account.objects.create(user=user, balance=0) # rest of user creation proccess # partials create a callable with the function and arguments # so that the function is called with the arguments when the transaction is committed # TODO: diff between partial and lambda here??? transaction.on_commit(partial(create_user_files, user_files, user)) def create_user_files(user_files, user): for file_data in user_files: url = Cloudinary.upload_file(file_data['data']) Files.objects.create(**file_data['meta_data'], user=user, url=url)


Далі — як команди PSQL блокують одна одну

У наступній публікації ми зануримося в PSQL і дізнаємося:

  • Як різні команди блокують таблиці та рядки
  • Які команди є найбільш ризикованими для виконання

Джерела

  • Атомний
  • Міграції та транзакції Django
    • У базах даних, які підтримують транзакції DDL (SQLite та PostgreSQL), усі операції міграції виконуватимуться в одній транзакції за замовчуванням. Навпаки, якщо база даних не підтримує транзакції DDL (наприклад, MySQL, Oracle), тоді всі операції виконуватимуться без транзакції.
    • Ви можете запобігти виконанню міграції в транзакції, встановивши для атомарного атрибута значення False. Наприклад:
    • Також можна виконати частини міграції всередині транзакції за допомогою atomic() або передати atomic=True до RunPython
  • Розділи PSQL і Django
    • ORM Django не має вбудованої підтримки для розділених таблиць, тому, якщо ви хочете використовувати розділи у своїй програмі, вам доведеться трохи попрацювати.
    • Одним із способів використання розділів є згортання власних міграцій, які запускають необроблений SQL. Це спрацює, але це означає, що вам доведеться вручну керувати міграціями для всіх змін, які ви вносите в таблицю в майбутньому.
    • Іншим варіантом є використання пакета під назвою django-postgres-extra. Django-postgres-extra пропонує підтримку кількох функцій PostgreSQL, які не вбудовані в ORM Django, наприклад, підтримку TRUNCATE TABLE і розділення таблиці.
  • Транзакції PSQL
  • Python: як працюють транзакції Django


L O A D I N G
. . . comments & more!

About Author

Michael T. Andemeskel HackerNoon profile picture
Michael T. Andemeskel@mta
I write code and, occasionally, bad poetry. Thankfully, my code isn’t as bad as my poetry.

ПОВІСИТИ БИРКИ

ЦЯ СТАТТЯ БУЛА ПРЕДСТАВЛЕНА В...