Object IDとは?MongoDBやAPIでの意味の違いとID設計の選び方
「Object ID」という言葉は、文脈ごとに別のものを指します。MongoDBでは各ドキュメントの_idに入る12バイトの値、SQL ServerではOBJECT_IDという組み込み関数、地理情報システムのArcGISではテーブルの属性フィールド、Rubyではオブジェクトインスタンスに処理系が振る番号、そしてAPI設計の文脈では外部に公開するリソース識別子のことです。検索結果に無関係なページが混じるのは、この語が指す対象がそもそも複数あるためです。
もう一つ混乱しやすいのが、ID・UID・GUID・UUID・ULIDという呼称の関係です。これらは同じ階層の選択肢ではありません。UUIDだけがRFC 9562という標準を持ち、GUIDはそのMicrosoft系の呼び名、UIDは総称、ULIDは別団体の仕様です。
この記事では語の切り分けと用語整理を済ませたうえで、連番・UUIDv4・UUIDv7・ULID・プレフィックス付きランダム値を選び分ける基準を、公式仕様と実測値から示します。掲載したコードはPHP 8.5.8で実行し、出力を確認したものです。
まとめ
- Object IDは単一の技術用語ではありません。MongoDBのObjectId(12バイト・24桁の16進文字列)、SQL ServerのOBJECT_ID関数、ArcGISの属性フィールド、API設計の公開識別子は、それぞれ別物です。
- UUIDは128ビットの識別子で、文字列表現は16進32桁とハイフン4本の合計36文字です。「32文字」という記述をよく見かけますが、これは16進の桁数だけを数えた誤りです。
- 2024年5月のRFC 9562がRFC 4122を置き換え、時刻順に並ぶUUIDv7が標準化されました。連番の索引効率とUUIDの分散生成のどちらを取るかという古い二者択一は、UUIDv7とULIDによって大部分が解消しています。
- 連番IDをUUIDに変えてもIDOR(オブジェクトレベル認可の不備)は塞がりません。OWASP API Security Top 10 2023の第1位は認可機構そのものの実装を求めています。
- 外部に公開するIDは、内部の主キーと分けて設計します。Stripeは自社のオブジェクトIDを最大255文字まで伸びうる不透明な文字列と定義し、プレフィックスの追加・削除を下位互換の変更として扱っています。
以下、それぞれの根拠と実装を順に見ていきます。
Object IDが指すものの切り分け
同じ「Object ID」と書かれていても、以下は互いに変換も互換もありません。実装に入る前に、自分が扱っているのがどれなのかを確定させます。
MongoDBのObjectIdを構成する12バイト
MongoDBのObjectIdは12バイトの値で、公式マニュアルは内訳を次のように定めています。先頭4バイトが生成時刻(Unixエポックからの秒数)、続く5バイトがクライアントプロセスごとに一度生成される乱数、末尾3バイトがプロセスごとの増分カウンタです。カウンタは0からではなくランダムな値で初期化され、プロセスが再起動すると振り直されます。バイト順は、タイムスタンプとカウンタについてはBSONの他の値と異なり上位バイトが先に来ます。
先頭にUnix秒が入っているため、ObjectIdの文字列から生成時刻を復元できます。
<?php
$oid = '6890f1a53c2d6b1a2e9b4f1a'; // 24桁の16進文字列 = 12バイト
$sec = hexdec(substr($oid, 0, 8)); // 先頭4バイト = Unix秒
echo gmdate('Y-m-d H:i:s', $sec), " UTC\n"; // 2025-08-04 17:45:09 UTC
echo substr($oid, 8, 10), "\n"; // 5バイトの乱数部
echo substr($oid, 18, 6), "\n"; // 3バイトのカウンタ部
ただし時刻が入っていても完全な順序は保証されません。公式マニュアルは「ObjectIds are approximately ordered by creation time, but are not perfectly ordered」と明記し、その理由として時刻の分解能が1秒しかないこと、生成主体がクライアントであり時計がずれうることを挙げています。_idでのソートは「おおむね生成順」であって、厳密な作成順の証明には使えません。ドキュメント指向データベースとしての全体像はMongoDBとは?ドキュメント指向データベースの構造・ドキュメント・使い方入門で扱っています。
SQL Server・ArcGIS・Rubyでの用法
SQL ServerのOBJECT_IDは、テーブルやビューなどスキーマスコープのオブジェクトについて、データベース内のオブジェクト識別番号を返す組み込み関数です。公式ドキュメントはDDLトリガーのようなスキーマスコープ外のオブジェクトを対象外と明記しています。テーブルの存在確認に使われる関数名であり、レコードの識別子とは無関係です。ArcGISのObjectIDフィールドは、フィーチャクラスやテーブルの各行にジオデータベースが自動で割り当てる管理用の一意番号を指します。Rubyのobject_idは、実行中のオブジェクトインスタンスに処理系が与える識別子で、公式ドキュメントの表現どおり現在のプロセス内でのみ一意です。
このほか、国際博物館会議(ICOM)が定めた美術品・文化財の記録標準もObject IDと呼ばれますが、こちらは情報システムの識別子とは無関係のため本記事では扱いません。
いずれも「Object ID」と表記されますが、設計者が決めるものではなく、製品や処理系が付与するものです。自分で採番方式を決めたいという場面なら、探しているのは次のリソース識別子のほうです。
API設計における公開リソース識別子
API設計の文脈でのオブジェクトIDは、レスポンスに載せてクライアントに渡し、以後そのリソースを指すために使う文字列を指します。Stripeのcus_で始まる顧客ID、GitHubのIssue番号、Slackのチャンネル識別子はいずれもこれにあたり、形式も長さも提供側が決めています。データベースの主キーをそのまま出すこともできますが、両者を同じものにするかどうかは設計判断です。この記事の後半は、この意味でのIDを対象にしています。
ID・UID・GUID・UUID・ULIDの呼び分け
これらは並列の選択肢ではなく、標準を持つものと総称が混ざっています。標準の有無で分けると関係がはっきりします。
| 呼称 | 実体 | 標準 | 文字数 |
|---|---|---|---|
| UUID | 128ビットの識別子 | RFC 9562 | 36 |
| GUID | UUIDのMicrosoft系の呼称 | RFC 9562に準拠 | 36 |
| ULID | 128ビットの時刻順識別子 | ULID Spec | 26 |
| ObjectId | MongoDBの12バイト値 | MongoDB仕様 | 24 |
| UID | 文脈依存の総称 | なし | 可変 |
| グローバルID | システム横断で一意という性質 | なし | 可変 |
UUIDとGUIDは実質的に同じもので、.NETのSystem.Guidが生成する値もRFC 9562のフォーマットに従います。両方が出てくる資料では単に呼び名が違うだけだと考えて差し支えありません。ただしバイト列に変換するときは注意が要ります。.NETのGuid.ToByteArray()は、公式ドキュメントが「The order of the beginning four-byte group and the next two two-byte groups is reversed」と述べるとおり、先頭の4バイト組と続く2バイト組2つを逆順で返します。文字列表現のバイト順とは一致しません。.NET 8以降はbigEndianを受け取る多重定義が追加されているので、データベースへバイナリで格納する経路ではこちらを使います。一方でUIDとグローバルIDには対応する規格がありません。UIDはUnixのユーザーIDから機器の識別子まで文脈次第で中身が変わりますし、グローバルIDは「複数システムをまたいで一意である」という性質の呼び名であって、生成方法を指定する言葉ではありません。要件定義でこの2語が出てきたら、何を指しているのかを先に確定させる必要があります。
採番方式の選び方
方式ごとに、生成する場所・時系列順に並ぶか・推測できるか・主キーとしての幅が変わります。判断材料をまとめると次のようになります。
| 方式 | 生成主体 | 時系列順 | 推測 | 主キー幅 |
|---|---|---|---|---|
| 連番 | DB | 並ぶ | 容易 | 8バイト |
| UUIDv4 | アプリ | 並ばない | 困難 | 16バイト |
| UUIDv7 | アプリ | 並ぶ | 困難 | 16バイト |
| ULID | アプリ | 並ぶ | 困難 | 16バイト |
| プレフィックス付き乱数 | アプリ | 並ばない | 困難 | 可変 |
選択の分かれ目は、まず「IDをアプリケーション側で先に決めたいか」です。決めたいなら連番は外れます。次に「時系列順に並ぶ必要があるか」で、必要ならUUIDv7かULIDに絞られます。以下、方式ごとの具体を見ます。
連番の適性と限界
データベースのオートインクリメント(MySQLのAUTO_INCREMENT、PostgreSQLのシーケンス)で採番する方式です。主キーが8バイトのBIGINTで済み、挿入が常にB木の右端に集中するため、ページの中間分割による断片化は起きにくくなります。単一のデータベースで完結し、IDを外部に出さない業務システムであれば、これを避ける理由はほとんどありません。
ただし右端集中は、裏返せば書き込みが特定のページに集まるということです。毎秒数千件を並行挿入する負荷では、この右端が競合点になります。断片化の回避と書き込みの集中は同じ現象の表と裏です。
限界は2点です。第一に、採番がINSERTの完了を待つため、アプリケーション側でIDを先に決められません。関連する複数レコードを一括で組み立ててから書き込む処理や、クライアント側で生成したIDを使う冪等性とは?読み方・意味からAPI・IaCでの担保方法まで実装者向けに解説で扱うような再送制御とは相性がよくありません。第二に、複数のデータベースインスタンスへ書き込みを分散させると、採番の一意性を別の仕組みで担保する必要が生じます。
UUIDv4のランダム性と索引の断片化
UUIDv4は、バージョンとバリアントを示す6ビットを除く122ビットが乱数です。生成に外部との調整が要らないため、どのプロセスからでも独立して発行できます。
問題は主キーに使ったときの挙動です。RFC 9562自身が「UUID versions that are not time ordered, such as UUIDv4 … have poor database-index locality. This means that new values created in succession are not close to each other in the index; thus, they require inserts to be performed at random locations」と述べています。連続して発行した値が索引上の離れた位置に入るため、挿入のたびにB木の別ページへ書き込みが飛びます。
もう一点、生成器の質が直接効きます。RFC 9562の6.9はCSPRNGの使用をSHOULDとしており、PHPではrandom_bytes()が該当し、mt_rand()やuniqid()は該当しません。
UUIDv7とULIDによる時系列順序の回復
UUIDv7は、先頭48ビットにUnixエポックからのミリ秒を置き、続く4ビットにバージョン値7、12ビットに乱数、2ビットにバリアント、残る62ビットに乱数を配置します。先頭が時刻なので、値をそのまま辞書順に並べるとミリ秒の精度で生成順に並びます。ライブラリを足さずに実装すると次のようになります。
<?php
function uuidv7(): string {
$ms = (int) floor(microtime(true) * 1000); // 48ビットのUnixミリ秒
$bin = substr(pack('J', $ms), 2, 6); // 上位2バイトを捨てて48ビットにする
$bin .= random_bytes(10); // 乱数80ビット(うち6ビットは次行以降で上書き)
$bin[6] = chr((ord($bin[6]) & 0x0f) | 0x70); // バージョン = 0b0111
$bin[8] = chr((ord($bin[8]) & 0x3f) | 0x80); // バリアント = 0b10
$hex = bin2hex($bin);
return sprintf('%s-%s-%s-%s-%s',
substr($hex, 0, 8), substr($hex, 8, 4), substr($hex, 12, 4),
substr($hex, 16, 4), substr($hex, 20, 12));
}
echo uuidv7(); // 019fd224-292d-7b6e-9b2f-7d57af5203f6
出力は36文字、15文字目がバージョン値の7、20文字目のバリアントニブルは8・9・a・bのいずれかになります。1.5ミリ秒の間隔をあけて5件ずつ生成する試行を200回行ったところ、辞書順ソートの結果は毎回そのまま生成順と一致しました。UUIDv4で同じことをすると、まず一致しません。
ただしこの実装には制約があります。同一ミリ秒内に複数発行すると先頭48ビットが同じ値になり、残りは純粋な乱数なので順序が保証されません。遅延を入れずに5件ずつ生成する試行を2,000回繰り返したところ、1,984回で辞書順と生成順がずれました。RFC 9562の6.2も「Take care to ensure UUIDs generated in batches are also monotonic」として、同一タイムスタンプ内の生成順を整理する仕組みを別途求めています。
これを自前で用意する必要はありません。ramsey/uuid 4.9.3のUuid::uuid7()は、内部で「Within the same ms, we increment the rand part by a random 24-bit number」という単調増加を実装済みです。ミリ秒あたり1件を超える頻度で発行するなら、上の最小実装ではなくライブラリを使ってください。
ULIDはUUIDとは別系統の仕様で、48ビットの時刻と80ビットの乱数をCrockford Base32(紛らわしいI・L・O・Uを除いた32文字)で符号化し、26文字になります。UUIDより10文字短く、辞書順ソートに対応する点は同じです。UUIDv7の各言語での生成方法とデータベース性能の実測はUUIDv7とは|UUIDv4との違い・生成方法・DB性能と各言語の実装を解説にまとめてあります。
プレフィックス付きランダム値の運用上の利点
Stripeのpi_やcus_のように、種別を示す接頭辞と乱数を組み合わせる方式です。ログやサポート問い合わせでIDだけを見たときに、それが何のリソースなのかが分かります。
<?php
function publicId(string $prefix, int $randomBytes = 10): string {
$alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; // I,L,O,U を除外
$bits = '';
foreach (str_split(random_bytes($randomBytes)) as $ch) {
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
$out .= $alphabet[bindec(str_pad($chunk, 5, '0'))];
}
return $prefix . '_' . $out;
}
echo publicId('ord'); // ord_6K6VHDYM01FHBRXM
乱数10バイト=80ビットで、生成される文字列は接頭辞込み20文字です。10万件を生成して重複が出ないことを確認しています。時系列順には並ばないため、並び順が必要な場合は作成日時カラムか、内部の主キーにUUIDv7を使う構成にします。
内部主キーと公開IDの分離
ここは設計の分岐点なので、立場をはっきりさせます。外部に出すIDと、データベースの主キーは、分けるほうが後で効きます。
根拠はStripeのAPI仕様です。Stripeは下位互換性のある変更として「オブジェクト ID、エラーメッセージ、その他の人間が解読できる文字列などの opaque 型の文字列の長さまたは形式を変更」を挙げ、そこに「固定のプレフィックス(支払い ID に対する ch_ など)の追加または削除が含まれます」と明記しています。つまり接頭辞は将来変わりうるものとして設計されており、利用側がch_という文字列を見て分岐する実装は、提供側から見れば仕様外の使い方です。接頭辞は人が読むための手がかりであって、プログラムが解釈する構造ではありません。
同じ節でStripeは、利用者に対して「最大 255 文字が含まれることのあるオブジェクト ID をシステムで処理できるように」求め、「MySQL を使用している場合は、ID を VARCHAR(255) COLLATE utf8_bin 列に格納する必要があります」と具体的なカラム定義まで指定しています。COLLATEを指定するのは、検索時に大文字小文字を区別させるためです。
この2点を自社APIに置き換えると、公開IDは長さも形式も後から変えられる不透明な文字列として定義し、主キーは別に持つ、という設計になります。逆に主キーをそのまま公開してしまうと、採番方式を変えたい、テーブルを分割したい、といった内部都合の変更がすべてAPIの破壊的変更になります。主キーの選定が後から効いてくる他の論点はDB設計におけるアンチパターンとは何か?具体的な例とその回避法で整理しています。
UUIDを主キーにするときのデータベース側の実装
方式を決めたら、格納方法で性能が変わります。とくにMySQLは文字列のまま持つか16バイトのバイナリで持つかで差が出ます。
MySQLでのBINARY(16)格納
MySQL 8.4のマニュアルは、InnoDBについて「each record in a secondary index contains the primary key columns for the row」と述べ、続けて「If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key」と明記しています。主キーの幅は、その表に張られたすべての二次索引に掛け算で効きます。
100万行の表に二次索引を3本張った場合、主キーの値はクラスタ化索引と二次索引3本の計4か所に載ります。主キーの幅だけを変えて4か所分を単純合計すると、次のようになります。
| 主キーの型 | 1行あたり | 4か所の合計 |
|---|---|---|
| CHAR(36) utf8mb4 | 144バイト | 549.3MB |
| CHAR(36) ascii | 36バイト | 137.3MB |
| BINARY(16) | 16バイト | 61.0MB |
| BIGINT | 8バイト | 30.5MB |
実際の索引はページの充填率やオーバーヘッドを含むため、この値がそのままディスク使用量になるわけではありません。見るべきは比率です。MySQL 8系の既定文字セットであるutf8mb4のままUUIDを文字列で持つとBINARY(16)の9倍、文字セットをasciiに絞っても2倍以上になります。
変換にはUUID_TO_BIN()を使います。第2引数のswap_flagに1を渡すと、マニュアルのいう「The time-low and time-high parts … are swapped. This moves the more rapidly varying part to the right and can improve indexing efficiency」という並べ替えが行われます。これはUUIDv1のように時刻が下位から並ぶ版を索引向きに直すための引数です。UUIDv7はもともと先頭が時刻なので、swap_flagは0のまま(既定)にします。
CREATE TABLE orders (
id BINARY(16) NOT NULL PRIMARY KEY,
public_id VARCHAR(255) COLLATE utf8mb4_bin NOT NULL,
created_at DATETIME(3) NOT NULL,
UNIQUE KEY uk_public_id (public_id)
);
INSERT INTO orders (id, public_id, created_at)
VALUES (UUID_TO_BIN('019fd224-292d-7b6e-9b2f-7d57af5203f6'), 'ord_6K6VHDYM01FHBRXM', NOW(3));
SELECT BIN_TO_UUID(id) AS id FROM orders;
上のDDLで公開ID列にutf8mb4_binを指定しているのは、Stripeが例示するutf8_binがMySQL 8ではutf8mb3の別名にあたり非推奨のためです。大文字小文字を区別させるという目的は同じです。またMySQLのUUID()関数が返すのはRFC 4122のバージョン1で、時刻が下位から並ぶ古い形式です。UUIDv7が欲しい場合はアプリケーション側で生成します。カラムの型選択という観点はデータベースの属性とは?カラム・項目との違いと設計の基本でも扱っています。
PostgreSQLのuuid型とuuidv7関数
PostgreSQLにはuuid型があり、内部表現は16バイトなのでMySQLのようなバイナリ変換は不要です。2025年9月25日にリリースされたPostgreSQL 18で、リリースノートに「Add UUID version 7 generation function uuidv7() … This UUID value is temporally sortable」と記載されたとおり、データベース側でUUIDv7を生成できるようになりました。同時にバージョン4を明示するuuidv4()が別名として追加され、従来のgen_random_uuid()も引き続き使えます。
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
public_id text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
PostgreSQL 17以前を使う場合、uuidv7()は存在しないため、アプリケーション側で生成した値を渡すか拡張を導入します。バージョンによって使える関数が変わる点は、移行計画で先に確認しておく箇所です。
連番IDの秘匿ではIDORが塞がらない理由
「連番IDは推測されるので、UUIDに変えてセキュリティを高める」という説明が広く流通しています。この説明は、対策として不十分です。
OWASP API Security Top 10 2023の第1位はAPI1:2023 Broken Object Level Authorizationで、その解説は「Object IDs can be anything from sequential integers, UUIDs, or generic strings. Regardless of the data type, they are easy to identify in the request target」と述べています。データ型が何であれ、IDはリクエストの中にあり、攻撃者から見えているという指摘です。UUIDであっても、他人のIDが一度どこかに漏れれば、そのリソースは同じように取得できます。IDの漏出経路は推測だけではなく、共有URL、ログ、リファラ、スクリーンショット、退職者の端末など複数あります。
OWASPの対策項目は、まず「Implement a proper authorization mechanism that relies on the user policies and hierarchy」、次に「Use the authorization mechanism to check if the logged-in user has access to perform the requested action on the record in every function」と続きます。ランダム値の使用は「Prefer the use of random and unpredictable values as GUIDs for records’ IDs」として挙げられていますが、これは認可チェックの代替ではなく、認可の実装を前提としたうえでの追加の一手です。
OWASPはさらに「Write tests to evaluate the vulnerability of the authorization mechanism」として、認可機構そのものをテストで検証することも挙げています。他人のIDを指定したリクエストが拒否されることを、正常系と同じようにテストで固定する、という意味です。
実装上の優先順位は明快です。全エンドポイントでリソース単位の認可チェックを書き、それをテストで守り、そのうえでIDを推測困難な値にします。認可チェックを書かないままUUIDへ移行する変更は、攻撃の難易度をわずかに上げるだけで、脆弱性そのものは残ります。既存システムの改修でどちらを先に着手するか迷う場面では、認可を先にしてください。
よくある質問
Object IDとUUIDは同じものですか?
違います。UUIDはRFC 9562で定義された128ビットの識別子という具体的な仕様名で、Object IDは文脈によって指す対象が変わる呼び方です。MongoDBのObjectIdは12バイトでUUIDの16バイトとは長さも構造も異なり、両者に互換性はありません。API設計の文脈で「オブジェクトIDをどうするか」と言う場合は、UUIDを含む複数の採番方式から選ぶという意味になります。
UUIDは32文字ですか、36文字ですか?
文字列表現は36文字です。RFC 9562の表記は16進8桁、4桁、4桁、4桁、12桁をハイフンでつないだ形式で、16進の合計が32桁、区切りのハイフンが4本、あわせて36文字になります。「32文字」という記述は16進の桁数だけを数えたものです。カラム定義をCHAR(32)にするとハイフンが収まらず値が切れるため、文字列で持つならCHAR(36)、バイナリで持つならMySQLはBINARY(16)、PostgreSQLはuuid型を使います。
UUIDが重複することはありますか?
仕様上の空間ではなく、実装の乱数生成器が原因で起こりえます。UUIDv4はバージョンとバリアントの6ビットを除く122ビットが乱数で、RFC 9562の6.9は「Implementations SHOULD utilize a cryptographically secure pseudorandom number generator (CSPRNG)」としています。この要件を満たさない生成器、たとえばPHPのmt_rand()やuniqid()を使うと、乱数の実効的な幅が122ビットより大幅に狭くなります。PHPマニュアルはuniqid()について「does not guarantee the uniqueness of the return value」と明記しています。重複を避けたい場合、まず確認すべきは自分のコードが使っている乱数源です。
グローバルIDとユニークIDは何が違いますか?
違いは一意性が保証される範囲の広さです。ユニークIDの範囲は多くの場合1つのテーブルや1つのシステムに閉じており、グローバルIDはシステムやデータベースをまたぐところまで広がります。連番はテーブル内でユニークですが、別のデータベースの連番とは衝突するためグローバルではありません。UUIDやULIDは調整なしにグローバルな一意性を狙える方式です。RFC 9562は「true global uniqueness is impossible to guarantee without a shared knowledge scheme」と断ったうえで、同じ文で「a shared knowledge scheme is not required by a UUID to provide uniqueness for practical implementation purposes」と続けています。理論上の絶対保証ではないが実用上は共有の仕組みなしで足りる、という位置づけです。
IDの作り方や決め方が分からないとき、どこから手をつければよいですか?
採番方式そのものは「採番方式の選び方」で示した2つの問い、つまりアプリケーション側で先にIDを決めたいか、時系列順に並べたいか、で絞り込めます。多くの場合ここで詰まる原因は方式選びではなく、決めるべきものが1つだと思い込んでいる点にあります。内部の主キーと、APIやURLに出す公開IDは別々に決められますし、別々に決めたほうが後から変えやすくなります。まず内部の主キーを方式選びの2問で決め、そのIDを外部に見せるかどうかを次に判断してください。見せるなら公開ID列を追加します。