sql >> Base de Datos >  >> RDS >> Database

Cómo crear un índice en Django sin tiempo de inactividad

La gestión de migraciones de bases de datos es un gran desafío en cualquier proyecto de software. Afortunadamente, a partir de la versión 1.7, Django viene con un marco de migración incorporado. El marco es muy poderoso y útil en la gestión de cambios en las bases de datos. Pero la flexibilidad proporcionada por el marco requería algunos compromisos. Para comprender las limitaciones de las migraciones de Django, abordará un problema bien conocido:crear un índice en Django sin tiempo de inactividad.

En este tutorial, aprenderá:

  • Cómo y cuándo Django genera nuevas migraciones
  • Cómo inspeccionar los comandos que genera Django para ejecutar migraciones
  • Cómo modificar de forma segura las migraciones para que se ajusten a sus necesidades

Este tutorial de nivel intermedio está diseñado para lectores que ya están familiarizados con las migraciones de Django. Para obtener una introducción a ese tema, consulte Migraciones de Django:una introducción.

Bono gratis: Haga clic aquí para obtener acceso gratuito a tutoriales y recursos adicionales de Django que puede usar para profundizar sus habilidades de desarrollo web de Python.


El problema de crear un índice en las migraciones de Django

Un cambio común que generalmente se vuelve necesario cuando crecen los datos almacenados por su aplicación es agregar un índice. Los índices se utilizan para acelerar las consultas y hacer que su aplicación se sienta rápida y receptiva.

En la mayoría de las bases de datos, agregar un índice requiere un bloqueo exclusivo en la tabla. Un bloqueo exclusivo evita operaciones de modificación de datos (DML) como UPDATE , INSERT y DELETE , mientras se crea el índice.

La base de datos obtiene los bloqueos implícitamente al ejecutar ciertas operaciones. Por ejemplo, cuando un usuario inicia sesión en su aplicación, Django actualizará el last_login campo en el auth_user mesa. Para realizar la actualización, la base de datos primero deberá obtener un bloqueo en la fila. Si otra conexión está bloqueando la fila, es posible que obtenga una excepción de la base de datos.

Bloquear una tabla puede representar un problema cuando es necesario mantener el sistema disponible durante las migraciones. Cuanto más grande sea la tabla, más tiempo puede llevar crear el índice. Cuanto más se tarde en crear el índice, más tiempo no estará disponible el sistema o no responderá a los usuarios.

Algunos proveedores de bases de datos ofrecen una forma de crear un índice sin bloquear la tabla. Por ejemplo, para crear un índice en PostgreSQL sin bloquear una tabla, puede usar CONCURRENTLY palabra clave:

CREATE INDEX CONCURRENTLY ix ON table (column);

En Oracle, hay un ONLINE opción para permitir operaciones DML en la tabla mientras se crea el índice:

CREATE INDEX ix ON table (column) ONLINE;

Al generar migraciones, Django no utilizará estas palabras clave especiales. Ejecutar la migración tal cual hará que la base de datos adquiera un bloqueo exclusivo en la tabla y evitará las operaciones DML mientras se crea el índice.

La creación simultánea de un índice tiene algunas advertencias. Es importante comprender de antemano los problemas específicos del backend de su base de datos. Por ejemplo, una advertencia en PostgreSQL es que crear un índice simultáneamente lleva más tiempo porque requiere un escaneo de tabla adicional.

En este tutorial, usará las migraciones de Django para crear un índice en una tabla grande, sin causar ningún tiempo de inactividad.

Nota: Para seguir este tutorial, se recomienda que utilice un backend de PostgreSQL, Django 2.x y Python 3.

También es posible seguir junto con otros backends de bases de datos. En lugares donde se utilizan características de SQL exclusivas de PostgreSQL, cambie el SQL para que coincida con el backend de su base de datos.



Configuración

Vas a usar una Sale inventada modelo en una aplicación llamada app . En una situación de la vida real, modelos como Sale son las tablas principales de la base de datos y, por lo general, serán muy grandes y almacenarán una gran cantidad de datos:

