Help us learn about your current experience with the documentation. Take the survey.

迁移样式指南

在为 GitLab 编写迁移时,您必须考虑到这些迁移会被数十万的各种规模的组织运行,其中一些组织的数据库中存有多年数据。

此外,无论升级规模大小,都需要让服务器离线,这对大多数组织来说都是很大的负担。因此,仔细编写迁移、能够在线应用并遵循以下样式指南非常重要。

迁移不允许要求 GitLab 安装永远离线。迁移必须始终以避免停机的方式编写。过去我们有一个定义迁移的过程,通过设置 DOWNTIME 常量允许停机。查看旧迁移时可能会看到这一点。这个流程存在了4年从未被使用过,因此我们了解到总是可以找到不同的方式来编写迁移以避免停机。

在编写迁移时,还要考虑数据库可能存在陈旧数据或不一致情况,并对此进行防护。尽量少对数据库的状态做假设。

不要依赖 GitLab 特定的代码,因为它可能在未来的版本中发生变化。如果需要,将 GitLab 代码复制粘贴到迁移中以实现向前兼容。

选择合适的迁移类型

在添加新迁移之前,第一步应该是确定哪种类型最为合适。

目前你可以创建三种类型的迁移,具体取决于它需要执行的工作以及完成所需的时间:

  1. 常规模式迁移。这些是位于 db/migrate 目录下的传统 Rails 迁移,会在新应用代码部署之前运行(对于 GitLab.com 是在 Canary 部署 之前)。这意味着它们应该相对快速,不超过几分钟,以免不必要的延迟部署。

    有一个例外是耗时较长但对应用程序正确运行至关重要的迁移。例如,你可能拥有强制唯一约束的索引,或是应用程序关键部分查询性能所需的索引。然而,当迁移速度不可接受时,更好的选择可能是使用 feature flag 保护该功能,并改为执行后部署迁移。功能可以在迁移完成后启用。

    用于添加新模型的迁移也属于这些常规模式迁移的一部分。唯一的区别是生成迁移所用的 Rails 命令以及额外生成的文件(一个是模型文件,另一个是模型的测试文件)。

  2. 后部署迁移。这些是位于 db/post_migrate 的 Rails 迁移,独立于 GitLab.com 部署运行。待处理的后续迁移会由发布经理通过 后部署迁移流水线 每日执行一次。这些迁移可用于对应用程序操作非关键的 schema 变更,或耗时最多几分钟的数据迁移。应在后部署阶段运行的 schema 变更常见示例包括:

    • 清理工作,如删除未使用的列。
    • 在高流量表上添加非关键索引。
    • 添加耗时较长的非关键索引。

    这些迁移不应用于对应用程序操作至关重要的 schema 变更。过去曾在后部署迁移中执行此类 schema 变更导致过问题,例如 此问题。应始终作为常规模式迁移而非在后部署迁移中执行的变更包括:

    • 创建新表,例如 create_table
    • 向现有表添加新列,例如 add_column

    后部署迁移通常简称为 PDM。

  3. 批量后台迁移。这些并非传统的 Rails 迁移,而是通过 Sidekiq 作业执行的应用程序代码(尽管使用了后部署迁移来调度它们)。仅适用于超出后部署迁移时间指南的数据迁移。批量后台迁移不应更改 schema。

使用以下图表指导你的决策,但请记住它只是一个工具,最终结果将始终取决于具体的变更内容:

graph LR
    A{Schema<br/>changed?}
    A -->|Yes| C{Critical to<br/>speed or<br/>behavior?}
    A -->|No| D{Is it fast?}

    C -->|Yes| H{Is it fast?}
    C -->|No| F[Post-deploy migration]

    H -->|Yes| E[Regular migration]
    H -->|No| I[Post-deploy migration<br/>+ feature flag]

    D -->|Yes| F[Post-deploy migration]
    D -->|No| G[Background migration]

在选择添加数据库索引时使用哪种迁移类型,也可参考 应使用的迁移类型

迁移应花费多长时间

一般来说,单个部署的所有迁移在 GitLab.com 上不应超过 1 小时。以下指南并非硬性规则,而是为了尽可能缩短迁移时间而制定的估算标准。

请注意,所有时长都应以 GitLab.com 为基准测量。

数据库迁移管道 的结果包含了迁移的时序信息。

迁移类型 推荐时长 备注
常规迁移 ≤ 3 分钟 有效例外情况是:若不进行此变更,应用功能或性能将严重退化且无法延迟的情况。
部署后迁移 ≤ 10 分钟 有效例外情况是架构变更,因为这些操作不能在后台迁移中执行。
后台迁移 > 10 分钟 由于这类迁移适用于更大的表,无法设定精确的时间指导方针;但任何单条查询必须在冷缓存下低于 1 秒执行时间

大表限制

对于超出大小阈值的表,在添加新列或索引前,请阅读 大表限制

决定目标数据库

GitLab 连接两个不同的 Postgres 数据库:mainci。这种拆分会影响迁移,因为它们可能在其中一个或两个数据库上运行。

阅读 多数据库迁移,了解你添加的迁移是否需要考虑这一点或如何处理。

创建常规模式迁移

要创建迁移,你可以使用以下 Rails 生成器:

bundle exec rails g migration migration_name_here

这会在 db/migrate 中生成迁移文件。

新增模型的常规模式迁移

要创建新模型,你可以使用以下 Rails 生成器:

bundle exec rails g model model_name_here

这将生成:

  • db/migrate 中的迁移文件
  • app/models 中的模型文件
  • spec/models 中的测试文件

模式变更

对模式的变更应提交至 db/structure.sql。该文件由 Rails 自动生成(当你运行 bundle exec rails db:migrate 时),因此通常不应手动编辑此文件。如果你的迁移向表中添加了列,该列会通过迁移追加到表的 schema 中。不要为现有表手动重新排序列,因为这会导致其他使用 Rails 生成的 db/structure.sql 的人产生冲突。

异步创建索引需要两个合并请求。 完成后,请在添加索引的合并请求中使用 add_concurrent_index 提交 schema 变更。

当你在 GDK 中的本地数据库与 main 的 schema 出现分歧时,你可能难以干净地提交 schema 变更到 Git。在这种情况下,你可以使用 scripts/regenerate-schema 脚本为你正在添加的迁移重新生成干净的 db/structure.sql。该脚本会应用 db/migratedb/post_migrate 中找到的所有迁移,因此如果有不想提交到 schema 的迁移,请重命名或删除它们。如果你的分支未针对默认 Git 分支,可以设置 TARGET 环境变量。


