消息撤回、编辑与特殊消息

撤回(recall)与编辑的共同点是「消息已经发出去过」,因此都不能靠 UPDATE 正文解决:对端可能已经收到、已缓存、已渲染,物理删除只会让各端状态分叉。正确做法是把撤回与编辑建模成事件,正文与状态分离,客户端收到事件后在本地按消息 ID 替换,服务端保留可审计的痕迹。

为什么不能物理删除

  • 已投递的消息无法回收:多端、离线设备、本地数据库都持有副本,DELETE 只能让未来的读者看不到,刚读过的人照旧可见,新旧读者视图分叉。
  • 审计与合规:群聊内容需要留存与举证,物理删除等于证据消失。
  • 序号连续性:删除会留下 seq 空洞,未读差值法与增量同步都会出现无法解释的缺口。
  • 多端一致:一端删了一端没删,用户会认为「消息随机消失」,这是最典型的 IM bug 报告来源。

所以正文行保留,终态用「状态位 + 事件」表达。状态位供查询侧快速过滤(直接返回占位文案),事件供投递与同步。

撤回的两种实现

实现写入形态读取成本优点缺点
原行状态更新UPDATE status=2无额外简单,seq 不变,列表顺序稳定热点行有锁竞争,历史分页需过滤,无法记录「谁在何时撤回」
追加撤回事件INSERT type=10客户端本地替换追加写,可审计,与增量同步天然契合,广播简单事件自身占一个 seq,客户端要处理引用与去重
两者组合(推荐)状态位 + 事件事件驱动,状态位过滤兼顾一致性与查询效率需要保证双写幂等
{
  "type": 10,
  "conv_id": "g_1001",
  "seq": 1025,
  "countable": 0,
  "recall": {
    "target_msg_id": 730199988776655,
    "target_seq": 1024,
    "operator_id": 20001,
    "reason": "sender",
    "recall_time": "2024-06-01T10:00:03.210Z"
  }
}
  • 撤回事件占一个 seq,保证所有端都能通过统一增量同步感知它;同时标记 countable=0,所以不会让未读数虚增(见上一章)。
  • 权限与时限:发送者默认 2 分钟内可撤回本人消息;群主与管理员可撤回群内任意消息(无时限);合规撤回由风控系统发起,用户侧统一显示「该消息已被撤回」。operator_id 与 reason 落表,便于追溯是谁撤的。
  • 双写幂等:事件与状态位用同一个消息 ID 作为幂等键,重放时 INSERT ... ON DUPLICATE KEY UPDATE 加状态位条件更新都不会产生副作用。

客户端的本地替换

收到撤回事件(target_msg_id = X)
  → 在本地的消息索引中按 msg_id 定位(不要按列表下标或位置定位)
    ├─ 命中 → 原地替换为占位「U1 撤回了一条消息」,不改 seq、不改列表顺序与滚动位置
    ├─ 未命中(该消息尚未加载)→ 写入本地 recall 集合,之后分页拉到 X 时直接渲染占位
    └─ X 是当前引用/回复的目标 → 引用块标记「原消息已撤回」,按策略清空或保留摘要快照
  → 若本地已把 X 计入未读,未读按事件 seq 正常扣减(因为事件占号)

两个细节:占位文案依据 operator_id 区分「你撤回了一条消息」;重复到达的撤回事件按 seq 幂等忽略。绝不能因为撤回而重排列表,否则用户正在阅读时视图会跳动。

撤回与投递的竞态

  • 消息未到、撤回先到:靠本地 recall 集合兜底(上面的分支二),到达正文时直接渲染占位。
  • 正文与事件乱序:按 seq 排序后再应用事件,或应用事件时检查目标是否已存在并打标。
  • 撤回风暴(管理员批量清理):事件合并投递(一个包携带多个 target_msg_id)+ 服务端限流,避免 2000 条撤回事件打满下行。
  • 撤回后重新编辑重发:企业 IM 的常见诉求,原文保留在客户端本地草稿,服务端不额外暴露;合规场景例外。

编辑:用版本而不是覆盖

