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

外键和关联

外键确保相关数据库表之间的一致性。从 Rails 4 版本开始,Rails 包含了迁移辅助方法,用于向数据库表添加外键约束。在 Rails 4 之前,确保某种程度一致性的唯一方法是在关联定义中使用 dependent 选项。

在应用层确保数据一致性在某些不幸的情况下可能会失败,因此我们最终可能会得到表中的不一致数据。这主要影响较旧的表,在这些表中我们没有框架支持来确保数据库级别的一致性。这些数据不一致可能导致意外的应用行为或错误。

当创建引用其他表记录的表时,应添加外键以维护数据完整性。当向模型添加关联时,也必须添加外键。此外,在添加外键时,必须始终先添加一个 index

例如,假设您有以下模型:

class User < ActiveRecord::Base
  has_many :posts
end

在这里为 posts.user_id 列添加外键。这确保在数据库级别强制执行数据一致性。外键还意味着数据库可以删除关联数据(例如,删除用户),而不是 Rails 必须执行此操作。

避免停机和迁移失败

添加外键包含两个部分

  1. 添加 FK 列和约束。
  2. 验证添加的约束以维护数据完整性。

(1) 使用 ALTER TABLE 语句,它采用最严格的锁(ACCESS EXCLUSIVE),而验证约束必须遍历整个表,对于大型/高流量表来说这将非常耗时。

因此在几乎所有情况下,我们必须在单独的事务中运行它们,以避免持有更严格的锁并长时间阻塞表上的其他操作。

在新列上

如果在创建表时添加外键,这很简单,可以使用 create_table (t.references, ..., foreign_key: true)

如果您有一个新的(记录不多)或空的表,该表不引用 高流量表,可以使用以下任一方法。

  1. add_reference(… foreign_key: true)
  2. 在同一事务中使用 add_column(…) 和 add_foreign_key(…)。

对于所有其他情况,添加列、添加 FK 约束和验证约束应在单独的事务中完成。

在现有列上

向现有数据库列添加外键需要数据库结构更改和潜在的数据更改。

如果表正在使用中,我们应始终假设存在不一致的数据。

向现有列添加 FK 约束是一个多里程碑过程:

  1. N.M: 向列添加 NOT VALID FK 约束,它还将确保不会创建或更新不一致的记录。
  2. N.M: 添加数据迁移,以修复或清理现有记录。 2. 如果迁移查询在 时间指南 范围内,这可以是常规或部署后迁移。 3. 如果不是,这必须在 批量后台迁移 中完成。
  3. 验证 FK 约束 2. 如果数据迁移是常规或部署后迁移,可以在同一里程碑中验证约束。 3. 如果是后台迁移,则只能在 BBM 完成后验证 FK。 这是必需的,以确保 FK 验证不会在数据迁移仍在后台运行时发生。

向现有或新列添加外键约束需要在列上添加索引。

如果索引是 异步 添加的,我们应该等待索引在 structure.sql 中添加完成。

这对于所有外键都是 必需 的,例如,以支持高效的级联删除:当表中的许多行被删除时,引用的记录也需要被删除。数据库必须在引用表中查找相应的记录。没有索引,这会导致对表进行顺序扫描,这可能需要很长时间。

示例

考虑以下表结构:

users 表:

  • id (integer, primary key)
  • name (string)

emails 表:

  • id (integer, primary key)
  • user_id (integer)
  • email (string)

ActiveRecord 中表达关系:

class User < ActiveRecord::Base
  has_many :emails
end

class Email < ActiveRecord::Base
  belongs_to :user
end

问题:当用户被删除时,与被删除用户相关的电子邮件记录仍保留在 emails 表中:

user = User.find(1)
user.destroy

emails = Email.where(user_id: 1) # 返回已删除用户的电子邮件

添加 FK 约束 (NOT VALID)

向表添加 NOT VALID 外键约束,它在添加或更新记录时强制执行一致性。

在上面的示例中,您仍然能够更新 emails 表中的记录。但是,当您尝试使用不存在的值更新 user_id 时,约束将抛出错误。

添加 NOT VALID 外键的迁移文件:

class AddNotValidForeignKeyToEmailsUser < Gitlab::Database::Migration[2.1]
  milestone '17.10'

  disable_ddl_transaction!

  def up
    add_concurrent_foreign_key(
      :emails,
      :users,
      column: :user_id,
      on_delete: :cascade,
      validate: false
    )
  end

  def down
    remove_foreign_key_if_exists :emails, column: :user_id
  end
