ちりもつもればミルキーウェイ

好奇心に可処分時間が奪われる

グローバルなid生成器(UUID v4とか)の比較

はじめに

分散システムやってるとどこからでも安全に採番できる強い衝突耐性をもったuuid的なほげほげidがほしくなります。

世にほげほげidはたくさんありますが、適当にREADMEとかよんでもみんな「これイケてるで!」「uuidとかより文字数すくないで!」「sortableでうれしいで!」とかそういうことばっかいっててどの程度衝突耐性があるのかよくわからん問題があります

今回はほげほげidの構成要素の調査と衝突耐性をざっとまとめて比較しようと思います。

比較する項目

ざっくり

  • random bit数
  • 衝突耐性
  • ソートできるか
  • その他メモ

みたいな感じで整理して比較していきます。

衝突耐性は

  • 衝突確率P=0.5のときの必要な試行回数
  • 乱数以外の識別要素

の2つで評価します。たとえばtimestampとか入れてるやつは暗号学的な安全性には寄与しないけど実用上はある精度の時刻で識別できて衝突耐性上がってるよな〜とかそういう感じです。利用される疑似乱数は暗号学的に理想的なものを想定します。

衝突確率は Birthday problem をみてみると以下で近似値を求められるっぽい。

1-e^{-n ^ 2/ (2d) }

これで計算すれば衝突確率P=0.5のときの試行回数nがわかりそうなんだけど、少しでも計算サボりたいからもうちょい簡単なのを探すと Auth0にいい記事を見つけた。ざっくりP=0.5なら

2^{n/2}

が試行回数らしい。簡単じゃん。記事で紹介されてる内容もわかりやすいしこれでいこう。

比較対象は適当に選びました。僕がこういう系の文脈のプログラムはGoで読み書きするのに慣れてるので、信頼できるGo実装があれば元ネタ言語がなんであれGo版読んでるのであしからず

TL;DR

せっかちな人のための比較表だけ先にあげとく

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
UUID v4 2122 261 ≒ 230京 なし No hexと区切りで36文字
ULID 280 240 ≒ 1兆 msec精度timestamp Yes base32で26文字
UUID v7 274 237 ≒ 1374億 msec精度timestamp Yes hexと区切りで36文字, draft中なので参考程度に
nanoid 2126 263 ≒ 920京 なし No base64で21文字
xid(mongo object id) 224 212 = 4096 sec精度timestamp, プロセス単位 Yes base32で20文字
Snowflake なし 衝突しない シーケンシャルid, machine id Yes 事前登録したworkerでのみ採番可

適当に目的にあったものを使えばいいんじゃなかろうか。今後しらない未知のほげほげIDに出くわしても同様の軸で評価できそう。

あとはbyteサイズとかも評価項目に入れても良かったけど、まあだいたい16byte前後とかだから表にいれるのはサボった。それぞれ詳細に見るとこではちゃんとbyte数も調べてるよ。

UUID v4

とりあえず充分ランダムな衝突耐性の強いidという文脈でこいつを避けては通れないのでやっていく

RFC4122ちゃんと読んだことなくてせっかくなので全部読んだ。ざっくりuuidのバージョンごとの仕様をまとめると

  • v1
    • 60bit timestamp
      • UTC 1582 October 15 からの経過時間で100ナノ秒精度。unix epochと全然関係ないので注意
    • 4bit version
    • 14bit sequence(重複排除のため)
    • 2bit variant(レイアウト指定で後方/前方互換のために4パターンある)
    • 48bit IEEE 802 MAC
  • v2
    • DCE Security versionでPOSIXとかでつかわれてるっぽい。RFCからは詳細不明
  • v3
    • 何らかの手段で得た一意なnameをmd5ハッシュにかけて16byteとる
      • DNSやらURLなりを使ってもよいが、このRFCでは任意の名前空間からどうやって一意の名前を取るかには言及せんでみたいな記載がある
      • 君だけの最強のnameを作ろう
    • versionとvariantの位置だけ上書き
    • v1とくらべると、versionとvariant以外は全部ハッシュ値やで!というかんじ
  • v4
    • 16byte random値とる
    • versionとvariantの位置だけ上書き
    • v1とくらべると、versionとvariant以外は全部乱数 or 高精度疑似乱数というだけ
    • 122bitが乱数なので、理想的な暗号学的な乱数を想定すると衝突確率P=0.5のとき 261 の衝突耐性がある
  • v5

