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

数据库案例研究:命名空间存储统计

引言

群组存储和限制管理中, 我们希望提供一种简单查看群组存储使用量的方法,并允许轻松管理。

提案

  1. 创建一个新的 ActiveRecord 模型,以聚合形式保存命名空间的统计信息(仅适用于根命名空间)。
  2. 每当属于此命名空间的项目发生变化时,刷新此模型中的统计信息。

问题

在 GitLab 中,我们通过callback在每次保存项目时更新项目存储统计信息。

然后通过Namespaces#with_statistics作用域检索每个命名空间的统计摘要。分析此查询后,我们发现:

  • 对于拥有超过 15k 个项目的命名空间,查询耗时长达 1.2 秒。
  • 无法使用ChatOps进行分析,因为会超时。

此外,当前用于更新项目统计信息(callback)的模式无法充分扩展。它目前是生产环境中耗时最长的数据库查询事务之一。我们不能向其中添加更多查询,因为这会增加事务的长度。

由于以上所有原因,我们不能使用相同的模式来存储和更新命名空间统计信息,因为 namespaces 表是 GitLab.com 上最大的表之一。因此,我们需要找到一种高效且替代的方法。

尝试

尝试 A:PostgreSQL 物化视图

模型可以通过基于项目路由 SQL 和物化视图的刷新策略进行更新:

SELECT split_part("rs".path, '/', 1) as root_path,
        COALESCE(SUM(ps.storage_size), 0) AS storage_size,
        COALESCE(SUM(ps.repository_size), 0) AS repository_size,
        COALESCE(SUM(ps.wiki_size), 0) AS wiki_size,
        COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size,
        COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size,
        COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size,
        COALESCE(SUM(ps.packages_size), 0) AS packages_size,
        COALESCE(SUM(ps.snippets_size), 0) AS snippets_size,
        COALESCE(SUM(ps.uploads_size), 0) AS uploads_size
FROM "projects"
    INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'
    INNER JOIN project_statistics ps ON ps.project_id  = projects.id
GROUP BY root_path

然后我们可以通过以下查询执行:

REFRESH MATERIALIZED VIEW root_namespace_storage_statistics;

虽然这意味着单个查询更新(可能很快),但它有一些缺点:

  • 物化视图的语法在 PostgreSQL 和 MySQL 之间有所不同。虽然此功能正在开发中,但 GitLab 仍然支持 MySQL。
  • Rails 没有对物化视图的原生支持。我们需要使用专门的 gem 来管理数据库视图,这需要额外的工作。

尝试 B:通过 CTE 更新

与尝试 A 类似:通过使用公共表表达式的刷新策略进行模型更新:

WITH refresh AS (
  SELECT split_part("rs".path, '/', 1) as root_path,
        COALESCE(SUM(ps.storage_size), 0) AS storage_size,
        COALESCE(SUM(ps.repository_size), 0) AS repository_size,
        COALESCE(SUM(ps.wiki_size), 0) AS wiki_size,
        COALESCE(SUM(ps.lfs_objects_size), 0) AS lfs_objects_size,
        COALESCE(SUM(ps.build_artifacts_size), 0) AS build_artifacts_size,
        COALESCE(SUM(ps.pipeline_artifacts_size), 0) AS pipeline_artifacts_size,
        COALESCE(SUM(ps.packages_size), 0) AS packages_size,
        COALESCE(SUM(ps.snippets_size), 0) AS snippets_size,
        COALESCE(SUM(ps.uploads_size), 0) AS uploads_size
  FROM "projects"
        INNER JOIN routes rs ON rs.source_id = projects.id AND rs.source_type = 'Project'
        INNER JOIN project_statistics ps ON ps.project_id  = projects.id
  GROUP BY root_path)
