paint-brush
Python - Django: Você nunca deve colocar essas coisas em transaçõespor@mta
Novo histórico

Python - Django: Você nunca deve colocar essas coisas em transações

por Michael T. Andemeskel17m2025/03/04
Read on Terminal Reader

Muito longo; Para ler

As transações são essenciais para os aplicativos Django, mas se você colocar o código errado nas transações, poderá causar uma interrupção!
featured image - Python - Django: Você nunca deve colocar essas coisas em transações
Michael T. Andemeskel HackerNoon profile picture


Contente

  • Resumo da Parte 1 - Mergulho profundo nas transações Django
  • TLDR, Resumo
  • Como as transações podem prejudicar nossos aplicativos?
  • Em
  • Arriscado
  • Fora
  • Próximo - Como os comandos PSQL bloqueiam uns aos outros
  • Fontes

Resumo da Parte 1 - Mergulho Profundo nas Transações Django

No post anterior, aprendemos o que acontece quando uma função decorada com transaction.atomic é chamada e o que acontece with transaction.atomic() . Em resumo:


  1. Uma conexão com o banco de dados é criada ou recuperada.
  2. Uma transação é criada, por exemplo, BEGIN; é enviada ao BD (se o BD for PSQL ou outra variante de SQL).
  3. A partir de agora, até que a função exista ou a instrução with seja concluída - seja com erro ou com sucesso - estaremos na transação, e o DB estará esperando por nós.
  4. Isso significa que (pelo menos para bancos de dados ACID como PSQL) a transação manterá bloqueios nas tabelas e linhas que estiver alterando.
  5. Quando uma operação do Django DB é executada, o DB captura o bloqueio correspondente e impede qualquer operação conflitante naquela tabela ou linha.
  6. Isso pode fazer com que outras conexões expirem devido à espera da liberação do bloqueio.
  7. Se a operação falhar ou se houver um erro de tempo de execução, o banco de dados reverte toda a transação e libera os bloqueios.
  8. Se toda a transação for bem-sucedida, todas as alterações serão confirmadas e ficarão disponíveis para outras conexões de banco de dados.
  9. As fechaduras estão liberadas.


Agora, discutiremos o que colocar em uma transação e o que evitar. Devido aos seguintes comportamentos de transação, certas operações são perigosas quando colocadas em um bloco de transação.


  • As transações mantêm os bloqueios até que a transação falhe ou seja concluída.
  • As transações revertem todas as suas operações de BD quando falham.
  • As transações solicitam bloqueios assim que uma operação de banco de dados é executada.

TLDR, Resumo

Em

  • Operações reversíveis no BD - Se a operação do BD não puder ser revertida e a transação falhar, o BD ficará em um estado ruim porque a transação tentará reverter as alterações automaticamente, mas elas são irreversíveis, então falhará.
  • Operações relacionadas no BD que são necessárias - Não podemos criar um novo registro de conta bancária sem um registro de usuário, então precisamos criar ambos na mesma transação.
  • Lógica de negócios reversível e relacionada - Calcular o saldo total que o cliente tem após criar um novo registro de conta com um depósito (até mesmo isso pode ser movido para fora de uma transação com modelagem e coordenação inteligentes).

Arriscado

  • Consultas lentas - Buscar dados geralmente é rápido, exceto por esses três cenários. Essas consultas tornarão a transação mais lenta e estenderão o tempo que ela mantém os bloqueios, o que terá efeitos adversos em outros usuários.
  • Operações em múltiplas tabelas - Uma transação com operações em múltiplas tabelas pode bloquear cada tabela até que seja feita. Isso é especialmente prevalente em migrações Django - outro motivo para manter migrações pequenas e focadas em uma ou algumas tabelas por vez.
  • Migrações de dados - Como as transações mantêm um bloqueio desde o momento em que a consulta é executada até que a transação falhe ou termine, uma migração que opera em todas as linhas da tabela acabará bloqueando a tabela inteira, impedindo leituras ou gravações em cada linha.
  • (Somente para PSQL e SQLite*) Alterar tabelas ou colunas - essas operações exigem a forma mais estrita de bloqueios e, portanto, impedirão leituras/gravações na tabela inteira. Essas operações são as mais propensas a causar uma interrupção.

Fora

  • Operações irreversíveis - Tudo na transação deve ser reversível se a transação for revertida; se colocarmos uma chamada de API na transação, ela não poderá ser desfeita.
  • Bloqueio de chamadas - Como as transações impedem que todas as outras consultas operem nas tabelas/linhas que a transação está alterando, qualquer código que aumente a duração da transação fará com que o banco de dados seja bloqueado, causando tempos limite e falta de resposta nos aplicativos dependentes do banco de dados.


Continue lendo para alternativas e exemplos de código.

