无限画布72小时压测实录:渲染瓶颈、内存泄漏与协同优化
这份无限画布压力测试我整整跑了72小时。测试结束后我盯着那串数据一句话都不想讲不是Bug多到让人崩溃而是真正让画布卡顿的瓶颈和团队之前猜的完全不是一回事。这个项目是我给前端组搭的一整套画布性能压测方案用来考核咱们自研的类 Figma/Miro 无限画布编辑器目标是回答一个很朴素的问题——当用户在画布上连续拖拽、缩放、画了几千条轨迹之后交互到底什么时候会变得不可用。整轮测试做下来结论相当扎心但过程本身的价值我觉得比结论还大。如果你正在做白板、思维导图、地图编辑器、大屏编排工具这类“没有边界”的界面或者你只是想知道那些看起来很顺滑的在线画布背后到底要过多少道性能关卡这篇文章应该能帮你省下好几个星期的弯路。我会把压测中踩过的坑、设计过的场景、跑出来的数据全部摊开讲。1. 为什么要给无限画布做压力测试1.1 无限画布和普通页面的本质区别无限画布这个交互范式简单说就是用户看到的只是一个视口viewport而内容本身没有坐标上限你可以往任意方向一直画就像你在一张无限大的纸上工作。它的核心难点在于普通网页的性能优化大多围绕“首屏加载快不快”“滚动列表卡不卡”展开但无限画布完全不一样它面临的是一个“无限坐标空间 有限视口”的渲染问题。传统列表页面滚动时浏览器本身对固定DOM节点的回收机制还能说得过去但画布场景下节点数量动不动就是几千上万个每一帧鼠标移动都可能触发平移、缩放、重绘如果方案不对哪怕页面只渲染了视口内的元素性能也会迅速劣化。更麻烦的是用户的交互不是单调的滚轮滚动而是“平移 缩放 拖拽绘制 框选 实时协作”这些动作交织在一起任何一个环节处理不好体感就是“卡一下”或“白屏闪烁”。给这类应用做压力测试和传统Web压测完全是两个路子。服务端压测关心的是并发用户数量、请求响应时间、吞吐量而画布压测必须把重心放在三个指标上渲染帧率FPS、交互延迟从用户输入到画面响应的时间差、内存增长曲线。这三个指标用常规的接口压测工具根本测不出来因为它发生在浏览器内部的渲染流程里你必须用浏览器自动化工具去模拟真实的手势操作。1.2 压测要回答的3个核心问题在我设计测试方案之前团队对无限画布的担忧主要集中在三个问题这也是我这次压测要回答的核心。第一渲染引擎能扛住多大的数据量也就是说画布上到底能同时容纳多少个矩形、连线、文本节点用户操作不会觉得卡。这个问题直接决定了产品上线后用户最多能在一张画布上画多少内容。第二长时间使用的内存表现怎么样很多人会忽略一个事实无限画布的卡顿很多时候不是单帧渲染慢而是长时间操作后内存持续上涨最终导致整个页面崩溃。像 PPT 一样的页面关了就不用了但画布可能是用户开了一天都不关的。第三多人实时协作时画布会不会因为远端数据同步而卡顿多人同时编辑一个无限画布远端节点的增删改会频繁触发本地重绘这个场景比单机操作复杂得多它牵扯到网络传输、冲突合并、渲染更新三层瓶颈。这三个问题分别对应了渲染性能、内存稳定性、协同一致性我做压测的全部内容都是围着它们转的。2. 72小时压测的方案设计与工具选型2.1 测试目标与指标定义正式测试之前我们先定义了这次压测的“及格线”。没有明确指标压测就只是一堆数字做完不知道是该改代码还是该庆祝。我们定的核心指标有三组帧率稳定性在持续交互过程中FPS 不能长期低于 30至少保持 90% 的时间在 30 FPS 以上最好能达到 60 FPS。交互响应延迟从用户触发平移/缩放到画面完成更新的延迟不超过 100ms这是人对“即时反馈”的普遍感知阈值。内存增长在 30 分钟的连续操作中堆内存增长不能超过初始内存的 50%并且释放用户交互后内存应该有一定程度回落而不是只涨不跌。这里要特别说一下为什么是 30 FPS 而不是 60 FPS。无限画布不同于视频播放它更多是“操作型界面”30 FPS 是保证可用性的最低门槛如果能跑到 60 FPS 体验就很顺滑但对弱移动设备来说60 FPS 有时候是奢望所以及格线定在 30 FPS优良线定在 60 FPS。2.2 工具选型为什么我没直接用 JMeter很多后台同事一提到压测条件反射就是打开 JMeter。对于无限画布这种前端交互密集型项目JMeter 只能覆盖一部分场景比如登录、接口、WebSocket 协同服务。所以我的方案是“双轨并行”。服务端这块我们确实用了 JMeter 来做协同接口的并发压测。具体来说我搭建了一个简单的 WebSocket 协同服务然后用 JMeter 的 WebSocket Sampler配合 websocket 插件模拟 200 个并发客户端同时往同一个画布发操作指令用来测试服务端的消息转发能力和数据库写入能力。这部分跑得很顺利但它的结果只能说明“服务端能转发多少条消息”完全无法回答“用户屏幕上渲染卡不卡”。前端渲染压测我选择了 Playwright。原因很简单它能真实启动 Chromium 内核支持以编程方式模拟鼠标拖拽、滚轮缩放、键盘输入还能直接通过 CDPChrome DevTools Protocol采集渲染性能指标比如 FPS、内存、主线程脚本耗时。对比 PuppeteerPlaywright 的多浏览器支持和自动等待机制做得更好压测脚本写起来更稳不会因为页面异步加载时序问题而频繁挂掉。在一份压测方案里同时出现 JMeter 和 Playwright看起来不伦不类但实际跑下来你会发现这套组合恰好把“服务端并发能力”和“客户端渲染能力”这两个维度完整覆盖了。这也是我想提醒大家的一点压测工具不是越高级越好而是要看你的瓶颈到底可能出现在哪一层再决定用什么工具去压那一层。2.3 用 Playwright 模拟真实用户操作Playwright 模拟无限画布操作最核心的方法是page.mouse.move()和page.mouse.wheel()。这里有一个很多刚接触浏览器自动化的人容易忽略的点画布的平移操作通常是通过鼠标按下拖拽实现的缩放通常是通过 Ctrl 滚轮或者触控板捏合实现的。所以我们的脚本必须按真实用户的操作序列来触发事件而不是直接调用某个内部函数。我写了一个基础脚本用于在画布上模拟“随机漫步式”的平移和缩放代码如下import { chromium } from playwright; const browser await chromium.launch({ headless: false }); const page await browser.newPage(); await page.goto(http://localhost:8080/canvas); await page.waitForSelector(#canvas-root); const startX 800; const startY 600; // 1. 模拟鼠标按住左键拖拽平移 for (let i 0; i 30; i) { const offsetX (Math.random() - 0.5) * 600; const offsetY (Math.random() - 0.5) * 600; await page.mouse.move(startX, startY); await page.mouse.down(); await page.mouse.move(startX offsetX, startY offsetY, { steps: 20 }); await page.mouse.up(); await page.waitForTimeout(200); } // 2. 模拟 Ctrl 滚轮缩放 for (let i 0; i 20; i) { await page.keyboard.down(Control); await page.mouse.wheel(0, -120); // 放大 await page.keyboard.up(Control); await page.waitForTimeout(300); } await page.close(); await browser.close();这段脚本只是一个骨架实际压测时我会把它包装成多个场景用例并循环执行。有人可能会问为什么不用page.mouse.move一次移动到位而要加steps: 20因为真实用户拖动鼠标时浏览器接收到的是每帧一次的 mousemove 事件而不是一个起始坐标一个结束坐标。加了 steps 之后Playwright 会逐帧模拟出中间位置的 mouse move 事件流这样画布渲染引擎在每一帧都要处理新的坐标变化才能真实反映“拖动卡不卡”。2.4 压测数据的构造思路测试画布不能像写单元测试一样只准备几十个矩形节点。无限画布压测的关键在于数据量要大、节点类型要多、分布要随机。我们构造了一套压力数据生成器用来在画布上批量创建节点。数据构造分三层第一层是基础节点包含矩形、圆形、文本、连线四种基础元素第二层是聚合结构比如“把100个节点放进一个容器组”这模拟的是思维导图或组织架构图里常见的嵌套场景第三层是大规模连线关系比如从同一个节点出发向周围 50 个节点各连一条贝塞尔曲线这会对抗真实项目的路径计算逻辑。用量上我分别测试了 100、500、1000、3000、5000、8000、10000 节点的数据规模。每个规模等级跑一组完整用例持续平移 3 分钟、持续缩放 2 分钟、随机框选 1 分钟、协同同步 5 分钟。这样单个规模大约需要 10 分钟以上七档数据量跑下来就是 70 多分钟再算上不同浏览器、不同设备、重复样本采集最后摊到 72 小时一点都不夸张。3. 核心场景实现与脚本细节3.1 场景一缩放与平移的连续压力缩放是无限画布最容易暴露渲染性能问题的场景。缩放时画布要么把所有元素的坐标重新计算一遍要么对不同层级使用多分辨率缓存贴图一旦处理不好就会出现严重的“抖动感”或者中途白屏。针对缩放我们的压测脚本不是简单做几次放大缩小而是模拟“持续两分钟的变焦操作”每 300ms 触发一次缩放指令中间穿插小幅平移让渲染引擎持续处于“需要重新计算坐标和视口裁剪”的状态。这个场景最大的价值在于能摧毁那些只做了“缩放结束时重绘”的投机实现——如果代码只响应缩放终点中间过程就会一卡一卡脚本通过连续变化的缩放值直接把这个假象打穿。实测下来我个人很喜欢用分档缩放先把画布缩放到 200%再降到 50%再放到 400%每一档都停 1 秒。过度平缓的缩放压力不够跳跃式缩放才能测出缓存策略和视口裁剪是否真正生效。跑完这个场景立刻去浏览器 Performance 面板看主线程的 Long Task 数量和每一帧的平均耗时比只看 FPS 更有说服力。3.2 场景二海量节点渲染压力海量节点是无限画布压测的“主菜”。在这个场景里不单纯是节点数量多更要模拟用户真实使用习惯——节点分布不应该均匀铺满整个画布而应该分成几个密集区域和一大片稀疏区域。因为真实用户总是会把注意力放在画布的某些角落那些区域节点特别密集其余区域相对空旷。按照上面的思路我设计了“三个区域中心”第一个区域放 60% 的节点模拟一个大型项目计划图第二个区域放 30%模拟会议记录第三个区域放 10%作为随手画的草稿区。这样当脚本随机移动视口时有时候会停留在节点稀疏的区域帧率很漂亮有时候会撞进密集区域帧率瞬间掉下来。这种“冷热交替”的压力模型远比一刀切的全图均匀分布更接近真实场景也更容易触发渲染引擎的边界问题。一个特别有用的调试技巧是在密集区域进行拖拽框选。框选会出现一个临时的选择矩形区域这个区域内的所有节点都要高亮描边等于让渲染层在原本压力极高的基础上再叠加一个高亮绘制任务。我们在一个 5000 节点规模下执行连续框选很快复现了“拖动框选时其他节点闪烁”的问题。3.3 场景三WebSocket 多人协同冲突多人协同场景的压测要同时做到两点服务端并发消息压力以及客户端接收消息后的渲染压力。这个场景我用 JMeter 打服务端用 Playwright 启动两个浏览器页面同时打开同一个画布。两个页面一个作为“操作方”另一个作为“观察方”。操作方每秒钟往画布写入一个节点观察方不做任何本地操作只验证画布内容是否实时同步同时记录观察方页面的 FPS 和内存变化。这模拟的是一名用户正在疯狂编辑时另一名用户仅查看但屏幕也要跟随刷新的现实场景。这种双页面压测非常有价值因为协同场景下很多卡顿不是本地操作引起的而是远端消息触发的。比如我们测出了一个问题当远端每秒钟新增 30 个节点时观察方页面出现明显的周期卡顿通过性能分析发现是节点新增触发了整棵渲染树的局部重排而不是增量挂载。这就是典型的“远端压力转嫁到本地渲染层”的隐患单页面压测根本发现不了。3.4 参数设计与数据记录为了让 72 小时的压力测试可复现、可对照我把所有参数集中在一个配置文件中管理。这个习惯强烈建议保留压测一旦做下去了你很可能需要反复调参重跑参数散落在脚本里会浪费大量时间。我们最终的测试参数大致如下参数项取值说明节点规模100/500/1000/3000/5000/8000/10000每个规模跑完整套动作单次平移距离100~800px随机生成模拟不同拖动幅度缩放倍率50%~500%用滚轮事件触发分档变化每次操作间隔200ms~500ms避免脚本执行太快压垮事件队列运行时长每场景 2~5 分钟保证采集足够样本采样频率每 1 秒采集一次 FPS/内存写入 JSON 文件后续统一分析并发协同数200 个 WebSocket 客户端服务端压力用 JMeter 模拟观察方数量2 个浏览器页面核对同步和渲染消耗数据记录方面我没有用复杂的看板而是让 Playwright 通过 CDP 采集数据后直接写入一个 JSON 文件再用一个简单的 Python 脚本做统计和绘图。CDP 的采集代码很简单核心是监听Performance接口相关的指标尤其是FPS和JSHeapUsedSize这两个字段足够支撑后续的大部分分析。4. 72小时实测结果哪些数据让我沉默了4.1 从4.8帧到60帧缩放渲染的差异整个压测中最让我意外的一组数据出现在缩放场景。团队在压测前做了一个优化按节点数量切换渲染模式——节点少于 500 时走 DOM 渲染节点多了以后自动切换成 Canvas 渲染。这个策略听起来很合理但实际数据非常难看。当画布上有 3000 个节点时DOM 模式下的缩放 FPS 平均只有 4.8 帧基本就是“眼睛看到什么就卡住什么”的状态。切到 Canvas 模式后同样条件下平均可以达到 58 帧几乎满帧运行。这个差距让我沉默的原因不是“Canvas 比 DOM 快”这么简单而是团队里有人在一个月前就提议全面切到 Canvas但担心 Canvas 在文本编辑、DOM 事件命中测试上支持不好一直犹豫。压测数据直接终结了这场争议无限画布的核心交互场景平移、缩放对渲染层要求太高DOM 模式无论如何优化也没法在多节点场景下保住流畅度。Canvas 的文本编辑短板可以通过“双击进入 overlay 编辑层”来解决这就是标准做法。到这一步技术选型不再是“感觉哪个好”的问题而是数据说了算。4.2 5000节点的分水岭所有场景跑下来之后我用节点规模为横轴、平均帧率为纵轴画了一张曲线图结果出现了非常清楚的“断崖”节点数量在 5000 以下时平均 FPS 还能维持在 45 以上一旦超过 5000FPS 直接掉到 25 左右并且在 8000 到 10000 的区间里帧率下降不再线性而是近乎垂直跌落。为什么会这么陡深入分析主线程耗时后发现5000 节点是当前渲染方案中“全量遍历节点”策略的性能临界点。我们的代码每一帧都会遍历所有节点做视口裁剪节点少的时候 JS 遍历可以忽略不计但超过 5000 之后每次遍历加排序的时间开始突破一帧的预算16.7ms于是帧率开始真正崩坏。这个分水岭数据对产品极其关键。它意味着如果我们的目标用户是“个人笔记用户”5000 节点的上限完全够用但如果是“大型项目规划团队”一张画布可能很快超过 5000 节点那就必须改成 Spatial Hash 或者 R-tree 这种空间索引结构来避免全量遍历。我们后续的重构方向完全是被这张曲线图推动的。4.3 内存泄漏的隐蔽信号内存压力测试跑的 30 分钟连续操作里我抓到了一个非常隐蔽的性能隐患。页面刚加载时堆内存大概在 120MB 左右前 10 分钟涨到 180MB这还算正常但到 15 分钟之后内存不涨了却开始出现一种奇怪的“尖刺趋势”——每隔两三分钟内存突然上涨 50MB然后又降下去反复循环。一开始我以为是脚本操作的不规律导致后来通过性能分析才发现这是 WebSocket 协同消息触发的一个旧节点销毁销毁不到位的清理逻辑每次收到远端删除节点的消息时代码会创建一个临时的“失效节点列表”但列表的引用一直被事件监听器持有导致 GC 无法回收直到下一轮大量消息进来才强制触发一次回收。这种“间歇性尖刺”比持续上涨的危险性更大因为它不容易被常规内存监测发现只会在长时间使用过程中周期性地拖慢主线程。4.4 CPU与电池移动端的真实感受压测进行到后半段我把同样的测试脚本放到了一台中端 Android 设备和一台集成显卡的旧笔记本上。移动端的表现比桌面端要悲观得多桌面端 3000 节点能跑 55 FPS同样的数据在 Android Chrome 上只有 21 FPS。更明显的影响是功耗。我们用电池检测工具看到长时间高负载平移画布时主线程 CPU 占用率会稳定在 90% 以上机身温度在十分钟左右就会明显上升。这个结果意味着如果产品主打移动端白板就必须在“渲染帧率”和“功耗”之间找平衡点不能再简单追求满帧而是要考虑降低绘制频率、降低分辨率甚至在检测到低电量时主动降低动画效果。5. 问题排查与常见坑5.1 防抖节流在画布上为什么会失效做压测脚本的时候我曾经试图用“限制事件频率”的方式模拟低端设备的卡顿场景结果意外发现了很多业务代码里的防抖节流设置在画布场景下压根不生效。原因很简单画布平移过程中浏览器每一帧都会派发 mousemove 事件如果代码里对 mousemove 做 200ms 的节流用户拖动画布的时候画面只能每秒更新 5 次这时候防抖是生效了但交互手感已经碎成了渣。正确的做法是配合requestAnimationFrame做“每帧只处理最后一次事件”的合并策略。也就是说事件可以频繁触发但真正的坐标计算和重绘只能发生在每一帧渲染开始之前。做压测时要专门检查这一类逻辑如果发现拖拽有明显的滞后感多半就是防抖节流参数设得过大。5.2 无限画布的“视口裁剪”处理视口裁剪是无限画布性能的第一道防线但也是最容易写错的地方。很多初版实现会用“节点坐标是否在画布的矩形范围内”来判断要不要渲染遇到旋转过的节点、跨越大边界的连线判断逻辑就会出问题导致视口外的节点被错误渲染视口内的节点反而被裁剪掉。压测中我专门构造了一批“跨界节点”——比如一个 3000px 宽的矩形它的中心点在视口外但有一半延伸进了视口。如果裁剪逻辑只看中心点这个矩形就会在缩放过程中一闪一闪的。这类问题在压测数据上不一定表现为低 FPS而是表现为“画面内容缺失”或“闪烁”需要结合录屏回放才能定位。5.3 压测数据别只用本地文件我在第一轮压测时所有节点数据都由页面本地生成的脚本跑起来很快但压测结果出现了不小的偏差。原因是本地生成的数据坐标范围太小所有节点都挤在原点附近视口裁剪完全发挥不了作用渲染引擎把全部节点都当成可见节点处理了数据再大也压不出真实问题。正确做法是准备一份模拟真实项目的数据文件节点坐标要分散到 10000×10000 像素的范围内并且有些区域非常密集。或者更狠一点直接把数据量扩大到百万级坐标点但渲染时只显示视口内的部分。这样才能真正测出无限画布在“无限”这个核心特征下的性能边界。5.4 常见问题速查表现象可能原因排查方向缩放到某级别时FPS骤降该级别触发了不同渲染策略检查模式切换条件增加预渲染缓存长时间操作后画布漂移浮点坐标累计误差检查节点坐标是否做了空间划分或取整多人协同出现重复节点WebSocket消息处理未去重检查远端消息的增量合并逻辑节点一多框选就闪烁高亮绘制覆盖顺序问题检查渲染层叠加关系和脏矩形更新移动端发热严重重绘频率过高降低 canvas 分辨率或抽帧渲染测试脚本偶发白屏浏览器自动回收了离屏画布检查 OffscreenCanvas 生命周期管理最后一个提醒压测脚本本身也是代码它的可靠性直接影响测试结论。我建议每次跑完正式用例后至少重复执行三次对 FPS 和内存取中位数而不是平均值这样可以排除偶发 GC 或网络波动带来的干扰。取中位数这个细节让我在多个场景里发现了平均值掩盖下的间歇性卡顿非常实用。回过头看这 72 小时最值钱的不是那一堆“哪些地方不行”的报告而是我们终于明白了一个道理无限画布的性能问题不存在一个“万能优化”方案它一定是数据规模、交互模型、渲染方案三者的综合博弈。每解决一个瓶颈下一个瓶颈就会在更大的数据量下冒出来这是一条没有终点的路。但有了这套压测方案打底以后每次改动我们都能快速知道自己是变好了还是变坏了这一点比任何技术选型都重要。