という感じ。v1はMACアドレス依存だしシーケンスつくるのにlock必要だしたいへ〜んという感じでござった

v3, v5はベースとなるnameの一意性の担保は自分でやらなきゃいけないので、イメージとしてはすでに一意なものをuuid形式にできるぞくらいの認識が良さそう。

というわけでなにも考えずにグローバルで一意なIDを採番できるのはuuid v4だけで、その評価は以下の感じである

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
2122 261 ≒ 230京 なし No hexと区切りで36文字

uuid v4 にまつわるよくある課題に触れておくと、b-tree index とかとそこそこ相性がわるいことが挙げられる。

ざっと説明するとuuid v4は毎回ランダムに割り当てられるから時間的局所性がなくて、primary keyとかにuuid v4つかうと連続して書き込まれるデータがindex上はあっちこっちに行ったりするので、書き込み時に触らなきゃいけないディスクがランダムアクセスになりがちでパフォーマンスによくない。時間的局所性があるとOS側で一定まとめてシーケンシャルアクセスにしたりしてくれるんだけどね。

逆にこの時間的局所性がない特徴はシャーディングとかのキーにするには非常に向いている。なんも気にせずに勝手に理想的に散らばるはずなので。
たとえばspannerとかはkeyをuuidにすることを推奨してたはずだけど、あれは裏でkeyを引っ掛けて物理的に分散させてデータ配置させてるからなはず。

ULID

uuid v4 はsortableじゃなくて困り事があったので、timestamp bitsを前半に入れてsortableにしちゃおう!というやつ

byteレイアウトを発見したのでそのまま貼る

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                      32_bit_uint_time_high                    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|     16_bit_uint_time_low      |       16_bit_uint_random      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       32_bit_uint_random                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       32_bit_uint_random                      |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

要は

  • 全16byte(uuidとおなじ)
  • 48bit unixtime timestamp(msec精度)
  • 80bit random

というだけでめちゃめちゃ単純。脳筋sortableや!

encodingもuuidよりコンパクトで、uuidはhexとブロックごとのハイフンで36文字?とかだけどこっちはbase32で26文字っぽい。

とはいえ容量が重要ならencodeせずにバイトで書き込めばいいのでencodingの容量は個人的には正直そんなに気にならない。

random bitがuuid v4比較で40bitすくない。そのかわりuuid v4が未来永劫に渡って衝突耐性を持たなきゃアカンのにくれべて、こっちはmsec精度の衝突耐性でよい。まあ短時間にアホほどid発番しなければ実用上問題になることはないだろう。

これも評価すると

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
280 240 ≒ 1兆 msec精度timestamp Yes base32で26文字

UUID v7

IETFにDraftがでてる。2020 Febから出てて現在version 3まででてる。最新は今年の3月にでた
https://datatracker.ietf.org/doc/draft-peabody-dispatch-new-uuid-format/

v6, v7, v8 が提案されてるけど、v7 がお気に入りなのでこれだけ紹介

version, variantはいままでのuuidとおなじ。これもレイアウトあるのでペタリ

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

ようは

  • 48bit timestamp
  • 74bit random

というつくり。やりたいことはほぼulidとおなじだけど、uuidは互換のためにversionとveriantが必要なのでそのぶん6bit randomが小さいというかんじ。

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
274 237 ≒ 1374億 msec精度timestamp Yes hexと区切りで36文字, draft中なので参考程度に

これだけみるとulidのほうが良さげに見えるけど、もしdraftが通れば正式にuuidのように振る舞えるので過去採番したuuid資産があるなら途中からしれっとv7に変えたりできる。(エンコードされた文字列の形式とかはコンパチなはずなので)

そうかんがえるとこいつも悪くない選択肢に思える。はよ標準化されてたも