end

INFO: 默认情况下 add_concurrent_foreign_key 方法会验证外键,因此需要显式传递 validate: false

添加外键而不验证它是一个快速操作。在能够对新数据强制执行约束之前,它只需要对表进行短时间锁定。

此外 add_concurrent_foreign_key 只在约束不存在时才会添加约束。

避免在同一个迁移文件中多次使用 add_foreign_keyadd_concurrent_foreign_key 约束,除非源表和目标表相同。

数据迁移以修复现有记录

这里的方法取决于数据量和清理策略。如果我们可以通过数据库查询找到"无效"记录并且记录数不高,那么数据迁移可以在常规或部署后 rails 迁移中执行。

如果数据量更大(>1000 条记录),最好创建一个后台迁移。如果不确定,请参考我们的 查询指南 或联系数据库框架团队寻求建议。

在数据库迁移中清理 emails 表记录的示例:

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

  class Email < ActiveRecord::Base
    include EachBatch
  end

  def up
    Email.each_batch do |batch|
      batch.joins('LEFT JOIN users ON emails.user_id = users.id')
           .where('users.id IS NULL')
           .delete_all
    end
  end

  def down
    # 当数据不一致不影响部署前后的应用版本时,可以是无操作。
    # 在这种情况下,我们可能在 `emails` 表中有记录,而 `users` 表中的关联记录已不存在。
  end
end

添加此数据迁移的 MR 应该应用 ~data-deletion 标签。 有关更多信息,请参阅 preparation-when-adding-data-migrations

验证外键

验证外键会扫描整个表并确保每个关系都是正确的。幸运的是,在运行时不会锁定源表 (users)。

如前所述,当使用 批量后台迁移 时,外键验证应仅在 BBM 完成后进行。

验证外键的迁移文件:

# frozen_string_literal: true

class ValidateForeignKeyOnEmailUsers < Gitlab::Database::Migration[2.1]
  def up
    validate_foreign_key :emails, :user_id
  end

  def down
    # 如果我们不回滚不一致的数据,可以安全地是无操作。
  end
end

异步验证外键

对于非常大的表,外键验证在运行数小时时可能难以管理。必要的数据库操作(如 autovacuum)无法运行,在 GitLab.com 上,部署过程被阻塞,等待迁移完成。

为了限制对 GitLab.com 的影响,存在一个在周末时间异步验证它们的过程。由于通常流量较低且部署较少,FK 验证可以在较低风险级别下进行。

在低影响时间安排外键验证
  1. 安排 FK 验证
  2. 验证 MR 已部署且 FK 在生产环境中有效
  3. 添加迁移以同步验证 FK
安排 FK 验证
  1. 创建一个包含部署后迁移的合并请求,该迁移准备外键以进行异步验证。
  2. 创建后续问题以添加同步验证外键的迁移。
  3. 在准备异步外键的合并请求中,添加提及后续问题的评论。

使用异步助手验证外键的示例如下所示。此迁移将外键名称输入 postgres_async_foreign_key_validations 表。在周末运行的进程从此表中提取外键并尝试验证它们。

# in db/post_migrate/

FK_NAME = :fk_be5624bf37

# TODO: FK 将在问题或合并请求中同步验证
def up
  # `some_column` 可以是列数组,如果提供了 `name` 则不是必需的。
  # `name` 优先于其他参数。
  prepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # 对于分区表,使用:
  prepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end

def down
  unprepare_async_foreign_key_validation :ci_builds, :some_column, name: FK_NAME

  # 对于分区表,使用:
  unprepare_partitioned_async_foreign_key_validation :p_ci_builds, :some_column, name: FK_NAME
end
验证 MR 已部署且 FK 在生产环境中有效
  1. 使用 ChatOps 和 /chatops run auto_deploy status <merge_sha> 验证部署后迁移是否已在 GitLab.com 上执行。如果输出返回 db/gprd,则部署后迁移已在生产数据库中执行。有关更多信息,请参阅 如何确定部署后迁移是否已在 GitLab.com 上执行
  2. 等待下一周,以便 FK 可以在周末进行验证。
  3. 使用 Database Lab 检查验证是否成功。确保输出不指示外键为 NOT VALID
添加迁移以同步验证 FK

