1 分

UUID vs ULID vs シリアルID:DBに最適なIDの選び方

UUID、ULID、シリアルID:各方式がインデックス、ソート、シャーディング、エクスポート/インポートに与える影響を実務視点で学び、後で後悔しないIDを選ぶ方法を解説します。

UUID vs ULID vs シリアルID:DBに最適なIDの選び方

本当の問題:後悔しないIDを選ぶこと

ID の選択は最初の週には退屈に見えます。リリースしてデータが増えると、その「単純な」決断はあらゆる場所に現れます:インデックス、URL、ログ、エクスポート、統合など。

本当の問いは「どれが最適か?」ではなく「後でどんな痛みを避けたいか?」です。ID は他のテーブルにコピーされたりクライアントにキャッシュされたり、他システムに依存されたりするため変更が難しい。

ID がプロダクトの進化に合わないと、だいたい次のような場面で気づきます:

  • プライマリキーインデックスが扱いにくいパターンで成長し、挿入やクエリが遅くなる。
  • ID でソートすると作成時刻と一致せず、ページネーションが面倒になる。
  • 他システムが ID 形式に依存するとマイグレーションが危険になる。
  • ログやデバッグで ID が読みづらいと調査に時間がかかる。
  • 複数ソースからのインポートで衝突が発生する。

今の利便性と将来の柔軟性の間には常にトレードオフがあります。シリアル整数は読みやすく高速なことが多いですがレコード数を漏らしやすく、データ統合が面倒になります。ランダムな UUID はシステム間での一意性に優れますが、インデックス負荷や人間によるログ読み取りで不利になります。ULID はグローバル一意性と時系列に近い順序付けを両立しようとしますが、ストレージやツールのトレードオフがあります。

一つの考え方:ID は主に誰のためか?

ID が主に人間(サポート、デバッグ、オペレーション)のためなら、短くスキャンしやすいものが有利です。マシン(分散書き込み、オフラインクライアント、マルチリージョン)のためなら、グローバル一意性や衝突回避がより重要です。

簡単な定義(平易な言葉で)

「UUID vs ULID vs serial IDs」と言うとき、本質は各行に与える一意なラベルの付け方を選んでいます。そのラベルは将来の挿入、ソート、マージ、移動のしやすさに影響します。

Serial IDs(integer/bigint)

シリアルID はカウンターです。データベースが 1、2、3 と順に発行します(多くは integer や bigint として保存)。読みやすく、保存コストが低く、新しい行が通常インデックスの末尾に追加されるため高速です。

UUIDs

UUID は 128 ビットの識別子で、3f8a... のようにランダムに見えます。多くのセットアップではデータベースに問い合わせずに生成できるので、異なるシステムが独立して ID を作れます。トレードオフはランダムに見える挿入がインデックスに負担をかけ、bigint より多くのスペースを使う点です。

ULIDs

ULID も 128 ビットですが、概ね時間順になるよう設計されています。新しい ULID は通常古いものの後に並ぶため、グローバルな一意性を保ちながらソート振る舞いが扱いやすくなります。

簡単な要約:

  • Serial:最も小さく、デフォルトで順序付けされる。
  • UUID:独立生成が最も簡単で、人間には分かりにくく、順序はランダムになりがち。
  • ULID:UUID のように独立生成可能だが、概ね時間順に並ぶ。

Serial はシングルデータベースのアプリや内部ツールでよく使われます。UUID は複数サービスやデバイス、リージョンでデータが生成される場合に現れます。ULID は分散ID生成をしたいがソート順や最新順クエリを気にするチームに人気です。

インデックスとパフォーマンス:実務で何が変わるか

プライマリキーは通常インデックス(多くは B-tree)で支えられています。そのインデックスはソートされた電話帳のようなもので、新しい行は適切な場所にエントリを置く必要があり、検索を速く保ちます。

