💬 设计一个IM即时通讯系统:微信的秘密!

46 阅读11分钟

📖 开场:对讲机的进化

想象对讲机的进化史 📻:

1.0版(传统短信)

你:发送消息 📱
    ↓
短信中心
    ↓
等待10秒
    ↓
对方:收到消息 ✅

缺点:
- 延迟高(10秒)❌
- 无法实时聊天 ❌
- 无法群聊 ❌

2.0版(IM即时通讯)

你:发送消息 📱
    ↓
长连接(WebSocket)
    ↓
实时推送(瞬间)⚡
    ↓
对方:立即收到 ✅

优点:
- 实时(毫秒级)✅
- 支持群聊 ✅
- 支持语音、视频、文件 ✅
- 离线消息 ✅

这就是IM系统:微信、QQ、钉钉的核心!


🤔 核心功能

功能1:单聊 💬

用户A → 发送消息
    ↓
IM服务器
    ↓
用户B ← 实时收到

特点:
- 一对一
- 实时推送
- 离线存储

功能2:群聊 👥

用户A → 发送消息到群
    ↓
IM服务器
    ↓
群成员B、C、D ← 同时收到

特点:
- 一对多
- 消息扩散(Fan-out)
- 群成员管理

功能3:离线消息 📬

用户A:发送消息
    ↓
用户B:离线(不在线)❌
    ↓
IM服务器:存储离线消息
    ↓
用户B:上线
    ↓
拉取离线消息 ✅

功能4:消息已读/未读 ✅

用户A → 发送消息
    ↓
用户B ← 收到消息(未读)
    ↓
用户B:阅读消息
    ↓
用户B → 发送"已读"回执
    ↓
用户A ← 收到"已读"标记 ✅

功能5:消息未读数 🔔

用户A:未读消息数 = 5
    ↓
显示小红点 🔴
    ↓
用户A:点击聊天
    ↓
未读数清零 ✅

🎯 架构设计

整体架构

        IM系统架构

┌────────────────────────────────────────┐
│           客户端(App/Web)             │
│  - WebSocket长连接                     │
│  - 消息发送/接收                       │
│  - 离线消息拉取                        │
└──────────────┬─────────────────────────┘
               │ WebSocket
               ↓
┌────────────────────────────────────────┐
│        接入层(Gateway)               │
│                                        │
│  - WebSocket连接管理                  │
│  - 消息路由                           │
│  - 负载均衡                           │
│  - 心跳检测                           │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│        业务逻辑层(Service)            │
│                                        │
│  - 消息处理                           │
│  - 群聊管理                           │
│  - 离线消息                           │
│  - 未读数统计                         │
└──────────────┬─────────────────────────┘
               │
               ↓
┌────────────────────────────────────────┐
│          存储层                        │
│                                        │
│  - MySQL(用户、群组)                 │
│  - MongoDB(消息历史)                 │
│  - Redis(在线状态、离线消息)         │
└────────────────────────────────────────┘

核心设计1:长连接管理 🔌

WebSocket连接

为什么用WebSocket?

HTTP:
客户端:发送请求
    ↓
服务器:返回响应
    ↓
连接关闭 ❌

WebSocket:
客户端 ←→ 服务器:长连接 ✅
    ↓
双向实时通信 ✅
服务器可以主动推送 ✅

代码实现(Spring Boot)

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

WebSocket配置

@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
    
    @Autowired
    private IMWebSocketHandler imWebSocketHandler;
    
    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        // ⭐ 注册WebSocket处理器
        registry.addHandler(imWebSocketHandler, "/im")
                .setAllowedOrigins("*");  // 允许跨域
    }
}

WebSocket处理器

@Component
public class IMWebSocketHandler extends TextWebSocketHandler {
    
    // ⭐ 存储所有连接:userId -> WebSocketSession
    private static final ConcurrentHashMap<Long, WebSocketSession> SESSIONS = 
        new ConcurrentHashMap<>();
    
