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

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

相关推荐
Rust研习社10 小时前
组合真的优于继承吗?为什么 Rust 和 Go 都拥抱组合舍弃继承?
后端·rust·编程语言
IT_陈寒10 小时前
JavaScript的闭包把我坑惨了,说好的内存会自动回收呢?
前端·人工智能·后端
CaffeinePro11 小时前
Pydantic深度使用:数据校验、枚举、ORM映射
后端·fastapi
Chenyiax12 小时前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH12 小时前
Koa和Express的区别
后端
MariaH12 小时前
Koa框架的使用
后端
luckdewei13 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某14 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy14 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom14 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github