Como as transações podem prejudicar nossos aplicativos?

O risco primário é que as transações mantenham bloqueios até que sejam feitas para evitar operações conflitantes em tabelas e linhas e permitir que a transação seja reversível - isso é essencial para tornar as operações do BD na transação atômicas. Isso significa que uma transação de longa duração que opera em várias tabelas ou algumas críticas pode causar interrupções ao monopolizar bloqueios e impedir leituras/gravações nessas tabelas/linhas.


Em essência, se colocarmos o código errado em um bloco de transação, podemos efetivamente derrubar o banco de dados, bloqueando todas as outras conexões com o banco de dados de realizar operações nele.


O risco secundário é que as transações precisam ser reversíveis e ESPERA-SE que sejam reversíveis. O BD reverte automaticamente todas as operações se ocorrer um erro na transação. Portanto, as operações do BD que colocamos na transação devem ser reversíveis - na maioria das vezes, não precisamos nos preocupar com isso com PSQL. Mas e quanto a outros códigos?


Muitas vezes, quando alteramos nossos dados, precisamos fazer tarefas de acompanhamento, como disparar eventos, atualizar serviços, enviar notificações push, etc. Essas tarefas NÃO são reversíveis - não podemos cancelar o envio de um evento, uma solicitação ou uma notificação. Se ocorrer um erro, as alterações de dados serão revertidas, mas já enviamos a notificação push dizendo: "Seu relatório foi gerado; clique aqui para visualizá-lo". O que acontece quando o usuário ou outros serviços agem com base nessas informações falsas? Haverá uma cascata de falhas. Portanto, qualquer código que não possa ser revertido não deve estar em uma transação, ou corremos o risco de deixar nosso sistema em um estado ruim quando ocorrer um erro na transação.

Em

  • Operações reversíveis no BD - Se a operação do BD não puder ser revertida e a transação falhar, o BD ficará em um estado ruim porque a transação tentará reverter as alterações automaticamente, mas elas são irreversíveis, então falhará.
  • Operações relacionadas no BD que são necessárias - Não podemos criar um novo registro de conta bancária sem um registro de usuário, então precisamos criar ambos na mesma transação.
  • Lógica de negócios reversível e relacionada - Calcular o saldo total que o cliente tem após criar um novo registro de conta com um depósito (até mesmo isso pode ser movido para fora de uma transação com modelagem e coordenação inteligentes).

Arriscado

Essas são coisas que, dependendo de quantos dados estão sendo processados e do tráfego do BD, podem causar interrupções devido à retenção de bloqueios por muito tempo. Todas essas coisas são boas se não demorarem muito.


  • Consultas lentas — A busca de dados geralmente é rápida, exceto nesses três cenários. Essas consultas tornarão a transação mais lenta e estenderão o tempo que ela mantém os bloqueios, o que terá efeitos adversos em outros usuários.

    • Exemplos
      • Consultas em colunas não indexadas
      • Consultas em tabelas grandes
      • Junta-se
     @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
    • Alternativa
      • Faça essas consultas antes e fora da transação.
 # 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


  • Operações em múltiplas tabelas — uma transação com operações em múltiplas tabelas pode bloquear cada tabela até que seja feita. Isso é especialmente prevalente em migrações Django — outro motivo para manter migrações pequenas e focadas em uma ou algumas tabelas por vez.


    • Exemplos
      • Veja Alterar estrutura de tabela ou coluna
     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)), ]
    • Alternativa
      • Divida a transação em várias transações menores e encadeie-as nos callbacks 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)), ]


  • Migrações de dados — Como as transações mantêm um bloqueio desde o momento em que a consulta é executada até que a transação falhe ou termine, uma migração que opera em todas as linhas da tabela acabará bloqueando a tabela inteira, seja impedindo leituras ou gravações em cada linha.

     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()
    • Alternativa
      • Envolva a transação em torno das operações individuais, não da migração inteira. Ao colocar as atualizações em cada linha da transação, mantemos os bloqueios apenas por um curto período de tempo.
 # 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()


  • ( Somente para PSQL e SQLite *) Alterar tabelas ou colunas — essas operações exigem a forma mais estrita de bloqueios e, portanto, impedirão leituras/gravações na tabela inteira. Essas operações são as mais propensas a causar uma interrupção.
    • Exemplo
      • Alterar coluna
      • Alterar tabela
    • Alternativa
      • Execute-as mais tarde. Essas consultas são necessárias, MAS não precisamos executá-las durante o horário comercial. A melhor política aqui é reduzir o risco de uma interrupção determinando o quão crucial é a tabela, estimando quanto tempo a migração pode levar, executando a migração quando o BD tiver menos tráfego e preparando um plano de reversão.

      • Reduza o tempo que a transação leva. Isso pode ser feito particionando a tabela e executando a migração em partições individuais. Partições PSQL e Django


