Skip to content

一、先看需求本质:服务端要“主动”找人

IM,Instant Messaging,即时通讯——微信单聊、客服对话、App 里的系统通知,都属于这一类:消息要在“发出去”和“被看到”之间几乎无延迟地送达

平时用微信,消息不丢、不重、一问一答顺序稳定,感觉理所当然。可真要自己动手做一个 IM(比如给产品加个客服聊天),把链路拆开才发现处处是坑:消息发出去了,对面可能没收到;超时重发,对面会收到两条一样的;走不同网络路径的消息,还可能后发的先到。

这三个现象分别对应 IM 系统的三件核心事:不丢、不重、不乱序。这篇把 IM 系统沿“怎么把一条消息可靠地从 A 送到 B”这条线拆开看一遍,把背后的机制梳理清楚——真要做的时候,心里先有一张图。

先想清楚 IM 和普通 HTTP 接口的本质区别:

普通 HTTP 接口IM 消息推送
谁找谁客户端主动请求,服务端被动应答服务端要主动把消息推给客户端
连接请求-响应完就断需要长连接,随时能推
容忍度慢一点无所谓消息丢了、重了、乱了用户立刻能感知

HTTP 的请求-响应模式决定了服务端无法主动发起——客户端不问,服务端就只能干等。这就是 IM 必须引入长连接的根本原因。

二、方案 v0:HTTP 轮询(能用,但很傻)

最直接的思路:既然服务端不能主动推,那客户端定时来问总行吧?

java
// 前端每 3 秒调一次
@GetMapping("/messages")
public List<ChatMessage> pull(@RequestParam Long afterId) {
    return messageMapper.selectAfter(afterId);   // 拉增量
}

能跑,但三个硬伤:

  1. 延迟高:消息平均要等 1.5 秒、最坏 3 秒才被发现,聊天体验像对讲机;
  2. 空请求占绝大多数:没人说话时 99% 的请求拉回来是空数组,纯浪费;
  3. 连接压力大:1000 个在线用户,每 3 秒就是 333 QPS 的空转。

改进版是长轮询(long polling)。还是用打电话查快递类比:

  • 普通轮询:每 3 分钟打一次电话问“到了吗”,对方答一句“没有”就挂。快递要是两分钟后到的,你就得多等近 3 分钟——而且大部分电话都是白打的;
  • 长轮询:电话打过去你不挂,对方也不挂,帮你盯着——快递一到立刻告诉你;盯了 25 秒还没到,才说“暂时没有,你过会儿再打”,你挂断后马上再打过去。

对应到 HTTP 就是:客户端发请求后,服务端不立刻返回,把请求挂住——期间有消息就立刻带着消息响应,没消息则等超时(如 25 秒)返回空;客户端收到响应后马上发起下一个请求,循环往复。

效果:消息一到就能送达,延迟接近于零,空请求也少了一大截。但两个短板还在——连接一直被占着(服务端要挂起大量等待中的请求),而且每次响应完都要重新发起,服务端依然只能“被动应答”,本质没变。真想让服务端主动开口,得换协议。

轮询不是一无是处——内部系统的低频通知(比如审批提醒)用 30 秒轮询完全够用。但要做出“实时聊天”的体验,就该换武器了。

三、方案 v1:WebSocket 长连接

WebSocket 是浏览器原生支持的全双工协议。全双工,说白了就是“双向都能随时开口”——像打电话,不像发短信。用法也不神秘:先发一个普通的 HTTP 请求,说“我要升级成 WebSocket”(服务器同意,返回一个 101 状态码),之后这条 TCP 连接就归双方自由收发,服务端随时能主动推。

3.1 Spring Boot 服务端

java
@Component
@ServerEndpoint("/ws/chat/{userId}")
public class ChatEndpoint {

    // 在线连接表:userId -> 会话(单机版)
    private static final Map<Long, Session> SESSIONS = new ConcurrentHashMap<>();

    @OnOpen
    public void onOpen(Session session, @PathParam("userId") Long userId) {
        SESSIONS.put(userId, session);
    }

    @OnMessage
    public void onMessage(String raw, Session session) {
        // 收到客户端消息:落库 + 投递(后面几节展开)
    }

    @OnClose
    public void onClose(Session session, @PathParam("userId") Long userId) {
        SESSIONS.remove(userId);
    }
}

推消息就是把数据写进对方 Session:

java
public static void push(Long userId, String json) {
    Session session = SESSIONS.get(userId);
    if (session != null && session.isOpen()) {
        session.getAsyncRemote().sendText(json);   // 异步发送,别堵业务线程
    }
}

