外键和关联
外键确保相关数据库表之间的一致性。从 Rails 4 版本开始,Rails 包含了迁移辅助方法,用于向数据库表添加外键约束。在 Rails 4 之前,确保某种程度一致性的唯一方法是在关联定义中使用 dependent 选项。
在应用层确保数据一致性在某些不幸的情况下可能会失败,因此我们最终可能会得到表中的不一致数据。这主要影响较旧的表,在这些表中我们没有框架支持来确保数据库级别的一致性。这些数据不一致可能导致意外的应用行为或错误。
当创建引用其他表记录的表时,应添加外键以维护数据完整性。当向模型添加关联时,也必须添加外键。此外,在添加外键时,必须始终先添加一个 index。
例如,假设您有以下模型:
class User < ActiveRecord::Base
has_many :posts
end在这里为 posts.user_id 列添加外键。这确保在数据库级别强制执行数据一致性。外键还意味着数据库可以删除关联数据(例如,删除用户),而不是 Rails 必须执行此操作。
避免停机和迁移失败
添加外键包含两个部分
- 添加 FK 列和约束。
- 验证添加的约束以维护数据完整性。
(1) 使用 ALTER TABLE 语句,它采用最严格的锁(ACCESS EXCLUSIVE),而验证约束必须遍历整个表,对于大型/高流量表来说这将非常耗时。
因此在几乎所有情况下,我们必须在单独的事务中运行它们,以避免持有更严格的锁并长时间阻塞表上的其他操作。
在新列上
如果在创建表时添加外键,这很简单,可以使用 create_table (t.references, ..., foreign_key: true)。
如果您有一个新的(记录不多)或空的表,该表不引用 高流量表,可以使用以下任一方法。
- add_reference(… foreign_key: true)
- 在同一事务中使用 add_column(…) 和 add_foreign_key(…)。
对于所有其他情况,添加列、添加 FK 约束和验证约束应在单独的事务中完成。
在现有列上
向现有数据库列添加外键需要数据库结构更改和潜在的数据更改。
如果表正在使用中,我们应始终假设存在不一致的数据。
向现有列添加 FK 约束是一个多里程碑过程:
N.M: 向列添加NOT VALIDFK 约束,它还将确保不会创建或更新不一致的记录。N.M: 添加数据迁移,以修复或清理现有记录。 2. 如果迁移查询在 时间指南 范围内,这可以是常规或部署后迁移。 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
endINFO:
默认情况下 add_concurrent_foreign_key 方法会验证外键,因此需要显式传递 validate: false。
添加外键而不验证它是一个快速操作。在能够对新数据强制执行约束之前,它只需要对表进行短时间锁定。
此外 add_concurrent_foreign_key 只在约束不存在时才会添加约束。
避免在同一个迁移文件中多次使用 add_foreign_key 或 add_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 验证可以在较低风险级别下进行。
在低影响时间安排外键验证
安排 FK 验证
- 创建一个包含部署后迁移的合并请求,该迁移准备外键以进行异步验证。
- 创建后续问题以添加同步验证外键的迁移。
- 在准备异步外键的合并请求中,添加提及后续问题的评论。
使用异步助手验证外键的示例如下所示。此迁移将外键名称输入 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 在生产环境中有效
- 使用 ChatOps 和
/chatops run auto_deploy status <merge_sha>验证部署后迁移是否已在 GitLab.com 上执行。如果输出返回db/gprd,则部署后迁移已在生产数据库中执行。有关更多信息,请参阅 如何确定部署后迁移是否已在 GitLab.com 上执行。 - 等待下一周,以便 FK 可以在周末进行验证。
- 使用 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 更改
在创建合并请求之前,您必须本地测试数据库外键更改。
验证异步验证的外键
在您的本地环境中使用异步助手来测试验证外键的更改:
- 通过在 Rails 控制台中运行
Feature.enable(:database_async_foreign_key_validation)来启用功能标志。 - 运行
bundle exec rails db:migrate以在异步验证表中创建条目。 - 运行
bundle exec rails gitlab:db:validate_async_constraints:all以在所有数据库上异步验证 FK。 - 要验证外键,使用 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_key 和 remove_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 中导致两个事务的死锁。以下是发生过程:
Note.create: 在notes上获取行锁ALTER TABLE ...在todos上获取表锁ALTER TABLE ... FOREIGN KEY尝试在notes上获取表锁,但这会阻塞在具有行锁的其他事务上Todo.create尝试在todos上获取行锁,但这会阻塞在具有todos表锁的其他事务上
这说明了两个事务如何都可能卡住等待对方完成,并且它们都会超时。我们的迁移中通常有事务重试,所以通常没问题,但应用代码也可能超时,用户可能会收到错误。如果此应用代码运行非常频繁,我们可能会不断使迁移超时,并且用户也可能定期收到错误。
删除外键的死锁情况类似,因为它也在两个表上获取锁,但更常见的场景,使用上面的示例,将是 DELETE FROM notes WHERE id = ...。此查询将在 notes 上获取锁,然后在 todos 上获取锁,并且可能发生完全相同的死锁。因此,对于删除外键,几乎总是最好使用 reverse_lock_order。
在迁移中更新外键
有时必须更改外键约束,保留列但更新约束条件。例如,从 ON DELETE CASCADE 更改为 ON DELETE SET NULL 或反之亦然。
PostgreSQL 不会阻止您添加重叠的外键。它遵循最近添加的约束。这允许我们替换外键,而不会 ever 失去列上的外键保护。
替换外键:
-
添加新外键:
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 -
删除旧外键:
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 在每次从引用表中删除记录时对表进行全表扫描。过去这导致了删除 projects 和 namespaces 时超时的事故。
也可以有一个复合索引覆盖此外键,只要外键位于复合索引的第一个位置即可。例如,如果您有一个外键 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:
- 该列引用另一个表,例如两个表属于不允许它们之间有外键的 GitLab schemas。
- 出于性能原因,外键被 Loose Foreign Key 替换。
- 该列表示 多态关系。请注意不应使用多态关联。
- 该列不打算引用另一个表。例如,分区表通常有
partition_id。
依赖删除
在定义关联时,不要定义诸如 dependent: :destroy 或 dependent: :delete 之类的选项。定义这些选项意味着 Rails 处理数据删除,而不是让数据库以最高效的方式处理。
换句话说,这是不好的,应不惜一切代价避免:
class User < ActiveRecord::Base
has_many :posts, dependent: :destroy
end如果您确实有此需求,应首先获得数据库专家的批准。
您也不应在模型上定义任何 before_destroy 或 after_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 列。