# 针对 main 重新生成 schema
scripts/regenerate-schema

# 针对 12-9-stable-ee 重新生成 schema
TARGET=12-9-stable-ee scripts/regenerate-schema

scripts/regenerate-schema 脚本可能会产生额外差异。如果发生这种情况,请使用手动流程,其中 <migration ID> 是迁移文件的 DATETIME 部分。


# 基于 master 变基
git rebase master

# 回滚变更
VERSION=<migration ID> bundle exec rails db:migrate:down:main

# 从 master 检出 db/structure.sql
git checkout origin/master db/structure.sql

# 应用变更
VERSION=<migration ID> bundle exec rails db:migrate:main

表创建后,应按照 数据库字典指南 中提到的步骤将其添加到数据库字典。

迁移校验和文件

当迁移首次执行时,会在 db/schema_migrations 中创建一个新的 migration checksum file(迁移校验和文件),其中包含一个由迁移时间戳生成的 SHA256。这个新文件的名称与迁移文件名的 时间戳部分 相同,例如 db/schema_migrations/20241021120146。该文件的内容是时间戳部分的 SHA256,例如:

$ echo -n "20241021120146" | sha256sum
7a3e382a6e5564bfa7004bca1a357a910b151e7399c6466113daf01526d97470  -

SHA256 为文件添加了独特内容,因此 Git 重命名检测会将它们视为 独立文件

这个 migration checksum file 表示迁移已成功执行,且结果记录在 db/structure.sql 中。该文件的存在防止同一迁移被多次执行,因此,在添加新迁移的合并请求中必须包含此文件。

有关 db/schema_migrations 目录的更多详情,请参阅 Development change: Database schema version handling outside of structure.sql

保持迁移校验和文件更新

  • 当新建迁移时,运行 rake db:migrate 执行迁移并生成对应的 db/schema_migration/<timestamp> 校验和文件,然后将此文件纳入版本控制。
  • 如果迁移被删除,移除对应的 db/schema_migration/<timestamp> 校验和文件。
  • 如果迁移的 时间戳部分 发生变化,移除对应的 db/schema_migration/<timestamp> 校验和文件,然后运行 rake db:migrate 生成新的文件,并将此文件纳入版本控制。
  • 如果迁移的内容发生改变,无需对 db/schema_migration/<timestamp> 校验和文件进行任何修改。

避免停机时间

文档 “Avoiding downtime in migrations” 指定了各种数据库操作,例如:

并解释了如何在不要求停机的情况下执行这些操作。

可逆性

你的迁移 必须 具备可逆性。这一点非常重要,因为在出现漏洞或错误时,应能够降级。

注意:在 GitLab 生产环境中,如果出现问题,会采用向前滚动策略(roll-forward),而非使用 db:rollback 回滚迁移。对于 GitLab 自管理(Self-Managed)部署,我们建议用户恢复升级过程开始前创建的备份。down 方法主要在开发环境中使用,例如当开发者希望在切换提交或分支时确保其本地的 structure.sql 文件和数据库处于一致状态。

在你的迁移中,添加一条注释描述如何测试迁移的可逆性。

有些迁移无法逆转。例如,某些数据迁移无法逆转,因为我们失去了迁移前数据库状态的信息。你仍然应该创建一个 down 方法并添加注释,解释为什么 up 方法执行的变更无法逆转,这样即使迁移期间执行的变更本身无法逆转,迁移本身也可以被逆转:

def down
  # 无操作 (no-op)

  # 注释解释为何 `up` 方法执行的变更无法逆转。
end

此类迁移本质上具有风险,并且在为审查准备迁移时需要进行 额外的操作

原子性与事务

默认情况下,迁移是一个单一事务:它在迁移开始时开启,并在所有步骤处理完成后提交。

以单一事务运行迁移可确保如果某个步骤失败,所有步骤都不会执行,从而使数据库保持在有效状态。因此,有以下两种选择:

  • 将所有迁移放入一个单一事务迁移中。
  • 如有必要,将大部分操作放入一个迁移中,并为无法在单一事务中完成的步骤创建单独的迁移。

例如,如果你创建了一个空表并需要为其构建索引,你应该使用常规的单一事务迁移和默认的 Rails schema 语句:add_index。此操作是阻塞操作,但由于该表尚未被使用,因此还没有任何记录,所以不会造成问题。

子事务通常是被禁止的。如需使用多个独立事务,请按照单一事务中的重操作中的说明进行。

单一事务中的重操作

在使用单一事务迁移时,事务会在整个迁移期间持有数据库连接,因此你必须确保迁移中的操作不会花费太多时间。一般来说,事务必须快速执行。为此,请遵守最大查询时间限制,即每次在迁移中运行的查询都要符合该限制。

如果你的单一事务迁移耗时过长,你有几种选择。在所有情况下,请记住根据迁移应花费的时间选择适当的迁移类型:

  • 将迁移拆分为多个单一事务迁移

  • 通过使用 disable_ddl_transaction!使用多个事务

  • 调整语句和锁超时设置后继续使用单一事务迁移。如果你的繁重工作负载必须使用事务的保证,你应该检查你的迁移能否在不触发超时限制的情况下执行。同样的建议适用于单一事务迁移和独立事务。

    • 语句超时:GitLab.com 生产数据库的语句超时设置为 15s,但创建索引通常需要超过 15 秒。当你使用现有的助手(包括 add_concurrent_index)时,它们会根据需要自动关闭语句超时。在极少数情况下,你可能需要通过使用 disable_statement_timeout自行设置超时限制。

为了运行迁移,我们直接连接到主数据库,绕过 PgBouncer 来控制 statement_timeoutlock_wait_timeout 等设置。

临时关闭语句超时限制

迁移助手 disable_statement_timeout 可让你临时将语句超时设置为 0,可以是每事务或每连接的方式。

  • 当你的语句不支持在显式事务块内运行时(例如 CREATE INDEX CONCURRENTLY),应使用每连接选项。

  • 如果你的语句支持显式事务块(例如 ALTER TABLE ... VALIDATE CONSTRAINT),则应使用每事务选项。