余談だけどdraft1とかの段階ではtimestampがいくつかの選択肢から精度が選べて可変でめんどかったり、timestamp単位の一意性の担保のためにシーケンスがあったり(なのでuuid v1とおなじでlockが必要だった)で実装が面倒そうな仕様だったんですが、draft重ねるうちに整理されてってdraft3の段階ではめちゃめちゃコンパクトないい感じの仕様になっててにっこりした

nanoid

ちゃんとしたspecは調べた感じみあたらなかったが、uuid相当のrandom bit(126bit)を備えつつ、encodingもbase64にして文字数節約!みたいな雰囲気っぽい。

126bitってめちゃめちゃ半端でバイト単位で考えて16byteあるとするとあと2bitあまるんですが、レイアウトがどこにも書いてないのでわからん。実装もjs読み慣れてないのでよくわからぬ。けどbase64で21文字ってことは 21 * 6 = 126 で計算あうので、多分126bitサイズであってるんだろう。で中身は全部random値ですと言う感じっぽい。

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
2126 263 ≒ 920京 なし No base64で21文字

まあこれだけみるとコンパクトだしuuidよりrandom bit多いしと思うが、さっきも言ったようにuuidは数bit無駄にして互換性をとることを重視してるので一概にどっちがいいとは言えん感じ。

xid(mongo object id)

