Skip to content

系统设计面试:实时消息系统(IM)怎么设计?

问题

设计一个实时消息系统(IM)。单聊和群聊的消息模型怎么设计?消息有序性如何保证?离线消息怎么存储和管理?

面试官期望你从"消息模型"切入,给出 P7 级别的存储方案,并能聊到 P8 级别的已读回执、消息必达、撤回机制等进阶话题。


消息模型:写扩散 vs 读扩散

IM 的第一个选择题就是消息存储模型。写扩散:发送方发消息时,直接写入所有接收者的收件箱;读扩散:只写发件箱,接收者读消息时从所有人的发件箱拉取并合并。

不同场景的选型:

场景推荐模型原因
单聊(1v1)写扩散接收者唯一,写一次即可
小群聊(≤500人)写扩散写扩散写入量可控,读时无延迟
大群聊(>500人)读扩散群消息写入量与群人数无关,避免写爆炸

微信实际就是这样做的:单聊用写扩散,500 人以内群聊用写扩散,500 人以上群聊切换到读扩散。为什么是 500?因为 500 人以下写扩散的总写入量 = 500 次 × 1KB ≈ 500KB,集群可以接受;超过 500 人,一个 1000 人群发一条消息就要写 1000 次,写入放大太严重。

存储表结构设计

度扩散模型下的核心表:

sql
-- 会话表(写扩散收件箱)
CREATE TABLE inbox (
    msg_id BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    receiver_id BIGINT NOT NULL,    -- 单聊:对方用户ID;群聊:群ID
    target_type TINYINT NOT NULL,    -- 1:单聊 2:群聊
    content TEXT NOT NULL,
    seq_id BIGINT NOT NULL,          -- 服务端分配的序列号
    created_at BIGINT NOT NULL,
    PRIMARY KEY (receiver_id, seq_id)  -- 按 receiver 分表
) PARTITION BY HASH(receiver_id) PARTITIONS 256;

-- 群聊发件箱(读扩散场景)
CREATE TABLE group_outbox (
    msg_id BIGINT NOT NULL,
    group_id BIGINT NOT NULL,
    sender_id BIGINT NOT NULL,
    content TEXT NOT NULL,
    seq_id BIGINT NOT NULL,
    created_at BIGINT NOT NULL,
    PRIMARY KEY (group_id, seq_id)
) PARTITION BY HASH(group_id) PARTITIONS 64;

分表 256 是常见做法,按 receiver_id 哈希分片,保证每个用户的收件箱数据集中在一个分片上,查询时一个分片搞定。


消息有序性:为什么不能信客户端时间

消息有序性看似简单,但客户端时间不可信——用户手机的本地时间可能比服务器慢 5 分钟,或者用户手动改过时间。如果按客户端时间排序,A 先发的消息可能因为手机时间晚了而排到 B 后面。

解法:服务端统一分配序列号(seq_id),客户端收到消息后按 seq_id 排序。

java
// 分布式序列号生成器(Snowflake 变体)
public class SeqGenerator {
    private final SnowflakeIdWorker idWorker;
    
    public SeqGenerator(long workerId) {
        // 1 bit 符号位 + 41 bit 毫秒 + 10 bit 机器ID + 12 bit 序列号
        this.idWorker = new SnowflakeIdWorker(workerId);
    }
    
    /**
     * 为消息分配全局唯一且递增的序列号
     * 注意:这里生成的是趋势递增 ID,不是严格递增
     * 严格递增限制单点写入,分布式环境下用 Snowflake 即可
     */
    public long nextSeq() {
        return idWorker.nextId();
    }
}

严格递增的场景可以通过 Redis INCR 或数据库自增实现,但单点写入会成为瓶颈。对于 IM 消息排序,趋势递增已经足够——同一会话的消息序列号整体递增,跨会话的排序不依赖全局时间序。


消息必达:TCP 保证不了,需要应用层 ACK

TCP 只能保证"数据到达对端内核",但应用程序可能还没处理就崩溃了。消息必达需要在应用层做确认机制。

java
/**
 * 消息推送 + ACK 确认机制
 */
public class MessageDeliveryService {
    
    private final RedisTemplate<String, String> redis;
    private final MessageSender sender;
    private final ExecutorService retryPool = Executors.newScheduledThreadPool(4);
    
