Sentry 开发者贡献指南 - 数据库迁移

Sentry 开发者贡献指南 - 数据库迁移

Django 迁移是我们处理 Sentry 中数据库更改的方式。

Django 迁移官方文档:https://docs.djangoproject.com/en/2.2/topics/migrations/

这些将涵盖了解迁移正在执行的操作所需的大部分内容。

命令

请注意,对于所有这些命令,如果在 getsentry 存储库中,您可以将 getsentry 替换为 sentry

将您的数据库升级到最新

sentry upgrade 会自动更新你的迁移。您也可以运行 sentry django migrate 来直接访问迁移命令。

将您的数据库移动到特定的迁移

当您要测试迁移时,这会很有帮助。

sentry django migrate <app_name> <migration_name> - 请注意,migration_name 可以是部分匹配,通常数字就是你所需要的。

例如:sentry django migrate sentry 0005

这也可用于回滚迁移。如果你犯了错误,在开发中很有用。

为迁移生成 SQL

这对审查您的代码的人很有帮助,因为并不总是清楚 Django 迁移实际要做什么。

sentry django sqlmigrate <app_name> <migration_name>

例如 sentry django sqlmigrate sentry 0003

生成迁移

这会根据您对模型所做的更改自动为您生成迁移。

sentry django makemigrations

或者

sentry django makemigrations <app_name> 用于一个指定的 app

例如 sentry django makemigrations sentry

当您在 pr 中包含迁移时,还要为迁移生成 sql 并将其作为注释包含在内,以便您的审阅者可以更轻松地了解 Django 正在做什么。

您还可以使用 sentry django makemigrations <app_name> --empty 生成空迁移。这对于数据迁移和其他自定义工作很有用。

将迁移合并到 master

合并到 master 时,您可能会注意到与 migrations_lockfile.txt 的冲突。
这个文件是为了帮助我们避免将具有相同迁移编号的两个迁移合并到 master,如果您与它发生冲突,那么很可能有人在您之前提交了迁移。

指南

在运行迁移时,我们需要注意一些事项。

过滤器

如果(数据)迁移涉及大表或未索引的列,最好迭代整个表而不是使用 filter。 例如:

EnvironmentProject.objects.filter(environment__name="none")

因为 EnvironmentProject 行太多,这会一次将太多行带入内存。
相反,我们应该使用 RangeQuerySetWrapperWithProgressBar 遍历所有 EnvironmentProject 行,因为它会分块进行。
例如:

for env in RangeQuerySetWrapperWithProgressBar(EnvironmentProject.objects.all()):
	if env.name == 'none':
		# Do what you need

我们通常更喜欢避免将 .filterRangeQuerySetWrapperWithProgressBar 一起使用。
由于它已经通过 id 对表进行排序,因此我们无法利用字段上的任何索引,并且可能会为每个块扫描大量行。
这会运行得更慢,但我们通常更喜欢这样,因为它在更长的时间内平均负载,并使每个查询获取每个块的成本相当低。

索引

我们更喜欢使用 CREATE INDEX CONCURRENTLY 在现有的大型表上创建索引。当我们这样做时,我们无法在事务中运行迁移,因此使用 atomic = False 来运行这些很重要。

删除列/表

由于我们的部署过程,这很复杂。
当我们部署时,我们运行迁移,然后推出应用程序代码,这需要一段时间。
这意味着如果我们只是删除一个列或模型,那么 sentry 中的代码将查找这些列/表并在部署完成之前出错。
在某些情况下,这可能意味着 Sentry 在部署完成之前很难停机。

为避免这种情况,请执行以下步骤:

  • 如果列不是空的,则将其标记为空,并创建一个迁移。
  • 部署。
  • 从模型中删除列,但在迁移中确保我们只将状态标记为已删除(removed)。
  • 部署。
  • 最后,创建一个删除列的迁移。

这是删除已经可以为空的列的示例。首先我们从模型中删除列,然后修改迁移以仅更新状态而不进行数据库操作。

operations = [
        migrations.SeparateDatabaseAndState(
            database_operations=[],
            state_operations=[
                migrations.RemoveField(model_name="alertrule", name="alert_threshold"),
                migrations.RemoveField(model_name="alertrule", name="resolve_threshold"),
                migrations.RemoveField(model_name="alertrule", name="threshold_type"),
            ],
        )
    ]

一旦部署完成,我们就可以部署实际的列删除。这个 pr 只会有一个迁移,因为 Django 不再知道这些字段。请注意,反向 SQL 仅适用于开发人员,因此可以不分配默认值或进行任何类型的回填:

operations = [
        migrations.SeparateDatabaseAndState(
            database_operations=[
                migrations.RunSQL(
                    """
                    ALTER TABLE "sentry_alertrule" DROP COLUMN "alert_threshold";
                    ALTER TABLE "sentry_alertrule" DROP COLUMN "resolve_threshold";
                    ALTER TABLE "sentry_alertrule" DROP COLUMN "threshold_type";
                    """,
                    reverse_sql="""
                    ALTER TABLE "sentry_alertrule" ADD COLUMN "alert_threshold" smallint NULL;
                    ALTER TABLE "sentry_alertrule" ADD COLUMN "resolve_threshold" int NULL;
                    ALTER TABLE "sentry_alertrule" ADD COLUMN "threshold_type" int NULL;

                    """,
                )
            ],
            state_operations=[],
        )
    ]

如果该表在其他表中被引用为外键,则需要格外小心。在这种情况下,首先删除其他表中的外键列,然后返回到此步骤。

  • 通过在列上设置 db_constraint=False,删除此表到其他表的任何数据库级外键约束。
  • 部署
  • sentry 代码库中删除模型和所有引用。确保迁移仅将状态标记为已删除。
  • 部署。
  • 创建一个删除表的迁移。
  • 部署

这是删除此模型的示例:

class AlertRuleTriggerAction(Model):
    alert_rule_trigger = FlexibleForeignKey("sentry.AlertRuleTrigger")
    integration = FlexibleForeignKey("sentry.Integration", null=True)
    type = models.SmallIntegerField()
    target_type = models.SmallIntegerField()
    # Identifier used to perform the action on a given target
    target_identifier = models.TextField(null=True)
    # Human readable name to display in the UI
    target_display = models.TextField(null=True)
    date_added = models.DateTimeField(default=timezone.now)

    class Meta:
        app_label = "sentry"
        db_table = "sentry_alertruletriggeraction"

首先,我们检查了它没有被任何其他模型引用,它没有。接下来,我们需要删除和 db 级外键约束。为此,我们改变这两列并生成一个迁移:

alert_rule_trigger = FlexibleForeignKey("sentry.AlertRuleTrigger", db_constraint=False)
integration = FlexibleForeignKey("sentry.Integration", null=True, db_constraint=False)

迁移中的操作看起来像

    operations = [
        migrations.AlterField(
            model_name='alertruletriggeraction',
            name='alert_rule_trigger',
            field=sentry.db.models.fields.foreignkey.FlexibleForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='sentry.AlertRuleTrigger'),
        ),
        migrations.AlterField(
            model_name='alertruletriggeraction',
            name='integration',
            field=sentry.db.models.fields.foreignkey.FlexibleForeignKey(db_constraint=False, null=True, on_delete=django.db.models.deletion.CASCADE, to='sentry.Integration'),
        ),
    ]

我们可以看到它生成的 sql 只是删除了 FK 约束

BEGIN;
SET CONSTRAINTS "a875987ae7debe6be88869cb2eebcdc5" IMMEDIATE; ALTER TABLE "sentry_alertruletriggeraction" DROP CONSTRAINT "a875987ae7debe6be88869cb2eebcdc5";
SET CONSTRAINTS "sentry_integration_id_14286d876e86361c_fk_sentry_integration_id" IMMEDIATE; ALTER TABLE "sentry_alertruletriggeraction" DROP CONSTRAINT "sentry_integration_id_14286d876e86361c_fk_sentry_integration_id";
COMMIT;

所以现在我们部署它并进入下一阶段。

下一阶段涉及从代码库中删除对模型的所有引用。所以我们这样做,然后我们生成一个迁移,从迁移状态中删除模型,而不是数据库。此迁移中的操作如下所示

operations = [
        migrations.SeparateDatabaseAndState(
            state_operations=[migrations.DeleteModel(name="AlertRuleTriggerAction")],
            database_operations=[],
        )
    ]

并且生成的 SQL 显示没有发生数据库更改。所以现在我们部署它并进入最后一步。

在这最后一步中,我们只想手动编写 DDL 来删除表。 所以我们使用 sentry django makemigrations --empty 来产生一个空的迁移,然后修改操作如下:

operations = [
        migrations.RunSQL(
            """
            DROP TABLE "sentry_alertruletriggeraction";
            """,
            reverse_sql="CREATE TABLE sentry_alertruletriggeraction (fake_col int)", # We just create a fake table here so that the DROP will work if we roll back the migration.
        )
    ]

然后我们部署它,我们就完成了。

外键

创建外键大多没问题,但是对于像 ProjectGroup 这样的大/繁忙的表,由于获取锁的困难,它可能会导致问题。
您仍然可以创建 Django 级别的外键,而无需创建数据库约束。为此,请在定义键时设置 db_constraint=False

重命名表

