消息列表已经显示“消息已撤回”,稍后刷新详情,旧正文却重新出现。这类问题不一定出在撤回按钮上:详情请求、实时通知和本地缓存可能走了不同路径,最后到达的数据覆盖了先到达的状态。
以米米商聊为背景,已整理的手机端功能资料包含“撤回”消息操作。本文只借这个交互提出工程问题,不推断产品的撤回时限、接口协议或实际缓存实现。下面的字段、版本与代码都是独立教学设计,所有消息正文均为合成数据。
1. 删除一条缓存,为什么反而丢了判断依据
假设消息 m1 最初处于版本 1,正文为“旧正文”。客户端发出了详情请求 A,服务端在处理 A 时返回版本 1;这份响应在网络中延迟了。随后撤回事件 B 携带版本 2,先到达客户端。
如果 B 的处理方式只是删除 m1 的缓存,A 到达时就可能执行“没有缓存则新增”。客户端已经忘记自己见过版本 2,于是把版本 1 的正文重新放回列表。
问题在于:缺少记录,不能同时表达“从没加载过”和“已经撤回过”。 两种情况需要不同的合并决策。
可以保留一条不含正文的撤回占位记录,通常称为墓碑记录(tombstone):它不是继续保留原消息内容,而是保留该消息的身份、版本和终止状态。后到的旧详情仍有一个比较对象。

