
做即时通讯开发的朋友应该都清楚,消息撤回和恢复这两个功能看起来简单,实际做起来门道还挺多的。我第一次在项目里接到这个需求的时候,觉得,不就是发个撤回指令吗,能有多复杂。结果真正动手的时候才发现,这里头涉及到状态同步、终端一致性问题,还有各种边界情况要处理。今天我就把在声网做即时通讯开发时积累的一些经验分享出来,希望能给正在做这个功能的朋友一点参考。
消息撤回这个功能,本质上是让发送方能够”收回”已经发出去的消息。在用户的认知里,撤回就是消息从聊天记录里消失不见了。但从技术角度来说,我们要做的其实是更新消息的状态,并且让所有相关设备都同步这个变化。
这里有个关键点需要理解:即时通讯系统的消息通常会在多个终端同步。一个人可能同时在手机、平板、电脑上登录同一个账号,当你撤回一条消息时,必须确保这三个设备上的消息状态都能正确更新。任何一个设备漏掉了这次同步,用户体验就会出问题。
消息恢复则是另一个维度的需求。有时候用户撤回了一条消息,过了一会儿又后悔了,想把消息找回来。这就需要我们在服务端保留一定期限的历史消息数据,提供恢复的接口。
在讨论具体实现之前,我们先聊聊整体的架构设计思路。声网在这块的技术方案是采用分层管理的模式,把消息的存储层、服务层和同步层分开来做。
存储层负责消息的持久化,需要考虑快速写入和高效查询。服务层处理业务逻辑,包括撤回指令的验证、恢复操作的权限判断等。同步层则是确保各个终端能够及时收到状态更新的通知。

