在Django中如何在不停机的情况下创建索引

更多内容请点击 我的博客 查看,欢迎来访。


原文by Haki Benita 翻译by StarMeow

在任何软件项目中,管理数据库迁移都是一个巨大的挑战。Django1.7版本就开始内置迁移框架,该框架非常强大,可用于管理数据库中的更改。要了解Django迁移的局限性,您将解决一个众所周知的问题:在Django中创建一个没有停机时间的索引。在这篇文章中,可以学到:

  • Django如何以及何时生成新的迁移
  • 如何检查Django生成的执行迁移的命令
  • 如何安全的修改迁移以满足自己的需求

在Django迁移中创建索引的问题

当应用存储的数据增长时,通常需要进行的常见更改是添加索引。索引用于加快查询速度,并能提高应用的运行和响应速度。

在大多数数据库中,添加索引需要对表进行独占锁定。创建索引时,独占锁可以防止数据修改(DML)操作,例如更新、插入和删除。

数据库在执行某些操作时隐式的获取锁。例如,当用户登录应用时,Django将更新auth_user表中的last_login字段。要执行更新,数据库首先必须获得该行的锁,如果要修改的行被另一个连接锁定,那么可能会出现数据库异常。

当需要在迁移期间保持系统可用时,锁定表可能会出现问题。表越大,创建索引耗时越长,那么系统不可用或响应用户的时间就越长。

一些数据库提供商提供了一种创建索引而不锁定表的方法。例如,要在PostgreSQL中创建索引而不锁定表,可以使用CONCURRENTLY关键字:

CREATE INDEX CONCURRENTLY ix ON table (column);

在Oracle中,有一个ONLINE选项允许在创建索引时也可以对表制定DML操作:

CREATE INDEX ix ON table (column) ONLINE;

在生成迁移时,Django不会使用这些特殊的关键字。按原样运行迁移将使数据库获得表上的独占锁,并在创建索引时防止DML操作,也就是执行migrate就不允许修改数据了。

并发创建索引也有一些注意事项。提前了解特定于数据库后端的问题是很重要的。例如,PostgreSQL中会有一个警告是并发创建索引需要更长的时间,因为它需要额外的表扫描。

在这个教程中,将使用Django迁移在一个数据量大的表上创建索引,而不会导致任何停机。

注意:要学习本教程,建议使用PostgreSQL后端Django 2.x和python3。
也可以使用其他数据库后端。在使用PostgreSQL特有的SQL特性的地方,更改SQL以匹配数据库后端。

设置

在一个名为app的应用中使用一个虚构的Sale模型。在现实生活中,Sale等模型是数据库中主要的表,它们通常会非常大,并存储大量的数据:

# models.py

from django.db import models

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

创建表,生成初始迁移并应用它

$ 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

过了一段时间,sales表变得非常大,用户开始抱怨速度太慢。在监视数据库时,注意到许多查询使用sold_at列。为了加快速度,决定在列上需要一个索引。

要在sold_at上添加索引,需要对模型进行以下更改:

# 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()

如果原样运行这个迁移,那么Django将在表上创建索引,并且它将被锁定,直到索引完成。在非常大的表上创建索引可能需要一段时间,并且希望避免停机。

在数据量很少和连接很少的本地开发环境中,这种迁移一般都是瞬间完成的。然而,对于具有并发连接的数据量极大的数据库中,获取锁并创建索引可能需要一段时间。

在接下来的步骤中,将修改Django创建的迁移,以便在不触发停机的情况下创建索引。

伪造迁移

第一种方法是手动创建索引。将生成迁移,但实际上不会让Django去应用它。相反,需要在数据库中手动运行SQL,然后让Django认为迁移已经完成。

原始迁移

首先,生成迁移:

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

使用sqlmigrate命令来查看Django将用于执行此迁移的SQL

$ 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;

SQL命令添加索引

希望在不锁定表的情况下创建索引,因此需要修改命令,添加CONCURRENTLY关键字,并在数据库中执行下面的语句:

# PostgreSQL Console

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

CREATE INDEX

注意,指定的命令没有BEGINCOMMIT部分。省略这些关键字将在没有数据库事务的情况下执行命令。将在本文后面讨论数据库事务。

在指定命令后,如果尝试运行迁移,会出现以下错误:

$ 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会提示该索引已经存在,因此无法继续迁移。因为刚刚已经在数据库中使用命令直接创建了索引,所以现在需要让Django认为已经应用了迁移。

如何伪造一个迁移

Django提供了一个内置的方法,可以将迁移标记为已执行,而不需要实际执行它们。要使用这个选项,请在应用迁移时设置一个--fake标志:

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

Django这次没有抛出错误。实际上,Django并没有真正应用任何迁移。它只是将其标记为已执行(或伪造)。

