
做IM产品这些年起起伏伏,消息撤回这个看似简单的功能,反而是最容易踩坑的。特别是当我们要把产品推到海外市场的时候,才发现之前那套方案根本不够用。今天这篇,我想把在声网积累的一些实践经验掰开揉碎讲讲,不是什么高深的理论,都是实打实踩出来的坑。
先说个事吧。去年我们海外有个客户,日活也就几十万,按理说这种规模用单机数据库完全没问题。结果他们上线消息撤回功能之后,数据库连接数经常打满,查询延迟飙升到几百毫秒。用户撤条消息,等了快两秒才看到”对方撤回了一条消息”,体验特别差。后来排查才发现,消息表没有做分库分页,撤回的时候要全表扫描,那张表已经有好几个亿的记录了。
这个事给我触动挺大的。消息撤回功能看起来简单,但真要做好了,从协议设计到存储架构,每一个环节都得考虑清楚。特别是出海产品,还要加上跨境网络的各种不确定性,更要谨慎再谨慎。
很多人觉得,撤回就是把消息删掉或者改个状态。这话对也不对。从技术角度看,撤回的核心其实是状态变更和权限校验。你要确保只有发送方能在限定时间内撤回自己的消息,同时要让所有相关客户端感知到这次变更。
这里面有几个关键点需要搞清楚。第一是时效性,微信是2分钟,钉钉是24小时,这个时间窗口怎么定,要看产品定位。第二是可见性,消息撤回了,看的人客户端要怎么处理?是直接不显示,还是显示一条”对方撤回了一条消息”的提示?第三是一致性,在分布式的环境下,如何保证所有节点的消息状态都是一致的?
我见过不少团队在设计撤回功能的时候,把注意力都放在怎么删除消息上,而忽略了状态同步的问题。结果就是用户撤回了消息,对话列表里显示的的最后一条消息还是原来的内容,或者是不同客户端看到的状态不一致。这种体验比不功能更糟糕,用户会完全失去对产品的信任。

消息撤回本质上是一次消息状态变更,所以在协议设计上,首先要考虑的是这次变更的标识。你需要给每条消息分配一个全局唯一的ID,这个ID要能区分不同的会话和不同的消息序列。
我们常用的做法是三层结构:会话ID加消息自增序号。举个例子,A给B发消息,会话ID可能是”AB加上时间戳”这种形式,然后每条消息在这个会话里从1开始自增。这样做的好处是,撤回的时候不需要传输完整的内容,只需要告诉对方”某某会话的第几号消息被撤回了”就行,消息体积极小,网络开销可以忽略不计。
协议格式大概是这样的结构:
| 字段 | 类型 | 说明 |
| conv_id | string | 会话唯一标识 |
| msg_seq | int64 | 消息序号 |
| operator_id | string | 操作者用户ID |
| recall_time | int64 | 撤回时间戳 |
| msg_type | int | 原消息类型 |
为什么还要记录原消息类型?因为撤回提示语可能会根据消息类型有所不同。文字消息撤回显示”对方撤回了一条消息”,图片消息可能显示”对方撤回了一张图片”,这些都需要在客户端做对应的处理。
另外,协议里最好加上撤回原因的字段,虽然大多数用户都不会填,但产品层面可能会用到。比如有些公司要求审计,或者用户想要记录自己为什么撤回,这个字段就派上用场了。