    @Autowired
    private MessageService messageService;
    
    /**
     * ⭐ 连接建立
     */
    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        // 从session中获取userId(登录时已设置)
        Long userId = getUserId(session);
        
        // 存储连接
        SESSIONS.put(userId, session);
        
        System.out.println("⭐ 用户上线:" + userId + ",当前在线人数:" + SESSIONS.size());
        
        // 拉取离线消息
        pullOfflineMessages(userId);
    }
    
    /**
     * ⭐ 收到消息
     */
    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        Long userId = getUserId(session);
        String payload = message.getPayload();
        
        // 解析消息
        IMMessage imMessage = JSON.parseObject(payload, IMMessage.class);
        
        // 处理消息
        messageService.handleMessage(userId, imMessage);
    }
    
    /**
     * ⭐ 连接关闭
     */
    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        Long userId = getUserId(session);
        
        // 移除连接
        SESSIONS.remove(userId);
        
        System.out.println("⭐ 用户下线:" + userId + ",当前在线人数:" + SESSIONS.size());
    }
    
    /**
     * ⭐ 发送消息给指定用户
     */
    public void sendMessage(Long userId, String message) throws IOException {
        WebSocketSession session = SESSIONS.get(userId);
        
        if (session != null && session.isOpen()) {
            session.sendMessage(new TextMessage(message));
        } else {
            // 用户离线,存储离线消息
            messageService.saveOfflineMessage(userId, message);
        }
    }
    
    /**
     * 拉取离线消息
     */
    private void pullOfflineMessages(Long userId) throws IOException {
        List<String> offlineMessages = messageService.getOfflineMessages(userId);
        
        for (String message : offlineMessages) {
            sendMessage(userId, message);
        }
        
        // 清空离线消息
        messageService.clearOfflineMessages(userId);
    }
    
    /**
     * 获取userId
     */
    private Long getUserId(WebSocketSession session) {
        return (Long) session.getAttributes().get("userId");
    }
}

心跳检测 💓

为什么需要心跳?

问题:
网络断开
    ↓
服务器不知道客户端已断开
    ↓
连接残留,占用资源 ❌

解决:
客户端:每30秒发送心跳包(ping)
    ↓
服务器:收到心跳包,回复(pong)
    ↓
超过60秒没收到心跳 → 关闭连接 ✅

代码实现

@Component
public class HeartbeatTask {
    
    @Autowired
    private IMWebSocketHandler imWebSocketHandler;
    
    // 最后一次心跳时间:userId -> timestamp
    private static final ConcurrentHashMap<Long, Long> LAST_HEARTBEAT = 
        new ConcurrentHashMap<>();
    
    /**
     * ⭐ 心跳检测(每30秒执行一次)
     */
    @Scheduled(fixedRate = 30000)
    public void checkHeartbeat() {
        long now = System.currentTimeMillis();
        
        LAST_HEARTBEAT.forEach((userId, lastTime) -> {
            // 超过60秒没收到心跳
            if (now - lastTime > 60000) {
                System.out.println("⭐ 用户心跳超时,关闭连接:" + userId);
                
                // 关闭连接
                imWebSocketHandler.closeConnection(userId);
                
                // 移除心跳记录
                LAST_HEARTBEAT.remove(userId);
            }
        });
    }
    
    /**
     * 更新心跳时间
     */
    public void updateHeartbeat(Long userId) {
        LAST_HEARTBEAT.put(userId, System.currentTimeMillis());
    }
}

核心设计2:单聊实现 💬

消息流程

用户A(userId=1) → 发送消息给用户B(userId=2)
    ↓
1. 客户端:构造消息对象
{
    "fromUserId": 1,
    "toUserId": 2,
    "content": "你好",
    "type": "TEXT",
    "timestamp": 1234567890
}
    ↓
2. 通过WebSocket发送到服务器
    ↓
