← 返回首页

IM即时通讯实战 — 消息必达的秘密

消息必达 已读未读 群聊扩散 WebSocket 离线消息

邮局+电话:微信聊天=打电话实时通、离线消息=信箱存着等你取、群聊=广播通知、已读回执=挂号信签收

📬 业务全景 — IM系统做什么

IM(Instant Messaging)即时通讯系统,是微信、钉钉、飞书这类App背后的核心系统。你每天发的消息、打的视频电话、参与的群聊,背后都有一套精密的消息投递系统在工作。

我们先打个比方,把IM系统想象成一个"邮局+电话"系统

点对点聊天 = 打电话 — 实时双向通话
群聊 = 会议室 — 一人说话全员听到
离线消息 = 信箱 — 不在家时信存着,回来取
已读回执 = 挂号信签收 — 确认对方收到了
💡 核心类比理解:你给朋友发微信,就像打电话——如果对方在线,消息立刻送达(电话打通了);如果对方不在线,消息就像信一样存在信箱里,等对方"回家"再取走;群聊就像会议室里说话,一个人发言,所有人都能听到;已读回执就像挂号信的签收回执,让你知道对方确实看了。

IM全流程:一条消息的旅行

用户A发送消息 服务端路由 消息投递 对方收到+ACK 已读回执 离线存储(如需)

让我们详细拆解每一步:

第1步:用户A发送消息 — A在聊天框输入"你好",点击发送。客户端通过WebSocket长连接把消息发到服务端。

第2步:服务端路由 — 服务端收到消息,查路由表:B在哪台服务器上?B的WebSocket连接在哪个节点?找到后把消息转发过去。

第3步:消息投递 — 服务端通过B的WebSocket连接推送消息。如果B在线,消息直接送达;如果B不在线,消息进入离线存储。

第4步:ACK确认 — B收到消息后,客户端返回一个ACK(确认收到)给服务端。服务端标记这条消息为"已送达"。

第5步:已读回执 — B打开聊天界面看消息时,客户端发送"已读"回执给服务端,服务端更新状态,并通知A"对方已读"。

第6步:离线存储 — 如果B不在线,消息存入离线消息队列(Redis List),等B上线后拉取。

🎯 核心挑战 — 5大难题

挑战1:消息必达 — 消息不能丢,丢了就是事故

IM系统最基本的要求:消息不能丢。你发给朋友的消息,朋友必须收到。网络断了重连后,丢失的消息要补上。

⚠️ 为什么消息会丢?:网络不稳定(WiFi切4G、进电梯信号消失)、服务端重启、客户端崩溃——任何环节出问题都可能导致消息丢失。就像你寄挂号信,邮递员可能在路上摔倒信丢了,但你必须确保信最终送到。

类比理解:挂号信必须送到,收不到要反复投递。普通信丢了就算了,但挂号信丢了邮局要负责——IM消息就是挂号信。

消息投递模型

发送方 服务端存储 推送 接收方 ACK 标记已送达
未收到ACK?→ 超时重推 → 直到收到ACK或超过重试次数

核心思路:先存后推。消息到达服务端后,先存到数据库(持久化),然后再推给接收方。如果推送失败,消息不会丢——因为已经存了,重推就行。

消息必达三原则:① 服务端先持久化再推送(不丢) ② 客户端必须回ACK(确认) ③ 未ACK超时重推(补上)

消息投递状态机

发送中 已送达(服务端) 已送达(客户端) 已读

消息投递状态从"发送中"一步步推进到"已读",每一步都有确认机制,确保不遗漏。

未ACK重推机制代码实现

// 消息投递服务 — 核心投递逻辑 public class MessageDeliveryService { // 消息投递状态枚举 public enum DeliveryStatus { SENDING, // 发送中 SERVER_RECEIVED, // 服务端已接收 DELIVERED, // 已送达客户端 READ // 已读 } @Autowired private MessageRepository messageRepo; @Autowired private WebSocketSessionManager sessionManager; @Autowired private RedisTemplate<String, String> redisTemplate; // 最大重试次数 private static final int MAX_RETRY = 5; // 重试间隔(毫秒): 1s, 2s, 4s, 8s, 16s 指数退避 private static final long[] RETRY_INTERVALS = { 1000, 2000, 4000, 8000, 16000 }; /** * 发送消息 — 核心投递流程 * 1. 先存DB(持久化,保证不丢) * 2. 再推WebSocket * 3. 等ACK,未ACK则重推 */ public void sendMessage(Message msg) { // 第1步:持久化消息到数据库(先存后推!) msg.setStatus(DeliveryStatus.SERVER_RECEIVED); msg.setRetryCount(0); messageRepo.save(msg); // 第2步:推送到接收方 pushMessage(msg); } /** * 推送消息到接收方的WebSocket连接 */ private void pushMessage(Message msg) { String userId = msg.getReceiverId(); WebSocketSession session = sessionManager.getSession(userId); if (session != null && session.isOpen()) { // 对方在线,通过WebSocket推送 try { String json = JsonUtil.toJson(msg); session.sendMessage(new TextMessage(json)); // 设置ACK超时检测 scheduleAckTimeout(msg); } catch (Exception e) { // 推送失败,进入重试队列 scheduleRetry(msg); } } else { // 对方不在线,存入离线消息队列 storeOfflineMessage(msg); } } /** * 处理ACK — 客户端确认收到消息 */ public void handleAck(String messageId, String userId) { Message msg = messageRepo.findById(messageId); if (msg != null) { msg.setStatus(DeliveryStatus.DELIVERED); messageRepo.save(msg); // 取消重试任务 cancelRetryTask(messageId); // 通知发送方:消息已送达 notifySender(msg.getSenderId(), messageId, "delivered"); } } /** * ACK超时 — 未收到确认,触发重试 */ private void scheduleAckTimeout(Message msg) { // 5秒内未收到ACK,触发重试 ScheduledFuture<?> future = scheduler.schedule(() -> { Message latest = messageRepo.findById(msg.getId()); if (latest != null && latest.getStatus() != DeliveryStatus.DELIVERED) { scheduleRetry(latest); } }, 5, TimeUnit.SECONDS); retryTasks.put(msg.getId(), future); } /** * 重试推送 — 指数退避策略 */ private void scheduleRetry(Message msg) { int retryCount = msg.getRetryCount(); if (retryCount >= MAX_RETRY) { // 超过最大重试次数,标记为投递失败,转离线存储 storeOfflineMessage(msg); return; } msg.setRetryCount(retryCount + 1); messageRepo.save(msg); // 指数退避:1s → 2s → 4s → 8s → 16s long delay = RETRY_INTERVALS[Math.min(retryCount, RETRY_INTERVALS.length - 1)]; scheduler.schedule(() -> pushMessage(msg), delay, TimeUnit.MILLISECONDS); } /** * 存入离线消息队列(Redis List) */ private void storeOfflineMessage(Message msg) { String key = "offline:" + msg.getReceiverId(); redisTemplate.opsForList().rightPush(key, JsonUtil.toJson(msg)); // 设置7天过期 redisTemplate.expire(key, 7, TimeUnit.DAYS); } }
🔑 关键点:先存后推是消息必达的核心。就像挂号信——邮局先登记你的信(持久化),然后再派送。即使派送途中出了问题,信还在邮局,可以重新派送。如果直接推不存,就像口头传话——中间人忘了,话就丢了。

