那段文字是谁删的?Yjs 14 正式版之前,一套删除归属方案的实现与边界

三个人一起改一份方案。

小林写下一段说明,小周删掉其中一句。过了一会儿,刚恢复网络的小陈把本地文档同步到服务器。版本历史里,那句话确实没了。

问题是:这次删除,该记在谁名下?

写下它的人、按下删除键的人、最后把更新传到服务器的人,可能是三个不同的人。只看最终文档,或者把"谁的连接触发了更新"当成"谁删的",都会出错。

这是协同编辑里一个常被低估的地方:让大家看到同一份文档,和准确记录每个人做过什么,是两件事。

下面这套方案来自开源仓库 yjs-v13-deletion-attribution。它的思路是:删除发生时先存证据,生成版本时再把证据对回文档。我也会说清楚,它为什么还不能直接当成审计系统。

先说清范围:我核对的是仓库提交 4bb8b3e。项目声明依赖 yjs: ^13.6.29,锁文件实际固定在 13.6.32。所以"Yjs 14 之前"在这里指的是这套基于 v13 内部结构的实现------它没有验证过所有早期版本,也不对 v14 作任何推断。

一、Yjs 记得"哪一段消失了",但不记得"谁删的"

Yjs 是协作文档背后的同步引擎。它要处理的事不少:多人同时改、更新乱序到达、同一份更新收到两次,最后还要让每个人的屏幕保持一致。

为此,文档里的内容都带着身份。最关键的两个字段是 clientIDclock

clientID 指向这段内容由哪个客户端创建------注意,它不是业务系统里的用户账号。clock 是这个客户端内部递增的编号,也不是真实时间。两个合起来,才能锁定某一段具体内容。

比如下面这条删除范围,读作:"客户端 10 创建的、编号 4 到 7 之前的那段内容,被删了。"

plaintext 复制代码
client: 10
范围: [4, 7)

[4, 7) 包含 4、5、6,不含 7。它只是方便理解的编号示例,不是你文档里的第 4 到第 7 个字。

Yjs 的 DeleteSet(删除集合)存的正是这类范围。**它告诉你删除落在谁创建的内容上,却不告诉你是谁动的手。**就算你另外维护了"客户端到用户"的映射,也不能顺理成章地把内容创建者当成删除者。

删完之后,内部可能还留着"这块被删过"的结构,俗称墓碑。墓碑是给同步和结构维护用的,不是一张自带签名的删除凭证;原文也可能在垃圾回收里被清掉。

所以这篇文章说的"删除归属",准确讲是删除这个动作归谁 ,不是被删内容的原作者归谁。记录里用的字段叫 author,你把它理解成"这次删除记在谁头上"就对了。

二、关键一步:身份当场记,位置以后再算

只在删除时记下"某人在第 20 个字处删了一刀"是不够的。别人稍后在前面加一段,那个第 20 字的位置就变了。

位置会动,内容的内部身份不动。所以这套实现把活拆成两步。

**第一步,服务器收到更新时:**从这次更新里取出它声明要删的范围,和这次事务真正新产生的删除范围对一遍。对得上,就把范围和认证过的用户身份一起存下来。

**第二步,生成版本快照时:**拿存下来的范围,回到文档里找到对应的删除墓碑,再算出它在这个版本里的位置。

图 1:先保存通过检查的删除证据,再计算它在对应版本中的位置。

第一步存下来的是这样一条记录:

typescript 复制代码
{
  client: 10,
  from: 4,
  to: 7,
  author: 'bob'
}

它的意思不是"Bob 删了第 4 到第 7 个字",而是"这段用内部编号标记的删除范围,算在 Bob 头上"。

整套算法只做两件事:捕获定位------捕获在更新到达时完成,定位在生成快照时进行,中间只靠一份存储记录衔接。骨架大致是这样:

typescript 复制代码
// 阶段一:更新到达时,先存证据
capture(update, user):
    claimed = 从 update 解析出它声明的删除范围
    actual  = 本事务实际新产生的删除范围
    valid   = claimed ∩ actual
    if valid 为空 or 未通过来源检查:
        return 空
    保存 { client, ranges: valid, author: user }

