“进度43%”这个标题,乍一看像是某个下载任务卡住了,或者是游戏加载界面里的数字。但作为常年泡在项目里的人,我第一反应是:这大概率是一个个人项目或者团队项目的中期节点记录。43%这个数字很微妙,它不是50%那种“刚好过半”的里程碑,也不是30%那种“刚起步”的早期阶段。它意味着最难的架构决策已经做完,但最磨人的调试和收尾工作还遥遥无期。
这篇文章我就拿最近手头一个真实项目来讲讲“进度43%”这个状态背后到底藏着什么,以及走到这一步时,你会遇到哪些典型问题、应该怎么调整节奏、有哪些坑我已经替你们踩过了。如果你也正处在一个项目的中间地带,感觉进度不上不下、动力时有时无,那这篇文章应该能给你一些参考。
1. 项目整体设计与思路拆解
1.1 43%这个节点意味着什么
先说说项目本身。我最近在做的这个项目是一个面向中小团队的内容协作工具,核心解决的是“多人同时编辑一份文档时,怎么避免互相覆盖”这个老生常谈但一直没被完美解决的问题。市面上类似的工具不少,但要么太重(适合大企业但小团队用不起来),要么太轻(只能写写笔记,连个版本对比都没有)。所以我们的定位很明确:做一个轻量但具备完整版本管理能力的协作工具。
到目前进度43%,具体是什么概念?按功能模块拆的话,大概是这样:
- 账号系统和权限管理:基本完成,包括第三方登录、邀请链接、角色分配
- 编辑器核心:完成60%,富文本编辑、Markdown 快捷键、实时保存都通了,但协同光标和冲突合并算法还在调
- 版本历史:刚起步,数据库表结构定了,但时间线 UI 和 diff 展示还没开始做
- 文件导入导出:完成80%,Markdown 导入导出已经稳定,但 Word 和 PDF 这块翻车了好几次
说实话,走到 43% 这个节点,我的一个最大感受是:前期的架构设计真的救了我一命。这个阶段最容易出现的问题就是“每修一个 bug 就引入两个新 bug”,而如果你在前期没有把模块边界划清楚,这时候代码已经成一锅粥了。我前期硬是花了两周时间只做架构设计和数据模型规划,没有写一行业务代码,当时觉得很浪费时间,现在回头看,这一个决定至少给我省了一个月的返工时间。
1.2 为什么选这些技术方案
这个项目的技术栈是前端 React + TypeScript,后端 Go + PostgreSQL,实时协同部分用了 WebSocket 搭配自定义的 CRDT 实现。可能有人会问,为什么不用现成的 Yjs 或者 Automerge,非要自己写一套?
原因有三点:第一,Yjs 确实很成熟,功能也强大,但它的包体积和抽象层数对我们这种“轻量”定位来说有点重了,而且深度定制的时候会碰到不少黑盒逻辑,排查问题很痛苦;第二,我们确实有比较特殊的合并策略需求——团队的编辑场景里,有一些“必须由管理员手动确认”的冲突类型,这在通用的 CRDT 算法里没办法优雅表达;第三,自己实现一套,虽然前期成本高,但后续排查问题、做性能优化的时候,代码是长在自己脑子里的,不用去翻第三方实现。
这个决策对不对?目前来看,方向是对的,但中间踩了不少坑,后面我会详细讲。如果你也要做类似的东西,我的建议是:除非你有明确的定制需求,否则还是优先用现成方案,毕竟轮子这东西,自己造一次就知道有多费劲了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 协同编辑的冲突合并算法到底怎么做
这个项目的核心难点,也是 43% 这个进度里最硬核的部分,就是多人同时编辑同一篇文档时,如何合并大家的修改。
我们采用的方案是基于操作转换的思路,但加了一些自己的改动。简单来说,每个用户做的每一次编辑(插入一个字、删除一段话、格式化一个标题)都被封装成一个“操作”对象,这个对象包含操作类型、位置信息、文本内容和时间戳。当一个用户的操作通过 WebSocket 广播到服务器时,服务器会先把这个操作应用到一个单一的全量文档状态上,然后再把操作分发到其他在线用户的客户端。
听起来很简单,对吧?真正的难点在于并发操作的顺序处理。比如用户 A 在文档第10行插入了一句话,同时用户 B 在第10行删除了一个词,这两个操作如果到达服务器的顺序不同,最终的结果也会不同。CRDT 的思路就是让每个操作带上足够多的上下文信息,使得无论操作以什么顺序到达,最终收敛到同一个状态。
我花了差不多一周半的时间去调这个问题,最后的解决方案比较笨但有效:每个字符在存储时都带上一个全局唯一的 ID(由用户 ID + 计数器生成),插入操作引用的是“前一个字符的 ID”而不是单纯的位置。这样即使两个人同时在同一个位置插入,两个字符也会被放在不同的顺序上,不会互相覆盖。删除操作则是给目标字符打上 tombstone 标记,而不是物理删除。
2.2 实时保存和断网恢复的设计细节
协同编辑还有一个特别容易被低估的问题:用户断网了怎么办?
我们现在的设计是:客户端的每一步操作都会先写入本地 IndexedDB,同时通过 WebSocket 发送到服务器。如果 WebSocket 断开,操作会继续累积在本地队列里,界面显示一个轻微的“离线状态”提示。等网络恢复后,客户端会把队列里的操作批量回放给服务器,服务器按顺序处理完毕后,返回最新状态,客户端再更新本地。
这里有个很重要的细节:回放操作时,必须按照产生顺序严格串行发送,不能并发乱序发。我就吃过这个亏,一开始为了加速重连过程,把队列里的操作三个五个并发往上发,结果一次回放之后文档乱成一堆,差点没回滚回来。后来改成单线程顺序发送,配合 Ack 确认机制,一条发完确认了再发下一条。通过这种设计,断网 2 分钟内重连,基本可以做到无感恢复;断网超过 10 分钟,系统会主动提示“操作已过期,建议复制本地内容后重载页面”。
这块我特别想提醒大家的是:本地缓存一定要做数据校验,不要无脑信任 IndexedDB 里的内容。浏览器清理缓存、用户手动删数据、磁盘空间不足导致写入失败,这些情况下 IndexedDB 返回的数据可能是坏的或者不完整的。我们现在的做法是给每一条本地记录加一个简单的 CRC32 校验,读取时校验失败就丢弃并输出日志,至少能定位问题而不是默默出现诡异的现象。
2.3 数据库表结构设计里那些事后才想明白的事
后端的核心表就三张:users(用户)、documents(文档)、operations(操作日志)。三张表各有一个“软删除标记”字段,因为实际操作中的内容误删和恢复场景,远比预想的多。
日志表在设计时有一个取舍:是把每一步操作都存下来做“完整回放式”的版本历史,还是只存快照、把版本历史做成间隔备份?前者存储耗高,但功能强大,任何时刻的文档状态都能精确还原;后者简单直接,但还原精度受快照间隔限制,两次快照中间的小修改在历史里就看不到了。
我们最后选了第一种,理由是:对于一个主打“版本管理”的工龄来说,精确到每次击键的历史记录才是真正的卖点。但这带来的直接问题就是数据膨胀得特别快。一篇3000字的文档,正常协作两天之后,这个文档关联的 operation 记录就能达到上万条。为了不让查询性能变得不可控,我们把 operation 表按文档 ID 做了 hash 分片,同时定期把老的操作记录压缩合并成快照(每50个版本压缩一次),压缩后该文档的“历史回放”性能基本维持恒定,用起来体感好很多。
3. 实操过程与核心环节实现
3.1 从架构图到第一行代码的关键决策
项目启动时,我先做了一件事:画一张尽量完整的架构图。不是那种敷衍的 PPT 图,而是把每个模块的接口、数据流向、异常处理路径都标清楚的那种。这张图花了两天时间,但在之后开发过程中几乎没有出现过“A 模块要和 B 模块通信,但接口在 C 模块”的情况。
接下来是数据模型设计。我们用了一个比较传统但稳妥的方案:每个表的主键都用雪花 ID,而不是数据库自增 ID。原因很直接:未来做分库分表可能用得着,而且在前端生成 ID 再发到后端,可以更好的支撑离线创建文档、离线写入一系列操作,保证网络恢复后不需要做重复数据消除。一开始就做了这个设计,后期省掉了改主键的噩梦。
然后才是搭建前后端工程骨架。这个阶段有个体感很真实的经验:千万别想着“随写随改”。如果你在写第一行业务代码之前没把统一的错误码、日志格式、时间戳规范、环境变量管理这些基础设施定好,后面迟早要大面积返工。我们这次花了4天时间做工程基建,中间不少同事觉得太磨叽,结果进了业务开发阶段,所有人都在庆幸当初把地基打牢了。
3.2 文档编辑器的协同实现详细过程
编辑器部分我用的底层是 Slate.js,这个库本身不提供协同能力,只提供文档模型。协作逻辑完全是在 Slate 之上自己实现的。核心流程是:
- 本地操作产生时,先走一遍 Slate 的 onChange 回调,拿到操作描述对象;
- 把操作对象转发给一个统一的操作通道(Coordination Service),这个通道负责给操作打上全局顺序编号(可以通过时间戳+客户端ID排序);
- 操作分发到所有客户端,每个客户端按顺序把操作应用到自己本地的文档模型上。
这里有一个调试时候特别折磨人的难点:本地操作和远端操作同时发生时,怎么保证本地状态不出错?我们的方案是:本地产生操作后,先立即应用在本地,同时把操作广播出去;其他客户端收到这个操作时,如果它自己也产生了操作,就按“服务器分配的顺序号”统一排序,确保所有客户端在同一个序列上应用操作。这样能保证最终一致性,但代价就是,如果网络延迟比较明显,某个客户端可能会短暂地看到文档“跳了一下”然后恢复,这算是一个体验上的权衡。
对于这块的工程质量,有一个指标我强烈建议大家关注:协同状态下的“操作收敛率”。我们开发了一套简单的测试逻辑,多个客户端同时随机编辑同一文档,每 100 次编辑后检查所有客户端的内容是否一致。最初这个收敛率只有 91% 左右,后来修了一堆 CRDT 边界问题才提升到 99.5%,但仍不敢说 100%,还在继续优化。
3.3 项目中期的基础设施与测试体系建设
开发进行到接近 40% 的时候,我和团队做了一件事:暂停 3 天,集中搭建自动化测试和 CI/CD 流水线。很多人觉得这太浪费时间,但实践证明这一步堪称整个项目的“续命丹”。
我们目前有约 180 个单元测试(覆盖核心逻辑模块)、15 个集成测试(模拟一个完整用户操作流程)以及 3 个端到端测试(启动真实的前端和后端跑一个多用户并发编辑场景)。CI/CD 用的是 GitHub Actions,每次 push 都会跑全部测试,测试通过之后才允许合并到主干分支。搭好这套体系后,后面两周的 bug 率至少下降了 70%,很多问题在 merge 之前就被挡在了门外。
如果你做的是多人协作的项目,请务必留出时间把测试体系建起来。这不是锦上添花,而是项目能活到上线的基本保障。做完基础设施之后的开发速度反而更快了——因为你知道自己动了一行代码会不会弄坏别的东西,敢大胆改代码,这种安心感对效率的提升是巨大的。
4. 常见问题与排查技巧实录
4.1 并发编辑时文档内容静默丢失
这个问题我们大概遇到了三四回,现象是两个用户同时在同一个段落里编辑,最后合入的文档里,某一句子凭空消失了,而双方在各自编辑器里都看到自己的改动是存在的。
排查了一圈,最终定位到的原因:CRDT 中字符的 tombstone 标记和重新插入逻辑出现了竞态。场景是这样的:A 删除了一段文字(打了个 tombstone),B 在同一时刻向这一段文字内部插入了新内容(新内容引用了“前一个字符”的 ID,但那个字符刚被标记为 tombstone)。在我们的实现里,新插入的字符因为引用了一个“已删除”的字符,导致没有在正确的位置显示,更糟的是这个字符在后续更新的序列里被跳过了,最终表现为内容丢失。
解决的方案是在算法层面做了一层“保护性逻辑”:当插入操作引用的前一个字符是 tombstone 状态时,自动寻找最近的、仍然活跃的字符作为插入锚点。这个修复之后,类似的丢失问题没有再出现过。这块的经验是:CRDT 的实现细节绝对不能想当然,每一行涉及 tombstone 的代码都需要额外的条件判断测试。
4.2 中断重连后操作被重复发送
这个问题是在调试离线恢复流程时发现的。用户断网期间操作了 10 分钟,恢复网络后程序把队列里的操作成功发送了,但之后因为一个回调触发错误,导致整个队列被重发了一遍。
结果就是文档里出现大量的重复字符。我在数据库里查了 operation 表,才发现问题:我们虽然给每条操作生成了 UUID,但服务器端在应用操作时没有对 UUID 做去重判断。换句话说是,服务器没有实现“幂等性”保证。
修复方案分了两层:服务端在收到操作时先检查 UUID 是否已存在,存在则跳过执行,只返回 Ack;客户端在重发队列前,先做一次本地去重。修改完之后,类似问题再没出现。涉及网络传输的项目,幂等性检查应该是根植于设计原语级别的,而不是最后打补丁解决的问题。
4.3 长文档性能劣化与前端卡顿
当一篇文档的字数超过 10000 字,并且操作历史达到几千条之后,前端开始出现明显的卡顿。原因不难猜到:每次操作都要遍历整个文档模型,把每个字符的状态和 tombstone 重新计算一遍,随着文档变大,这个开销成倍上涨。
我们的优化策略是分两步走:第一步,引入“可见区域渲染”策略,只渲染当前视口附近的内容,用虚拟滚动替代一次性全部渲染,这一步把渲染相关的性能拖慢解决了大半;第二步,在 CRDT 算法层面做增量计算,不再每次更新事件都全量 diff 文档,而是只更新受影响的那一段内容。两个优化叠加后,即使是 5 万字的文档,打字响应时间也能稳定在 100ms 左右。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 处理建议 |
|---|---|---|
| 并发编辑后内容丢失 | tombstone 处理逻辑不严谨 | 检查插入时锚点是否合理找到活跃字符,添加规则处理引用 tombstone 的情况 |
| 离线恢复后内容重复 | 操作重发且服务器未做幂等 | 操作带上 UUID,服务端保存已处理 ID,重复则忽略;客户端也做本地去重 |
| 长文档输入卡顿 | 全量 diff 导致计算量过大 | 改用增量计算 + 虚拟滚动渲染,不渲染不可见区域 |
| 快速操作后文档顺序错乱 | 操作顺序号分配跨设备不一致 | 统一以服务器接收时的顺序编号,客户端本地操作等待编号后再广播 |
| 离线编辑超时无法保存 | 本地队列无限增长 | 设置队列长度上限和时间上限,超限后提示用户备份并重载页面 |
| 权限变更后旧会话未失效 | 权限校验只在登录时做 | WebSocket 消息里也附带权限校验,服务端每次操作前再查一次权限 |
5. 后续规划与个人体会
5.1 接下来 43% 到 100% 的路怎么走
进度到 43% 之后,接下来的开发节奏我会调整。前 40% 的精力大多花在了攻坚核心算法和技术难点上,后面 57% 我计划分配成三块:功能补全(30%)、稳定性打磨(30%)、体验细化(40%)。功能补全主要是把版本历史的 UI、文件导入导出格式完善做好;稳定性打磨就是加强测试覆盖、压测、异常恢复这些“看不到但随时可能致命”的部分;体验细化则包括各类边缘情况的前端交互优化,比如多人同时移动光标时的视觉效果、编辑器边框闪烁提醒、邮件通知的文案这种细节。
这个分配比例可能让人意外,但我越来越觉得,一个工具类的产品,决定用户去留的往往不是“核心功能够不够酷”,而是“日常用起来舒不舒服”。核心算法做得再牛,用户没有感知,但一个文案提示写得贴心、一个加载动画过渡流畅,用户却会实实在在记住。所以越到后期,我越会把精力倾斜到体验上。
5.2 站在 43% 位置的复盘与建议
回头看看这个 43% 的过程,我最想分享给自己的经验是:做项目不要迷信“快速迭代”,前期架构设计多花的时间,后面一定能省回来。这几乎是这次项目最大的收获。
其次,尽快建立自动化测试和持续集成体系,这事越早做,后面省的时间越多。别想着“等核心功能做完了再补测试”,到时候代码量已经大到补不动了。最后就是,遇到并发、同步、离线这类问题时,一定要做好操作幂等和日志全程可追踪,这是调试效率的生命线,没有这两样,排查一个诡异 bug 可能要耗掉好几天时间,而有了它们,也许两个小时内就能定位到问题。
5.3 给同样处在项目中期的人一句实在话
如果你现在也卡在项目的“43%”这个不上不下的阶段——功能做了不少,但离能用还有一段距离,每天打开代码库都有点不知道从哪下手——我的建议是:不要慌,更不要为了凑进度而压缩质量。把剩下的部分拆成一个一个具体的、可验收的小任务,按“今天我能拿下哪个”来排优先级。同时,每周真正留出完整的一天来做技术债清理和测试修复,而不是只看新功能数量。
项目的中间段永远是最难熬的。前面的新鲜感已经消耗殆尽,后面的大功告成还没影子,只有琐碎的问题和不断的调试在磨你的耐心。但恰恰是这种时候,最能拉开项目走向的分水岭。扛得住这一段,把地基打好,后面推进的速度会让你自己都惊讶。
按照我现在的节奏,剩余部分大概还需要六到八周。中间免不了还会有新的坑、新的突发问题,但走到 43% 之后,我对整个项目的技术路径和实现方案都有了把握,剩下的主要是时间问题和执行问题。这大概是这个阶段最值得欣慰的一件事:方向已经清晰,剩下的只是埋头走路。