UPDATE namespace_storage_statistics
SET storage_size = refresh.storage_size,
    repository_size = refresh.repository_size,
    wiki_size = refresh.wiki_size,
    lfs_objects_size = refresh.lfs_objects_size,
    build_artifacts_size = refresh.build_artifacts_size,
    pipeline_artifacts_size = refresh.pipeline_artifacts_size,
    packages_size  = refresh.packages_size,
    snippets_size  = refresh.snippets_size,
    uploads_size  = refresh.uploads_size
FROM refresh
    INNER JOIN routes rs ON rs.path = refresh.root_path AND rs.source_type = 'Namespace'
WHERE namespace_storage_statistics.namespace_id = rs.source_id

与尝试 A 具有相同的优点和缺点。

尝试 C:移除模型并将统计信息存储在 Redis 中

我们可以移除存储聚合统计信息的模型,而是使用 Redis Set。 这将是无聊的解决方案,也是最快的实现方式, 因为 GitLab 已经将 Redis 作为其架构的一部分。

这种方法的缺点是 Redis 不提供与 PostgreSQL 相同的持久性/一致性保证, 而且这是我们在 Redis 故障时不能丢失的信息。

尝试 D:标记根命名空间及其子命名空间

直接将根命名空间与其子命名空间关联起来,因此 每当创建没有父命名空间的命名空间时,该命名空间会被标记 为根命名空间 ID:

ID 根 ID 父 ID
1 1 NULL
2 1 1
3 1 2

要聚合命名空间内的统计信息,我们可以执行类似以下的查询:

SELECT COUNT(...)
FROM projects
WHERE namespace_id IN (
  SELECT id
  FROM namespaces
  WHERE root_id = X
)

尽管这种方法会使聚合变得更容易,但它有一些重大缺点:

  • 我们需要通过添加和填充新列来迁移所有命名空间。由于表的大小,处理时间和成本会非常显著。后台迁移将需要大约 153h,参见 https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/29772
  • 后台迁移必须在发布前完成,这将使功能延迟另一个里程碑。

尝试 E(最终):异步更新命名空间存储统计信息

这种方法继续使用我们已经有的增量统计更新,但通过 Sidekiq 作业在不同的事务中刷新它们:

  1. 创建第二个表(namespace_aggregation_schedules),包含两列 idnamespace_id
  2. 每当项目统计信息发生变化时,向 namespace_aggregation_schedules 插入一行
  3. 插入行后,我们安排另一个工作程序在两个不同的时刻异步执行:
    • 一个排队等待立即执行,另一个安排在 1.5h 小时后执行。
    • 只有当我们能够基于根命名空间 ID 在 Redis 上获得 1.5h 的租约时,才安排这些作业。
    • 如果我们无法获得租约,则表示有另一个聚合正在进行中,或安排在不超过 1.5h 的时间内。
  4. 这个工作程序将:
    • 通过服务查询所有命名空间来更新根命名空间存储统计信息。
    • 更新后删除相关的 namespace_aggregation_schedules
  5. 还包括另一个 Sidekiq 作业,用于遍历 namespace_aggregation_schedules 表中的任何剩余行,并为每个待处理行安排作业。
    • 此作业通过 cron 安排,每天晚上(UTC)运行。

此实现具有以下优点:

  • 所有更新都是异步完成的,因此我们不会增加 project_statistics 事务的长度。
  • 我们在单个 SQL 查询中完成更新。
  • 它与 PostgreSQL 和 MySQL 兼容。
  • 不需要后台迁移。

这种方法的唯一缺点是命名空间的统计信息在变更完成后最多延迟 1.5 小时更新, 这意味着存在一个统计信息不准确的时间窗口。因为我们仍然没有强制执行存储限制,所以这不是一个主要问题。

结论

异步更新存储统计信息是聚合根命名空间问题较少且性能较高的方法。

关于此用例的所有详细信息可以在以下链接找到:

命名空间存储统计信息的性能已在暂存和生产环境(GitLab.com)中进行了测量。所有结果都已发布在 https://gitlab.com/gitlab-org/gitlab-foss/-/issues/64092:迄今为止没有报告任何问题。