ランダムなID(典型的な UUIDv4)では新しいエントリがインデックスのあちこちに入ります。これにより多くのインデックスページにアクセスし、ページ分割が増え、書き込みが増えます。時間とともにインデックスのチャーンが増え、挿入ごとの負荷、キャッシュミス、予想より大きなインデックスを招きます。

ほぼ増加するID(serial/bigint や時間順に近い ID、ULID の多く)ではデータベースは通常インデックス末尾に追加できます。これにより最近のページがホットになりやすく、挿入は高負荷時でも滑らかになります。

キーサイズは重要です。インデックスエントリは無料ではありません:

  • serial bigint:8 バイト
  • UUID:16 バイト
  • ULID:バイナリで 16 バイト、26 文字の文字列で保存するとずっと大きい

大きなキーは1ページに入るエントリ数を減らします。結果としてインデックスが深くなり、クエリごとに読み込むページが増え、速さを保つためにより多くの RAM が必要になります。

恒常的に挿入される「events」テーブルがある場合、ランダムな UUID 主キーは bigint より早く遅さを感じ始めることがあります。重い書き込みが予想されるなら、インデックスコストが最初に目に見える差になることが多いです。

ソート、ページネーション、時間順序

「もっと読み込む」や無限スクロールを作ったことがあるなら、ソートがうまくいかないIDの悩みはすでに経験しているはずです。ID が「よくソートする」とは、ID で並び替えたときに安定した意味のある順序(多くは作成時刻)になることを指します。

ランダムなID(UUIDv4 のよう)では新しい行が散らばります。id での並びは時間と一致せず、この id の後 といったカーソルページネーションは信頼できません。通常は created_at に頼りますが、慎重に扱う必要があります。

ULID は概ね時間順になるよう設計されています。ULID でソートすれば(文字列でもバイナリでも)新しいアイテムが後に来る傾向があり、カーソルを最後に見た ULID にするだけでページネーションが楽になります。

ULID が与えるもの(と与えないもの)

ULID はフィードの自然な時間順や単純なカーソル、UUIDv4 よりランダム挿入が少ないという利点を与えます。

しかし ULID は複数マシンが同一ミリ秒に大量に生成した場合に完全な時間順を保証するわけではありません。厳密な順序が必要なら実際のタイムスタンプを使ってください。

それでも created_at が優れているとき

バックフィルや履歴レコードのインポート、明確なタイブレークが必要な場合は created_at でソートする方が安全です。

実用的なパターンは (created_at, id) のようにして、id はタイブレーカーに留めることです。

将来のシャーディング:ID 衝突を避ける

出荷前にIDを決める
スキーマが広がる前にデフォルトのIDパターンをプランニングモードで決めましょう。

シャーディングとは1つのデータベースを複数に分割し、それぞれがデータの一部を保持することです。多くのチームは単一 DB がスケールしにくくなるか、単一障害点が問題になるときにこれを行います。

ID の選択はシャーディングを扱いやすくするか面倒にするかを左右します。

連番(自動増分の serialbigint)では、各シャードが喜んで 1, 2, 3... を発行します。同じ ID が複数のシャードに存在し得ます。データをマージしたり行を移動したりクロスシャード機能を作る必要が出たときに衝突に直面します。

調整(中央のIDサービス、シャードごとの範囲割当て)で衝突を避けることは可能ですが、手間が増えボトルネックになり得ます。

UUID や ULID は各シャードが独立して ID を生成できるため重複のリスクを減らします。将来データを複数 DB に分ける可能性があるなら、これは純粋な連番に対する強い反論になります。

実用的な計画例(コストと共に)

よくある妥協策はシャード接頭辞を付け、各シャードでローカルシーケンスを使うやり方です。列を二つにするか一つにパックするかの選択ができます。

動きますがカスタムID形式になるため、すべての統合がその形式を理解する必要があり、ソートはグローバルな時間順でなくなり、データ移動時に ID 書き換えが必要になれば共有されている参照が壊れます。