使用 disable_statement_timeout 很少需要,因为大多数迁移助手在需要时会内部使用它。例如,创建索引通常需要超过 15 秒,这是 GitLab.com 生产数据库配置的默认语句超时时间。助手 add_concurrent_index 会在传递给 disable_statement_timeout 的块内创建索引,从而按连接方式关闭语句超时。

如果你在迁移中编写原始 SQL 语句,可能需要手动使用 disable_statement_timeout。在这种情况下,请咨询数据库评审者和维护者。

禁用事务包装的迁移

你可以通过使用 disable_ddl_transaction!(一个ActiveRecord方法)来选择不在单个事务中运行你的迁移。

该方法可能在其他数据库系统中被调用,产生不同的结果。在GitLab中,我们仅使用PostgreSQL。

你应该始终将 disable_ddl_transaction! 理解为以下含义:

“不要在单个PostgreSQL事务中执行此迁移。我仅在需要时如果需要才会打开PostgreSQL事务。”

即使你没有使用显式的PostgreSQL事务 .transaction(或 BEGIN; COMMIT;),每个SQL语句仍会被当作事务执行。 请参阅PostgreSQL文档中的事务部分

在GitLab中,我们有时会将使用 disable_ddl_transaction! 的迁移称为非事务性迁移。 这只是意味着这些迁移不是以单个事务执行的。

何时应使用 disable_ddl_transaction!?大多数情况下,现有的RuboCop规则或迁移助手可以检测你是否应该使用它。 如果你不确定是否应在迁移中使用它,请跳过 disable_ddl_transaction!,让RuboCop规则和数据库审查引导你。

当PostgreSQL要求某个操作必须在显式事务之外执行时,使用 disable_ddl_transaction!

  • 最典型的例子是命令 CREATE INDEX CONCURRENTLY。 PostgreSQL允许阻塞版本(CREATE INDEX)在事务内运行。 与 CREATE INDEX 不同,CREATE INDEX CONCURRENTLY 必须在事务外执行。 因此,即使迁移只运行一条语句 CREATE INDEX CONCURRENTLY,你也应禁用 disable_ddl_transaction!。 这也是为什么使用辅助方法 add_concurrent_index 需要 disable_ddl_transaction! 的原因。 CREATE INDEX CONCURRENTLY 属于例外而非常规情况。

当你因任何原因需要在迁移中运行多个事务时,使用 disable_ddl_transaction!。 大多数时候,你会使用多个事务来避免在一个慢速事务中运行

  • 例如,当你插入、更新或删除(DML)大量数据时,你应该分批执行。 如果需要对每批操作进行分组,可以在处理一批时显式打开事务块。 对于任何合理的大工作量,考虑使用批量后台迁移

当迁移助手需要它们时,使用 disable_ddl_transaction!。 各种迁移助手需要与 disable_ddl_transaction! 一起运行,因为它们需要精确控制何时以及如何打开事务。

  • 外键可以在事务内添加,这与 CREATE INDEX CONCURRENTLY 不同。 但是,PostgreSQL没有提供类似 CREATE INDEX CONCURRENTLY 的选项。 辅助方法add_concurrent_foreign_key 会自行打开事务, 以最小化锁定的方式锁定源表和目标表,同时添加和验证外键。
  • 如前所述,如果不确信,请跳过 disable_ddl_transaction! 并查看是否有违反任何RuboCop检查的情况。

当你的迁移实际上不涉及PostgreSQL数据库,或者涉及多个PostgreSQL数据库时,使用 disable_ddl_transaction!

  • 例如,你的迁移可能针对Redis服务器。通常来说,你不能在PostgreSQL事务内与外部服务交互
  • 事务用于单个数据库连接。 如果你的迁移目标是多个数据库,例如 cimain 数据库,请遵循多数据库迁移

命名规范

数据库对象(如表、索引和视图)的名称必须为小写。 小写名称可确保未加引号的查询不会导致错误。

我们保持列名与ActiveRecord的架构约定一致。

自定义索引和约束名称应遵循约束命名约定指南

截断过长的索引名

PostgreSQL 限制了标识符的长度,例如列名或索引名。列名通常不是问题,但索引名往往更长。以下是缩短过长名称的一些方法:

  • i_ 替代 index_ 作为前缀。
  • 跳过冗余前缀。例如,index_vulnerability_findings_remediations_on_vulnerability_remediation_id 变为 index_vulnerability_findings_remediations_on_remediation_id
  • 不指定列名,而是指定索引的目的,例如 index_users_for_unconfirmation_notification

迁移时间戳的时效性

迁移文件名中的时间戳决定了迁移执行的顺序。重要的是维护以下两者之间的大致关联:

  1. 迁移添加到 GitLab 代码库的时间。
  2. 迁移本身的时间戳。

新迁移的时间戳绝不应早于上一个 必需升级停止点。偶尔会压缩迁移,如果添加的迁移时间戳早于上一个必需停止点,可能会发生类似 issue 408304 的问题。

例如,如果我们当前正在针对 GitLab 16.0 进行开发,上一个必需停止点是 15.11。15.11 于 2023 年 4 月 23 日发布。因此,可接受的最小时间戳应为 20230424000000。

最佳实践

虽然上述内容应视为硬性规则,但最佳实践是尽量让迁移时间戳保持在预计合并上游日期的三周内,无论自上次必需停止点以来过去了多少时间。

要更新迁移时间戳:

  1. cimain 数据库降级该迁移:

    rake db:migrate:down:main VERSION=<timestamp>
    rake db:migrate:down:ci VERSION=<timestamp>
  2. 删除迁移文件。

  3. 按照 迁移样式指南 重新创建迁移。

或者,你可以使用此脚本来刷新所有迁移时间戳:

scripts/refresh-migrations-timestamps

该脚本:

  1. 将所有迁移时间戳更新为当前时间
  2. 保持迁移的相对顺序
  3. 更新文件名和时间戳(位于迁移类内部)
  4. 处理常规迁移和后部署迁移

如果你的迁移已处于评审状态很长时间(> 3 周),或在变基旧迁移分支时,请在合并前运行此脚本。

迁移助手与版本控制

许多常见数据库迁移模式都有相应的辅助方法可用。这些助手可以在 Gitlab::Database::MigrationHelpers 及相关模块中找到。

为了允许随时间改变助手的行为,我们为迁移助手实现了版本化方案。这使我们能够维持现有迁移中助手的行为,同时为新迁移更改行为。