3. 服务器:保存消息到数据库
    ↓
4. 服务器:查找用户B的连接
    ↓
5. 用户B在线 → 实时推送 ✅
   用户B离线 → 存储离线消息 📬

代码实现

消息实体

@Data
public class IMMessage {
    private Long id;                // 消息ID
    private Long fromUserId;        // 发送者ID
    private Long toUserId;          // 接收者ID(单聊)
    private Long groupId;           // 群组ID(群聊)
    private String content;         // 消息内容
    private MessageType type;       // 消息类型:TEXT/IMAGE/VOICE/VIDEO
    private Long timestamp;         // 时间戳
    private MessageStatus status;   // 消息状态:SENT/DELIVERED/READ
}

public enum MessageType {
    TEXT,       // 文本
    IMAGE,      // 图片
    VOICE,      // 语音
    VIDEO,      // 视频
    FILE        // 文件
}

public enum MessageStatus {
    SENT,       // 已发送
    DELIVERED,  // 已送达
    READ        // 已读
}

消息处理

@Service
public class MessageService {
    
    @Autowired
    private MessageMapper messageMapper;
    
    @Autowired
    private IMWebSocketHandler webSocketHandler;
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * ⭐ 处理单聊消息
     */
    public void handleMessage(Long fromUserId, IMMessage message) throws IOException {
        // 1. 保存消息到数据库(MongoDB)
        message.setId(idGenerator.nextId());
        message.setFromUserId(fromUserId);
        message.setTimestamp(System.currentTimeMillis());
        message.setStatus(MessageStatus.SENT);
        messageMapper.insert(message);
        
        // 2. 推送消息给接收者
        Long toUserId = message.getToUserId();
        
        try {
            webSocketHandler.sendMessage(toUserId, JSON.toJSONString(message));
            
            // 消息已送达
            message.setStatus(MessageStatus.DELIVERED);
            messageMapper.updateById(message);
            
        } catch (Exception e) {
            // 用户离线,存储离线消息
            saveOfflineMessage(toUserId, JSON.toJSONString(message));
        }
        
        // 3. 更新未读数
        incrementUnreadCount(toUserId, fromUserId);
    }
    
    /**
     * ⭐ 保存离线消息(Redis)
     */
    public void saveOfflineMessage(Long userId, String message) {
        String key = "offline_msg:" + userId;
        redisTemplate.opsForList().rightPush(key, message);
    }
    
    /**
     * ⭐ 获取离线消息
     */
    public List<String> getOfflineMessages(Long userId) {
        String key = "offline_msg:" + userId;
        
        Long size = redisTemplate.opsForList().size(key);
        if (size == null || size == 0) {
            return Collections.emptyList();
        }
        
        return redisTemplate.opsForList().range(key, 0, size - 1);
    }
    
    /**
     * ⭐ 清空离线消息
     */
    public void clearOfflineMessages(Long userId) {
        String key = "offline_msg:" + userId;
        redisTemplate.delete(key);
    }
    
    /**
     * ⭐ 增加未读数(Redis)
     */
    private void incrementUnreadCount(Long userId, Long fromUserId) {
        String key = "unread:" + userId + ":" + fromUserId;
        redisTemplate.opsForValue().increment(key, 1);
    }
}

核心设计3:群聊实现 👥

消息扩散(Fan-out)

用户A → 发送消息到群(100人)
    ↓
方案1:写扩散(推模式)
    ↓
服务器:遍历群成员(100人)
    ↓
推送给每个在线成员
    ↓
离线成员:存储离线消息

优点:
- 读取快(直接收到消息)✅

缺点:
- 写入慢(需要推送100次)❌
- 大群压力大(1000人群)❌

代码实现

群消息处理

@Service
public class GroupMessageService {
    
    @Autowired
    private GroupMemberMapper groupMemberMapper;
    
    @Autowired
    private MessageService messageService;
    
