Database
 sql >> Base de Dados >  >> RDS >> Database

Como criar um índice no Django sem tempo de inatividade


Gerenciar migrações de banco de dados é um grande desafio em qualquer projeto de software. Felizmente, a partir da versão 1.7, o Django vem com um framework de migração embutido. A estrutura é muito poderosa e útil no gerenciamento de mudanças em bancos de dados. Mas a flexibilidade fornecida pela estrutura exigia alguns compromissos. Para entender as limitações das migrações do Django, você enfrentará um problema bem conhecido:criar um índice no Django sem tempo de inatividade.

Neste tutorial, você aprenderá:
  • Como e quando o Django gera novas migrações
  • Como inspecionar os comandos que o Django gera para executar migrações
  • Como modificar migrações com segurança para atender às suas necessidades

Este tutorial de nível intermediário foi desenvolvido para leitores que já estão familiarizados com as migrações do Django. Para uma introdução a esse tópico, confira Django Migrations:A Primer.

Bônus grátis: Clique aqui para obter acesso gratuito a tutoriais e recursos adicionais do Django que você pode usar para aprofundar suas habilidades de desenvolvimento web em Python.

O problema com a criação de um índice nas migrações do Django


Uma alteração comum que geralmente se torna necessária quando os dados armazenados pelo seu aplicativo crescem é adicionar um índice. Os índices são usados ​​para acelerar as consultas e fazer com que seu aplicativo pareça rápido e responsivo.

Na maioria dos bancos de dados, adicionar um índice requer um bloqueio exclusivo na tabela. Um bloqueio exclusivo impede operações de modificação de dados (DML), como UPDATE , INSERT e DELETE , enquanto o índice é criado.

Os bloqueios são obtidos implicitamente pelo banco de dados ao executar determinadas operações. Por exemplo, quando um usuário faz login em seu aplicativo, o Django atualizará o last_login campo no auth_user tabela. Para realizar a atualização, o banco de dados terá primeiro que obter um bloqueio na linha. Se a linha estiver sendo bloqueada por outra conexão, você poderá obter uma exceção de banco de dados.

Bloquear uma tabela pode representar um problema quando é necessário manter o sistema disponível durante as migrações. Quanto maior a tabela, mais tempo pode levar para criar o índice. Quanto mais tempo leva para criar o índice, mais tempo o sistema fica indisponível ou não responde aos usuários.

Alguns fornecedores de banco de dados fornecem uma maneira de criar um índice sem bloquear a tabela. Por exemplo, para criar um índice no PostgreSQL sem bloquear uma tabela, você pode usar o CONCURRENTLY palavra-chave:
CREATE INDEX CONCURRENTLY ix ON table (column);

No Oracle, existe um ONLINE opção para permitir operações DML na tabela enquanto o índice é criado:
CREATE INDEX ix ON table (column) ONLINE;

Ao gerar migrações, o Django não usará essas palavras-chave especiais. Executar a migração como está fará com que o banco de dados adquira um bloqueio exclusivo na tabela e evite operações DML enquanto o índice é criado.

Criar um índice simultaneamente tem algumas ressalvas. É importante entender os problemas específicos do back-end do banco de dados com antecedência. Por exemplo, uma ressalva no PostgreSQL é que criar um índice simultaneamente leva mais tempo porque requer uma varredura de tabela adicional.

Neste tutorial, você usará as migrações do Django para criar um índice em uma tabela grande, sem causar tempo de inatividade.

Observação: Para seguir este tutorial, é recomendado que você use um backend PostgreSQL, Django 2.xe Python 3.

Também é possível acompanhar outros back-ends de banco de dados. Em locais onde são usados ​​recursos SQL exclusivos do PostgreSQL, altere o SQL para corresponder ao back-end do seu banco de dados.


Configuração