*O Django só envolve transações em migrações para PSQL e 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"), ]


Fora

  • Operações irreversíveis — Tudo na transação deve ser reversível se a transação for revertida; se colocarmos uma chamada de API na transação, ela não poderá ser desfeita.

    • Exemplos
      • Adicionar um evento a uma fila — acionar outros eventos
      • Enviando uma chamada de API — atualizações de status, tarefas de gatilho, etc.
     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
    • Alternativas
      • Execute a lógica comercial crítica que precisa acontecer no retorno de chamada oncommit da transação — O retorno de chamada oncommit SEMPRE é chamado depois que uma transação é bem-sucedida, e todas as atualizações da transação estão disponíveis no retorno de chamada oncommit.
      • Também podemos tornar a operação reversível, ou seja, criar uma maneira de excluir eventos ou enviar uma chamada de API undo — Esta é uma tarefa não trivial para a equipe que possui a fila de eventos ou a API. Eu não recomendo.
 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!"))


  • Bloqueio de chamadas — como as transações impedem que todas as outras consultas operem nas tabelas/linhas que a transação está alterando, qualquer código que aumente a duração da transação fará com que o banco de dados seja bloqueado, causando tempos limite e falta de resposta nos aplicativos dependentes do banco de dados.


    • Exemplos
      • Chamadas de rede — Envio de solicitações para APIs para obter dados para inserir na transação.
      • O registro ocorre por meio disto — Dependendo do registrador que usamos, a biblioteca pode enviar os logs quando estamos na transação ou, se estivermos usando o datadog, outro processo salvará os logs, mas ainda pagamos o preço de armazenar os logs na memória até que o trabalho em lote os salve em um arquivo.
      • Operações de disco — Carregando um CSV para inserir em uma tabela ou exportando uma tabela para um CSV.
      • Tarefas pesadas de CPU — multiplicação de matrizes ou qualquer transformação matemática/de dados pesada bloqueará a CPU e todas as outras operações — o Bloqueio Global do Interpretador do Python força cada thread a usar a CPU uma de cada vez, e o Django é processado individualmente (cada operação na transação acontece em série).
     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
    • Alternativas
      • Tenha os dados prontos antes da transação — Carregue os dados do disco/rede antecipadamente. Quando os dados estiverem prontos, execute a transação com eles.
      • Use placeholder data — Crie um conjunto de dados placeholder aparentes (sem maneira de confundi-los com dados de produção) que sejam facilmente recuperáveis e identificáveis. Carregue-os antes da transação e use-os.
      • Gerar dados — Se houver restrições de exclusividade nos campos que impeçam o uso de dados de espaço reservado.
      • Isso é arriscado — levará a falhas imprevisíveis devido à natureza dos algoritmos pseudoaleatórios — então use um bom algoritmo aleatório com uma boa semente para evitar falhas surpresa.
      • Remover ou modificar restrições — Se as restrições não estiverem nos permitindo criar nossos registros com segurança, há um problema com nosso esquema. Torne as restrições dependentes de uma coluna de estado que especifica quando o registro está completo e deve ser monitorado.
 # 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)


Próximo — Como os comandos PSQL bloqueiam uns aos outros

No próximo post, vamos nos aprofundar no PSQL e descobrir:

  • Como diferentes comandos bloqueiam tabelas e linhas
  • Quais comandos são mais arriscados de executar

Fontes

  • Atômico
  • Migrações e transações do Django
    • Em bancos de dados que suportam transações DDL (SQLite e PostgreSQL), todas as operações de migração serão executadas dentro de uma única transação por padrão. Em contraste, se um banco de dados não suporta transações DDL (por exemplo, MySQL, Oracle), todas as operações serão executadas sem uma transação.
    • Você pode impedir que uma migração seja executada em uma transação definindo o atributo atomic como False. Por exemplo:
    • Também é possível executar partes da migração dentro de uma transação usando atomic() ou passando atomic=True para RunPython
  • Partições PSQL e Django
    • O ORM do Django não tem suporte integrado para tabelas particionadas, então se você quiser usar partições em seu aplicativo, isso exigirá um pouco mais de trabalho.
    • Uma maneira de usar partições é fazer suas próprias migrações que executam SQL bruto. Isso funcionará, mas significa que você terá que gerenciar manualmente as migrações para todas as alterações que fizer na tabela no futuro.
    • Outra opção é usar um pacote chamado django-postgres-extra. Django-postgres-extra oferece suporte para vários recursos do PostgreSQL que não são incorporados ao ORM do Django, por exemplo, suporte para TRUNCATE TABLE e particionamento de tabela.
  • Transações PSQL
  • Python: Como funcionam as transações Django