早めに自問してください:将来複数データベースのデータを結合して参照を安定させる必要がありますか?もしあるなら、最初からグローバル一意のIDを計画するか、後で移行するための予算を見込んでください。

データのエクスポートとインポートワークフロー

エクスポートとインポートは ID 選択が理論でなくなる瞬間です。プロダクションをステージングにクローンしたり、バックアップを復元したり、別システムのデータをマージしたりするたびに、ID が安定で移植可能かが試されます。

シリアル(自動増分)ID では、別のデータベースに安全にリプレイして参照が壊れないとは限りません。サブセットだけをインポートする場合(例:顧客200件とその注文)、テーブルを正しい順で読み込み元の主キーを保持する必要があります。番号が付け直されると外部キーが壊れます。

UUID や ULID はデータベースのシーケンス外で生成されることが多いため、環境間で移しやすく ID を保持してリレーションを保てます。バックアップ復元や部分エクスポート、データマージで役立ちます。

例:本番からアカウント50件をエクスポートしてステージングでデバッグする場面。UUID/ULID の主キーなら関連行(プロジェクト、請求、ログ)もインポートしてそのまま親を指します。シリアルID だと old_id -> new_id の変換テーブルを作って外部キーを書き換える必要になることが多いです。

バルクインポートでは ID タイプより基本が重要です:

  • インポーターがデフォルトで新しいIDを生成しないようにする。
  • 親を先にインポートし子を後で入れ、ロード後に外部キーを検証する。
  • シリアルを使うならシーケンスをリセットしないと次の挿入で衝突する。
  • ULID は文字列とバイナリのどちらで保存・エクスポートするかを一貫させる。

10分で決める方法

将来に痛みを残さないことに焦点を当てれば、短時間で堅実な判断ができます。

  1. 将来のリスクを具体的なイベントとして書き出す。複数データベースへの分割、別システムからのデータマージ、オフライン書き込み、環境間の頻繁なデータコピーなど。
  2. ID のソート順が作成時刻と一致する必要があるか決める。
  3. 書き込み量とインデックス感度を見積もる。重い挿入が予想されるなら serial BIGINT は B-tree に優しい。ランダム UUID はチャーンを招きやすい。
  4. デフォルトを決め、例外を文書化する。多くのテーブルは1つのデフォルト、例外は公開用IDや外部エンティティに限定するのが現実的。
  5. 変更する余地を残す。ID に意味を詰め込みすぎず、ID がどこで生成されるか(DB かアプリか)を明確にし、制約は明示する。

よくあるミスと罠

スナップショットで実験する
ID 変更を早期にテストし、新しい戦略で問題が出たらロールバック。

最大の罠は流行りだからという理由で ID を選び、後でクエリ、スケール、データ共有のやり方と衝突することです。多くの問題は数ヶ月後に現れます。

よくある失敗例:

  • 何も検討せずに UUID を至る所で使い、コストを見落とす。UUIDv4 はインデックスを肥大化させキャッシュ親和性を下げる。アプリは動き続けますが、書き込みやバックアップにコストがかかる。
  • シリアルID に頼っていて後で複数システムやリージョン、シャードとデータをマージする必要に迫られる。衝突に対してオフセットや接頭辞で対処すると、その汚れが全ての統合に漏れる。
  • ULID が全てを高速にすると仮定する。挿入順や時系列ソートは改善しますが、遅い結合、欠けたインデックス、ワイドな行は解決しない。高負荷下で単調性を保証しないジェネレータもある。
  • 連番IDを公開してしまう。URL や API に 123, 124, 125 を使うと近傍レコードを推測され探られる恐れがある。
  • プロジェクト途中で ID タイプを変更するがマイグレーション計画がない。外部キー、キャッシュ、ログ、外部ペイロードは古いIDを長く参照し続ける。