    public void deliver(Message msg, Long receiverId) {
        // 步骤1:持久化消息(写入收件箱)
        inboxDao.insert(msg);
        
        // 步骤2:记录待确认状态
        String ackKey = "im:ack:" + msg.getMsgId();
        redis.opsForValue().set(ackKey, "pending", Duration.ofSeconds(30));
        
        // 步骤3:推送消息
        boolean sent = sender.push(msg, receiverId);
        
        if (!sent) {
            // 推送失败 → 进入重试队列
            retry(msg, receiverId, 3);  // 最多重试 3 次
        }
        
        // 步骤4:异步等待 ACK(累客户端收到消息后回复 ack)
        scheduleAckCheck(msg, receiverId);
    }
    
    private void scheduleAckCheck(Message msg, Long receiverId) {
        retryPool.schedule(() -> {
            String ackKey = "im:ack:" + msg.getMsgId();
            if ("pending".equals(redis.opsForValue().get(ackKey))) {
                // 5 秒内未收到 ACK,重试
                retry(msg, receiverId, 2);
            }
        }, 5, TimeUnit.SECONDS);
    }
    
    private void retry(Message msg, Long receiverId, int remaining) {
        if (remaining <= 0) return;
        
        sender.push(msg, receiverId);
        retryPool.schedule(() -> retry(msg, receiverId, remaining - 1), 
                           3, TimeUnit.SECONDS);
    }
}

客户端收到消息后回复 ACK {msg_id},服务端收到后删除待确认标记。如果 5 秒内未收到 ACK,服务端重推,最多重试 3 次。3 次都失败 → 转为离线消息,用户下次上线时拉取。


离线消息存储

用户离线时,消息不能丢。方案:按用户维度,将消息存入离线消息表。

sql
-- 离线消息表
CREATE TABLE offline_message (
    user_id BIGINT NOT NULL,
    msg_id BIGINT NOT NULL,
    content TEXT NOT NULL,
    created_at BIGINT NOT NULL,
    PRIMARY KEY (user_id, msg_id)
) PARTITION BY HASH(user_id) PARTITIONS 256;

用户上线时的拉取逻辑:

java
public List<Message> pullOfflineMessages(Long userId, Long lastSeqId) {
    // 第一次拉取:最近 100 条未读
    // 后续拉取:游标分页,lastSeqId 作为起点
    return offlineMessageDao.findByUserId(userId, lastSeqId, 100);
}

关键优化:离线消息不能无限存。用户 30 天不登录,离线消息超过 1000 条 → 只保留最近 1000 条,超过的丢弃并提示"请查看历史消息"。历史消息走冷存储(HBase 或对象存储),按时间范围查询。


P7/P8 该会的加深

已读回执:存"状态"还是存"最后读取位置"?

如果每条消息都存"已读/未读"状态,按 1 亿用户、每人每天 100 条消息计算,一天就是 100 亿条已读状态记录,存储爆炸。

真正方案:不存每条消息的已读状态,改成存用户最后读取的消息 ID。用户上线时,服务端计算:

未读消息数 = 收件箱消息总数 - 用户最后读取位置

存储量从 O(消息数 × 用户数) 降到 O(每用户 1 条记录)。用户读取消息后,更新 last_read_seq_id 即可。

消息撤回:不是真的删除

消息撤回不是把消息从数据库删掉,而是标记为"已撤回"。客户端展示时,如果消息 ID 在撤回列表中,显示"对方撤回了一条消息"。

撤回的 2 分钟限制(微信)是为了避免服务端存储所有历史撤回记录——超过 2 分钟的消息,服务端不再允许撤回,也就不需要长期维护撤回列表。

消息的端到端加密(E2EE)

P8 级别还要能聊端到端加密。Telegram 的 Secret Chat 和 Signal 的做法:客户端生成密钥对,密钥交换通过 Diffie-Hellman 完成,服务端只做密文中继,不存储明文。服务端即使被攻破,也无法读取消息内容。

但这个方案和"消息必达"、"消息搜索"有冲突——服务端无法对加密消息建立索引,全文搜索只能在客户端本地做。大多数 IM 产品(微信、钉钉)选择不做 E2EE,以便支持云端搜索和多设备同步。


总结

面试实时消息系统,核心是存储模型的选择消息有序性。P7 级别要能讲清楚:

  • 写扩散 vs 读扩散的选型边界(群聊人数阈值)
  • 服务端分配 seq_id 保证有序性
  • 离线消息的分表存储和游标分页

P8 级别还要掌握:

  • 已读回执的"最后读取位置"方案
  • 消息必达的 ACK 确认机制
  • 消息撤回的实现原理
  • 端到端加密的取舍

面试官问 IM 系统,最终想听的是"你知不知道每种方案的代价"——不是"微信用了什么方案"(那是产品常识),而是"为什么微信选这个方案,代价是什么,换你在其他场景会怎么选"。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。