为此,所有数据库迁移都应继承自 Gitlab::Database::Migration,这是一个“版本化”类。对于新迁移,应使用最新版本(可在 Gitlab::Database::Migration::MIGRATION_CLASSES 中查找)以使用最新版本的迁移助手。

在此示例中,我们使用迁移类的版本 2.1:

class TestMigration < Gitlab::Database::Migration[2.1]
  def change
  end
end

不要直接将 Gitlab::Database::MigrationHelpers 包含到迁移中。相反,请使用最新版本的 Gitlab::Database::Migration,它会自动暴露最新版本的迁移助手。

获取数据库锁时的重试机制

在更改数据库架构时,我们使用辅助方法来调用 DDL(数据定义语言)语句。在某些情况下,这些 DDL 语句需要特定的数据库锁。

示例:

def change
  remove_column :users, :full_name, :string
end

执行此迁移需要对 users 表拥有排他锁。当该表被并发访问和修改时,获取锁可能需要一段时间。锁请求在队列中等待,一旦入队,它也可能阻塞 users 表上的其他查询。

有关 PostgreSQL 锁的更多信息:显式锁定

出于稳定性的考虑,GitLab.com 设置了较短的 statement_timeout。当迁移被调用时,任何数据库查询都有固定的执行时间。在最坏的情况下,请求位于锁队列中,在配置的语句超时期间阻塞其他查询,然后因 “由于语句超时而取消语句” 错误而失败。

这个问题可能导致应用程序升级过程失败,甚至引发应用程序稳定性问题,因为该表可能在短时间内无法访问。

为了提高数据库迁移的可靠性和稳定性,GitLab 代码库提供了一种方法,通过不同的 lock_timeout 设置和尝试之间的等待时间来重试操作。多次较短的尝试获取必要锁,使数据库能够处理其他语句。

在使用 non_transactional 迁移时,with_lock_retries 方法可对迁移内执行的代码块的锁获取重试和超时配置进行显式控制。

事务性迁移

常规迁移会在事务中执行完整的迁移。锁重试机制默认启用(除非使用 disable_ddl_transaction!)。

这会导致锁超时由迁移控制。此外,如果在超时内未能授予锁,可能会导致重试整个迁移。

偶尔,迁移可能需要获取多个不同对象的锁。为防止目录膨胀,应在执行任何 DDL 之前明确请求所有这些锁。更好的策略是将迁移拆分,这样我们每次只需获取一个锁。

同一表上的多项变更

启用锁重试方法论后,所有操作会被包装到一个事务中。当你持有锁时,应尽可能在该事务内完成更多操作,而不是之后再尝试获取另一个锁。请注意在块内运行长时间数据库语句的风险。所获取的锁会一直保持到事务(块)结束,并且根据锁的类型,可能会阻塞其他数据库操作。

def up
  add_column :users, :full_name, :string
  add_column :users, :bio, :string
end

def down
  remove_column :users, :full_name
  remove_column :users, :bio
end

修改列的默认值

如果不遵循多版本流程,修改列默认值可能导致应用停机。 详情请参阅 避免迁移过程中修改列默认值导致的停机

def up
  change_column_default :merge_requests, :lock_version, from: nil, to: 0
end

def down
  change_column_default :merge_requests, :lock_version, from: 0, to: nil
end

当有两个外键时创建新表

每个事务应仅创建一个外键。这是因为添加外键约束需要对被引用表施加 SHARE ROW EXCLUSIVE,而应避免在同一事务中对多个表加锁。

为此,我们需要三个迁移:

  1. 创建不带外键的表(包含索引)。
  2. 为第一个表添加外键。
  3. 为第二个表添加外键。

创建表:

def up
  create_table :imports do |t|
    t.bigint :project_id, null: false
    t.bigint :user_id, null: false
    t.string :jid, limit: 255

    t.index :project_id
    t.index :user_id
  end
end

def down
  drop_table :imports
end

projects 表添加外键:

此时可以使用 add_concurrent_foreign_key 方法,因为这个辅助方法内置了锁重试机制。

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :projects, column: :project_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :project_id
  end
end

users 表添加外键:

disable_ddl_transaction!

def up
  add_concurrent_foreign_key :imports, :users, column: :user_id, on_delete: :cascade
end

def down
  with_lock_retries do
    remove_foreign_key :imports, column: :user_id
  end
end

非事务性迁移的使用

只有在通过 disable_ddl_transaction! 禁用事务性迁移时,才能使用 with_lock_retries 辅助方法来保护单个步骤序列。它会开启一个事务来执行给定的代码块。

自定义 RuboCop 规则会确保只有允许的方法能放入锁重试块内。

disable_ddl_transaction!

def up
  with_lock_retries do
    add_column(:users, :name, :text, if_not_exists: true)
  end

  add_text_limit :users, :name, 255 # 包含约束验证(全表扫描)
end

RuboCop 规则通常允许以下标准 Rails 迁移方法。此示例会导致 RuboCop 报错:

disable_ddl_transaction!

def up
  with_lock_retries do
    add_concurrent_index :users, :name
  end
end

何时使用辅助方法

只能在使用 with_lock_retries 辅助方法时,确保执行过程不在已开启的事务内(不建议使用 PostgreSQL 子事务)。它可以与标准 Rails 迁移辅助方法配合使用。如果在同一张表上执行多个迁移辅助方法,不会出现问题。

当数据库迁移涉及其中一个高流量表时,建议使用 with_lock_retries 辅助方法。

示例变更包括:

  • add_foreign_key / remove_foreign_key
  • add_column / remove_column
  • change_column_default
  • create_table / drop_table

with_lock_retries 方法不能change 方法中使用,你必须手动定义 updown 方法使迁移可逆。

辅助方法的运作方式

  1. 迭代 50 次。
  2. 对每次迭代,设置预配置的 lock_timeout
  3. 尝试执行给定代码块(如 remove_column)。
  4. 若抛出 LockWaitTimeout 错误,休眠预设的 sleep_time 后重试代码块。
  5. 若未抛出错误,当前迭代成功执行代码块。

更多信息请参阅 Gitlab::Database::WithLockRetries 类。with_lock_retries 辅助方法实现在 Gitlab::Database::MigrationHelpers 模块中。