    @Autowired
    private IMWebSocketHandler webSocketHandler;
    
    /**
     * ⭐ 处理群聊消息
     */
    public void handleGroupMessage(Long fromUserId, IMMessage message) throws IOException {
        Long groupId = message.getGroupId();
        
        // 1. 保存消息到数据库
        message.setId(idGenerator.nextId());
        message.setFromUserId(fromUserId);
        message.setTimestamp(System.currentTimeMillis());
        messageMapper.insert(message);
        
        // ⭐ 2. 查询群成员
        List<GroupMember> members = groupMemberMapper.selectByGroupId(groupId);
        
        // ⭐ 3. 推送消息给所有群成员(除了发送者)
        for (GroupMember member : members) {
            if (member.getUserId().equals(fromUserId)) {
                continue;  // 跳过发送者
            }
            
            try {
                webSocketHandler.sendMessage(member.getUserId(), JSON.toJSONString(message));
            } catch (Exception e) {
                // 用户离线,存储离线消息
                messageService.saveOfflineMessage(member.getUserId(), JSON.toJSONString(message));
            }
        }
    }
}

大群优化 🚀

问题

1000人大群:
用户A发送消息
    ↓
推送给1000人
    ↓
服务器压力大 💀

优化方案

1. 读扩散(拉模式):
   - 用户A发送消息 → 存储到消息池
   - 群成员:定时拉取最新消息
   - 优点:写入快 ✅
   - 缺点:实时性差 ❌

2. 消息队列:
   - 用户A发送消息 → 写入MQ
   - 消费者:异步推送给群成员
   - 优点:异步,不阻塞 ✅

3. 消息合并:
   - 1秒内的多条消息 → 合并推送
   - 减少推送次数 ✅

核心设计4:未读数统计 🔔

Redis实现

未读数存储(Redis Hash):
Key: unread:{userId}
Field: 好友/群ID
Value: 未读数

例子:
unread:1
    ├── 25(好友25条未读消息)
    ├── 32(好友32条未读消息)
    └── group:10010(群10010条未读消息)

总未读数:5 + 2 + 10 = 17

代码实现

@Service
public class UnreadService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final String UNREAD_KEY_PREFIX = "unread:";
    
    /**
     * ⭐ 增加未读数
     */
    public void incrementUnread(Long userId, String sessionId) {
        String key = UNREAD_KEY_PREFIX + userId;
        redisTemplate.opsForHash().increment(key, sessionId, 1);
    }
    
    /**
     * ⭐ 获取某个会话的未读数
     */
    public Long getUnreadCount(Long userId, String sessionId) {
        String key = UNREAD_KEY_PREFIX + userId;
        Object value = redisTemplate.opsForHash().get(key, sessionId);
        return value == null ? 0L : Long.valueOf(value.toString());
    }
    
    /**
     * ⭐ 获取总未读数
     */
    public Long getTotalUnreadCount(Long userId) {
        String key = UNREAD_KEY_PREFIX + userId;
        
        Map<Object, Object> unreadMap = redisTemplate.opsForHash().entries(key);
        
        long total = 0;
        for (Object value : unreadMap.values()) {
            total += Long.parseLong(value.toString());
        }
        
        return total;
    }
    
    /**
     * ⭐ 清空某个会话的未读数
     */
    public void clearUnread(Long userId, String sessionId) {
        String key = UNREAD_KEY_PREFIX + userId;
        redisTemplate.opsForHash().delete(key, sessionId);
    }
    
    /**
     * ⭐ 标记消息已读
     */
    public void markAsRead(Long userId, String sessionId, Long messageId) {
        // 1. 清空未读数
        clearUnread(userId, sessionId);
        
        // 2. 发送已读回执
        IMMessage receipt = new IMMessage();
        receipt.setType(MessageType.READ_RECEIPT);
        receipt.setToUserId(userId);
        receipt.setMessageId(messageId);
        
        // 发送回执
        // ...
    }
}