// 阶段二:生成快照时,再把证据对回文档
attribute(document, stored):
    marks = []
    for { ranges, author } in stored:
        for range in ranges:
            pos = 在 document 里定位 range 对应的删除痕迹
            if pos 存在:
                marks.add({ at: pos, author })
    return 去重(marks)

有点像寄存行李:当场记下行李编号和经手人,以后再回来问它放在哪。不能只记一个货架位置------架子随时会挪。

但真正难的不是存这四个字段,而是:凭什么断定这段删除该算在眼前这个人头上?

三、不是"谁传过来,就算谁删的"

先排除一个常见误判:历史同步不是现场操作

协同编辑里有类消息叫 SyncStep2,作用是补齐对方缺的那部分文档状态。它可能带着别人之前做过的删除。

开头例子里,小陈恢复网络后传来的历史状态,不代表那些旧删除都是小陈干的。

所以这套实现对 SyncStep2 直接返回空声明,只从 SYNC_UPDATE 消息里取出可核对的删除范围。

但这里有个限制要记住:**消息类型只是过滤器,不是作者证明。**如果业务允许客户端把别人的历史更新重新打包成 SYNC_UPDATE 上传,算法没有本事凭空认出原始操作者。

也就是说,这套方案能成立,前提是宿主系统的身份认证和更新来源是可信的。它适合可信协作链路里的归属计算,但别指望靠一个消息类型,就升级成能对抗恶意伪造的审计系统。

再对一遍:声明删了,不等于这次真删了

这套做法每次拿两个集合来比。

一个是这条消息声称要删的范围 ,一个是这次事务实际新产生的删除。只有两边都有的部分,才进入归属记录。一句话讲:

候选归属范围=本帧声明的删除范围∩本事务实际生效的删除范围候选归属范围 = 本帧声明的删除范围 \cap 本事务实际生效的删除范围 候选归属范围=本帧声明的删除范围∩本事务实际生效的删除范围

举个例子。同一个客户端编号下,消息声明删除 [4, 9),而服务器这次实际新增的删除只有 [6, 10)。两者重叠的是 [6, 9)------也只有这一段能进候选。

图 2:交集只是候选结果;身份认证和事务前已知范围检查仍不可省略。

求交的逻辑很直接:先看 client 是否相同,起点取大的、终点取小的,起点小于终点才留下。

typescript 复制代码
intersect(a, b):
    if a.client != b.client:
        return 空
    from = max(a.from, b.from)
    to   = min(a.to, b.to)
    return { client: a.client, from, to } if from < to else 空

这一步同时挡住两种麻烦。

一种是重复删除。Bob 和 Carol 都删了同一段,如果 Bob 的更新先在这个服务端生效,Carol 的更新到时可能根本没产生新的删除。这时就不能凭 Carol 的声明,把同一段再记给 Carol。

这也带出一个重要边界:**这套方案记的是这个副本上实际生效的删除贡献,不是所有用户按过删除键的完整流水。**它没法证明现实中谁先动手,也不保证不同副本各自算出来的归属天然一致。想要统一结果,就得设一个明确的服务端归属入口,并把结果存下来。

另一种是延迟生效的删除。某次删除先到,可它要删的内容还没到,Yjs 会先把这次删除挂起,等目标内容到了再处理。于是一个由某人触发的事务里,可能混进了别人早先留下的删除。

取交集,能把不在本次声明里的那部分筛掉,避免把"刚好让删除生效的人"当成"发起删除的人"。这也是为什么光看 transaction.origin 不够:它能帮你对上事务来源,却没法替事务里所有删除作证。

最后一道门:目标内容必须"事先就在"

这套实现还有一道保守检查:被删的内容,在这次事务开始前,服务器是不是已经知道它?

它看的是 beforeState,也就是事务开始前各客户端已知的下一个 clock。假设某个客户端的值是 20,那 [4, 7) 完全落在已知范围内;而 [18, 22) 越过了边界,就有一部分不能被认定"事先已知"。

为什么非要这道门?

因为离线用户可能先写一段话、又把它删掉,最后才把整个状态同步上来。服务器一下子同时看到"内容到达"和"内容已删",很难分清这是当前用户刚做的,还是转手来的历史。

它的选择是:宁可漏记,也不误记。

而且它比"只把越界的那一小段滤掉"更狠:**只要本次实际删除里有一段越过了事务前的已知边界,整笔事务都不出归属记录。**它不会把安全的部分裁下来留着。

