化實錄:壓測暴露的坐標精度、內存泄漏與渲染瓶頸)
我至今還記得第一次把一份兩萬多節(jié)點的真實數據拖進畫布的那個瞬間畫面像慢放了十倍拖動時能清清楚楚看到每一步卡頓幀率連掩耳盜鈴的余地都不給我。什么無限畫布在真實數據面前就是一頁普通的 HTML。最初我只是想復刻一個類似 libtv 的無限畫布demo 做得風生水起平移、縮放、框選樣樣齊全發(fā)到群里還有人夸了一句“可以啊”。然后朋友丟來這份數據我沉默了。接下來的三天我給自己布置了一項任務搭一套能重復跑、能采指標、能暴露問題的壓力測試環(huán)境然后把這個無限畫布從里到外錘一遍。整個流程走完花了差不多 72 小時。這篇文章就是那 72 小時的完整記錄——我設計了哪些測試、測出了哪些問題、每一處問題是怎么一步步定位到根因的、最后落地了哪些優(yōu)化。如果你也在做無限畫布、白板、地圖編輯這類“視口無限但資源有限”的應用這篇應該能幫你少踩幾個坑。1. 事出有因一個“能用”的 demo 為什么逼我搭了壓測架子1.1 無限畫布的壓力模型和普通頁面完全不是一回事普通網頁的性能瓶頸翻來覆去就那幾樣DOM 節(jié)點太多、重排重繪太頻繁、圖片懶加載沒做好。你在一個普通頁面里塞兩萬個元素瀏覽器會罵你但還不至于直接癱掉。無限畫布不一樣。它的核心場景是“世界坐標”無限大而“視口坐標”永遠只占屏幕那一小塊。聽起來很美好所有渲染只要處理可視區(qū)里的幾十個幾百個元素就行。但要讓這個模型成立你必須在每次視口變化時回答三個問題哪些元素現在可見——可見性剔除它們在世界坐標下的位置換算到屏幕坐標是多少——坐標變換用戶的手勢、縮放、平移怎么和這套坐標系統(tǒng)一——事件處理這三個問題環(huán)環(huán)相扣任何一個環(huán)節(jié)寫得不嚴謹元素量一上來就現原形。普通頁面不會遇到“世界坐標里的浮點數精度不夠用”這種奇葩問題但無限畫布一定會遇到后面我會詳細講。我最開始的 demo 之所以“能用”是因為我只用幾十個元素自測數據少到所有問題都被掩埋了。等到真實數據灌進來復雜度直接從 O(可視區(qū)元素) 退化成了 O(全部元素)不卡才怪。1.2 為什么不能靠“手滑亂點”來壓測很多人覺得壓測就是打開頁面鼠標瘋狂拖一拖卡了就截圖吐槽兩句。這種玩法只能給你一個模糊的感覺“好像有點卡?!彼卮鸩涣巳齻€關鍵問題卡在哪里為什么卡改完代碼之后是變好了還是變壞了我當時給了自己兩個明確目標第一壓測過程必須可復現第二每次壓測必須有客觀指標可以對比。手動操作這條直接廢掉我需要腳本化。我用 Puppeteer 驅動一個真實的 Chrome 實例通過 CDPChrome DevTools Protocol往里灌數據、模擬鼠標指針軌跡、模擬滾輪縮放同時周期性采集幀率、內存、長任務耗時。簡單說一下骨架const puppeteer require(puppeteer); const browser await puppeteer.launch({ headless: false, args: [--disable-gpu-sandbox, --window-size1440,900] }); const page await browser.newPage(); await page.goto(http://localhost:5173); // 在頁面里注入幀率采樣器 await page.evaluate(() { window.__fpsSamples []; let frames 0; const probe () { frames; window.__fpsSamples.push(performance.now()); }; setInterval(() { window.__fps frames; frames 0; }, 1000); requestAnimationFrame(probe); }); // 用 CDP 模擬鼠標拖拽 const cdp await page.target().createCDPSession(); await cdp.send(Input.dispatchMouseEvent, { type: mousePressed, x: 700, y: 450, button: left, buttons: 1 }); for (let x 700; x 300; x - 10) { await cdp.send(Input.dispatchMouseEvent, { type: mouseMoved, x, y: 450, button: left, buttons: 1 }); await new Promise(r setTimeout(r, 16)); } await cdp.send(Input.dispatchMouseEvent, { type: mouseReleased, x: 300, y: 450, button: left, buttons: 0 });這套腳本本身不復雜但它把“我隨便拖一拖”變成了“每一次拖拽的路徑、速度、時間間隔都是固定的”。后續(xù)我再跑第二次、第三次拿到的是同一個條件下可以橫向對比的數字。1.3 72 小時這個數字是怎么花掉的先說清楚不是三天三夜盯著屏幕不睡覺。壓力測試這個東西等結果的時間比干活的時間多得多。我大概的時間分配是第一天前 8 小時搭測試環(huán)境、寫采集腳本、調通自動化鏈路第一天到第二天鋪數據和跑基準測試把 5k、20k、80k 三檔數據都生成出來每檔跑好幾輪操作序列存下 trace第二天大部分時間長時間穩(wěn)定性測試一輪 4 小時期間反復看內存曲線和性能指標第三天針對暴露的問題定位、修復、再跑回歸真正分析問題和改代碼的時間可能只占三分之一其余時間全在等 Chrome 跑完、等內存曲線畫出來、等 heap snapshot 生成。但恰恰是這些等待時間暴露了平時手動操作根本發(fā)現不了的問題——比如內存悄悄上漲這種事你手滑兩分鐘是絕對看不出來的。2. 測試矩陣怎么搭才有參考價值規(guī)模、操作、指標三件套2.1 規(guī)模設計數據量只是第一層一開始我天真地以為壓力測試就是把元素數量翻倍20k 不夠就 80k。跑完一輪之后發(fā)現元素的“種類”和“分布方式”很大程度上決定了瓶頸會在哪里。我把測試數據設計成了三個維度元素類型混合比。矩形、路徑、文本、位圖這四類元素的渲染路徑完全不同。矩形和路徑在 Canvas 2D 里走的是幾何填充文本涉及字體測量和字形緩存位圖涉及貼圖上傳和 GPU 內存。如果只測矩形你根本測不出文字緩存泄漏的問題如果只測位圖又會誤以為所有元素都那么吃顯存。我最終的混合比大概是矩形 40%、路徑 30%、文本 20%、位圖 10%算比較貼近真實白板類應用。幾何復雜度。同樣是路徑一條直線和一條由幾千個貝塞爾曲線拼成的鋼筆路徑處理成本天差地別。我在壓測數據里摻了一部分高復雜度路徑——每條路徑幾百個點——來模擬真實用戶畫的那些“靈魂草圖”。分布方式。元素在無限畫布里的空間分布也很關鍵。我生成了三類分布均勻散布模擬平鋪的筆記、中心聚簇模擬用戶瘋狂放大一個區(qū)域畫圖、帶狀分布模擬橫向流程圖。這三類分布對可見性剔除的壓力完全不同聚簇分布很容易暴露空間索引退化的場景。最終我定了三檔測試規(guī)模5k 元素作為基準檔20k 作為常規(guī)壓力檔80k 作為極限檔。每檔都有固定的元素類型混合比和分布方式保證不同檔位之間只有“量”的差異沒有“質”的變量。2.2 操作設計縮放才是無限畫布的隱藏殺手畫布交互里最耗性能的操作不是平移而是縮放尤其是連續(xù)、深度的縮放。原因很簡單平移只改變視口原點而縮放改變的是整個坐標變換的尺度所有可見元素的世界坐標到屏幕坐標的換算全部要重算浮點誤差也跟著放大。我的操作序列分成了四組慢速閱讀以大約 200px/s 的速度勻速平移模擬用戶在查看內容中速拖拽以 800px/s 拖動中間穿插幾次停頓快速甩動模擬鼠標快速甩過畫布直接觸發(fā)慣性滾動連續(xù)深度縮放從 1x 連續(xù)放大到 100000x再縮小回來反復三輪縮放中心固定在視口左上角三分之一處而不是視口中心重點說下最后這個。很多人測縮放是“一次性 setTransform 跳到某個倍率”這完全測不出精度問題。真實用戶縮放是一格一格滾動滾輪每次縮放都會在前一次的基礎上再乘一個系數浮點誤差會在這個過程中不斷累積。連續(xù)縮放三輪坐標系統(tǒng)的漂移就會顯露出來。另外縮放中心不能總在視口中心。真實用戶通常把鼠標指向自己關注的內容縮放中心在鼠標位置。這個操作路徑對坐標系的要求更高也很容易把隱藏的 bug 暴露出來后面講第二跪的時候會細說。2.3 指標設計FPS 之外還要盯什么幀率只是最表面的指標它只能告訴你“卡了”但說不清卡在主線程還是合成器、是 CPU 不夠還是 GPU 內存爆了。我給自己定了五個指標指標采集方法我設的紅線FPS頁面內 rAF 計數器每秒記錄一次常規(guī)操作不低于 55fps快速甩動不低于 30fps長任務PerformanceObserver 觀察 longtask10 分鐘內超過 50ms 的長任務不超過 5 個JS 堆內存performance.memory.usedJSHeapSize長跑 4 小時后曲線應回落或保持平穩(wěn)不許線性爬升事件響應延遲事件派發(fā)到下一幀渲染完成的時間不超過 100msGPU 進程內存外部監(jiān)控 chrome GPU 進程 RSS不允許持續(xù)增長排除紋理泄漏FPS 和長任務用 Performance API 就能拿到JS 堆內存靠 performance.memoryGPU 進程內存我是用系統(tǒng)命令每隔 30 秒拉一次 chrome 子進程的物理內存雖然不精確但紋理泄漏這類問題它會先報警。這五個指標要綜合看。比如 FPS 掉到 30但長任務很少那問題可能不在 JS 計算而在合成器的光柵化壓力如果 JS 堆內存曲線平穩(wěn)但 GPU 進程內存一直漲那八成是離屏 canvas 或紋理對象沒釋放。2.4 固定測試用例集保證回歸有同一個基準我把整輪操作序列寫成了一個用例文件里面包含了操作類型、坐標、時長、間隔。所有代碼改完之后我都是重新跑這份完全相同的用例文件做回歸。這比“手動拖一拖差不多不卡了”靠譜得多因為它能給出前后對比的數字變化。最近我還把這套用例文件做成了簡單的 CI gate只跑基準檔和常規(guī)壓力檔兩個檔位都過了閾值才允許合并代碼。極限 80k 檔跑得太慢留給每周手動跑一次。3. 七十二小時里最沖擊人的三連跪問題復現與排查鏈路3.1 第一跪節(jié)點一多Pan 就掉到 30fps問題根本不在 draw現象很干脆20k 元素中速拖拽FPS 穩(wěn)定在 30 上下視覺上已經明顯掉幀。當時的直覺是“繪制太慢”——畢竟元素多Canvas 2D 指令多重繪開銷大。所以我第一反應是去看繪制函數的耗時火焰圖拉出來卻傻眼了整個 draw 階段只占每幀總耗時的 20%真正的大頭是一個叫updateViewport的函數每幀都要跑 25ms 以上。這個函數做了什么它遍歷了畫布里的所有元素把每個元素的世界坐標轉換到屏幕坐標寫回元素對象再做一次臟矩形檢測判斷哪些區(qū)域需要重繪。問題是遍歷的是“全部元素”而不是“可見元素”。20k 個元素哪怕只有 300 個在可視區(qū)內循環(huán)依然要跑滿 20k 次。更蠢的是每次拖拽的每一幀都要跑一遍等于我拖 10 秒這個循環(huán)就跑了 600 次每次都是 20k 級別的遍歷。這是非常典型的錯誤認知以為 Canvas 2D 的繪制很貴需要用軟件層面的坐標變換去優(yōu)化結果把優(yōu)化寫成了新的瓶頸。Canvas 2D 的ctx.setTransform本身可以在 GPU 側完成視口變換完全不需要你在 JS 里手工改元素坐標。根因定位之后其實修復很簡單元素坐標永遠保持世界坐標不動視口變換全部交給 ctx 的變換矩陣updateViewport 只要根據視口范圍算出需要遍歷哪幾個格子然后調 ctx.setTransform 就行。這個改動做完20k 元素平移直接回到 58fps 左右火焰圖里那個大頭消失了。3.2 第二跪深度縮放后圖形開始“發(fā)抖”定位到 double 精度問題這個問題的排查過程比第一個曲折得多。表現是當畫布縮放到 300000% 左右再平移時圖形邊緣出現肉眼可見的抖動像坐標被“取整”了一樣一格一格地跳??s放再深一點圖形干脆“亂飛”。我一開始懷疑是抗鋸齒或者像素對齊的問題畢竟 Canvas 2D 在非整數坐標下繪制會觸發(fā)次像素渲染。我把繪制坐標全部改成Math.round取整確實有所緩解但只是把抖動從“一格一格”變成了“偶爾跳一下”治標不治本。接下來我懷疑是 Path2D 構造的問題——會不會是路徑字符串在構造時丟精度我把所有路徑緩存換成離屏 canvas抖動依舊。排除了繪制層剩下的只有坐標變換層。我寫了一個小實驗固定兩個相鄰元素它們的屏幕坐標差應該是 0.5px然后持續(xù)縮放到 1e6 級別每幀打印這兩個元素的實際屏幕坐標。結果發(fā)現真正的問題是浮點數在兩次運算順序下的舍入差異。我當時的代碼做的是screenX element.worldX * viewport.scale - camera.worldX * viewport.scale;先乘后減。當 viewport.scale 很大時element.worldX 乘出來是一個天文數字camera.worldX 乘出來也是一個天文數字兩個天文數字相減要得到一個小數字此時 double 精度根本不夠用誤差被放大到肉眼可見。正確做法是先減后乘讓大數在減完之后變小再乘screenX (element.worldX - camera.worldX) * viewport.scale;但光這樣還不夠。當縮放足夠深時element.worldX 和 camera.worldX 本身已經是 1e12 級別的數字兩個這么大的數相減結果的小數精度照樣損失。最終的解法是把世界坐標拆成“瓦片索引 瓦片內偏移”——大數部分用整數索引表達小數部分用浮點偏移表達先減偏移再乘 scale。這套思路地圖引擎里很常見我后面第四節(jié)會貼具體方案。3.3 第三跪明明什么都沒動內存 4 小時漲了 400MB這個是我跑長時間穩(wěn)定性測試時才發(fā)現的。第一輪長跑前 30 分鐘一切正常JS 堆內存有漲有落屬于正常的 GC 波動。但 1 小時之后我意識到曲線不對勁它漲一點回一點但每次回落都比上次的谷底高一點總體在爬坡。4 小時跑完漲了差不多 400MB。這種問題最討厭因為它不會讓你的頁面一夜之間崩掉但在用戶的真實使用場景里掛一整天之后畫布卡成幻燈片是一定的。排查思路是連續(xù)抓三份 heap snapshot。我先跑到 2 小時節(jié)點抓了一份再跑 30 分鐘抓一份又過 30 分鐘抓一份然后對比三份快照里的對象增長。diff 結果顯示了兩個異常第一個是 Path2D 緩存對象數量暴漲。我做了個 Path2D 緩存key 是路徑字符串想著能復用就復用。但 key 設計太粗糙文字元素的緩存 key 只有 text 和 fontSize不同顏色的、不用旋轉角度的文字全部命中同一個 key導致緩存不斷把舊值頂掉再存新值命中率低得可憐純純的負優(yōu)化。第二個是離屏 canvas 對象出現大量 Detached說明有 canvas 從緩存里移除時沒有正確釋放 GPU 資源。這個其實只改一行代碼就能解決把 canvas 的 width 和 height 置為 0強制讓它釋放底層紋理。根因清楚了修復也很直接。Path2D 緩存換成 LRU限制最大條目數key 里加上填充色、描邊色、透明度、旋轉角度這些影響路徑實例的屬性。離屏 canvas 在移除時統(tǒng)一執(zhí)行canvas.width 0; canvas.height 0;。改完后再跑一輪 4 小時長測內存曲線基本平了。3.4 一個被順帶打出來的 bug雙擊后手勢識別失靈這不是壓測主線上的問題但操作序列里包含雙擊縮放幾次循環(huán)之后我注意到一個詭異的現象雙擊縮放后下一次單擊會被識別成拖拽工具從 select 切到了 pan體驗直接崩壞。查下來根因讓人哭笑不得事件處理器里的 lastX/lastY 沒有跟隨縮放更新。第一次雙擊縮放后相機位置變了但 lastX/lastY 還是老位置。下一次按下鼠標時計算位移距離用的是新的屏幕坐標減去舊的 lastX/lastY距離直接超出手勢庫的閾值被判定為拖拽。這類問題很像“低概率偶發(fā) bug”——實際不是偶發(fā)而是我的事件層和渲染層各自維護了一套坐標基準沒有統(tǒng)一。修復方式是把事件處理器改成每次事件都用同一套 worldToScreen 函數重新計算當前坐標不再用累加的 lastX/lastY。4. 針對根因我最終落地的優(yōu)化方案與實測數據4.1 渲染分層把“每幀重算”改成“分層緩存 可見區(qū)瓦片”這一輪壓測讓我徹底放棄了“每次視口變化就重繪整個內容層”的思路改成三層 canvas 疊加背景網格層離屏 canvas 緩存只有縮放層級變化時才重畫內容層主畫布承載所有業(yè)務元素交互指示層框選、懸停高亮等高頻變化但內容很少的東西單獨一層對于內容層我并沒有做全量離屏緩存——因為元素太多時全量離屏一次重繪的成本也不低。我選擇了瓦片緩存把可視區(qū)按 512x512 切塊只緩存可視區(qū)周邊一圈的瓦片超出范圍的瓦片走 LRU 淘汰。某個瓦片內的元素發(fā)生變化時只重繪那個瓦片不影響其他區(qū)域。這套方案相當于把無限畫布拆成了“有限個小畫布”既避免了每幀全量重繪又不會因為緩存無上限而吃光內存。4.2 變換重構世界坐標拆成“瓦片索引 瓦片內偏移”針對 double 精度問題我借鑒了地圖引擎的坐標方案。每個元素的世界坐標不再直接是一個浮點數而是拆成worldX tileIndexX * TILE_WORLD_SIZE tileOffsetX; worldY tileIndexY * TILE_WORLD_SIZE tileOffsetY;tileIndexX 是整數tileOffsetX 是浮點偏移。渲染時視口變換走的是const screenX Math.round((offsetXFromCamera) * viewport.scale); const screenY Math.round((offsetYFromCamera) * viewport.scale); ctx.setTransform(viewport.scale, 0, 0, viewport.scale, screenX, screenY);這里的關鍵是把帶大數的部分從浮點運算里剝離出去只對小數偏移做浮點乘加最大程度保留精度。屏幕端保留 Math.round避免次像素抖動。4.3 事件側合并rAF 節(jié)流 坐標基準統(tǒng)一pointermove 事件的觸發(fā)頻率比 60fps 高得多如果每個事件都去更新視口、命中檢測、重繪必然造成大量重復工作。我在事件層加了個節(jié)流器事件回調只負責把最新的坐標存下來真正的處理邏輯統(tǒng)一放到 requestAnimationFrame 里執(zhí)行。另外事件處理器和渲染層共用同一套坐標系換算函數。這個看似不起眼的改動直接消除了上一節(jié)提到的 lastX/lastY 漂移問題——所有坐標每次都是從 worldToScreen 重新計算不存在累積誤差。let pendingMove null; canvas.addEventListener(pointermove, (e) { pendingMove { x: e.clientX, y: e.clientY }; }); function onFrame() { if (pendingMove) { const { x, y } pendingMove; // 統(tǒng)一走 worldToScreen 的結果做手勢判定和渲染 pendingMove null; } requestAnimationFrame(onFrame); } requestAnimationFrame(onFrame);4.4 優(yōu)化后復測的數據對比改完上面幾處之后我用同一個用例集重新跑了一輪。挑三組有代表性的數據測試場景優(yōu)化前優(yōu)化后20k 元素中速平移 FPS31fps58fps連續(xù)深度縮放后平移 FPS22fps有亂跳55fps坐標穩(wěn)定4 小時長跑 JS 堆內存增量410MB45MB隨 GC 波動第三組數據特別說明45MB 的增量在 GC 正常波動范圍內曲線不再持續(xù)爬坡。GPU 進程內存也不再增長紋理泄漏問題解除。4.5 這些方案為什么算“夠用”而不是“最優(yōu)”必須說清楚這套方案是針對我自己的壓測數據設計的??臻g索引我用的均勻網格因為測試數據分布比較均勻均勻網格的 O(1) 定位在可視區(qū)查詢時很劃算。但如果真實場景里元素高度聚簇——比如用戶瘋狂往一個區(qū)域里塞內容均勻網格會退化到 O(n)這時候四叉樹或 R 樹是更合適的選擇。瓦片緩存也一樣我有意設置了緩存上限避免極端縮放時緩存爆炸。更極端的場景需要引入 LOD ——縮放層級很深時不再渲染細節(jié)元素只顯示聚合后的摘要圖形——這是第二階段的優(yōu)化任務了。5. 跑完這輪壓測我對無限畫布性能優(yōu)化的一些重新認識5.1 靜態(tài)渲染流暢不等于交互流暢以前我覺得“打開畫布能立刻看到全部內容”就算流暢現在我知道這只是及格線。真正考驗一個無限畫布的是交互過程中每一幀的穩(wěn)定性拖拽時能不能保持 60fps縮放時坐標會不會漂移連續(xù)操作十幾分鐘之后內存穩(wěn)不穩(wěn)。靜態(tài)渲染再快交互時掉幀用戶照樣覺得卡。5.2 壓測的投入產出比高得出人意料搭這套東西前三天我一度覺得“有這個時間我功能都多寫倆了”。跑完才發(fā)現一個性能問題在用戶手里被發(fā)現通常是不可復現、不好定位、消息滯后的但一個性能問題在自動化壓測里被發(fā)現它能穩(wěn)定復現、能采集上下文、能在修復后回歸驗證。這套測試環(huán)境從搭好到現在已經幫我抓出好幾個我手測根本發(fā)現不了的問題回本了。5.3 一些可以直接帶走的具體建議總結幾條我在這次壓測里真正用上、也覺得最值得分享的實踐壓測前先定義三個必須達標的場景比如“20k 元素平移不掉幀”“深度縮放不漂移”“長跑 4 小時內存平穩(wěn)”后續(xù)所有優(yōu)化都以這個為準繩。內存測試一定要單獨跑并且至少跑 2 小時以上。短時間手動操作無法暴露緩慢的內存泄漏。坐標計算不要寫成“先乘后減”的形式大數乘完再減等于把精度問題放大到不可收拾永遠先減后乘。任何緩存都要想清楚 key 和淘汰策略。沒有 LRU、沒有容量上限、key 設計粗糙的緩存在低數據量下是“優(yōu)化”在高數據量下就是泄漏源。長跑比猛跑更容易發(fā)現問題。單次高負載能測出計算瓶頸但只有長時間運行才能測出緩存和數據結構的累積問題。5.4 最后再分享一個小技巧這套測試用例我后來做成了 JSON 文件里面就是一組操作指令和期望閾值每次改完代碼跑一遍用腳本自動判定通過與否。不需要真的寫復雜的測試框架一個幾十行的 Node 腳本足夠。這個習慣一旦養(yǎng)成后續(xù)每次重構都有同一把尺子量著心里踏實很多。72 小時換來的不是一次“通過的測試”而是一份我對這個項目性能底線的清晰認知。無限畫布這類應用看起來難的是“無限”實際上難的是在無限的空間里始終保持有限的計算成本。每一次優(yōu)化本質上都是在跟這個原則對齊。