时效性是撤回功能最核心的限制条件。国内很多产品用2分钟,这个时间是怎么来的?我猜和产品经理的经验有关——太短了用户来不及反应,太长了又失去了”撤回”的意义。
但出海产品要注意,不同地区对时效性的要求可能不一样。欧美用户对隐私更敏感,可能希望时间窗口更长一些;东南亚部分地区网络基础设施差,消息送达本身就慢,如果时间窗口太短,用户可能刚收到消息就已经撤不回了。
技术实现上,时间窗口的校验要在服务端做,不能信任客户端的时间。客户端发起的撤回请求,服务端要对比当前时间和消息发送时间,只有在窗口期内才能执行。那服务端的时间哪里来?建议用NTP同步到权威时间源,避免单机时间不一致导致的bug。
还有一个细节要注意:消息发送时间和送达时间是两回事。如果用户A在10:00:00发消息,用户B在10:02:30才收到,那A在10:02:00发起撤回,从服务端看是合法的,但B那边消息刚收到就被撤回了,体验非常差。所以有些产品会引入”送达时间”的概念,只有消息送达对方之后才开始计算撤回窗口。这个要看产品定位取舍,没有标准答案。
回到开头提到的那个坑,消息存储架构如果没设计好,撤回功能上线之后会非常痛苦。我整理了几个关键的设计原则,都是实践里总结出来的经验。
消息表的主键用什么?很多团队会用UUID,这个其实是下策。UUID虽然全局唯一,但无序,会导致索引碎片化,插入性能差。更好的做法是用会话ID加消息序号的复合主键,消息序号在会话内自增。这样查询和撤回的时候,可以直接定位到具体记录,不需要走索引扫描。
具体到存储层面,我们可以用MySQL分表,按会话ID取模,把数据分散到多张表里。每张表再按消息序号建索引,撤回的时候先算出会话落在哪张表,然后用消息序号直接定位。这里有个小技巧,消息序号可以用时间戳加自增序号的组合,既保证有序,又能大致按时间排序,方便后续清理历史数据。
消息撤回了,原来的记录怎么办?直接删掉?还是标记状态?
我的建议是标记状态,不要物理删除。原因有几个:第一,审计和合规需求,很多行业要求保留通信记录,撤回不等于删除;第二,便于统计和排查问题,你可以知道一条消息从发送到撤回的完整生命周期;第三,逻辑删除比物理删除的实现成本更低,不需要担心数据迁移或者误删的问题。
具体怎么做呢?给消息表加一个status字段,0代表正常,1代表已撤回。撤回的时候更新这个字段,然后通过消息变更通知让客户端刷新。这样既保留了原始数据,又不影响业务逻辑。
查询撤回消息的时候,最常见的场景是根据会话ID查询最近的消息。假设用户在某个会话里发了1000条消息,其中10条撤回了,要展示的时候需要把这10条标记为已撤回状态。
这里的关键索引是(conversation_id, message_sequence)。有了这个索引,查询某个会话的消息是O(1)的时间复杂度。但如果撤回了大量消息,这条索引可能会变得很大,需要定期重建或者做归档。
另一个容易被忽略的索引是撤回时间的索引。如果你需要实现”查看所有撤回消息”这种管理功能,这个索引就派上用场了。建议把created_at和updated_at都建索引,很多统计和审计场景都会用到。
服务端的事情搞定了,客户端也不省心。撤回消息的同步涉及到两个核心问题:一是怎么保证消息变更通知到达所有客户端,二是收到通知之后客户端怎么更新界面。
声网在实时音视频和即时通讯领域深耕多年,我们常用的通知通道有两种:TCP长连接和WebSocket。长连接的优势是稳定,适合高频次的同步场景;WebSocket则更灵活,天然支持浏览器环境。
对于消息撤回这种低频但重要的操作,一定要保证到达率。所以我们会在应用层加确认机制:服务端发送撤回通知,客户端收到后要回ACK,如果超时没收到ACK,服务端要重试。当然,重试要有上限,防止死循环。
还有一个细节是消息去重。因为网络原因,同一条撤回通知可能被发送多次,客户端要有幂等处理能力。我们的做法是每条消息变更通知都带一个全局自增的sequence,客户端记录自己处理过的最大sequence,收到重复的通知直接丢弃。
客户端收到撤回通知后,怎么更新界面?这要看产品形态。
最简单的是消息列表级刷新:收到通知后刷新整个会话的消息列表,重新从服务端拉取。这种做法实现简单,但体验不够流畅,特别是消息多的时候,会有明显的闪烁感。
进阶的做法是单条消息局部更新:定位到被撤回的那条消息,把内容替换成提示文案或者直接隐藏。这种体验更好,但实现起来复杂一些,需要在本地维护一份消息数据结构的映射。
更高级的做法是增量更新:服务端在推送撤回通知的同时,把替换后的消息体也发过来,客户端直接用新消息替换旧消息。这种方式最流畅,但对网络带宽的要求也最高。
出海产品和国内产品最大的不同,在于网络环境的复杂性。国内我们可以用专线或者CDN加速,海外就麻烦多了,不同地区的网络基础设施差异巨大,运营商劫持、跨国链路延迟、区域性网络故障都是常态。
假设用户A在国内,用户B在新加坡,A撤回了一条消息。由于跨国链路延迟,B可能在几秒之后才收到撤回通知。在这几秒内,B如果在线,会看到什么?
有两种主流的解决方案。第一种是强一致性:A撤回消息后,服务端阻塞等待,直到确认B收到撤回通知才返回成功。这种做法用户体验差,特别是在网络不好的时候,用户点完撤回要等很久才有反应。
第二种是最终一致性:服务端确认撤回成功就返回,通知通过异步通道慢慢推。这种做法体验好,但会有短暂的状态不一致。我们的做法是在服务端维护一个”撤回待同步”的状态,用户B下次上线或者刷新的时候,一定会拉到最新的状态。
如果你的用户分布在全球多个地区,可能需要部署多机房。这时候消息数据的同步就是一个大问题:用户A在国内发的消息,用户B在北美登录,消息数据怎么同步?
常规的做法是主从复制,国内机房是主库,北美机房是从库。消息发到主库,然后同步到从库。但这里有个时间差的问题,如果A在北美撤回一条国内发的消息,从库可能还没同步到这条消息,撤回就会失败。
声网的方案是用全局时钟来解决这个问题。每个数据中心都有自己的时钟服务,定期和原子钟同步,保证时间误差在毫秒级。撤回的时候带上时间戳,即使数据还没同步,只要时间窗口内,都允许撤回。后台会有异步任务处理这些冲突,保证最终一致性。
消息撤回的权限控制看似简单——只能撤回自己发的消息——但在实际场景中,会有各种边界情况需要考虑。
首先是群聊场景。群主能不能撤回群成员的消息?管理员呢?不同产品的设计不一样,我们的做法是提供灵活的权限配置,支持按角色设置撤回规则。技术实现上,每次撤回请求都要校验操作者的权限,不只是校验消息是不是自己发的。
然后是多设备同步。用户A在手机和电脑上同时登录,撤回了手机上的某条消息,电脑上要不要同步撤回?大多数产品是要的。这就需要服务端记录每条消息的发送设备,撤回的时候不仅要更新消息状态,还要通知该用户的所有在线设备。
还有一种情况是安全审计。某些行业要求所有操作都要可追溯,包括谁在什么时间撤回了什么消息。所以我们的日志系统会记录每一条撤回操作的详细信息,包括操作者、被撤回的消息ID、时间戳、IP地址等等。这些日志要独立存储,防止被篡改。
上线撤回功能之后,性能问题往往不是一下子暴露出来的,而是随着数据量增长慢慢显现。下面几个优化点是我们实践下来觉得最有效的。
批量处理撤回请求。有时候用户会连续撤回好多条消息,如果每条都单独处理,数据库压力会很大。我们会把同一会话的撤回请求合并,批量更新数据库,一次性写入。实测下来,性能提升非常明显。
异步化通知。撤回操作成功后,通知客户端可以异步做,不要阻塞主流程。服务端只管更新数据库和返回成功,然后把通知任务丢到消息队列里慢慢推。这样即使推送失败,也不会影响撤回本身的成功率。
历史数据归档。超过一定时间的消息,比如三个月前的,可以归档到冷存储里。主表数据量小了,查询和更新的速度自然就上去了。归档不影响撤回功能,因为归档后的消息同样可以更新状态,只是读的时候要从归档库读。
消息撤回这个功能,说大不大,说小也不小。它不像在线状态或者已读功能那样容易被人忽视,也不像音视频通话那样复杂。但要把每一个细节都做好,确实需要花不少心思。
从协议设计到存储架构,从客户端同步到跨境多机房,每个环节都有坑。我们团队在声网的实践过程中,一步步踩坑填坑,才有了现在这套相对成熟的方案。希望这篇文章能给正在做类似事情的同行一些参考。
技术这条路,没有捷径,唯有不断实践和总结。如果你有什么问题或者不同的看法,欢迎交流。
