聊天(私聊+群聊)业务设计
本文档基于 Spring Boot + WebSocket 技术栈,提供聊天业务(含私聊、群聊)完整设计方案,涵盖连接建立、消息路由、在线状态、离线消息、心跳保活、数据库设计及分布式扩展,同时提供两种主流实现方式,适配不同项目规模,可直接用于开发、评审及交付。
1. 概述
1.1 业务范围
本方案实现企业级聊天核心功能,覆盖两大核心场景,贴合实际业务需求:
• 私聊:用户之间一对一实时消息收发、未读计数、离线消息拉取、消息送达状态标记
• 群聊:多用户群组消息推送、群成员管理、群内未读消息记录、群消息广播
1.2 设计目标
• 实时性:WebSocket 全双工通信,消息推送延迟 ≤ 100ms
• 可靠性:消息持久化、离线消息不丢失、送达状态可追溯
• 可扩展性:支持单机部署、分布式扩展,适配用户量增长
• 兼容性:支持浏览器原生 WebSocket,不兼容场景自动降级(SockJS)
• 安全性:用户认证、消息防篡改、防未授权访问
1.3 实现方案说明
提供两种主流实现方式,可根据团队技术栈和业务规模选择:
• 方案一:原生 @ServerEndpoint(JSR-356)+ 自定义路由:轻量级,代码简洁,适合中小型项目、简单聊天场景
• 方案二:STOMP 子协议 + Spring Messaging:Spring 官方推荐,内置消息路由、点对点推送能力,功能完善,适合需要复杂消息管理(如群聊、广播)的中大型项目
2. 技术选型
|--------------|-------------------------------------|-------------------------------------|
| 组件类别 | 选型 | 说明 |
| 基础框架 | Spring Boot 2.x / 3.x | 提供自动配置、依赖管理,简化开发流程 |
| WebSocket 核心 | JSR-356(原生)、Spring WebSocket(STOMP) | 遵循 WebSocket 标准,与 Spring 生态无缝集成 |
| 消息协议 | STOMP(可选) | 结构化消息路由,简化私聊、群聊推送逻辑,当前项目采用 |
| 认证授权 | JWT | 无状态认证,与 WebSocket 握手阶段集成,防止未授权连接 |
| 状态存储 | Redis(Set / Hash) | 存储在线用户状态、分布式会话、心跳时间,支持水平扩展 |
| 持久化存储 | MySQL + MyBatis-Plus | 存储用户信息、私聊/群聊消息、群成员关系、会话记录 |
| 消息序列化 | JSON(Fastjson2 / Jackson) | 前后端统一数据格式,简洁易解析 |
| 兼容方案 | SockJS | 浏览器不支持原生 WebSocket 时,自动降级为 HTTP 长轮询 |
| 日志/监控 | SLF4J + Logback | 记录连接、消息收发、异常信息,便于问题排查 |
3. 整体架构
3.1 架构图
核心架构分层清晰,职责明确,支持单机及分布式部署:
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ┌─────────────────┐ │ 客户端(前端) │ │ // 发送消息、订阅推送、展示聊天界面 └────────┬────────┘ │ WebSocket 连接(WS/WSS) ┌────────▼────────┐ │ WebSocket 服务端 │ │ // 连接管理、消息接收、路由、推送 │ (Spring Boot) │ │ └────────┬────────┘ │ ┌────────▼────────┐ ┌────────▼────────┐ │ Redis │ │ MySQL │ │ │ │ (在线状态/会话) │ │(消息/用户/群组) │ |
3.2 核心组件职责
• 客户端:负责与服务端建立 WebSocket 连接,发送消息、订阅消息推送,渲染聊天界面(支持私聊、群聊切换)
• WebSocket 服务端:核心业务处理层,负责连接建立/断开、消息接收/转发、在线状态维护、心跳检测、离线消息触发
• Redis:缓存层,存储在线用户映射(userId → sessionId/serverId)、用户心跳时间、分布式会话,支持快速查询在线状态
• MySQL:持久化层,存储用户信息、私聊消息、群聊消息、群成员关系、会话记录、未读计数,保证消息不丢失
4. 核心流程设计(私聊+群聊)
4.1 通用流程:连接建立与认证
无论私聊还是群聊,用户需先建立 WebSocket 连接并完成认证,流程统一:
前端携带 JWT Token(通过 Sec-WebSocket-Protocol 头或 Cookie 传递,避免 URL 泄露),发起 WebSocket 连接请求
服务端在握手阶段拦截请求,解析 Token 并校验有效性,获取用户 ID
校验通过:将用户 ID 与 WebSocket Session 绑定,标记用户上线(更新本地缓存 + Redis)
校验失败:拒绝连接,关闭 Session
用户上线后,自动拉取离线消息(私聊未读消息 + 所有群聊未读消息)
4.1.1 方案一:原生 @ServerEndpoint 实现
java
@ServerEndpoint(value = "/chat", configurator = SpringBeanConfigurator.class)
@Component
@Slf4j
public class ChatEndpoint {
// 本地缓存:在线用户(userId → Session),单机部署可用
private static final Map<Long, Session> onlineUsers = new ConcurrentHashMap<>();
// 注入依赖(通过 SpringBeanConfigurator 解决原生注解无法注入问题)
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OfflineMessageService offlineMessageService;
// 连接建立(@OnOpen 触发)
@OnOpen
public void onOpen(Session session) {
try {
// 1. 从请求头获取 Token 并校验
String token = session.getRequestParameterMap().get("token").get(0);
Long userId = JwtUtil.parseToken(token);
if (userId == null) {
session.close();
return;
}
// 2. 绑定 userId 与 Session
onlineUsers.put(userId, session);
session.getUserProperties().put("userId", userId);
// 3. 标记用户在线(Redis 存储,支持分布式)
redisTemplate.opsForSet().add("chat:online:users", userId.toString());
// 4. 拉取离线消息(私聊+群聊)
List<MessageVO> offlineMessages = offlineMessageService.getOfflineAllMessages(userId);
for (MessageVO msg : offlineMessages) {
session.getBasicRemote().sendText(JSON.toJSONString(msg));
}
} catch (Exception e) {
log.error("WebSocket 连接建立失败", e);
try {
session.close();
} catch (IOException ignored) {}
}
}
}
4.1.2 方案二:STOMP + Spring Messaging 实现
java
// 1. WebSocket 配置
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 服务端推送通道:私聊(/queue)、群聊/广播(/topic)
config.enableSimpleBroker("/topic", "/queue");
// 客户端发送消息前缀:必须以 /app 开头
config.setApplicationDestinationPrefixes("/app");
// 私聊专用前缀:自动拼接 userId
config.setUserDestinationPrefix("/user");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 连接地址:/ws/chat,允许跨域,支持 SockJS 降级
registry.addEndpoint("/ws/chat")
.setAllowedOriginPatterns("*")
.withSockJS()
.setInterceptors(new HandshakeInterceptor() {
// 握手拦截器:校验 Token
@Override
public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Map<String, Object> attributes) {
String token = request.getHeaders().getFirst("token");
Long userId = JwtUtil.parseToken(token);
if (userId == null) {
return false;
}
// 将 userId 存入会话属性
attributes.put("userId", userId);
return true;
}
});
}
}
// 2. 连接监听器(自动处理上下线)
@Component
@RequiredArgsConstructor
public class WebSocketEventListener {
private final OnlineUserService onlineUserService;
// 连接成功:用户上线
@EventListener
public void handleWebSocketConnect(SessionConnectedEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
Long userId = (Long) headerAccessor.getSessionAttributes().get("userId");
String sessionId = headerAccessor.getSessionId();
if (userId != null) {
onlineUserService.userOnline(userId, sessionId);
}
}
// 连接断开:用户下线
@EventListener
public void handleWebSocketDisconnect(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
Long userId = (Long) headerAccessor.getSessionAttributes().get("userId");
if (userId != null) {
onlineUserService.userOffline(userId);
}
}
}
4.2 私聊流程(核心业务)
一对一消息交互,支持实时推送、离线拉取、未读计数、送达标记,流程如下:
1. 前端发送私聊消息:通过 stomp.send() 发送到 /app/private/send,携带发送者、接收者、消息内容
2. 服务端接收消息:通过 @MessageMapping("/private/send") 接收,校验发送者身份(防止冒用)
3. 消息持久化:将消息存入 private_message 表,初始状态 delivered = 0(未送达)
4. 更新会话:更新 private_session 表,记录最后一条消息、未读计数(接收方未读+1)
5. 在线判断:通过 OnlineUserService 判断接收方是否在线
6. 在线推送:调用 convertAndSendToUser(),推送消息到接收方的 /user/{userId}/queue/private 通道
7. 标记送达:推送成功后,更新 private_message 表的 delivered = 1(已送达)
8. 离线处理:接收方不在线时,不推送,等待用户上线后自动拉取
9. 接收方接收:前端订阅 /user/queue/private 通道,收到消息后渲染到聊天界面
4.2.1 核心代码(STOMP 方案)
java
// 1. Controller:接收私聊消息
@RestController
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
// 接收前端私聊消息:前端发送地址 /app/private/send
@MessageMapping("/private/send")
@ApiOperation("发送私聊消息")
public void sendPrivateMessage(@Payload PrivateMessageDTO dto) {
chatService.sendPrivateMessage(dto);
}
}
// 2. Service:私聊业务逻辑
@Service
@RequiredArgsConstructor
@Transactional
public class ChatService {
private final PrivateMessageMapper privateMessageMapper;
private final PrivateSessionMapper privateSessionMapper;
private final OnlineUserService onlineUserService;
private final SimpMessageSendingOperations messagingTemplate;
// 发送私聊消息主逻辑
public void sendPrivateMessage(PrivateMessageDTO dto) {
// 1. 构造消息并持久化
PrivateMessage message = new PrivateMessage();
message.setFromUserId(dto.getFromUserId());
message.setToUserId(dto.getToUserId());
message.setContent(dto.getContent());
message.setDelivered(0); // 初始未送达
message.setCreateTime(LocalDateTime.now());
privateMessageMapper.insert(message);
// 2. 更新私聊会话:最后一条消息、未读计数
updatePrivateSession(dto, message);
// 3. 接收方在线 → 实时推送
if (onlineUserService.isUserOnline(dto.getToUserId())) {
messagingTemplate.convertAndSendToUser(
dto.getToUserId().toString(),
"/queue/private",
message
);
// 标记消息已送达
message.setDelivered(1);
privateMessageMapper.updateById(message);
}
}
// 更新私聊会话
private void updatePrivateSession(PrivateMessageDTO dto, PrivateMessage message) {
PrivateSession session = privateSessionMapper.selectById(dto.getSessionId());
session.setLastMessage(dto.getContent());
session.setLastMsgId(message.getId());
session.setLastMsgTime(LocalDateTime.now());
// 接收方未读计数+1(区分两个用户)
if (dto.getFromUserId().equals(session.getUser1Id())) {
session.setUnreadCount2(session.getUnreadCount2() + 1);
} else {
session.setUnreadCount1(session.getUnreadCount1() + 1);
}
privateSessionMapper.updateById(session);
}
}
4.3 群聊流程(核心业务)
多用户群组消息交互,支持群消息广播、群成员未读记录、离线拉取,流程如下:
1. 前端发送群聊消息:通过 stomp.send() 发送到 /app/group/send,携带群组 ID、发送者、消息内容
2. 服务端接收消息:通过 @MessageMapping("/group/send") 接收,校验发送者是否为群成员(权限校验)
3. 消息持久化:将消息存入 group_message 表
4. 获取群成员:查询 group_member 表,获取该群组所有成员 ID
5. 遍历成员推送:对每个成员,判断是否在线
6. 在线推送:推送消息到该成员的 /user/{userId}/queue/group 通道
7. 离线处理:成员不在线时,不推送,等待用户上线后,根据群成员表的 last_read_msg_id 拉取未读群消息
8. 接收方接收:前端订阅 /user/queue/group 通道,收到消息后渲染到群聊界面
9. 未读更新:用户打开群聊时,更新 group_member 表的 last_read_msg_id,清除未读计数
4.3.1 核心代码(STOMP 方案)
java
// 1. Controller:接收群聊消息
@MessageMapping("/group/send")
@ApiOperation("发送群聊消息")
public void sendGroupMessage(@Payload GroupMessageDTO dto) {
chatService.sendGroupMessage(dto);
}
// 2. Service:群聊业务逻辑
public void sendGroupMessage(GroupMessageDTO dto) {
// 1. 校验发送者是否为群成员(权限控制)
boolean isMember = groupMemberMapper.exists(
new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, dto.getGroupId())
.eq(GroupMember::getUserId, dto.getFromUserId())
);
if (!isMember) {
throw new RuntimeException("非群成员,无法发送消息");
}
// 2. 构造群消息并持久化
GroupMessage message = new GroupMessage();
message.setGroupId(dto.getGroupId());
message.setFromUserId(dto.getFromUserId());
message.setContent(dto.getContent());
message.setCreateTime(LocalDateTime.now());
groupMessageMapper.insert(message);
// 3. 获取该群组所有成员
List<GroupMember> groupMembers = groupMemberMapper.selectList(
new LambdaQueryWrapper<GroupMember>()
.eq(GroupMember::getGroupId, dto.getGroupId())
);
// 4. 遍历成员,在线则推送
for (GroupMember member : groupMembers) {
// 排除发送者自身
if (member.getUserId().equals(dto.getFromUserId())) {
continue;
}
// 在线则推送
if (onlineUserService.isUserOnline(member.getUserId())) {
messagingTemplate.convertAndSendToUser(
member.getUserId().toString(),
"/queue/group",
message
);
}
// 离线不处理,等待上线拉取
}
}
4.4 离线消息处理(私聊+群聊统一)
用户离线时,消息不推送,存储在数据库,用户上线后自动拉取,保证消息不丢失:
4.4.1 私聊离线消息拉取
java
@Service
@RequiredArgsConstructor
public class OfflineMessageService {
private final PrivateMessageMapper privateMessageMapper;
private final GroupMessageMapper groupMessageMapper;
private final GroupMemberMapper groupMemberMapper;
// 拉取用户所有离线消息(私聊+群聊)
public List<MessageVO> getOfflineAllMessages(Long userId) {
List<MessageVO> allMessages = new ArrayList<>();
// 1. 拉取私聊离线消息(未送达)
List<PrivateMessage> privateOfflineMsgs = privateMessageMapper.selectList(
new LambdaQueryWrapper<PrivateMessage>()
.eq(PrivateMessage::getToUserId, userId)
.eq(PrivateMessage::getDelivered, 0)
.orderByAsc(PrivateMessage::getCreateTime)
);
allMessages.addAll(privateOfflineMsgs.stream()
.map(msg -> new MessageVO("private", msg))
.collect(Collectors.toList()));
// 2. 拉取所有群聊离线消息
List<GroupMember> userGroups = groupMemberMapper.selectUserGroups(userId);
for (GroupMember groupMember : userGroups) {
List<GroupMessage> groupOfflineMsgs = groupMessageMapper.selectList(
new LambdaQueryWrapper<GroupMessage>()
.eq(GroupMessage::getGroupId, groupMember.getGroupId())
.gt(GroupMessage::getId, groupMember.getLastReadMsgId())
.orderByAsc(GroupMessage::getCreateTime)
);
allMessages.addAll(groupOfflineMsgs.stream()
.map(msg -> new MessageVO("group", msg))
.collect(Collectors.toList()));
}
return allMessages;
}
}
4.5 在线状态管理
支持单机、分布式部署,通过本地缓存 + Redis 双重保证在线状态准确性:
java
@Service
@RequiredArgsConstructor
@Slf4j
public class OnlineUserService {
// Redis 在线用户集合 key
private static final String REDIS_ONLINE_USERS_KEY = "chat:online:users";
// Redis 存储【用户ID -> 会话ID】的 key 前缀
private static final String REDIS_USER_SESSION_PREFIX = "chat:session:";
// 本地缓存:userId → sessionId(单机部署优化查询速度)
private final ConcurrentHashMap<Long, String> userSessionMap = new ConcurrentHashMap<>();
private final StringRedisTemplate stringRedisTemplate;
private final SimpMessageSendingOperations messagingTemplate;
// 异步推送线程池
private static final ExecutorService MESSAGE_PUSH_EXECUTOR = new ThreadPoolExecutor(
5, // 核心线程数
20, // 最大线程数
60L, // 空闲线程存活时间
TimeUnit.SECONDS, // 时间单位
new LinkedBlockingQueue<>(1000), // 任务队列
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者线程执行
);
// 用户上线:本地缓存 + Redis 写入,异步增量广播上线状态
public void userOnline(Long userId, String sessionId) {
userSessionMap.put(userId, sessionId);
stringRedisTemplate.opsForSet().add(REDIS_ONLINE_USERS_KEY, userId.toString());
stringRedisTemplate.opsForValue().set(REDIS_USER_SESSION_PREFIX + userId, sessionId);
// 异步增量广播上线状态(仅推送当前用户状态,非全量列表)
asyncBroadcastOnlineStatus(userId, 1);
}
// 用户下线:本地缓存 + Redis 删除,异步增量广播下线状态
public void userOffline(Long userId) {
userSessionMap.remove(userId);
stringRedisTemplate.opsForSet().remove(REDIS_ONLINE_USERS_KEY, userId.toString());
stringRedisTemplate.delete(REDIS_USER_SESSION_PREFIX + userId);
// 异步增量广播下线状态(仅推送当前用户状态,非全量列表)
asyncBroadcastOnlineStatus(userId, 0);
}
// 判断用户是否在线:本地缓存优先,再查 Redis
public boolean isUserOnline(Long userId) {
return userSessionMap.containsKey(userId) ||
Boolean.TRUE.equals(stringRedisTemplate.opsForSet().isMember(REDIS_ONLINE_USERS_KEY, userId.toString()));
}
// 获取所有在线用户ID集合
public Set<Long> getOnlineUsers() {
Set<String> onlineUserIds = stringRedisTemplate.opsForSet().members(REDIS_ONLINE_USERS_KEY);
return onlineUserIds != null ?
onlineUserIds.stream().map(Long::parseLong).collect(Collectors.toSet()) :
java.util.Collections.emptySet();
}
// 异步增量广播在线状态变更
private void asyncBroadcastOnlineStatus(Long userId, Integer status) {
MESSAGE_PUSH_EXECUTOR.execute(() -> {
try {
OnlineStatusVO vo = new OnlineStatusVO(userId, status, System.currentTimeMillis());
messagingTemplate.convertAndSend("/topic/online-status", vo);
} catch (Exception e) {
log.error("增量广播在线状态失败,userId: {}, status: {}", userId, status, e);
}
});
}
// 获取消息推送线程池
public static ExecutorService getMessagePushExecutor() {
return MESSAGE_PUSH_EXECUTOR;
}
// 关闭线程池(应用关闭时调用)
public static void shutdownExecutor() {
MESSAGE_PUSH_EXECUTOR.shutdown();
try {
if (!MESSAGE_PUSH_EXECUTOR.awaitTermination(60, TimeUnit.SECONDS)) {
MESSAGE_PUSH_EXECUTOR.shutdownNow();
}
} catch (InterruptedException e) {
MESSAGE_PUSH_EXECUTOR.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
- 异步推送:创建专门的消息推送线程池(核心5线程、最大20线程、队列1000),异步处理私聊、群聊消息推送及在线状态广播,避免阻塞消息接收逻辑,拒绝策略采用CallerRunsPolicy防止任务丢失
- 增量广播:在线状态更新时,不再推送全量在线用户列表,仅推送单个用户的上线/下线信息(通过OnlineStatusVO封装),减少带宽消耗
- 优雅关闭:新增应用关闭监听器(ApplicationShutdownListener),监听应用关闭事件,优雅关闭消息推送线程池,防止任务丢失
防止连接异常断开,保证消息推送可靠性,两种方案适配不同实现:
4.6.1 方案一:原生 WebSocket 心跳(自定义实现)
java
// 1. 客户端定时发送 ping 消息(每 15 秒)
function sendPing() {
setInterval(() => {
if (stomp.connected) {
stomp.send("/app/ping");
}
}, 15000);
}
// 2. 服务端接收 ping,回复 pong,更新心跳时间
@OnMessage
public void onMessage(String message, Session session) {
Long userId = (Long) session.getUserProperties().get("userId");
if (userId == null) return;
// 处理心跳
if ("ping".equals(message)) {
// 更新最后心跳时间
lastHeartbeat.put(userId, System.currentTimeMillis());
// 回复 pong
session.getBasicRemote().sendText("pong");
return;
}
// 处理业务消息(私聊/群聊)
// ...
}
// 3. 定时扫描超时连接(每 10 秒,超时 30 秒则下线)
static {
ScheduledExecutorService heartbeatChecker = Executors.newSingleThreadScheduledExecutor();
heartbeatChecker.scheduleAtFixedRate(() -> {
long now = System.currentTimeMillis();
lastHeartbeat.forEach((userId, time) -> {
if (now - time > 30000) {
// 超时,执行下线逻辑
onlineUserService.userOffline(userId);
onlineUsers.remove(userId);
lastHeartbeat.remove(userId);
}
});
}, 10, 10, TimeUnit.SECONDS);
}
4.6.2 方案二:STOMP 内置心跳
java
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
// 客户端心跳间隔:25 秒(客户端每 25 秒发送一次心跳)
registration.setTimeBetweenClientPings(25000);
// 服务端心跳超时时间:30 秒(超过 30 秒未收到心跳,关闭连接)
registration.setSendTimeLimit(30000);
}
4.7 安全性设计
• 认证:WebSocket 握手阶段必须校验 JWT Token,未认证用户拒绝连接
• 授权:发送消息时校验发送者身份(私聊 fromUserId 与当前用户一致,群聊发送者为群成员)
• 传输加密:生产环境强制使用 WSS(WebSocket over TLS),防止消息被窃听、篡改
• 防刷限流:基于 Redis 令牌桶算法,限制用户消息发送频率(如每秒最多 5 条)
• 消息防篡改:消息存储时记录发送者签名,接收时校验,防止伪造消息
5. 数据库设计
基于 MySQL 设计,包含用户、私聊、群聊核心表,支持消息持久化、会话管理、未读计数,可直接执行建表:
5.1 用户表(user)
sql
CREATE TABLE `user` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` varchar(50) NOT NULL COMMENT '用户名',
`nickname` varchar(50) DEFAULT NULL COMMENT '昵称',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像地址',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
5.2 私聊消息表(private_message)
sql
CREATE TABLE `private_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID',
`from_user_id` bigint NOT NULL COMMENT '发送者ID',
`to_user_id` bigint NOT NULL COMMENT '接收者ID',
`session_id` bigint NOT NULL COMMENT '私聊会话ID',
`content` varchar(1024) NOT NULL COMMENT '消息内容',
`type` varchar(20) DEFAULT 'text' COMMENT '消息类型:text/image/file',
`delivered` tinyint(1) DEFAULT 0 COMMENT '送达状态:0-未送达,1-已送达',
`is_recalled` tinyint(1) DEFAULT 0 COMMENT '是否撤回:0-未撤回,1-已撤回',
`recall_time` datetime DEFAULT NULL COMMENT '撤回时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
PRIMARY KEY (`id`),
KEY `idx_to_user_delivered` (`to_user_id`, `delivered`) COMMENT '离线消息查询索引',
KEY `idx_session_id` (`session_id`) COMMENT '会话消息查询索引',
KEY `idx_create_time` (`create_time`) COMMENT '时间排序索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊消息表';
5.3 私聊会话表(private_session)
sql
CREATE TABLE `private_session` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '会话ID',
`user1_id` bigint NOT NULL COMMENT '用户1ID',
`user2_id` bigint NOT NULL COMMENT '用户2ID',
`last_message` varchar(512) DEFAULT NULL COMMENT '最后一条消息内容',
`last_msg_id` bigint DEFAULT NULL COMMENT '最后一条消息ID',
`last_msg_time` datetime DEFAULT NULL COMMENT '最后一条消息时间',
`unread_count1` int DEFAULT 0 COMMENT '用户1未读计数',
`unread_count2` int DEFAULT 0 COMMENT '用户2未读计数',
`last_read_msg_id1` bigint DEFAULT 0 COMMENT '用户1最后已读消息ID',
`last_read_msg_id2` bigint DEFAULT 0 COMMENT '用户2最后已读消息ID',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '会话创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user1_user2` (`user1_id`, `user2_id`) COMMENT '防止重复会话'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';
5.4 群组表(group)
sql
sql
CREATE TABLE `group` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '群组ID',
`name` varchar(100) NOT NULL COMMENT '群组名称',
`owner_id` bigint NOT NULL COMMENT '群主ID',
`avatar` varchar(255) DEFAULT NULL COMMENT '群组头像',
`description` varchar(512) DEFAULT NULL COMMENT '群组描述',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群组表';
5.5 群成员表(group_member)
sql
sql
CREATE TABLE `group` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '群组ID',
`name` varchar(100) NOT NULL COMMENT '群组名称',
`owner_id` bigint NOT NULL COMMENT '群主ID',
`avatar` varchar(255) DEFAULT NULL COMMENT '群组头像',
`description` varchar(512) DEFAULT NULL COMMENT '群组描述',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群组表';
5.6 群聊消息表(group_message)
sql
CREATE TABLE `group_message` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '消息ID',
`group_id` bigint NOT NULL COMMENT '群组ID',
`from_user_id` bigint NOT NULL COMMENT '发送者ID',
`content` varchar(1024) NOT NULL COMMENT '消息内容',
`type` varchar(20) DEFAULT 'text' COMMENT '消息类型:text/image/file',
`is_recalled` tinyint(1) DEFAULT 0 COMMENT '是否撤回:0-未撤回,1-已撤回',
`recall_time` datetime DEFAULT NULL COMMENT '撤回时间',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发送时间',
PRIMARY KEY (`id`),
KEY `idx_group_id` (`group_id`) COMMENT '群消息查询索引',
KEY `idx_create_time` (`create_time`) COMMENT '时间排序索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='群聊消息表';
6. 前端对接代码(完整实战版)
基于 STOMP + SockJS 实现,适配当前项目,包含连接、发送消息、订阅消息、心跳保活,可直接复制使用:
6.1 连接 WebSocket
javascript
// 全局 STOMP 客户端对象
let stompClient = null;
// 当前登录用户 ID(从本地存储获取)
const currentUserId = localStorage.getItem("userId");
// JWT Token(从本地存储获取)
const token = localStorage.getItem("token");
// 建立 WebSocket 连接
function connectWebSocket() {
// 连接地址:对应后端配置的 /ws/chat
const socket = new SockJS('/ws/chat');
stompClient = Stomp.over(socket);
// 携带 Token 连接(对应后端握手拦截器)
stompClient.connect(
{ "token": token, "userId": currentUserId },
() => {
console.log("WebSocket 连接成功");
// 连接成功后,订阅私聊、群聊消息
subscribeAllMessages();
// 启动心跳保活
startHeartbeat();
},
(error) => {
console.error("WebSocket 连接失败,正在重试...", error);
// 连接失败重试(每 3 秒重试一次)
setTimeout(connectWebSocket, 3000);
}
);
}
// 页面加载时建立连接
window.onload = connectWebSocket;
6.2 订阅消息(私聊+群聊)
javascript
// 订阅所有消息(私聊+群聊)
function subscribeAllMessages() {
// 1. 订阅私聊消息:/user/queue/private(对应后端 convertAndSendToUser)
stompClient.subscribe('/user/queue/private', (msg) => {
const message = JSON.parse(msg.body);
// 渲染私聊消息到界面
renderPrivateMessage(message);
});
// 2. 订阅群聊消息:/user/queue/group(对应后端 convertAndSendToUser)
stompClient.subscribe('/user/queue/group', (msg) => {
const message = JSON.parse(msg.body);
// 渲染群聊消息到界面
renderGroupMessage(message);
});
// 3. 订阅在线状态更新(可选)
stompClient.subscribe('/topic/online-users', (msg) => {
const onlineUsers = JSON.parse(msg.body);
// 更新前端在线用户展示
updateOnlineStatus(onlineUsers);
});
}
// 渲染私聊消息
function renderPrivateMessage(message) {
// 根据消息发送者,判断是自己发送还是对方发送,渲染不同样式
const isSelf = message.fromUserId == currentUserId;
const msgHtml = `
${isSelf ? '我' : message.fromNickname}${message.content}${formatTime(message.createTime)}
`;
document.getElementById("private-chat-container").innerHTML += msgHtml;
}
// 渲染群聊消息
function renderGroupMessage(message) {
const isSelf = message.fromUserId == currentUserId;
const msgHtml = `
${isSelf ? '我' : message.fromNickname}${message.content}${formatTime(message.createTime)}
`;
document.getElementById(`group-chat-container-${message.groupId}`).innerHTML += msgHtml;
}
6.3 发送消息(私聊+群聊)
javascript
// 发送私聊消息
function sendPrivateMessage(toUserId, sessionId, content) {
if (!stompClient || !stompClient.connected) {
alert("连接未建立,请稍后再试");
return;
}
// 构造消息体(与后端 PrivateMessageDTO 对应)
const message = {
fromUserId: currentUserId,
toUserId: toUserId,
sessionId: sessionId,
content: content
};
// 发送到后端 /app/private/send(对应 @MessageMapping("/private/send"))
stompClient.send("/app/private/send", {}, JSON.stringify(message));
// 本地渲染自己发送的消息(无需等待后端推送)
renderPrivateMessage({
fromUserId: currentUserId,
toUserId: toUserId,
content: content,
createTime: new Date().toISOString(),
fromNickname: "我"
});
}
// 发送群聊消息
function sendGroupMessage(groupId, content) {
if (!stompClient || !stompClient.connected) {
alert("连接未建立,请稍后再试");
return;
}
// 构造消息体(与后端 GroupMessageDTO 对应)
const message = {
fromUserId: currentUserId,
groupId: groupId,
content: content
};
// 发送到后端 /app/group/send(对应 @MessageMapping("/group/send"))
stompClient.send("/app/group/send", {}, JSON.stringify(message));
// 本地渲染自己发送的消息
renderGroupMessage({
fromUserId: currentUserId,
groupId: groupId,
content: content,
createTime: new Date().toISOString(),
fromNickname: "我"
});
}
6.4 心跳保活
javascript
// 心跳保活(STOMP 内置心跳,前端无需额外发送 ping,仅处理断开重连)
function startHeartbeat() {
// 监听连接断开事件,自动重连
stompClient.onDisconnect = () => {
console.log("WebSocket 连接断开,正在重试...");
setTimeout(connectWebSocket, 3000);
};
}
7. 双方案对比与选型建议
|-------|---------------------------------|------------------------------------|
| 对比维度 | 方案一:原生 @ServerEndpoint | 方案二:STOMP + Spring Messaging |
| 复杂度 | 低,代码简洁,无需额外依赖 | 中,依赖 Spring Messaging,配置稍多 |
| 消息路由 | 需手动维护用户 Session 映射,自定义路由逻辑 | 内置路由,支持 /app /user /topic,无需手动处理 |
| 点对点推送 | 需手动查找目标 Session,调用 sendText() | 内置 convertAndSendToUser(),自动拼接用户路径 |
| 群聊/广播 | 需手动遍历群成员,逐一推送,效率较低 | 支持 /topic 广播,可批量推送,效率高 |
| 分布式支持 | 需自己实现 Redis 分布式 Session 管理、消息转发 | Spring 生态集成良好,配合 Redis 可快速实现分布式 |
| 当前项目 | 未使用 | 正在使用,贴合现有代码 |
| 选型建议 | 中小型项目、简单聊天场景,无需复杂路由 | 中大型项目、需要私聊+群聊、分布式部署,推荐使用 |
8. 性能与扩展设计
8.1 水平扩展方案(分布式部署)
• 在线状态统一管理:使用 Redis Set 存储所有在线用户,每个服务器节点仅维护本地连接的用户
• 消息转发:当目标用户不在当前服务器时,通过 Redis Pub/Sub 或消息队列(如 RabbitMQ)将消息转发到目标服务器
• 网关路由:使用 Spring Cloud Gateway,基于 userId 一致性哈希,将用户 WebSocket 连接路由到固定服务器,减少跨节点消息转发
8.2 数据库
• 分区表:对 private_message、group_message 按 create_time 分区,避免单表数据量过大,提升查询效率
• 读写分离:消息写入走主库,历史消息查询、离线消息拉取走从库,减轻主库压力
• 索引优化:重点优化离线消息查询索引(to_user_id, delivered)、群消息查询索引(group_id)
• 消息清理:定期清理过期历史消息(如 3 个月前),可使用定时任务 + 分区表删除,提升性能
8.3 消息推送
• 批量推送:群聊消息可批量获取在线成员,批量推送,减少循环推送次数
• 增量广播:在线状态更新时,仅推送上线/下线的用户信息,而非全量在线列表,减少带宽消耗
• 异步推送:使用线程池异步处理消息推送,避免阻塞消息接收逻辑
9. 常见问题与解决方案
|-----------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------|
| 常见问题 | 原因分析 | 解决方案 |
| Bean 名称冲突(如 ChatController) | 项目中存在多个同名 Controller,Spring 容器无法区分 | 1. 改名(如 ChatMessageController);2. 手动指定 Bean 名称(@Controller("chatMessageController")) |
| 前端收不到私聊/群聊消息 | 1. 订阅路径错误;2. 用户未在线;3. 推送路径错误 | 1. 检查订阅路径(私聊 /user/queue/private,群聊 /user/queue/group);2. 确认用户在线;3. 检查 convertAndSendToUser 参数是否正确 |
| 在线状态不准 | 1. 连接断开未触发下线逻辑;2. 心跳超时未处理;3. Redis 缓存未更新 | 1. 确保 @OnClose 或 SessionDisconnectEvent 正常执行;2. 检查心跳配置;3. 验证 Redis 写入/删除逻辑 |
| 跨域报错 | 后端未配置跨域允许,前端请求被拦截 | 配置 setAllowedOriginPatterns("*"),允许所有前端域名访问 |
| 离线消息拉取失败 | 1. 未读消息查询条件错误;2. 上线时未触发拉取逻辑 | 1. 检查查询条件(私聊 delivered=0,群聊 id>last_read_msg_id);2. 确保连接成功后调用 getOfflineAllMessages 方法 |
10.其他功能
• 消息类型扩展:支持图片、文件、表情等消息类型,需新增消息类型字段,处理文件上传/下载
• 消息撤回:完善消息撤回逻辑,支持撤回时间限制(如 2 分钟内),前端同步更新消息状态
• 已读回执:新增已读回执功能,用户打开消息后,更新