Você usará uma Sale inventada model em um aplicativo chamado app . Em uma situação da vida real, modelos como Sale são as principais tabelas do banco de dados e geralmente serão muito grandes e armazenarão muitos dados:
# models.py

from django.db import models

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

Para criar a tabela, gere a migração inicial e aplique-a:
$ 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

Depois de um tempo, a tabela de vendas fica muito grande e os usuários começam a reclamar da lentidão. Ao monitorar o banco de dados, você notou que muitas consultas usam o sold_at coluna. Para acelerar as coisas, você decide que precisa de um índice na coluna.

Para adicionar um índice em sold_at , você faz a seguinte alteração no 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()

Se você executar essa migração como está, o Django criará o índice na tabela e ficará bloqueado até que o índice seja concluído. Pode demorar um pouco para criar um índice em uma tabela muito grande e você deseja evitar o tempo de inatividade.

Em um ambiente de desenvolvimento local com um pequeno conjunto de dados e poucas conexões, essa migração pode parecer instantânea. No entanto, em grandes conjuntos de dados com muitas conexões simultâneas, obter um bloqueio e criar o índice pode demorar um pouco.

Nas próximas etapas, você modificará as migrações criadas pelo Django para criar o índice sem causar nenhum tempo de inatividade.


Migração falsa


A primeira abordagem é criar o índice manualmente. Você vai gerar a migração, mas não vai realmente deixar o Django aplicá-la. Em vez disso, você executará o SQL manualmente no banco de dados e fará o Django pensar que a migração foi concluída.

Primeiro, gere a migração:
$ python manage.py makemigrations --name add_index_fake
Migrations for 'app':
  app/migrations/0002_add_index_fake.py
    - Alter field sold_at on sale

Use o sqlmigrate comando para visualizar o SQL que o Django usará para executar esta migração:
$ 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;

Você deseja criar o índice sem bloquear a tabela, portanto, é necessário modificar o comando. Adicione o CONCURRENTLY palavra-chave e execute no banco de dados:
app=# CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
ON "app_sale" ("sold_at");

CREATE INDEX

Observe que você executou o comando sem o BEGIN e COMMIT partes. A omissão dessas palavras-chave executará os comandos sem uma transação de banco de dados. Discutiremos as transações de banco de dados posteriormente neste artigo.

Depois de executar o comando, se você tentar aplicar as migrações, receberá o seguinte erro:
$ 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

O Django reclama que o índice já existe, então não pode prosseguir com a migração. Você acabou de criar o índice diretamente no banco de dados, então agora você precisa fazer o Django pensar que a migração já foi aplicada.

Como falsificar uma migração

O Django fornece uma maneira integrada de marcar migrações como executadas, sem realmente executá-las. Para usar esta opção, defina o --fake sinalizar ao aplicar a migração:
$ python manage.py migrate --fake
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_fake... FAKED

O Django não gerou um erro desta vez. Na verdade, o Django não aplicou nenhuma migração. Apenas marcou como executado (ou FAKED ).

Aqui estão alguns problemas a serem considerados ao fingir migrações:

  • O comando manual deve ser equivalente ao SQL gerado pelo Django: Você precisa ter certeza de que o comando que você executa é equivalente ao SQL gerado pelo Django. Use sqlmigrate para produzir o comando SQL. Se os comandos não corresponderem, você poderá acabar com inconsistências entre o banco de dados e o estado dos modelos.

  • Outras migrações não aplicadas também serão falsificadas: Quando você tiver várias migrações não aplicadas, todas elas serão falsificadas. Antes de aplicar as migrações, é importante garantir que apenas as migrações que você deseja falsificar não sejam aplicadas. Caso contrário, você pode acabar com inconsistências. Outra opção é especificar a migração exata que você deseja falsificar.

  • É necessário acesso direto ao banco de dados: Você precisa executar o comando SQL no banco de dados. Isso nem sempre é uma opção. Além disso, executar comandos diretamente em um banco de dados de produção é perigoso e deve ser evitado sempre que possível.

  • Os processos de implantação automatizada podem precisar de ajustes: Se você automatizou o processo de implantação (usando CI, CD ou outras ferramentas de automação), talvez seja necessário alterar o processo para migrações falsas. Isso nem sempre é desejável.

