消息撤回不是删除:IM 消息的状态机设计

SailTrack
2026-08-11
点 赞
0
热 度
5
评 论
0
  1. 首页
  2. 后端开发
  3. 消息撤回不是删除:IM 消息的状态机设计

写在前面

微信的撤回体验是:“你撤回了一条消息”,灰字挂在原位。很多人以为这是把消息删了——如果真删了,离线的设备同步时会出大乱子:别人设备上有这条消息,你的设备上没有,下次同步以谁为准?

所以撤回的正确模型不是"删除",是"消息还在,状态变了"。这篇记录我给 IM 消息做的状态机:NORMAL → EDITED → RECALLED,以及围绕它的一圈校验。顺带讲讲编辑和撤回共用的那条核心纪律。

一、消息的一生:三种状态

im_message 表的 status 字段就三个业务值:

NORMAL(正常) ──编辑──> EDITED(已编辑)
    │                      │
    └──────────撤回────────┴──> RECALLED(已撤回)

撤回的实现是把消息改写而不是删除:

UPDATE im_message
   SET content_json = CAST(? AS JSON),
       status = 'RECALLED',
       edited_at = ?
 WHERE id = ?
   AND status IN ('NORMAL', 'EDITED')

被替换进去的内容是固定的占位:{"text": "消息已撤回"}。原内容被覆盖,状态置为 RECALLED,edited_at 记下撤回时间。行还在、seqNo 还在、位置还在——所有人的消息列表里它都还在原来的位置,只是变成了一句灰字。

为什么保留行而不是删除? 三个理由:其一会话的消息序号(seqNo)是连续的,删行会造成空洞,同步协议要为空洞写一堆特例;其二其他设备离线期间可能已经收到过原消息,"原消息彻底消失"会让多端一致性变成玄学;其三撤回是广播过的事实——"他撤回了一条消息"本身就是要同步给所有人的信息,删了就没得广播了。

二、撤回的资格校验:三层问答

点撤回按钮之前,服务端要回答三个问题:

private void ensureRecallableMessage(ImMessageRow message, Long conversationId, Long userId) {
    ensureOwnMessageInConversation(message, conversationId, userId);
    if (!Set.of("NORMAL", "EDITED").contains(message.status())) {
        throw new BizException(409, "当前消息状态不允许撤回");
    }
}

private void ensureOwnMessageInConversation(ImMessageRow message, Long conversationId, Long userId) {
    if (!conversationId.equals(message.conversationId())) {
        throw new BizException(404, "消息不存在");
    }
    if (!USER_SENDER_TYPE.equals(message.senderType()) || !userId.equals(message.senderId())) {
        throw new BizException(403, "只能操作自己发送的消息");
    }
}

第一层:消息属于这个会话吗? 注意跨会话的引用返回的是 404 而不是 403——“消息不存在”。用 403 回答"这个消息在别的会话里",等于向枚举者确认了消息的存在和位置。信息泄露往往就藏在这些细微的状态码选择里:403 说"它在但我不能看",404 说"无可奉告",能保密的边界一律用后者。

第二层:是自己的消息吗? IM 撤回权限比企业 OA 严格得多——只能撤自己发的。群主和管理员都没有撤回他人消息的能力(那属于"管理动作",要有独立的权限模型和审计,混进普通撤回里迟早出事)。

第三层:状态允许吗? 只有 NORMAL 和 EDITED 可撤。RECALLED 的消息不能再撤(它已经撤了),二次撤回返回 409——409 Conflict 是"操作和当前状态冲突"的标准答案,比 400"参数错误"准确得多:参数没问题,是世界的状态变了。

三、并发撤回:让数据库当裁判

校验通过和 UPDATE 执行之间有时间差——双端同时点撤回(比如手机和电脑都开着会话)时,两个请求可能都通过了校验。真正的防线在 UPDATE 的 WHERE 里:

int updated = imMessageRepository.recallMessage(messageId, contentJson, LocalDateTime.now());
if (updated == 0) {
    throw new BizException(409, "当前消息状态不允许撤回");
}

WHERE status IN ('NORMAL', 'EDITED') 是数据库层面的状态守卫:两个并发撤回,第一个把状态改成 RECALLED,第二个的 UPDATE 匹配不到行、影响行数为 0,返回 409。校验是给人看的提示,WHERE 条件才是给并发看的规则——这一层不能省,也不该省。

这个模式我用到过很多次:假期冻结的 UPDATE 带 deleted_at IS NULL,任务拾取带 status = 'PENDING'——把状态机的边写在 SQL 的 WHERE 里,并发正确性就不依赖应用层的运气。

四、编辑:同一条规则的两个出口

编辑的实现和撤回共享几乎全部结构:校验"是自己的消息 + 状态允许",然后改写 content_json、状态置 EDITED、记录 edited_at,最后广播 IM_MESSAGE_EDITED 事件。

区别只在两处。编辑允许的次数不限(状态从 NORMAL 到 EDITED 后仍是 EDITED,可以继续编辑),撤回是终态。编辑后的内容是真实的,撤回后的内容是占位符。前端把两种事件渲染成不同的样子:"已编辑"的小标、"消息已撤回"的灰字。

两者共用的广播纪律也值得记一笔:

pushConversationEventAfterCommit(
    conversationId,
    ImWebSocketEvent.of(ImWebSocketEventTypes.MESSAGE_RECALLED, ...));

事件挂到事务提交后才推送——系列里讲领域事件时说过这个原则:未提交数据上的推送等于让在线用户提前看到还不存在的世界。变更落库和实时推送共享同一个事务边界,在线的人靠 WebSocket 收到事件,离线的人靠 HTTP 补拉对账,两条路最终汇到同一份数据。

五、撤回之后:下游的连锁反应

RECALLED 状态会像涟漪一样扩散到下游能力,每处都要接住:

附件下载:撤回的消息带过附件,下载接口要先过状态检查——ensureMessageAllowsAttachmentDownload 同样只放行 NORMAL/EDITED,已撤回消息的附件立即不可下载。

AI 引用:员工向 AI 助手引用某条消息提问,如果那条消息被撤回,引用它的后续提问不应继续携带已撤回的内容——内容已被占位符覆盖,自然失效,这也是"覆盖而非删除"的另一个红利:引用它的所有链路自动看到占位符,不需要挨个通知。

翻译/转发:同样以状态为前置条件。转发时校验"有文本或有可下载附件",撤回消息两者皆无,转发自然被拦。

状态机的美妙之处就在这里:下游不需要知道"撤回"这个动作发生过,只需要遵守状态语义。

六、小结

三点体会:

  1. 撤回是改写,不是删除。 行在、序号在、位置在,变的是内容与状态——多端同步和引用链路都因此保持简单。
  2. 校验给人看,WHERE 给并发看。 应用层的资格检查决定用户体验,SQL 的状态守卫决定并发正确性,后者永远不能只靠前者。
  3. 404 和 403 的边界就是信息边界。 资源存在与否能保密时就保密——状态码的选择是安全设计的一部分,不是随手一填。

上线后的一个深夜,同事在测试群里撤回了一条发错的消息,另一端显示"消息已撤回"——整条链路无声无息。IM 的很多正确性就是这样:无声,但经得起推敲。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

65 文章数
11 分类数
5 评论数
60标签数