重命名表很危险,会导致停机。发生这种情况的原因是在部署期间将运行旧/新代码的混合。
因此,一旦我们在 Postgres 中重命名该表,如果旧代码尝试访问它,它就会立即开始出错。有两种方法可以处理重命名表:

  • 不要在 Postgres 中重命名表。相反,只需在 Django 中重命名模型,并确保将 Meta.db_table 设置为当前表名,这样不会有任何中断。这是首选方法。
  • 如果你真的想重命名表,那么步骤将是:
  • 使用新名称创建一个表
  • 开始对旧表和新表进行双重写入,最好是在事务中。
  • 将旧行回填到新表中。
  • model 更改为从新表开始读取。
  • 停止写入旧表并从代码中删除引用。
  • 丢弃旧表。
  • 一般来说,这是不值得做的,与回报相比,这需要冒很多风险/付出很多努力。

添加列

创建新列时,它们应始终创建为可为空的。这是出于两个原因:

  • 如果存在现有行,添加非空列需要设置默认值,添加默认值需要完全重写表。这是危险的,很可能会导致停机
  • 在部署期间,新旧代码混合运行。如果旧代码尝试向表中插入一行,则插入将失败,因为旧代码不知道新列存在,因此无法为该列提供值。

向列添加 NOT NULL

not null 添加到列可能很危险,即使该列的表的每一行都有数据。
这是因为 Postgres 仍然需要对所有行执行非空检查,然后才能添加约束。
在小表上这可能没问题,因为检查会很快,但在大表上这可能会导致停机。
这里有几个选项可以确保安全:

  • ALTER TABLE tbl ADD CONSTRAINT cnstr CHECK (col IS NOT NULL) NOT VALID; ALTER TABLE tbl VALIDATE CONSTRAINT cnstr;. 首先,我们将约束创建为无效。然后我们之后验证它。我们仍然需要扫描整个表来验证,但我们只需要持有一个 SHARE UPDATE EXCLUSIVE 锁,它只会阻止其他 ALTER TABLE 命令,但允许读/写继续。这很有效,但会有 0.5-1% 的轻微性能损失。在 Postgres 12 之后,我们可以扩展这个方法来添加一个真正的 NOT NULL 约束。
  • 如果表足够小并且体积足够小,那么创建一个普通的 NOT NULL 约束应该是安全的。小是几百万行或更少。

添加具有默认值的列

向现有表添加具有默认值的列是危险的。这需要 Postgres 锁定表并重写它。相反,更好的选择是:

  • Postgres 中添加没有默认值的列,但在 Django 中添加默认值。这使我们能够确保所有新行都具有默认值。这是通过修改迁移文件以包含 migrations.SeperateDatabaseAndState 来完成的
operations = [
    migrations.SeparateDatabaseAndState(
        database_operations=[
            migrations.AddField(
                model_name="mymodel",
                name="new_field",
                # Don't use a default in Postgres, a data migration can be used afterward to backfill
                field=models.PositiveSmallIntegerField(null=True),
            ),
        ],
        state_operations=[
            migrations.AddField(
                model_name="mymodel",
                name="new_field",
                # Use the default in Django, new rows will use the specified default
                field=models.PositiveSmallIntegerField(null=True, default=1),
            ),
        ],
    )
    ]
  • 通过数据迁移使用默认值回填预先存在的行。

改变列类型

改变列的类型通常是危险的,因为它需要重写整个表。有一些例外:

  • varchar(<size>) 更改为更大尺寸的 varchar
  • 将任何 varchar 更改为 text
  • numeric 更改为 numeric,其中 precision 更高但 scale 相同。

对于任何其他类型,最好的前进路径通常是:

  • 创建具有新类型的列。
  • 开始对新旧列进行双重写入。
  • 回填并将旧列值转换为新列。
  • 更改代码以使用新字段。
  • 停止写入旧列并从代码中删除引用。
  • 从数据库中删除旧列。

通常,这值得在 #discuss-backend 中讨论。

重命名列

重命名列是危险的,会导致停机。
发生这种情况的原因是在部署期间将运行旧/新代码的混合。
因此,一旦我们在 Postgres 中重命名该列,如果旧代码尝试访问它,它就会立即开始出错。有两种方法可以处理重命名列:

  • 不要重命名 Postgres 中的列。相反,只需在 Django 中重命名字段,并在定义中使用 db_column 将其设置为现有的列名,这样就不会中断。这是首选方法。
  • 如果你真的想重命名列,那么步骤将是:
    • 创建具有新名称的列
    • 开始对新旧列进行双重写入。
    • 将旧列值回填到新列中。
    • 将字段更改为从新列开始读取。
    • 停止写入旧列并从代码中删除引用。
    • 从数据库中删除旧列。
    • 一般来说,这是不值得做的,与回报相比,这需要冒很多风险/付出很多努力。

更多

上一篇:Sentry 监控 - Snuba 数据中台架构简介(Kafka+Clickhouse)


下一篇:Sentry 监控 - 全栈开发人员的分布式跟踪 101 系列教程(第一部分)