2. 先约定版本含义,再写合并规则
本例约定:一个缓存实例只服务一个会话;消息 ID 在该会话内不复用;版本由可信的权威端按消息状态变更递增,详情与撤回事件使用同一套版本。客户端收到的只是已经解析好的普通数据对象。
version 不是客户端接收时间,也不是两个设备各自生成的序号。没有共同的版本语义,比较大小只是在比较两个数字,无法说明哪份业务状态更新。
状态只有 active 和 withdrawn。前者必须包含字符串正文;后者的正文必须为 null。本例把撤回定义为终止状态:重发要使用新的消息 ID,不能把同一个 ID 恢复为有效消息。这是示例的业务约束,并非对任何产品恢复策略的描述。
| 输入相对已有记录 | 处理方式 | 原因 |
|---|---|---|
| 版本更低 | 返回 stale,保留已有记录 |
旧快照不能覆盖新状态 |
| 版本相同,状态和正文相同 | 返回 duplicate |
重复投递不重复改变状态 |
| 版本相同,内容不同 | 报版本冲突 | 同一版本不能代表两份不同状态 |
| 版本更高 | 校验状态转换后更新 | 更大版本仍要满足业务约束 |
| 没有已有记录 | 接受合法输入 | 可先收到撤回,也可先收到正文 |
会话范围必须参与校验,否则不同会话中的同名消息可能互相覆盖。版本也必须处于可精确比较的数值范围。这里用 Number.isSafeInteger() 校验正整数;超出范围的版本需要另行约定字符串或其他编码,不能直接转成 Number 后比较。API 的安全整数范围见 MDN 文档。
3. 一个保留撤回状态的最小缓存
下面实现使用 Map 存储消息。Map.set() 更新同一个键的值,但不会自动处理版本或状态约束,这些规则仍由业务代码实现,见 MDN 的 Map 说明。
function createMessageCache(conversationId) {
const validId = value => typeof value === "string"
&& /^[A-Za-z0-9_-]{1,64}$/.test(value);
if (!validId(conversationId)) throw new TypeError("invalid conversationId");
const records = new Map();
function normalize(raw) {
if (raw === null || typeof raw !== "object"
|| raw.conversationId !== conversationId || !validId(raw.id)) {
throw new TypeError("invalid message scope or id");
}
if (!Number.isSafeInteger(raw.version) || raw.version < 1) {
throw new TypeError("invalid version");
}
if (raw.state !== "active" && raw.state !== "withdrawn") {
throw new TypeError("invalid state");
}
if ((raw.state === "active" && typeof raw.body !== "string")
|| (raw.state === "withdrawn" && raw.body !== null)) {
throw new TypeError("invalid body");
}
return {
conversationId, id: raw.id, version: raw.version,
state: raw.state, body: raw.body
};
}
return {
accept(raw) {
const next = normalize(raw);
const prev = records.get(next.id);
if (prev) {
if (next.version < prev.version) return "stale";
if (next.version === prev.version) {
if (next.state === prev.state && next.body === prev.body) {
return "duplicate";
}
throw new Error("conflicting payload at the same version");
}
if (prev.state === "withdrawn" && next.state === "active") {
throw new Error("withdrawn is terminal in this example");
}
}
records.set(next.id, next);
return "applied";
},
get(id) {
if (!validId(id)) throw new TypeError("invalid id");
const current = records.get(id);
return current ? {
...current } : undefined;
}
};
}
ID 的字符范围与 64 字符上限只是教学约定,不代表产品限制。输入中的额外字段不会进入缓存,非法字段会在修改 Map 前抛错。真正的接口解析仍需按自己的数据结构设计。
这里把字段复制成新对象,读取时再返回一个副本,避免调用者通过改写输入对象或返回对象改变内部状态。正文只有字符串或 null,其余字段也都是基本类型,所以浅复制足够覆盖本例;将来加入嵌套附件、富文本对象时必须重新评估复制策略。对象展开属于浅复制,见 MDN 的展开语法说明。
4. 用延迟响应和重复事件验证结果
把上一段与下面的演示放在同一个 JavaScript 文件中执行。演示只模拟事件的到达顺序,不调用真实产品接口,也不使用真实聊天内容。
const cache = createMessageCache("c1");
const active = {
conversationId: "c1", id: "m1", version: 1,
state: "active", body: "旧正文"
};
const withdrawn = {
conversationId: "c1", id: "m1", version: 2,
state: "withdrawn", body: null
};
console.assert(cache.accept(active) === "applied");
console.assert(cache.accept(withdrawn) === "applied");
console.assert(cache.accept({
...active }) === "stale");
console.assert(cache.accept({
...withdrawn }) === "duplicate");
console.assert(cache.get("m1").state === "withdrawn");
console.assert(cache.get("m1").body === null);
console.log(cache.get("m1"));
最终记录的版本是 2,状态为 withdrawn,正文为 null。关键结果是第三次输入返回 stale:旧详情有合法格式,仍然不能覆盖撤回占位记录。
本文配套验证还覆盖了撤回先到、等版本不同正文、跨会话输入、输入及输出对象被修改、版本范围和非法状态等边界。对“有效正文版本 1、撤回版本 2、旧正文版本 1、重复撤回版本 2”四份输入枚举 24 种到达排列,最终状态均为撤回。该结果限定在这些合法输入和这个同步合并器内,不能替代实际网络、数据库或多端测试。
同一版本发生冲突时,示例报错并保持已有状态。接入应用后应触发日志与权威数据核对,不能随意选择最后到达的正文。错误上报也不应直接附带完整聊天内容。
5. 哪些边界不能靠一条墓碑记录解决
| 场景 | 这个示例的边界 |
|---|---|
| 缓存被清空或占位记录被淘汰 | 版本知识丢失,旧正文可能再次被当成首次输入 |
| 分页列表里没有出现某条消息 | 缺席不等于撤回,不能据此制造墓碑 |
| 详情与撤回来自不同版本体系 | 数字大小不再有共同业务含义 |
| 权威端提供了不合法的状态历史 | 客户端不能只凭最大版本证明历史正确 |
| 其他页面、通知或旧对象仍持有正文 | 此处更新不会自动清除那些副本 |
一个直接的反例是:创建新的空缓存,然后向它输入演示中的版本 1 正文,结果仍为 applied。它没有见过版本 2,自然没有依据拒绝版本 1。因此,不应写成“保留墓碑就永远不会恢复旧正文”。
实际淘汰策略需要明确什么时候可以安全放弃旧版本知识,例如使用权威快照及其同步水位,确保更早的输入不会再进入当前缓存;必要时保留更轻的版本元数据。这些协议并未在本文实现。单独设定一个固定 TTL,或用某一页查询结果替换整个缓存,都不足以证明安全。
终止状态的检查也有前提。若空缓存先收到版本 3 的有效正文,再收到版本 2 的撤回,本例会把后者判为旧数据。它无法凭两个孤立快照判断权威端是否违反了“撤回后不能恢复”的约束。这需要权威端保证状态历史,或通过同步协议核对。
此外,body: null 只表示当前缓存记录不再保存正文。此前返回给调用者的副本、截图、通知、日志与备份不会因此自动消失,不能把缓存状态更新写成对所有内容的彻底删除承诺。
6. 接入前,把约束落到每一条数据入口
首先确认消息 ID 的范围、版本的生成方,以及详情、列表和实时事件能否共用一套版本。随后将它们接到同一个合并入口;如果详情页面仍然直接赋值正文,列表里的墓碑再正确也无法保护那条旁路。
渲染层读取状态:有效消息展示正文,撤回记录展示占位提示。重复事件不应重复追加占位项;版本冲突与非法恢复应进入核对流程。再检查刷新、分页、离线重连和缓存淘汰是否会绕过合并规则。
最需要验证的不是“撤回事件能不能删掉正文”,而是“撤回之后到达的每一种旧数据,会被哪条规则拦住”。保留版本与撤回状态,为这个问题提供了可检查的依据;它的适用范围由身份、版本、数据入口和保留策略共同决定。
本文由 AI 辅助整理,示例代码经过独立本地验证。产品背景只引用已整理的手机端功能资料,工程方案与合成演示不代表产品内部实现。