3.2 心跳保活:为什么连接“自己就断了”

长连接不是建完就一劳永逸。两个常见的“莫名断线”:

  • Nginx 代理超时proxy_read_timeout 默认 60 秒,60 秒内这条连接上没有任何数据传输,Nginx 就把连接掐了;
  • NAT/防火墙回收:运营商的 NAT 设备对空闲 TCP 连接的存活时间有自己的策略,通常几分钟。

解法是心跳:客户端定时(比如 30 秒)发一个 ping,服务端回 pong。一来让 Nginx 看到连接是活的,二来让双方及时发现“对面其实已经不在了”。

javascript
// 客户端心跳 + 断线重连
function connect() {
    ws = new WebSocket('wss://example.com/ws/chat/1001');
    ws.onclose = () => setTimeout(connect, 3000);   // 断了 3 秒后重连
// 生产上用"指数退避":每次重试间隔翻倍(3s→6s→12s),
// 避免服务端刚恢复就被一波重连挤爆
}
setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) ws.send('{"type":"ping"}');
}, 30000);

到这里,“服务端主动推”已经通了。但如果服务端部署了多台实例,一个新的问题立刻出现。

四、方案 v2:多实例投递——A 连在 1 号机,B 连在 2 号机

单机版里 SESSIONS 这个 Map 什么都能搞定:A 发消息,从 Map 里找到 B 的 Session 推过去。可一旦部署两台实例做负载均衡:A 连在 server-1,B 连在 server-2——A 发消息时,server-1 的 Map 里根本没有 B。

连接是挂在具体某台机器内存里的,天然跨不了实例。

IM 多实例消息投递:Redis 路由表方案

两类解法:

4.1 路由表方案:记住每个人连在哪

用户建立连接时,把 userId -> serverId 写进 Redis;A 发消息给 B 时,先查 B 连在哪台机,再把消息转发过去(转发通道用 Redis 的发布订阅或 MQ 都行,下一小节的广播方案用的就是 MQ):

连接建立:Redis SET route:user:1002 "server-2"
A 发消息:查 B 在 server-2 → 消息投给 server-2 → server-2 查本地 Map 推给 B

4.2 广播方案:消息撒出去,各机自认

先补一句 MQ 是什么,没接触过的同学别跳过这段。 MQ(消息队列)就是一个"快递中转站":A 把消息扔进队列就走人,谁需要谁来取—— 发送方和接收方不用互相认识,也不用同时在线,队列负责把消息存着、转交。 细节可以看我之前的 RabbitMQ 快速入门,这里只需要记住这句话就够了。

不记路由,A 的消息直接进 MQ 广播(每个实例一个队列),每台实例收到后判断"B 连在我这吗"——在就推,不在就丢弃:

方案优点缺点适合
路由表精准投递,无浪费要维护路由的正确性(断线要清、实例宕机要清)在线用户多、消息量大
MQ 广播无状态,实现简单每条消息所有实例都处理一遍,实例越多浪费越大实例数少(几台以内)

中小规模系统从 MQ 广播起步完全够用,量大了再切路由表。这也是“先能用,再演进”的老路子。

五、方案 v3:可靠投递——消息不能丢

前面解决的是“送到”,现在解决“可靠地送到”。回到开头说的第一件事:消息为什么会丢?发送端网络抖动、服务端推送时对方刚好断线、推送失败没人管……丢消息的根本原因是:把“发出去”当成了“送达”

5.1 核心原则:先落库,再推送

java
@OnMessage
public void onMessage(String raw, Session session) {
    ChatMessage msg = JSON.parseObject(raw, ChatMessage.class);

    // ① 先落库——落库成功,这条消息就永远不会丢
    messageMapper.insert(msg);

    // ② 再尽力推送——推不推得到,都不影响消息本身的安全
    pushService.tryPush(msg.getToUserId(), msg);
}

消息一旦落库,它就是持久存在的事实;推送只是“尽快通知对方来取”的手段。推挂了没关系——对方重连后还能从库里补。这一条原则是整个 IM 可靠性的地基。

5.2 两段 ACK:发送确认 + 送达确认

光落库还不够,还要让两端都“知道对方知道了”:

  • A → 服务端:A 发出 msgId=100 后开始计时,没等到 ACK 就重发(带着同一个 msgId,服务端幂等处理,下一节讲);
  • 服务端 → B:推送后 B 回 ACK,服务端把这条消息标记“已送达”;超时没等到,标记留在“待送达”,B 下次上线/重连时补推。

