← 回首頁

一個天真的 append,不到一分鐘就讓即時圖表當掉

每個 tick 往陣列後面接一筆,前十秒看起來完全正常。實測每秒 3,000 筆時,大約第 55 秒撞破幀預算,然後超線性惡化。

一條發光的藍色資料流湧入矩形視窗,較舊的資料從左緣溢出並淡出消失。

即時圖表做好了。你 demo 過,很順,大家都滿意。然後它跑過一次開盤,分頁就沒辦法用了。

Bug 只有一行,而且看起來是對的

setData((data) => [...data, tick]);

那個陣列從不縮短。每個 tick 都會複製它前面的所有東西,所以一個 tick 的成本 隨著頁面開多久而成長。它不是傳統意義上的記憶體洩漏——沒有任何東西無法回收—— 但效果一樣,而且更糟,因為工作量也跟著長。

要看幀時間,不是幀率

這是問題藏起來的地方。requestAnimationFrame 封頂在螢幕更新率,所以幀率在你 已經超支之前一直都讀 60。它是一顆只有「過/不過」的燈,沒有刻度。

做下面這個工具時兩個都量了。每秒 3,000 筆時,天真 append 每秒在做六千八百萬次 陣列元素複製,而幀率仍然是健康的 60。幀時間是 2.46 毫秒——16.7 毫秒預算的 15%, 而且在爬。

串流更新策略
更新策略

每幀 0.00 毫秒 · 用掉 16.7 毫秒預算的 0%

記憶體裡保留著 0 個點

每秒複製 0 個值

那條斜坡,量出來的樣子

天真 append,每秒 3,000 筆,視窗 2,000 點,一台普通筆電:

經過時間 每幀時間 佔幀預算 保留點數
10 秒 3.06 毫秒 18% 29,045
30 秒 5.73 毫秒 34% 90,142
50 秒 10.99 毫秒 66% 149,717
60 秒 21 毫秒 126% 180,394
70 秒 72 毫秒 431% 210,149

大約在第 55 秒越線。然後它變成超線性:十秒之內從 21 毫秒衝到 72 毫秒。 那是回饋迴路——一旦一幀花的時間超過 tick 間隔,每幀累積的 tick 就更多, 複製量更大,於是更慢。

前三十秒沒有任何徵兆。 這就是為什麼這種東西會上線。

三種策略,同一個視窗

同一組測試,第十秒時:

策略 每幀時間 每秒複製值數 保留點數
[...data, tick] 3.06 毫秒 68,523,000 無限成長
[...data.slice(-n), tick] 0.29 毫秒 6,000,000 2,000
環形緩衝區 0.04 毫秒 3,000 2,000

環形緩衝區的每幀成本是天真 append 的 1/61,而且是平的——它的成本不隨 視窗大小或執行時間改變,因為它只覆寫一個預先配置好的 Float64Array 裡的一格, 之後再也不配置記憶體。

不過也注意切視窗的代價:每秒複製六百萬個值,只為了顯示兩千個。它是有界的, 那才是重點,但它不是免費的。

實際該做的三件事

  1. 一定要把緩衝區限住。 如果只改一件事就改這個。切成固定視窗比天真 append 只多打幾個字,卻完全消滅了無限成長。
  2. 量幀時間,不是幀率,並且拿它對 16.7 毫秒比。幀率只會在來不及之後才告訴你。
  3. tick 率上到幾千才需要環形緩衝區——市場資料、遙測、音訊。在那之下切視窗 就夠了,而且更簡單,簡單的贏。

常見追問

看幀率不對嗎?

不對,而這正是陷阱。requestAnimationFrame 封頂在螢幕更新率,所以幀率在你 已經超支之前一直都讀 60。幀時間才看得到那條上升的斜坡,而那時你還有餘裕可用。

為什麼越線之後惡化得那麼快?

因為它變成回饋迴路。一旦一幀花的時間超過 tick 間隔,每一幀累積的 tick 就更多, 複製量又更大,於是更慢。實測:第 60 秒 21 毫秒,第 70 秒 72 毫秒。

切成固定視窗夠不夠?

通常夠。它把記憶體限住了,而且在同一組測試裡比天真 append 便宜 8 倍。 但它每個 tick 仍然配置一個長度 n 的新陣列——每秒 3,000 筆、視窗 2,000 點的話, 那是每秒複製六百萬個值,只為了顯示兩千個。

什麼時候值得多寫環形緩衝區的程式碼?

當 tick 率高到「每個 tick 的配置」會出現在效能剖析上的時候——實務上就是 市場資料、遙測與音訊。一秒更新一次的儀表板用切視窗就好,也更簡單。