1 ポイント 投稿者 GN⁺ 2024-02-18 | 1件のコメント | WhatsAppで共有

GitLabのPostgresスキーマ設計に関する私のメモ

  • GitLabのPostgresスキーマを調べることで、独自に設計するスキーマと比較し、GitLabのスキーマ定義からベストプラクティスを学び取ることを目的とした。
  • GitLabはオープンソースのDevOpsプラットフォームで、GitHubの代替として、自社でホスト可能。

適切な主キー型の選択

  • データベースが小さいうちは目立たないが、成長するにつれて主キーはストレージ容量、書き込み速度、読み取り速度に影響を与える。
  • GitLabでは、573のテーブルのうち380がbigserialの主キー型、170がserial4を使用し、残り23が複合主キーを使用している。

内部IDと外部IDの利用

  • 主キーを外部世界に公開しないことはよいプラクティスである。
  • GitLabは、issuesci_pipelinesdeploymentsepicsなどのテーブルで、内部ID(id)と外部ID(iid)の両方を使用している。

text型とCHECK制約の使用

  • GitLabのスキーマはcharacter varying(n)textの両方を使用しているが、text型の方が頻繁に使われている。
  • text型は長さの制約を持たず、CHECKを使って長さ制約を定義する。

命名規則

  • すべてのテーブルに複数形を使用し、モジュール名のプレフィックスでネームスペースを提供する。
  • テーブルと列名はsnake_case規則に従う。

タイムスタンプのタイムゾーン設定

  • GitLabはtimestamp with timezonetimestamp without timezoneの両方を使用している。
  • システム操作にはtimestamp without timezoneを、ユーザー操作にはtimestamp with timezoneを使用している。

外部キー制約

  • GitLabはほとんどのテーブルで外部キー制約を使用しているが、audit_eventsabuse_reportsweb_hooks_logsspam_logsなどの一部のテーブルでは使用していない。

大きなテーブルのパーティショニング

  • GitLabはクエリのパフォーマンス向上のため、サイズが拡大しうるテーブルをパーティション分割する。

Trigramとgin_trgm_opsを使用したLIKE検索ユースケースのサポート

  • GitLabはGIN(Generalized Inverted Index)インデックスを使用して効率的な検索を実行している。

jsonbの使用

  • GitLabのスキーマでは複数のテーブルでjsonbデータ型を使用している。

その他のヒント

  • 更新可能なテーブルにはupdated_atのような監査フィールドを使用し、更新不可能なログテーブルでは使用しない。
  • Enumsはcharacter varyingの代わりにsmallintで保存され、スペースを節約している。

GN⁺の意見:

  • GitLabのスキーマ設計はデータベース設計に対する洞察を提供し、特に大規模システム向けのスキーマ最適化に関する重要な教訓を含んでいる。
  • GitLabがオープンソースであるため、このようなスキーマ設計の意思決定は、他の開発者が自分のプロジェクトに適用できる実践的な例を提供する。
  • GitLabのスキーマから学べることは、データ型の選択、インデックス戦略、パーティショニング、外部キー制約の使用など、データベースの性能と保守性に大きな影響を与える要素を慎重に考慮することが必要だという点だ。

