Nano IDが%を使わない理由 — 36文字なら256中112を捨てる
ランダムな ID を自前で作るとき、乱数を1バイト取って alphabet[byte % alphabet.length] と書くのはよくある書き方である。文字が64種類ならこれで問題ないが、62種類や36種類にした途端、一部の文字だけが出やすくなる。いわゆる modulo bias で、ID の予測しにくさがその分だけ下がる。
この記事では、ハシトシステムの NanoID 生成ツール と、npm の nanoid パッケージ(手元の node_modules に入っていた 3.3.11 の index.js)のコードを読み、偏りの大きさと、それを避ける「マスクして範囲外を捨てる」方式の手間を Node.js で数える。
256 を文字数で割り切れないと偏る
1バイトの乱数は 0〜255 の256通りで、どれも同じ確率で出る。これを % n で 0〜n-1 に縮めると、256 が n で割り切れない場合、余りの分だけ小さい番号が1回多く選ばれる。
- 62文字(英大小文字+数字): 256 = 62 × 4 + 8。先頭8文字(A〜H)は5回、残り54文字は4回選ばれる。A〜H は他の文字の1.25倍出やすい。
- 36文字(英小文字+数字): 256 = 36 × 7 + 4。先頭4文字(a〜d)は8回、残りは7回。約1.14倍。
- 10文字(数字のみ): 256 = 10 × 25 + 6。0〜5 は26回、6〜9 は25回。1.04倍。
- 64文字(URL で使える英数字と
_-): 256 = 64 × 4 で割り切れるので偏らない。
nanoid の既定の nanoid() 関数は64文字のアルファベットを使い、pool[i] & 63 で下位6ビットを取り出しているだけで、捨てる処理は無い。64文字だから成り立つ書き方で、これを真似て文字数だけ変えると偏りが入る。
マスクで切り出し、範囲外は捨てる
文字数を自由に選べる customAlphabet は、別の関数 customRandom を通る。該当部分は次のとおりである(nanoid 3.3.11 の index.js から)。
1 | |
mask は 0〜(文字数-1) を覆う最小の「2のべき乗 - 1」である。36文字なら35を覆う63(2進で 111111)になる。バイトを & 63 で 0〜63 に縮めると64通りが均等に出るので、36以上が出たら alphabet[...] が undefined になり、|| '' で何も足さずに次のバイトへ進む。残った 0〜35 は均等なので、偏りは消える。
代わりにバイトを捨てる。36文字なら64通りのうち28通りを捨てるので、1バイトあたりの採用率は 36/64 = 56.25% になる。256通りで数えると112個が捨てられる。
ツール側の maskFor は clz32 を使わず、mask = 1 から始めて mask < n - 1 の間だけ左シフトして1を足す、というループで同じ値を出している。ツールのソースには、条件を mask < n にすると64文字のときに127になり、半分のバイトを捨てることになる、というコメントが残っている。2〜256 のすべての文字数で nanoid の式と値が一致することは、下の手順で確かめた。
step は「1回で足りる」ように多めに取る
捨てる分があるので、21文字の ID を作るのに21バイトでは足りないことが多い。customRandom は1回に step バイトをまとめて取り、足りなければもう一度取る。step は 1.6 × mask × 長さ ÷ 文字数 を切り上げた値で、mask ÷ 文字数 はおおよそ採用率の逆数になる。1.6 はそれに掛ける余裕分である。
36文字・21文字なら ceil(1.6 × 63 × 21 ÷ 36) = 59 バイトになる。59バイトのうち採用されるのは平均で約33個なので、ほとんどの場合1回で21文字がそろう。
実際に試す
Node.js 24.11.1(Windows 11)で実行した。依存パッケージは要らない。次の内容を nanoid-bias.mjs として保存する。
1 | |
1 | |
出力(下の4行は乱数を使うので、実行のたびに少し変わる):
1 | |
% の列では、64文字以外のすべてで最小と最大が1回ずれている。& mask の列はどの文字数でも最小と最大が同じで、その代わりに右端の数だけバイトを捨てている。36文字では256通り中112通り、10文字では mask が15なので96通りを捨てる。
step 分のバイトで1回のうちに21文字がそろう割合は、10万回の試行で99.9%を超えた。そろわなかった残りは、customRandom がもう一度 step バイトを取って続きを埋める。
ツールのループ版のマスクが nanoid の式と一致するかは、次の1行で確かめられる。
1 | |
1 | |
長さと文字数で衝突しにくさが変わる
偏りを消しても、ID の強さは「文字数の長さ乗」で決まる。1文字あたりのビット数は log2(文字数) で、21文字なら64文字で126ビット、36文字で約108.6ビット、10文字では約69.8ビットになる。ツールが使う誕生日問題の近似 sqrt(2 × N × ln(1/(1-p)))(N は文字数の長さ乗)で、衝突確率が1%に達する個数を出すと次のとおりである。
| 文字数 | 長さ | ビット数 | 1%で衝突する個数 |
|---|---|---|---|
| 64 | 21 | 126.0 | 約 1.31 × 10^18 |
| 62 | 21 | 125.0 | 約 9.37 × 10^17 |
| 36 | 21 | 108.6 | 約 3.11 × 10^15 |
| 10 | 21 | 69.8 | 約 4.48 × 10^9 |
数字だけの21桁は、約45億個で衝突確率が1%に届く。文字の種類を減らしたら、そのぶん長さを足す必要がある。
実装するときの注意
byte % 文字数で文字を選ばない。64、32、16 のように256を割り切る文字数でない限り、先頭側の文字が出やすくなる。- マスクは「文字数-1 を覆う最小の 2^k - 1」にする。大きすぎるマスクは偏りこそ生まないが、捨てるバイトが増えて遅くなる。
- 捨てる前提でバイトを多めに取る。
nanoidは1.6 × mask × 長さ ÷ 文字数を1回分として取り、足りなければ取り直す。 - 乱数源は暗号論的なものに限る。ハシトシステムのツールは
crypto.getRandomValuesが使えない環境では生成せず、その旨を表示する。Math.randomに黙って切り替えると、偏りを消しても予測できる ID になる。 - アルファベットに同じ文字を2回入れない。ツールは重複を落としてから使う。重複があると、その文字だけ2倍出やすくなる。
まとめ
- 1バイトを
% 62で文字にすると、先頭8文字が他の1.25倍出る。36文字なら先頭4文字が約1.14倍になる。 nanoidのcustomAlphabetは、0〜(文字数-1) を覆う最小のマスクで切り出し、範囲外を捨てて偏りを消す。36文字なら256通り中112通りを捨てる。- 捨てる分を見込んで1回に
ceil(1.6 × mask × 長さ ÷ 文字数)バイトを取る。36文字・21文字なら59バイトで、10万回中99.9%以上が1回で足りた。
コードは nanoid の GitHub リポジトリ で読める。ブラウザの乱数 API は MDN の Crypto.getRandomValues() を参照。ほかの ID 形式は「ULIDをブラウザで生成する」「UUID v7を自前実装する」「Snowflake ID を BigInt で分解する」も参照。
ツール: NanoID 生成(ハシトシステム)。長さと使う文字を選ぶと ID を生成し、その組み合わせで衝突確率が1%に達する個数と、指定した頻度で作り続けた場合の年数を出す。
このバージョンはその範囲に入るのか
依存の話でいちばん間違えやすいのは ^1.2.3 や ~1.2.3 がどこまでを許すかです(^0.2.3 のようにメジャーが 0 のときは規則が変わります)。範囲とバージョンを貼ると、下限・上限に展開したうえで一致・不一致とその理由を表示するsemver 範囲判定ツールを置いています。ブラウザの中だけで動き、入力はどこにも送信しません。