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

松散外键

问题陈述

在关系型数据库(包括 PostgreSQL)中,外键提供了一种连接两个数据库表的方法,并确保它们之间的数据一致性。在 GitLab 中,外键 是数据库设计过程中至关重要的部分。我们的大部分数据库表都包含外键。

随着持续的数据库解构工作进行,关联记录可能存在于两个不同的数据库服务器上。使用标准的 PostgreSQL 外键无法确保两个数据库之间的数据一致性。PostgreSQL 不支持跨多个数据库服务器的外键操作。

示例:

  • 数据库 “Main”:projects
  • 数据库 “CI”:ci_pipelines

一个项目可以拥有多个流水线。当项目被删除时,关联的 ci_pipeline 记录(通过 project_id 列)也必须被删除。

在多数据库设置下,这无法通过外键实现。

异步方案

我们对此问题的首选方案是最终一致性(eventual consistency)。通过松散外键功能,我们可以配置延迟的关联清理,而不会对应用程序性能产生负面影响。

最终一致性的实现方式

在之前的示例中,projects 表中的一条记录可以关联多个 ci_pipeline 记录。为了将清理过程与实际的父记录删除分离,我们可以:

  1. projects 表上创建一个 DELETE 触发器。 将删除记录记录在单独的表中(deleted_records)。
  2. 一个作业每隔一到两分钟检查一次 deleted_records 表。
  3. 对于表中的每条记录,使用 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_linkssource_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_deleteasync_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

移除外键

如果存在现有外键,则可以从数据库中移除它。此外键描述了 projectsci_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 文件中移除该定义,并确保数据库中没有遗留的待删除记录。

  1. 从配置文件 (config/gitlab_loose_foreign_keys.yml) 中移除松散外键定义。

删除跟踪触发器仅在父表不再使用松散外键时才需要移除。如果模型仍保留至少一个 loose_foreign_key 定义,则可以跳过这些步骤:

  1. 从父表中移除触发器(如果父表仍然存在)。
  2. 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: :destroydependent: :nullify 的说明

我们曾考虑使用这些 Rails 功能作为外键的替代方案,但存在几个问题,包括:

  1. 这些功能在事务上下文中使用不同的连接而我们不允许这样做
  2. 这些功能可能导致严重的性能下降,因为我们需要从 PostgreSQL 加载所有记录,在 Ruby 中循环遍历它们,并调用单独的 DELETE 查询。
  3. 这些功能可能会遗漏数据,因为它们只覆盖了直接在模型上调用 destroy 方法的情况。还有其他情况,包括 delete_all 和来自另一个父表的级联删除,这些情况可能被遗漏。

对于需要在数据库外部清理数据(例如对象存储)的非平凡对象,如果您希望使用 dependent: :destroy,请参阅跨数据库避免使用 dependent: :nullifydependent: :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 锁确保。

记录清理流程

  1. 获取 Redis 锁。
  2. 确定要清理的数据库。
  3. 收集所有跟踪删除的数据库表(父表)。
    • 这是通过读取 config/gitlab_loose_foreign_keys.yml 文件实现的。
    • 当表存在松散外键定义并且安装了 DELETE 触发器时,该表被视为“已跟踪”。
  4. 循环遍历这些表(无限循环)。
  5. 对于每个表,加载一批要清理的已删除父记录。
  6. 根据 YAML 配置,为引用的子表构建 DELETEUPDATE(置空)查询。
  7. 调用查询。
  8. 重复直到所有子记录被清理或达到最大限制。
  9. 当所有子记录被清理后,移除已删除的父记录。

数据库结构

该功能依赖于安装在父表上的触发器。当父记录被删除时,触发器会自动将新记录插入到 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 表存在于两个数据库服务器(cimain)上。worker 将通过读取 lib/gitlab/database/gitlab_schemas.yml YAML 文件来确定哪些父表属于哪个数据库。

示例:

  • 主数据库表
    • projects
    • namespaces
    • merge_requests
  • CI 数据库表
    • ci_builds
    • ci_pipelines

当 worker 为 ci 数据库调用时,worker 仅从 ci_buildsci_pipelines 表加载已删除记录。在清理过程中,DELETEUPDATE 查询主要运行在位于 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 使用以下步骤添加:

  1. 创建一个新分区,其中分区的 VALUECURRENT_PARTITION + 1
  2. 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 记录由松散外键功能删除。

  1. 清理 worker 被调度并拾取一批已删除的 projects 记录。大型项目是其中的一部分。
  2. 孤立的 ci_builds 行的删除已经开始。
  3. 达到时间限制,但清理尚未完成。
  4. 已删除记录的 cleanup_attempts 列递增。
  5. 转到步骤 1。下一个清理 worker 继续清理。
  6. cleanup_attempts 达到 3 时,通过更新 consume_after 列,该批处理在 10 分钟后重新安排。
  7. 下一个清理 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;