消息撤回、编辑与特殊消息
撤回(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 信封,只传元数据、二进制进对象存储,引用存摘要快照,未知类型必须降级渲染而不是丢弃。