📖 开场:对讲机的进化
想象对讲机的进化史 📻:
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
├── 2 → 5(好友2有5条未读消息)
├── 3 → 2(好友3有2条未读消息)
└── group:100 → 10(群100有10条未读消息)
总未读数: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: 三层保障:
-
消息持久化:
- 保存到MongoDB
-
离线消息:
- 用户离线时存储到Redis
-
消息确认机制:
- 客户端收到消息后发送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即时通讯系统的设计!🎊
核心要点:
- WebSocket长连接:实时推送,双向通信
- 离线消息:Redis存储,上线拉取
- 群聊:写扩散推送,大群优化
- 未读数:Redis Hash统计
- 消息持久化: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⭐!