ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MiroFish:基于CRDT与WebSocket的实时数据协同画布

MiroFish:基于CRDT与WebSocket的实时数据协同画布 MiroFish 这个名字最早出现在我们内部群的一条凌晨消息里“要不别贴图了让数据自己游起来。”那天晚上刚开完一场复盘会会议室里三块大屏各显示一套指标五个人对着三套口径吵了两个小时最后的共识是——谁也不服谁。会后有人把三块屏的截图拼进协作白板画满箭头和问号白板确实很热闹但截图里的数字是死的改一个阈值就得重新截图、重新贴、重新对齐第二天再看已经过期了。我当时的想法很朴素如果白板上的图元能自己动、能自己带着数据活起来很多会议其实能省掉一半时间。MiroFish 就是从这个念头里长出来的东西——一块支持多人同时编辑的画布加上一条实时数据通道把指标变成在画布里游动的“鱼群”谁都能顺手戳一下看细节谁都能拉一条线把两个数据池连起来。它不算大工程核心代码不到八千行但它解决的是那种“说不清但每次开会都难受”的问题。这篇内容适合三类人看一类是做内部工具、想把监控和协作揉到一起的工程师一类是正在评估协同编辑方案、纠结 CRDT 还是 OT 的技术负责人还有一类就是想找个周末项目练手的独立开发者MiroFish 的技术栈不算轻但每一层都能拆出来单独用。我不会把它包装成一个“企业级解决方案”它就是一个小团队自己攒出来、在生产环境跑了大半年的东西哪些地方是凑合的、哪些地方是被逼出来的我都会写清楚。1. 从“截图贴墙”到“画布上游动的数据”MiroFish 到底解决什么问题1.1 需求的原点三块屏幕、五个人、零共识先把场景讲清楚否则后面所有技术选型都像是为了炫技。我们的日常是这样一个服务集群指标分散在三套系统里——业务埋点在时序库里调用链在某套 APM 里基础设施指标又在另一套平台。每套系统都有自己的看板和自己的口径单看都挺清楚放在一起就打架。有一次排查一个偶发的超时我们从上午十点查到下午三点最后发现是两边的“成功率”定义不同一边把超时算失败一边把超时算成功但单独打标。这件事之后我就很确定我们缺的不是更多的看板而是一块能把不同来源的东西摆在一起、并且允许所有人当场改、当场标的画布。截图贴白板这个做法本质上是用“静态快照”去承载“动态共识”它会以肉眼可见的速度腐烂。第一天贴上去的时候大家还新鲜第三天就没人看了因为没人知道图上的数字是什么时候的。更麻烦的是拓扑关系服务 A 调用服务 BB 依赖中间件 C这些关系在三套系统里各画了一遍三张图长得都不一样新人进来直接懵。MiroFish 的第一个目标就是把“图元 关系 实时数据”这三件事放在同一个坐标系里图元本身带时间戳关系可以被任何有权限的人修改数据每隔固定周期刷新一次。1.2 定位边界它不做 BI也不做在线文档做内部工具最容易犯的错是贪心什么都要最后什么都不精。所以 MiroFish 从第一天就定了三条边界写进了 README 的第一段。第一条它不做 BI 查询。复杂的聚合、下钻、同环比仍然交给专业的分析平台MiroFish 只做“已经算好的指标”的可视化映射。第二条它不做富文本文档。它不是又一个在线文档图元上的文字就是标签级别想写长篇大论请回到文档工具里去。第三条它不做告警。告警的送达链路、去重、静默、升级策略是一整套独立体系硬塞进来只会把画布变成一个噪声源。提示边界这件事必须在项目早期用文字固化下来写在 README 最显眼的位置。我见过太多内部工具因为边界模糊功能列表半年翻了三倍最后没人维护。这些边界带来的好处非常直接数据模型简单了。整个 MiroFish 的核心实体只有四种——画布、图元、连接线、数据源绑定。没有表格、没有表单、没有复杂权限组权限只分三层只读、可编辑、可管理。简单到什么程度一个新人读完数据模型定义半小时就能上手改代码。1.3 名字拆解Miro 管“在哪里看”Fish 管“怎么看”给项目起名字这件事看起来不重要其实会影响你对产品的想象。MiroFish 拆成两半Miro 那一半指的是“画布”这个载体强调多人在同一片空间里协作、拖拽、标注、对齐的自由度Fish 那一半指的是数据的呈现形态数据不是钉在表格里的数字而是有速度、有方向、有聚集行为的活物。一群鱼在水里游水质变了鱼群的密度、速度、聚散就会跟着变看的人不需要看懂每个数字只看形状就知道出事了。这个隐喻不是装饰它直接决定了渲染层的设计。传统看板里一个指标就是一个数字加一条折线在 MiroFish 里一个指标是一群个体个体的速度和聚合度由指标的变化率决定个体的颜色由健康度区间决定。举一个我们实际在用的例子订单服务的 QPS 正常时是一群匀速游动的蓝鱼一旦 P99 延迟突破阈值鱼群的游动速度会突然下降并开始聚集视觉上非常刺眼。值班的同学说他有一次是在“余光扫到画面变慢”的时候才发现问题的比告警短信还早了四十秒。2. 技术选型为什么是 CRDT WebSocket Canvas2.1 协同模型CRDT 与 OT 的取舍协同编辑这块绕不开两个流派CRDT无冲突复制数据类型和 OT操作变换。我们选 CRDT不是因为它更时髦而是因为它对“弱中心化部署”和“离线编辑”更友好。OT 需要一个中心服务器来定序变换函数要针对每一种操作类型单独推导图元移动、缩放、旋转、改属性、删连线每一种组合都要证明收敛性我们的团队规模撑不起这种维护成本。CRDT 的代价是元数据膨胀但换来的好处是每个客户端可以本地立即生效服务器只做广播和持久化不需要理解操作语义。对比维度CRDTOT收敛保证数学上天然收敛与到达顺序无关依赖服务器定序与变换函数正确性中心依赖弱可 P2P、可离线强必须有权威服务端元数据开销较大每个字符/属性带标识较小实现复杂度选对库后较低每种操作都要推导变换适合场景图形编辑、离线优先、多端纯文本、长文档、强中心我们用的是基于 Lamport 时间戳加站点 ID 的组合标识图元粒度的合并没有做到字符级——这是一个刻意的取舍。图形编辑里两个人的光标很少落在同一个像素上冲突主要集中在“同一次拖拽的最终位置”粒度到图元就够了。字符级 CRDT 在万亿级文本场景才有明显价值我们做的是几十个图元的画布用不上。2.2 传输层为什么弃用 SSE 和轮询第一版我用的是 SSEServer-Sent Events理由是简单浏览器原生支持自动重连服务端就是普通的 HTTP 响应流连协议都不用自定义。跑了三天就换掉了原因有三个。第一SSE 是单向的客户端要发操作还得再开一条 POST 通道两条通道之间的顺序无法保证实际操作里出现过“我的删除请求到了但对方的更新广播先到”导致图元删了又冒出来。第二SSE 在 HTTP/1.1 下受同域连接数限制一台机器开了六条 SSE 连接之后同域的普通请求就开始排队这个坑非常隐蔽。第三SSE 的自动重连会带上 Last-Event-ID但我们的操作流有自己的版本向量两套序号体系叠在一起调试起来让人崩溃。方案方向重连语义多路复用结论短轮询双向无天然延迟高请求量爆炸长轮询双向需自实现天然连接频繁重建不适合高频操作SSE单向浏览器内置HTTP/1.1 受限需配第二条通道弃用WebSocket双向需自实现原生支持二进制与多路消息最终选择换成 WebSocket 之后重连逻辑得自己写这是代价。但换来的是同一条连接上所有消息天然有序操作和广播混在一条流里版本号只有一套排查问题时看一串日志就能把因果链理清楚。2.3 渲染层Canvas 脏矩形与 SVG 的边界渲染这块我纠结了挺久。SVG 的好处是每个图元就是一个 DOM 节点事件绑定、无障碍、CSS 动画全都免费坏处是节点一多就卡尤其是我们这种每秒都在动的东西200 个图元开始掉帧500 个就直接幻灯片。Canvas 的好处是绘制性能可控坏处是所有交互都得自己算命中检测、层级、文本换行、缩放坐标变换一个都不能偷懒。最后的方案是混合静态的标注层、连线层用 SVG动态的鱼群层用 Canvas两层用同一个坐标系缩放平移由同一套变换矩阵驱动。这个方案的关键在于两个层的重绘节奏要分开SVG 层只在结构变化时重绘Canvas 层按 60 帧跑。为了不把整个 Canvas 重画一遍我们用了脏矩形只重绘鱼群上一帧和这一帧覆盖到的区域实测在 2000 个图元的情况下单帧绘制时间从 14ms 降到 3ms 左右。2.4 服务端与存储Go、Redis Streams、时序库服务端选 Go理由是 WebSocket 的长连接场景下goroutine 的调度模型比线程轻太多单机撑五万连接时内存占用还在可控范围。广播环节用一个 hub 模式每个画布一个 hubhub 内部维护连接集合和一个带缓冲的 channel写入慢的连接会被踢掉避免慢消费者拖垮整个 hub。这个“踢掉慢消费者”的策略我们踩过坑后面会讲。存储分三块。画布的文档状态图元、连线、属性存在关系型数据库里用 JSON 字段存 CRDT 的合并结果定期做一次快照压缩。操作日志走 Redis Streams保留 72 小时用来做重连补齐。指标数据本身不进 MiroFish只存数据源的连接配置和最近一次取到的值真正的原始数据留在时序库里靠定时拉取或者订阅推送。3. 核心链路拆解一条数据从采集到“游动”起来3.1 接入层三种数据源的协议归一我们实际接了三种源HTTP 拉取的 JSON 接口、MQTT 订阅的主题、以及时序库的查询接口。三种源的差异很大拉取是周期性的订阅是事件驱动的查询是按时间窗口的。为了不让下游感知差异接入层定义了一个统一的中间结构核心字段只有五个数据源标识、指标键、时间戳、数值、标签集合。所有适配器只负责把各自的原始数据翻译成这个结构翻译完就往内部的一个 channel 里丢。{ source_id: order-svc-prod, metric_key: order.qps, ts: 1735689600000, value: 1284.5, tags: { env: prod, region: cn-east } }这里有个容易忽略的细节时间戳的语义。拉取型数据源返回的时间戳到底是“采集时间”还是“数据生成时间”我们一开始没区分结果在做鱼群动画插值时出现了负延迟——鱼往前游了一下又倒回来。后来统一规定所有接入的数据必须带两个时间戳ts是数据产生的时刻ingest_ts是进入 MiroFish 的时刻动画插值只用ts监控接入延迟只看两者之差。3.2 增量同步协议opId、版本向量与因果序客户端的每一个操作打成一个 op结构尽量扁平方便序列化和调试。{ op_id: a3f1site-2, canvas_id: c_9f2, type: element.move, target: el_77, payload: { x: 320, y: 180 }, deps: { site-1: 41, site-2: 17, site-3: 9 } }deps就是提交时的版本向量表示“我基于这些站点的哪些版本产生了这个操作”。服务端收到 op 之后先检查依赖是否满足能满足就直接广播并更新自己的版本不能满足就放进一个待定队列等缺的 op 到了再一起处理。本地客户端收到远端 op 时同样做一次依赖检查缺依赖就暂存避免“基于旧状态的操作”被错误地应用到新状态上。合并函数是整个 CRDT 的心脏逻辑不复杂但必须非常小心。// 简化后的合并逻辑位置按时间戳大者胜属性按字段独立合并 func MergeElement(local, remote Element) Element { if remote.Lamport local.Lamport || (remote.Lamport local.Lamport remote.SiteID local.SiteID) { local.Pos remote.Pos local.Lamport remote.Lamport local.SiteID remote.SiteID } // 属性字段独立合并避免整体覆盖导致别人改的颜色被我的移动冲掉 for k, v : range remote.Props { if local.PropClock[k] remote.PropClock[k] { local.Props[k] v local.PropClock[k] remote.PropClock[k] } } return local }这段代码里最值得说的不是胜负规则而是属性独立合并。第一版我们是整体覆盖的结果出现了一个很典型的现象A 把图元挪到左边B 同时把图元改成红色合并之后要么位置回退要么颜色丢失。拆成字段级时钟之后这类冲突才真正消失。3.3 鱼群渲染把指标映射成个体的行为参数鱼群不是一个装饰动画它是数据到视觉的映射函数。我们定义了四个行为参数游动速度、聚集度、颜色通道、个体抖动幅度。每个参数都有明确的输入和映射曲线。行为参数输入指标映射方式视觉含义游动速度指标变化率一阶差分线性截断映射到 0.2x 到 2.0x变化越快鱼游得越急聚集度指标离散度同组多个实例的标准差反比映射标准差越大越松散实例越不均衡队形越乱颜色通道健康度区间分段映射正常蓝、注意黄、异常红一眼看出水位抖动幅度采样间隔内的极差归一化后映射到 0 到 6 像素抖动大说明数据不稳映射曲线用分段线性而不是 sigmoid原因是可解释性。值班同学需要知道“鱼游到多快算异常”如果用平滑曲线阈值附近的变化太模糊反而不好判断。分段线性虽然折角生硬但每一段都有明确含义调参时改的是拐点坐标团队里非算法背景的同学也能改。function mapSpeed(deltaRate) { const segs [[0, 0.2], [0.05, 0.6], [0.2, 1.2], [0.5, 2.0]]; if (deltaRate segs[0][0]) return segs[0][1]; for (let i 1; i segs.length; i) { const [x0, y0] segs[i - 1], [x1, y1] segs[i]; if (deltaRate x1) { const t (deltaRate - x0) / (x1 - x0); return y0 t * (y1 - y0); } } return segs[segs.length - 1][1]; }3.4 断线重连与状态收敛重连不是简单地把 WebSocket 重新连上就完事真正麻烦的是状态收敛。我们的做法分三步连接建立时先发一个sync.request带上本地版本向量服务端对比自己的版本算出客户端缺失的操作区间从 Redis Streams 里读出来按序下发客户端应用完这批操作后再发一次sync.ack服务端才认为它进入正常广播队列。这中间如果操作量太大会退化成一次全量快照下发阈值设的是 5000 条操作或者 2MB 数据。有个细节值得说收敛期间要屏蔽本地编辑的广播但允许本地编辑生效。否则用户会看到自己的操作被“拉回去”再“推出来”体感很差。我们专门做了一层乐观锁收敛期间本地操作只入本地队列标记为 pending等收敛完成再统一提交。4. 实测数据与压测现场200 个并发编辑者时发生了什么4.1 压测方案与观测指标压测没搞得很复杂用的是一个自己写的脚本模拟三种角色纯浏览者只收不发、轻度编辑者每分钟 1 到 3 次操作、重度编辑者每秒 2 到 5 次操作。比例按 7:2:1 配尽量贴近真实分布。观测指标有四个操作广播的 P99 延迟、客户端帧率、服务端单连接平均内存、以及重连后的收敛耗时。场景连接数图元数广播 P99客户端帧率收敛耗时小画布3030042ms58fps0.3s中画布120120078ms52fps0.9s大画布2005000156ms41fps2.7s极端场景2005000 每秒 500 指标更新340ms28fps4.1s极端场景那一行是必然要付出代价的5000 个图元加上每秒 500 次指标刷新帧率掉到 28fps 已经算是能接受。真正的教训不在这张表里而在压测过程中暴露出来的几个问题。4.2 内存泄漏被忽略的离屏 canvas 与事件监听第一次压测跑了两小时服务端内存曲线一路缓慢上扬没有任何回落。我一开始怀疑是连接泄漏用 pprof 抓了一轮堆快照发现连接数很稳定泄漏点在别处。最后定位到两个地方一是每个画布一个离屏 canvas 用作缓存但画布关闭时只清了引用没调用释放逻辑导致底层像素缓冲没被回收二是窗口 resize 事件的监听函数绑在画布上画布销毁时没有解绑每开关一次画布就多一个闭包持有整棵场景树。这两个问题的共同点是它们都不会在短时间压测中暴露只有长时间跑才看得见。所以我把压测时长从两小时改成了十二小时并且加了一条硬规则——任何创建资源的代码必须同时写一个释放路径并且这个释放路径要能被测试覆盖。注意前端资源的生命周期管理最容易在“快速迭代”中被牺牲。我的经验是只要引入了离屏画布、Web Worker、定时器、全局事件监听中的任意一个就必须同时写一个destroy函数哪怕当期用不上。4.3 弱网下的“幽灵图元”某个周二的下午有同事反馈白板上出现了一个“幽灵图元”位置在左上角点不中刷新之后就没了。日志查了半天最后复现出来的路径是这样的客户端在弱网环境下发出删除操作操作已经抵达服务端并广播但这个客户端的广播通道正好断了重连之后客户端拿本地版本向量去要增量服务端的 Streams 里那条删除操作已经过期被裁剪掉于是服务端认为“你没有缺失”直接跳到最新状态客户端本地的图元既没被删除也没在服务端的快照里出现成了一个孤儿。修复方案有两个层面。短期方案是延长 Streams 的保留时间并提高全量快照的触发敏感度让裁剪与全量的时间窗口错开。长期方案是在快照里加一个校验摘要客户端应用完增量后对比摘要不一致就强制全量。这个摘要我们用的是图元 ID 集合的哈希加每个图元的版本号异或值成本很低但能兜住绝大多数不一致。4.4 一次时钟漂移导致的乱序事故还有一次事故更隐蔽。集群里有台机器的时间同步服务挂了两天没人发现走了大概 8 秒的偏移。这台机器上的客户端发出的 opLamport 时间戳看着是正常的但指标数据里的ts是本地时钟打的比别的机器早了 8 秒。结果就是这条数据在动画插值时永远排在前面鱼群出现了一个奇怪的“抢跑”个体一直在队首。这件事之后我们做了两件事。第一所有接入数据的ts必须以服务端接收时间为准做一次偏差校正客户端上报的时钟偏差记录在标签里超过阈值的直接丢弃并告警。第二Lamport 时间戳和物理时钟彻底解耦物理时钟只用于指标插值绝不参与 CRDT 的合并判定。这两个东西混在一起是灾难的源头。5. 从日志里捞出来的经验协同画布类项目的坑位地图5.1 协同类的三个高频坑第一个坑是“看起来很简单的移动操作”。图元的移动在用户看来就是拖拽但在协同场景下一次拖拽会产生几十上百个中间位置。如果每个位置都发一次 op带宽和合并开销都会爆如果在拖拽结束时只发一次 op别人就看不到你正在拖。我们的折中做法是拖拽过程中用低频约每 80ms发“预览位置”这个 op 带一个ephemeral: true标记不参与持久化和合并判定只在广播层存活 3 秒拖拽结束时发一个正式 op覆盖整个移动。这样既能看到别人在动又不会污染文档状态。第二个坑是“选择集冲突”。两个人选中同一个图元A 删掉它B 正在改它的颜色B 的操作就落空了。处理方式是在 op 里带上前置条件如果目标已不存在服务端标记为 no-op 并回一个提示客户端把编辑面板上的“已失效”状态显示出来。别小看这个提示没有它用户会觉得“这东西不稳定”。第三个坑是“撤销重做”。CRDT 天生不记录“用户意图”它只有操作。做撤销的时候不能简单地把操作反向执行因为中间可能夹杂了别人的操作。我们的做法是维护一个本地操作栈撤销时生成一个“逆操作”把这个逆操作当作一个新操作发出去让它参与正常的合并流程。这样做的代价是撤销不保证回到历史状态但保证了全局一致性。5.2 可视化类的两个隐性坑隐性坑之一是“坐标系漂移”。缩放和平移的变换矩阵如果在前端各模块里各算一遍迟早会出现鼠标点击位置和实际图元位置差几个像素的情况。我们的做法是把变换矩阵收敛到一个Viewport单例所有坐标转换必须走它的方法禁止任何地方自己乘矩阵。这条规则写进了代码规范用 lint 规则检查违反直接构建失败。隐性坑之二是“文本测量”。Canvas 里绘制文本前必须知道宽度而文本宽度依赖字体加载状态。字体没加载完就测量得到的是回退字体的宽度字体加载完之后布局就错位了。修复方式是用document.fonts.ready阻塞首帧渲染并且把测量结果按“字体字号文本内容”做缓存避免每次重绘都重复测量——文本测量是 Canvas 里最贵的操作之一不加缓存2000 个图元的画布每秒能多烧掉 20ms。5.3 部署与运维踩过的坑部署这块有两个教训。第一个是关于慢消费者WebSocket 连接的写入如果阻塞会拖住整个广播循环。我们最初给每个连接一个无缓冲 channel结果一个网络差的用户能让整个画布卡住。改成带缓冲的 channel 之后还要处理写满的情况——我们的策略是写满就断开并让客户端重连同时把这个用户的连接标记为“降级”重连后先只给他发摘要等他确认再进广播队列。第二个是关于水平扩展。WebSocket 是有粘性的用户在哪台机器上他的 op 就发到哪台机器但广播要覆盖整个画布的所有用户所以必须有一个跨节点的广播通道。我们用的是 Redis 的发布订阅按画布 ID 分频道。这里有个坑Redis 发布订阅不保证投递节点抖动期间的消息会丢。所以增量补齐必须走 Streams 而不能走 Pub/SubPub/Sub 只负责“通知有新消息”真实数据从 Streams 里拉。这两者的分工一定要分清楚。# 关键配置片段 sync: reconnect_timeout_ms: 5000 full_snapshot_op_threshold: 5000 full_snapshot_size_threshold_mb: 2 digest_check_enabled: true broadcast: per_conn_buffer: 256 slow_consumer_policy: disconnect history_ttl_hours: 72 render: target_fps: 60 dirty_rect_enabled: true text_measure_cache_size: 40966. 把它跑起来并改成你自己的形状6.1 最小可运行配置本地跑起来只需要三样东西一个关系型数据库、一个 Redis、以及一个能在浏览器里打开的页面。首次启动会自动建表并写入一份示例画布包含三个图元和一条连线外加一个模拟数据源每两秒产生一次随机波动。想看鱼群动起来不用接任何真实数据示例数据源就够。配置项一共不到三十个我建议第一次跑的时候把render.target_fps调到 30观察 CPU 占用再慢慢调回 60。很多人上手就把画布塞满几千个图元然后觉得“这玩意儿太卡”其实是没按规模调参。图元数量在 500 以内时脏矩形基本没收益关掉反而更简单超过 1000 再打开收益才明显。6.2 插件机制自定义图元和自定义数据源MiroFish 留了两个扩展点。图元类型插件用来定义新的图形和它的渲染逻辑接口只有三个方法draw、hitTest、getBounds。数据源适配器插件用来接新类型的数据源接口也只有三个方法connect、fetch、close。接口少是刻意的接口一多插件作者就会开始往里面塞业务逻辑最后变成另一个需要维护的系统。// 自定义图元插件示例 export default { type: pool, draw(ctx, el, viewport) { const p viewport.worldToScreen(el.pos); ctx.beginPath(); ctx.arc(p.x, p.y, el.props.radius * viewport.scale, 0, Math.PI * 2); ctx.fillStyle el.props.color; ctx.fill(); }, hitTest(el, worldPoint, viewport) { const dx worldPoint.x - el.pos.x; const dy worldPoint.y - el.pos.y; return Math.hypot(dx, dy) el.props.radius; }, getBounds(el) { const r el.props.radius; return { x: el.pos.x - r, y: el.pos.y - r, w: r * 2, h: r * 2 }; } };6.3 后续还能往哪扩有三个方向我一直在想但还没做。第一个是“时间轴回放”。既然操作日志留了 72 小时理论上可以把画布的演化过程录下来回放这对复盘特别有用——会后不用靠回忆直接看当时大家是怎么讨论的。第二个是“跨画布引用”允许一个画布里嵌另一个画布的缩略视图这在画多层架构图的时候会省很多事。第三个是移动端只读视图值班同学在外面临时想看状态不需要能编辑只需要能看得清楚。我在实际用下来最深的体会是这类工具的价值不在功能多而在“打开成本”低。我们现在开复盘会的第一件事就是把 MiroFish 的画布投出来谁有想法直接上手改改完的数据是活的不用再截图、不再对口径。它替我们省掉的不是操作时间是那种“先解释一遍这张图是什么时候的”的沟通成本。如果你也在做类似的内部工具我建议先把图元和连接线这两个模型打磨到极致其余的都可以后面再加——因为一旦模型不稳后面每加一个功能都是在给未来的自己挖坑。
返回列表