summaryrefslogtreecommitdiff
path: root/2025/05/08/Multiplayer-Collaborative-Systems-tips
diff options
context:
space:
mode:
authormuqiuhan <[email protected]>2025-09-09 06:17:00 +0000
committermuqiuhan <[email protected]>2025-09-09 06:17:00 +0000
commitd5de65fdb1802cdf498d65d93397f290813c377c (patch)
tree92fe61a76d20d1203203665d4b48e7f151f088bd /2025/05/08/Multiplayer-Collaborative-Systems-tips
parent48efa2dfde7c263f84ee5bb0872747034908d607 (diff)
downloadblog-d5de65fdb1802cdf498d65d93397f290813c377c.tar.gz
deploy: 9665097f0fa0dae9f92123fac54d26c0818758a5
Diffstat (limited to '2025/05/08/Multiplayer-Collaborative-Systems-tips')
-rw-r--r--2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html82
1 files changed, 51 insertions, 31 deletions
diff --git a/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html b/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
index 47a9feef..42f2895c 100644
--- a/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
+++ b/2025/05/08/Multiplayer-Collaborative-Systems-tips/index.html
@@ -193,36 +193,41 @@
</div>
<div class="post-content">
<p>最近碰到一块业务:在系统中可以存在多个用户同时对某个项目信息进行编辑,这种多人协作的场景挺有意思的,不过在我们的业务中,并不需要实时协作,只需要保证不会出错就行,话虽如此,但也可以探索一下实时协作的实现方案,防止老年痴呆。</p>
-<p>先来看看第一个方案 —— CRDT(Conflict-free Replicated Data Type,无冲突可复制数据类型)是一类数据结构,它保证了在分布式节点(或多客户端)上进行离线&#x2F;并发更新后,无需中心协调、也无需人工干预,通过“合并策略”就能得到一致的最终状态。 </p>
+<p>先来看看第一个方案 —— CRDT(Conflict-free Replicated Data Type,无冲突可复制数据类型)是一类数据结构,它保证了在分布式节点(或多客户端)上进行离线/并发更新后,无需中心协调、也无需人工干预,通过“合并策略”就能得到一致的最终状态。</p>
<p>核心思想是:所有并发操作都是幂等(idempotent)、可交换(commutative)的。</p>
-<p>常见类型有:<br>一、G-Counter(只能增计数器)<br>二、PN-Counter(可增可减计数器)<br>三、LWW-Register(最后写入胜出)<br>四、结合 JSON 的树型 CRDT(如 <a target="_blank" rel="noopener" href="https://github.com/automerge/automerge">Automerge</a> &#x2F; <a target="_blank" rel="noopener" href="https://yjs.dev/">Yjs</a>) </p>
-<p>更多原理可参考 Decipad 博客“Collaborative and Offline Editing Using CRDTs”[^1]。</p>
+<p>常见类型有:<br>
+一、G-Counter(只能增计数器)<br>
+二、PN-Counter(可增可减计数器)<br>
+三、LWW-Register(最后写入胜出)<br>
+四、结合 JSON 的树型 CRDT(如 <a target="_blank" rel="noopener" href="https://github.com/automerge/automerge">Automerge</a> / <a target="_blank" rel="noopener" href="https://yjs.dev/">Yjs</a>)</p>
+<p>更多原理可参考 Decipad 博客“Collaborative and Offline Editing Using CRDTs”<sup class="footnote-ref"><a href="#fn1" id="fnref1">[1]</a></sup>。</p>
<blockquote>
<p>有一个挺有趣的 Rust 项目 <a target="_blank" rel="noopener" href="https://github.com/loro-dev/loro">Loro: Make your JSON data collaborative and version-controlled with CRDTs</a></p>
</blockquote>
-<p>假设我的项目信息编辑页面允许多人实时&#x2F;离线修改某个研究项目的“名称”、“描述”字段,前端用 SvelteKit + GraphQL 获取和提交变更:</p>
+<p>假设我的项目信息编辑页面允许多人实时/离线修改某个研究项目的“名称”、“描述”字段,前端用 SvelteKit + GraphQL 获取和提交变更:</p>
<figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">┌── 用户 A 离线修改了 “description” 的若干段文本 </span><br><span class="line">└── 用户 B 同时在线修改了同一字段的其他段落 </span><br></pre></td></tr></table></figure>
-
<p>如果后端使用 CRDT(比如把 <code>description</code> 用 JSON-CRDT 存储),两次修改只要在任意顺序合并都能得到完整的内容:</p>
-<p>首先,A 客户端本地 apply 操作并缓存,恢复网络后推给服务器;<br>然后,服务器用 CRDT merge(A.delta, B.delta),得到一致文档<br>最后,服务器广播新文档到所有客户端,A&#x2F;B 均得到相同结果 </p>
+<p>首先,A 客户端本地 apply 操作并缓存,恢复网络后推给服务器;<br>
+然后,服务器用 CRDT merge(A.delta, B.delta),得到一致文档<br>
+最后,服务器广播新文档到所有客户端,A/B 均得到相同结果</p>
<hr>
-<p>好了说点实际符合业务场景的方案,首先想到的是悲观锁(Pessimistic Locking) ,思路是:用户打开编辑界面时,向后端申请“锁” → 其它用户尝试编辑时被拒绝 → 编辑完成后释放锁&#x2F;超时自动释放。 </p>
+<p>好了说点实际符合业务场景的方案,首先想到的是悲观锁(Pessimistic Locking) ,思路是:用户打开编辑界面时,向后端申请“锁” → 其它用户尝试编辑时被拒绝 → 编辑完成后释放锁/超时自动释放。</p>
<p>假如有一个这样的锁表:</p>
-<pre><code class="sql">CREATE TABLE project_lock (
+<pre><code class="language-sql">CREATE TABLE project_lock (
project_id UUID PRIMARY KEY,
locked_by UUID NOT NULL,
expires_at TIMESTAMPTZ NOT NULL
);
</code></pre>
<p>可以在事务内申请它:</p>
-<pre><code class="ts">const now = new Date();
+<pre><code class="language-ts">const now = new Date();
const expires = new Date(now.getTime() + 5*60*1000); // 5 分钟后过期
await prisma.$transaction(async tx =&gt; &#123;
const existing = await tx.project_lock.findUnique(&#123; where:&#123; project_id &#125; &#125;);
if (existing &amp;&amp; existing.expires_at &gt; now) &#123;
- throw new Error(&#39;项目正被人编辑&#39;);
- &#125;
+ throw new Error('项目正被人编辑');
+ &#125;
await tx.project_lock.upsert(&#123;
where: &#123; project_id &#125;,
@@ -232,14 +237,16 @@ await prisma.$transaction(async tx =&gt; &#123;
&#125;);
</code></pre>
<p>释放锁就直接从锁表里删掉对应的数据即可:</p>
-<pre><code class="ts">await prisma.project_lock.delete(&#123; where:&#123; project_id &#125; &#125;);
+<pre><code class="language-ts">await prisma.project_lock.delete(&#123; where:&#123; project_id &#125; &#125;);
</code></pre>
<p>前端的话,大概就是:</p>
-<p>在进入编辑前请求一下 <code>/api/project/:id/lock</code> 之类的 API,失败则提示“被占用”;<br>在 <code>onbeforeunload</code> 时执行 <code>/unlock</code>;<br>超时后后端自动允许新锁。 </p>
+<p>在进入编辑前请求一下 <code>/api/project/:id/lock</code> 之类的 API,失败则提示“被占用”;<br>
+在 <code>onbeforeunload</code> 时执行 <code>/unlock</code>;<br>
+超时后后端自动允许新锁。</p>
<hr>
-<p>第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。 </p>
+<p>第二个方案是乐观并发控制(Optimistic Concurrency) :记录资源的版本号或时间戳;客户端提交更新时带上自己的版本号,后端检查版本是否一致,不一致则认为冲突,返回 409,由客户端告知用户“数据已过期,请刷新后合并”。</p>
<p>具体实现中,可以尝试在 <code>project</code> 表加上 <code>version INT NOT NULL DEFAULT 1, updated_at TIMESTAMPTZ</code> ,然后更新项目时:</p>
-<pre><code class="ts">async updateProject(parent, &#123; id, version, input &#125;, ctx) &#123;
+<pre><code class="language-ts">async updateProject(parent, &#123; id, version, input &#125;, ctx) &#123;
const result = await prisma.$executeRaw`
UPDATE project
SET name = $&#123;input.name&#125;,
@@ -247,38 +254,51 @@ await prisma.$transaction(async tx =&gt; &#123;
version = version + 1,
updated_at = now()
WHERE id = $&#123;id&#125; AND version = $&#123;version&#125;
- `;
+ `;
if (result === 0) &#123;
- throw new ConflictException(&#39;版本冲突,请刷新后重试&#39;);
+ throw new ConflictException('版本冲突,请刷新后重试');
&#125;
return prisma.project.findUnique(&#123; where:&#123; id &#125; &#125;);
&#125;
</code></pre>
-<p>前端捕获到冲突错误可以用一个弹窗提示“另有用户已更新此项目,是否合并&#x2F;重新加载?” 之类的玩意儿。</p>
+<p>前端捕获到冲突错误可以用一个弹窗提示“另有用户已更新此项目,是否合并/重新加载?” 之类的玩意儿。</p>
<hr>
-<p>第三个方案是:操作转化(Operational Transformation,OT) </p>
-<p>也就是记录用户每次的“操作”(insert&#x2F;delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。 </p>
-<p>有一些实现案例:<br>一、ShareDB(Node.js)<br>二、Google Docs 中的同步算法 </p>
-<p>具体实现的话,可能要现在前端逐字符&#x2F;块地包装成操作并 WebSocket 推送,服务器再维护一个“操作历史队列”,每来一个 op 就 transform 并 broadcast,而客户端收到广播后,按顺序 replay 保证视图一致。</p>
+<p>第三个方案是:操作转化(Operational Transformation,OT)</p>
+<p>也就是记录用户每次的“操作”(insert/delete at position),服务器根据历史操作序列对并发操作做转化(transform),确保先到达的操作调整后再应用后到达的。</p>
+<p>有一些实现案例:<br>
+一、ShareDB(Node.js)<br>
+二、Google Docs 中的同步算法</p>
+<p>具体实现的话,可能要现在前端逐字符/块地包装成操作并 WebSocket 推送,服务器再维护一个“操作历史队列”,每来一个 op 就 transform 并 broadcast,而客户端收到广播后,按顺序 replay 保证视图一致。</p>
<hr>
<p>最后可能还可以用事件溯源(Event Sourcing)+ 场景命令模式来实现:</p>
-<p>不直接存状态,而是存所有“命令 &#x2F; 事件”(Event),回放事件得到当前状态。冲突通过合并策略或补偿事件(Compensating Events)解决。 </p>
-<p>例如:<br>在每次更新时推送 <code>ProjectUpdated &#123; projectId, fieldsChanged, userId, timestamp &#125;</code> ,<br>然后写入事件存储(如 Kafka &#x2F; EventStoreDB),<br>读端 Consumer 按顺序重建最新状态或按领域聚合 ,<br>最后在并发时如果两个事件都修改了同一字段,可在写端做校验&#x2F;补偿,或在读端做最后写入胜出等策略 。</p>
+<p>不直接存状态,而是存所有“命令 / 事件”(Event),回放事件得到当前状态。冲突通过合并策略或补偿事件(Compensating Events)解决。</p>
+<p>例如:<br>
+在每次更新时推送 <code>ProjectUpdated &#123; projectId, fieldsChanged, userId, timestamp &#125;</code> ,<br>
+然后写入事件存储(如 Kafka / EventStoreDB),<br>
+读端 Consumer 按顺序重建最新状态或按领域聚合 ,<br>
+最后在并发时如果两个事件都修改了同一字段,可在写端做校验/补偿,或在读端做最后写入胜出等策略 。</p>
<hr>
<p>总结来说,</p>
<ul>
-<li>CRDT 最擅长 去中心化、离线编辑、自动合并; </li>
-<li>若不引入 CRDT,可根据业务侧重点选用: <ol>
-<li>悲观锁 → 强制串行编辑,简单粗暴; </li>
-<li>乐观并发 → 适合大多数业务场景,成本低; </li>
-<li>OT → 适合富文本或实时协同场景,复杂度中等; </li>
+<li>CRDT 最擅长 去中心化、离线编辑、自动合并;</li>
+<li>若不引入 CRDT,可根据业务侧重点选用:
+<ol>
+<li>悲观锁 → 强制串行编辑,简单粗暴;</li>
+<li>乐观并发 → 适合大多数业务场景,成本低;</li>
+<li>OT → 适合富文本或实时协同场景,复杂度中等;</li>
<li>事件溯源 → 适合需要全历史审计、可回放的场景。</li>
</ol>
</li>
</ul>
<hr>
-<p>[^1]: Decipad 博客 “Collaborative and Offline Editing Using CRDTs”<br> <a target="_blank" rel="noopener" href="https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts">https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts</a></p>
-<p>[^2]: Hacker News 讨论(CRDT 相关线程)<br> <a target="_blank" rel="noopener" href="https://news.ycombinator.com/item?id=38289327">https://news.ycombinator.com/item?id=38289327</a></p>
+<hr class="footnotes-sep">
+<section class="footnotes">
+<ol class="footnotes-list">
+<li id="fn1" class="footnote-item"><p>Decipad 博客 “Collaborative and Offline Editing Using CRDTs”<br>
+<a target="_blank" rel="noopener" href="https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts">https://www.decipad.com/blog/decipads-innovative-method-collaborative-and-offline-editing-using-crdts</a> <a href="#fnref1" class="footnote-backref">↩︎</a></p>
+</li>
+</ol>
+</section>
</div>