以下是在进行伪迁移时需要考虑的一些问题:

  • 手动命令必须要与Django生成的SQL等价:需要确保执行的命令等同于Django生成的SQL。使用sqlmigrate生成SQL命令。如果命令不匹配,则可能导致数据库和模型状态之间的不一致。
  • 其他未应用的迁移也将被伪造:当有多个未应用的迁移时,如果使用这个命令,他们都将被伪造。在应用迁移之前,首先要确保只有想要伪造的迁移没有被应用。否则,可能会得到不一致的结果。另一个选项是指定要伪造的确切迁移。
  • 需要直接访问数据库:需要在数据库中运行SQL命令,这并不总是一种选择。此外,直接在生产数据库中执行命令是危险的,应尽可能避免。
  • 自动化部署过程可能需要调整:自动化部署过程(使用CI、CD或其他自动化工具),则可能需要更改过程以伪造迁移。这并不总是令人满意的。

回退迁移清理

在继续下一节之前,需要将数据库恢复到它在初始迁移之后的状态。要做到这一点,回退初始迁移。

$ 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取消了第二次前一种所做的更改,所以现在可以安全的删除迁移文件:

$ rm app/migrations/0002_add_index_fake.py

确保做的都是正确的,可以检查迁移

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

应用了初始迁移,并且已经没有未应用的迁移了。

在迁移中执行原始SQL

在上一节中,直接在数据库中执行SQL并伪造迁移。这就完成了任务,但是有一个更好的解决方案。

Django提供了一种使用RunSQL在迁移中执行原始SQL的方法。尝试使用它,而不是直接在数据库中执行命令。

生成空迁移文件并修改

首先,生成一个新的空迁移:

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

接下来,编辑迁移文件并添加RunSQL操作:

# 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");',
        ),
    ]

运行迁移时,将得到如下输出:

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

这看起来不错,但有一个问题。再次来尝试生成迁移:

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

Django再次生成了相同的迁移。为什么会这样?

回退迁移清理

在回答这个问题之前,需要清理并撤消对数据库所做的更改。首先删除最后一次迁移。它没有被应用,所以可以安全删除

$ rm app/migrations/0003_leftover_migration.py

接下来,列出app应用程序的迁移:

$ 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...Traceback (most recent call last):

NotImplementedError: You cannot reverse this operation

Django无法逆转迁移。

逆向迁移操作

要逆向迁移,Django对每个操作执行相反的操作。在本例中,添加索引的反面是删除索引。当一个迁移时可逆的时候,可以取消应用它。就像在Git中使用checkout一样,如果对较早的迁移执行了migrate命令,可以进行撤销迁移。

许多内置的迁移操作已经定义了反向操作。例如,添加字段的反向操作是删除对应的列。创建模型的反向操作是删除相应的表。

有些操作是不可逆的,例如,删除字段或删除模型没有反向操作,因为一旦应用了迁移,数据就会消失。

在上一节中,使用了RunSQL操作。但尝试逆转迁移时,会报错,根据错误,迁移中的一个操作无法撤消。Django默认情况下无法逆转原始SQL,因为Django不知道该操作执行了什么,所以不能自动生成相反的操作。

如何使迁移可逆

为了使迁移是可逆的,迁移中的所有操作都必须是可逆的。不可能反转部分迁移,因此单个不可逆操作将使整个迁移不可逆。

要使RunSQL操作可逆,必须提供在操作反转时执行的SQL。反向SQL在reverse_sql参数中提供。

添加索引的相反操作是删除索引。要使迁移可逆,请提供reverse_sql来删除索引:

# 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";',
        ),
    ]

现在试着反转迁移:

$ 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

第二次迁移发生了逆转,Django删除了索引。现在可以安全地删除迁移文件了:

$ rm app/migrations/0002_add_index_runsql.py

提供reverse_sql总是一个好主意。在反转原始SQL操作不需要其他操作的情况下,可以使用特殊的哨兵语句 migrations.RunSQL.noop将该操作标记为可逆操作。

migrations.RunSQL(
    sql='...',  # 向前的SQL语句
    reverse_sql=migrations.RunSQL.noop,
),

了解模型状态和数据库状态

在之前尝试使用RunSQL手动创建索引时,即使索引是在数据库中已创建的,Django也会反复生成相同的迁移。要理解Django为什么要这样做,首先理解Django如何决定何时生成新的迁移。

Django生成新的迁移时

在生成和应用迁移的过程中,Django数据库状态和模型状态之间进行同步。例如,当向模型添加字段时,Django会向表中添加一列;当从模型中删除字段时,Django将从表中删除该列。

为了在模型和数据库之间同步,Django维护着一个表示模型的状态,为了使数据库与模型同步,Django会生成迁移操作,迁移操作转换为可以在数据库中执行的且针对数据库类型的SQL语句。当所有迁移操作都执行后,数据库和模型应该是一致的。

为了获取数据库的状态,Django聚合了过去所有迁移的操作。当迁移的聚合状态与模型的状态不一致时,Django生成一个新的迁移。