当外键在生产数据库中有效后,创建第二个合并请求以同步验证外键。架构更改必须在此第二个合并请求中更新并提交到 structure.sql。同步迁移在 GitLab.com 上导致无操作,但您仍应像预期的那样为其他安装添加迁移。下面的块演示了如何为之前的异步示例创建第二个迁移。

在合并第二个使用 validate_foreign_key 的迁移之前,验证外键在生产环境中是否有效。如果第二个迁移在验证执行之前部署,则当第二个迁移执行时,外键将同步验证。

# in db/post_migrate/

  FK_NAME = :fk_be5624bf37

  def up
    validate_foreign_key :ci_builds, :some_column, name: FK_NAME
  end

  def down
    # 如果我们不回滚不一致的数据,可以安全地是无操作。
  end
end

在本地测试数据库 FK 更改

在创建合并请求之前,您必须本地测试数据库外键更改。

验证异步验证的外键

在您的本地环境中使用异步助手来测试验证外键的更改:

  1. 通过在 Rails 控制台中运行 Feature.enable(:database_async_foreign_key_validation) 来启用功能标志。
  2. 运行 bundle exec rails db:migrate 以在异步验证表中创建条目。
  3. 运行 bundle exec rails gitlab:db:validate_async_constraints:all 以在所有数据库上异步验证 FK。
  4. 要验证外键,使用 GDK 命令 gdk psql 打开 PostgreSQL 控制台,并运行命令 \d+ table_name 检查您的外键是否有效。成功的验证会从外键定义中移除 NOT VALID

删除外键

此操作不需要停机。

使用 bigint 作为外键

添加新外键时,应将其定义为 bigint。即使引用表具有 integer 主键类型,您也必须将新外键引用为 bigint。由于我们正在将所有主键迁移到 bigint,使用 bigint 外键在将父表迁移到 bigint 主键时可以节省时间并减少步骤。

考虑 reverse_lock_order

考虑对 高流量表 使用 reverse_lock_order add_concurrent_foreign_keyremove_foreign_key_if_exists 都采用一个布尔选项 reverse_lock_order,默认为 false。

您可以在 原始问题 中阅读有关此的更多上下文信息。

当我们有已知查询也在同一表上以高频率获取锁(通常是行锁)时,这很有用。

例如,考虑您想添加如下外键的场景:

ALTER TABLE ONLY todos
    ADD CONSTRAINT fk_91d1f47b13 FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE;

并考虑以下假设的应用代码:

Todo.transaction do
   note = Note.create(...)
   # 观察如果在此处添加外键会发生什么!
   todo = Todo.create!(note_id: note.id)
end

如果您尝试在两个插入语句之间创建外键,我们最终可能会在 Postgres 中导致两个事务的死锁。以下是发生过程:

  1. Note.create: 在 notes 上获取行锁
  2. ALTER TABLE ...todos 上获取表锁
  3. ALTER TABLE ... FOREIGN KEY 尝试在 notes 上获取表锁,但这会阻塞在具有行锁的其他事务上
  4. Todo.create 尝试在 todos 上获取行锁,但这会阻塞在具有 todos 表锁的其他事务上

这说明了两个事务如何都可能卡住等待对方完成,并且它们都会超时。我们的迁移中通常有事务重试,所以通常没问题,但应用代码也可能超时,用户可能会收到错误。如果此应用代码运行非常频繁,我们可能会不断使迁移超时,并且用户也可能定期收到错误。

删除外键的死锁情况类似,因为它也在两个表上获取锁,但更常见的场景,使用上面的示例,将是 DELETE FROM notes WHERE id = ...。此查询将在 notes 上获取锁,然后在 todos 上获取锁,并且可能发生完全相同的死锁。因此,对于删除外键,几乎总是最好使用 reverse_lock_order

在迁移中更新外键

有时必须更改外键约束,保留列但更新约束条件。例如,从 ON DELETE CASCADE 更改为 ON DELETE SET NULL 或反之亦然。

PostgreSQL 不会阻止您添加重叠的外键。它遵循最近添加的约束。这允许我们替换外键,而不会 ever 失去列上的外键保护。