# models.py

from django.db import models

class Sale(models.Model):
    sold_at = models.DateTimeField(
        auto_now_add=True,
    )
    charged_amount = models.PositiveIntegerField()

Para crear la tabla, genera la migración inicial y aplícala:

$ python manage.py makemigrations
Migrations for 'app':
  app/migrations/0001_initial.py
    - Create model Sale

$ python manage migrate
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0001_initial... OK

Después de un tiempo, la tabla de ventas se vuelve muy grande y los usuarios comienzan a quejarse de la lentitud. Mientras monitoreaba la base de datos, notó que muchas consultas usan el sold_at columna. Para acelerar las cosas, decide que necesita un índice en la columna.

Para agregar un índice en sold_at , realiza el siguiente cambio en el modelo:

# models.py

from django.db import models

class Sale(models.Model):
    sold_at = models.DateTimeField(
        auto_now_add=True,
        db_index=True,
    )
    charged_amount = models.PositiveIntegerField()

Si ejecuta esta migración tal como está, Django creará el índice en la tabla y se bloqueará hasta que se complete el índice. Puede tomar un tiempo crear un índice en una tabla muy grande y desea evitar el tiempo de inactividad.

En un entorno de desarrollo local con un conjunto de datos pequeño y muy pocas conexiones, esta migración puede parecer instantánea. Sin embargo, en grandes conjuntos de datos con muchas conexiones simultáneas, obtener un bloqueo y crear el índice puede llevar un tiempo.

En los próximos pasos, modificará las migraciones creadas por Django para crear el índice sin causar ningún tiempo de inactividad.



Migración falsa

El primer enfoque es crear el índice manualmente. Vas a generar la migración, pero no vas a dejar que Django la aplique. En su lugar, ejecutará el SQL manualmente en la base de datos y luego hará que Django piense que la migración se completó.

Primero, genera la migración:

$ python manage.py makemigrations --name add_index_fake
Migrations for 'app':
  app/migrations/0002_add_index_fake.py
    - Alter field sold_at on sale

Usa el sqlmigrate Comando para ver el SQL que Django usará para ejecutar esta migración:

$ python manage.py sqlmigrate app 0002
BEGIN;
--
-- Alter field sold_at on sale
--
CREATE INDEX "app_sale_sold_at_b9438ae4" ON "app_sale" ("sold_at");
COMMIT;

Desea crear el índice sin bloquear la tabla, por lo que debe modificar el comando. Agregue el CONCURRENTLY palabra clave y ejecutar en la base de datos:

app=# CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
ON "app_sale" ("sold_at");

CREATE INDEX

Observe que ejecutó el comando sin BEGIN y COMMIT partes. Omitir estas palabras clave ejecutará los comandos sin una transacción de base de datos. Discutiremos las transacciones de la base de datos más adelante en este artículo.

Después de ejecutar el comando, si intenta aplicar migraciones, obtendrá el siguiente error:

$ python manage.py migrate

Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_fake...Traceback (most recent call last):
  File "venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 85, in _execute
    return self.cursor.execute(sql, params)

psycopg2.ProgrammingError: relation "app_sale_sold_at_b9438ae4" already exists

Django se queja de que el índice ya existe, por lo que no puede continuar con la migración. Acaba de crear el índice directamente en la base de datos, por lo que ahora debe hacer que Django piense que la migración ya se aplicó.

Cómo falsificar una migración

Django proporciona una forma integrada de marcar las migraciones como ejecutadas, sin ejecutarlas realmente. Para usar esta opción, configure el --fake marcar al aplicar la migración:

$ python manage.py migrate --fake
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_fake... FAKED

Django no generó un error esta vez. De hecho, Django realmente no aplicó ninguna migración. Simplemente lo marcó como ejecutado (o FAKED) ).