早期に対処すべき警告サイン:

  • パートナーからのインポートや環境間マージが予想される。
  • 別のタイムスタンプに頼らず時間順ページングが必要。
  • ID を外部で共有する予定がある(URL、Webhook、モバイルアプリ)。
  • 大規模な ID マイグレーションのダウンタイムを許容できない。
  • 非常に大きいテーブルがあり、インデックスサイズと書き込み速度が重要。

コミット前の簡単チェックリスト

データベースとクエリの現実チェック

多くのテーブルで一つの主キー型を選び、守る。型を混在させるとジョインや API、マイグレーションが面倒になる。

想定スケールでインデックスサイズを見積もる。キーが広いとプライマリインデックスは大きくなり、より多くのメモリと IO が必要になる。

ページネーションの方法を決める。ID でページングするなら ID が予測可能な順序を持つことを確認するか、タイムスタンプでページングするなら created_at にインデックスを張って一貫して使う。

将来対策チェック

本番に近いデータでインポート計画を試す。外部キーが壊れずにレコードを再作成できるか、再インポートが新しいIDを暗黙に生成しないかを検証する。

衝突戦略を書き残す。誰が ID を生成するか(DB かアプリか)、オフラインで二つのシステムが同時に作成したときの同期はどうするか。

公開 URL やログで漏れるパターンに注意する(レコード数、作成頻度、内部シャードのヒントなど)。シリアルID を使うなら他者が近傍の ID を推測できることを想定する。

現実的な例:MVP からマルチシステムへ

エクスポートとインポートを簡単にする
外部キーを書き換えずにインポートやマージ、環境コピーを設計します。

創業者が簡単な CRM をローンチ:contacts、deals、notes。Postgres 一つ、Web アプリ一つ、目的はとにかく早く出すこと。

最初は serial bigint 主キー が完璧に思えます。挿入は速く、インデックスは整い、ログで読みやすい。

1年後、顧客が監査のため四半期エクスポートを求め、創業者はマーケティングツールからリードをインポートし始めます。内部だけだった ID が CSV、メール、サポートチケットに現れるようになります。二つのシステムが両方 1, 2, 3... を使うとマージが厄介になり、ソース列やマッピングテーブル、インポート時の ID 書き換えが必要になります。

2年目にはモバイルアプリができ、オフラインでレコードを作成して後で同期する必要が出ます。クライアント側で DB に問い合わせずに ID を生成でき、異なる環境でデータが着地しても衝突リスクが低いことが求められます。

長く持つ妥協案の一例:

  • 内部結合とストレージ効率のために bigint 主キーを残す。
  • 共有・同期・エクスポート用に別の不変な公開ID(ULID または 利用可能なら UUIDv7)を追加する。
  • エクスポートやシステム間マージでは公開ID をマージキーとして使う。

プロジェクトでの実践的な次のステップ

UUID、ULID、serial の間で迷っているなら、データの移動と成長の仕方で決めてください。

一般的なケースのワンセンテンス選択:

  • 単一データベースで内部統合のリスクが低い内部ツール:bigint serial 主キー。
  • 共有可能な URL やクライアント側で生成が必要な公開アプリ:UUID(推測されにくく、システム間で安全)。
  • テナントやリージョンごとに将来分割する可能性のある SaaS:ULID(または UUIDv7)で新しい行が近接してインデックスに入るようにする。
  • パートナーからの大量インポートやオフラインデバイスが多い:外部エンティティに対して純粋なシリアルIDは避ける。

混在が最良解になることが多いです。内部的にデータベース内で完結するテーブル(ジョインテーブル、バックグラウンドジョブ)は serial bigint を使い、ユーザー、組織、請求書、エクスポート・同期対象の公開エンティティには UUID/ULID を使う、という方針です。

もし Koder.ai (koder.ai) で構築するなら、テーブルや API を大量に生成する前に ID パターンを決める価値があります。プラットフォームのプランニングモードやスナップショット/ロールバックを使えば、スキーマ変更を小さいうちに適用・検証しやすくなります。

よくある質問

How do I choose between serial IDs, UUIDs, and ULIDs without overthinking it?