替换外键:

  1. 添加新外键:

    class ReplaceFkOnPackagesPackagesProjectId < Gitlab::Database::Migration[2.1]
      disable_ddl_transaction!
    
      NEW_CONSTRAINT_NAME = 'fk_new'
    
      def up
        add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
      end
    
      def down
        with_lock_retries do
          remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :nullify, name: NEW_CONSTRAINT_NAME)
        end
      end
    end
  2. 删除旧外键:

    class RemoveFkOld < Gitlab::Database::Migration[2.1]
      disable_ddl_transaction!
    
      OLD_CONSTRAINT_NAME = 'fk_old'
    
      def up
        with_lock_retries do
          remove_foreign_key_if_exists(:packages_packages, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
        end
      end
    
      def down
        add_concurrent_foreign_key(:packages_packages, :projects, column: :project_id, on_delete: :cascade, name: OLD_CONSTRAINT_NAME)
      end
    end

级联删除

每个外键都必须定义一个 ON DELETE 子句,在 99% 的情况下,这应设置为 CASCADE

索引

在 PostgreSQL 中添加外键时,列不会自动建立索引,因此您还必须添加并发索引。所有外键都需要索引,并且必须在添加外键之前添加索引。这意味着它们可以是同一迁移中的较早步骤,或者是在添加外键的迁移之前的迁移中添加。出于相同的原因,外键必须在删除支持这些外键的索引之前被删除。

没有外键索引会强制 Postgres 在每次从引用表中删除记录时对表进行全表扫描。过去这导致了删除 projectsnamespaces 时超时的事故。

也可以有一个复合索引覆盖此外键,只要外键位于复合索引的第一个位置即可。例如,如果您有一个外键 project_id,那么拥有像 BTREE (project_id, user_id) 这样的复合索引是可以的,但拥有像 BTREE (user_id, project_id) 这样的索引是不行的。后者不允许仅通过 project_id 进行高效查找,因此不会防止级联删除超时。像 BTREE (project_id) WHERE user_id IS NULL 这样的部分索引永远不能用于级联删除,并且不能作为外键的索引。

命名外键

默认情况下 Ruby on Rails 对外键使用 _id 后缀。所以我们只应在两个表之间的关联中使用这个后缀。如果您想引用第三方平台上的 ID,建议使用 _xid 后缀。

规范 spec/db/schema_spec.rb 测试所有带有 _id 后缀的列是否有外键约束。如果该规范失败,如果列符合以下两个标准之一,请将该列添加到 ignored_fk_columns_map

  1. 该列引用另一个表,例如两个表属于不允许它们之间有外键的 GitLab schemas
  2. 出于性能原因,外键被 Loose Foreign Key 替换。
  3. 该列表示 多态关系。请注意不应使用多态关联。
  4. 该列不打算引用另一个表。例如,分区表通常有 partition_id

依赖删除

在定义关联时,不要定义诸如 dependent: :destroydependent: :delete 之类的选项。定义这些选项意味着 Rails 处理数据删除,而不是让数据库以最高效的方式处理。

换句话说,这是不好的,应不惜一切代价避免:

class User < ActiveRecord::Base
  has_many :posts, dependent: :destroy
end

如果您确实有此需求,应首先获得数据库专家的批准。

您也不应在模型上定义任何 before_destroyafter_destroy 回调,除非绝对需要,并且只有在获得数据库专家批准时。例如,如果表中的每一行在文件系统上都有对应的文件,添加 after_destroy 钩子可能很诱人。然而,这向模型引入了非数据库逻辑,并且我们不再能够依赖外键来删除数据,因为这会导致文件系统数据被遗留。在这种情况下,您应该使用服务类来处理删除非数据库数据。

当关系跨越多个数据库时,使用 dependent: :destroy 或上述钩子会遇到更多问题。您可以在 Avoid dependent: :nullify and dependent: :destroy across databases 中阅读更多替代方案。

使用 has_one 关联的替代主键

有时使用 has_one 关联来创建一对一关系:

class User < ActiveRecord::Base
  has_one :user_config
end

class UserConfig < ActiveRecord::Base
  belongs_to :user
end

在这些情况下,有机会删除关联表(在此示例中为 user_config.id)上不必要的 id 列。相反,可以使用源表 ID 作为关联表的主键:

create_table :user_configs, id: false do |t|
  t.references :users, primary_key: true, default: nil, index: false, foreign_key: { on_delete: :cascade }
  ...
end

设置 default: nil 确保不会创建主键序列,并且因为主键会自动获得索引,我们设置 index: false 以避免创建重复索引。您还必须将新主键添加到模型中:

class UserConfig < ActiveRecord::Base
  self.primary_key = :user_id

  belongs_to :user
end

使用外键作为主键可以节省空间,但可能使 Service Ping 中的 批量计数 效率降低。如果表与 Service Ping 相关,请考虑使用常规的 id 列。