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 не завершится — с ошибкой или успешно — мы будем находиться в транзакции, и DB будет нас ждать.
  4. Это означает, что (по крайней мере для баз данных ACID, таких как PSQL) транзакция будет блокировать таблицы и строки, которые она изменяет.
  5. При выполнении операции Django 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