代价就是,一笔事务里本来能记的删除,也可能被整体跳过。这是准确度和覆盖率之间的取舍。产品说明里最好写清楚,别把"没有记录"直接当成"没发生删除"。

四、字没了,名字该标在哪?

捕获做完,还有个展示问题:删掉的内容已经不占位置了,界面往哪儿画标记?

这套实现选的是零宽锚点,也就是一个不占宽度的小点位。

比如原文 hello,Bob 删掉中间的 el,剩 hlo。标记就落在 hl 之间,提示"这里发生过一次删除"。它不是硬把已经不存在的两个字符,画成现在还占着的一段区间。

输出长这样:

typescript 复制代码
{ at: 2, author: 'bob' }

at: 2 用的是这套实现里编辑器的结构坐标,不是你肉眼数的"第二个字"。在这套实现的段落结构里,进入段落算一个位置、走过 h 再算一个,所以锚点落在 2。

这个位置是怎么走出来的?

定位的做法是:从指定的 XmlFragment 出发,沿着内部链表往前走。

typescript 复制代码
locate(document, range):
    pos = 0
    for node in walk(document):              // 沿内部链表前进
        if node 是已删除内容:
            if node.clientID 与 clock 范围 命中 range:
                marks.add({ at: pos, author })   // 留下标记,位置不前进
        else if node 是叶子:
            pos += 1                             // 图片等叶子各占一位
        else if node 是普通非叶子元素:
            pos += 1                             // 进入
            ...                                  // 递归子树
            pos += 1                             // 离开
        else:
            pos += node 的可见长度               // 活着的文本
    return marks

遇到还在的文本,位置往前走;进入和离开一个普通的非叶子元素,各往前走一位;遇到 isLeaf 判定的叶子节点,按一位算。比如你的编辑器把图片当叶子,就得把这条规则告诉算法。

遇到已删除的内容,位置不动,而是去比对墓碑的 clientID 和 clock 范围,看是不是和外部记录重叠。对上了,就在当前位置留一个作者标记。

这也解释了为什么不能拿一份普通文本来算:段落、图片、嵌套节点各有各的坐标规则。isLeaf 必须和你编辑器的 schema 对齐------但对齐了,也还不等于所有复杂富文本场景都算对。

同一个位置冒出两个作者,并不矛盾

假设原文 abcdef。Bob 删 cd,Carol 接着删 ef,最后只剩 ab

两次删除在当前文档里,可能落到同一个锚点:同一个 at: 3,可能同时对应 Bob 和 Carol 两个人。

图 3:删除内容不再占用当前位置;同一锚点可以保留不同删除者的标记。

这不是说两人删了同一段内容,而是两个不同的已删除范围,在剩下的文档里收到了同一个位置。Yjs 也可能把相邻的删除并到一起,所以别假设"一块墓碑只对应一个人"。

它按"位置 + 作者"去重:同一个位置的不同作者都留着;同一作者在同一位置的多次命中,合并成一个标记。

所以要记住:**标记数量不等于删除次数,标记列表也不是完整操作流水。**界面上可以点开悬浮提示看全部人员,但别随手只留一个名字,更别把数组顺序讲成"谁先动手"。

五、怎么接进真实系统?

从业务角度看,整条链路是:收更新 → 绑事务 → 记范围 → 暂存证据 → 生成快照 → 存下归属。

收到消息时,宿主先确认这条连接的认证用户,再解析消息声明的删除范围。身份要来自服务器验证过的会话,不能让客户端自己填个用户名就算数。生产环境里也建议用稳定的用户 ID,展示时再换成昵称。

接着,必须把这条消息和它实际产生的 Yjs 事务对上。一种稳妥的做法是按连接排队:消息到达时先登记这份声明,等它对应的 Yjs 事务结束后再取走消费。

有个很容易照抄出错的细节:会产生事务的 SyncStep2 **虽然不参与归属,也要留一个空声明占位;不产生对应事务的消息,则不能一律塞进同一条队列。**否则后面的声明会被前面的事务吃掉,作者和删除范围就错位了。

真正接入时,还得盯住鉴权失败、解析失败、中途报错、连接关闭、服务器内部事务这些情况会不会打乱对应关系。这套实现给的是配对工具,不是适配所有版本 Hocuspocus 的完整协议插件。

