基于WebSocket的 “聊天” 业务设计与实战指南

聊天(私聊+群聊)业务设计

本文档基于 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 连接并完成认证,流程统一:

  1. 前端携带 JWT Token(通过 Sec-WebSocket-Protocol 头或 Cookie 传递,避免 URL 泄露),发起 WebSocket 连接请求

  2. 服务端在握手阶段拦截请求,解析 Token 并校验有效性,获取用户 ID

  3. 校验通过:将用户 ID 与 WebSocket Session 绑定,标记用户上线(更新本地缓存 + Redis)

  4. 校验失败:拒绝连接,关闭 Session

  5. 用户上线后,自动拉取离线消息(私聊未读消息 + 所有群聊未读消息)

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 分钟内),前端同步更新消息状态

• 已读回执:新增已读回执功能,用户打开消息后,更新

相关推荐
hongtianzai2 小时前
Laravel7.x十大核心特性解析
java·c语言·开发语言·golang·php
计算机学姐2 小时前
基于SpringBoot的校园二手交易系统
java·vue.js·spring boot·后端·spring·tomcat·intellij-idea
朱一头zcy2 小时前
Linux系列02:网络配置、修改hosts映射文件、关闭防火墙
linux·运维·网络
夕珩2 小时前
Java 排序算法详解:冒泡排序、选择排序、堆排序
java·算法·排序算法
紫檀香2 小时前
Alembic入门教程
后端·python
用户580559502102 小时前
深入理解 Go defer(下):编译器与runtime视角的实现原理
后端·go
工边页字2 小时前
为什么 RAG系统里,Embedding成本往往远低于 LLM成本,但很多公司仍然疯狂优化 Embedding?
前端·人工智能·后端
952362 小时前
初识多线程
java·开发语言·jvm·后端·学习·多线程
斯密码赛我是美女2 小时前
周报(欢乐赛+信息搜集ctfshow+Trae-mcp)
网络·windows