Limpeza

Antes de passar para a próxima seção, você precisa trazer o banco de dados de volta ao seu estado logo após a migração inicial. Para fazer isso, migre de volta para a migração 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

O Django não aplicou as alterações feitas na segunda migração, então agora é seguro deletar também o arquivo:
$ rm app/migrations/0002_add_index_fake.py

Para ter certeza de que fez tudo certo, inspecione as migrações:
$ python manage.py showmigrations app
app
 [X] 0001_initial

A migração inicial foi aplicada e não há migrações não aplicadas.


Executar SQL bruto em migrações


Na seção anterior, você executou o SQL diretamente no banco de dados e falsificou a migração. Isso faz o trabalho, mas há uma solução melhor.

O Django fornece uma maneira de executar SQL bruto em migrações usando RunSQL . Vamos tentar usá-lo em vez de executar o comando diretamente no banco de dados.

Primeiro, gere uma nova migração vazia:
$ python manage.py makemigrations app --empty --name add_index_runsql
Migrations for 'app':
  app/migrations/0002_add_index_runsql.py

Em seguida, edite o arquivo de migração e adicione um RunSQL Operação:
# 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");',
        ),
    ]

Ao executar a migração, você obterá a seguinte saída:
$ python manage.py migrate
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_runsql... OK

Isso parece bom, mas há um problema. Vamos tentar gerar as migrações novamente:
$ python manage.py makemigrations --name leftover_migration
Migrations for 'app':
  app/migrations/0003_leftover_migration.py
    - Alter field sold_at on sale

O Django gerou a mesma migração novamente. Por que fez isso?

Limpeza

Antes que possamos responder a essa pergunta, você precisa limpar e desfazer as alterações feitas no banco de dados. Comece excluindo a última migração. Ele não foi aplicado, portanto, é seguro excluir:
$ rm app/migrations/0003_leftover_migration.py

Em seguida, liste as migrações para o app aplicativo:
$ python manage.py showmigrations app
app
 [X] 0001_initial
 [X] 0002_add_index_runsql

A terceira migração se foi, mas a segunda foi aplicada. Você deseja voltar ao estado logo após a migração inicial. Tente migrar de volta para a migração inicial como você fez na seção 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

O Django não consegue reverter a migração.


Operação de migração reversa


Para reverter uma migração, o Django executa uma ação oposta para cada operação. Nesse caso, o inverso de adicionar um índice é eliminá-lo. Como você já viu, quando uma migração é reversível, você pode desaplicá-la. Assim como você pode usar checkout no Git, você pode reverter uma migração se executar migrate para uma migração anterior.

Muitas operações de migração integradas já definem uma ação reversa. Por exemplo, a ação inversa para adicionar um campo é descartar a coluna correspondente. A ação inversa para criar um modelo é eliminar a tabela correspondente.

Algumas operações de migração não são reversíveis. Por exemplo, não há ação reversa para remover um campo ou excluir um modelo, porque uma vez que a migração foi aplicada, os dados desapareceram.

Na seção anterior, você usou o RunSQL Operação. Ao tentar reverter a migração, você encontrou um erro. De acordo com o erro, uma das operações na migração não pode ser revertida. O Django não consegue reverter o SQL bruto por padrão. Como o Django não tem conhecimento do que foi executado pela operação, ele não pode gerar uma ação oposta automaticamente.

Como tornar uma migração reversível

Para que uma migração seja reversível, todas as operações nela devem ser reversíveis. Não é possível reverter parte de uma migração, portanto, uma única operação irreversível tornará toda a migração irreversível.