协议解析也有前提。这套实现按"文档名、Sync 消息类型、同步子类型、payload"这套封装来读消息,再交给 Yjs 的更新解码。如果网关把文档名剥掉了,或者用了别的封装、Update V2,你都得自己适配------不能把任意二进制消息直接丢给它。

事务结束时,用这笔事务自己的 beforeStatedeleteSet 去捕获,别反复用文档启动时缓存的那份状态向量。核心调用大致是这样,身份解析和事务配对仍然归宿主负责:

typescript 复制代码
function capture(beforeState, deleteSet, claimed, author):
    actual = deleteSet                        // 本事务实际新增的删除范围
    valid  = claimed ∩ actual                 // 声明与实际求交
    if valid 为空:
        return []                             // 没有可归属的删除
    if valid 越过了 beforeState 的已知边界:
        return []                             // 宁可不记,也不误记
    return [{ client, from, to, author } ...] // 通过检查,写入证据

然后按文档把 records 存起来------可以放在内存里,也可以放进 Redis。生成快照时,先领取还没处理的记录,再算出它们的位置,把文档状态和归属一起落库;写失败就把记录还回去。

长期来看,最好分开存两种数据:原始范围记录是以后重算的依据;算好的 at **只对应生成它的那一版文档。**如果只存锚点、丢掉原始记录,换一份文档状态后,旧坐标就未必还成立。

还有一点要事先想好:领取操作拿到的是当前待处理的记录,不会自动把过去所有版本的记录都累计回来。产品若要展示"截至这个版本的全部删除历史",累计和查询规则得业务层自己做。

六、再往深一层,三处边界不能省

上面讲的思路成立,不等于这套实现已经把所有富文本和审计问题都解决了。下面三点,是真正上项目之前值得先处理的。

1. 内部结构长度,不等于可见字数

当前的文本遍历,会把每个未删除文本 Item 的长度都累加进位置。

但用来表示格式变化的内部内容(比如一段加粗),在内部结构上会算一个长度,却不该占一个可见字符的位置。

于是:**在带这类格式 Item 的文本链上,现有遍历可能把格式标记也当成一个位置,让后面的删除锚点整体偏移。**光补一个 isLeaf 回调,解决不了这个问题。

这和"加粗等于多一个字"恰恰相反:内部为了表示加粗多加的结构,本来就不该算成一个字。

真要补强,得把"用来做身份匹配的 clock 长度"和"用来显示的可见长度"分开:活着的格式标记不该推进文本位置;纯格式删除算不算"删字记录",也该单独定规则。必要的话,还得赶在垃圾回收之前留住足够的类型信息。

说明一下,这是对着源码推出来的风险判断,不是我已经跑过的复现实验。

2. 先删句子、再删整段,旧归属可能找不到锚点

设想 Bob 先删掉一段字,Carol 随后把整个段落删掉,系统直到这时才生成快照。

当前遍历碰到已删除的父级 Item,会匹配父级本身,然后直接跳到右边,不再进它的子树。Bob 早先那条指向子内容的记录,就不一定还找得到。

垃圾回收会让情况更糟:Yjs 回收内部子项时,还会清掉类型节点的入口指针。所以别假设任何历史删除的墓碑,永远都能从最终文档树里走出来。

关掉垃圾回收也不是一键解决,因为当前算法本身就会跳过已删除父节点的子树。

如果业务要求长期留住这类历史,就得扩展设计:趁结构还定位得到时留下父链或锚点依据,维护历史文档状态,或者扩展对已删除子树的定位规则。光靠当前这份文档和一个 clock 区间,没法承诺所有旧删除都找得回位置。

3. Redis 原子领取,不代表证据绝不丢

Redis 实现用 Lua 做原子操作:领取时一次性读取并删除列表项;写快照失败,还能把记录还回去。

它解决了一部分并发领取的问题,但没把 Redis 和快照库变成同一个事务。

如果进程在领取成功、写数据库之前崩了,异常处理根本没机会把记录还回去,记录照样丢。追加失败、归还失败也需要监控,不能只靠一句没处理结果的异步调用。

