<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Shonbox</title><description>Shon 的技術沙盒</description><link>https://agito850.github.io/</link><language>zh_TW</language><item><title>當雪花 ID 飄過 2⁵³：一場表單集體失蹤事件</title><link>https://agito850.github.io/posts/snowflake-id-2-53/</link><guid isPermaLink="true">https://agito850.github.io/posts/snowflake-id-2-53/</guid><description>後端用 Snowflake ID、前端用 JS Number 接，數字一大就集體出包——記一次意外學到的精度教訓。</description><pubDate>Thu, 25 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;h2&gt;事發當天&lt;/h2&gt;
&lt;p&gt;某天，表單系統開始「鬧鬼」。&lt;/p&gt;
&lt;p&gt;使用者回報：&lt;strong&gt;不能申請、不能簽核、連點開以前的表單都直接 404&lt;/strong&gt;。但詭異的是——不是全部壞掉，是「有些人有事、有些人沒事」，而且越晚建立的單越容易出問題。&lt;/p&gt;
&lt;p&gt;聽起來像隨機，其實一點都不隨機。這是一個藏在數字裡、慢慢逼近的定時炸彈。&lt;/p&gt;
&lt;h2&gt;兇手：一個你以為很大、其實沒那麼大的數字&lt;/h2&gt;
&lt;p&gt;後端的主鍵用的是 &lt;strong&gt;Snowflake ID&lt;/strong&gt;（雪花演算法）——一個 64 位元的整數，長這樣：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;1739284756392834561
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;19 位數，看起來很安全對吧？問題是，這個 id 傳到前端後，是被 &lt;strong&gt;JavaScript 的 &lt;code&gt;Number&lt;/code&gt;&lt;/strong&gt; 接住的。&lt;/p&gt;
&lt;p&gt;而 JavaScript 的 &lt;code&gt;Number&lt;/code&gt; 是 IEEE 754 雙精度浮點數，它能「精準」表示的整數上限是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Number.MAX_SAFE_INTEGER // 9007199254740991  ← 也就是 2⁵³ - 1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;9007199254740991&lt;/code&gt; 是 16 位數。我們的雪花 ID 是 &lt;strong&gt;19 位數&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;超過這條線之後，JS 不會報錯，它會&lt;strong&gt;默默地四捨五入&lt;/strong&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;9007199254740992 === 9007199254740993 // true 😱
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;沒看錯。在 JS 眼裡，這兩個不同的數字是「相等」的，因為它已經沒有足夠的精度去區分它們了。&lt;/p&gt;
&lt;h2&gt;最詭異的線索：只有「奇數」的單壞掉&lt;/h2&gt;
&lt;p&gt;排查過程中還有一個讓人一頭霧水的現象——&lt;strong&gt;壞掉的單清一色是奇數 id，偶數的單全都好好的&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;一開始覺得這也太玄了，後來想通就笑了。這其實是浮點數精度最赤裸的證據。&lt;/p&gt;
&lt;h3&gt;先看 JS 的 Number 在記憶體裡長怎樣&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;Number&lt;/code&gt; 是 64 位元的雙精度浮點數（IEEE 754），記憶體佈局是：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[ 1 bit 符號 ][ 11 bit 指數 ][ 52 bit 小數 ]
                              ↑ 加上一個隱含的前導 1，等於 53 bit 有效精度
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;關鍵在於：&lt;strong&gt;指數決定數字「多大」，那 52(53) bit 決定在這個大小下能「切多細」。&lt;/strong&gt; 有效位數是固定的 53 bit，一旦整數需要超過 53 bit 才寫得完，低位就沒有 bit 可以放，只能被丟掉。&lt;/p&gt;
&lt;h3&gt;間隔為什麼會「每跨一個次方就翻倍」&lt;/h3&gt;
&lt;p&gt;可表示整數之間的最小間隔（ULP，unit in the last place）有個乾淨的公式：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;間隔 = 2^(指數 − 52)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;數字每翻一倍（指數 +1），高位就得多吃一個 bit，低位就被擠掉一個 bit，於是間隔跟著 ×2：&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;數值範圍&lt;/th&gt;
&lt;th&gt;指數&lt;/th&gt;
&lt;th&gt;間隔 = 2^(指數−52)&lt;/th&gt;
&lt;th&gt;結果&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0&lt;/code&gt; ~ &lt;code&gt;2⁵³&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;≤52&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;每個整數都能精準表示 ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2⁵³&lt;/code&gt; ~ &lt;code&gt;2⁵⁴&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;53&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;只剩偶數，奇數被吸到鄰近偶數&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2⁵⁴&lt;/code&gt; ~ &lt;code&gt;2⁵⁵&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;只剩 4 的倍數，其餘對齊到最近的 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2⁵⁵&lt;/code&gt; ~ &lt;code&gt;2⁵⁶&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;8&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;只剩 8 的倍數…&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote&gt;
&lt;p&gt;注意「對齊」是&lt;strong&gt;四捨五入到最近的格子點&lt;/strong&gt;（IEEE 754 預設「就近取偶」），不是一律進位——有的往下、有的往上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;回到奇數&lt;/h3&gt;
&lt;p&gt;奇數的最後一個 bit 一定是 &lt;code&gt;1&lt;/code&gt;。當 id 落在 &lt;code&gt;2⁵³ ~ 2⁵⁴&lt;/code&gt; 這段、間隔正好是 &lt;strong&gt;2&lt;/strong&gt; 時，那個 &lt;code&gt;1&lt;/code&gt; 沒有格子可放，就被抹成最近的偶數：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;9007199254740993 // 你以為的奇數 id
// → 實際存進變數的是 9007199254740992，被改成偶數了
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;偶數剛好踩在格子上、毫髮無傷；奇數卡在格子中間，全軍覆沒。「只有奇數壞掉」不是隨機災情，而是&lt;strong&gt;間隔 = 2&lt;/strong&gt; 的必然結果。再往上飆過 &lt;code&gt;2⁵⁴&lt;/code&gt;，間隔變 4，連一半的偶數都要跟著陪葬。😆&lt;/p&gt;
&lt;h2&gt;災難是怎麼發生的&lt;/h2&gt;
&lt;p&gt;把上面的事實串起來，整個慘案就清楚了：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;後端產生 id：&lt;code&gt;...834561&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;透過 JSON 傳到前端，&lt;code&gt;JSON.parse&lt;/code&gt; 用 &lt;code&gt;Number&lt;/code&gt; 接 → 尾數被磨掉，變成 &lt;code&gt;...834560&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;使用者操作後，前端把這個「被竄改過」的 id 傳回後端&lt;/li&gt;
&lt;li&gt;後端拿 &lt;code&gt;...834560&lt;/code&gt; 去查 → &lt;strong&gt;查無此單&lt;/strong&gt; → 404 / 簽核失敗 / 申請噴錯&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote&gt;
&lt;p&gt;最陰險的地方是：&lt;strong&gt;後端完全無辜，資料也完全正確&lt;/strong&gt;。資料是在「前端用 Number 接住的那一瞬間」就被悄悄改掉的，沒有任何例外、沒有任何 log 哀號。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;至於為什麼「越晚的單越容易中」？因為雪花 ID 的高位是時間戳，&lt;strong&gt;時間往前走，數字就越大&lt;/strong&gt;。一旦越過 2⁵³ 這條線，新生成的 id 就全員中招。它不是突然壞掉，是「準時」壞掉。&lt;/p&gt;
&lt;h2&gt;解法：別用數字接，用字串&lt;/h2&gt;
&lt;p&gt;修法意外地樸素——&lt;strong&gt;把 id 當字串傳，全程不要碰 &lt;code&gt;Number&lt;/code&gt;&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;後端序列化時，把 &lt;code&gt;long&lt;/code&gt; 轉成 &lt;code&gt;string&lt;/code&gt; 再吐給前端。以 .NET / &lt;code&gt;System.Text.Json&lt;/code&gt; 為例：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public class LongToStringConverter : JsonConverter&amp;lt;long&amp;gt;
{
    public override long Read(ref Utf8JsonReader reader, Type t, JsonSerializerOptions o)
        =&amp;gt; long.Parse(reader.GetString()!);

    public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions o)
        =&amp;gt; writer.WriteStringValue(value.ToString()); // 1739284756392834561 → &quot;1739284756392834561&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;前端從頭到尾把 id 當成不透明的字串看待——不做數學運算、不比大小、只拿來查資料。字串沒有精度問題，尾數一個都不會少。&lt;/p&gt;
