IM 消息可靠性保证

2019年双十一当晚,某社交App的用户发现了一个诡异的bug:好友发来的消息,收到了两条一模一样的。

技术团队排查后发现:发送方的网络在消息ACK时抖动了一下,导致ACK丢失,服务端以为消息没送达,触发重试。接收方收到两条一样的消息。

这不是偶发问题——双十一期间用户网络不稳定,重试风暴导致5%的用户收到了重复消息。

这是一个典型的IM可靠性问题:消息重试导致重复。

【架构权衡】

IM消息可靠性的本质是"不丢不重"——丢了用户投诉,重复了用户也投诉。但"不丢"和"不重"是一对矛盾:要保证不丢就要重试,重试就可能重复;要避免重复就要去重,去重就要有状态。工程上没有银弹,关键是根据业务场景选择合适的可靠性级别

一、可靠性的三个级别 🔴

1.1 三个级别的定义

IM消息可靠性三个级别:

【Level 1: 至少发送一次】(最常用)
- 保证消息一定被发送出去
- 可能重复接收
- 实现成本:中等
- 适用场景:普通聊天

【Level 2: 恰好一次】
- 消息只被处理一次
- 不丢失,不重复
- 实现成本:高
- 适用场景:支付通知、订单变更

【Level 3: 顺序保证】
- 消息按发送顺序到达
- 实现成本:很高
- 适用场景:金融交易、状态同步

1.2 面试核心问题

面试官:IM消息的可靠性怎么保证?

候选人:核心是三个机制:消息确认超时重试消息去重

发送方发消息给服务端,服务端回复ACK;服务端推送给接收方,接收方回复ACK;如果ACK超时,就重试;接收方收到重复消息时,根据msg_id去重。

面试官:那"至少发送一次"和"恰好一次"有什么区别?

候选人:区别在于去重的位置。

"至少发送一次":发送方重试,服务端去重。服务端收到重复消息,返回ACK但不重复处理。

"恰好一次":发送前先查询消息是否已存在,幂等写入。或者引入分布式事务,保证消息只被处理一次。

面试官:哪种更常用?

候选人:大多数IM场景用"至少发送一次",因为实现成本低,可靠性也够用。"恰好一次"太重了,一般只用在支付类场景。

【面试官心理】

消息可靠性的追问通常会深入到"消息去重"的实现细节。能回答"服务端根据msg_id去重"的候选人,说明理解了基本原理;能说出"去重表+滑动窗口"的候选人,说明有实战经验;能提到"幂等写入"的候选人,说明理解分布式系统的核心问题。

二、消息确认机制 🔴

2.1 两层ACK设计

第一层ACK:发送方 → 服务端
- 发送方发消息,收到服务端ACK才算发送成功
- 超时时间:3秒
- 超时后重试,最多3次

第二层ACK:服务端 → 接收方
- 服务端推消息给接收方,收到接收方ACK才算送达
- 超时时间:5秒
- 超时后重试,最多3次

2.2 ACK消息格式

// 第一层ACK:服务端确认收到消息
{
    "type": "msg_ack",
    "msg_id": 12345678,
    "code": 0,          // 0=成功,非0=失败
    "server_time": 1734067200000
}

// 第二层ACK:接收方确认收到消息
{
    "type": "delivered_ack",
    "msg_id": 12345678,
    "client_time": 1734067200001
}

// 已读回执
{
    "type": "read_ack",
    "msg_id": 12345678,
    "read_time": 1734067200002
}

2.3 超时重试策略

重试策略:指数退避

第1次重试:1秒后
第2次重试:2秒后
第3次重试:4秒后
第4次重试:放弃,标记为发送失败

重试时带上重试次数和首次发送时间:
{
    "msg_id": 12345678,
    "retry_count": 2,
    "first_send_time": 1734067100000
}

接收方收到重试消息:
1. 先查本地缓存是否有 msg_id
2. 如果有,说明已处理过,直接返回ACK
3. 如果没有,处理消息,返回ACK

三、消息去重 🟡

3.1 去重原理

去重核心:msg_id唯一性 + 本地缓存

发送方生成msg_id(雪花ID),保证全局唯一
接收方维护最近N个已处理msg_id的缓存

缓存结构:
- 数据结构:布隆过滤器 / HashSet / Redis
- 容量:最近1000个msg_id
- 淘汰策略:FIFO或TTL(5分钟)

3.2 去重实现

public class MessageDeduplicator {

    // 本地去重:最近处理的消息ID
    private final ConcurrentHashSet<Long> recentMsgIds = new ConcurrentHashSet<>();

    // 布隆过滤器:判断msg_id可能存在
    private final BloomFilter<Long> bloomFilter = BloomFilter.create(
        Funnels.longFunnel(),
        1000000,  // 预期元素数
        0.01      // 误判率1%
    );