另外,队列默认保留 24 小时,每个文档最多存 5000 条范围记录,超了会裁掉更早的。**它更像一个有容量上限的中转站,不是无限期的审计档案。**串行队列只管当前存储实例内的操作;Redis 脚本的原子性,也不会自动给你跨实例的快照一致性。

要更稳,得考虑持久事件表,或者带领取凭证、超时恢复、确认机制的队列,用幂等标识处理重试,并把"文档状态"和"记录批次"的一致性边界划清楚。这些都是建议补强,不是它已经有的能力。

特别要避开一个时序坑:生成快照时文档还在收更新,最后存下的是时刻 A 的状态,却贴上了在时刻 B 算出来的坐标。状态和锚点,必须来自同一份确定的文档版本。

七、这些测试说了什么,又漏了什么?

这套实现的测试覆盖了四件事:删除范围捕获与已知状态检查、真实文本墓碑到零宽位置的映射、相邻不同作者的删除,以及同步消息解析、SyncStep2 不参与归属。

其中,"删掉 hello 里的两个字符"和"两人依次删相邻内容"用的是真实 Yjs 文档和事务,能给核心示例撑腰。

但这离上线验收还差得远。

前面说的格式 Item 坐标问题、已删除父节点里的旧归属、跨实例快照竞争、领取后进程崩溃,当前测试都没覆盖;并发重复删除、延迟删除重放,也不能仅凭说明文字就当成已有专门的回归测试。

上线前,至少得把这些风险变成针对你实际编辑器 schema 和服务端消息链路的测试。图片、嵌套节点、撤销重做、离线合并、序列化后重新加载、同一事务里混着已知和未知删除范围,这些也都该补上。

性能也得按真实文档量来测。现在求交是双重遍历,墓碑匹配逐条比对,标记去重还要扫一遍已有结果;实现里既没有按 client 建的区间索引,也没有性能基准。长文档或大批记录可以考虑分组、排序后再求交,配上更高效的查找和去重结构------但在没测过之前,别宣称现有实现能扛住某个并发量。

还有一句得讲明:我是对着源码和测试内容做的静态核对,没在真实环境里跑测试或压测。所以"仓库写了这些测试",不等于"这篇文章验证它们全通过了"。

八、它到底能给产品什么承诺?

如果你想在可信协作链路里,给 Yjs v13 文档补上"谁删了这里",这套实现给了个清楚的起点:先核对删除证据,再保存稳定范围,最后把范围映射到对应版本的位置。

它不是从最终快照里猜作者,也没把最后发更新的人当成删除者。

但用它的时候,产品上最好把三件事分开:

"删除归给某人",意思是这条记录通过了来源和范围检查;"没有归属结果",可能是判不准、记录丢了,或者当前文档已经定位不到------都不能反过来说"没发生删除";"完整审计历史",要的就不止这几个零宽标记了,还得有事件身份、时间、原始记录和可靠的持久化。

如果你还想显示被删掉的原文,那得另外留住历史版本或相关内容。一个 { at, author } 不含原文,更不会自动具备恢复能力。

回到开头那个问题:小陈把更新传上来,不足以让系统把这句删除记在他名下。真正该出现在版本历史里的名字,来自删除当下留下的证据;没有这份证据,界面就应该允许答案是------这次删除的执行者无法可靠确定。

相关推荐
hold?fish:palm1 小时前
34 合并K个升序链表
javascript·算法·链表
Navigator_Z3 小时前
LeetCode //C - 1252. Cells with Odd Values in a Matrix
c语言·算法·leetcode
野槐3 小时前
前端项目优化(有了我,会发生...)
前端
幸运小圣4 小时前
PointerEvent 常用属性与事件整理【JavaScript】
开发语言·javascript·ecmascript
aa小小4 小时前
【无标题】
前端
语戚5 小时前
力扣 1621. 大小为K的不重叠线段的数目:动态规划(Java 实现)
java·算法·leetcode·动态规划·力扣·dp
青山木5 小时前
Hot 100 --- 最长有效括号
java·数据结构·算法·leetcode·动态规划
计算机魔术师5 小时前
还在单线程跟 AI 聊天?Claude Code 并行多会话让你一个人干三个人的活
前端
志尊宝5 小时前
Vue3 零基础每日笔记(038):亲手封装 useDebounceFn 与 useThrottleFn——高频事件的流量阀
前端·javascript·vue·html·vue3