1591 字
8 分鐘
當雪花 ID 飄過 2⁵³:一場表單集體失蹤事件

事發當天#

某天,表單系統開始「鬧鬼」。

使用者回報:不能申請、不能簽核、連點開以前的表單都直接 404。但詭異的是——不是全部壞掉,是「有些人有事、有些人沒事」,而且越晚建立的單越容易出問題。

聽起來像隨機,其實一點都不隨機。這是一個藏在數字裡、慢慢逼近的定時炸彈。

兇手:一個你以為很大、其實沒那麼大的數字#

後端的主鍵用的是 Snowflake ID(雪花演算法)——一個 64 位元的整數,長這樣:

1739284756392834561

19 位數,看起來很安全對吧?問題是,這個 id 傳到前端後,是被 JavaScript 的 Number 接住的。

而 JavaScript 的 Number 是 IEEE 754 雙精度浮點數,它能「精準」表示的整數上限是:

Number.MAX_SAFE_INTEGER // 9007199254740991 ← 也就是 2⁵³ - 1

9007199254740991 是 16 位數。我們的雪花 ID 是 19 位數

超過這條線之後,JS 不會報錯,它會默默地四捨五入

9007199254740992 === 9007199254740993 // true 😱

沒看錯。在 JS 眼裡,這兩個不同的數字是「相等」的,因為它已經沒有足夠的精度去區分它們了。

最詭異的線索:只有「奇數」的單壞掉#

排查過程中還有一個讓人一頭霧水的現象——壞掉的單清一色是奇數 id,偶數的單全都好好的

一開始覺得這也太玄了,後來想通就笑了。這其實是浮點數精度最赤裸的證據。

先看 JS 的 Number 在記憶體裡長怎樣#

Number 是 64 位元的雙精度浮點數(IEEE 754),記憶體佈局是:

[ 1 bit 符號 ][ 11 bit 指數 ][ 52 bit 小數 ]
↑ 加上一個隱含的前導 1,等於 53 bit 有效精度

關鍵在於:指數決定數字「多大」,那 52(53) bit 決定在這個大小下能「切多細」。 有效位數是固定的 53 bit,一旦整數需要超過 53 bit 才寫得完,低位就沒有 bit 可以放,只能被丟掉。

間隔為什麼會「每跨一個次方就翻倍」#

可表示整數之間的最小間隔(ULP,unit in the last place)有個乾淨的公式:

間隔 = 2^(指數 − 52)

數字每翻一倍(指數 +1),高位就得多吃一個 bit,低位就被擠掉一個 bit,於是間隔跟著 ×2:

數值範圍指數間隔 = 2^(指數−52)結果
0 ~ 2⁵³≤521每個整數都能精準表示 ✅
2⁵³ ~ 2⁵⁴532只剩偶數,奇數被吸到鄰近偶數
2⁵⁴ ~ 2⁵⁵544只剩 4 的倍數,其餘對齊到最近的 4
2⁵⁵ ~ 2⁵⁶558只剩 8 的倍數…

注意「對齊」是四捨五入到最近的格子點(IEEE 754 預設「就近取偶」),不是一律進位——有的往下、有的往上。

回到奇數#

奇數的最後一個 bit 一定是 1。當 id 落在 2⁵³ ~ 2⁵⁴ 這段、間隔正好是 2 時,那個 1 沒有格子可放,就被抹成最近的偶數:

9007199254740993 // 你以為的奇數 id
// → 實際存進變數的是 9007199254740992,被改成偶數了

偶數剛好踩在格子上、毫髮無傷;奇數卡在格子中間,全軍覆沒。「只有奇數壞掉」不是隨機災情,而是間隔 = 2 的必然結果。再往上飆過 2⁵⁴,間隔變 4,連一半的偶數都要跟著陪葬。😆

災難是怎麼發生的#

把上面的事實串起來,整個慘案就清楚了:

  1. 後端產生 id:...834561
  2. 透過 JSON 傳到前端,JSON.parseNumber 接 → 尾數被磨掉,變成 ...834560
  3. 使用者操作後,前端把這個「被竄改過」的 id 傳回後端
  4. 後端拿 ...834560 去查 → 查無此單 → 404 / 簽核失敗 / 申請噴錯

最陰險的地方是:後端完全無辜,資料也完全正確。資料是在「前端用 Number 接住的那一瞬間」就被悄悄改掉的,沒有任何例外、沒有任何 log 哀號。

至於為什麼「越晚的單越容易中」?因為雪花 ID 的高位是時間戳,時間往前走,數字就越大。一旦越過 2⁵³ 這條線,新生成的 id 就全員中招。它不是突然壞掉,是「準時」壞掉。

解法:別用數字接,用字串#

修法意外地樸素——把 id 當字串傳,全程不要碰 Number

後端序列化時,把 long 轉成 string 再吐給前端。以 .NET / System.Text.Json 為例:

public class LongToStringConverter : JsonConverter<long>
{
public override long Read(ref Utf8JsonReader reader, Type t, JsonSerializerOptions o)
=> long.Parse(reader.GetString()!);
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions o)
=> writer.WriteStringValue(value.ToString()); // 1739284756392834561 → "1739284756392834561"
}

前端從頭到尾把 id 當成不透明的字串看待——不做數學運算、不比大小、只拿來查資料。字串沒有精度問題,尾數一個都不會少。

意外學到的一課#

最有意思的是——這個坑我本來「應該」要早就知道,卻因為運氣好一直沒踩到。

我以前碰的系統,主鍵都是用 GUID。GUID 長這樣:

3f2504e0-4f89-41d3-9a0c-0305e82c3301

它天生就是字串,所以我從以前就習慣用 string 接 id,從來沒機會遇到 Number 精度問題。

換句話說,是「舊習慣」幫我擋掉了這顆雷——直到換成雪花 ID,數字型的主鍵第一次出現,這個我從沒意識到的前提就咬了我一口。

這件事給我的提醒是:

很多「我一直都這樣做也沒事」的安全感,其實只是剛好沒踩到那個前提而已。 換了一個技術、換了一種型別,原本看不見的假設就會浮上來討債。

雪花很美,但飄過 2⁵³ 的時候,記得幫它換上一件叫 string 的外套。❄️


參考資料#

當雪花 ID 飄過 2⁵³:一場表單集體失蹤事件
https://agito850.github.io/posts/snowflake-id-2-53/
作者
Shon
發佈於
2026-06-25
許可協議
CC BY-NC-SA 4.0