    public boolean isDuplicate(long msgId) {
        // 第一关:布隆过滤器快速判断
        if (!bloomFilter.mightContain(msgId)) {
            return false;  // 一定不重复
        }

        // 第二关:HashSet精确判断
        if (recentMsgIds.contains(msgId)) {
            return true;  // 确定重复
        }

        // 第三关:第一次见到,加入缓存
        recentMsgIds.add(msgId);
        bloomFilter.put(msgId);
        return false;
    }

    // 定期清理过期消息
    public void cleanup() {
        // 保留最近5分钟的消息
        long cutoffTime = System.currentTimeMillis() - 5 * 60 * 1000;
        // 实际实现需要记录每个msgId的插入时间
    }
}

3.3 服务端去重表

-- 消息去重表
CREATE TABLE msg_dedup (
    msg_id BIGINT PRIMARY KEY,
    status TINYINT NOT NULL,     -- 0=处理中,1=已处理
    process_time DATETIME,
    INDEX idx_status_time (status, process_time)
);

-- 幂等写入
INSERT IGNORE INTO msg_dedup (msg_id, status, process_time)
VALUES (?, 0, NOW());

-- 检查是否已处理
SELECT status FROM msg_dedup WHERE msg_id = ?;

-- 处理完成后更新状态
UPDATE msg_dedup SET status = 1, process_time = NOW() WHERE msg_id = ? AND status = 0;

四、消息存储 🔴

4.1 消息状态机

消息状态流转:

[sending] → [sent] → [delivered] → [read]
    ↓           ↓           ↓
[failed]    [failed]    [failed]

详细说明:
- sending:消息正在发送(等待服务端ACK)
- sent:服务端已收到(等待接收方ACK)
- delivered:接收方已收到(等待已读)
- read:接收方已读
- failed:发送失败(超过重试次数)

4.2 消息表设计

CREATE TABLE message (
    msg_id BIGINT PRIMARY KEY,           -- 雪花ID
    conversation_id BIGINT NOT NULL,     -- 会话ID
    from_uid BIGINT NOT NULL,            -- 发送方
    to_uid BIGINT NOT NULL,              -- 接收方
    content_type TINYINT NOT NULL,       -- 1=文本,2=图片,3=语音,4=视频
    content TEXT,                         -- 消息内容(文本或媒体URL)
    status TINYINT NOT NULL DEFAULT 0,   -- 状态
    create_time DATETIME NOT NULL,
    INDEX idx_conversation_time (conversation_id, create_time),
    INDEX idx_from_uid (from_uid, create_time),
    INDEX idx_to_uid_time (to_uid, create_time)
) ENGINE=InnoDB;

4.3 离线消息同步

离线消息触发条件:
1. 用户主动下线
2. 心跳超时(60秒无心跳)
3. 网络断开

离线消息拉取流程:
1. 用户上线,携带 last_read_msg_id
2. 服务端查询 [last_read_msg_id, 最新] 区间内的消息
3. 批量返回给客户端
4. 客户端按 msg_id 排序显示

五、已读状态设计 🟡

5.1 单聊已读

单聊已读逻辑:
1. 用户A打开聊天窗口,看到消息列表
2. 客户端发送已读消息:{last_read_msg_id: 123456}
3. 服务端更新已读状态:update read_status set last_read = 123456 where uid = A
4. 如果A的聊天窗口在B的设备上同时打开,B也收到通知

未读数计算:
未读数 = 消息表count - 已读消息ID

5.2 群聊已读

群聊已读的实现复杂度远高于单聊:

方案A:已读列表(谁读了)
- 每条消息记录已读用户列表
- 存储成本高,适合小群

方案B:已读计数(多少人读了)
- 每条消息记录已读人数
- 存储成本低,但看不到具体谁读了

方案C:按需拉取(拉取时查询)
- 每次进入聊天窗口时,服务端计算未读消息
- 不存储已读状态,实时计算
- 适合大群(万人群)

微信大群用的是方案C:
- 拉取最近N条消息及其发送时间
- 客户端本地计算哪些已发送给自己

六、生产避坑 🟡

6.1 可靠性的五大坑

坑1:ACK风暴

问题:大量消息同时ACK,导致服务端压力
场景:接收方网络恢复,堆积的1万条消息同时发送ACK
影响:服务端瞬时处理1万条ACK,CPU飙升
解决方案:
- ACK批量确认:每100ms汇总发送一次ACK
- 限流:每秒最多处理N条ACK
- 降级:ACK丢失不重试,用户下次拉取时补齐

坑2:重试导致消息乱序

问题:消息1重试,消息2先到达,消息1后到达
场景:网络抖动,消息1 ACK丢失
影响:接收方看到消息顺序错乱
解决方案:
- 消息携带 sequence_num,接收方按 sequence_num 排序
- 乱序消息暂存缓冲区,等待缺失的消息
- 乱序超过阈值(如10条),触发全量同步

