原文链接
这篇文章写于 2022 年,前一年 GitLab 刚好完成 IPO。目前 GitLab 市值超过 100 亿美金,它的所有收入都来源于同名产品 GitLab,而这篇文章就是全面分析 GitLab 这个产品的数据库 schema。
我花了一些时间研究 GitLab 的 Postgres schema。GitLab 是 Github 的一个替代品。你可以自部署 GitLab,因为它是一个开源的 DevOps 平台。
我之所以要了解 Gitlab 这样的大项目的 schema,是为了与我正在设计的 schema 进行比较,并从他们的 schema 定义中学到一些最佳实践。我确实从中受益良多。
我清楚的知道,最佳实践取决于具体情况,不能盲目应用。
GitLab 的数据库 schema 文件 structure.sql [1] 包含超过 34000 行代码。GitLab 本质上是一个集成式的 Ruby on Rails 应用。虽然通常我们会用 schema.rb 文件来管理数据库的版本迁移,但 GitLab 团队在他们的问题追踪系统中的一个讨论 [2] 说明了他们选择 structure.sql 的原因。
原因在于 schema.rb 只能包含标准的迁移操作(使用 Rails DSL),这样做是为了使数据库 schema 文件对数据库系统保持中立,抽象化特定的 SQL 操作。这导致我们无法利用 PostgreSQL 的一些高级特性,如触发器、分区、物化视图等。
为了充分利用这些高级特性,我们应该考虑使用纯 SQL 格式的schema 文件 structure.sql,而不是 Ruby/Rails 的标准架构文件 schema.rb。
这意味着我们需要修改配置 config.active_record.schema_format = :sql,并重新以 SQL 格式生成数据库 schema。这可能还需要调整一些构建流程。 现在,让我们回顾一下我从 GitLab Postgres schema 中学到的东西。
为表使用正确的主键类型
在我的工作中,我犯了主键类型标准化的错误。这意味着将 bigint 或 uuid 标准化,这样所有表无论其结构、访问模式和增长速度如何,都将具有相同的类型。
当数据库规模较小时,这不会产生任何明显的影响,但当数据库规模扩大时,主键就会对存储空间、写入速度和读取速度产生明显的影响。因此,我们在为表选择正确的主键类型时应进行适当的思考。
正如我在之前的一篇文章 [3] 中所讨论的,当你使用 Postgres 本地 UUID v4 类型而不是 bigserial 类型时,表的大小会增加 25%,插入率则会下降到 bigserial 类型的 25%。这是一个很大的差别。我还与 ULID 进行了比较,但它的性能也很差。其中一个原因可能是 ULID 的实现。
在这种情况下,我很想了解 GitLab 是如何选择主键类型的。
在 573 个表中,380 个表使用 bigserial 主键类型,170 个表使用 serial4 主键类型,其余 23 个表使用复合主键。他们没有使用 uuid v4 主键或其他类似 ULID 的深奥键类型的表。
1 quintillion = 10 亿 billion
选择 serial 还是 bigserial 取决于表中记录的数量。
application_settings、badges、chat_teams、notification_settings、project_settings 等表使用串行类型。对于一些表,如 issues、web_hooks、merge_requests 和 projects,我很惊讶地发现它们使用了 serial 类型。
这个 serial 类型可能适用于自部署的社区或企业版本,但对于 GitLab.com SaaS 服务,这可能会造成问题。例如,GitHub 在 2020 年拥有 1.28 亿个公共仓库 [4]。即使每个仓库有 20 个问题,也会超过序列范围。此外,更改表格类型的成本也很高。表需要被重写,意味着你需要耐心等待。如果要对表进行分片,这同样会成为一个问题。
我做了一个快速实验,结果表明,对于我这个有两列和 1000 万条记录的表,将数据类型从 integer 改为 bigint 需要 11 秒。
create table exp_bs(id serial primary key, n bigint not null)
插入 1000 万条记录
insert into exp_bs(n) select g.n from generate_series(1,10000000) as g(n)
变更数据类型
alter table exp_bs alter column id TYPE bigint;
ALTER TABLE
Time: 10845.062 ms (00:10.845)
您还必须更改序列以改变其类型。这种操作很快。
alter sequence exp_bs_id_seq as bigint;
ALTER SEQUENCE
Time: 4.505 ms
所有 bigserial 序列都从 1 开始,直到 bigint 的最大值。
CREATE SEQUENCE audit_events_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
使用内部和外部 ID
通常,不向外部世界公开主键是一个好习惯。这在你使用 integer 或 bigint 型的顺序自增标识符时尤为重要,因为它们是可预测的。
因此,我很想知道在创建 GitLab 问题时会发生什么。是向外部用户公开主键 id,还是使用其他 id?如果公开 issues 表的主键 id,那么在项目中创建 issue 时,它就不会以 1 开头,你可以很容易猜出 GitLab 中存在多少个问题。这既不安全,用户体验也很差。
为了避免将主键暴露给终端用户,常见的解决方案是使用两个 ID。第一个是你的主键 id,它保持在系统内部,从不暴露于任何公共环境。第二个 id 是我们与外部世界共享的。根据我过去的经验,我使用 UUID v4 作为外部 id。正如我们在前一点中讨论的,使用 UUID 有存储成本。
GitLab 也在需要与外部世界共享 id 的表中使用内部和外部 id。像 issues、ci_pipelines、deployments、epics 以及其他一些表有两个 id - id 和 iid。下面是 issue schema 的一部分。如下所示,iid 的数据类型为 integer。
CREATE TABLE issues (
id integer NOT NULL,
title character varying,
project_id integer,
iid integer,
// ……)
正如你所看到的,有 id 和 iid 两列。iid 列的值与最终用户共享。一个 issue 使用 project_id 和 iid 进行唯一标识。这是因为可能有多个 issue 具有相同的 iid。为了更清楚地说明这一点,如果您创建了两个项目,并在每个版本库中创建了一个 issue,那么这两个项目的可见 ID 都必须是 1,如下图所示。sg 和 sg2 项目都以 issue ID 1 开始。
https://gitlab.com/shekhargulati123/sg/-/issues/1
https://gitlab.com/shekhargulati123/sg2/-/issues/1
它们在 project_id 和 iid 上都有一个唯一索引,以便快速有效地获取 issue。
CREATE UNIQUE INDEX index_issues_on_project_id_and_iid ON public.issues USING btree (project_id, iid);
使用带有检查约束的 text 字符类型
Postgres 文档 [5] 中描述了三种字符类型。
我主要使用 character varying(n) 或 varchar(n) 来存储字符串值。GitLab schema 既使用character varying(n),也使用 text,但更常用的是 text 类型