paint-brush
Python - Django: Nunca deberías poñer estas cousas nas transacciónspor@mta
Nova historia

Python - Django: Nunca deberías poñer estas cousas nas transaccións

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

Demasiado longo; Ler

As transaccións son fundamentais para as aplicacións de Django, pero se introduces o código incorrecto nas transaccións, podes provocar unha interrupción.
featured image - Python - Django: Nunca deberías poñer estas cousas nas transaccións
Michael T. Andemeskel HackerNoon profile picture


Contido

  • Resumo da parte 1: mergullo profundamente nas transaccións de Django
  • TLDR, Resumo
  • Como poden prexudicar as nosas aplicacións as transaccións?
  • En
  • Arriscado
  • Fóra
  • Seguinte - Como os comandos PSQL se bloquean entre si
  • Fontes

Resumo da parte 1: mergullo profundamente nas transaccións de Django

Na publicación anterior , aprendemos o que ocorre cando se chama unha función decorada con transaction.atomic e o que ocorre with transaction.atomic() . En resumo:


  1. Créase ou recuperase unha conexión á base de datos.
  2. Créase unha transacción, por exemplo, BEGIN; envíase á base de datos (se a base de datos é PSQL ou outra variante de SQL).
  3. A partir de agora ata que exista a función ou conclúa a instrución with, xa sexa cun erro ou con éxito, estaremos na transacción e DB estará agardando por nós.
  4. Isto significa que (polo menos para as bases de datos ACID como PSQL) a transacción manterá bloqueos nas táboas e filas que está a cambiar.
  5. Cando se executa unha operación de base de datos de Django, a base de datos colle o bloqueo correspondente e impide calquera operación conflitiva nesa táboa ou fila.
  6. Isto pode provocar que outras conexións se esgoten debido á espera de que se libere o bloqueo.
  7. Se a operación falla ou se hai un erro de execución, a base de datos retrotrae toda a transacción e libera os bloqueos.
  8. Se toda a transacción ten éxito, todos os cambios están confirmados e dispoñibles para outras conexións de base de datos.
  9. Os peches están soltos.


Agora, discutiremos que poñer nunha transacción e que evitar. Debido aos seguintes comportamentos de transacción, certas operacións son perigosas cando se colocan nun bloque de transaccións.


  • As transaccións manteñen os bloqueos ata que a transacción falla ou se completa.
  • As transaccións inverten todas as súas operacións de base de datos cando fallan.
  • As transaccións solicitan bloqueos tan pronto como se executa unha operación de base de datos.

TLDR, Resumo

En

  • Operacións reversibles na base de datos : se non se pode inverter a operación da base de datos e a transacción falla, a base de datos quedará nun mal estado porque a transacción tentará retrotraer os cambios automaticamente, pero son irreversibles, polo que fallará.
  • Operacións relacionadas na base de datos que son necesarias - Non podemos crear un novo rexistro de conta bancaria sen un rexistro de usuario, polo que debemos crear ambos na mesma transacción.
  • Lóxica comercial reversible e relacionada - Cálculo do saldo total que ten o cliente despois de crear un novo rexistro de conta cun depósito (mesmo isto pode ser eliminado dunha transacción cun modelado e coordinación intelixentes).

Arriscado

  • Consultas lentas : a obtención de datos adoita ser rápida, excepto nestes tres escenarios. Estas consultas ralentizarán a transacción e prolongarán o tempo que mantén os bloqueos, o que terá efectos adversos sobre outros usuarios.
  • Operacións en varias táboas : unha transacción con operacións en varias táboas pode bloquear cada táboa ata que se faga. Isto é especialmente frecuente nas migracións de Django, outra razón para manter as migracións pequenas e centradas nunha ou algunhas táboas á vez.
  • Migracións de datos - Dado que as transaccións manteñen un bloqueo desde que se executa a consulta ata que falla ou finaliza a transacción, unha migración que opera en cada fila da táboa acabará bloqueando a táboa completa, xa sexa impedindo lecturas ou escrituras en cada fila.
  • (Só para PSQL e SQLite*) Cambio de táboas ou columnas : estas operacións requiren a forma máis estrita de bloqueos e, polo tanto, impedirán lecturas/escrituras en toda a táboa. Estas operacións son as máis propensas a provocar unha interrupción.

Fóra

  • Operacións irreversibles : todo o que hai na transacción debería ser reversible se a transacción é revertida; se poñemos unha chamada API na transacción, non se pode desfacer.
  • Bloqueo de chamadas - Dado que as transaccións impiden que todas as outras consultas funcionen nas táboas/filas na que a transacción está a cambiar, calquera código que aumente a duración da transacción fará que se bloquee a base de datos, causando tempo de espera e falta de resposta nas aplicacións dependentes da base de datos.


