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

3f8a... のようにランダムに見えます。多くのセットアップではデータベースに問い合わせずに生成できるので、異なるシステムが独立して ID を作れます。トレードオフはランダムに見える挿入がインデックスに負担をかけ、bigint より多くのスペースを使う点です。\n\n### ULIDs\n\nULID も 128 ビットですが、概ね時間順になるよう設計されています。新しい ULID は通常古いものの後に並ぶため、グローバルな一意性を保ちながらソート振る舞いが扱いやすくなります。\n\n簡単な要約:\n\n- Serial:最も小さく、デフォルトで順序付けされる。\n- UUID:独立生成が最も簡単で、人間には分かりにくく、順序はランダムになりがち。\n- ULID:UUID のように独立生成可能だが、概ね時間順に並ぶ。\n\nSerial はシングルデータベースのアプリや内部ツールでよく使われます。UUID は複数サービスやデバイス、リージョンでデータが生成される場合に現れます。ULID は分散ID生成をしたいがソート順や最新順クエリを気にするチームに人気です。\n\n## インデックスとパフォーマンス:実務で何が変わるか\n\nプライマリキーは通常インデックス(多くは B-tree)で支えられています。そのインデックスはソートされた電話帳のようなもので、新しい行は適切な場所にエントリを置く必要があり、検索を速く保ちます。\n\nランダムなID(典型的な UUIDv4)では新しいエントリがインデックスのあちこちに入ります。これにより多くのインデックスページにアクセスし、ページ分割が増え、書き込みが増えます。時間とともにインデックスのチャーンが増え、挿入ごとの負荷、キャッシュミス、予想より大きなインデックスを招きます。\n\nほぼ増加するID(serial/bigint や時間順に近い ID、ULID の多く)ではデータベースは通常インデックス末尾に追加できます。これにより最近のページがホットになりやすく、挿入は高負荷時でも滑らかになります。\n\nキーサイズは重要です。インデックスエントリは無料ではありません:\n\n- serial bigint:8 バイト\n- UUID:16 バイト\n- ULID:バイナリで 16 バイト、26 文字の文字列で保存するとずっと大きい\n\n大きなキーは1ページに入るエントリ数を減らします。結果としてインデックスが深くなり、クエリごとに読み込むページが増え、速さを保つためにより多くの RAM が必要になります。\n\n恒常的に挿入される「events」テーブルがある場合、ランダムな UUID 主キーは bigint より早く遅さを感じ始めることがあります。重い書き込みが予想されるなら、インデックスコストが最初に目に見える差になることが多いです。\n\n## ソート、ページネーション、時間順序\n\n「もっと読み込む」や無限スクロールを作ったことがあるなら、ソートがうまくいかないIDの悩みはすでに経験しているはずです。ID が「よくソートする」とは、ID で並び替えたときに安定した意味のある順序(多くは作成時刻)になることを指します。\n\nランダムなID(UUIDv4 のよう)では新しい行が散らばります。id での並びは時間と一致せず、この id の後 といったカーソルページネーションは信頼できません。通常は created_at に頼りますが、慎重に扱う必要があります。\n\nULID は概ね時間順になるよう設計されています。ULID でソートすれば(文字列でもバイナリでも)新しいアイテムが後に来る傾向があり、カーソルを最後に見た ULID にするだけでページネーションが楽になります。\n\n### ULID が与えるもの(と与えないもの)\n\nULID はフィードの自然な時間順や単純なカーソル、UUIDv4 よりランダム挿入が少ないという利点を与えます。\n\nしかし ULID は複数マシンが同一ミリ秒に大量に生成した場合に完全な時間順を保証するわけではありません。厳密な順序が必要なら実際のタイムスタンプを使ってください。\n\n### それでも created_at が優れているとき\n\nバックフィルや履歴レコードのインポート、明確なタイブレークが必要な場合は created_at でソートする方が安全です。\n\n実用的なパターンは (created_at, id) のようにして、id はタイブレーカーに留めることです。\n\n## 将来のシャーディング:ID 衝突を避ける\n\nシャーディングとは1つのデータベースを複数に分割し、それぞれがデータの一部を保持することです。多くのチームは単一 DB がスケールしにくくなるか、単一障害点が問題になるときにこれを行います。\n\nID の選択はシャーディングを扱いやすくするか面倒にするかを左右します。\n\n連番(自動増分の serial や bigint)では、各シャードが喜んで 1, 2, 3... を発行します。同じ ID が複数のシャードに存在し得ます。データをマージしたり行を移動したりクロスシャード機能を作る必要が出たときに衝突に直面します。\n\n調整(中央のIDサービス、シャードごとの範囲割当て)で衝突を避けることは可能ですが、手間が増えボトルネックになり得ます。\n\nUUID や ULID は各シャードが独立して ID を生成できるため重複のリスクを減らします。将来データを複数 DB に分ける可能性があるなら、これは純粋な連番に対する強い反論になります。\n\n### 実用的な計画例(コストと共に)\n\nよくある妥協策はシャード接頭辞を付け、各シャードでローカルシーケンスを使うやり方です。列を二つにするか一つにパックするかの選択ができます。\n\n動きますがカスタムID形式になるため、すべての統合がその形式を理解する必要があり、ソートはグローバルな時間順でなくなり、データ移動時に ID 書き換えが必要になれば共有されている参照が壊れます。\n\n早めに自問してください:将来複数データベースのデータを結合して参照を安定させる必要がありますか?もしあるなら、最初からグローバル一意のIDを計画するか、後で移行するための予算を見込んでください。\n\n## データのエクスポートとインポートワークフロー\n\nエクスポートとインポートは ID 選択が理論でなくなる瞬間です。プロダクションをステージングにクローンしたり、バックアップを復元したり、別システムのデータをマージしたりするたびに、ID が安定で移植可能かが試されます。\n\nシリアル(自動増分)ID では、別のデータベースに安全にリプレイして参照が壊れないとは限りません。サブセットだけをインポートする場合(例:顧客200件とその注文)、テーブルを正しい順で読み込み元の主キーを保持する必要があります。番号が付け直されると外部キーが壊れます。\n\nUUID や ULID はデータベースのシーケンス外で生成されることが多いため、環境間で移しやすく ID を保持してリレーションを保てます。バックアップ復元や部分エクスポート、データマージで役立ちます。\n\n例:本番からアカウント50件をエクスポートしてステージングでデバッグする場面。UUID/ULID の主キーなら関連行(プロジェクト、請求、ログ)もインポートしてそのまま親を指します。シリアルID だと old_id -> new_id の変換テーブルを作って外部キーを書き換える必要になることが多いです。\n\nバルクインポートでは ID タイプより基本が重要です:\n\n- インポーターがデフォルトで新しいIDを生成しないようにする。\n- 親を先にインポートし子を後で入れ、ロード後に外部キーを検証する。\n- シリアルを使うならシーケンスをリセットしないと次の挿入で衝突する。\n- ULID は文字列とバイナリのどちらで保存・エクスポートするかを一貫させる。\n\n## 10分で決める方法\n\n将来に痛みを残さないことに焦点を当てれば、短時間で堅実な判断ができます。\n\n1. 将来のリスクを具体的なイベントとして書き出す。複数データベースへの分割、別システムからのデータマージ、オフライン書き込み、環境間の頻繁なデータコピーなど。\n2. ID のソート順が作成時刻と一致する必要があるか決める。\n3. 書き込み量とインデックス感度を見積もる。重い挿入が予想されるなら serial BIGINT は B-tree に優しい。ランダム UUID はチャーンを招きやすい。\n4. デフォルトを決め、例外を文書化する。多くのテーブルは1つのデフォルト、例外は公開用IDや外部エンティティに限定するのが現実的。\n5. 変更する余地を残す。ID に意味を詰め込みすぎず、ID がどこで生成されるか(DB かアプリか)を明確にし、制約は明示する。\n\n## よくあるミスと罠\n\n最大の罠は流行りだからという理由で ID を選び、後でクエリ、スケール、データ共有のやり方と衝突することです。多くの問題は数ヶ月後に現れます。\n\nよくある失敗例:\n\n- 何も検討せずに UUID を至る所で使い、コストを見落とす。UUIDv4 はインデックスを肥大化させキャッシュ親和性を下げる。アプリは動き続けますが、書き込みやバックアップにコストがかかる。\n- シリアルID に頼っていて後で複数システムやリージョン、シャードとデータをマージする必要に迫られる。衝突に対してオフセットや接頭辞で対処すると、その汚れが全ての統合に漏れる。\n- ULID が全てを高速にすると仮定する。挿入順や時系列ソートは改善しますが、遅い結合、欠けたインデックス、ワイドな行は解決しない。高負荷下で単調性を保証しないジェネレータもある。\n- 連番IDを公開してしまう。URL や API に 123, 124, 125 を使うと近傍レコードを推測され探られる恐れがある。\n- プロジェクト途中で ID タイプを変更するがマイグレーション計画がない。外部キー、キャッシュ、ログ、外部ペイロードは古いIDを長く参照し続ける。\n\n早期に対処すべき警告サイン:\n\n- パートナーからのインポートや環境間マージが予想される。\n- 別のタイムスタンプに頼らず時間順ページングが必要。\n- ID を外部で共有する予定がある(URL、Webhook、モバイルアプリ)。\n- 大規模な ID マイグレーションのダウンタイムを許容できない。\n- 非常に大きいテーブルがあり、インデックスサイズと書き込み速度が重要。\n\n## コミット前の簡単チェックリスト\n\n### データベースとクエリの現実チェック\n\n多くのテーブルで一つの主キー型を選び、守る。型を混在させるとジョインや API、マイグレーションが面倒になる。\n\n想定スケールでインデックスサイズを見積もる。キーが広いとプライマリインデックスは大きくなり、より多くのメモリと IO が必要になる。\n\nページネーションの方法を決める。ID でページングするなら ID が予測可能な順序を持つことを確認するか、タイムスタンプでページングするなら created_at にインデックスを張って一貫して使う。\n\n### 将来対策チェック\n\n本番に近いデータでインポート計画を試す。外部キーが壊れずにレコードを再作成できるか、再インポートが新しいIDを暗黙に生成しないかを検証する。\n\n衝突戦略を書き残す。誰が ID を生成するか(DB かアプリか)、オフラインで二つのシステムが同時に作成したときの同期はどうするか。\n\n公開 URL やログで漏れるパターンに注意する(レコード数、作成頻度、内部シャードのヒントなど)。シリアルID を使うなら他者が近傍の ID を推測できることを想定する。\n\n## 現実的な例:MVP からマルチシステムへ\n\n創業者が簡単な CRM をローンチ:contacts、deals、notes。Postgres 一つ、Web アプリ一つ、目的はとにかく早く出すこと。\n\n最初は serial bigint 主キー が完璧に思えます。挿入は速く、インデックスは整い、ログで読みやすい。\n\n1年後、顧客が監査のため四半期エクスポートを求め、創業者はマーケティングツールからリードをインポートし始めます。内部だけだった ID が CSV、メール、サポートチケットに現れるようになります。二つのシステムが両方 1, 2, 3... を使うとマージが厄介になり、ソース列やマッピングテーブル、インポート時の ID 書き換えが必要になります。\n\n2年目にはモバイルアプリができ、オフラインでレコードを作成して後で同期する必要が出ます。クライアント側で DB に問い合わせずに ID を生成でき、異なる環境でデータが着地しても衝突リスクが低いことが求められます。\n\n長く持つ妥協案の一例:\n\n- 内部結合とストレージ効率のために bigint 主キーを残す。\n- 共有・同期・エクスポート用に別の不変な公開ID(ULID または 利用可能なら UUIDv7)を追加する。\n- エクスポートやシステム間マージでは公開ID をマージキーとして使う。\n\n## プロジェクトでの実践的な次のステップ\n\nUUID、ULID、serial の間で迷っているなら、データの移動と成長の仕方で決めてください。\n\n一般的なケースのワンセンテンス選択:\n\n- 単一データベースで内部統合のリスクが低い内部ツール:bigint serial 主キー。\n- 共有可能な URL やクライアント側で生成が必要な公開アプリ:UUID(推測されにくく、システム間で安全)。\n- テナントやリージョンごとに将来分割する可能性のある SaaS:ULID(または UUIDv7)で新しい行が近接してインデックスに入るようにする。\n- パートナーからの大量インポートやオフラインデバイスが多い:外部エンティティに対して純粋なシリアルIDは避ける。\n\n混在が最良解になることが多いです。内部的にデータベース内で完結するテーブル(ジョインテーブル、バックグラウンドジョブ)は serial bigint を使い、ユーザー、組織、請求書、エクスポート・同期対象の公開エンティティには UUID/ULID を使う、という方針です。\n\nもし Koder.ai (koder.ai) で構築するなら、テーブルや API を大量に生成する前に ID パターンを決める価値があります。プラットフォームのプランニングモードやスナップショット/ロールバックを使えば、スキーマ変更を小さいうちに適用・検証しやすくなります。まず避けたい将来の痛みを書き出してください:ランダムなインデックス書き込みによる遅い挿入、扱いにくいページネーション、リスクの高いマイグレーション、インポートやマージ時のID衝突など。データが複数のシステム間で移動したり、複数箇所で作成される可能性があるなら、グローバルに一意なID(UUID/ULID)をデフォルトにして、時系列に関する要件は別に扱うのが無難です。
単一のデータベースで書き込みが多く、IDが内部で完結するなら Serial bigint が強力なデフォルトです。B-tree インデックスに優しく、ログでも読みやすい。一方で、公開するとレコード数が推測される可能性や、将来データをマージする際の衝突が主な欠点です。
複数のサービス、リージョン、デバイス、またはオフラインクライアントでレコードが生成される可能性があり、協調なしで非常に低い衝突リスクを望むなら UUID を選びます。公開用IDとしても推奨されます。トレードオフはインデックスが大きくなり、順序がランダムになりやすい点です。
ULID はどこでも生成でき、かつ概ね作成時刻順に並ぶ点が利点です。これによりカーソルページネーションが簡単になり、UUIDv4 のようなランダム挿入の問題を軽減します。ただし同一ミリ秒内に複数マシンで大量に生成されると厳密な時間順にはならないため、厳密な順序が必要なら created_at を使ってください。
はい。特に UUIDv4 のようなランダム性が強い場合、書き込みが多いテーブルでは影響が出やすいです。ランダム挿入はプライマリキーのインデックスページを広く触るため、ページ分割やキャッシュの競合が増え、持続的な挿入速度が落ちたりメモリ/IO 要件が増えたりします。
ランダムなIDでソートすると作成時刻と一致しないため、“このIDの後”というカーソルで安定したタイムラインが得られません。一般的な対処は created_at でページングし、ID をタイブレーカーとして使うこと(例:(created_at, id))。IDだけでページングしたいなら ULID のような時間整列可能なIDの方が扱いやすいです。
シリアルは各シャードが独立して 1, 2, 3... を発行するため衝突が発生します。シャード間でデータを結合したり移動したりする必要が出た時に問題になります。シャードごとに範囲を割り当てるなどの調整は可能ですが、運用の複雑さやボトルネックを招きます。UUID/ULID は各シャードで独立生成しても衝突リスクが非常に低いため、シャーディングを将来考えている場合は強い利点です。
エクスポートして別環境にインポートする際、UUID/ULID は ID を保持したまま移せるのでリレーションが壊れません。シリアルID だと部分インポートで old_id -> new_id の変換テーブルを作って外部キーを書き換える必要が出がちで、ミスしやすいです。頻繁に環境をクローンしたりデータをマージするなら、グローバルに一意なIDの方が手間を減らします。
多くのチームが採るパターンは二重IDです:内部結合やストレージ効率のためのコンパクトな内部主キー(serial bigint)と、URL、API、エクスポート、システム間参照用の不変な公開ID(ULID または UUID)。公開IDは安定して扱い、再利用や解釈をしないことで安全性を保ちます。
早めに方針を決め、一貫して適用してください。Koder.ai を使うなら、生成前にデフォルトの ID 戦略をプランニングモードで決め、スナップショットやロールバックで変更を検証するのが安全です。最も大変なのは新しいIDを作ることではなく、古いIDを参照し続ける外部システムやキャッシュ、ログ、外部ペイロードを更新することです。