松散外键
问题陈述
在关系型数据库(包括 PostgreSQL)中,外键提供了一种连接两个数据库表的方法,并确保它们之间的数据一致性。在 GitLab 中,外键 是数据库设计过程中至关重要的部分。我们的大部分数据库表都包含外键。
随着持续的数据库解构工作进行,关联记录可能存在于两个不同的数据库服务器上。使用标准的 PostgreSQL 外键无法确保两个数据库之间的数据一致性。PostgreSQL 不支持跨多个数据库服务器的外键操作。
示例:
- 数据库 “Main”:
projects表 - 数据库 “CI”:
ci_pipelines表
一个项目可以拥有多个流水线。当项目被删除时,关联的 ci_pipeline 记录(通过 project_id 列)也必须被删除。
在多数据库设置下,这无法通过外键实现。
异步方案
我们对此问题的首选方案是最终一致性(eventual consistency)。通过松散外键功能,我们可以配置延迟的关联清理,而不会对应用程序性能产生负面影响。
最终一致性的实现方式
在之前的示例中,projects 表中的一条记录可以关联多个 ci_pipeline 记录。为了将清理过程与实际的父记录删除分离,我们可以:
- 在
projects表上创建一个DELETE触发器。 将删除记录记录在单独的表中(deleted_records)。 - 一个作业每隔一到两分钟检查一次
deleted_records表。 - 对于表中的每条记录,使用
project_id删除关联的ci_pipelines记录。
要使此过程正常工作,我们必须注册哪些表需要异步清理。
scripts/decomposition/generate-loose-foreign-key 工具
我们构建了一个自动化工具,作为解构工作的一部分,辅助将外键迁移为松散外键。该工具展示现有的外键,并允许将选定的外键自动转换为松散外键。这确保了外键和松散外键定义之间的一致性,并确保它们得到适当的测试。
我们强烈建议您使用自动化脚本将任何外键转换为松散外键。
该工具确保了转换外键的所有方面都得到覆盖。这包括:
- 创建迁移以移除外键。
- 使用新迁移更新
db/structure.sql。 - 更新
config/gitlab_loose_foreign_keys.yml以添加新的松散外键。 - 创建或更新模型的规范(specs),以确保松散外键得到适当支持。
该工具位于 scripts/decomposition/generate-loose-foreign-key:
$ scripts/decomposition/generate-loose-foreign-key -h
用法: scripts/decomposition/generate-loose-foreign-key [选项] <过滤器...>
-c, --cross-schema 仅显示跨模式外键
-n, --dry-run 不执行任何命令(试运行)
-r, --[no-]rspec 是否自动创建 rspecs
-h, --help 打印此帮助信息对于跨模式外键的迁移,我们使用 -c 修饰符来显示尚未迁移的外键:
$ scripts/decomposition/generate-loose-foreign-key -c
正在重新创建当前测试数据库
已删除数据库 'gitlabhq_test_ee'
已删除数据库 'gitlabhq_geo_test_ee'
已创建数据库 'gitlabhq_test_ee'
已创建数据库 'gitlabhq_geo_test_ee'
显示跨模式外键 (20):
ID | HAS_LFK | FROM | TO | COLUMN | ON_DELETE
0 | N | ci_builds | projects | project_id | cascade
1 | N | ci_job_artifacts | projects | project_id | cascade
2 | N | ci_pipelines | projects | project_id | cascade
3 | Y | ci_pipelines | merge_requests | merge_request_id | cascade
4 | N | external_pull_requests | projects | project_id | cascade
5 | N | ci_sources_pipelines | projects | project_id | cascade
6 | N | ci_stages | projects | project_id | cascade
7 | N | ci_pipeline_schedules | projects | project_id | cascade
8 | N | ci_runner_projects | projects | project_id | cascade
9 | Y | dast_site_profiles_pipelines | ci_pipelines | ci_pipeline_id | cascade
10 | Y | vulnerability_feedback | ci_pipelines | pipeline_id | nullify
11 | N | ci_variables | projects | project_id | cascade
12 | N | ci_refs | projects | project_id | cascade
13 | N | ci_builds_metadata | projects | project_id | cascade
14 | N | ci_subscriptions_projects | projects | downstream_project_id | cascade
15 | N | ci_subscriptions_projects | projects | upstream_project_id | cascade
16 | N | ci_sources_projects | projects | source_project_id | cascade
17 | N | ci_job_token_project_scope_links | projects | source_project_id | cascade
18 | N | ci_job_token_project_scope_links | projects | target_project_id | cascade
19 | N | ci_project_monthly_usages | projects | project_id | cascade
要匹配外键 (FK),请输入一个或多个过滤器以匹配 FROM/TO/COLUMN:
- scripts/decomposition/generate-loose-foreign-key (过滤器...)
- scripts/decomposition/generate-loose-foreign-key ci_job_artifacts project_id
- scripts/decomposition/generate-loose-foreign-key dast_site_profiles_pipelines该命令接受正则表达式列表,用于匹配外键生成中的源表、目标表或列。例如,运行以下命令来交换 ci_job_token_project_scope_links 的所有外键以用于解构数据库:
scripts/decomposition/generate-loose-foreign-key -c ci_job_token_project_scope_links要仅交换 ci_job_token_project_scope_links 的 source_project_id 以用于解构数据库,请运行:
scripts/decomposition/generate-loose-foreign-key -c ci_job_token_project_scope_links source_project_id要精确匹配表或列的名称,您可以使用正则表达式位置锚点 ^ 和 $。例如,此命令仅匹配 events 表上的外键,而不匹配 incident_management_timeline_events 表:
scripts/decomposition/generate-loose-foreign-key -n ^events$要交换所有外键(所有以 _id 结尾的),但不创建新分支(仅提交更改)且不创建 RSpec 测试,请运行:
scripts/decomposition/generate-loose-foreign-key -c --no-branch --no-rspec _id要交换所有引用 projects 的外键,但不创建新分支(仅提交更改),请运行:
scripts/decomposition/generate-loose-foreign-key -c --no-branch projects示例迁移和配置
配置松散外键
松散外键在 YAML 文件中定义。配置需要以下信息:
- 父表名称 (
projects) - 子表名称 (
ci_pipelines) - 数据清理方法 (
async_delete或async_nullify)
YAML 文件位于 config/gitlab_loose_foreign_keys.yml。该文件按子表名称对外键定义进行分组。子表可以有多个松散外键定义,因此我们将它们存储为数组。
示例定义:
ci_pipelines:
- table: projects
column: project_id
on_delete: async_delete如果 YAML 文件中已存在 ci_pipelines 键,则可以向数组中添加新条目:
ci_pipelines:
- table: projects
column: project_id
on_delete: async_delete
- table: another_table
column: another_id
on_delete: :async_nullify跟踪记录变更
在普通非分区表上
要了解 projects 表中的删除情况,请使用部署后迁移配置一个 DELETE 触发器。该触发器只需配置一次。如果模型已经至少有一个 loose_foreign_key 定义,则可以跳过此步骤:
class TrackProjectRecordChanges < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions(:projects)
end
def down
untrack_record_deletions(:projects)
end
end在分区表上
要在分区表上跟踪删除,我们需要使用辅助方法 track_record_deletions_override_table_name。这是因为我们需要确保当 DELETE 语句针对分区表或其分区运行时,我们总是注册父(分区)表名而不是分区(子)表名。
示例如下:
class TrackWorkloadDeletions < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
track_record_deletions_override_table_name(:p_ci_workloads)
end
def down
untrack_record_deletions(:p_ci_workloads)
end
end移除外键
如果存在现有外键,则可以从数据库中移除它。此外键描述了 projects 和 ci_pipelines 表之间的链接:
ALTER TABLE ONLY ci_pipelines
ADD CONSTRAINT fk_86635dbd80
FOREIGN KEY (project_id)
REFERENCES projects(id)
ON DELETE CASCADE;该迁移必须在 DELETE 触发器安装完毕且松散外键定义部署后运行。因此,它必须是一个在触发器迁移之后日期的部署后迁移。如果外键被提前删除,很可能导致数据不一致,需要手动清理:
class RemoveProjectsCiPipelineFk < Gitlab::Database::Migration[2.3]
disable_ddl_transaction!
def up
with_lock_retries do
remove_foreign_key_if_exists(:ci_pipelines, :projects, name: "fk_86635dbd80")
end
end
def down
add_concurrent_foreign_key(:ci_pipelines, :projects, name: "fk_86635dbd80", column: :project_id, target_column: :id, on_delete: "cascade")
end
end至此,设置阶段结束。被删除的 projects 记录应由计划好的清理工作器作业自动拾取。
移除松散外键
当松散外键定义不再需要(父表被移除,或 FK 已恢复)时,我们需要从 YAML 文件中移除该定义,并确保数据库中没有遗留的待删除记录。
- 从配置文件 (
config/gitlab_loose_foreign_keys.yml) 中移除松散外键定义。
删除跟踪触发器仅在父表不再使用松散外键时才需要移除。如果模型仍保留至少一个 loose_foreign_key 定义,则可以跳过这些步骤:
- 从父表中移除触发器(如果父表仍然存在)。
- 从
loose_foreign_keys_deleted_records表中移除遗留的已删除记录。
用于移除触发器的迁移:
class UnTrackProjectRecordChanges < Gitlab::Database::Migration[2.3]
include Gitlab::Database::MigrationHelpers::LooseForeignKeyHelpers
def up
untrack_record_deletions(:projects)
end
def down
track_record_deletions(:projects)
end
end随着触发器的移除,我们阻止了记录进一步插入到 loose_foreign_keys_deleted_records 表中,但表中仍有可能存在遗留的待处理记录。这些记录必须通过内联数据迁移来移除。
class RemoveLeftoverProjectDeletions < Gitlab::Database::Migration[2.3]
disable_ddl_transaction!
def up
loop do
result = execute <<~SQL
DELETE FROM "loose_foreign_keys_deleted_records"
WHERE
("loose_foreign_keys_deleted_records"."partition", "loose_foreign_keys_deleted_records"."id") IN (
SELECT "loose_foreign_keys_deleted_records"."partition", "loose_foreign_keys_deleted_records"."id"
FROM "loose_foreign_keys_deleted_records"
WHERE
"loose_foreign_keys_deleted_records"."fully_qualified_table_name" = 'public.projects' AND
"loose_foreign_keys_deleted_records"."status" = 1
LIMIT 100
)
SQL
break if result.cmd_tuples == 0
end
end
def down
# 无操作
end
end测试
共享示例 “it has loose foreign keys” 可用于测试 ON DELETE 触发器和松散外键定义的存在。
添加到模型测试文件中:
it_behaves_like 'it has loose foreign keys' do
let(:factory_name) { :project }
end在移除外键之后,使用共享示例 “cleanup by a loose foreign key” 来测试通过添加的松散外键删除或置空子记录:
it_behaves_like 'cleanup by a loose foreign key' do
let!(:model) { create(:ci_pipeline, user: create(:user)) }
let!(:parent) { model.user }
end松散外键的注意事项
记录创建
该功能提供了一种在父记录删除后高效清理关联记录的方法。没有外键时,应用程序有责任在创建新的关联记录时验证父记录是否存在。
不好的示例:使用给定 ID 创建记录(project_id 来自用户输入)。在此示例中,没有什么能阻止我们传递一个随机的项目 ID:
Ci::Pipeline.create!(project_id: params[:project_id])好的示例:创建记录并进行额外检查:
project = Project.find(params[:project_id])
Ci::Pipeline.create!(project_id: project.id)关联查找
考虑以下 HTTP 请求:
GET /projects/5/pipelines/100控制器操作忽略了 project_id 参数,并使用 ID 查找流水线:
def show
# 不好,避免这样做
pipeline = Ci::Pipeline.find(params[:id]) # 100
end当父 Project 模型被删除时,此端点仍然有效。这可以被视为数据泄露,在正常情况下不应发生:
def show
# 好
project = Project.find(params[:project_id])
pipeline = project.pipelines.find(params[:pipeline_id]) # 100
end此示例在 GitLab 中不太可能发生,因为我们通常查找父模型来执行权限检查。
关于 dependent: :destroy 和 dependent: :nullify 的说明
我们曾考虑使用这些 Rails 功能作为外键的替代方案,但存在几个问题,包括:
- 这些功能在事务上下文中使用不同的连接而我们不允许这样做。
- 这些功能可能导致严重的性能下降,因为我们需要从 PostgreSQL 加载所有记录,在 Ruby 中循环遍历它们,并调用单独的
DELETE查询。 - 这些功能可能会遗漏数据,因为它们只覆盖了直接在模型上调用
destroy方法的情况。还有其他情况,包括delete_all和来自另一个父表的级联删除,这些情况可能被遗漏。
对于需要在数据库外部清理数据(例如对象存储)的非平凡对象,如果您希望使用 dependent: :destroy,请参阅跨数据库避免使用 dependent: :nullify 和 dependent: :destroy中的替代方案。
将目标列更新为特定值
松散外键可用于在父表中的条目被删除时,将目标列更新为特定值。
重要的是要添加一个索引(如果尚不存在)在 (column, target_column) 上,以避免任何性能问题。
任何以这两个列开头的索引都可以工作。
配置需要额外信息:
- 要更新的列 (
target_column) - 要在目标列中设置的值 (
target_value)
示例定义:
packages:
- table: projects
column: project_id
on_delete: update_column_to
target_column: status
target_value: 4松散外键的风险及可能的缓解措施
总体而言,松散外键架构是最终一致的,清理延迟可能导致对 GitLab 用户或运营商可见的问题。我们认为这种权衡是可以接受的,但在某些情况下,问题可能过于频繁或严重,我们必须实施缓解策略。通用的缓解策略可能是为具有较高影响且延迟清理的记录设置一个“紧急”队列。
以下是一些可能发生的更具体的问题示例以及我们如何缓解它们。在所有列出的情况下,我们仍然认为所描述的问题风险较低且影响较小,因此我们选择不实施任何缓解措施。
记录应被删除但视图中仍显示
此假设性示例可能发生在具有以下外键的情况下:
ALTER TABLE ONLY vulnerability_occurrence_pipelines
ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;在此示例中,我们期望每当删除关联的 ci_pipelines 记录时,删除所有关联的 vulnerability_occurrence_pipelines 记录。在这种情况下,您可能会在 GitLab 中遇到某个漏洞页面显示漏洞的出现。但是,当您尝试选择指向流水线的链接时,会得到 404,因为流水线已被删除。然后,当您导航回来时,您可能会发现该出现也已消失。
缓解措施
在漏洞页面上渲染漏洞出现时,我们可以尝试加载相应的流水线,并选择如果找不到流水线则跳过显示该出现。
被删除的父记录在渲染视图时导致 500 错误
此假设性示例可能发生在具有以下外键的情况下:
ALTER TABLE ONLY vulnerability_occurrence_pipelines
ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;在此示例中,我们期望每当删除关联的 ci_pipelines 记录时,删除所有关联的 vulnerability_occurrence_pipelines 记录。在这种情况下,您可能会在 GitLab 中遇到某个漏洞页面显示漏洞的“出现”。但是,在渲染出现时,我们尝试加载,例如 occurrence.pipeline.created_at,这会导致用户收到 500 错误。
缓解措施
在漏洞页面上渲染漏洞出现时,我们可以尝试加载相应的流水线,并选择如果找不到流水线则跳过显示该出现。
被删除的父记录在 Sidekiq worker 中访问并导致作业失败
此假设性示例可能发生在具有以下外键的情况下:
ALTER TABLE ONLY vulnerability_occurrence_pipelines
ADD CONSTRAINT fk_rails_6421e35d7d FOREIGN KEY (pipeline_id) REFERENCES ci_pipelines(id) ON DELETE CASCADE;在此示例中,我们期望每当删除关联的 ci_pipelines 记录时,删除所有关联的 vulnerability_occurrence_pipelines 记录。在这种情况下,您可能会遇到一个负责处理漏洞并遍历所有出现的 Sidekiq worker,如果它执行 occurrence.pipeline.created_at,则会导致 Sidekiq 作业失败。
缓解措施
在 Sidekiq worker 中遍历漏洞出现时,我们可以尝试加载相应的流水线,并选择如果找不到流水线则跳过处理该出现。
架构
松散外键功能在 LooseForeignKeys Ruby 命名空间内实现。代码与核心应用程序代码隔离,理论上它可以是一个独立的库。
该功能仅在 LooseForeignKeys::CleanupWorker worker 类中被调用。该 worker 通过 cron 作业调度,其调度取决于 GitLab 实例的配置。
- 未解构的 GitLab(1 个数据库):每分钟调用一次。
- 已解构的 GitLab(2 个数据库,CI 和 Main):每分钟调用一次,一次清理一个数据库。例如,主数据库的清理 worker 每两分钟运行一次。
为了避免锁争用和处理相同的数据库行,worker 不并行运行。此行为通过 Redis 锁确保。
记录清理流程:
- 获取 Redis 锁。
- 确定要清理的数据库。
- 收集所有跟踪删除的数据库表(父表)。
- 这是通过读取
config/gitlab_loose_foreign_keys.yml文件实现的。 - 当表存在松散外键定义并且安装了
DELETE触发器时,该表被视为“已跟踪”。
- 这是通过读取
- 循环遍历这些表(无限循环)。
- 对于每个表,加载一批要清理的已删除父记录。
- 根据 YAML 配置,为引用的子表构建
DELETE或UPDATE(置空)查询。 - 调用查询。
- 重复直到所有子记录被清理或达到最大限制。
- 当所有子记录被清理后,移除已删除的父记录。
数据库结构
该功能依赖于安装在父表上的触发器。当父记录被删除时,触发器会自动将新记录插入到 loose_foreign_keys_deleted_records 数据库表中。
插入的记录存储有关已删除记录的以下信息:
fully_qualified_table_name:记录所在的数据库名称。primary_key_value:记录的 ID,该值存在于子表中作为外键值。目前不支持复合主键,父表必须有一个id列。status:默认为待处理 (pending),表示清理过程的状态。consume_after:默认为当前时间。cleanup_attempts:默认为 0。worker 尝试清理此记录的次数。非零数字表示此记录有许多子记录,清理它需要多次运行。
数据库解构
在数据库解构之后,loose_foreign_keys_deleted_records 表存在于两个数据库服务器(ci 和 main)上。worker 将通过读取 lib/gitlab/database/gitlab_schemas.yml YAML 文件来确定哪些父表属于哪个数据库。
示例:
- 主数据库表
projectsnamespacesmerge_requests
- CI 数据库表
ci_buildsci_pipelines
当 worker 为 ci 数据库调用时,worker 仅从 ci_builds 和 ci_pipelines 表加载已删除记录。在清理过程中,DELETE 和 UPDATE 查询主要运行在位于 Main 数据库中的表上。在此示例中,一个 UPDATE 查询将 merge_requests.head_pipeline_id 列置空。
数据库分区
由于该表每天接收大量插入,因此实现了一种特殊的分区策略来解决数据膨胀问题。最初,时间衰减策略曾考虑用于此功能,但由于数据量巨大,我们决定实施一种新策略。
当所有直接子记录被清理后,已删除记录被视为已完全处理。发生这种情况时,松散外键 worker 会更新已删除记录的 status 列。在此步骤之后,该记录不再需要。
滑动分区策略通过在满足特定条件时添加新分区并移除旧分区,提供了一种高效清理旧、未使用数据的方法。loose_foreign_keys_deleted_records 数据库表是列表分区(list partitioned),大多数时间只有一个分区附加到该表上。
分区表 "public.loose_foreign_keys_deleted_records"
Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
----------------------------+--------------------------+-----------+----------+----------------------------------------------------------------+----------+--------------+-------------
id | bigint | | not null | nextval('loose_foreign_keys_deleted_records_id_seq'::regclass) | plain | |
partition | bigint | | not null | 84 | plain | |
primary_key_value | bigint | | not null | | plain | |
status | smallint | | not null | 1 | plain | |
created_at | timestamp with time zone | | not null | now() | plain | |
fully_qualified_table_name | text | | not null | | extended | |
consume_after | timestamp with time zone | | | now() | plain | |
cleanup_attempts | smallint | | | 0 | plain | |
分区键: LIST (partition)
索引:
"loose_foreign_keys_deleted_records_pkey" PRIMARY KEY, btree (partition, id)
"index_loose_foreign_keys_deleted_records_for_partitioned_query" btree (partition, fully_qualified_table_name, consume_after, id) WHERE status = 1
检查约束:
"check_1a541f3235" CHECK (char_length(fully_qualified_table_name) <= 150)
分区: gitlab_partitions_dynamic.loose_foreign_keys_deleted_records_84 FOR VALUES IN ('84')partition 列控制插入方向,partition 值确定哪个分区通过触发器接收删除的行。请注意,partition 表的默认值与列表分区的值(84)匹配。在触发器内的 INSERT 查询中,partition 的值被省略,触发器总是依赖该列的默认值。
触发器的示例 INSERT 查询:
INSERT INTO loose_foreign_keys_deleted_records
(fully_qualified_table_name, primary_key_value)
SELECT TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, old_table.id FROM old_table;分区“滑动”过程由两个定期执行的回调控制。这些回调在 LooseForeignKeys::DeletedRecord 模型中定义。
next_partition_if 回调控制何时创建新分区。当当前分区中至少有一条记录超过 24 小时时,会创建一个新分区。新分区由 PartitionManager 使用以下步骤添加:
- 创建一个新分区,其中分区的
VALUE为CURRENT_PARTITION + 1。 - 将
partition列的默认值更新为CURRENT_PARTITION + 1。
通过这些步骤,所有通过触发器的新 INSERT 查询最终都会进入新分区。此时,数据库表有两个分区。
detach_partition_if 回调确定是否可以将旧分区从表中分离。如果分区中没有待处理(未处理)的记录(status = 1),则该分区可分离。分离的分区在一段时间内可用,您可以在 detached_partitions 表中查看分离的分区列表:
select * from detached_partitions;清理查询
LooseForeignKeys::CleanupWorker 拥有自己的数据库查询构建器,该构建器依赖于 Arel。该功能不引用任何应用程序特定的 ActiveRecord 模型,以避免意外副作用。数据库查询是分批处理的,这意味着多个父记录同时被清理。
示例 DELETE 查询:
DELETE
FROM "merge_request_metrics"
WHERE ("merge_request_metrics"."id") IN
(SELECT "merge_request_metrics"."id"
FROM "merge_request_metrics"
WHERE "merge_request_metrics"."pipeline_id" IN (1, 2, 10, 20)
LIMIT 1000 FOR UPDATE SKIP LOCKED)父记录的主键值为 1、2、10 和 20。
示例 UPDATE(置空)查询:
UPDATE "merge_requests"
SET "head_pipeline_id" = NULL
WHERE ("merge_requests"."id") IN
(SELECT "merge_requests"."id"
FROM "merge_requests"
WHERE "merge_requests"."head_pipeline_id" IN (3, 4, 30, 40)
LIMIT 500 FOR UPDATE SKIP LOCKED)这些查询是分批处理的,这意味着在许多情况下,需要多次调用才能清理所有关联的子记录。
分批处理是通过循环实现的,当所有关联的子记录被清理或达到限制时,处理停止。
loop do
modification_count = process_batch_with_skip_locked
break if modification_count == 0 || over_limit?
end
loop do
modification_count = process_batch
break if modification_count == 0 || over_limit?
end基于循环的分批处理优先于 EachBatch,原因如下:
- 批处理中的记录被修改,因此下一个批处理包含不同的记录。
- 外键列上总是有索引,但该列通常不是唯一的。
EachBatch需要一个唯一的列进行迭代。 - 记录顺序对清理无关紧要。
请注意,我们有两个循环。初始循环处理带有 SKIP LOCKED 子句的记录。该查询跳过被其他应用程序进程锁定的行。这确保了清理 worker 不太可能被阻塞。第二个循环执行不带 SKIP LOCKED 的数据库查询,以确保所有记录都已处理。
处理限制
持续的、大量的记录更新或删除可能导致事件并影响 GitLab 的可用性:
- 增加表膨胀。
- 增加待处理 WAL 文件的数量。
- 忙碌的表,难以获取锁。
为了缓解这些问题,在 worker 运行时会应用多个限制。
- 每个查询都有
LIMIT,查询不能处理无限数量的行。 - 记录删除和记录更新的最大数量受到限制。
- 数据库查询的最大运行时间(30 秒)受到限制。
限制规则在 LooseForeignKeys::ModificationTracker 类中实现。当达到其中一个限制(记录修改计数、时间限制)时,处理会立即停止。一段时间后,下一个计划好的 worker 会继续清理过程。
性能特征
父表上的数据库触发器降低了记录删除速度。从父表中删除行的每个语句都会调用触发器,将记录插入到 loose_foreign_keys_deleted_records 表中。
清理 worker 内的查询是相当高效的索引扫描,在限制到位的情况下,它们不太可能影响应用程序的其他部分。
数据库查询不在事务中运行,当发生错误时,例如语句超时或 worker 崩溃,下一个作业会继续处理。
故障排除
已删除记录的累积
在某些情况下,worker 需要处理异常大量的数据。这在典型使用场景下可能发生,例如删除大型项目或组时。在这种情况下,可能需要删除或置空数百万行。由于 worker 强制的限制,处理这些数据需要一些时间。
在清理“重负载”记录时,该功能通过为较大的批处理重新安排稍后的时间来确保公平处理。这为其他已删除记录的清理留出了时间。
例如,删除一个包含数百万 ci_builds 记录的项目。ci_builds 记录由松散外键功能删除。
- 清理 worker 被调度并拾取一批已删除的
projects记录。大型项目是其中的一部分。 - 孤立的
ci_builds行的删除已经开始。 - 达到时间限制,但清理尚未完成。
- 已删除记录的
cleanup_attempts列递增。 - 转到步骤 1。下一个清理 worker 继续清理。
- 当
cleanup_attempts达到 3 时,通过更新consume_after列,该批处理在 10 分钟后重新安排。 - 下一个清理 worker 处理不同的批处理。
我们已部署 Prometheus 指标来监控已删除记录的清理:
loose_foreign_key_processed_deleted_records:已处理的已删除记录数。当发生大型清理时,此数字会下降。loose_foreign_key_incremented_deleted_records:未完成处理的已删除记录数。cleanup_attempts列已递增。loose_foreign_key_rescheduled_deleted_records:在 3 次清理尝试后不得不在稍后时间重新安排的已删除记录数。
示例 PromQL 查询:
loose_foreign_key_rescheduled_deleted_records{env="gprd", table="ci_runners"}查看情况的另一种方法是运行数据库查询。此查询提供了未处理记录的确切计数:
SELECT partition, fully_qualified_table_name, count(*)
FROM loose_foreign_keys_deleted_records
WHERE
status = 1
GROUP BY 1, 2;示例输出:
partition | fully_qualified_table_name | count
-----------+----------------------------+-------
87 | public.ci_builds | 874
87 | public.ci_job_artifacts | 6658
87 | public.ci_pipelines | 102
87 | public.ci_runners | 111
87 | public.merge_requests | 255
87 | public.namespaces | 25
87 | public.projects | 6查询包含分区号,这对于检测清理过程是否显著滞后很有用。当列表中存在多个不同的分区值时,意味着某些已删除记录的清理在几天内没有完成(每天添加一个新分区)。
诊断问题的步骤:
- 检查哪些记录正在累积。
- 尝试估算剩余记录的数量。
- 查看 worker 性能统计信息(Kibana 或 Grafana)。
可能的解决方案:
- 短期:增加批处理大小。
- 长期:更频繁地调用 worker。并行化 worker
对于一次性修复,我们可以从 rails console 多次运行清理 worker。worker 可以并行运行,但这可能会引入锁争用并增加 worker 运行时间。
LooseForeignKeys::CleanupWorker.new.perform当清理完成后,较旧的分区会由 PartitionManager 自动分离。
PartitionManager 错误
此问题过去在 Staging 环境中发生过,并且已得到缓解。
添加新分区时,partition 列的默认值也会更新。这是一个模式更改,与新分区创建在同一事务中执行。partition 列过时的可能性极低。
但是,如果发生这种情况,可能会导致应用程序范围的故障,因为 partition 值指向一个不存在的分区。症状:从安装了 DELETE 触发器的表中删除记录失败。
\d+ loose_foreign_keys_deleted_records;
Column | Type | Collation | Nullable | Default | Storage | Stats target | Description
----------------------------+--------------------------+-----------+----------+----------------------------------------------------------------+----------+--------------+-------------
id | bigint | | not null | nextval('loose_foreign_keys_deleted_records_id_seq'::regclass) | plain | |
partition | bigint | | not null | 4 | plain | |
primary_key_value | bigint | | not null | | plain | |
status | smallint | | not null | 1 | plain | |
created_at | timestamp with time zone | | not null | now() | plain | |
fully_qualified_table_name | text | | not null | | extended | |
consume_after | timestamp with time zone | | | now() | plain | |
cleanup_attempts | smallint | | | 0 | plain | |
分区键: LIST (partition)
索引:
"loose_foreign_keys_deleted_records_pkey" PRIMARY KEY, btree (partition, id)
"index_loose_foreign_keys_deleted_records_for_partitioned_query" btree (partition, fully_qualified_table_name, consume_after, id) WHERE status = 1
检查约束:
"check_1a541f3235" CHECK (char_length(fully_qualified_table_name) <= 150)
分区: gitlab_partitions_dynamic.loose_foreign_keys_deleted_records_3 FOR VALUES IN ('3')检查 partition 列的默认值并将其与可用分区(4 vs 3)进行比较。值为 4 的分区不存在。要缓解此问题,需要进行紧急的模式更改:
ALTER TABLE loose_foreign_keys_deleted_records ALTER COLUMN partition SET DEFAULT 3;