在最坏情况下,该方法:

  • 在 40 分钟内最多执行代码块 50 次。
    • 大部分时间用于每次迭代后的预设休眠期。
  • 第 50 次重试后,无 lock_timeout 地执行代码块,就像标准迁移调用一样。
  • 若仍无法获取锁,迁移会因 statement timeout 错误失败。

若存在访问 users 表且运行时间极长(40 分钟以上)的事务,迁移可能会失败。

SQL层面的锁重试方法

在本节中,我们提供一个简化的SQL示例,演示lock_timeout的使用。您可以通过在多个psql会话中运行给定的代码片段来跟随学习。

当修改表以添加列时,需要在表上获取AccessExclusiveLock(该锁与大多数锁类型冲突)。如果目标表非常繁忙,添加列的事务可能无法及时获取AccessExclusiveLock

假设一个事务正在尝试向表中插入一行:

-- Transaction 1
BEGIN;
INSERT INTO my_notes (id) VALUES (1);

此时Transaction 1已在my_notes上获取了RowExclusiveLock。Transaction 1在提交或中止前仍可执行更多语句。可能有其他类似的并发事务访问my_notes

假设一个事务性迁移试图在不使用任何锁重试辅助工具的情况下向表中添加列:

-- Transaction 2
BEGIN;
ALTER TABLE my_notes ADD COLUMN title text;

Transaction 2现在被阻塞,因为它无法在my_notes表上获取AccessExclusiveLock——Transaction 1仍在执行并持有my_notes上的RowExclusiveLock

更棘手的影响是,由于Transaction 2排队等待获取AccessExclusiveLock,那些通常不会与Transaction 1冲突的事务会被阻塞。正常情况下,若另一个事务在同一时间对同一张表my_notes进行读写操作,该事务会顺利执行(因为读写所需的锁不会与Transaction 1持有的RowExclusiveLock冲突)。然而,当获取AccessExclusiveLock的请求排队后,尽管这些后续请求本可与Transaction 1并行执行,但对表施加冲突锁的请求仍会被阻塞。

如果我们使用with_lock_retries,Transaction 2会在指定时间内未能获取锁后快速超时,从而让其他事务继续执行:

-- Transaction 2 (带锁超时的版本)
BEGIN;
SET LOCAL lock_timeout to '100ms'; -- 由锁重试助手添加。
ALTER TABLE my_notes ADD COLUMN title text;

锁重试助手会在不同时间间隔反复尝试相同的事务,直至成功。

SET LOCAL将参数(lock_timeout)的变化范围限定在事务内。

移除索引

若表非空时移除索引,请务必使用remove_concurrent_index方法而非常规的remove_index方法。remove_concurrent_index方法会并发删除索引,因此无需加锁,也无需停机时间。要使用此方法,必须在迁移类主体中调用disable_ddl_transaction!方法禁用单事务模式,示例如下:

class MyMigration < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  INDEX_NAME = 'index_name'

  def up
    remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
  end
end

您可通过Grafana验证索引未被使用:

sum by (type)(rate(pg_stat_user_indexes_idx_scan{env="gprd", indexrelname="INSERT INDEX NAME HERE"}[30d]))

移除索引前无需检查其是否存在,但必须指定要移除的索引名称。这既可以通过将名称作为选项传递给remove_indexremove_concurrent_index的适当形式实现,也可使用remove_concurrent_index_by_name方法。明确指定名称很重要,以确保移除的是正确的索引。

对于小表(如空表或记录数少于1,000的表),建议在单事务迁移中使用remove_index,并结合其他不需要disable_ddl_transaction!的操作。

禁用索引

禁用索引并非安全操作

添加索引

添加索引前,请考虑是否有必要。有关详情及最佳实践,请参阅添加数据库索引指南。

测试索引是否存在

如果迁移需要基于索引的存在或缺失来执行条件逻辑,你必须通过索引名称来测试其存在性。这有助于避免 Rails 比较索引定义方式时可能出现的问题,这些问题可能导致意外结果。

有关更多详情,请查看 添加数据库索引 指南。

NOT NULL 约束

有关更多信息,请参阅关于 NOT NULL 约束 的风格指南。

添加带有默认值的列

由于 GitLab 中 PostgreSQL 的最低版本现在是 11,添加带有默认值的列变得更加简单,在所有情况下都应使用标准的 add_column 辅助方法。

在 PostgreSQL 11 之前,添加带有默认值的列存在问题,因为这会导致全表重写。

移除非空列的默认值

如果你已添加非空列并使用默认值填充现有数据,你需要保留该默认值,直到应用程序代码更新后至少一次。你不能在同一迁移中移除默认值,因为迁移会在模型代码更新前运行,而模型会持有旧的模式缓存,这意味着它们不会知道这个列,也无法设置它。在这种情况下,建议:

  1. 在标准迁移中添加带有默认值的列。
  2. 在部署后迁移中移除默认值。

部署后迁移发生在应用程序重启之后,确保新列已被发现。

更改列默认值

有人可能会认为使用 change_column_default 更改默认列对于较大的表来说是一项昂贵且具有破坏性的操作,但实际上并非如此。

以以下迁移为例:

class DefaultRequestAccessGroups < Gitlab::Database::Migration[2.1]
  def change
    change_column_default(:namespaces, :request_access_enabled, from: false, to: true)
  end
end

上述迁移更改了我们最大表之一 namespaces 的默认列值。这可以转换为:

ALTER TABLE namespaces
ALTER COLUMN request_access_enabled
SET DEFAULT false

在这种特定情况下,默认值已经存在,我们只是在更改 request_access_enabled 列的元数据,这并不意味着重写 namespaces 表中的所有现有记录。只有当创建带有默认值的新列时,所有记录才会被重写。

在 PostgreSQL 11.0 中引入了一种更快的 ALTER TABLE ADD COLUMN 带有非空默认值 操作,消除了添加带有默认值的新列时重写表的需要。

出于上述原因,在单事务迁移中使用 change_column_default 是安全的,无需要求 disable_ddl_transaction!

更新现有列

要将现有列更新为特定值,你可以使用 update_column_in_batches。这将更新拆分为批次,因此我们不会在一次语句中更新太多行。

此示例将 projects 表中的 foo 列更新为 10,其中 some_column'hello'

update_column_in_batches(:projects, :foo, 10) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

