消息可靠性与顺序
本项目承诺的是至少一次投递 + 会话内有序 + 客户端去重,不是“恰好一次”。理解这一点,才能正确实现发送重试、离线补收、已读游标与多端一致性。
契约保证
| 维度 | 保证 | 客户端职责 |
|---|---|---|
| 投递 | at-least-once(客户端按 messageId 去重) | 按 messageId 去重;重复消息只渲染/入库一次 |
| 发送幂等 | 同用户 clientMsgId 10 分钟内幂等 | 重试复用同一 clientMsgId |
| 顺序 | 同一会话内 seq 单调递增;跨会话无全局序 | 同一会话按 seq 排序,不按到达时间 |
| 补收 | hello 后自动补发离线消息,随后 sync_done | 以 sync_done 作为同步完成信号 |
| 游标推进 | 仅当 ack 的 messageId 属于当前用户可达消息时推进游标;乱序 ack 取已确认最大连续 seq | 按服务端可达消息推进,处理乱序 ack |
| 故障切换 | 服务端重启后内存会话游标丢失时,以持久化消息与客户端 seq 补拉恢复(v1.0.0 演示实现:内存态+可选 PostgreSQL) | 以持久化消息 + seq 补拉恢复 |
| 离线队列 | 每会话 500 条 | 超出部分需历史分页拉取 |
“恰好一次”不存在
网络超时后你无法知道消息是否已入库:唯一安全做法是用同一 clientMsgId 重试,由服务端幂等去重;客户端再按 messageId 去重收端。任何“超时就换新 ID 重发”的实现都会产生重复消息。
正确实现的骨架
JavaScript
import { createClient, ConversationType, Events } from '@ni-paas/im-web';
const client = createClient({ appkey: 'YOUR_APP_KEY', serverUrl: 'http://127.0.0.1:8080' });
const seen = new Set(); // 收端去重(生产按 messageId 持久化)
const pending = new Map(); // 发端待确认:clientMsgId -> {type,targetId,content}
async function sendOnce(type, targetId, content) {
const clientMsgId = crypto.randomUUID(); // 同一业务消息的重试必须复用
pending.set(clientMsgId, { type, targetId, content });
const res = await client.sendMessage(type, targetId, content, { clientMsgId });
if (res.code === 0) pending.delete(clientMsgId);
return res;
}
// 断线恢复后:对未确认消息用原 clientMsgId 重发(服务端幂等)
async function resendPending() {
for (const [clientMsgId, item] of pending) {
const res = await client.sendMessage(item.type, item.targetId, item.content, { clientMsgId });
if (res.code === 0 || ![3001, 3002, 3003, 1008].includes(res.code)) pending.delete(clientMsgId);
}
}
client.addEventListener(Events.MESSAGES, ({ messages }) => {
const fresh = messages.filter((m) => !seen.has(m.messageId));
fresh.forEach((m) => seen.add(m.messageId));
renderSortedBySeq(fresh); // 同一会话按 seq 排序
});失败模式与对策
| 失败模式 | 现象 | 对策 |
|---|---|---|
| 发送超时 | 调用方不知道是否成功 | 复用 clientMsgId 重试;收端去重 |
| 服务端重启 | 内存游标丢失 | 按持久化消息与 seq 补拉;以 sync_done 对齐 |
| 离线队列溢出 | 超过 500 条的部分不在补发里 | 重连后按历史分页补齐;重要消息用业务归档 |
| 重复投递 | 收端收到两条相同消息 | 按 messageId 去重(不是 clientMsgId) |
| 乱序到达 | 后发消息先到 | 按 seq 重排,不要用本地时间 |
| 半开连接 | TCP 还在但无法收发 | 依赖 ping/pong 与空闲超时;SDK 判定后重连 |