Estos son algunos problemas a tener en cuenta al falsificar migraciones:

  • El comando manual debe ser equivalente al SQL generado por Django: Debe asegurarse de que el comando que ejecuta sea equivalente al SQL generado por Django. Usa sqlmigrate para producir el comando SQL. Si los comandos no coinciden, es posible que termine con inconsistencias entre la base de datos y el estado de los modelos.

  • También se falsificarán otras migraciones no aplicadas: Cuando tenga varias migraciones sin aplicar, todas serán falsificadas. Antes de aplicar las migraciones, es importante asegurarse de que solo no se apliquen las migraciones que desea falsificar. De lo contrario, podría terminar con inconsistencias. Otra opción es especificar la migración exacta que desea falsificar.

  • Se requiere acceso directo a la base de datos: Debe ejecutar el comando SQL en la base de datos. Esto no siempre es una opción. Además, ejecutar comandos directamente en una base de datos de producción es peligroso y debe evitarse cuando sea posible.

  • Los procesos de implementación automatizados pueden necesitar ajustes: Si automatizó el proceso de implementación (usando CI, CD u otras herramientas de automatización), es posible que deba modificar el proceso para migraciones falsas. Esto no siempre es deseable.

Limpieza

Antes de pasar a la siguiente sección, debe devolver la base de datos a su estado justo después de la migración inicial. Para hacer eso, vuelva a migrar a la migración inicial:

$ python manage.py migrate 0001
Operations to perform:
  Target specific migration: 0001_initial, from app
Running migrations:
  Rendering model states... DONE
  Unapplying app.0002_add_index_fake... OK

Django no aplicó los cambios realizados en la segunda migración, por lo que ahora también es seguro eliminar el archivo:

$ rm app/migrations/0002_add_index_fake.py

Para asegurarse de que hizo todo bien, inspeccione las migraciones:

$ python manage.py showmigrations app
app
 [X] 0001_initial

Se aplicó la migración inicial y no hay migraciones sin aplicar.



Ejecutar SQL sin procesar en migraciones

En la sección anterior, ejecutó SQL directamente en la base de datos y falsificó la migración. Esto hace el trabajo, pero hay una solución mejor.

Django proporciona una forma de ejecutar SQL sin procesar en migraciones usando RunSQL . Intentemos usarlo en lugar de ejecutar el comando directamente en la base de datos.

Primero, genera una nueva migración vacía:

$ python manage.py makemigrations app --empty --name add_index_runsql
Migrations for 'app':
  app/migrations/0002_add_index_runsql.py

A continuación, edite el archivo de migración y agregue un RunSQL operación:

# migrations/0002_add_index_runsql.py

from django.db import migrations, models

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [
        migrations.RunSQL(
            'CREATE INDEX "app_sale_sold_at_b9438ae4" '
            'ON "app_sale" ("sold_at");',
        ),
    ]

Cuando ejecute la migración, obtendrá el siguiente resultado:

$ python manage.py migrate
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_runsql... OK

Esto se ve bien, pero hay un problema. Intentemos generar migraciones nuevamente:

$ python manage.py makemigrations --name leftover_migration
Migrations for 'app':
  app/migrations/0003_leftover_migration.py
    - Alter field sold_at on sale

Django volvió a generar la misma migración. ¿Por qué hizo eso?

Limpieza

Antes de que podamos responder a esa pregunta, debe limpiar y deshacer los cambios que realizó en la base de datos. Comience por eliminar la última migración. No se aplicó, por lo que es seguro eliminarlo:

$ rm app/migrations/0003_leftover_migration.py

A continuación, enumere las migraciones para la app aplicación:

$ python manage.py showmigrations app
app
 [X] 0001_initial
 [X] 0002_add_index_runsql

La tercera migración se ha ido, pero se aplica la segunda. Quiere volver al estado inmediatamente después de la migración inicial. Intente volver a la migración inicial como lo hizo en la sección anterior:

$ python manage.py migrate app 0001
Operations to perform:
  Target specific migration: 0001_initial, from app
Running migrations:
  Rendering model states... DONE
  Unapplying app.0002_add_index_runsql...Traceback (most recent call last):

NotImplementedError: You cannot reverse this operation

Django no puede revertir la migración.



Operación de migración inversa

