
删除也是同步——墓碑、复活与 outbox 队列
旦阅诞生记 · 13。进度同步做到这里,身份(BookKey)、坐标(绝对下标)、冲突(三层裁决)、统计(按设备求和)都齐了,还剩一个最容易被当成"顺手就做"的操作:删除。书架上长按一本书选删除,本地把文件删掉就完事了吗?没有。删本地文件是三行代码,难的是让另一台设备知道"这本书没了"——以及想清楚,它到底该不该知道。
删除是事件,不是状态
先把"删除"这个词拆开,它在一个阅读器里其实混着三件不同的事:
- 删正文文件——纯本机操作,磁盘上的 txt 没了就没了,与同步无关;
- 删本地的进度记录——书架条目、书签、时长,也是本机操作;
- 告诉其他设备"这本书的进度不要再有了"——这才是同步问题。
麻烦全在第三件。进度是状态:可以反复上报、反复覆盖,迟到一天的旧进度不改变语义。删除是事件:它发生了就发生了,而且必须在时间轴上找到自己的位置——"先写了再删"和"先删了又写",结果完全不同。
第一反应往往是:同步时把这条进度记录从服务端表里真删掉。推演一下就知道不行。设备 A 删了书,服务端删行;设备 B 下次同步把本地这份进度推上来——服务端一看没有记录,乖乖新建一条,删除被无声地撤销了,而且无从裁决:谁先谁后?没有痕迹。B 甚至不知道 A 删过。
这就是分布式存储里删除的经典处理:不删行,打标记。墓碑(tombstone)——记录还在,多了一个 Deleted 位和一个删除时间:
// 删除分支(ProgressSyncService.SyncAsync 内)
else if (!record.Deleted && at >= record.ClientUpdatedAt)
{
record.Deleted = true; // 不删行,立碑
record.ClientUpdatedAt = at; // 删除时间参与同一条时间轴
record.ServerUpdatedAt = now;
record.Revision++;
record.LastDeviceId = deviceId;
record.LastDeviceName = deviceName;
}
墓碑的本质是把"删除"降维成一种可同步的状态变更:它有内容(BookKey)、有时间戳、能和普通写入在同一条时间轴上比较先后,能随同步广播,也能重发。所有读取口径同时改掉——列表、单查、增量拉取一律 !Deleted 过滤;同步响应里墓碑单独走一个通道:
var items = merged.Values.Where(x => !x.Deleted).Select(ToDto).ToList();
var deleted = merged.Values.Where(x => x.Deleted)
.Select(x => new DeletedDto(x.BookKey, x.ClientUpdatedAt))
.ToList();
把删除塞进同一条时间轴
立碑不难,难的是碑和普通写入怎么比先后。我的规则只有一条判据:
var at = Clamp(del.ClientUpdatedAt, now); // 时钟超前的设备夹到服务端 now
if (!record.Deleted && at >= record.ClientUpdatedAt) { /* 立碑 */ }
注意它和进度写入的裁决用的是同一个字段(ClientUpdatedAt)、同一个夹取规则(Clamp)。这是刻意的:如果删除走另一套判据(比如只用服务端时间),"写"和"删"就处在两条时间轴上,交叉时会出现两头都说自己赢的死局。一个同步系统里,对同一份数据的所有操作必须共享一条时间轴,删除不是特权操作。
于是四种组合都有确定答案:
| 顺序 | 结果 |
|---|---|
| 先写后删(删的时间更新) | 立碑,进度作废 |
| 先删后写(写的时间更新) | 复活,进度恢复 |
| 时间相等(同一毫秒) | 删赢——>= 偏向删除,宁可让用户重读一次,不让"已删的书"反复诈尸 |
最后那行的取舍值得说一句:相等时偏向删除还是偏向写入,没有对错,只有代价。我偏向删除,因为"书已经从书架上拿走了"是更强的用户意志,一次误同步把它塞回来比让它安静消失更糟。
复活是这套设计里我最喜欢的一处。服务端收到对一本已删书的写入时:
else if (record.Deleted)
{
// 删除后又重新打开这本书 → 更新更晚则复活
if (clientAt >= record.ClientUpdatedAt)
InitFrom(record, dto, clientAt, deviceId, deviceName, now);
}
墓碑不是封杀令。它表达的主张是"这条进度应该消失",而不是"这本书从此不许存在"。用户在 A 设备删了书,不代表 B 设备上那份 txt 得陪葬——B 上用户要是还在读这本书,ClientUpdatedAt 更新,服务端按规则把记录复活,同步自然恢复。谁还在读,谁有权复活它。 删除的意志由"继续不读"来延续,而不是由一次操作永久锁死。
客户端:先入队,再发送
服务端立碑的前提是删除事件能可靠送达。客户端这一侧,我没有在删书时直接调删除接口,而是先登记进一个落盘的待发队列:
/** 删书时登记墓碑,下次同步把「删除」广播给其他设备。 */
export function addPendingDelete(bookKey : string, clientUpdatedAt : number) : void {
if (bookKey.length == 0) return
// 同一本书重复删除:去掉旧的,保留最新的时间戳
for (let i = syncState.pendingDeletes.length - 1; i >= 0; i--) {
if (syncState.pendingDeletes[i].bookKey == bookKey) syncState.pendingDeletes.splice(i, 1)
}
syncState.pendingDeletes.push({ bookKey: bookKey, clientUpdatedAt: clientUpdatedAt } as DeletedDto)
saveSync() // 落盘:离线删书、进程被杀,事件都不能丢
}
这就是消息系统里的 outbox 模式:业务操作(删本地书)和事件投递(广播删除)解耦,事件先持久化,发送由下一次同步顺带完成。删书那一刻可能正离线、可能同步失败、可能 App 秒退——只要队列落了盘,事件就丢不了。
队列的清理同样有讲究。同步请求发出后,确认送达的墓碑按内容匹配着清:
/** 已确认送达服务端的墓碑不再重发 */
function ackDeletes(sent : DeletedDto[]) : void {
for (let i = syncState.pendingDeletes.length - 1; i >= 0; i--) {
const pd = syncState.pendingDeletes[i]
let acked = false
for (let j = 0; j < sent.length; j++) {
if (sent[j].bookKey == pd.bookKey && sent[j].clientUpdatedAt <= pd.clientUpdatedAt) {
acked = true
break
}
}
if (acked) syncState.pendingDeletes.splice(i, 1)
}
}
为什么不干脆"发完清空队列"?因为请求在途的那几百毫秒里,用户可能又删了一本书——清空会把它一起清掉,其他设备永远收不到这次删除。按 bookKey + clientUpdatedAt 逐条匹配,新产生的墓碑自然留下来等下一轮。墓碑允许重复发送(服务端立碑是幂等的),队列只负责"至少一次",去重交给服务端。
收到墓碑之后:不删本地,只停同步
另一台设备收到墓碑,该做什么?两端的选择完全一致——什么都不删:
foreach (var d in resp.Deleted)
{
var book = _store.FindByKey(d.BookKey);
if (book is not null)
Say($"《{book.Title}》在另一台设备上被删除(本机文件保留,仅不再上传它的进度)");
}
手机端同样只计数提示:"其他设备删除 N 本"。进度记录、书架条目、正文文件,一个都不动。
这是整篇里最重要的一个取舍:删除的意志属于发起删除的那台设备,不应该破坏性地传播。 B 设备上可能还存着这本书的 txt,可能用户正读着——因为 A 上有人按了删除就把 B 的文件删掉,那不是同步,是事故。同步系统里破坏性操作默认不传播,要传播必须显式确认(这和网盘的"删除会同步到所有设备"是两种哲学,阅读器选了保守的那种)。
但要诚实地记一笔落差:提示文案说的是"仅不再上传它的进度",实现上却并不完全如此——本机下次继续阅读,updatedAt 变新,服务端按复活规则把记录救回来,同步就恢复了。这个行为本身是我要的(谁还在读谁复活),但文案撒了谎:它承诺"停上传",实际是"你不动它就安静,你一动它就复活"。措辞应该改成"若你仍在本机阅读这本书,它会自动恢复同步"。文案是接口的一部分,说的和做的不一致,用户迟早拿截图来找你。
没有身份,就没有墓碑
还有一个安静的 if 值得单独一节:
const key = b.bookKey != null ? b.bookKey! : ""
if (key.length > 0) addPendingDelete(key, Date.now())
删除广播只认 BookKey。一本书如果算不出 BookKey(导入即失败、或粘贴进来的纯文本),删它就只删本地,不登记墓碑——因为别的设备上那本书的进度记录和这本"东西"之间没有可信的身份关联,广播删除等于拿书名当凶器,可能误杀一本内容不同的同名书。系列的第二辑讲过"认出同一本书"有多难,这里只是它的另一面:身份没建立之前,一切跨端操作都保持沉默。 删不掉的代价是另一台设备上多留一条进度记录,可接受;误删的代价是别人的进度消失,不可接受。
两个还没结的账
复盘这篇时把删除链路完整走了一遍,留两笔账在这里,免得它们变成只有代码知道的秘密:
墓碑的回收。 墓碑永不删除就会累积(每本删过的书一行)。安全的回收条件是"所有设备都已确认收到这次删除",那需要一张设备同步水位表,对自用规模纯属过度设计。可行的折衷是按时间阈值(比如半年)批量清理,赌注是"某台设备半年没开过同步"——个人书库,赌得起。
明细表的残留。 立碑时 DeviceBookStat 的时长明细行不清理,而且墓碑分支之后 MergeDeviceReadTimeAsync 照样执行——删掉的书,设备时长明细还在悄悄增长;一旦复活,旧时长会原样回来。这个行为目前是"未定义"而非"错误"(复活带回时长某种意义上还说得通),但它没有经过设计决策。已记入发布计划待办,连着修复合一起验证。
小结
删除的同步,三条原则:
- 删除是事件,墓碑是它的时间轴坐标——不删行、立碑、与写入共享同一条时间轴和同一套时钟夹取;
- 破坏性操作默认不传播——收到墓碑只停主张、不删文件,本机的书由本机的用户负责;
- 先入队,再发送——落盘的 outbox 换"至少一次"投递,幂等的立碑换重复投递免疫,ack 按内容匹配而不是清空队列。
至于"谁还在读谁复活"——那是我对删除这件事的一点私心:同步系统可以替你搬运意志,但不该替你执行告别。
下一篇:增量同步的盲区——since 拉不回来的那些记录。