这个分层的好处是什么呢?当你想修改撤回逻辑的时候,不会影响到消息存储;当你优化同步策略的时候,也不用改动业务层的代码。职责分离清晰,后续维护和迭代都会轻松很多。
消息表的设计直接决定了撤回和恢复功能能不能做好。我见过一些团队在设计初期没考虑周全,后面加了撤回功能后发现改不动数据结构,只能做很多妥协。下面这张表展示了声网在用的消息基础结构,关键字段我都标出来了:
| 字段名 | 类型 | 说明 |
| msg_id | bigint | 消息唯一标识 |
| conversation_id | varchar | 会话ID |
| sender_id | varchar | 发送者用户ID |
| content | text | 消息内容 |
| msg_type | int | 消息类型枚举值 |
| status | int | 消息状态 |
| created_at | timestamp | 消息创建时间 |
| recalled_at | timestamp | 撤回时间,可为空 |
status这个字段很重要,我们定义了几个枚举值:正常状态是0,撤回状态是1,删除状态是2。当消息被撤回时,我们不是把记录删掉,而是把status改成1,再把recalled_at填上撤回的时间戳。这样做有几个好处:保留历史记录方便审计,不需要频繁操作数据库的删除,性能上也更好。
有朋友可能会问,那消息内容要保留吗?我的建议是保留,但可以做加密或者压缩处理。一方面是怕用户突然要恢复,另一方面是有些场景下可能需要配合监管要求调取历史数据。不过保留期限可以有限制,比如保留7天或者30天,到期之后再物理删除。
撤回操作看起来就是一个动作,但其实要拆成几个步骤来做。
用户点击撤回按钮时,客户端首先要做一个本地校验。比如检查这条消息是不是自己发的、是不是在允许撤回的时间范围内(大多数产品是2分钟,有些是5分钟)。本地校验能减少无效的请求,减轻服务器压力。
校验通过后,客户端会给服务器发送一个撤回请求。这个请求需要包含几个关键信息:消息ID、发起撤回的用户ID、当前会话的标识。请求最好带上时间戳,防止重放攻击。
服务端收到撤回请求后,要做的第一件事是验证权限。声网的做法是先查这条消息是不是属于请求里的用户,然后检查时间窗口。验证通过后,服务端会做两件事:更新数据库里的消息状态,然后触发同步通知。
更新数据库比较简单,一条update语句就能搞定。关键是同步通知这块,要确保所有相关的终端都能及时收到。有两种方案可选:一种是使用长连接推送,实时性更好;另一种是让终端下次上线时主动拉取。前者体验更好,但实现复杂一些。声网用的是长连接加拉取相结合的方案,保证在绝大多数情况下用户都能秒级看到撤回效果。
终端收到撤回通知后,要更新本地的消息展示。这里需要注意的是动画效果的处理。有些产品在撤回时会有一条消息记录直接消失,用户体验上可能会有点突兀。声网的做法是显示一条”对方撤回了一条消息”的系统提示,这样既告知了用户发生了撤回,又保留了操作的可追溯性。
恢复功能本质上是把被撤回的消息状态再改回去。但这个功能要不要开放给用户,怎么开放,都有讲究。
从技术角度来说,恢复就是status字段从1再改回0,同时清空recalled_at字段。服务端提供一个恢复接口,参数是消息ID和用户ID。调用前同样要做权限验证,必须是消息的原始发送者才能恢复。
但产品层面需要考虑更多问题。恢复的消息会不会造成对方的困惑?比如对方已经看到了撤回提示,结果消息又回来了。所以有些产品干脆不做恢复功能,要么就是做了也只对发送方自己可见,另一方仍然显示撤回状态。
还有一个问题是时效性。恢复操作要不要也设个期限?比如撤回后24小时内可以恢复。我的建议是设置,而且这个期限要和撤回的时间窗口区分开。撤回时间窗口可以短一些,比如2分钟,给用户一个”说错话可以马上改”的机会。恢复期限可以长一些,比如7天,让用户有足够的思考时间。
做即时通讯开发的都知道,百分之九十的bug都出在边界情况上。消息撤回和恢复功能也不例外,我列几个容易踩坑的点。
多端同步的竞态问题。比如用户在手机上撤回了消息,但同时在电脑上打开了聊天窗口。两个客户端几乎同时收到撤回通知,都要去更新本地数据。如果代码写得不好,可能会出现状态不一致。我的做法是在客户端做状态机的管理,同一个消息只处理一次状态变更,重复的通知直接忽略。
消息ID冲突。有时候网络不好,撤回请求发了两遍,服务端收到了两个相同的请求。如果不做幂等处理,可能会出问题。解决方案是在服务端对撤回请求做幂等校验,用消息ID加上撤回时间戳作为唯一键,重复的请求直接返回成功但不重复处理。
超大群组的消息撤回。一个两千人的群,有人发错了消息要撤回。服务端要同时给两千个终端发通知,这对推送系统是很大的压力。声网的优化方案是先用消息队列削峰,然后分批推送,保证核心功能不受影响。另外也可以考虑延迟推送,对于非在线用户,下次上线再同步。
文件消息的撤回。文字消息撤回直接把状态改了就行,但文件消息还有存储的问题。撤回的时候文件要不要删?我的建议是不删,保留源文件,只是让接收方无法再下载。如果文件确实敏感,可以加一个定时清理的任务,过期后再物理删除。
技术实现只是基础,最终用户感知到的是体验。我分享几个我们在声网产品中实践下来的细节。
消息撤回和恢复这两个功能,说大不大,说小也不小。往简单了做,两个接口加一个状态字段就能跑起来。往细了做,要考虑同步延迟、竞态条件、并发压力、用户体验一大堆问题。
我个人觉得,在即时通讯这个领域,功能实现只是起点,后续的优化和打磨才是真正见功力的时候。用户可能说不出来哪里好,但一定能感知到好不好用。这也是为什么声网在这块投入了比较多的资源去做细致的技术优化。
如果你正在开发类似的功能,建议先想清楚自己的产品定位和用户场景,然后再选择合适的技术方案。盲目照搬大厂的做法,可能会引入不必要的复杂度。毕竟,适合的才是最好的。