在前面的示例中,使用原始SQL语句创建了索引,Django不知道已经创建了索引,因为没有使用它熟悉的迁移操作。

当Django聚合所有迁移并将他们与模型的状态进行比较时,它发现缺少一个索引。这就是为什么即使手动创建了索引,Django仍然认为它是缺失的,并为它生成了一个新的迁移。

如何在迁移中分离数据库和状态

由于Django无法按照要求的方式创建索引,所以我们需要提供自己的SQL语句,但仍然要让Django知道已经创建了索引。

换句话说,我们要在数据库中执行一些操作,并为Django提供迁移操作来同步其内部状态。为了,Django提供了一个名为SeparateDatabaseAndState的特殊迁移操作。这种操作并不为人所知,应该留给想这种特殊情况下使用。

编辑迁移文件要比从头开始写容易得多,因为,首先以常规的方式生成一个迁移:

$ 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

这是Django生成的迁移内容,和之前一样:

# 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在字段sold_at上生成了一个AlterField操作。该操作将创建索引并更新状态。我们希望保留这个操作,但是在数据库中提供一个不同的命令来执行。

同样,要获得该命令,请使用Django生成的SQL:

$ 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;

在适当位置添加CONCURRENTLY关键字:

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

接下来,编辑迁移文件并使用SeparateDatabaseAndState提供修改后的SQL命令执行:

# 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=[  # 原来的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";
                """),
            ],
        ),

    ],

迁移操作SeparateDatabaseAndState接收2个操作列表:

  1. state_operations是应用于内部模型状态的操作,它们不会影响数据库。
  2. database_operations是应用数据库的操作。

state_operations中保留了Django生成的原始操作。当使用SeparateDatabaseAndState时,我们通常会这么做。注意,db_index=True参数Django提供给该字段。这个迁移操作将让Django知道字段上有一个索引。

使用了Django生成的SQL并添加了CONCURRENTLY关键字。使用特殊的操作RunSQL来执行迁移中的原始SQL。

如果试图运行此迁移,将获得以下输出:

$ 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

非原子迁移

在SQL中,CREATEDROPALTER以及TRUNCATE操作被称为数据库定义语言Data Definition Language (DDL)。在支持事务性DDL的数据库中,比如PostgreSQL, Django默认情况下在数据库事务中执行迁移。然而,根据上面的错误,PostgreSQL不能在事务块中并发地创建索引。

为了能够在迁移中并发地创建索引,需要告诉Django不要在数据库事务中执行迁移。为此,将atomic设置为False,将迁移标记为非原子non-atomic,也就是添加atomic = 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";
                """),
            ],
        ),

    ],

将迁移标记为非原子之后,可以运行迁移:

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

只是执行了迁移,没有引起任何停机。

下面是使用SeparateDatabaseAndState时需要考虑的一些问题:

  • 数据库操作必须等同于状态操作:数据库和模型状态之间的不一致会导致很多麻烦。 一个很好的起点是将Django生成的操作保持在state_operations中,并编辑sqlmigrate的输出以在database_operations中使用。
  • 出现错误时,非原子迁移无法回滚:如果迁移过程中出现错误,则无法回滚。 必须回滚迁移或手动完成迁移。 将非原子迁移中执行的操作保持在最低限度是个好主意。 如果在迁移中有其他操作,请将它们移至新迁移。
  • 迁移可能是特定于供应商的:Django生成的SQL特定于项目中使用的数据库后端。 它可能适用于其他数据库后端,但不能保证。 如果需要支持多个数据库后端,则需要对此方法进行一些调整。

结论

使用大型的数据库的一个问题开始本教程。 希望为用户更快地创建应用程序,并且希望这样做而不会导致任何停机。

在本教程结束时,设法生成并安全地修改Django迁移以实现此目标。 在整个过程中解决了不同的问题,并设法使用迁移框架提供的内置工具来克服它们。

在本教程中,学习了以下内容:

  • Django迁移如何使用模型和数据库状态在内部工作,以及何时生成新的迁移
  • 如何使用RunSQL操作在迁移中执行自定义SQL
  • 可逆迁移是什么,以及如何使RunSQL操作可逆
  • 什么原子迁移,以及如何根据需要更改默认行为
  • 如何在Django中安全地执行复杂的迁移

模型和数据库状态之间的分离是一个重要的概念。 一旦理解了它,以及如何利用它,就可以克服内置迁移操作的许多限制。 想到的一些用例包括添加已在数据库中创建的索引,并为DDL命令提供特定于供应商的参数。

【操作步骤】不停机情况下创建索引

在为sold_at字段添加db_index=True前需要执行makemigrationsmigrate来保证当前的迁移和数据库中一致。然后添加db_index=True,再执行makemigrations

$ 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

会得到add_index_separate_database_and_state.py文件

# 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,
            ),
        ),
    ]

进行修改

# 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=[  # 应用于内部模型状态的操作。 它们不会影响数据库。也就是自动生成的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";
                """),
            ],
        ),

    ],
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值