如果需要计算更新,可以将值包装在 Arel.sql 中,这样 Arel 会将其视为 SQL 字面量。这也是 Rails 6 所需的弃用项。

以下示例与上面的相同,但值设置为 barbaz 列的乘积:

update_value = Arel.sql('bar * baz')

update_column_in_batches(:projects, :foo, update_value) do |table, query|
  query.where(table[:some_column].eq('hello'))
end

在使用 update_column_in_batches 时,只要仅更新表中行的较小子集,运行在大表上可能是可接受的,但在事先未在 GitLab.com 预发布环境中验证(或请求他人为你验证)的情况下,请不要忽视这一点。

删除外键约束

当删除外键约束时,我们需要在相关联的两个表上获取锁。对于具有高写入模式的表,使用 with_lock_retries 是个好主意,否则你可能无法及时获取锁。在获取锁时你也可能会遇到死锁,因为通常应用程序以 parent,child 顺序写入。然而,删除外键会以 child,parent 顺序获取锁。为了解决这个问题,你可以显式地以 parent,child 顺序获取锁,例如:

disable_ddl_transaction!

def up
  with_lock_retries do
    execute('lock table ci_pipelines, ci_builds in access exclusive mode')

    remove_foreign_key :ci_builds, to_table: :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
  end
end

def down
  add_concurrent_foreign_key :ci_builds, :ci_pipelines, column: :pipeline_id, on_delete: :cascade, name: 'the_fk_name'
end

删除数据库表

在删除表后,应按照 数据库字典指南 中的步骤将其添加到数据库字典。

删除数据库表并不常见,Rails 提供的 drop_table 方法通常被认为是安全的。在删除表之前,请考虑以下几点:如果你的表在外键关联到一个 高流量表(如 projects),那么 DROP TABLE 语句可能会阻塞并发流量,直到因 语句超时 错误而失败。

没有记录(功能从未使用过)且 没有外键

  • 使用迁移中的 drop_table 方法。
def change
  drop_table :my_table
end

有记录没有外键

  • 移除与应用程序相关的代码,如模型、控制器和服务。
  • 在部署后的迁移中使用 drop_table

如果确定代码未被使用,可以放在单个迁移中;如果想降低风险,可以在应用更改合并后再提交第二个合并请求。此方式提供了回滚的机会。

def up
  drop_table :my_table
end

def down
  # create_table ...
end

有外键

  • 移除与应用程序相关的代码,如模型、控制器和服务。
  • 在部署后的迁移中,使用 with_lock_retries 辅助方法移除外键。在随后的另一个部署后迁移中,使用 drop_table

如果确定代码未被使用,可以放在单个迁移中;如果想降低风险,可以在应用更改合并后再提交第二个合并请求。此方式提供了回滚的机会。

通过非事务性迁移从 projects 表移除外键:

# 第一个迁移文件
class RemovingForeignKeyMigrationClass < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  def up
    with_lock_retries do
      remove_foreign_key :my_table, :projects
    end
  end

  def down
    add_concurrent_foreign_key :my_table, :projects, column: COLUMN_NAME
  end
end

删除表:

# 第二个迁移文件
class DroppingTableMigrationClass < Gitlab::Database::Migration[2.1]
  def up
    drop_table :my_table
  end

  def down
    # create_table with the same schema but without the removed foreign key ...
  end
end

删除序列

删除序列并不常见,但你可以使用数据库团队提供的 drop_sequence 方法。其工作原理如下:

删除序列:

  • 如果序列实际被使用,则移除默认值。
  • 执行 DROP SEQUENCE

重新添加序列:

  • 创建序列,可以选择指定当前值。
  • 更改列的默认值。

一个 Rails 迁移示例:

class DropSequenceTest < Gitlab::Database::Migration[2.1]
  def up
    drop_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq)
  end

  def down
    default_value = Ci::Pipeline.maximum(:id) + 10_000

    add_sequence(:ci_pipelines_config, :pipeline_id, :ci_pipelines_config_pipeline_id_seq, default_value)
  end
end

add_sequence 应避免用于带有外键的列。将这些列添加序列 仅允许 在 down 方法中(恢复之前的架构状态)。

截断表

截断表的操作并不常见,但你可以使用数据库团队提供的 truncate_tables! 方法。

其内部工作原理如下:

  • 找到待截断表的 gitlab_schema
  • 若待截断表的 gitlab_schema 包含在连接的 gitlab_schemas 中,则执行 TRUNCATE 语句。
  • 若待截断表的 gitlab_schema 未包含在连接的 gitlab_schemas 中,则不做任何操作。

交换主键

要对表进行分区,必须交换主键,因为分区键必须包含在主键中

你可以使用数据库团队提供的 swap_primary_key 方法。

其内部工作原理如下:

  • 删除主键约束。
  • 使用之前定义的索引添加主键。
class SwapPrimaryKey < Gitlab::Database::Migration[2.1]
  disable_ddl_transaction!

  TABLE_NAME = :table_name
  PRIMARY_KEY = :table_name_pkey
  OLD_INDEX_NAME = :old_index_name
  NEW_INDEX_NAME = :new_index_name

  def up
    swap_primary_key(TABLE_NAME, PRIMARY_KEY, NEW_INDEX_NAME)
  end

  def down
    add_concurrent_index(TABLE_NAME, :id, unique: true, name: OLD_INDEX_NAME)
    add_concurrent_index(TABLE_NAME, [:id, :partition_id], unique: true, name: NEW_INDEX_NAME)

    unswap_primary_key(TABLE_NAME, PRIMARY_KEY, OLD_INDEX_NAME)
  end
end

为确保交换主键,请先在一个单独的迁移中提前引入新索引。

整数列类型

默认情况下,一个整数列最多可存储 4 字节(32 位)的数字,即最大值为 2,147,483,647。在创建用于存储以字节为单位的文件大小的列时,请注意这一点。若你跟踪的是以字节为单位的文件大小,这将把最大文件大小限制在略超 2 GB。

若要让整数列能存储最多 8 字节(64 位)的数字,需显式将限制设为 8 字节。这让该列可存储高达 9,223,372,036,854,775,807 的值。

Rails 迁移示例:

add_column(:projects, :foo, :integer, default: 10, limit: 8)

字符串与 Text 数据类型

有关更多信息,请参阅 text 数据类型 风格指南。

时间戳列类型