1件のコメント

 
GN⁺ 2024-02-18
Hacker News のコメント
  • 主キーを外部に公開するなという慣行がなぜ必要なのか疑問。リクエストはいずれにせよ認証されるべきなら、ID の推測を防ぐことにどんな価値があるのかも分からない
    推測した ID だけで認証・認可なしに何か有用なことができるなら、すでに別の場所が深刻に壊れているということであり、スキーマに不要な複雑さを加えるより、そこに注力すべき。競合他社が顧客数を推定できないようにする競争情報の秘匿程度なら価値があるかもしれないが、GitLab がそれを大きく気にしていたとは思えない。GitLab の id + iid という判断は、内部 ID の推測防止よりもクエリ性能上の要件のほうが大きかった可能性が高そう

    • その通りだが、ID を推測できることはセキュリティ脆弱性をひどいものにしたり、はるかに悪化させたりし得る
      UUID をユーザーに公開していれば、同じ脆弱性があっても攻撃者は UUID を当てなければならないためずっと難しくなり、二次的な情報源が必要になる場合もある。データ漏えいは起きても対応する時間ができ、漏えい量も算定可能になる。逆に連番 ID なら、問題は即座に全面的な漏えいへと拡大し、個人情報監督機関へ義務報告が必要な大規模インシデントになり得る。こういうものは本来役に立たないはずの多層防御だが、実際にこのやり方で破られたひどいソフトウェアは存在する
    • 記事にもあるように、セキュリティというより競争情報に近い。単純な自動増分 ID は、テーブルの総レコード数や増加率を露呈する
      Issue テーブルの主キー id を公開すると、プロジェクトで Issue を作るときに 1 から始まらないため、GitLab 全体にどれだけ Issue があるかを簡単に推測できる
    • セキュリティ劇場という表現はあまりに乱用されている。セキュリティは複数の層で構成できるし、そうあるべきで、認証のような一つの層が破られたからといって、残りまで簡単にアクセス可能になってはいけない
      もちろん、推測した ID だけで認証・認可なしに何かできるなら大問題であることは確か。しかしその時点で他の層がなければ、すでにゲームオーバーだ。バグは事前に知らせてくれないし、特に微妙なバグはなおさらだ。そうしたバグが現れたとき、せめて ID を推測不能にしておけば、システムの全ユーザーアカウントにまで簡単にアクセスされる事態を避けられたと感謝することになるだろう
    • アクセス制御にもバグは生じる。推測不能な ID は、そうしたバグの一部を悪用するのをはるかに難しくする
      当然、まずは正しいアクセス制御を保証することに集中すべきだが、推測不能な ID はひどい災害とヒヤリハットの差を生み得る。UUID が合わないなら、自動増分のデータベース ID を使い、それを暗号化する方法も可能で、適切なソフトウェア層があれば暗号化 ID もほぼ自動で機能する
    • 些細な用語の違いだが、これは通常、社内データ活用を指す「ビジネスインテリジェンス」よりも、競争情報と呼ぶほうが適切に見える。https://en.wikipedia.org/wiki/Competitive_intelligence
  • 公開リポジトリ 1億2,800万個の大半は他のリポジトリのフォークで、主リポジトリにプルリクエストを作るためだけに存在しているので、ミスをしない限り Issue はなさそう
    小さなトイプロジェクトやすぐ放棄されたプロジェクトも、Issue がないかごく少ない可能性が高い。数百・数千件の Issue があるプロジェクトは確かにあるだろうが、1億2,800万リポジトリ全体の平均はかなり低く、20億の上限内に収まる可能性が高い。それでも、そのテーブルに 4 バイト型、正確には 31 ビットを使うのは、github.com を含む一部の組織にとって時限爆弾だという点には同意する

    • 現在もリポジトリ 362,107,148個、ユニークな Issue・プルリクエスト 818,516,506件で、まだ上限未満
      https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc...
    • GitHub が Rails から距離を置く判断には、ActiveRecord の大きな欠陥、つまり複合主キーサポートの不足も一部影響したと思う
      PRIMARY KEY(repo_id, issue_id) のような基本的な要件が ActiveRecord の中では不必要に複雑になり、単一の主キーカラムを要求する ActiveRecord に合わせるため、一意キーと別の主キーを併用する回避策が必要になる。UUID 主キーも回避策のように見えるが、(repo_id, issue_id) ペアの一意制約は依然として必要で、データベースサイズとオーバーヘッドが増える。より広く見ると、Ruby on Rails の単一モデル・コントローラ・ビュー層に基づくモノリシックな MVC 構造も、アプリケーションが大きくなるほどスケーラビリティと保守性の問題を生み、MVC はモジュール型またはコンポーネントベースの構造のほうが合っていると思う
    • GitLab Cloud がマルチテナントデータベースを使っているのか、ユーザー・顧客・組織別のデータベースを使っているのか、確実に知っているのか気になる
      セルフホスティングとクラウドの両方を提供する製品は、顧客別データベースを好むことが多かった。ホスティング形態に関係なく同じクエリを使えるように、コードベースの共有部分がずっと単純になるからだ。顧客別データベースなら、そのような利用量上限に近づくことはほとんどなく、近づくならセルフホスティングのほうが適しているだろう
    • その時限爆弾は11秒のマイグレーションで解除できる爆弾だ
    • 主キーを int から bigint へマイグレーションすることは可能。準備とカスタムコードが少し必要だが、無停止でできる
      おおむねこの手順に従って大規模マイグレーションを管理しており、少し修正して使っている: http://zemanta.github.io/2021/08/25/column-migration-from-in...
      外部キー、インデックス、制約条件全般がプロセスを難しくするが、不可能ではない。自分の場合、データマイグレーションには数時間かかったが、速い必要はなかった。GitLab には、バージョンアップ中のどこでも動作するように、アップグレード後ジョブを実行するツールがあると認識している
  • UUID カラムの保存サイズについての話は説得力が弱い。テーブルにほかのカラムが 5 つあるなら、128ビット対64ビットの差は大きくない
    より重要な懸念は性能である。UUIDv4 は広くサポートされているが、完全にランダムなのでインデックス性能には理想的ではない。UUIDv7[0] は Snowflake[1] に近く、時間的局所性があるが、実装はまだそれほど広く普及していない。別のアプローチとして、bigserial を使ってキーを暗号化する方法もある: https://github.com/abevoelker/gfc64
    しかしこの方式は、1) 秘密値をローテーションできず、2) 一度漏えいすると誰でもテーブルサイズをフェルミ推定できる。公開 ID と内部 ID を分けるのは面倒で、公開 ID が UUIDv4 なら性能も犠牲にする。UUIDv7 が最も多くの条件を満たす解だと考える
    [0]: https://uuid7.com/
    [1]: https://en.wikipedia.org/wiki/Snowflake_ID

    • そのカラム 1 つのサイズだけが問題なのではなく、その id外部キーとして使われるすべての場所と、それらの外部キーカラムに必要なインデックスサイズも問題になる
      ユーザー ID のように、データベース全体で数十、数百の外部キーから参照されうる値を考えればよい
    • 問題は、ほかの 5 つのカラムがインデックス化されていないことにある
      データベース性能には 3 つの段階がある。1) インデックスとデータの両方がメモリに収まる。2) インデックスはメモリに収まるがデータは収まらない。3) インデックスとデータの両方がメモリに収まらない。1 なら望ましいが、そうでないなら何としても 2 を守らなければならない。インデックスサイズが 2 倍になることは、それをより難しくする
    • データベースの主キーは、typedef void* のような基本ポインタだと考えればよい。サイズはメモリ・ディスク使用量、スループットのボトルネック、結合や検索の最も深い内部ループでキーを比較する CPU 時間まで、全体的な性能に影響する
      x86-64 CPU が登場したとき、64 ビットポインタ移行の性能影響があまりに大きかったため x32/ilp32 が作られ、.NET が今でも “prefer 32-bit” をデフォルトにしている理由もそこにある。データベースの主キーに128ビット UUIDを使うのはひどい間違いである
    • UUIDv7 も万能ではない。多くの場合、リソースの作成時刻を漏らしたくない
      たとえば動画を公開の 1 か月前にアップロードしても、視聴者にはその事実を知られたくないことがある
    • このアプローチには別の変種もある: https://pgxn.org/dist/permuteseq/
      URL やメールなどに入れて表示するときに値を暗号化することも可能である: https://wiki.postgresql.org/wiki/Pseudo_encrypt
      こうすれば、連番インデックスの利点をかなり維持しつつ、キーの変更も可能になる。ただしキーを変えるとブックマークが壊れ、過去のメールで送ったリンクが無効になり、実質的にはすべての名前を変更するのと同じ効果が残る
  • 細かい揚げ足取りだが、textvarchar のセクションには触れておきたい
    筆者は存在しない性能差を証明しようとして多くを語った後で、「2 つの型の間に大きな性能差はない」と結論づけている。これはずっと前に決着のついた話題で、「大きくない」のではなく「ない」のである。PostgreSQL Wiki[1] は、非常によい理由がない限り text を使うよう明記しており、ドキュメント[2] も「多くの目的では character varyingtext 上のドメインのように動作する」と述べ、緑色の Tip ボックスで「この 3 つの型の間に性能差はない」と言っている。したがって GitLab がほとんど text を使っているのは、ドキュメントを読み、中途半端に「移植可能」なスキーマではなく PostgreSQL に合わせてスキーマを設計したということに見える
    [1] https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use...
    [2] https://www.postgresql.org/docs/current/datatype-character.h...

    • 実際、保存する文字列長の変更に合わせてスキーマをマイグレーションしなければならない場合には、かなりの性能差が生じる
      varchar(300)varchar(200) に変えるにはすべての行を書き直す必要があるが、text カラムの制約条件を更新するのは基本的にほぼ無料で、既存の値が新しい制約を満たすか確認する全テーブルスキャンだけで済む。本文でも、text 型と CHECK 制約を使えば、長さチェックがある場合に character varyingvarchar(n) よりもスキーマ進化が容易だとしている
  • 外部キーは高コストだという話はよく繰り返されるが、ベンチマークは少ない主張である
    誤った実装方法はいくらでもあるが、いずれにせよスタックのどこかでは整合性を強制している。それを再実装するよりデータベースを活用するには知識と実験が必要で、たいてい大きな事故を防いでくれる

  • GitLabとGitHubの性能差を誰かが整理したり、注目して見たことがあるのか気になる
    どちらもRailsベースのアプリケーションだが、全体的なページ読み込み時間はGitLabがGitHubに比べてひどいと感じる

    • 数年前にGitLabを使っていたとき、大きなプルリクエストでクライアント側の性能問題がかなり深刻だった。GitHubも理想的ではないが、それでも許容できるレベルで処理している
    • GitHubと比較するのは、Chromeと他のブラウザ、さらにはChromiumベースのブラウザを比較するのに似ている
      ChromeとGitHubは、ユーザー体験を損ねてでもあらゆる小技を使うだろう。たとえば会社のGitHubでマージdiffを開き、Ctrl Fで検索して結果がなければ次へ進み、Git履歴を手作業でたどっていたところ、100番目のdiffになってようやく、いちばん重要なファイルが奥深くに隠れていたことに気づいたことがある。誰かがページ読み込み指標を満たして昇進しやすくするためだったのだろう
    • GitHubは昨年の2回の障害を除けばおおむね安定しており、普通はかなり速い。そうでなければキーボードショートカットを使っていなかったはずだ
      GitLabの文化と性能軽視を理解する助けになるかもしれない元開発者の記事がここにある: https://news.ycombinator.com/item?id=39303323
      私はGitLabを性能問題を実感するほど十分には使っていないが、この記事は参考になると思う
  • CI変数CI_PIPELINE_IIDCI_MERGE_REQUEST_IIDに付いている追加のIが何を意味するのか、いつも気になっていた
    データベース関連の選択だろうと推測していたが、この記事がそれを確認してくれた

  • 「1 quintillionは1,000,000,000 billionに等しい」と見ると、私たちがだいたいint32int64の間でしか選ばないのはかなり不思議に思える。約1兆個のカーディナリティをサポートする5バイト整数型があってもよさそうだ

    • 値をぎっしり詰めてパックするのでないなら、2の累乗ではないサイズを選ぶのは筋が通らない
  • 自動増分IDを使わないのは合理的かもしれないが、内部用と外部用でIDを2つ持つ利点はよく分からない
    カラムとインデックスの数が増え、常に先に検索が必要になり、内部キーを変えつつ外部キーは変えないというセキュリティ上のシナリオも思い浮かばない。何か見落としているのだろうか?

    • プロジェクト単位で何かをする場合は、いずれにせよ必要な情報をすでに持っている。また、各プロジェクトのissueが2兆7000億3億571万7325のような数字から始まるより、1から始まるほうがユーザーに親切だ
  • PostgreSQLネイティブのUUID v4型をbigserialの代わりに使うと、テーブルサイズが25%大きくなり、挿入率がbigserialの25%まで落ちるというが、なぜUUIDv4がそれほど悪いのか気になる
    UUIDはただの128ビット数値ではないのか? 生成コストがものすごく高いのか、何が起きているのか分からない

    • UUIDv4は完全にランダムで、Bツリーインデックスは合理的な順序を持つ「右寄り」の値を期待する
      そのためUUIDv4カラムのインデックス処理が遅くなり、これがUUIDv6とUUIDv7の開発動機になった
    • サイズが25%増えるのは確かだが、行あたり8バイトという小さく予測可能な線形増加にすぎない。行の残りのデータと比べれば、それほど心配するほどではない
      より大きな問題は挿入率だ。UUIDの場合、挿入率は利用可能なRAM量によって制限される。自動増分整数はそうではない。整数は時間と相関するが、UUID4はランダムなので、規模が大きくなると性能特性が根本的に変わる。小さなテーブルでは挿入ペナルティはほぼ無視できるが、Bツリーインデックスのサイズがメモリ限界に達すると、PostgreSQLはUUIDのBツリー全体をメモリに置けず、ディスクページの入れ替えに依存する。自動増分整数では、時間的に近い行が同じインデックスページを使うため、同じ負荷でもディスクに当たる必要がない。この規模に達すると、差は一定の25%低下ではなく25倍の性能の崖であり、スキーマ移行を除けばRAMを増設するしかない
    • Bツリーが原因だと思う。Bツリーとページは、最後のページだけが多く使われるときによりうまく動作する
      UUIDはソートされていない書き込みを大量に生み、ページ肥大につながる
    • ソート順がランダムに分布すると、Bツリーのキャッシュ局所性が悪くなる。挿入が最後のページに行かず、あちこちに散らばる
      バッチ挿入の局所性も後で照会するときに悪くなり、関連レコードをランダムに探す必要が出る。結局、挿入時点とその後の選択時点の両方でコストを払うことになる