消息状态机:发送中 → 已落库 → 已送达 → 已读——就像 WhatsApp 消息旁的小勾:转圈(发送中)→ 单勾(发出去了)→ 双勾(对方收到了)。微信反而没这套:发送失败只有红色感叹号,连“已读”都不给你——技术上完全做得到(上面的 ACK 和回执就是),纯粹是产品不想让“已读不回”变成社交压力。每一步都有据可查,丢没丢、卡在哪,一目了然。

IM 可靠投递时序:先落库再推送,两段 ACK

六、方案 v4:不重、不乱序

6.1 去重:客户端按 msgId 幂等

A 超时重发了 msgId=100,服务端会收到两次。处理方式很直接:msgId 加唯一索引,重复插入直接失败——这正是幂等设计里的“唯一键方案”,MQ 重复消费、支付重复回调,用的都是同一招:

sql
ALTER TABLE chat_message ADD UNIQUE KEY uk_msg_id (msg_id);

客户端收到消息也按 msgId 去重(服务端补推 + 实时推送可能让 B 收到两条),一个 Set 就够了。

6.2 乱序:服务端单调递增 seq

A 先发“在吗”再发“帮我个忙”,B 却先看到第二句——两条消息像两封信,一封走空运一封走陆运,后寄的先到。

解法:服务端为每个会话维护一个单调递增的 seq,消息落库时分配,客户端收到后按 seq 排序展示。 seq 分配要原子(INCR),Redis 或数据库自增都行。

问题手段本质
落库 + ACK + 补推把消息变成持久事实
msgId 唯一键幂等
乱序会话内 seq全局排序基准

三个问题三件套,都是老朋友:持久化、幂等、单调序列。

七、方案 v5:离线消息——写扩散还是读扩散

B 掉线两小时,期间 A 发了 20 条。B 重连后怎么拿到?上一节说“补推”,但补推的前提是知道从哪补到哪——这就是离线消息的设计。

两种经典方案:

写扩散(收件箱模式):像给每个人配一个专属信箱——每条消息投递时,往每个接收人的信箱里塞一份。B 上线只需要翻自己的信箱。

  • 优点:读简单,一个信箱就是完整时间线,多端同步天然支持;
  • 缺点:写放大——群里 500 人,一条群消息要塞 500 份。

读扩散(发件箱模式):像公告栏——消息只贴一份在自己的会话里,B 上线后把他关注的所有公告栏挨个翻一遍,把没读的拉回来。

  • 优点:写简单,只写一份;
  • 缺点:会话一多,上线要翻几十个公告栏,读放大。

业界的成熟做法是混合:单聊用写扩散(收件箱,量可控),群聊用读扩散(只存一份,成员按需拉取)。微信、钉钉大抵是这个思路。

实现上不需要真存两份数据,用“游标”就能模拟收件箱。游标说白了就是一张书签:记住你读到了哪,下次从书签后面继续读——每个用户每个会话维护一个 lastAckSeq,上线后把所有会话里 seq > lastAckSeq 的消息拉出来,拉完更新游标:

java
public List<ChatMessage> pullOffline(Long userId) {
    // 拉用户所有会话中,seq 大于游标的消息(一条 SQL,按会话分组)
    List<ChatMessage> offline = messageMapper.selectAfterCursor(userId);
    return offline;
}
// 补推完成后,把游标推进到最新 seq

一个游标 + 唯一键 + 单调 seq,离线消息、续传、多端同步全都是这一套机制的自然延伸。

八、再多一步:已读回执与多端同步

  • 已读回执:本质也是一条消息(“我读到 seq=42 了”),走同一套可靠投递链路,只是接收方是原发送者。别为它单独发明机制;
  • 多端同步:同一账号手机 + 电脑同时在线。写扩散方案里天然解决——两个端各维护各的游标,各自从收件箱拉取,互不干扰。

看到这里会发现 IM 的设计套路:所有花哨的功能,拆到底都是“可靠投递 + 游标 + 幂等”的组合

九、选型表 + 我的结论

场景建议方案
内部低频通知(审批提醒、告警)30 秒轮询足够,别过度设计
中小规模实时聊天(几千在线)WebSocket + MQ 广播 + 收件箱游标
大规模/强实时WebSocket + 路由表 + 单聊写扩散、群聊读扩散
不想自研融云、环信、腾讯 IM,买量换时间

自研 IM 的三句话总结:先落库再推送(不丢),msgId 唯一键(不重),会话内 seq(不乱序)。剩下的心跳、路由、扩散,都是围绕这三句话展开的工程细节。

延伸阅读

IM 的每个子问题单独拉出来都值得展开,相关的专题: