paint-brush
Python - Django: Nunca deberías poner estas cosas en las transaccionespor@mta
Nueva Historia

Python - Django: Nunca deberías poner estas cosas en las transacciones

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

Demasiado Largo; Para Leer

Las transacciones son fundamentales para las aplicaciones Django, pero si colocas el código incorrecto en las transacciones, ¡puedes provocar una interrupción!
featured image - Python - Django: Nunca deberías poner estas cosas en las transacciones
Michael T. Andemeskel HackerNoon profile picture


Contenido

  • Parte 1 Resumen: Análisis profundo de las transacciones de Django
  • TLDR, Resumen
  • ¿Cómo pueden las transacciones dañar nuestras aplicaciones?
  • En
  • Arriesgado
  • Afuera
  • Siguiente - Cómo los comandos PSQL se bloquean entre sí
  • Fuentes

Parte 1 Resumen: Análisis profundo de las transacciones de Django

En la publicación anterior, aprendimos qué sucede cuando se llama a una función decorada con transaction.atomic y qué sucede with transaction.atomic() . En resumen:


  1. Se crea o recupera una conexión a la base de datos.
  2. Se crea una transacción, por ejemplo, BEGIN; se envía a la base de datos (si la base de datos es PSQL u otra variante de SQL).
  3. Desde ahora hasta que la función exista o la instrucción with concluya (ya sea con un error o con éxito), estaremos en la transacción y DB nos estará esperando.
  4. Esto significa que (al menos para bases de datos ACID como PSQL) la transacción mantendrá bloqueos en las tablas y filas que esté modificando.
  5. Cuando se ejecuta una operación de base de datos Django, la base de datos toma el bloqueo correspondiente y evita cualquier operación conflictiva en esa tabla o fila.
  6. Esto puede provocar que otras conexiones agoten el tiempo de espera debido a que se libera el bloqueo.
  7. Si la operación falla o si hay un error de tiempo de ejecución, la base de datos revierte toda la transacción y libera los bloqueos.
  8. Si toda la transacción tiene éxito, todos los cambios se confirman y están disponibles para otras conexiones de base de datos.
  9. Los bloqueos están liberados.


Ahora, analizaremos qué incluir en una transacción y qué evitar. Debido a los siguientes comportamientos de transacción, ciertas operaciones son peligrosas cuando se colocan en un bloque de transacción.


  • Las transacciones se mantienen bloqueadas hasta que fallan o se completan.
  • Las transacciones revierten todas sus operaciones de base de datos cuando fallan.
  • Las solicitudes de transacciones se bloquean tan pronto como se ejecuta una operación de base de datos.

TLDR, Resumen

En

  • Operaciones reversibles en la base de datos : si la operación en la base de datos no se puede revertir y la transacción falla, la base de datos quedará en un mal estado porque la transacción intentará revertir los cambios automáticamente, pero son irreversibles, por lo que fallará.
  • Operaciones relacionadas en la base de datos que son necesarias : no podemos crear un nuevo registro de cuenta bancaria sin un registro de usuario, por lo que debemos crear ambos en la misma transacción.
  • Lógica de negocios reversible y relacionada : cálculo del saldo total que tiene el cliente después de crear un nuevo registro de cuenta con un depósito (incluso esto se puede sacar de una transacción con un modelado y una coordinación inteligentes).

Arriesgado

  • Consultas lentas : la obtención de datos suele ser rápida, excepto en estos tres casos. Estas consultas ralentizarán la transacción y prolongarán el tiempo de bloqueo, lo que tendrá efectos adversos para otros usuarios.
  • Operaciones en varias tablas : una transacción con operaciones en varias tablas puede bloquear cada tabla hasta que finalice. Esto es especialmente frecuente en las migraciones de Django: otra razón para mantener las migraciones pequeñas y centradas en una o varias tablas a la vez.
  • Migraciones de datos : dado que las transacciones mantienen un bloqueo desde que se ejecuta la consulta hasta que la transacción falla o finaliza, una migración que opera en cada fila de la tabla terminará bloqueando toda la tabla, ya sea al evitar lecturas o escrituras en cada fila.
  • (Solo para PSQL y SQLite*) Cambiar tablas o columnas : estas operaciones requieren la forma más estricta de bloqueos y, por lo tanto, impedirán las lecturas y escrituras en toda la tabla. Estas operaciones son las que tienen más probabilidades de provocar una interrupción del servicio.

Afuera

  • Operaciones irreversibles : todo en la transacción debe ser reversible si la transacción se revierte; si colocamos una llamada API en la transacción, no se puede deshacer.
  • Bloqueo de llamadas : dado que las transacciones impiden que todas las demás consultas operen en las tablas/filas que la transacción está modificando, cualquier código que aumente la duración de la transacción provocará que la base de datos se bloquee, lo que ocasiona tiempos de espera y falta de respuesta en las aplicaciones que dependen de la base de datos.


Continúe leyendo para conocer alternativas y ejemplos de código.

¿Cómo pueden las transacciones dañar nuestras aplicaciones?

El riesgo principal es que las transacciones mantengan bloqueos hasta que finalicen para evitar operaciones conflictivas en tablas y filas y permitir que la transacción sea reversible; esto es esencial para que las operaciones de la base de datos en la transacción sean atómicas. Esto significa que una transacción de larga duración que opera en varias tablas o en unas pocas tablas críticas puede causar interrupciones al acaparar bloqueos e impedir lecturas/escrituras en esas tablas/filas.


En esencia, si colocamos el código incorrecto en un bloque de transacción, podemos efectivamente inutilizar la base de datos al bloquear todas las demás conexiones a la base de datos para que no puedan realizar operaciones en ella.


El riesgo secundario es que las transacciones deben ser reversibles y se ESPERA que lo sean. La base de datos revierte automáticamente cada operación si ocurre un error en la transacción. Por lo tanto, las operaciones de la base de datos que ponemos en la transacción deben ser reversibles; en su mayor parte, no necesitamos preocuparnos por esto con PSQL. Pero ¿qué sucede con otros códigos?


A menudo, cuando cambiamos nuestros datos, necesitamos realizar tareas de seguimiento como activar eventos, actualizar servicios, enviar notificaciones push, etc. Estas tareas NO son reversibles: no podemos anular el envío de un evento, una solicitud o una notificación. Si se produce un error, los cambios en los datos se revierten, pero ya hemos enviado la notificación push que dice: "Se ha generado su informe; haga clic aquí para verlo". ¿Qué sucede cuando el usuario u otros servicios actúan sobre esta información falsa? Habrá una cascada de errores. Por lo tanto, cualquier código que no se pueda revertir no debería estar en una transacción, o corremos el riesgo de dejar nuestro sistema en un mal estado cuando se produce un error en la transacción.

En

  • Operaciones reversibles en la base de datos : si la operación en la base de datos no se puede revertir y la transacción falla, la base de datos quedará en un mal estado porque la transacción intentará revertir los cambios automáticamente, pero son irreversibles, por lo que fallará.
  • Operaciones relacionadas en la base de datos que son necesarias : no podemos crear un nuevo registro de cuenta bancaria sin un registro de usuario, por lo que debemos crear ambos en la misma transacción.
  • Lógica de negocios reversible y relacionada : cálculo del saldo total que tiene el cliente después de crear un nuevo registro de cuenta con un depósito (incluso esto se puede sacar de una transacción con un modelado y una coordinación inteligentes).

Arriesgado

Se trata de cosas que, según la cantidad de datos que se estén procesando y el tráfico de la base de datos, pueden provocar interrupciones debido a la retención de bloqueos durante demasiado tiempo. Todas estas cosas están bien si no tardan demasiado.


  • Consultas lentas : la obtención de datos suele ser rápida, excepto en estos tres casos. Estas consultas ralentizarán la transacción y prolongarán el tiempo de bloqueo, lo que tendrá efectos adversos para otros usuarios.

    • Ejemplos
      • Consultas sobre columnas no indexadas
      • Consultas en tablas grandes
      • Se une
     @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
      • Realice estas consultas antes y fuera de la 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


  • Operaciones en varias tablas : una transacción con operaciones en varias tablas puede bloquear cada tabla hasta que finalice. Esto es especialmente frecuente en las migraciones de Django, otra razón para mantener las migraciones pequeñas y centradas en una o unas pocas tablas a la vez.


    • Ejemplos
      • Ver Cambiar la estructura de una tabla o 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
      • Divida la transacción en varias transacciones más pequeñas y encadénelas en las devoluciones de llamadas 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)), ]


  • Migraciones de datos : dado que las transacciones mantienen un bloqueo desde que se ejecuta la consulta hasta que la transacción falla o finaliza, una migración que opera en cada fila de la tabla terminará bloqueando toda la tabla, ya sea al evitar lecturas o 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
      • Envuelva la transacción en torno a las operaciones individuales, no a toda la migración. Al colocar las actualizaciones en cada fila de la transacción, solo conservamos los bloqueos durante un breve período de tiempo.
 # 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()


  • ( Solo para PSQL y SQLite*) Cambiar tablas o columnas : estas operaciones requieren la forma más estricta de bloqueos y, por lo tanto, evitarán lecturas y escrituras en toda la tabla. Estas operaciones son las que tienen más probabilidades de provocar una interrupción del servicio.
    • Ejemplo
      • Columna del altar
      • Mesa del altar
    • Alternativa
      • Ejecútelas más tarde. Estas consultas son necesarias, PERO no tenemos que ejecutarlas durante el horario comercial. La mejor política en este caso es reducir el riesgo de una interrupción determinando la importancia de la tabla, estimando cuánto tiempo puede llevar la migración, ejecutando la migración cuando la base de datos tenga menos tráfico y preparando un plan de recuperación.

      • Reduzca la cantidad de tiempo que demora la transacción. Esto se puede lograr dividiendo la tabla y ejecutando la migración en particiones individuales. Particiones PSQL y Django


