我在做一个本地 N 体引力模拟器。后端用 RK4 积分推进状态,前端通过 WebSocket 接收位置、轨迹、指标和事件,再画到 Canvas 上。
三个天体动起来并不难。连续跑一段时间后,计算、网络、画布和磁盘的速度差异才会冒出来。某个浏览器标签页卡了两秒,不该让积分线程跟着停;轨迹越积越长,也不能靠一直加内存解决。
项目的模块依赖是:
launcher → web → application → core
core 保持纯 Java,只处理向量、软化引力、RK4 和物理指标。实验状态、事件分发、队列及文件持久化放在 application,REST 和 WebSocket 留给 web,launcher 负责启动和打包前端资源。
这层边界定下来后,网络和文件系统没有机会混进积分代码。后面处理慢客户端时轻松了很多。
有些消息过期就没用了
一开始最容易写成一个普通 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。