← 文章 / Writing

Engineering13 min编者 nabunana ↗

三体模拟器跑起来以后,四条链路开始互相拖后腿

一次实时模拟项目里的拆分:积分照常跑,网络只发该发的,画布和归档各自守住上限。
#Java#Vue#WebSocket#Canvas#性能优化

我在做一个本地 N 体引力模拟器。后端用 RK4 积分推进状态,前端通过 WebSocket 接收位置、轨迹、指标和事件,再画到 Canvas 上。

三个天体动起来并不难。连续跑一段时间后,计算、网络、画布和磁盘的速度差异才会冒出来。某个浏览器标签页卡了两秒,不该让积分线程跟着停;轨迹越积越长,也不能靠一直加内存解决。

项目的模块依赖是:

launcher → web → application → core

core 保持纯 Java,只处理向量、软化引力、RK4 和物理指标。实验状态、事件分发、队列及文件持久化放在 application,REST 和 WebSocket 留给 weblauncher 负责启动和打包前端资源。

这层边界定下来后,网络和文件系统没有机会混进积分代码。后面处理慢客户端时轻松了很多。

有些消息过期就没用了

一开始最容易写成一个普通 FIFO:后端产生消息,前端按顺序消费。可位置快照与状态事件并不是同一种东西。

假设页面卡住两秒,后端以 60Hz 发送位置。恢复后再播放一百多帧旧坐标没有意义,直接拿最新位置就够了。实验开始、暂停、结束、近距离事件和错误则不能随便覆盖,少一次就可能让页面状态错下去。

所以每个监听者都有自己的 mailbox。快照、轨迹和指标放在按类型区分的 latest-wins 槽位,新消息覆盖旧消息;状态、事件和错误进入有界可靠队列。某个客户端慢,只会在自己的 mailbox 里积压,不会挡住模拟线程和其他客户端。

可靠队列也设了容量。消费者长期跟不上时,继续堆内存没有好处,断开后让它通过 REST 拉一份完整状态,再用递增的 sequence 接回 WebSocket,处理起来更清楚。

积分步数和发布频率各算各的

积分器一秒可以走很多步,浏览器没必要逐步接收。现在快照和轨迹的发布上限都是 60Hz,指标是 2Hz,由单调时钟控制。错过某次发布就跳过,不在下一轮补发。

这几个频率只管展示,不参与数值计算。调整前端刷新率不会改变积分结果,计算变快也不会自动把更多 JSON 推上网络。

实际测公网传输时也能看出这个区别:latest-wins 能阻止慢消费者把队列撑大,却不会减少正常情况下已经发出的流量。要降带宽,得改发布频率或消息内容,不能指望换一种排队方式顺便解决。

轨迹必须有固定上限

每个天体的实时轨迹只保留最近 8,000 个点。前端使用环形缓冲区,满了就覆盖最旧的数据,不做无界数组追加。归档侧最多保留 50,000 个状态,超过后采样。

磁盘写入不在积分热路径执行。状态先进入内存队列,归档线程按 512 点成批落盘并定期 flush。磁盘慢时,压力留在自己的有界队列里。

Canvas 这边的问题更直观:轨迹点越来越多,如果每帧重新转换并绘制全部历史,运行时间越长越吃力。当前实现先做线性径向预筛,去掉挤在同一像素附近的点,再用 Ramer-Douglas-Peucker 抽稀。网格、轨迹和动态天体拆到不同画布层,静态内容不用跟着每帧重画。

快照到达的节奏也不会刚好贴合浏览器刷新。前端会保留一个小缓冲区,在相邻快照之间插值。WebSocket 负责送来离散事实,requestAnimationFrame 按自己的节奏画连续运动。

契约也管消息语义

REST 结构写在 OpenAPI,WebSocket 事件写在 JSON Schema。前端类型从契约生成,业务代码再通过一层手写 facade 使用。

我不想让“快照可覆盖、状态要可靠”只停留在实现习惯里。事件类型、payload 和 sequence 都是协议的一部分。字段或枚举变化时,先改契约,再生成类型,生产者和消费者会一起暴露需要调整的地方。

现在这条链路可以长时间运行,但参数仍然是针对当前规模定的。60Hz、8,000 点和 512 条 mailbox 容量都不是通用答案。以后天体数量或公网使用方式变了,先测清楚是哪一段开始吃紧,再改对应的上限。

回放拖动不是改一个时间值

时间轴最早的实现只盯着滑块数值。拖到最右端时,页面会立刻切回 LIVE;如果鼠标还没松开,再往左拖,后续移动就被忽略了。另一些大范围瞬移来自回放缓存:exact、focus、live 和 overview 各有自己的帧,旧逻辑可能拿到一张更远的精确帧,却错过时间上更近的普通帧。