Continúa lendo alternativas e exemplos de código.

Como poden prexudicar as nosas aplicacións as transaccións?

O risco principal é que as transaccións manteñan bloqueos ata que se fagan para evitar operacións conflitivas en táboas e filas e permitir que a transacción sexa reversible; isto é esencial para que as operacións de base de datos na transacción sexan atómicas. Isto significa que unha transacción de longa duración que opera en varias táboas ou algunhas críticas pode causar interrupcións ao acaparar bloqueos e impedir lecturas/escrituras nesas táboas/filas.


En esencia, se poñemos o código incorrecto nun bloque de transaccións, podemos eliminar a base de datos de forma efectiva bloqueando todas as outras conexións á base de datos para que non realicen operacións nela.


O risco secundario é que as transaccións deben ser reversibles e espérase que sexan reversibles. A base de datos inverte automaticamente cada operación se se produce un erro na transacción. Polo tanto, as operacións de base de datos que poñemos na transacción deberían ser reversibles; na súa maior parte, non necesitamos preocuparnos por isto con PSQL. Pero e outros códigos?


Moitas veces, cando cambiamos os nosos datos, necesitamos facer tarefas de seguimento como activar eventos, actualizar servizos, enviar notificacións push, etc. Estas tarefas NON son reversibles: non podemos anular o envío dun evento, solicitude ou notificación. Se se produce un erro, os cambios de datos revéranse, pero xa enviamos a notificación push dicindo: "Xerouse o teu informe; fai clic aquí para velo". Que ocorre cando o usuario ou outros servizos actúan sobre esta información falsa? Haberá unha cascada de fracasos. Polo tanto, calquera código que non se poida reverter non debería estar nunha transacción ou corremos o risco de deixar o noso sistema nun mal estado cando se produce un erro na transacción.

En

  • Operacións reversibles na base de datos : se a operación da base de datos non se pode inverter e a transacción falla, a base de datos quedará nun mal estado porque a transacción tentará retrotraer os cambios automaticamente, pero son irreversibles, polo que fallará.
  • Operacións relacionadas na base de datos que son necesarias - Non podemos crear un novo rexistro de conta bancaria sen un rexistro de usuario, polo que debemos crear ambos na mesma transacción.
  • Lóxica comercial reversible e relacionada - Cálculo do saldo total que ten o cliente despois de crear un novo rexistro de conta cun depósito (mesmo isto pode ser eliminado dunha transacción cun modelado e coordinación intelixentes).

Arriscado

Estas son cousas que, dependendo da cantidade de datos que se procesen e do tráfico de base de datos, poden causar interrupcións debido a que se mantén os bloqueos durante demasiado tempo. Todas estas cousas están ben se non tardan moito.


  • Consultas lentas : a obtención de datos adoita ser rápida, excepto nestes tres escenarios. Estas consultas ralentizarán a transacción e prolongarán o tempo que mantén os bloqueos, o que terá efectos adversos sobre outros usuarios.

    • Exemplos
      • Consultas en columnas non indexadas
      • Consultas en táboas grandes
      • Xúntase
     @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
      • Fai estas consultas antes e fóra da transacción.
 # 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


  • Operacións en varias táboas : unha transacción con operacións en varias táboas pode bloquear cada táboa ata que se faga. Isto é especialmente frecuente nas migracións de Django, outra razón para manter as migracións pequenas e centradas nunha ou algunhas táboas á vez.


    • Exemplos
      • Consulte Cambio da estrutura da táboa ou columna
     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
      • Divide a transacción en varias transaccións máis pequenas e encadeaas nas devolucións de chamada 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)), ]


  • Migracións de datos — Dado que as transaccións manteñen un bloqueo desde que se executa a consulta ata que a transacción falla ou remata, unha migración que opera en cada fila da táboa acabará bloqueando toda a táboa, xa sexa impedindo lecturas ou escrituras en cada fila.

     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
      • Envolve a transacción arredor das operacións individuais, non da migración completa. Ao poñer as actualizacións en cada fila da transacción, só conservamos os bloqueos durante un 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()


  • ( para PSQL e SQLite *) Cambiar táboas ou columnas : estas operacións requiren a forma máis estrita de bloqueos e, polo tanto, impedirán lecturas/escrituras en toda a táboa. Estas operacións son as máis propensas a provocar unha interrupción.
    • Exemplo
      • Cambiar columna
      • Cambiar a táboa
    • Alternativa
      • Execútalos máis tarde. Estas consultas son necesarias, PERO non temos que executalas en horario comercial. A mellor política aquí é reducir o risco dunha interrupción determinando o crucial que é a táboa, estimando canto tempo pode levar a migración, executar a migración cando a base de datos teña menos tráfico e preparando un plan de retroceso.

      • Reducir o tempo que leva a transacción. Isto pódese facer particionando a táboa e executando a migración en particións individuais. Particións PSQL e Django