挑战2:已读未读 — 对方看了没有?

你发完消息后,最关心的是什么?"对方看了没有!"这就是已读未读的核心需求。

类比理解:已读回执就像挂号信的签收回执。你寄出一封挂号信,邮局会给你一张回执,告诉你对方已经签收了。微信的"已读"就是这种签收回执。

💡 单聊已读 vs 群聊已读:单聊简单——一对一,更新一条记录就行。群聊复杂——500人的群,谁看了谁没看?这就需要仔细设计了。

单聊已读:点对点更新

单聊已读很简单,只需要维护一个"已读位置"——记录对方读到了第几条消息。

// 已读状态表设计 // 表名: read_status // +-----------+----------+------+ // | session_id| user_id | last_read_msg_id | // +-----------+----------+------+ // | s_001 | user_B | msg_105 | // +-----------+----------+------+ // 含义:在会话s_001中,用户B已读的最后一条消息是msg_105 public class ReadStatusService { @Autowired private ReadStatusRepository readStatusRepo; @Autowired private WebSocketSessionManager sessionManager; /** * 标记已读 — 用户打开聊天界面时调用 * @param sessionId 会话ID * @param userId 当前用户ID * @param lastReadMsgId 已读的最后一条消息ID */ public void markAsRead(String sessionId, String userId, String lastReadMsgId) { // 更新已读位置 ReadStatus status = readStatusRepo.findBySessionAndUser(sessionId, userId); if (status == null) { status = new ReadStatus(); status.setSessionId(sessionId); status.setUserId(userId); } status.setLastReadMsgId(lastReadMsgId); status.setReadTime(new Date()); readStatusRepo.save(status); // 通知对方:消息已读 String otherUserId = getOtherUserId(sessionId, userId); ReadNotification notification = new ReadNotification(); notification.setSessionId(sessionId); notification.setLastReadMsgId(lastReadMsgId); notification.setReadBy(userId); sessionManager.sendToUser(otherUserId, JsonUtil.toJson(notification)); } /** * 获取未读消息数 */ public int getUnreadCount(String sessionId, String userId) { ReadStatus status = readStatusRepo.findBySessionAndUser(sessionId, userId); if (status == null) { // 从未读过,返回该会话所有消息数 return messageRepo.countBySession(sessionId); } // 统计lastReadMsgId之后的消息数 return messageRepo.countAfterId(sessionId, status.getLastReadMsgId()); } }

群聊已读:计数器方案 vs 明细方案

群聊已读就复杂了——500人的群,每个人是否已读都要记录。两种方案各有取舍:

对比维度 计数器方案 明细方案
原理 每条消息维护一个"已读人数"计数器 每条消息记录"谁已读了"的明细列表
存储量 极小:1条消息 = 1个数字 较大:1条消息 × 500人 = 500条记录
查询已读人数 O(1),直接读计数器 O(N),需要count查询
查询谁已读/未读 做不到,只有数字 O(N),可以精确列出
适用场景 只关心"几个人已读"的场景 需要知道"谁读了谁没读"的场景
典型应用 微信群聊(只显示已读人数) 钉钉群聊(显示谁读了谁没读)
性能 高,计数器操作极快 低,写入量大,查询慢
// ========== 方案1:计数器方案 ========== // Redis Key设计: group:read:count:{groupId}:{msgId} → 已读人数 // Redis Key设计: group:read:total:{groupId}:{msgId} → 总人数 public class GroupReadCounterService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private StringRedisTemplate stringRedis; /** * 群消息已读 — 计数器方案 * 每个用户已读一次,计数器+1 */ public void markGroupRead(String groupId, String msgId, String userId) { // 用Set去重:同一个用户对同一条消息只能算一次已读 String setKey = "group:read:set:" + groupId + ":" + msgId; Long added = stringRedis.opsForSet().add(setKey, userId); if (added > 0) { // 第一次标记已读,计数器+1 String countKey = "group:read:count:" + groupId + ":" + msgId; redisTemplate.opsForValue().increment(countKey); } } /** * 获取群消息已读人数 */ public int getReadCount(String groupId, String msgId) { String countKey = "group:read:count:" + groupId + ":" + msgId; String count = redisTemplate.opsForValue().get(countKey); return count == null ? 0 : Integer.parseInt(count); } /** * 获取未读人数 = 群总人数 - 已读人数 */ public int getUnreadCount(String groupId, String msgId, int totalMembers) { return totalMembers - getReadCount(groupId, msgId); } } // ========== 方案2:明细方案 ========== // 表名: group_read_detail // +---------+--------+--------+-----------+ // | group_id| msg_id | user_id| read_time | // +---------+--------+--------+-----------+ // | g_001 | msg_10 | user_A | 10:30:00 | // | g_001 | msg_10 | user_B | 10:31:00 | // +---------+--------+--------+-----------+ public class GroupReadDetailService { @Autowired private GroupReadDetailRepository readDetailRepo; /** * 标记群消息已读 — 明细方案 */ public void markGroupRead(String groupId, String msgId, String userId) { // 幂等:已存在则不重复插入 if (!readDetailRepo.existsByGroupAndMsgAndUser(groupId, msgId, userId)) { GroupReadDetail detail = new GroupReadDetail(); detail.setGroupId(groupId); detail.setMsgId(msgId); detail.setUserId(userId); detail.setReadTime(new Date()); readDetailRepo.save(detail); } } /** * 查询谁已读了(钉钉场景:显示已读人列表) */ public List<String> getReadUsers(String groupId, String msgId) { return readDetailRepo.findReadUserIds(groupId, msgId); } /** * 查询谁未读(钉钉场景:显示未读人列表,方便@提醒) */ public List<String> getUnreadUsers(String groupId, String msgId) { List<String> allMembers = groupMemberRepo.findMemberIds(groupId); List<String> readUsers = getReadUsers(groupId, msgId); allMembers.removeAll(readUsers); return allMembers; } }
💡 实战建议:大多数场景用计数器方案就够了(存储小、性能高)。只有像钉钉这种需要精确显示"谁读了谁没读"的场景,才用明细方案。也可以混合使用:Redis计数器做实时展示,异步写入DB明细做后续分析。