后来把拖动改成完整的 pointer 会话。按下时记录 pointer 并捕获,移动事件用 requestAnimationFrame 合并,取消和丢失 capture 都会正常收尾。拖动期间一直留在 REVIEW,只有松手且位置确实在最右端才回到 LIVE。浏览器没有 requestAnimationFrame 时就直接应用移动,不等到松手才更新。

回放缓冲也不再按缓存级别找帧,而是从四层缓存里一起找时间上最近的前后包围帧,再做只影响显示的线性插值。拖动过程中不发高频精确查询,释放后才请求 focus 和精确结果;过期响应交给已有的 generation 和 AbortController 丢掉。

公网验收时,时间轴从 12,808 步连续回拖到 4,483 步,2D 和 3D 视图都保持可用。这类问题单靠单元测试里的几个固定位置很难暴露,连续往返拖动和右边界回拖得单独测。

同样的实验不再存第二份

文件归档有上限,不代表可以放任相同参数的任务重复保存。实验名称和自动生成的天体 ID 不影响物理结果,所以服务端会在参数归一化后生成稳定配置键,并在队列锁里完成查重或创建。

第一次创建返回 201。相同物理配置再次提交时返回已有实验和 200,前端直接进入那条记录。只要有实际参数变化,仍然创建新实验。查重放在锁内,是为了挡住连续点击和多个客户端同时提交,不让两个请求都在“还没找到”后各写一份。

完成、暂停、取消或失败的实验也可以重启。确认后清空旧轨迹和报告,保留原实验 ID,再重新入队。这个行为和“新建另一个同参数实验”分开以后,磁盘上的记录更容易解释,前端也不用猜应该跳到哪个 ID。

今聴いている / NOW LISTENINGYorushika

读完之后,留一点安静给音乐。这是一则状态记录,不是播放器。

站内搜索

AgentVibe的东西 真的看懂了吗Notes · 工程 反思 AI 能力 · 攒了一堆能跑的项目,能力却没长进。问题不在代码谁写的,在于我有没有真正想明白。谈谈多Agent并发与OpenclawNotes · AI 工作流 OpenClaw 工程取舍 · 把一个写文案的活拆成五六个 Agent 开会,听着爽,实际是 Token 火葬场。真正把事做完的,是单 Agent 加工具,加上几个克制的角色。一次 GitHub Actions 自动发布复盘Engineering · Astro GitHub Actions Nginx SSH CI/CD 部署 · 从旧 Hexo GitHub Pages、Astro 新站到服务器:怎样处理分支切换、SSH 信任、未入库的 94 首音乐,以及可回滚的原子发布.更新静态博客时,我不直接覆盖 /var/www/blogEngineering · Astro Nginx SFTP 部署 静态网站 · 这个 Astro 博客的发布过程:先在旁边准备好完整站点,核对资源,再一次替换并保留回滚。Java 没有断,WebSocket 为什么一直重连Engineering · Java WebSocket Nginx Vue 部署 · 一次公网部署故障复盘:REST 正常、进程没重启,WebSocket 却持续 403,子路由刷新也跟着 404。做“今天吃什么”,我只想让页面给一家店Engineering · Java Vue uni-app 推荐系统 产品设计 · ELMA 目前的项目思路:少问几个问题,一次给一家店,再从反馈和行为里慢慢学。做一个能按真实尺寸打印的卡牌 PDF 工具Projects · Python Pillow PDF Tkinter Desktop · 卡牌图片放进 A4 不难,麻烦的是毫米、DPI、图片比例和桌面程序里的几个小坑。三体模拟器跑起来以后,四条链路开始互相拖后腿Engineering · Java Vue WebSocket Canvas 性能优化 · 一次实时模拟项目里的拆分:积分照常跑,网络只发该发的,画布和归档各自守住上限。聊天摘要 Agent:消息拉全以后,才轮得到模型Agent · Python Agent SQLite Privacy 容错设计 · 记录聊天摘要从能运行到可以放心使用,中间补过的分页、游标、证据和隐私边界。拿一个 8KB 数组写个 mallocSystems · C Memory Data Structure 嵌入式 · MiniMalloc 里真正需要想明白的几件事:块头放哪、空闲块怎么找,以及 free 后怎样把内存拼回去。我为什么还在用 RSSNotes · RSS Reading Web · 不猜兴趣,也不替我调整顺序。想看的来源,自己订阅就够了。这个夏天看过的动画ACG · Anime ACG Life · 没做排名,只记下几段过了一阵子还会想起来的内容。ProjectsPage · 正在构建的项目与实验MusicPage · Yorushika、n-buna、Vocaloid 与听歌记录AboutPage · 关于这个数字空间和它的主人