*Django só envolve as transaccións en torno ás migracións 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"), ]


Fóra

  • Operacións irreversibles : todo o que hai na transacción debería ser reversible se a transacción é revertida; se poñemos unha chamada API na transacción, non se pode desfacer.

    • Exemplos
      • Engadir un evento a unha cola: activa outros eventos
      • Envío dunha chamada API: actualizacións de estado, traballos de activación, 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
      • Facer a lóxica empresarial crítica que debe ocorrer na devolución de chamada oncommit da transacción: a devolución de chamada oncommit SEMPRE chámase despois de que unha transacción teña éxito e todas as actualizacións da transacción están dispoñibles na devolución de chamada oncommit.
      • Tamén podemos facer reversible a operación, é dicir, crear unha forma de eliminar eventos ou enviar unha chamada á API para desfacer. Esta é unha tarefa non trivial para o equipo propietario da cola de eventos ou da API. Non 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!"))


  • Bloqueo de chamadas : dado que as transaccións impiden que todas as outras consultas funcionen nas táboas/filas na que a transacción está a cambiar, calquera código que aumente a duración da transacción fará que a base de datos se bloquee, provocando tempo de espera e falta de resposta nas aplicacións dependentes da base de datos.


    • Exemplos
      • Chamadas de rede: envío de solicitudes ás API para obter datos para poñer na transacción.
      • O rexistro vai baixo isto — Dependendo do rexistrador que usemos, a biblioteca pode enviar os rexistros cando esteamos na transacción, ou se estamos a usar datadog, terá outro proceso para gardar os rexistros, pero aínda pagamos o prezo de almacenar os rexistros na memoria ata que o traballo por lotes os garde nun ficheiro.
      • Operacións de disco — Cargando un CSV para inserilo nunha táboa ou exportar unha táboa a un CSV.
      • Tarefas pesadas da CPU — A multiplicación de matrices ou calquera transformación pesada de matemáticas/datos bloqueará a CPU e todas as outras operacións — O bloqueo global do intérprete de Python obriga a que cada fío utilice a CPU de cada vez, e Django é de procesamento único (cada operación na transacción ocorre en serie).
     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
      • Prepare os datos antes da transacción — Cargue os datos do disco ou da rede previamente. Unha vez que os datos estean listos, executa a transacción con eles.
      • Usar datos de marcador de posición: crea un conxunto de datos de marcador de posición aparentes (sen xeito de confundilo con datos de produción) que se poidan recuperar e identificar facilmente. Cargao antes da transacción e utilízao.
      • Xerar datos: se hai restricións de exclusividade nos campos que impiden que se utilicen datos de marcador de posición.
      • Isto é arriscado: provocará fallos imprevisibles debido á natureza dos algoritmos pseudoaleatorios, polo que utiliza un bo algoritmo aleatorio cunha boa semente para evitar fallos sorpresa.
      • Eliminar ou modificar restricións: se as restricións non nos permiten crear os nosos rexistros con seguridade, hai un problema co noso esquema. Fai que as restricións dependan dunha columna de estado que especifique cando se completa o rexistro e debe ser supervisado.
 # 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)


Seguinte: como se bloquean os comandos PSQL entre si

Na seguinte publicación, mergullaremos en PSQL e descubriremos:

  • Como os diferentes comandos bloquean táboas e filas
  • Que comandos son os máis arriscados de executar

Fontes

  • Atómico
  • Migracións e transaccións de Django
    • Nas bases de datos que admiten transaccións DDL (SQLite e PostgreSQL), todas as operacións de migración executaranse dentro dunha única transacción por defecto. Pola contra, se unha base de datos non admite transaccións DDL (por exemplo, MySQL, Oracle), todas as operacións executaranse sen transacción.
    • Pode evitar que se execute unha migración nunha transacción configurando o atributo atómico en False. Por exemplo:
    • Tamén é posible executar partes da migración dentro dunha transacción usando atomic() ou pasando atomic=True a RunPython
  • Particións PSQL e Django
    • O ORM de Django non ten soporte integrado para táboas particionadas, polo que se queres usar particións na túa aplicación, vai levar un pouco de traballo extra.
    • Unha forma de usar particións é tirar as túas propias migracións que executen SQL en bruto. Isto funcionará, pero significa que terás que xestionar manualmente as migracións para todos os cambios que fagas na táboa no futuro.
    • Outra opción é usar un paquete chamado django-postgres-extra. Django-postgres-extra ofrece soporte para varias funcións de PostgreSQL que non están integradas no ORM de Django, por exemplo, soporte para TRUNCATE TABLE e partición de táboas.
  • Transaccións PSQL
  • Python: como funcionan as transaccións de Django