挑战3:群聊扩散 — 1条消息推给500人

群聊最大的挑战:1条消息要推给群里的所有人。500人的群,发1条消息就要投递500次。万人大群呢?10万人大群呢?这就是"群聊扩散"问题。

类比理解:班主任通知全班——不需要跟每人单独说,发到班群里,所有人自己来看就行。但如果是"写扩散"(给每人单独发),就是老师给50个学生分别打电话,太累了。

写扩散 vs 读扩散

对比维度 写扩散(推模式) 读扩散(拉模式)
原理 发消息时,写入每个成员的收件箱 发消息时,只写入群信箱;读消息时,从群信箱拉取
写入量 大:1条消息 × N人 = N次写入 小:1条消息 = 1次写入
读取量 小:直接读自己收件箱 大:每次打开群聊都要查群信箱
写延迟 高:要写N份,大群写完要几秒 低:只写1份,瞬间完成
读延迟 低:直接读自己的 较高:需要查群信箱+过滤
适用场景 小群(<200人),读多写少 大群(>200人),写多读少
典型应用 微信小群聊 微博超话、B站直播间
写扩散
消息 写N份 用户1收件箱 用户2收件箱 ...用户N收件箱
读扩散
消息 写1份到群信箱 用户打开群聊时拉取

混合方案:小群写扩散、大群读扩散