spec: 独自実装なのでなし(byte表現はmongoのobject idなるものとコンパチっぽい。こまかいフォールバック先の挙動はことなるんだろうが https://www.mongodb.com/docs/manual/reference/method/ObjectId/ )
実装: https://github.com/rs/xid

文字表現はmongo object idはhexで24文字で、xidはbase32で20文字のよう。

レイアウトは

  • 全12byte
    • 4byteがtimestamp
    • 3byteがhostnameのmd5ハッシュ値(とれなかったらcrypto/randから3byte)
    • 2byteがPID
    • 3byteがcrypto/randから
  • String() はbase32でencodeして20文字

というかんじ。実装はこのあたり

https://github.com/rs/xid/blob/5cbb911d27d5efc5f0be784aac766db82ebd067f/id.go#L146-L164

面白いところとしては、暗号学的な乱数からは3byteしかとってないこと。
timestampとかprocess単位のunique性があるから実用上は問題なさそうだけど、同一マシンで単位時間だと衝突確率P=0.5のときに212くらいの採番能力てことで、わりとぶつかりそうで結構強気。
サイズも12byteしかなくてわりかしコンパクトである。

32bit unix epochをもってるからsortableで、かつprocessやマシンを区別してユニークということっぽい。

sortableにしたいのはわかるけど、マシンとかプロセスの情報埋め込むのよくわかんない

元ネタがmongodbみたいな分散ストレージの文脈で生まれてるから、どこで採番されたのかわかるとデータの物理的な配置を考えるときに都合良かったのかな?よくわからん

1秒単位で同一マシンにてめちゃくちゃid採番したい!みたいなユースケースではそこそこ衝突すると思います。同一マシン同一プロセス単位時間内なら 224 の空間しかないのでそこそこぶつかりうる

ちゃんとREADMEにも暗号学的に安全じゃないからそのへん気にするなら /dev/urandom なり crypto/rand つかえよって書いてある

Xid is dependent on the system time, a monotonic counter and so is not cryptographically secure. If unpredictability of IDs is important, you should not use Xids. It is worth noting that most other UUID-like implementations are also not cryptographically secure. You should use libraries that rely on cryptographically secure sources (like /dev/urandom on unix, crypto/rand in golang), if you want a truly random ID generator.

最終的な評価はこんなかんじ!

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
224 212 = 4096 sec精度timestamp, プロセス単位 Yes base32で20文字

Snowflake

本元みるかぎりなんかもうちょっと整理して改めてopen sourceにするよみたいなこといってるので、現段階で評価するのはあんまフェアじゃない気もするけど個人的なメモなので無視しておく(インフラ依存があるみたいな言及が気になる) https://github.com/twitter-archive/snowflake

公式なスペックが見当たらなかったのでとりあえずannounceをみる https://blog.twitter.com/engineering/en_us/a/2010/announcing-snowflake

これみるかぎり、twitterが使うためにつくったやつなので達成したい課題は結構twitterならではみたいな雰囲気ある

  • 高可用性
  • Sortable
  • 秒間数万opに耐える
  • 採番したidは64bit以下(twitter内の互換の都合上)

uuidは128bitだからつかえんし、zookeeperとかも検討したけど性能要件みたせなかったでとのこと

構成としては

  • timestamp
  • ワーカー番号
  • シーケンス番号

でなりたってるっぽい。シーケンスがあるってことはシーケンスを管理する主体者が必要なはず。それでインフラ依存があるみたいな話か

announceには具体的なレイアウトの記載がない!

当時の実装 読もうとしたけどScalaは慣れてないので、READMEサクッと読むことにする

  • 41bit timestamp
  • 10bit machine id
  • 12bit シーケンス(都度rolloverで、同一timestamp内でrolloverを保護する機能つきとのこと)

ほんでクロック同期をNTPにたよってるとのこと。NTPくん整合性担保の文脈ではガンガンズレるしいい印象がまったくないが、ID採番の文脈なら許容できるぞということかな(多少時系列崩れた採番してもtransaction管理してるわけでもなしに、大きな影響ないってことだろう)

これシーケンスの採番の仕組みがイマイチわかってないが、慣れないScalaコードをチラ見した感じだとclass内部にメンバとして持ってるっぽいから、オンメモリで管理してるオーラを感じる。

事前にworkerとしてserverを登録する仕組みで、worker同士は10bit machine idで区別されるから、それぞれのworkerで好き勝手シーケンシャルidを発行してもぶつからない!ってことだな。でド頭のtimestampでそれらは大まかなソートが可能!的な

まあ8byteの大きさでtwitterレベルの書き込み量に対して一意である!というのが要件として厳しいのでシーケンシャルに頼らざる得なくて、とはいえ1台しか採番できないとスループット追いつかないからmachine識別子も持たせてスケールできるようにしたって感じかな

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
なし 衝突しない シーケンシャルid, machine id Yes 事前登録したworkerでのみ採番可

まとめ

いろいろ見たので比較表をつくると以下の感じ

random bit P=0.5の試行回数 乱数以外の識別要素 Sortable Note
UUID v4 2122 261 ≒ 230京 なし No hexと区切りで36文字
ULID 280 240 ≒ 1兆 msec精度timestamp Yes base32で26文字
UUID v7 274 237 ≒ 1374億 msec精度timestamp Yes hexと区切りで36文字, draft中なので参考程度に
nanoid 2126 263 ≒ 920京 なし No base64で21文字
xid(mongo object id) 224 212 = 4096 sec精度timestamp, プロセス単位 Yes base32で20文字
Snowflake なし 衝突しない シーケンシャルid, machine id Yes 事前登録したworkerでのみ採番可

個人的なまとめとしては

  • sortableを重視するなら現状ではULID押し
    • UUID v7が今のすがたでdraft通ったらそっちを使いそう
  • シャーディングのkeyにしたいとか分散しててほしい意図があるなら UUID v4 押し
    • nanoidもいいけど有効bitそんなに変わらん
    • 今後別のversionの UUID 使いたくなったときヌルっと変えれたほうがうれしい
  • エンコーディング後の文字が少ないことを売りにしてるやつが意外とおおい
    • 個人的には容量がクリティカルな領域ではbyte表現でそのまま書き込めばいいと思ってるのであまり引かれない
    • ただ、外部APIの冪等性担保のための識別子として字数制限がある場合とかもあるので、そういうときはうれしそう
      • とはいえUUIDでもbase64なりすれば22文字とかになりそうなのであんまりかわらんが
  • sortableなやつはたいていtimestampを入れるかわりにrandom bitを減らしてるっぽい
    • 単位時間でハチャメチャ採番するusecaseだとrandom bitの数見て単位時間に何件くらいなら衝突しないとみなせるのか計算してから採用するのをおすすめ
    • まあ実用上問題になることはないとおもうが、CPUぶん回して採番の向こう側に行く使い方なら計算しとけ