事發當天
某天,表單系統開始「鬧鬼」。
使用者回報:不能申請、不能簽核、連點開以前的表單都直接 404。但詭異的是——不是全部壞掉,是「有些人有事、有些人沒事」,而且越晚建立的單越容易出問題。
聽起來像隨機,其實一點都不隨機。這是一個藏在數字裡、慢慢逼近的定時炸彈。
兇手:一個你以為很大、其實沒那麼大的數字
後端的主鍵用的是 Snowflake ID(雪花演算法)——一個 64 位元的整數,長這樣:
173928475639283456119 位數,看起來很安全對吧?問題是,這個 id 傳到前端後,是被 JavaScript 的 Number 接住的。
而 JavaScript 的 Number 是 IEEE 754 雙精度浮點數,它能「精準」表示的整數上限是:
Number.MAX_SAFE_INTEGER // 9007199254740991 ← 也就是 2⁵³ - 19007199254740991 是 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⁵³ | ≤52 | 1 | 每個整數都能精準表示 ✅ |
2⁵³ ~ 2⁵⁴ | 53 | 2 | 只剩偶數,奇數被吸到鄰近偶數 |
2⁵⁴ ~ 2⁵⁵ | 54 | 4 | 只剩 4 的倍數,其餘對齊到最近的 4 |
2⁵⁵ ~ 2⁵⁶ | 55 | 8 | 只剩 8 的倍數… |
注意「對齊」是四捨五入到最近的格子點(IEEE 754 預設「就近取偶」),不是一律進位——有的往下、有的往上。
回到奇數
奇數的最後一個 bit 一定是 1。當 id 落在 2⁵³ ~ 2⁵⁴ 這段、間隔正好是 2 時,那個 1 沒有格子可放,就被抹成最近的偶數:
9007199254740993 // 你以為的奇數 id// → 實際存進變數的是 9007199254740992,被改成偶數了偶數剛好踩在格子上、毫髮無傷;奇數卡在格子中間,全軍覆沒。「只有奇數壞掉」不是隨機災情,而是間隔 = 2 的必然結果。再往上飆過 2⁵⁴,間隔變 4,連一半的偶數都要跟著陪葬。😆
災難是怎麼發生的
把上面的事實串起來,整個慘案就清楚了:
- 後端產生 id:
...834561 - 透過 JSON 傳到前端,
JSON.parse用Number接 → 尾數被磨掉,變成...834560 - 使用者操作後,前端把這個「被竄改過」的 id 傳回後端
- 後端拿
...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 的外套。❄️
參考資料
- MDN —
Number.MAX_SAFE_INTEGER:官方說明 JS 安全整數上限為 2⁵³ - 1(9007199254740991),以及為何雙精度浮點只能精準表示到這裡。 - RFC 8259 §6 — JSON 的 Number 互通性:JSON 規範直言,為了互通性,數字最好落在 IEEE 754 double 能表示的範圍(即 2⁵³)內,否則各家實作可能精度不一。
- Discord 開發者文件 — Snowflakes:知名實戰案例。Discord 明講「Snowflake 是 64 位元,API 一律以字串回傳,以避免某些語言的整數溢位」。
- Twitter Snowflake(原始專案,已封存):雪花演算法的出處,64 位元 = 時間戳 + 機器碼 + 序號的設計緣由。