坑3:幂等写入的竞态条件

问题:同一msg_id的两次请求几乎同时到达
场景:
- 请求1:SELECT status FROM msg_dedup WHERE msg_id = ?
- 请求2:SELECT status FROM msg_dedup WHERE msg_id = ?
- 两个请求都查到 status=0
- 两个请求都执行 UPDATE,消息被处理两次

解决方案:
- 分布式锁:SETNX lock:msg:{msg_id},加锁处理
- UPDATE时带条件:UPDATE ... WHERE msg_id = ? AND status = 0
  返回 affected_rows = 0 说明已被处理

坑4:消息回执丢失

问题:服务端收到接收方ACK,更新状态为delivered,但发送方没收到
场景:服务端回复ACK时网络抖动
影响:发送方显示"发送失败",但接收方实际已收到
解决方案:
- 发送方收到服务端的sent_ack就显示"发送成功"
- 发送方收到delivered_ack才显示"已送达"
- 如果delivered_ack超时,不降级显示,因为接收方可能已收到

坑5:消息库成为瓶颈

问题:消息表数据量太大,查询变慢
场景:亿级用户,每天千亿条消息
影响:未读数计算、离线消息拉取变慢
解决方案:
- 分库分表:按 msg_id 取模
- 冷热分离:近7天存MySQL,历史存ES/HBase
- 缓存:未读数缓存到Redis,定期同步

6.2 可靠性监控指标

指标目标值告警阈值
消息送达率>99.9%<99.5%
消息重复率<0.1%>1%
ACK丢失率<0.01%>0.1%
消息延迟P99<500ms>2s
重试率<5%>15%

【架构权衡】

IM可靠性设计有一个核心原则:根据业务场景选择可靠性级别。普通聊天用"至少发送一次"就够了,金融支付需要"恰好一次",普通状态同步用"最终一致"即可。过度追求可靠性会增加系统复杂度,Under-design则会导致用户投诉。知道在哪儿妥协,比盲目追求完美更重要。

七、真实面试回放 🟡

面试官:微信消息显示"已发送"、"已送达"、"已读",这三个状态分别是什么时候设置的?

候选人(小林):

"已发送":客户端收到服务端的msg_ack时设置。服务端收到消息后回复ACK,ACK里有server_time。

"已送达":服务端收到接收方的delivered_ack时设置。服务端把ACK转发给发送方。

"已读":接收方客户端主动发送read_ack,服务端更新数据库后,通过WebSocket通知发送方。

面试官:如果接收方网络断了,收不到delivered_ack,发送方会一直显示"发送中"吗?

小林:不会。服务端有个超时机制:

  • 如果5秒内没收到delivered_ack,重试推送
  • 重试3次后,标记消息为"发送失败"或"未送达"
  • 发送方收到失败通知后,显示"发送失败",可以重新发送

但如果接收方只是网络慢而不是断了,重试成功后会更新为"已送达",发送方不会感知到这次重试。

面试官:如果接收方在离线期间收到了消息(通过离线拉取),发送方怎么知道?

小林:离线拉取消息时,服务端会检查每条消息的状态:

  • 如果消息没有 delivered_ack,说明接收方之前没收到
  • 服务端在返回离线消息时,同时返回这些消息的"送达确认"给发送方
  • 发送方收到后,更新显示为"已送达"

面试官:如果同一账号在两个设备上同时登录,消息怎么同步?

小林:两个设备各自维护自己的 last_read_msg_id。服务端在推送消息时,同时推送给两个设备。

已读状态也要同步:如果设备A发送了已读ack,服务端通知设备B更新已读状态。

还有一个问题:消息ID在两个设备上可能收到两次(服务端同时推),去重靠 msg_id 来处理。

【面试官手记】

小林这场面试的亮点:

  1. 三个状态的触发时机讲得清晰:msg_ack、delivered_ack、read_ack

  2. 离线拉取的送达通知:这是进阶问题,小林知道服务端会补发送达确认

  3. 多端同步的去重:提到 msg_id 去重,说明考虑了实际场景

这场面试属于P7级别,能回答出离线同步细节的候选人,通常有IM系统的实战经验。

追问方向:如果继续追问,会问"如何保证消息顺序"和"群聊已读怎么设计",这两个问题更考验候选人的深度思考能力。

IM消息可靠性的核心是不丢不重,记住三个要点:

  1. ACK机制:两层ACK(发送方↔服务端、服务端↔接收方),超时重试
  2. 消息去重:msg_id唯一 + 本地缓存 + 服务端去重表
  3. 离线同步:上线后拉取未读消息,服务端补发送达确认

当你能在面试中讲清楚"为什么消息会重复"和"怎么防止重复",IM可靠性这关就算过了。