まず避けたい将来の痛みを書き出してください:ランダムなインデックス書き込みによる遅い挿入、扱いにくいページネーション、リスクの高いマイグレーション、インポートやマージ時のID衝突など。データが複数のシステム間で移動したり、複数箇所で作成される可能性があるなら、グローバルに一意なID(UUID/ULID)をデフォルトにして、時系列に関する要件は別に扱うのが無難です。

When is a serial bigint primary key the best choice?

単一のデータベースで書き込みが多く、IDが内部で完結するなら Serial bigint が強力なデフォルトです。B-tree インデックスに優しく、ログでも読みやすい。一方で、公開するとレコード数が推測される可能性や、将来データをマージする際の衝突が主な欠点です。

When should I use UUIDs as my primary key?

複数のサービス、リージョン、デバイス、またはオフラインクライアントでレコードが生成される可能性があり、協調なしで非常に低い衝突リスクを望むなら UUID を選びます。公開用IDとしても推奨されます。トレードオフはインデックスが大きくなり、順序がランダムになりやすい点です。

What’s the practical advantage of ULIDs over UUIDs?

ULID はどこでも生成でき、かつ概ね作成時刻順に並ぶ点が利点です。これによりカーソルページネーションが簡単になり、UUIDv4 のようなランダム挿入の問題を軽減します。ただし同一ミリ秒内に複数マシンで大量に生成されると厳密な時間順にはならないため、厳密な順序が必要なら created_at を使ってください。

Do random UUID primary keys really hurt performance in Postgres?

はい。特に UUIDv4 のようなランダム性が強い場合、書き込みが多いテーブルでは影響が出やすいです。ランダム挿入はプライマリキーのインデックスページを広く触るため、ページ分割やキャッシュの競合が増え、持続的な挿入速度が落ちたりメモリ/IO 要件が増えたりします。

Why does pagination get weird when I sort by UUID?

ランダムなIDでソートすると作成時刻と一致しないため、“このIDの後”というカーソルで安定したタイムラインが得られません。一般的な対処は created_at でページングし、ID をタイブレーカーとして使うこと(例:(created_at, id))。IDだけでページングしたいなら ULID のような時間整列可能なIDの方が扱いやすいです。

How does my ID choice affect sharding later?

シリアルは各シャードが独立して 1, 2, 3... を発行するため衝突が発生します。シャード間でデータを結合したり移動したりする必要が出た時に問題になります。シャードごとに範囲を割り当てるなどの調整は可能ですが、運用の複雑さやボトルネックを招きます。UUID/ULID は各シャードで独立生成しても衝突リスクが非常に低いため、シャーディングを将来考えている場合は強い利点です。

Which ID type is safest for exports, imports, and merging datasets?

エクスポートして別環境にインポートする際、UUID/ULID は ID を保持したまま移せるのでリレーションが壊れません。シリアルID だと部分インポートで old_id -> new_id の変換テーブルを作って外部キーを書き換える必要が出がちで、ミスしやすいです。頻繁に環境をクローンしたりデータをマージするなら、グローバルに一意なIDの方が手間を減らします。

Should I use one ID for everything, or have both internal and public IDs?

多くのチームが採るパターンは二重IDです:内部結合やストレージ効率のためのコンパクトな内部主キー(serial bigint)と、URL、API、エクスポート、システム間参照用の不変な公開ID(ULID または UUID)。公開IDは安定して扱い、再利用や解釈をしないことで安全性を保ちます。

What’s the safest way to commit to an ID strategy when building with Koder.ai?

早めに方針を決め、一貫して適用してください。Koder.ai を使うなら、生成前にデフォルトの ID 戦略をプランニングモードで決め、スナップショットやロールバックで変更を検証するのが安全です。最も大変なのは新しいIDを作ることではなく、古いIDを参照し続ける外部システムやキャッシュ、ログ、外部ペイロードを更新することです。

Related posts