*Django solo envuelve transacciones alrededor de migraciones para PSQL y 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"), ]


Afuera

  • Operaciones irreversibles : todo en la transacción debe ser reversible si la transacción se revierte; si colocamos una llamada API en la transacción, no se puede deshacer.

    • Ejemplos
      • Agregar un evento a una cola: activar otros eventos
      • Envío de una llamada API: actualizaciones de estado, trabajos 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
      • Realizar la lógica empresarial crítica que debe realizarse en la devolución de llamada oncommit de la transacción: la devolución de llamada oncommit SIEMPRE se llama después de que una transacción es exitosa, y todas las actualizaciones de la transacción están disponibles en la devolución de llamada oncommit.
      • También podemos hacer que la operación sea reversible, es decir, crear una forma de eliminar eventos o enviar una llamada API para deshacer. Esta es una tarea nada trivial para el equipo que posee la cola de eventos o la API. No lo recomiendo.
 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 llamadas : dado que las transacciones impiden que todas las demás consultas operen en las tablas/filas que la transacción está modificando, cualquier código que aumente la duración de la transacción hará que la base de datos se bloquee, lo que ocasiona tiempos de espera y falta de respuesta en las aplicaciones que dependen de la base de datos.


    • Ejemplos
      • Llamadas de red: envío de solicitudes a las API para obtener datos para incluir en la transacción.
      • El registro se realiza de esta manera: dependiendo del registrador que usemos, la biblioteca puede enviar los registros cuando estamos en la transacción o, si usamos datadog, tendrá otro proceso que guarde los registros, pero aún pagamos el precio de almacenar los registros en la memoria hasta que el trabajo por lotes los guarde en un archivo.
      • Operaciones de disco: cargar un CSV para insertarlo en una tabla o exportar una tabla a un CSV.
      • Tareas intensivas de CPU: la multiplicación de matrices o cualquier transformación pesada de datos o matemáticas bloqueará la CPU y todas las demás operaciones. El bloqueo del intérprete global de Python obliga a cada hilo a usar la CPU de a uno por vez, y Django tiene un procesamiento único (cada operación en la transacción ocurre 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 los datos antes de la transacción: cargue los datos del disco o de la red con anticipación. Una vez que los datos estén listos, ejecute la transacción con ellos.
      • Utilizar datos de marcador de posición: crear un conjunto de datos de marcador de posición aparentes (que no se puedan confundir con datos de producción) que se puedan recuperar e identificar fácilmente. Cárguelos antes de la transacción y utilícelos.
      • Generar datos: si existen restricciones de unicidad en los campos que impiden que se utilicen datos de marcador de posición.
      • Esto es riesgoso: provocará fallas impredecibles debido a la naturaleza de los algoritmos pseudoaleatorios, así que utilice un buen algoritmo aleatorio con una buena semilla para evitar fallas sorpresivas.
      • Eliminar o modificar restricciones: si las restricciones no nos permiten crear nuestros registros de forma segura, hay un problema con nuestro esquema. Haga que las restricciones dependan de una columna de estado que especifique cuándo se completa el registro y cuándo se debe supervisar.
 # 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)


Siguiente: Cómo los comandos PSQL se bloquean entre sí

En la próxima publicación, profundizaremos en PSQL y descubriremos:

  • Cómo diferentes comandos bloquean tablas y filas
  • ¿Qué comandos son los más riesgosos de ejecutar?

Fuentes

  • Atómico
  • Migraciones y transacciones de Django
    • En las bases de datos que admiten transacciones DDL (SQLite y PostgreSQL), todas las operaciones de migración se ejecutarán dentro de una única transacción de forma predeterminada. Por el contrario, si una base de datos no admite transacciones DDL (por ejemplo, MySQL u Oracle), todas las operaciones se ejecutarán sin una transacción.
    • Puede evitar que se ejecute una migración en una transacción configurando el atributo atómico en Falso. Por ejemplo:
    • También es posible ejecutar partes de la migración dentro de una transacción usando atomic() o pasando atomic=True a RunPython
  • Particiones PSQL y Django
    • El ORM de Django no tiene soporte integrado para tablas particionadas, por lo que si desea utilizar particiones en su aplicación, le llevará un poco de trabajo extra.
    • Una forma de usar particiones es crear sus propias migraciones que ejecuten SQL sin formato. Esto funcionará, pero significa que tendrá que administrar manualmente las migraciones para todos los cambios que realice en la tabla en el futuro.
    • Otra opción es utilizar un paquete llamado django-postgres-extra. Django-postgres-extra ofrece compatibilidad con varias funciones de PostgreSQL que no están integradas en el ORM de Django, por ejemplo, compatibilidad con TRUNCATE TABLE y particionamiento de tablas.
  • Transacciones PSQL
  • Python: cómo funcionan las transacciones de Django