Para revertir una migración, Django ejecuta una acción opuesta para cada operación. En este caso, lo contrario de agregar un índice es eliminarlo. Como ya has visto, cuando una migración es reversible, puedes dejar de aplicarla. Al igual que puede usar checkout en Git, puede revertir una migración si ejecuta migrate a una migración anterior.

Muchas operaciones de migración integradas ya definen una acción inversa. Por ejemplo, la acción inversa para agregar un campo es eliminar la columna correspondiente. La acción inversa para crear un modelo es soltar la tabla correspondiente.

Algunas operaciones de migración no son reversibles. Por ejemplo, no existe una acción inversa para eliminar un campo o eliminar un modelo, porque una vez que se aplicó la migración, los datos desaparecen.

En la sección anterior, utilizó el RunSQL operación. Cuando intentó revertir la migración, encontró un error. Según el error, una de las operaciones en la migración no se puede revertir. Django no puede revertir SQL sin formato de forma predeterminada. Debido a que Django no tiene conocimiento de lo que ejecutó la operación, no puede generar una acción opuesta automáticamente.

Cómo hacer que una migración sea reversible

Para que una migración sea reversible, todas las operaciones en ella deben ser reversibles. No es posible revertir parte de una migración, por lo que una sola operación no reversible hará que toda la migración no sea reversible.

Para hacer un RunSQL operación reversible, debe proporcionar SQL para ejecutar cuando se invierte la operación. El SQL inverso se proporciona en el reverse_sql argumento.

La acción opuesta a agregar un índice es eliminarlo. Para hacer que su migración sea reversible, proporcione el reverse_sql para soltar el índice:

# migrations/0002_add_index_runsql.py

from django.db import migrations, models

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [
        migrations.RunSQL(
            'CREATE INDEX "app_sale_sold_at_b9438ae4" '
            'ON "app_sale" ("sold_at");',

            reverse_sql='DROP INDEX "app_sale_sold_at_b9438ae4";',
        ),
    ]

Ahora intente revertir la migración:

$ python manage.py showmigrations app
app
 [X] 0001_initial
 [X] 0002_add_index_runsql

$ python manage.py migrate app 0001
Operations to perform:
  Target specific migration: 0001_initial, from app
Running migrations:
  Rendering model states... DONE
 Unapplying app.0002_add_index_runsql... OK

$ python manage.py showmigrations app
app
 [X] 0001_initial
 [ ] 0002_add_index_runsql

La segunda migración se invirtió y Django eliminó el índice. Ahora es seguro eliminar el archivo de migración:

$ rm app/migrations/0002_add_index_runsql.py

Siempre es una buena idea proporcionar reverse_sql . En situaciones en las que revertir una operación de SQL sin procesar no requiere ninguna acción, puede marcar la operación como reversible utilizando el centinela especial migrations.RunSQL.noop :

migrations.RunSQL(
    sql='...',  # Your forward SQL here
    reverse_sql=migrations.RunSQL.noop,
),


Comprender el estado del modelo y el estado de la base de datos

En su intento anterior de crear el índice manualmente usando RunSQL , Django generó la misma migración una y otra vez a pesar de que el índice se creó en la base de datos. Para comprender por qué Django hizo eso, primero debe comprender cómo decide Django cuándo generar nuevas migraciones.


Cuando Django genera una nueva migración

En el proceso de generar y aplicar migraciones, Django sincroniza entre el estado de la base de datos y el estado de los modelos. Por ejemplo, cuando agrega un campo a un modelo, Django agrega una columna a la tabla. Cuando elimina un campo del modelo, Django elimina la columna de la tabla.

Para sincronizar entre los modelos y la base de datos, Django mantiene un estado que representa los modelos. Para sincronizar la base de datos con los modelos, Django genera operaciones de migración. Las operaciones de migración se traducen en un SQL específico del proveedor que se puede ejecutar en la base de datos. Cuando se ejecutan todas las operaciones de migración, se espera que la base de datos y los modelos sean coherentes.

Para obtener el estado de la base de datos, Django agrega las operaciones de todas las migraciones anteriores. Cuando el estado agregado de las migraciones no es consistente con el estado de los modelos, Django genera una nueva migración.