默认情况下,Rails 使用 timestamp 数据类型来存储无时区信息的时间戳数据。通过调用 add_timestampstimestamps 方法来使用 timestamp 数据类型。

此外,Rails 会将 :datetime 数据类型转换为 timestamp 类型。

示例:

# timestamps
create_table :users do |t|
  t.timestamps
end

# add_timestamps
def up
  add_timestamps :users
end

# :datetime
def up
  add_column :users, :last_sign_in, :datetime
end

不要使用这些方法,而应使用以下方法来存储带有时区的时间戳:

  • add_timestamps_with_timezone
  • timestamps_with_timezone
  • datetime_with_timezone

这能确保所有时间戳都指定了时区。这意味着当系统时区变更时,现有时间戳不会突然使用不同时区。同时,这也让最初使用的时区非常清晰。

在数据库中存储JSON

Rails 5原生支持JSONB(二进制JSON)列类型。
添加此列的迁移示例:

class AddOptionsToBuildMetadata < Gitlab::Database::Migration[2.1]
  def change
    add_column :ci_builds_metadata, :config_options, :jsonb
  end
end

默认情况下,哈希键将为字符串。您可选择添加自定义数据类型以提供不同的键访问方式。

class BuildMetadata
  attribute :config_options, ::Gitlab::Database::Type::IndifferentJsonb.new # 用于 indifferent 访问,若仅需符号作为键,可使用 ::Gitlab::Database::Type::SymbolizedJsonb.new。
end

使用JSONB列时,需通过JsonSchemaValidator控制数据插入。同时需指定size_limit以避免大JSONB数据引发的性能问题,64 KB 为推荐上限。

JsonbSizeLimit cop会对新验证强制该要求——因无界JSONB增长会导致内存压力与海量记录下的查询变慢。对更大数据集,应改用对象存储并在数据库中存储引用。

class BuildMetadata
  validates :config_options, json_schema: { filename: 'build_metadata_config_option', size_limit: 64.kilobytes }
end

此外,可将JSONB列的键暴露为ActiveRecord属性(适用于复杂验证或变更追踪场景)。此功能由jsonb_accessor gem提供,不替代JsonSchemaValidator

module Organizations
  class OrganizationSetting < ApplicationRecord
    belongs_to :organization

    validates :settings, json_schema: { filename: "organization_settings" }

    jsonb_accessor :settings,
      restricted_visibility_levels: [:integer, { array: true }]

    validates_each :restricted_visibility_levels do |record, attr, value|
      value&.each do |level|
        unless Gitlab::VisibilityLevel.options.value?(level)
          record.errors.add(attr, format(_("'%{level}' is not a valid visibility level"), level: level))
        end
      end
    end
  end
end

现可通过restricted_visibility_levels作为ActiveRecord属性使用:

> s = Organizations::OrganizationSetting.find(1)
=> #<Organizations::OrganizationSetting:0x0000000148d67628>
> s.settings
=> {"restricted_visibility_levels"=>[20]}
> s.restricted_visibility_levels
=> [20]
> s.restricted_visibility_levels = [0]
=> [0]
> s.changes
=> {"settings"=>[{"restricted_visibility_levels"=>[20]}, {"restricted_visibility_levels"=>[0]}], "restricted_visibility_levels"=>[[20], [0]]}

加密属性

勿将encrypts属性存为数据库中的:text;应改用:jsonb(利用PostgreSQL的JSONB类型提升存储效率):

class AddSecretToSomething < Gitlab::Database::Migration[2.1]
  def change
    add_column :something, :secret, :jsonb, null: true
  end
end

在JSONB列中存储加密属性时,需添加长度验证(遵循Active Record Encryption建议)。多数加密属性最大长度设为510即可。

class Something < ApplicationRecord
  encrypts :secret
  validates :secret, length: { maximum: 510 }
end

带类型安全的增强验证

为保障数据完整性,需在加密前验证明文值的格式与类型:

class Something < ApplicationRecord
  encrypts :secret

  validates :secret,
            length: { maximum: 510 },
            format: { with: /\A[a-zA-Z]+\z/, allow_nil: true }

  validate :ensure_string_type

  private

  def ensure_string_type
    unless secret.is_a?(String) || secret.nil?
      errors.add(:secret, "必须是字符串")
    end
  end
end

该方法通过Rails格式验证器验证明文值(非底层JSONB值),并通过断言属性为Stringnil确保类型安全。

测试

请参阅测试Rails迁移风格指南。

数据迁移

优先使用 Arel 和原生 SQL 而非通常的 ActiveRecord 语法。若使用原生 SQL,需手动用 quote_string 辅助函数对所有输入进行引号转义。

Arel 示例:

users = Arel::Table.new(:users)
users.group(users[:user_id]).having(users[:id].count.gt(5))

# 用这些结果更新其他表

原生 SQL 与 quote_string 辅助函数示例:

select_all("SELECT name, COUNT(id) as cnt FROM tags GROUP BY name HAVING COUNT(id) > 1").each do |tag|
  tag_name = quote_string(tag["name"])
  duplicate_ids = select_all("SELECT id FROM tags WHERE name = '#{tag_name}'").map{|tag| tag["id"]}
  origin_tag_id = duplicate_ids.first
  duplicate_ids.delete origin_tag_id

  execute("UPDATE taggings SET tag_id = #{origin_tag_id} WHERE tag_id IN(#{duplicate_ids.join(",")})")
  execute("DELETE FROM tags WHERE id IN(#{duplicate_ids.join(",")})")
end

若需更复杂逻辑,可在迁移中定义并使用本地模型。例如:

class MyMigration < Gitlab::Database::Migration[2.1]
  class Project < MigrationRecord
    self.table_name = 'projects'
  end

  def up
    # 重置所有更新数据库模型的列信息
    # 确保 ActiveRecord 对表结构的认知是最新的
    Project.reset_column_information

    # ... ...
  end
end

执行此操作时需明确设置模型表名,避免从类名或命名空间推导。

需注意在迁移中使用模型的限制

修改现有数据

多数情况下,修改数据库数据时应优先采用批处理方式进行。

使用辅助函数 each_batch_range,该函数支持高效遍历集合。默认批次大小由 BATCH_SIZE 常量定义。

参考以下示例了解用法。

批量清理数据

disable_ddl_transaction!

