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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// snowflake.mjs — Node.js 18 以降(BigInt と Date だけで動く。依存なし)
const EPOCHS = { discord: 1420070400000n, twitter: 1288834974657n };

export function decode(idStr, epoch) {
const id = BigInt(idStr); // 文字列のまま BigInt にする(Number を経由しない)
const ms = (id >> 22n) + epoch; // 上位42ビット + エポック = Unix ミリ秒
return {
iso: new Date(Number(ms)).toISOString(),
bits17to21: Number((id >> 17n) & 0x1fn), // Discord: worker / Twitter: datacenter
bits12to16: Number((id >> 12n) & 0x1fn), // Discord: process / Twitter: worker
sequence: Number(id & 0xfffn),
};
}

export function rangeAt(isoTime, epoch) {
const ms = BigInt(Date.parse(isoTime));
const lo = (ms - epoch) << 22n;
const hi = lo + (1n << 22n) - 1n;
return { lo: lo.toString(), hi: hi.toString() };
}

const sample = "175928847299117063"; // Discord 公式ドキュメントの例
console.log("decode:", decode(sample, EPOCHS.discord));
console.log("Number 経由:", Number(sample), "安全な整数か:", Number.isSafeInteger(Number(sample)));
console.log("Number 経由で壊した ID:", decode(String(Number(sample)), EPOCHS.discord));
console.log("JSON.parse:", JSON.parse('{"id":175928847299117063}').id);
console.log("range 2016-04-30T11:18:25.796Z:", rangeAt("2016-04-30T11:18:25.796Z", EPOCHS.discord));

実行します。

1
node snowflake.mjs

実際の出力です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
decode: {
iso: '2016-04-30T11:18:25.796Z',
bits17to21: 1,
bits12to16: 0,
sequence: 7
}
Number 経由: 175928847299117060 安全な整数か: false
Number 経由で壊した ID: {
iso: '2016-04-30T11:18:25.796Z',
bits17to21: 1,
bits12to16: 0,
sequence: 4
}
JSON.parse: 175928847299117060
range 2016-04-30T11:18:25.796Z: { lo: '175928847298985984', hi: '175928847303180287' }

読み取れることは3つです。

  • 1行目の日時 2016-04-30T11:18:25.796Z は、Discord のドキュメントに載っている値と一致します
  • Number を通すと末尾が ...063 から ...060 に丸められ、日時は同じなのにシーケンスが 7 から 4 に変わります。JSON.parse でも同じ丸めが起きます
  • 最後の行の範囲 175928847298985984〜175928847303180287 に、元の ID 175928847299117063 が含まれています。ページングで「この時刻以降」を指定するときの下限にはこの lo を使えます

ブラウザで同じことを確かめるなら、Snowflake ID デコーダ で「サンプルを入れる」を押すと、この例 ID の日時と64ビットの内訳が表示されます。計算はすべてブラウザ内で行われ、ID はサーバーに送られません。UUID や ULID のように時刻を含む別形式の ID は、UUID 判定・解析や ULID 生成で扱えます。

参考にした一次情報