📊 数据库设计

用户表

-- ⭐ 用户表
CREATE TABLE t_user (
    id BIGINT PRIMARY KEY COMMENT '用户ID',
    username VARCHAR(50) NOT NULL COMMENT '用户名',
    nickname VARCHAR(100) COMMENT '昵称',
    avatar VARCHAR(500) COMMENT '头像',
    status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-正常 2-禁用',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    
    UNIQUE KEY uk_username (username)
) COMMENT '用户表';

消息表

-- ⭐ 消息表(MongoDB)
{
    "_id": ObjectId("..."),
    "messageId": 123456789,          // 消息ID(雪花算法)
    "fromUserId": 1,                 // 发送者ID
    "toUserId": 2,                   // 接收者ID(单聊)
    "groupId": null,                 // 群组ID(群聊)
    "content": "你好",                // 消息内容
    "type": "TEXT",                  // 消息类型
    "status": "READ",                // 消息状态
    "timestamp": 1234567890000,      // 时间戳
    "createTime": ISODate("...")     // 创建时间
}

-- 索引
db.message.createIndex({"fromUserId": 1, "toUserId": 1, "timestamp": -1});
db.message.createIndex({"groupId": 1, "timestamp": -1});

群组表

-- ⭐ 群组表
CREATE TABLE t_group (
    id BIGINT PRIMARY KEY COMMENT '群组ID',
    group_name VARCHAR(100) NOT NULL COMMENT '群名称',
    owner_id BIGINT NOT NULL COMMENT '群主ID',
    member_count INT NOT NULL DEFAULT 0 COMMENT '成员数',
    max_member_count INT NOT NULL DEFAULT 500 COMMENT '最大成员数',
    create_time DATETIME NOT NULL COMMENT '创建时间',
    
    INDEX idx_owner_id (owner_id)
) COMMENT '群组表';

-- ⭐ 群成员表
CREATE TABLE t_group_member (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    group_id BIGINT NOT NULL COMMENT '群组ID',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    role TINYINT NOT NULL DEFAULT 1 COMMENT '角色:1-普通成员 2-管理员 3-群主',
    join_time DATETIME NOT NULL COMMENT '加入时间',
    
    UNIQUE KEY uk_group_user (group_id, user_id),
    INDEX idx_user_id (user_id)
) COMMENT '群成员表';

🎓 面试题速答

Q1: IM系统如何保证消息实时性?

A: WebSocket长连接

// 客户端和服务器建立WebSocket连接
WebSocketSession session;

// 服务器主动推送消息
session.sendMessage(new TextMessage(message));

优点

  • 双向通信,服务器可以主动推送
  • 实时性高(毫秒级)
  • 减少连接建立开销

Q2: 离线消息如何实现?

A: Redis存储 + 上线拉取

// 用户离线时,存储到Redis
public void saveOfflineMessage(Long userId, String message) {
    String key = "offline_msg:" + userId;
    redisTemplate.opsForList().rightPush(key, message);
}

// 用户上线时,拉取离线消息
public List<String> getOfflineMessages(Long userId) {
    String key = "offline_msg:" + userId;
    return redisTemplate.opsForList().range(key, 0, -1);
}

Q3: 群聊如何实现?

A: 写扩散(推模式)

// 查询群成员
List<GroupMember> members = groupMemberMapper.selectByGroupId(groupId);

// 推送给所有群成员
for (GroupMember member : members) {
    webSocketHandler.sendMessage(member.getUserId(), message);
}

大群优化

  • 消息队列异步推送
  • 消息合并(1秒内的消息合并)

Q4: 未读数如何统计?

A: Redis Hash

// 增加未读数
public void incrementUnread(Long userId, String sessionId) {
    String key = "unread:" + userId;
    redisTemplate.opsForHash().increment(key, sessionId, 1);
}