En el ejemplo anterior, creó el índice utilizando SQL sin formato. Django no sabía que creaste el índice porque no usaste una operación de migración familiar.

Cuando Django agregó todas las migraciones y las comparó con el estado de los modelos, descubrió que faltaba un índice. Es por eso que, incluso después de crear el índice manualmente, Django aún pensó que faltaba y generó una nueva migración para él.



Cómo separar la base de datos y el estado en las migraciones

Dado que Django no puede crear el índice de la manera que desea, desea proporcionar su propio SQL pero aún así dejar que Django sepa que lo creó.

En otras palabras, debe ejecutar algo en la base de datos y proporcionar a Django la operación de migración para sincronizar su estado interno. Para hacer eso, Django nos proporciona una operación de migración especial llamada SeparateDatabaseAndState . Esta operación no es muy conocida y debe reservarse para casos especiales como este.

Es mucho más fácil editar migraciones que escribirlas desde cero, así que comience generando una migración de la forma habitual:

$ python manage.py makemigrations --name add_index_separate_database_and_state

Migrations for 'app':
  app/migrations/0002_add_index_separate_database_and_state.py
    - Alter field sold_at on sale

Este es el contenido de la migración generada por Django, igual que antes:

# migrations/0002_add_index_separate_database_and_state.py

from django.db import migrations, models

class Migration(migrations.Migration):

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [
        migrations.AlterField(
            model_name='sale',
            name='sold_at',
            field=models.DateTimeField(
                auto_now_add=True,
                db_index=True,
            ),
        ),
    ]

Django generó un AlterField operación en el campo sold_at . La operación creará un índice y actualizará el estado. Queremos mantener esta operación pero proporcionar un comando diferente para ejecutar en la base de datos.

Una vez más, para obtener el comando, use el SQL generado por Django:

$ python manage.py sqlmigrate app 0002
BEGIN;
--
-- Alter field sold_at on sale
--
CREATE INDEX "app_sale_sold_at_b9438ae4" ON "app_sale" ("sold_at");
COMMIT;

Agregue el CONCURRENTLY palabra clave en el lugar apropiado:

CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
ON "app_sale" ("sold_at");

Luego, edite el archivo de migración y use SeparateDatabaseAndState para proporcionar su comando SQL modificado para su ejecución:

# migrations/0002_add_index_separate_database_and_state.py

from django.db import migrations, models

class Migration(migrations.Migration):

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [

        migrations.SeparateDatabaseAndState(

            state_operations=[
                migrations.AlterField(
                    model_name='sale',
                    name='sold_at',
                    field=models.DateTimeField(
                        auto_now_add=True,
                        db_index=True,
                    ),
                ),
            ],

            database_operations=[
                migrations.RunSQL(sql="""
                    CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
                    ON "app_sale" ("sold_at");
                """, reverse_sql="""
                    DROP INDEX "app_sale_sold_at_b9438ae4";
                """),
            ],
        ),

    ],

La operación de migración SeparateDatabaseAndState acepta 2 listas de operaciones:

  1. operaciones_estado son operaciones a aplicar sobre el estado del modelo interno. No afectan la base de datos.
  2. operaciones_de_base_de_datos son operaciones para aplicar a la base de datos.

Mantuviste la operación original generada por Django en state_operations . Al usar SeparateDatabaseAndState , esto es lo que normalmente querrá hacer. Observe que db_index=True argumento se proporciona al campo. Esta operación de migración le permitirá a Django saber que hay un índice en el campo.

Usaste el SQL generado por Django y agregaste el CONCURRENTLY palabra clave. Usaste la acción especial RunSQL para ejecutar SQL sin procesar en la migración.

Si intenta ejecutar la migración, obtendrá el siguiente resultado:

$ python manage.py migrate app
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_separate_database_and_state...Traceback (most recent call last):
  File "/venv/lib/python3.7/site-packages/django/db/backends/utils.py", line 83, in _execute
    return self.cursor.execute(sql)