直接 UPDATE 正文会让多端看到不同版本,也无法判断「什么时候被改过」。做法是追加版本行,消息行只记当前版本号:

CREATE TABLE im_message_revision (
  msg_id    BIGINT      NOT NULL,
  version   INT         NOT NULL COMMENT '从 1 开始,1 为原始版本',
  body      MEDIUMBLOB  NOT NULL COMMENT '该版本的正文',
  editor_id BIGINT      NOT NULL,
  edit_time DATETIME(3) NOT NULL,
  PRIMARY KEY (msg_id, version)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  • 编辑约束:仅发送者本人、仅文本与卡片类消息、时限(如 5 分钟,可配)、每消息最多 N 次。
  • 编辑广播一条 type=11 的事件(引用 msg_id 与新 version),客户端替换正文并显示「已编辑」标记;历史版本仅在有审计需求的场景开放查询。
  • 编辑事件同样占一个 seq 且 countable=0:这样离线端也能通过同一条序号流拉到它,不必为「变更」另建一套同步通道。
  • 撤回是终态:晚到的编辑事件对已撤回消息直接丢弃,避免出现「已撤回但还是新版正文」的矛盾状态。

特殊消息的表示

统一信封(envelope):msg_type + 类型化 body + 可扩展 ext。正文里的二进制一律走对象存储直传,消息体只放对象键(object key)与元数据。

{
  "msg_id": 730199988776655,
  "conv_id": "g_1001",
  "seq": 1024,
  "sender_id": 20001,
  "type": 2,
  "body": {"key": "img/2024/06/ab12cd.jpg", "w": 1080, "h": 1440,
           "size": 358400, "md5": "9f2c...", "thumb_key": "img/2024/06/ab12cd_t.jpg"},
  "quote": {"msg_id": 730199988776600, "sender_id": 20002, "summary": "报价单已发你邮箱"},
  "ext": {"aigc": false, "origin": "web"}
}
type消息body 关键字段特殊处理
1文本text、at_uids@ 需落 at 索引,供 @ 未读单独计数
2图片key、w、h、size、md5、thumb_key原图与缩略图分离,客户端按网络选择
3语音key、duration_ms、format、text转写结果异步回填,用编辑事件更新
4视频key、duration_ms、w、h、cover_key封面单独存,避免首帧黑屏
5文件key、name、size、md5按 md5 秒传判重
8合并转发多条消息的快照列表存快照而非引用,避免原消息撤回后无法渲染
9系统通知action(入群/退群/改名)、operator不计数、不产生红点
10 / 11撤回 / 编辑事件见上文不计数,但参与 seq
1000+业务自定义卡片由业务定义通过 ext 扩展,老客户端降级为「暂不支持的消息」

引用(quote)要存 summary 快照而不只是 msg_id:原消息被撤回或超出可见范围时,引用块仍有内容可渲染。

兜底与边界

  • 老客户端遇到未知 type:渲染「当前版本暂不支持此消息」,绝不允许丢弃整条消息,否则会破坏 seq 连续性判断,进而误判缺口并触发全量补拉。
  • 撤回事件丢失:与普通消息同一套 gap 检测与补拉机制,不为事件单开通道。
  • 撤回与对象存储:不立即删除文件(可能已被下载,删除只影响后续可访问性),接口层不再返回该 key、授权链接短时过期即可;只有合规下架才走「物理删除 + 缓存失效」的独立通道。
  • 撤回时限判定:以服务端时间为准,客户端只做展示,避免改本地时钟绕过限制。
  • 未读与撤回:撤回不减未读(消息确实到过),但在未读数精确校正时要把它算作 countable=0。

小结:撤回与编辑都不物理删除正文,而是追加「撤回事件 / 编辑版本」并让客户端按 msg_id 做本地替换;事件占 seq、countable=0,与未读差值法、增量同步共用同一条序号流;编辑用版本行 + 事件而非覆盖正文,撤回是终态且晚到的编辑一律丢弃;图片、语音、文件等特殊消息统一走 type + body + ext 信封,只传元数据、二进制进对象存储,引用存摘要快照,未知类型必须降级渲染而不是丢弃。

笔记加载中…