分散ユニークID生成の設計
採番のたびにDBへ問い合わせず、衝突ゼロでソート可能なIDを毎秒数十万件量産できる。Snowflakeの64bit配分・UUID各版・時計逆行対策までを設計面接の解答形式で押さえられる。
- Snowflakeは64bitを「符号1+時刻41+マシン10+連番12」に配分し、時刻上位・単調増加でソート可能。1ノードあたり毎ミリ秒4096個、理論上は全体で毎秒約42億個を採番できる。
- UUIDv4はランダム122bitで調整不要だが単調でなくB木インデックスを断片化させる。時刻先頭のUUIDv7が実務の折衷解で、有序性とグローバル一意性を両立する。
- 時計の逆行はSnowflakeの一意性を壊す最大の敵。NTPの巻き戻し中は採番を待機し、事前割当(segment)方式ならDBの1行更新でID範囲を配り、単一障害点とラウンドトリップを同時に減らせる。
要件と規模の見積もり
分散ユニークID生成器(ticket server)の機能要件は単純で、「重複しない64bit整数を、複数のサーバーから同時に、低遅延で払い出す」ことに尽きます。だが非機能要件がこの問題を面接に値するものにします。第一にグローバル一意性(全ノード・全時刻で衝突ゼロ)、第二に単調増加とソート可能性(新しいIDほど数値が大きい=生成時刻でおおよそ整列する)、第三に高スループットと低遅延(採番のたびに中央DBへ往復しない)、第四に可用性(一部ノードが落ちても採番が止まらない)です。
規模を具体的な桁で見積もります。大規模SNSのタイムライン投稿・いいね・DMなどにIDを振ると仮定し、ピーク書き込みを100,000 QPSと置きます。IDは1件8バイト。ID列そのもののストレージ増加は 100,000 × 8 byte/s = 800 KB/s、1日あたり約69GB、1年で約25TBに達します(本体データではなくID列だけでこの桁)。ここから「採番の速さ」より「IDをインデックスに詰めたときの挙動」が支配的コストだと分かります。ランダムなIDはB木の挿入位置を毎回散らし、ページ分割とキャッシュミスを誘発するからです。ゆえに時刻先頭で単調増加するIDが設計の中心命題になります。
IDは主キー・外部キーとして全テーブルとインデックスに繰り返し格納されます。128bitのUUIDを主キーにすると、8バイト整数の倍のインデックス容量とメモリ帯域を消費します。64bit符号付き整数は多くの言語・DBの BIGINT に自然に収まり、JavaScriptの安全整数(53bit)を除けば扱いも軽い。この「64bitに収める」制約こそがSnowflake設計の出発点です。
大枠の設計
まずAPIとデータモデルを定めます。採番APIは next_id() -> int64 の一つだけ。中央集権DBを唯一の採番元にする素朴案は、UPDATE counter SET val = val + 1 RETURNING val を全採番で実行するもので、強い単調性は得るものの、そのDBが単一障害点かつスループット上限になります。そこで各ノードが自律的に採番する方式(Twitter Snowflake)を大枠に採用します。ビット配分は次のとおりです。
| ビット幅 | フィールド | 意味と範囲 |
|---|---|---|
| 1 bit | 符号ビット | 常に0。負数を避け、単調増加を数値順序と一致させる |
| 41 bit | タイムスタンプ | 独自エポックからのミリ秒。約 2^41 ms ≒ 69年分を表現できる |
| 10 bit | マシンID | 1024個までのノードを識別(データセンタ5bit+ワーカ5bitに分けることも多い) |
| 12 bit | シーケンス | 同一ミリ秒内の連番。0〜4095、つまり1ノードあたり毎ミリ秒4096個 |
この配分から採番能力が逆算できます。1ノードは毎ミリ秒4096個、すなわち毎秒約409万個。マシンIDが1024通りなので理論上限は 4096 × 1000 × 1024 ≒ 4.19 × 10^9(毎秒約42億個)で、先の100,000 QPS要件を4桁上回ります。採番ロジックの擬似コードは次のとおりです。
def next_id():
ts = current_millis()
if ts < last_ts: # 時計が逆行した
wait_until(last_ts) # 逆行が解消するまで待つ(後述)
ts = current_millis()
if ts == last_ts:
seq = (seq + 1) & 4095 # 12bitマスクで巡回
if seq == 0: # 同一msで4096個を使い切った
ts = wait_next_millis(last_ts) # 次のミリ秒までスピン
else:
seq = 0 # ミリ秒が進んだので連番リセット
last_ts = ts
return ((ts - EPOCH) << 22) | (machine_id << 12) | seq
上位ビットほど時刻という配置が肝心です。IDを数値として比較すると、まず時刻、次にマシンID、最後に連番の順で効くため、生成の前後関係がほぼそのまま数値順序になり、DBのB木では常に右端付近へ追記されます。ページ分割がほぼ起きず、ホットな末尾ページだけがキャッシュに乗るため、書き込みが軽くなります。この局所性の効きは、LSMツリーのように追記を前提とする構造とも相性が良い点で、ストレージ実装の議論(LSMツリー、ライトアヘッドログ)にも接続します。
主要コンポーネントの深掘り
マシンIDの割り当てが最初の要所です。同じマシンIDを2ノードが持てば連番空間が衝突し一意性が崩れます。静的な設定ファイルは単純ですが運用でずれます。実務ではZooKeeperやetcdのような合意ストアに一時ノードを作り、IDを排他的にリースするのが定石です(合意アルゴリズムの中身はPaxosとRaftを参照)。10bitで1024ノードという上限は、コンテナが頻繁に入れ替わる環境では意外に窮屈で、後述のトレードオフの対象になります。
UUIDとの比較も面接で必ず問われます。UUIDは調整なしでローカル生成できる128bit識別子ですが、版によって性質が大きく異なります。
| 方式 | 生成方法とビット構成 | 単調性・ソート | 衝突耐性と用途 |
|---|---|---|---|
| UUIDv1 | 48bit MAC+60bit時刻(100ns刻み) | 時刻が下位側に散り、そのままでは非有序 | MAC露出のプライバシー懸念。時刻の並べ替えに要ビット操作 |
| UUIDv4 | 122bitが乱数 | 完全に非有序。B木を断片化させる | 調整不要で最も手軽。分散生成の衝突確率は事実上ゼロ |
| UUIDv7 | 先頭48bitがUnixミリ秒+残りは乱数 | 時刻先頭で辞書順=時刻順に有序 | v4の手軽さと有序性を両立。近年の主キー第一候補 |
| Snowflake | 41bit時刻+10bitマシン+12bit連番 | 上位が時刻で強く単調 | 64bitに収まる。マシンID調整が前提 |
要点は「単調性はビット配置がすべて」という事実です。UUIDv4がインデックスに不向きなのは乱数だからではなく、時刻情報を上位に持たないからです。UUIDv7はまさにこの点を修正し、先頭48bitにミリ秒を置くことでSnowflakeに近い局所性を、マシンID調整なしで得ます。64bitに収める必要がなくグローバルな協調も避けたいならUUIDv7、8バイト厳守で時刻精度と連番の細かな制御が要るならSnowflake、という住み分けになります。
「UUIDはユニークだから主キーに最適」と答えると詰められます。正しくは「v4は一意だが非有序でインデックス性能を劣化させる」。有序性が欲しいならv7かSnowflake、と性質で語れるかが評価点です。ソート可能=時刻情報が上位ビットにある、という一点で整理しましょう。
ボトルネックとトレードオフ
横にスクロール
最大の敵は時計の逆行(clock skew / NTPの巻き戻し)です。Snowflakeの一意性はローカル時計の単調前進を前提にするため、NTP同期で時刻が過去へ飛ぶと、同じ (時刻, マシンID, 連番) を再発行して衝突します。対策は擬似コードのとおり、逆行を検知したら逆行幅を過ぎるまで採番を待機する(短い逆行なら数ミリ秒のブロックで吸収)ことです。逆行が大きい場合は採番を止めてアラートを上げる方が、静かに重複IDを撒くより安全です。実運用では、時刻を1ミリ秒でも巻き戻さないよう単調増加を保証するOSクロック(monotonic clock)を併用します。
次のボトルネックは中央集権DB採番のスループットです。1行のカウンタ更新は強い単調性を与える反面、全採番がその1行のロック争奪に直列化し、可用性もその1台に依存します。これを緩和するのが事前割当(segment / ticket)方式です。各アプリノードがDBから「10001〜20000」のようにID範囲を一括で借り、ローカルではメモリ上のカウンタで配り、使い切る前に次の範囲を非同期で先読みします。DBへの往復はステップ幅分の1に減り、範囲を配る UPDATE segment SET max_id = max_id + step は原子的な1操作で済みます。
| 観点 | DBの都度採番 | 事前割当(segment) | Snowflake(自律採番) |
|---|---|---|---|
| DBラウンドトリップ | 採番ごとに1回(最も重い) | step件に1回(大幅減) | 不要(ローカル完結) |
| 単調性 | 完全にグローバル単調 | 範囲内は単調・範囲間は概ね昇順 | 時刻粒度で概ね単調 |
| 障害時のID欠番 | 起きにくい | ノード停止で借用済み範囲が飛ぶ | 欠番の概念なし |
| 単一障害点 | 採番元DBに依存 | DB停止時も借用済み範囲で延命可能 | 共有状態なしで最も堅牢 |
トレードオフは厳密な単調性と分散性の綱引きに集約されます。全世界で1件たりとも逆転しない厳密単調が要るなら中央採番しかありませんが、可用性とスループットを犠牲にします。Snowflakeやsegmentは「同一ミリ秒内の順序」や「範囲間の順序」を緩め、代わりにローカル採番の速度と耐障害性を得ます。多くのシステムでこの緩和は許容範囲です。ID自体に厳密な全順序を求めず、必要なら別途タイムスタンプ列で並べればよいからです。
さらにスケール時の論点として、採番の負荷分散とマシンID枯渇があります。10bitの1024ノード上限は、オートスケールでコンテナが分単位で生成・破棄される環境では足りなくなりがちで、ビット配分をタイムスタンプ41→39bit・マシン10→12bitのように振り直す(=寿命を縮めて並列度を上げる)判断が要ります。採番ノード群自体を負荷に応じて増減させる設計は、リクエストを均すL4ロードバランサや、範囲リースの整合を保つ一貫性ハッシュといった周辺技術と組み合わせて初めて実運用に耐えます。結局のところ「64bitという固定予算を、時刻の寿命・並列ノード数・毎ミリ秒の採番数へどう三分割するか」が、この設計の本質的な意思決定です。
システム設計の記事ガイド
分散ユニークID生成の設計を実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
Snowflake
比較で見る軸
難易度: advanced / カテゴリ: システム設計 / タグ数: 5
導入後に効く点
UUIDv4はランダム122bitで調整不要だが単調でなくB木インデックスを断片化させる。時刻先頭のUUIDv7が実務の折衷解で、有序性とグローバル一意性を両立する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- システム設計
- タグ数
- 5
判断チェックリスト
- 自社の用途が「Snowflake / UUID」に近いか確認する。
- 強みである「Snowflakeは64bitを「符号1+時刻41+マシン10+連番12」に配分し、時刻上位・単調増加でソート可能。1ノードあたり毎ミリ秒4096個、理論上は全体で毎秒約42億個を採番できる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。