Para fazer um RunSQL operação reversível, você deve fornecer SQL para executar quando a operação for revertida. O SQL reverso é fornecido no reverse_sql argumento.

A ação oposta à adição de um índice é eliminá-lo. Para tornar sua migração reversível, forneça o reverse_sql para eliminar o í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";',
        ),
    ]

Agora tente reverter a migração:
$ 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

A segunda migração foi revertida e o índice foi descartado pelo Django. Agora é seguro excluir o arquivo de migração:
$ rm app/migrations/0002_add_index_runsql.py

É sempre uma boa ideia fornecer reverse_sql . Em situações em que a reversão de uma operação SQL bruta não requer nenhuma ação, você pode marcar a operação como reversível usando o sentinela especial migrations.RunSQL.noop :
migrations.RunSQL(
    sql='...',  # Your forward SQL here
    reverse_sql=migrations.RunSQL.noop,
),


Entenda o estado do modelo e o estado do banco de dados


Em sua tentativa anterior de criar o índice manualmente usando RunSQL , o Django gerou a mesma migração repetidas vezes, embora o índice tenha sido criado no banco de dados. Para entender por que o Django fez isso, primeiro você precisa entender como o Django decide quando gerar novas migrações.

Quando o Django gera uma nova migração


No processo de geração e aplicação de migrações, o Django sincroniza entre o estado do banco de dados e o estado dos modelos. Por exemplo, quando você adiciona um campo a um modelo, o Django adiciona uma coluna à tabela. Quando você remove um campo do modelo, o Django remove a coluna da tabela.

Para sincronizar entre os modelos e o banco de dados, o Django mantém um estado que representa os modelos. Para sincronizar o banco de dados com os modelos, o Django gera operações de migração. As operações de migração se traduzem em um SQL específico do fornecedor que pode ser executado no banco de dados. Quando todas as operações de migração são executadas, espera-se que o banco de dados e os modelos sejam consistentes.

Para obter o estado do banco de dados, o Django agrega as operações de todas as migrações anteriores. Quando o estado agregado das migrações não é consistente com o estado dos modelos, o Django gera uma nova migração.

No exemplo anterior, você criou o índice usando SQL bruto. O Django não sabia que você criou o índice porque você não usou uma operação de migração familiar.

Quando o Django agregou todas as migrações e as comparou com o estado dos modelos, descobriu que faltava um índice. É por isso que, mesmo depois que você criou o índice manualmente, o Django ainda achou que estava faltando e gerou uma nova migração para ele.


Como separar banco de dados e estado nas migrações


Como o Django não pode criar o índice da maneira que você deseja, você deseja fornecer seu próprio SQL, mas ainda informar ao Django que você o criou.

Em outras palavras, você precisa executar algo no banco de dados e fornecer ao Django a operação de migração para sincronizar seu estado interno. Para fazer isso, o Django nos fornece uma operação de migração especial chamada SeparateDatabaseAndState . Esta operação é pouco conhecida e deve ser reservada para casos especiais como este.

É muito mais fácil editar migrações do que escrevê-las do zero, então comece gerando uma migração da maneira usual:
$ 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 é o conteúdo da migração gerada pelo Django, como 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 gerou um AlterField operação no campo sold_at . A operação criará um índice e atualizará o estado. Queremos manter esta operação, mas fornecer um comando diferente para executar no banco de dados.

Mais uma vez, para obter o comando, use o SQL gerado pelo 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;

Adicione o CONCURRENTLY palavra-chave no local apropriado:
CREATE INDEX CONCURRENTLY "app_sale_sold_at_b9438ae4"
ON "app_sale" ("sold_at");

