Snowflake ID を BigInt で分解する、Discord と X のエポックとビット名の違い
Discord のメッセージや X(旧 Twitter)の投稿には、175928847299117063 のような長い整数の ID が付いています。これは Snowflake ID と呼ばれる形式で、上位ビットに作成時刻が埋め込まれているため、ID だけから「いつ作られたか」を復元できます。
hashitosystem の Snowflake ID デコーダ は、この分解をブラウザ内の JavaScript(BigInt)だけで行うツールです。この記事では、ツールが内部でやっている計算を Node.js で再現しながら、実装で踏みやすい2つの落とし穴を一次情報と突き合わせて整理します。
Snowflake ID の64ビットの内訳
Discord の公式ドキュメント(API Reference の Snowflakes の節)は、64ビットの内訳を次のように定めています。
| ビット位置 | ビット数 | 中身 | 取り出し方 |
|---|---|---|---|
| 63〜22 | 42 | Discord エポックからの経過ミリ秒 | (snowflake >> 22) + 1420070400000 |
| 21〜17 | 5 | Internal worker ID | (snowflake & 0x3E0000) >> 17 |
| 16〜12 | 5 | Internal process ID | (snowflake & 0x1F000) >> 12 |
| 11〜0 | 12 | そのプロセスで ID を発行するたびに増える値 | snowflake & 0xFFF |
エポックは「そのサービスが0ミリ秒とみなす時刻」です。Discord は2015年最初の瞬間、Unix ミリ秒で 1420070400000 です。同じドキュメントには、例として 175928847299117063 が 2016-04-30 11:18:25.796 UTC になることが載っています。
Snowflake の元祖である Twitter の実装(twitter-archive/snowflake、2021年9月にアーカイブ済み)では、IdWorker.scala にエポックが twepoch = 1288834974657L と書かれています。エポックを取り違えると、復元した日時が年単位でずれます。ツールでサービスを選ばせているのはこのためです。
落とし穴1: Number を1度でも通すと ID が壊れる
JavaScript の Number は倍精度浮動小数点数で、誤差なく表せる整数は Number.MAX_SAFE_INTEGER(2^53 - 1 = 9007199254740991)までです。Snowflake ID は現在これを大きく超えています。
Discord のドキュメントも、Snowflake は最大64ビットなので、一部の言語で整数があふれないよう HTTP API では常に文字列で返すと明記しています。つまり受け取った文字列を Number() や parseInt() に通した時点で、下位の桁が丸められます。
厄介なのは、丸めで壊れるのは下位ビットなので、日時は正しく見えてしまうことです。後の「実際に試す」で確かめますが、公式ドキュメントの例 ID を Number 経由にすると、日時はミリ秒まで一致したまま、シーケンス(下位12ビット)が 7 から 4 に変わります。「日時が合っているから大丈夫」とは判断できません。ID を比較やキーに使うなら、文字列か BigInt のまま扱います。
同じ理由で、JSON.parse に数値のまま ID が入った JSON を渡しても丸められます。Discord の API が文字列で返すのは、この事故を防ぐためです。
落とし穴2: 同じビットでも Discord と旧 Twitter で呼び名が違う
下位22ビットの分け方(5ビット + 5ビット + 12ビット)は両者で同じですが、名前の付け方が違います。
| ビット位置 | Discord の呼び名 | 旧 Twitter の実装での呼び名 |
|---|---|---|
| 21〜17 | Internal worker ID | datacenter ID |
| 16〜12 | Internal process ID | worker ID |
| 11〜0 | Increment | sequence |
Twitter の IdWorker.scala は workerIdShift = sequenceBits(12)、datacenterIdShift = sequenceBits + workerIdBits(17)と定義しているので、ビット17〜21が datacenter、12〜16が worker です。一方 Discord は17〜21を worker と呼んでいます。
「worker ID を出して」と言われたとき、どちらの定義で読むかで答えるビットが変わります。ツールの表示は Discord の呼び名(ワーカー ID・プロセス ID)に合わせていますが、X の ID を読むときはこの対応表で読み替えてください。
なお、旧 Twitter の README ではタイムスタンプを41ビット、マシン ID を10ビット、シーケンスを12ビットとしています。タイムスタンプを置く位置(timestampLeftShift = 12 + 5 + 5 = 22ビットの左シフト)は Discord と同じです。
ツールの実装: 22ビット以外は推測で分解しない
Snowflake ID デコーダ は、入力を正規表現で数字だけか確かめてから BigInt に変換し、64ビット(18446744073709551615)を超える値は弾きます。日時は id >> shift にエポックを足して求めます。
シフト幅は入力できるようにしてありますが、下位ビットの内訳(ワーカー・プロセス・シーケンス)はシフト幅が22のときだけ表示します。Snowflake 風の ID を独自に採番しているサービスでは下位ビットの割り当てがまちまちなので、22以外では下位ビットをひとかたまりの値として出し、推測で分解しません。
逆方向の「日時から ID の範囲を求める」機能もあります。Discord のドキュメントは、ページングで特定の時刻以降を取りたいときに (timestamp_ms - DISCORD_EPOCH) << 22 で ID を作れると書いています。ツールはその値を最小値、下位22ビットをすべて1にした値を最大値として、その1ミリ秒に発行されうる ID の範囲を出します。
実際に試す
前提: Node.js 18 以降(BigInt と Date だけを使い、外部パッケージは不要)。筆者は Node.js v24.11.1(Windows 11)で実行しました。
次のファイルを snowflake.mjs として保存します。
1 | |
実行します。
1 | |
実際の出力です。
1 | |
読み取れることは3つです。
- 1行目の日時
2016-04-30T11:18:25.796Zは、Discord のドキュメントに載っている値と一致します Numberを通すと末尾が...063から...060に丸められ、日時は同じなのにシーケンスが 7 から 4 に変わります。JSON.parseでも同じ丸めが起きます- 最後の行の範囲
175928847298985984〜175928847303180287に、元の ID175928847299117063が含まれています。ページングで「この時刻以降」を指定するときの下限にはこのloを使えます
ブラウザで同じことを確かめるなら、Snowflake ID デコーダ で「サンプルを入れる」を押すと、この例 ID の日時と64ビットの内訳が表示されます。計算はすべてブラウザ内で行われ、ID はサーバーに送られません。UUID や ULID のように時刻を含む別形式の ID は、UUID 判定・解析や ULID 生成で扱えます。
参考にした一次情報
- Discord Developer Documentation, API Reference「Snowflakes」: https://docs.discord.com/developers/reference
- twitter-archive/snowflake(README と
IdWorker.scala): https://github.com/twitter-archive/snowflake/tree/snowflake-2010 - hashitosystem「Snowflake ID デコーダ」の実装(
public/tools/snowflake/index.html)