// 获取总未读数
public Long getTotalUnreadCount(Long userId) {
    String key = "unread:" + userId;
    Map<Object, Object> unreadMap = redisTemplate.opsForHash().entries(key);
    return unreadMap.values().stream()
        .mapToLong(v -> Long.parseLong(v.toString()))
        .sum();
}

Q5: 如何保证消息不丢失?

A: 三层保障

  1. 消息持久化

    • 保存到MongoDB
  2. 离线消息

    • 用户离线时存储到Redis
  3. 消息确认机制

    • 客户端收到消息后发送ACK
    • 服务器收到ACK后标记为已送达
// 发送消息
sendMessage(message);

// 等待ACK
waitForAck(messageId, timeout);

// 超时重发
if (!receivedAck) {
    resendMessage(message);
}

Q6: 心跳机制的作用?

A: 检测连接状态

// 客户端:每30秒发送心跳
setInterval(() => {
    websocket.send("ping");
}, 30000);

// 服务器:超过60秒没收到心跳,关闭连接
@Scheduled(fixedRate = 30000)
public void checkHeartbeat() {
    long now = System.currentTimeMillis();
    LAST_HEARTBEAT.forEach((userId, lastTime) -> {
        if (now - lastTime > 60000) {
            closeConnection(userId);
        }
    });
}

作用

  • 及时发现断开的连接
  • 释放资源

🎬 总结

       IM系统核心设计

┌────────────────────────────────────┐
│ 1. 长连接(WebSocket)⭐            │
│    - 实时推送                      │
│    - 双向通信                      │
│    - 心跳检测                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 2. 离线消息(Redis)               │
│    - 存储离线消息                  │
│    - 上线时拉取                    │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 3. 群聊(写扩散)                  │
│    - 推送给所有群成员              │
│    - 大群优化(MQ异步)            │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 4. 未读数(Redis Hash)            │
│    - 统计未读消息                  │
│    - 已读清零                      │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 5. 消息持久化(MongoDB)           │
│    - 保证消息不丢失                │
│    - 历史消息查询                  │
└────────────────────────────────────┘

🎉 恭喜你!

你已经完全掌握了IM即时通讯系统的设计!🎊

核心要点

  1. WebSocket长连接:实时推送,双向通信
  2. 离线消息:Redis存储,上线拉取
  3. 群聊:写扩散推送,大群优化
  4. 未读数:Redis Hash统计
  5. 消息持久化:MongoDB存储

下次面试,这样回答

"IM系统使用WebSocket实现长连接。客户端和服务器建立连接后,服务器可以主动推送消息,实现毫秒级实时通讯。连接建立后存储在ConcurrentHashMap中,key是userId,value是WebSocketSession。

离线消息使用Redis List存储。用户离线时,消息存储到Redis的离线消息队列中。用户上线时,从Redis拉取所有离线消息并推送,然后清空离线队列。

群聊采用写扩散模式。查询群成员列表,遍历推送消息给每个在线成员。对于大群优化,消息先写入消息队列,由消费者异步推送,避免阻塞。

未读数使用Redis Hash统计。key是'unread:userId',field是会话ID(好友或群ID),value是未读数。用户读取消息时清空对应会话的未读数。总未读数是所有field的value之和。

消息可靠性通过三层保障:消息持久化到MongoDB、离线消息存储到Redis、消息确认机制(ACK)。发送消息后等待ACK,超时则重发。

心跳机制用于检测连接状态。客户端每30秒发送ping,服务器回复pong。服务器定时检查最后心跳时间,超过60秒没收到则关闭连接释放资源。"

面试官:👍 "很好!你对IM系统的设计理解很深刻!"


本文完 🎬

上一篇: 208-设计一个电商系统的订单服务.md
下一篇: 210-设计一个直播弹幕系统.md

作者注:写完这篇,我觉得自己可以去开发微信了!💬
如果这篇文章对你有帮助,请给我一个Star⭐!