聊天项目里的消息发送确认
这几年一直在做一款商业 IM 软件,App 用 Flutter,Web 端是工作台。趁着整理代码,把里面一些平时容易忽略的细节记下来。
先写消息发送。点击发送以后,输入框清空、消息出现在列表里,这时请求可能都还没到服务器。页面上的发送状态到底应该在哪一步改变?
消息出现在列表里,只是第一步
在 Web 端,文本发送会先创建一个本地消息,把状态设为 SENDING,然后交给本地消息状态处理,再通过 XMPP 发送。
这样用户不用等远端确认,就能在聊天窗口看到自己的消息。
这里有几个不同的状态:
| 时刻 | 能说明什么 |
|---|---|
| 本地消息创建完成 | 客户端已经记录这次发送意图 |
| 调用 XMPP 发送方法 | 客户端尝试把消息交给连接 |
| 收到发送确认,或查询到服务端记录 | 可以按项目的确认规则更新发送状态 |
| 收到已读信息 | 阅读状态发生了变化 |
尤其是最后两项,需要分开处理。消息发送成功,不意味着对方已经阅读;对方阅读了,也不应该反过来成为发送流程唯一的成功条件。
项目里 messageStatus、isRead、isViewMarker 等字段承担不同职责。如果把它们都压进一个 success,UI 看起来简单了,后面的逻辑反而很难解释。
ACK 没回来,怎么办?
项目的 Web 端有一个 progressiveMessageCheckWithAck,负责发送后的渐进式确认。
它会先等待一段时间,检查 ACK 缓存;如果没有命中,再调用消息存在性接口。如果仍然没有确认,就继续下一轮。
当前源码里的等待间隔是:
const checkIntervals = [300, 300, 500, 1000, 2000, 4000, 6000, 10000]
单位是毫秒,每一项都是这一轮额外等待的时间。它们不是从发送开始计算的绝对时间点,整个确认过程还要加上 HTTP 请求本身的耗时。
这个细节在排查“为什么发送状态这么久才结束”时很有用。只看最后一个 10000,会误以为整个流程最多十秒。
实际的确认顺序可以写成:
等待本轮间隔
→ 检查本地 ACK 缓存
→ 未命中时查询服务端是否存在该消息
→ 已确认则结束,否则进入下一轮
→ 所有轮次结束后,再做一次最终查询
HTTP 查询确认存在后,也会把消息 ID 加入 ACK 缓存。这个缓存最多保留 100 个 ID,用来保存近期确认结果,不能把它当成完整的消息历史。
ACK 能及时回来最好;收不到时,还有 HTTP 可以主动查一次。
超时最麻烦的地方,是结果可能还不确定
假设发生下面这个过程:
- 客户端把消息发了出去。
- 服务端已经收到消息。
- 客户端切换网络,没拿到确认。
- 后续查询也因为网络问题失败。
从客户端看到的结果来说,确认流程失败了。但服务端可能已经保存了这条消息。
这款商业 IM 软件当前的 Web 实现会在确认耗尽后把发送状态设为 FAILED。这个字段可以支持界面的失败提示,却不能推导出“服务端一定没有这条消息”。
同样,查询接口返回存在,可以支持客户端把消息标为已发送;它不能证明所有接收设备都收到了,更不能证明对方读过。
如果继续细化这套状态,我会考虑把“明确拒绝”和“暂时无法确认”分开。目前代码还没有这样细分。
重发之前,先想清楚消息 ID
一旦允许用户重发,问题就从“再调用一次发送方法”,变成“这次调用和上次是什么关系”。
同一段文本有可能是用户主动发送了两次,也可能是同一次发送的重试。不能靠内容相同来去重。
项目在普通文本发送时会生成消息 ID,并把它放进 stanza 的 id 和 origin-id。这样本地消息和协议消息有了可以关联的标识。
但有 origin-id 字段,不代表整个系统就自动具备完整的重试幂等能力。
设计重试时,需要把几件事一起对齐:本地保存的是哪个 ID、重发携带哪个 ID、确认接口查哪个 ID,以及服务端按什么规则判重。客户端复用标识,也仍然需要服务端配合。
例如,如果希望“同一次发送操作重试后仍然对应一条业务消息”,就应该明确一个稳定的业务标识,并让相关链路使用同一套语义。客户端有 ID 还不够,服务端也要按同一个 ID 判重。
几个需要补测的情况
单独检查正常发送,覆盖不到最有讨论价值的部分。我更关心下面几种情况:
- ACK 到达前,HTTP 查询先确认成功,之后 ACK 再到达。
- ACK 始终没有到达,但 HTTP 能查到消息。
- HTTP 暂时失败,后续一轮恢复成功。
- 确认耗尽后,原消息又通过历史同步回到客户端。
- 用户点击重发,本地消息与新的确认结果能否正确关联。
这些情况适合补进故障测试,尤其要看迟到的 ACK 会不会改错消息。
排查发送状态时,我会先拿消息 ID 对一下 ACK 缓存和历史接口。先确认服务器上有没有这条消息,再决定要不要重发。