Em seguida, edite o arquivo de migração e use SeparateDatabaseAndState para fornecer seu comando SQL modificado para execução:
# 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";
                """),
            ],
        ),

    ],

A operação de migração SeparateDatabaseAndState aceita 2 listas de operações:
  1. state_operations são operações a serem aplicadas no estado do modelo interno. Eles não afetam o banco de dados.
  2. database_operations são operações a serem aplicadas ao banco de dados.

Você manteve a operação original gerada pelo Django em state_operations . Ao usar SeparateDatabaseAndState , isso é o que você normalmente vai querer fazer. Observe que o db_index=True argumento é fornecido para o campo. Esta operação de migração permitirá ao Django saber que existe um índice no campo.

Você usou o SQL gerado pelo Django e adicionou o CONCURRENTLY palavra-chave. Você usou a ação especial RunSQL para executar SQL bruto na migração.

Se você tentar executar a migração, obterá a seguinte saída:
$ 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



Migrações não atômicas


Em SQL, CREATE , DROP , ALTER e TRUNCATE as operações são chamadas de Linguagem de definição de dados (DDL). Em bancos de dados que suportam DDL transacional, como PostgreSQL, o Django executa migrações dentro de uma transação de banco de dados por padrão. Porém, de acordo com o erro acima, o PostgreSQL não pode criar um índice concorrentemente dentro de um bloco de transação.

Para poder criar um índice concorrentemente dentro de uma migração, você precisa dizer ao Django para não executar a migração em uma transação de banco de dados. Para fazer isso, você marca a migração como não atômica definindo atomic para 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";
                """),
            ],
        ),

    ],

Depois de marcar a migração como não atômica, você pode executar a migração:
$ python manage.py migrate app
Operations to perform:
  Apply all migrations: app
Running migrations:
  Applying app.0002_add_index_separate_database_and_state... OK

Você acabou de executar a migração sem causar nenhum tempo de inatividade.

Aqui estão alguns problemas a serem considerados ao usar SeparateDatabaseAndState :

  • As operações do banco de dados devem ser equivalentes às operações de estado: Inconsistências entre o banco de dados e o estado do modelo podem causar muitos problemas. Um bom ponto de partida é manter as operações geradas pelo Django em state_operations e edite a saída de sqlmigrate para usar em database_operations .

  • Migrações não atômicas não podem ser revertidas em caso de erro: Se houver um erro durante a migração, você não poderá reverter. Você teria que reverter a migração ou concluí-la manualmente. É uma boa ideia manter as operações executadas dentro de uma migração não atômica no mínimo. Se você tiver operações adicionais na migração, mova-as para uma nova migração.

  • A migração pode ser específica do fornecedor: O SQL gerado pelo Django é específico para o backend do banco de dados usado no projeto. Pode funcionar com outros back-ends de banco de dados, mas isso não é garantido. Se você precisar dar suporte a vários back-ends de banco de dados, precisará fazer alguns ajustes nessa abordagem.


Conclusão


Você iniciou este tutorial com uma tabela grande e um problema. Você queria tornar seu aplicativo mais rápido para seus usuários, e queria fazer isso sem causar tempo de inatividade.

Ao final do tutorial, você conseguiu gerar e modificar com segurança uma migração do Django para atingir esse objetivo. Você abordou diferentes problemas ao longo do caminho e conseguiu superá-los usando ferramentas integradas fornecidas pela estrutura de migrações.

Neste tutorial, você aprendeu o seguinte:
  • Como as migrações do Django funcionam internamente usando o modelo e o estado do banco de dados e quando novas migrações são geradas
  • Como executar SQL personalizado em migrações usando o RunSQL ação
  • O que são migrações reversíveis e como fazer um RunSQL ação reversível
  • O que são migrações atômicas e como alterar o comportamento padrão de acordo com suas necessidades
  • Como executar migrações complexas com segurança no Django

A separação entre o modelo e o estado do banco de dados é um conceito importante. Depois de entendê-lo e de como utilizá-lo, você poderá superar muitas limitações das operações de migração integradas. Alguns casos de uso que vêm à mente incluem adicionar um índice que já foi criado no banco de dados e fornecer argumentos específicos do fornecedor para comandos DDL.