psycopg2.InternalError: CREATE INDEX CONCURRENTLY cannot run inside a transaction block



Migraciones no atómicas

En SQL, CREATE , DROP , ALTER y TRUNCATE las operaciones se conocen como lenguaje de definición de datos (DDL). En bases de datos que admiten DDL transaccional, como PostgreSQL, Django ejecuta migraciones dentro de una transacción de base de datos de forma predeterminada. Sin embargo, según el error anterior, PostgreSQL no puede crear un índice simultáneamente dentro de un bloque de transacciones.

Para poder crear un índice simultáneamente dentro de una migración, debe decirle a Django que no ejecute la migración en una transacción de base de datos. Para hacer eso, marque la migración como no atómica configurando atomic a False :

# migrations/0002_add_index_separate_database_and_state.py

from django.db import migrations, models

class Migration(migrations.Migration):
    atomic = False

    dependencies = [
        ('app', '0001_initial'),
    ]

    operations = [

        migrations.SeparateDatabaseAndState(

            state_operations=[
                migrations.AlterField(
                    model_name='sale',
                    name='sold_at',
                    field=models.DateTimeField(
                        auto_now_add=True,
                        db_index=True,
                    ),
                ),
            ],

            database_operations=[
                migrations.RunSQL(sql="""
                    CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
                    ON "app_sale" ("sold_at");
                """,
                reverse_sql="""
                    DROP INDEX "app_sale_sold_at_b9438ae4";
                """),
            ],
        ),

    ],

Después de marcar la migración como no atómica, puede ejecutar la migración:

$ python manage.py migrate app
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_separate_database_and_state... OK

Acaba de ejecutar la migración sin causar ningún tiempo de inactividad.

Aquí hay algunos problemas a considerar cuando usa SeparateDatabaseAndState :

  • Las operaciones de la base de datos deben ser equivalentes a las operaciones de estado: Las inconsistencias entre la base de datos y el estado del modelo pueden causar muchos problemas. Un buen punto de partida es mantener las operaciones generadas por Django en state_operations y edite la salida de sqlmigrate para usar en database_operations .

  • Las migraciones no atómicas no pueden revertirse en caso de error: Si hay un error durante la migración, no podrá retroceder. Tendría que revertir la migración o completarla manualmente. Es una buena idea mantener al mínimo las operaciones ejecutadas dentro de una migración no atómica. Si tiene operaciones adicionales en la migración, muévalas a una nueva migración.

  • La migración puede ser específica del proveedor: El SQL generado por Django es específico del backend de la base de datos utilizado en el proyecto. Podría funcionar con otros backends de bases de datos, pero eso no está garantizado. Si necesita admitir varios backends de bases de datos, debe realizar algunos ajustes en este enfoque.



Conclusión

Comenzaste este tutorial con una mesa grande y un problema. Quería hacer que su aplicación fuera más rápida para sus usuarios, y quería hacerlo sin causarles ningún tiempo de inactividad.

Al final del tutorial, logró generar y modificar de manera segura una migración de Django para lograr este objetivo. Abordó diferentes problemas en el camino y logró superarlos utilizando herramientas integradas proporcionadas por el marco de migraciones.

En este tutorial, aprendiste lo siguiente:

  • Cómo funcionan internamente las migraciones de Django utilizando el modelo y el estado de la base de datos, y cuándo se generan nuevas migraciones
  • Cómo ejecutar SQL personalizado en migraciones usando RunSQL acción
  • Qué son las migraciones reversibles y cómo hacer un RunSQL acción reversible
  • Qué son las migraciones atómicas y cómo cambiar el comportamiento predeterminado según sus necesidades
  • Cómo ejecutar con seguridad migraciones complejas en Django

La separación entre el modelo y el estado de la base de datos es un concepto importante. Una vez que lo comprenda y sepa cómo utilizarlo, podrá superar muchas limitaciones de las operaciones de migración integradas. Algunos casos de uso que vienen a la mente incluyen agregar un índice que ya se creó en la base de datos y proporcionar argumentos específicos del proveedor a los comandos DDL.