实战最优解:人数 ≤ 200 用写扩散(每人收件箱直接有消息,读起来快),人数 > 200 用读扩散(只写群信箱,读时再拉)。这样兼顾了小群的体验和大群的性能。
public class GroupMessageDispatchService { // 写扩散阈值:群成员数 <= 200 用写扩散 private static final int WRITE_FANOUT_THRESHOLD = 200; @Autowired private GroupMemberRepository groupMemberRepo; @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private RocketMQTemplate mqTemplate; @Autowired private MessageDeliveryService deliveryService; /** * 群消息扩散 — 混合方案 * 1. 消息先存到群消息表 * 2. 根据群大小选择扩散策略 */ public void dispatchGroupMessage(Message msg, String groupId) { // 第1步:消息存入群消息表(所有方案都需要) groupMessageRepo.save(msg); // 第2步:获取群成员数 int memberCount = groupMemberRepo.countByGroup(groupId); if (memberCount <= WRITE_FANOUT_THRESHOLD) { // 小群:写扩散 — 给每个在线成员推送 writeFanout(msg, groupId); } else { // 大群:读扩散 — 只更新群信箱时间线 readFanout(msg, groupId); } } /** * 写扩散:给每个群成员推送消息 */ private void writeFanout(Message msg, String groupId) { List<String> memberIds = groupMemberRepo.findMemberIds(groupId); // 异步批量推送,不阻塞发送方 for (String memberId : memberIds) { if (!memberId.equals(msg.getSenderId())) { // 排除发送者自己 Message copy = msg.copyForReceiver(memberId); // 写入每个成员的收件箱Timeline(Redis Sorted Set) String inboxKey = "inbox:" + memberId; redisTemplate.opsForZSet().add( inboxKey, copy.getId(), msg.getTimestamp() ); // 如果在线,实时推送 deliveryService.pushMessage(copy); } } } /** * 读扩散:只更新群信箱时间线 * 用户打开群聊时,再从群信箱拉取 */ private void readFanout(Message msg, String groupId) { // 只写入群信箱时间线 String groupInboxKey = "group:inbox:" + groupId; redisTemplate.opsForZSet().add( groupInboxKey, msg.getId(), msg.getTimestamp() ); // 通知在线成员:有新群消息(只通知,不推送内容) List<String> onlineMembers = sessionManager.getOnlineMembers(groupId); for (String memberId : onlineMembers) { if (!memberId.equals(msg.getSenderId())) { GroupNotification notify = new GroupNotification(); notify.setGroupId(groupId); notify.setMsgId(msg.getId()); notify.setType("NEW_MSG"); sessionManager.sendToUser(memberId, JsonUtil.toJson(notify)); } } } }

挑战4:长连接管理 — WebSocket连接建立、心跳、断线重连

IM系统的基础是WebSocket长连接。就像打电话——拨号建立连接、通话中保持连接、沉默太久问一句"还在吗"(心跳)、信号不好断了自动重拨(断线重连)。

WebSocket连接生命周期
客户端发起连接 握手(HTTP→WS升级) 连接建立
连接建立 心跳保活(每30s) 连接正常
连接正常 心跳超时(3次无响应) 判定断开
判定断开 自动重连 连接恢复
💡 为什么需要心跳?:网络中存在"半开连接"——客户端断网了,但服务端不知道,还以为连接在。就像打电话时对方手机没电了,你还在说话,其实对方已经听不到了。心跳机制就是每隔几秒问一句"还在吗",对方不回答就判定断开。

心跳机制 + 断线重连代码

// ========== 服务端心跳检测 ========== @ServerEndpoint(value = "/ws/im/{userId}") public class ImWebSocketServer { // 心跳超时时间:60秒无心跳判定断开 private static final long HEARTBEAT_TIMEOUT = 60_000; // 存储所有在线连接: userId → Session private static final ConcurrentHashMap<String, Session> onlineSessions = new ConcurrentHashMap<>(); // 存储最后心跳时间: userId → 最后心跳时间 private static final ConcurrentHashMap<String, Long> lastHeartbeat = new ConcurrentHashMap<>(); /** * 连接建立 */ @OnOpen public void onOpen(Session session, @PathParam("userId") String userId) { onlineSessions.put(userId, session); lastHeartbeat.put(userId, System.currentTimeMillis()); log.info("用户上线: {}", userId); } /** * 收到消息 */ @OnMessage public void onMessage(String message, Session session) { JsonNode node = ObjectMapper.readTree(message); String type = node.get("type").asText(); if ("heartbeat".equals(type)) { // 收到心跳,更新最后心跳时间 String userId = getUserIdFromSession(session); lastHeartbeat.put(userId, System.currentTimeMillis()); // 回复心跳 sendToSession(session, "{\"type\":\"heartbeat_ack\"}"); } else if ("message".equals(type)) { // 处理业务消息 messageService.handleMessage(node); } } /** * 连接关闭 */ @OnClose public void onClose(Session session) { String userId = getUserIdFromSession(session); onlineSessions.remove(userId); lastHeartbeat.remove(userId); log.info("用户下线: {}", userId); } /** * 心跳检测定时任务 — 每隔30秒扫描一次 * 清理超过60秒没有心跳的连接 */ public static void checkHeartbeat() { long now = System.currentTimeMillis(); for (Map.Entry<String, Long> entry : lastHeartbeat.entrySet()) { if (now - entry.getValue() > HEARTBEAT_TIMEOUT) { String userId = entry.getKey(); Session session = onlineSessions.get(userId); if (session != null) { // 关闭超时连接 try { session.close(); } catch (Exception e) { log.warn("关闭超时连接失败: {}", userId); } } onlineSessions.remove(userId); lastHeartbeat.remove(userId); log.info("心跳超时,清理连接: {}", userId); } } } }
// ========== 客户端断线重连(JavaScript)========== class IMClient { constructor(userId) { this.userId = userId; this.ws = null; this.heartbeatTimer = null; this.reconnectTimer = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; // 重连间隔:1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s... this.reconnectIntervals = [1000, 2000, 4000, 8000, 16000, 32000, 60000]; this.connect(); } // 建立连接 connect() { this.ws = new WebSocket(`ws://server/ws/im/${this.userId}`); this.ws.onopen = () => { console.log('WebSocket连接成功'); this.reconnectAttempts = 0; // 重置重连次数 this.startHeartbeat(); // 启动心跳 this.pullOfflineMessages(); // 拉取离线消息 }; this.ws.onmessage = (event) => { const data = JSON.parse(event.data); if (data.type === 'heartbeat_ack') { // 心跳回复,连接正常 return; } if (data.type === 'message') { this.handleMessage(data); // 发送ACK确认 this.sendAck(data.id); } }; this.ws.onclose = () => { console.log('WebSocket连接关闭'); this.stopHeartbeat(); this.scheduleReconnect(); // 自动重连 }; this.ws.onerror = (err) => { console.error('WebSocket错误', err); }; } // 心跳保活 — 每30秒发一次 startHeartbeat() { this.heartbeatTimer = setInterval(() => { if (this.ws && this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({type: 'heartbeat'})); } }, 30000); } stopHeartbeat() { clearInterval(this.heartbeatTimer); } // 断线重连 — 指数退避 scheduleReconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.log('超过最大重连次数,停止重连'); return; } const delay = this.reconnectIntervals[ Math.min(this.reconnectAttempts, this.reconnectIntervals.length - 1) ]; console.log(`${delay}ms后尝试第${this.reconnectAttempts + 1}次重连`); this.reconnectTimer = setTimeout(() => { this.reconnectAttempts++; this.connect(); }, delay); } // 拉取离线消息 pullOfflineMessages() { fetch(`/api/messages/offline?userId=${this.userId}`) .then(res => res.json()) .then(messages => { messages.forEach(msg => this.handleMessage(msg)); }); } }

挑战5:离线消息 — 不在线时的消息怎么存?

用户不在线时,发给他的消息怎么处理?存起来,上线后再取

类比理解:你出差了,快递员来送包裹你不在家。快递员不会把包裹扔掉,而是放在快递柜里(离线存储)。等你回来,拿取件码去快递柜取就行了。

发送方发消息 服务端发现接收方不在线 存入Redis List(离线队列)
接收方上线 拉取离线消息 展示给用户 清空离线队列
public class OfflineMessageService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private MessageRepository messageRepo; // 离线消息Key前缀: offline:{userId} private static final String OFFLINE_KEY_PREFIX = "offline:"; // 离线消息最大保存条数 private static final int MAX_OFFLINE_MSG = 1000; // 离线消息过期天数 private static final int EXPIRE_DAYS = 7; /** * 存储离线消息 * 用户不在线时,消息存入Redis List */ public void storeOffline(Message msg) { String key = OFFLINE_KEY_PREFIX + msg.getReceiverId(); String json = JsonUtil.toJson(msg); // 右入队(先进先出) redisTemplate.opsForList().rightPush(key, json); // 限制离线消息数量,防止堆积 Long size = redisTemplate.opsForList().size(key); if (size != null && size > MAX_OFFLINE_MSG) { // 移除最旧的消息 redisTemplate.opsForList().leftPop(key); } // 设置过期时间 redisTemplate.expire(key, EXPIRE_DAYS, TimeUnit.DAYS); } /** * 拉取离线消息 * 用户上线后调用,一次性取出所有离线消息 */ public List<Message> pullOfflineMessages(String userId) { String key = OFFLINE_KEY_PREFIX + userId; // 取出所有离线消息 List<String> jsonList = redisTemplate.opsForList().range(key, 0, -1); if (jsonList == null || jsonList.isEmpty()) { return Collections.emptyList(); } // 反序列化 List<Message> messages = jsonList.stream() .map(json -> JsonUtil.fromJson(json, Message.class)) .collect(Collectors.toList()); // 取完后清空离线队列 redisTemplate.delete(key); return messages; } /** * 获取离线消息数量(用于显示角标) */ public long getOfflineCount(String userId) { String key = OFFLINE_KEY_PREFIX + userId; Long size = redisTemplate.opsForList().size(key); return size == null ? 0 : size; } }

🏗️ 架构设计 — IM系统架构

IM系统的整体架构分为三层:接入层逻辑层存储层。就像一栋楼——接入层是大堂(客人从哪里进来)、逻辑层是各个办公室(处理具体业务)、存储层是仓库(存东西的地方)。

接入层 — WebSocket Gateway
WebSocket集群 连接路由(Nginx) 负载均衡 SSL/TLS
逻辑层 — 业务服务
消息路由服务 会话管理服务 已读服务 群聊扩散服务 离线消息服务 消息ACK服务
存储层 — 数据持久化
MySQL(消息持久化) Redis(在线状态/离线队列/已读计数) RocketMQ(异步消息) MongoDB(消息历史归档)

长连接服务器集群方案

单台WebSocket服务器能支撑的连接数有限(通常几万),百万用户在线需要集群方案

客户端A 客户端B 客户端C
Nginx(负载均衡)
WS节点1 WS节点2 WS节点3
问题:A连节点1,B连节点3,A给B发消息怎么路由?
Redis发布/订阅 节点间消息转发
💡 路由表设计:用Redis维护一张路由表 user_route:{userId} → {serverNode},记录每个用户连在哪个WS节点上。发消息时,先查路由表找到目标节点,再把消息转发过去。如果目标用户在另一个节点,就用Redis Pub/Sub把消息转发到那个节点。
// 路由表管理 public class UserRouteService { @Autowired private RedisTemplate<String, String> redisTemplate; // 路由Key: route:{userId} → 服务器节点ID private static final String ROUTE_PREFIX = "route:"; /** * 用户上线 — 注册路由 */ public void registerRoute(String userId, String serverNode) { String key = ROUTE_PREFIX + userId; redisTemplate.opsForValue().set(key, serverNode, 24, TimeUnit.HOURS); } /** * 用户下线 — 删除路由 */ public void removeRoute(String userId) { redisTemplate.delete(ROUTE_PREFIX + userId); } /** * 查询用户所在节点 */ public String getServerNode(String userId) { return redisTemplate.opsForValue().get(ROUTE_PREFIX + userId); } /** * 跨节点消息转发 * 如果目标用户不在当前节点,通过Redis Pub/Sub转发 */ public void routeMessage(Message msg) { String targetNode = getServerNode(msg.getReceiverId()); if (targetNode == null) { // 用户不在线,存离线消息 offlineService.storeOffline(msg); return; } if (targetNode.equals(ServerInfo.getCurrentNode())) { // 目标用户在当前节点,直接推送 localPush(msg); } else { // 目标用户在其他节点,通过Redis Pub/Sub转发 redisTemplate.convertAndSend( "ws:node:" + targetNode, JsonUtil.toJson(msg) ); } } }

🔄 关键流程 — 完整代码实现

流程1:发消息完整流程(Spring Boot + WebSocket + Netty)

客户端A发送 WS网关接收 消息持久化 消息路由 推送/离线存储 等待ACK
// ========== 1. Netty WebSocket服务端 ========== public class NettyWebSocketServer { private final int port; public NettyWebSocketServer(int port) { this.port = port; } public void start() throws InterruptedException { EventLoopGroup bossGroup = new NioEventLoopGroup(1); EventLoopGroup workerGroup = new NioEventLoopGroup(); try { ServerBootstrap bootstrap = new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer<SocketChannel>() { @Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline = ch.pipeline(); // HTTP编解码(用于WebSocket握手) pipeline.addLast(new HttpServerCodec()); pipeline.addLast(new HttpObjectAggregator(65536)); // WebSocket协议处理 pipeline.addLast(new WebSocketServerProtocolHandler("/ws")); // 空闲检测(心跳) pipeline.addLast(new IdleStateHandler(60, 0, 0, TimeUnit.SECONDS)); // 业务处理器 pipeline.addLast(new ImMessageHandler()); } }); Channel channel = bootstrap.bind(port).sync().channel(); log.info("WebSocket服务器启动,端口: {}", port); channel.closeFuture().sync(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } } } // ========== 2. 消息业务处理器 ========== public class ImMessageHandler extends SimpleChannelInboundHandler<WebSocketFrame> { @Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) { if (frame instanceof TextWebSocketFrame) { String text = ((TextWebSocketFrame) frame).text(); JsonNode node = JsonUtil.parse(text); String type = node.get("type").asText(); switch (type) { case "message": handleChatMessage(ctx, node); break; case "heartbeat": handleHeartbeat(ctx); break; case "ack": handleAck(node); break; case "read": handleRead(node); break; } } } /** * 处理聊天消息 */ private void handleChatMessage(ChannelHandlerContext ctx, JsonNode node) { Message msg = JsonUtil.fromJson(node.get("data"), Message.class); // 第1步:消息持久化(先存后推) messageService.saveMessage(msg); // 第2步:发送方确认(消息已到达服务端) sendToChannel(ctx, JsonUtil.toJson(AckResponse.serverReceived(msg.getId()))); // 第3步:消息路由 + 投递 if (msg.isGroupMessage()) { // 群消息走扩散逻辑 groupDispatchService.dispatchGroupMessage(msg, msg.getGroupId()); } else { // 单聊走路由投递 routeService.routeMessage(msg); } } /** * 空闲检测 — 心跳超时处理 */ @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof IdleStateEvent) { // 60秒没有读到数据,关闭连接 log.warn("连接空闲超时,关闭: {}", ctx.channel()); ctx.close(); } } } // ========== 3. 消息发送API ========== @RestController @RequestMapping("/api/messages") public class MessageController { @Autowired private MessageService messageService; @Autowired private OfflineMessageService offlineService; /** * 发送消息 */ @PostMapping("/send") public Result<String> sendMessage(@RequestBody SendMessageRequest req) { Message msg = new Message(); msg.setId(IdGenerator.nextId()); msg.setSenderId(req.getSenderId()); msg.setReceiverId(req.getReceiverId()); msg.setContent(req.getContent()); msg.setTimestamp(System.currentTimeMillis()); messageService.sendMessage(msg); return Result.success(msg.getId()); } /** * 拉取离线消息 */ @GetMapping("/offline") public Result<List<Message>> pullOffline(@RequestParam String userId) { List<Message> messages = offlineService.pullOfflineMessages(userId); return Result.success(messages); } }

流程2:群消息扩散完整代码

// 群消息发送 — 混合扩散策略完整流程 @Service public class GroupMessageService { private static final int WRITE_FANOUT_THRESHOLD = 200; private static final int BATCH_SIZE = 50; @Autowired private GroupMemberRepository groupMemberRepo; @Autowired private RocketMQTemplate mqTemplate; @Autowired private RedisTemplate<String, String> redisTemplate; /** * 发送群消息 — 入口 */ @Transactional public String sendGroupMessage(String groupId, String senderId, String content) { // 1. 生成消息ID(雪花算法,保证有序且唯一) String msgId = SnowflakeIdGenerator.nextId(); // 2. 消息持久化到群消息表 GroupMessage groupMsg = new GroupMessage(); groupMsg.setId(msgId); groupMsg.setGroupId(groupId); groupMsg.setSenderId(senderId); groupMsg.setContent(content); groupMsg.setTimestamp(System.currentTimeMillis()); groupMessageRepo.save(groupMsg); // 3. 更新群消息时间线(读扩散用) String groupTimelineKey = "group:timeline:" + groupId; redisTemplate.opsForZSet().add(groupTimelineKey, msgId, groupMsg.getTimestamp()); // 4. 异步扩散 int memberCount = groupMemberRepo.countByGroup(groupId); if (memberCount <= WRITE_FANOUT_THRESHOLD) { // 小群:写扩散(异步MQ,分批写入) List<String> memberIds = groupMemberRepo.findMemberIds(groupId); // 分批发送MQ消息,避免单条消息太大 Lists.partition(memberIds, BATCH_SIZE).forEach(batch -> { FanoutMessage fanoutMsg = new FanoutMessage(); fanoutMsg.setMsgId(msgId); fanoutMsg.setGroupId(groupId); fanoutMsg.setContent(content); fanoutMsg.setSenderId(senderId); fanoutMsg.setTargetUserIds(batch); mqTemplate.syncSend("group-fanout", fanoutMsg); }); } else { // 大群:读扩散 — 只更新群信箱 + 通知在线成员 List<String> onlineMembers = sessionManager.getOnlineMembers(groupId); for (String memberId : onlineMembers) { if (!memberId.equals(senderId)) { sessionManager.sendToUser(memberId, JsonUtil.toJson(GroupNotification.newMsg(groupId, msgId))); } } } return msgId; } } // ========== MQ消费者:处理写扩散 ========== @RocketMQMessageListener(topic = "group-fanout", consumerGroup = "fanout-consumer") public class FanoutMessageConsumer implements RocketMQListener<FanoutMessage> { @Override public void onMessage(FanoutMessage msg) { for (String userId : msg.getTargetUserIds()) { if (userId.equals(msg.getSenderId())) continue; // 写入用户收件箱 String inboxKey = "inbox:" + userId; redisTemplate.opsForZSet().add(inboxKey, msg.getMsgId(), System.currentTimeMillis()); // 在线则实时推送 if (sessionManager.isOnline(userId)) { deliveryService.pushMessage(msg, userId); } else { offlineService.storeOffline(msg, userId); } } } }

流程3:离线消息拉取代码

// 离线消息拉取 — 用户上线后执行 @Service public class OfflineMessagePullService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private MessageRepository messageRepo; private static final String OFFLINE_PREFIX = "offline:"; /** * 用户上线 — 拉取离线消息 * 1. 从Redis List取出离线消息ID列表 * 2. 批量从DB查消息详情(避免Redis存完整消息体太大) * 3. 按时间排序后返回 */ public List<Message> pullOnLogin(String userId) { String key = OFFLINE_PREFIX + userId; // 取出所有离线消息ID List<String> msgIds = redisTemplate.opsForList().range(key, 0, -1); if (msgIds == null || msgIds.isEmpty()) { return Collections.emptyList(); } // 批量查消息详情 List<Message> messages = messageRepo.findByIds(msgIds); // 按时间排序 messages.sort(Comparator.comparingLong(Message::getTimestamp)); // 清空离线队列 redisTemplate.delete(key); // 异步:标记这些消息为已送达 CompletableFuture.runAsync(() -> { messages.forEach(msg -> messageRepo.updateStatus(msg.getId(), DeliveryStatus.DELIVERED)); }); return messages; } /** * 增量拉取 — 客户端记录最后消息ID,只拉取新增的 * 适用于长时间在线后的增量同步 */ public List<Message> pullIncremental(String userId, String lastMsgId) { // 查询lastMsgId之后的所有消息 return messageRepo.findByReceiverAfterId(userId, lastMsgId); } }

💥 踩坑实录 — 4个真实事故

坑1:消息乱序 — 网络延迟导致消息顺序错乱

现象:用户A发了3条消息"1""2""3",对方收到顺序变成了"1""3""2"。

原因:消息2在网络中延迟了,消息3先到达。就像3封信走不同邮路,先发的后到。

发"1" 发"2" 发"3"
↓ 快 ↓ 慢(网络延迟) ↓ 快
收"1" 收"3" 收"2"

解决方案:给每条消息加序号(seq),客户端按seq排序显示。

// 消息序号方案 public class MessageSeqService { @Autowired private RedisTemplate<String, String> redisTemplate; /** * 为会话分配消息序号 * 每个会话独立递增,保证会话内有序 */ public long nextSeq(String sessionId) { String key = "msg:seq:" + sessionId; return redisTemplate.opsForValue().increment(key); } } // 客户端:按seq排序显示 // messages.sort((a, b) => a.seq - b.seq); // 即使收到顺序乱了,展示也是正确的
关键:消息序号保证逻辑顺序,不依赖网络到达顺序。就像快递单号——3号包裹可能比2号先到,但你按单号排序就不会搞错。

坑2:群消息风暴 — 500人群每秒100条消息

现象:活跃群里消息刷屏,服务端CPU飙升,推送延迟严重。

原因:500人 × 100条/秒 = 每秒5万次推送。就像500人同时说话,每个人都要听其他499人的——信息爆炸。

解决方案:限流 + 合并推送

// 群消息限流 + 合并推送 @Service public class GroupMessageThrottleService { @Autowired private RedisTemplate<String, String> redisTemplate; // 每群每秒最大消息数 private static final int MAX_MSG_PER_SEC = 50; // 合并推送窗口:200ms内的消息合并成一条推送 private static final long MERGE_WINDOW_MS = 200; // 合并缓冲区: groupId → 待推送消息列表 private final ConcurrentHashMap<String, List<Message>> mergeBuffer = new ConcurrentHashMap<>(); /** * 限流检查 */ public boolean allowMessage(String groupId) { String key = "group:rate:" + groupId; Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { // 第一条消息,设置1秒过期 redisTemplate.expire(key, 1, TimeUnit.SECONDS); } return count <= MAX_MSG_PER_SEC; } /** * 合并推送 * 200ms内的消息打包成一条推送,减少WebSocket帧数 */ public void addToMergeBuffer(Message msg, String groupId) { mergeBuffer.computeIfAbsent(groupId, k -> new CopyOnWriteArrayList<>()) .add(msg); // 延迟200ms后批量推送 scheduler.schedule(() -> flushBuffer(groupId), MERGE_WINDOW_MS, TimeUnit.MILLISECONDS); } private void flushBuffer(String groupId) { List<Message> batch = mergeBuffer.remove(groupId); if (batch != null && !batch.isEmpty()) { // 合并成一条WebSocket帧发送 String json = JsonUtil.toJson(MergedMessage.of(batch)); sessionManager.broadcastToGroup(groupId, json); } } }

坑3:WebSocket连接假死 — 网络断但服务端不知道

现象:客户端已经断网了,服务端的Session还在,消息推送过去以为成功,实际对方根本没收到。

原因:TCP连接的"半开"状态——一方断网,另一方不知道。就像打电话对方手机没电了,你还在说话,但对方已经听不到了。

解决方案:双向心跳检测 + 连接有效性验证

// 服务端心跳检测 — 使用Netty的IdleStateHandler // 在Pipeline中添加: pipeline.addLast(new IdleStateHandler(60, 30, 0, TimeUnit.SECONDS)); // 参数说明: // readerIdleTime=60: 60秒没读到数据 → 触发空闲事件 → 关闭连接 // writerIdleTime=30: 30秒没写数据 → 触发空闲事件 → 发心跳 @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { if (evt instanceof IdleStateEvent) { IdleStateEvent event = (IdleStateEvent) evt; switch (event.state()) { case READER_IDLE: // 读空闲:客户端可能已断网,关闭连接 log.warn("读空闲超时,关闭连接: {}", ctx.channel()); ctx.close(); break; case WRITER_IDLE: // 写空闲:主动发心跳探测连接 ctx.writeAndFlush(new TextWebSocketFrame( "{\"type\":\"ping\"}")); break; } } }
💡 最佳实践:客户端30秒发一次心跳,服务端60秒收不到心跳就关闭连接。客户端发现连接断了立即重连。这样即使出现"假死"连接,最多60秒就能检测到并清理。

坑4:离线消息堆积 — 用户3个月没上线

现象:某用户3个月没上线,Redis里存了大量离线消息,占用大量内存。

原因:离线消息只存不清理,越来越多。就像信箱不清理,信越堆越多。

解决方案:过期清理策略

// 离线消息过期清理策略 @Service public class OfflineMessageCleanService { @Autowired private RedisTemplate<String, String> redisTemplate; @Autowired private MessageRepository messageRepo; // 离线消息保留天数 private static final int RETENTION_DAYS = 7; // 每个用户最大离线消息数 private static final int MAX_OFFLINE_COUNT = 500; /** * 定时清理过期离线消息 — 每天凌晨3点执行 */ @Scheduled(cron = "0 0 3 * * ?") public void cleanExpiredOfflineMessages() { // 扫描所有离线消息Key Set<String> keys = redisTemplate.keys("offline:*"); for (String key : keys) { Long size = redisTemplate.opsForList().size(key); if (size != null && size > MAX_OFFLINE_COUNT) { // 超过500条,只保留最新的500条 long removeCount = size - MAX_OFFLINE_COUNT; for (long i = 0; i < removeCount; i++) { redisTemplate.opsForList().leftPop(key); } log.info("清理离线消息: key={}, 移除{}条", key, removeCount); } // 刷新过期时间 redisTemplate.expire(key, RETENTION_DAYS, TimeUnit.DAYS); } } /** * 替代方案:存入离线消息时直接限制 * 比定时清理更实时 */ public void storeWithLimit(Message msg) { String key = "offline:" + msg.getReceiverId(); redisTemplate.opsForList().rightPush(key, JsonUtil.toJson(msg)); // 裁剪到最新500条 redisTemplate.opsForList().trim(key, -MAX_OFFLINE_COUNT, -1); redisTemplate.expire(key, RETENTION_DAYS, TimeUnit.DAYS); } }

🔍 排查思路 — 问题定位方法论

IM系统出问题时,按照以下思路逐一排查:

1. 消息没收到? — 按链路逐环节排查

发送方 网关 持久化 路由 推送 ACK
# 1. 检查消息是否持久化成功 SELECT * FROM message WHERE id = 'msg_xxx'; # → 如果查不到:持久化环节出问题 # 2. 检查路由表 redis-cli GET route:{userId} # → 如果返回null:用户不在线,应该走离线存储 # → 如果返回节点ID:检查该节点是否存活 # 3. 检查离线消息队列 redis-cli LLEN offline:{userId} # → 如果有积压:用户没来拉取,或拉取失败 # 4. 检查WebSocket连接状态 redis-cli GET ws:session:{userId} # → 检查连接是否存活、心跳是否正常

2. 消息延迟? — 定位瓶颈

# 1. 查看消息端到端延迟 # 在消息体中记录各环节时间戳 { "sendTime": 1700000000000, // 发送方发送时间 "serverRecvTime": 1700000000010, // 服务端收到时间 "pushTime": 1700000000050, // 推送时间 "ackTime": 1700000000100 // ACK时间 } # → 哪个环节耗时大,就优化哪里 # 2. 查看MQ消费延迟 rabbitmqctl list_queues name messages_ready messages_unacknowledged # → 如果unacknowledged多:消费者处理慢 # 3. 查看Redis延迟 redis-cli --latency # → 如果延迟高:检查Redis内存是否不足、大Key是否存在

3. 群消息推不动? — 检查扩散逻辑

# 1. 检查群消息是否入库 SELECT * FROM group_message WHERE group_id = 'g_xxx' ORDER BY timestamp DESC LIMIT 10; # 2. 检查扩散任务是否消费 查看MQ消费组堆积情况 # → 如果堆积:消费者不足或处理慢 # 3. 检查WebSocket连接数 netstat -an | grep :8080 | wc -l # → 如果连接数过多:考虑扩容WS节点

4. 通用排查命令速查

$ redis-cli INFO clients # 查看Redis连接数 $ redis-cli DBSIZE # 查看Redis Key总数 $ redis-cli MEMORY DOCTOR # Redis内存诊断 $ netstat -an | grep ESTABLISHED | wc -l # 查看TCP连接数 $ jstat -gcutil <pid> 1000 # 查看JVM GC情况 $ jstack <pid> | grep BLOCKED # 查看线程阻塞

📝 面试高频 — 5大必考题

Q1:如何保证IM消息不丢失?

答题要点

核心思路:先存后推 + ACK确认 + 超时重推

1. 先存后推:消息到达服务端后先持久化到DB,再推送。即使推送失败,消息不会丢。
2. ACK机制:客户端收到消息后必须返回ACK。服务端收到ACK才标记为"已送达"。
3. 超时重推:如果5秒内没收到ACK,重推消息。最多重试5次(指数退避)。
4. 离线兜底:超过重试次数后,消息存入离线队列,等用户上线后拉取。
5. 端到端确认:发送方能看到消息状态(发送中→已送达→已读),形成闭环。

Q2:群聊消息用写扩散还是读扩散?

答题要点

核心思路:小群写扩散,大群读扩散(混合方案)

1. 写扩散:发消息时写入每个成员的收件箱。读时快(直接读自己的),写时慢(N次写入)。适合小群(≤200人)。
2. 读扩散:发消息时只写入群信箱。读时需要从群信箱拉取,写时快。适合大群(>200人)。
3. 混合方案:根据群大小动态选择。阈值一般设200人。
4. 写扩散优化:用MQ异步写入,分批处理,避免阻塞发送方。
5. 读扩散优化:用Redis Sorted Set存群时间线,ZREVRANGE分页拉取。

Q3:WebSocket和HTTP长轮询的区别?IM为什么用WebSocket?

答题要点

对比维度 HTTP长轮询 WebSocket
通信方式 客户端不断发请求问"有消息吗?" 建立连接后双向通信
实时性 有延迟(取决于轮询间隔) 毫秒级实时
开销 每次请求都要重建HTTP连接、传Header 一次握手后持续通信
服务端推送 不支持,只能客户端拉 原生支持
适用场景 低频消息、兼容性要求高 IM、实时游戏、协作编辑
💡 加分回答:IM选WebSocket因为:① 实时性要求高(聊天不能延迟) ② 双向通信(既要发又要收) ③ 低开销(连接复用,不用反复握手) ④ 服务端能主动推送。HTTP长轮询是WebSocket出现前的替代方案,现在已不推荐用于IM。

Q4:如何处理海量用户的长连接管理?

答题要点

核心思路:集群化 + 路由表 + 发布订阅

1. 集群化:多台WS服务器,Nginx负载均衡,每台承载几万连接。
2. 路由表:Redis维护 userId → serverNode 映射,知道每个用户连在哪个节点。
3. 跨节点通信:A在节点1,B在节点3,用Redis Pub/Sub转发消息。
4. 心跳保活:客户端30s发心跳,服务端60s超时清理,避免假死连接。
5. 断线重连:客户端指数退避重连,重连后拉取离线消息。
6. 连接迁移:节点下线时,通知客户端重连到新节点(优雅停机)。

Q5:已读未读怎么设计?群聊已读和单聊已读有什么区别?

答题要点

核心思路:单聊简单(已读位置),群聊复杂(计数器vs明细)

1. 单聊已读:维护一个read_status表,记录每个会话中用户已读的最后一条消息ID。查询未读数 = 总消息数 - 已读位置之后的消息数。
2. 群聊已读 - 计数器方案:Redis Set去重 + 计数器。每人已读一次计数+1。存储小、性能高,但不知道"谁读了"。
3. 群聊已读 - 明细方案:DB存每条消息的已读人列表。可以查"谁读了谁没读",但存储大、写入量大。
4. 实战建议:微信用计数器(只显示已读人数),钉钉用明细(显示谁读了谁没读)。
5. 性能优化:先写Redis,异步落DB。批量更新已读位置,不要每条消息都更新。