def up
  each_batch_range('ci_pending_builds', scope: ->(table) { table.ref_protected }, of: BATCH_SIZE) do |min, max|
    execute <<~SQL
      DELETE FROM ci_pending_builds
        USING ci_builds
        WHERE ci_builds.id = ci_pending_builds.build_id
          AND ci_builds.status != 'pending'
          AND ci_builds.type = 'Ci::Build'
          AND ci_pending_builds.id BETWEEN #{min} AND #{max}
    SQL
  end
end
  • 第一个参数是被修改的表:'ci_pending_builds'
  • 第二个参数调用 Lambda 函数获取相关数据集(默认为 .all):scope: ->(table) { table.ref_protected }
  • 第三个参数是批次大小(默认值在 BATCH_SIZE 常量中定义):of: BATCH_SIZE

此处有示例 MR 展示如何使用该辅助函数。

在迁移中使用应用代码(不推荐)

在迁移中使用应用代码(包括模型)通常不被鼓励。这是因为迁移会长期存在,其依赖的应用代码可能在未来发生变化并导致迁移失效。过去某些后台迁移需使用应用代码以避免复制数百行分散在多个文件中的代码到迁移中。在这些罕见场景下,确保迁移具备良好测试至关重要,以便未来重构代码的人员能知晓是否破坏了迁移。使用应用代码也不适用于批量后台迁移,模型需在迁移中声明。

通常可通过定义继承自 MigrationRecord 的类来避免在迁移中使用应用代码(特别是模型)(见下方示例)。

若使用模型(包括迁移内定义的),应先通过 reset_column_information清除列缓存

若使用利用单表继承(STI)的模型,需注意特殊情况

这可避免因之前迁移中已修改并缓存的列被后续使用而导致的问题。

示例:向 users 表添加列 my_column

重要的是不要遗漏 User.reset_column_information 命令,以确保旧模式从缓存中删除,ActiveRecord 加载更新的模式信息。

class AddAndSeedMyColumn < Gitlab::Database::Migration[2.1]
  class User < MigrationRecord
    self.table_name = 'users'
  end

  def up
    User.count # 模型上任何缓存列信息的 ActiveRecord 调用。

    add_column :users, :my_column, :integer, default: 1

    User.reset_column_information # 旧模式从缓存中删除。
    User.find_each do |user|
      user.my_column = 42 if some_condition # 这里 ActiveRecord 能看到正确的模式。
      user.save!
    end
  end
end

底层数据表被修改后,通过 ActiveRecord 访问。

如果在前一个不同的迁移中对表进行了修改,且两个迁移在同一个 db:migrate 进程中运行,也需要使用此方法。

结果如下。注意包含 my_column

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-18T00:41:26.851769 #459802] DEBUG -- :   AddAndSeedMyColumn::User Update (1.1ms)  UPDATE "users" SET "my_column" = $1, "updated_at" = $2 WHERE "users"."id" = $3  [["my_column", 42], ["updated_at", "2020-07-17 23:41:26.849044"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

如果您跳过了清理模式缓存(User.reset_column_information),则 ActiveRecord 不会使用该列,预期的更改也不会生效,导致以下结果,其中查询中缺少 my_column

== 20200705232821 AddAndSeedMyColumn: migrating ==============================
D, [2020-07-06T00:37:12.483876 #130101] DEBUG -- :    (0.2ms)  BEGIN
D, [2020-07-06T00:37:12.521660 #130101] DEBUG -- :    (0.4ms)  SELECT COUNT(*) FROM "user"
-- add_column(:users, :my_column, :integer, {:default=>1})
D, [2020-07-06T00:37:12.523309 #130101] DEBUG -- :    (0.8ms)  ALTER TABLE "users" ADD "my_column" integer DEFAULT 1
   -> 0.0016s
D, [2020-07-06T00:37:12.650641 #130101] DEBUG -- :   AddAndSeedMyColumn::User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1000]]
D, [2020-07-06T00:37:12.653459 #130101] DEBUG -- :   AddAndSeedMyColumn::User Update (0.5ms)  UPDATE "users" SET "updated_at" = $1 WHERE "users"."id" = $2  [["updated_at", "2020-07-05 23:37:12.652297"], ["id", 1]]
D, [2020-07-06T00:37:12.653648 #130101] DEBUG -- :   ↳ config/initializers/config_initializers_active_record_locking.rb:13:in `_update_row'
== 20200705232821 AddAndSeedMyColumn: migrated (0.1706s) =====================

高流量表

以下是当前高流量表的列表。

确定哪些表是高流量表可能比较困难。GitLab 自托管实例可能会使用 GitLab 的不同功能,具有不同的使用模式,因此仅基于 GitLab.com 做出的假设是不够的。

为了识别 GitLab.com 的高流量表,会考虑以下指标。此处链接的指标仅为 GitLab 内部使用:

任何与当前高流量表相比有较高读操作的表都可能成为候选对象。

一般来说,我们不鼓励在高流量表中添加仅用于 GitLab.com 分析或报告的列。这会对所有 GitLab 自托管实例产生负面性能影响,而不会为它们提供直接的功能价值。

里程碑

从 GitLab 16.6 开始,所有新迁移都必须指定一个里程碑,使用以下语法:

class AddFooToBar < Gitlab::Database::Migration[2.2]
  milestone '16.6'

  def change
    # 您的迁移代码在这里
  end
end

在迁移中添加正确的里程碑,使我们能够逻辑地将迁移分区到相应的 GitLab 次版本中。这样做可以:

  • 简化升级过程。
  • 缓解仅依赖迁移时间戳排序时出现的潜在迁移顺序问题。

自动清理环绕保护

这是 PostgreSQL 的特殊自动清理运行模式,它需要对正在清理的表持有 ShareUpdateExclusiveLock 锁。对于较大的表,这可能需要数小时,并且该锁可能与大多数尝试同时修改表的 DDL 迁移冲突。由于迁移无法及时获取锁,它们将失败并阻塞部署。

后部署迁移(PDM)管道 可以检查并停止执行,如果检测到其中一个表上存在环绕预防清理进程。为此,我们需要在迁移名称中使用完整的表名。例如 add_foreign_key_between_ci_builds_and_ci_job_artifacts 将在执行迁移前检查 ci_buildsci_job_artifacts 表上的清理情况。

如果迁移没有冲突的锁,可以通过不在迁移名称中使用完整表名来跳过清理检查,例如 create_async_index_on_job_artifacts