&lt;h2&gt;意外學到的一課&lt;/h2&gt;
&lt;p&gt;最有意思的是——&lt;strong&gt;這個坑我本來「應該」要早就知道，卻因為運氣好一直沒踩到。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;我以前碰的系統，主鍵都是用 &lt;strong&gt;GUID&lt;/strong&gt;。GUID 長這樣：&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;3f2504e0-4f89-41d3-9a0c-0305e82c3301
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它天生就是字串，所以我&lt;strong&gt;從以前就習慣用 &lt;code&gt;string&lt;/code&gt; 接 id&lt;/strong&gt;，從來沒機會遇到 Number 精度問題。&lt;/p&gt;
&lt;p&gt;換句話說，是「舊習慣」幫我擋掉了這顆雷——直到換成雪花 ID，數字型的主鍵第一次出現，這個我從沒意識到的前提就咬了我一口。&lt;/p&gt;
&lt;p&gt;這件事給我的提醒是：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;很多「我一直都這樣做也沒事」的安全感，其實只是&lt;strong&gt;剛好沒踩到那個前提&lt;/strong&gt;而已。
換了一個技術、換了一種型別，原本看不見的假設就會浮上來討債。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;雪花很美，但飄過 2⁵³ 的時候，記得幫它換上一件叫 &lt;code&gt;string&lt;/code&gt; 的外套。❄️&lt;/p&gt;
&lt;hr /&gt;
&lt;h2&gt;參考資料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER&quot;&gt;MDN — &lt;code&gt;Number.MAX_SAFE_INTEGER&lt;/code&gt;&lt;/a&gt;：官方說明 JS 安全整數上限為 2⁵³ - 1（&lt;code&gt;9007199254740991&lt;/code&gt;），以及為何雙精度浮點只能精準表示到這裡。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc8259#section-6&quot;&gt;RFC 8259 §6 — JSON 的 Number 互通性&lt;/a&gt;：JSON 規範直言，為了互通性，數字最好落在 IEEE 754 double 能表示的範圍（即 2⁵³）內，否則各家實作可能精度不一。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.discord.com/developers/reference&quot;&gt;Discord 開發者文件 — Snowflakes&lt;/a&gt;：知名實戰案例。Discord 明講「Snowflake 是 64 位元，API 一律以字串回傳，以避免某些語言的整數溢位」。&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/twitter-archive/snowflake&quot;&gt;Twitter Snowflake（原始專案，已封存）&lt;/a&gt;：雪花演算法的出處，64 位元 = 時間戳 + 機器碼 + 序號的設計緣由。&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>嗨，歡迎來到 Shon 的技術沙盒</title><link>https://agito850.github.io/posts/welcome/</link><guid isPermaLink="true">https://agito850.github.io/posts/welcome/</guid><description>一點創作、一點踩坑，還有一點作為人類的思考與祈願。</description><pubDate>Wed, 24 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;嗨嗨，這裡是 &lt;strong&gt;Shon 的技術沙盒&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;裡面有 一點創作、一點踩坑，還有一點作為人類的思考跟祈願。&lt;/p&gt;
</content:encoded></item></channel></rss>