恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Spring Boot社交后端实战:从好友申请到WebSocket实时聊天
首页
资讯中心
/
Spring Boot社交后端实战:从好友申请到WebSocket实时聊天
Spring Boot社交后端实战:从好友申请到WebSocket实时聊天
发布时间:2026/10/7 21:35:39
在夜间节目场景里“交个朋友”是一个非常典型的陌生人社交话题。本文不讨论节目内容本身而是从开发者的视角把“黑夜排档·交个朋友”这个选题落地成一个可以运行的社交后端项目用户注册、兴趣标签匹配、好友申请、实时聊天、离线消息一条链路完整走通。无论你是刚学完 Spring Boot 的新手还是准备做校园交友、兴趣社区、夜间陪伴类产品的小团队本文给出的工程思路和代码都能直接参考。1. 为什么做“交个朋友”业务背景与核心概念1.1 陌生人社交产品要解决什么“交个朋友”看起来是一句简单的话但落到互联网产品上它背后是一整套陌生人社交链路让用户先“被看见”注册、头像、昵称、兴趣标签、城市位置。让用户“找到人”按兴趣、地域、活跃度做匹配和推荐。让用户“建立关系”好友申请、同意/拒绝、好友列表。让用户“持续互动”一对一的实时聊天、离线消息补偿。这和通讯录类应用不同。通讯录解决“已有关系”的通讯问题陌生人社交重点解决“如何从陌生人变成朋友”的推荐和破冰问题。所以在后端设计上匹配逻辑、关系状态机、实时消息三块是核心。1.2 “黑夜排档”场景的技术启发夜间时段用户活跃度高、倾诉意愿强因此产品对这种场景的诉求往往是高峰并发波动明显需要缓存和削峰。用户更在意私密性聊天内容必须鉴权。需要防骚扰、防恶意注册、防刷消息。所以本文的实战案例虽然规模不大但会把 Redis、WebSocket、数据库事务、接口幂等等元素都带上让项目具备向生产环境演进的骨架。1.3 核心概念区分好友、关注、会话很多社交项目会混淆三个概念概念是否双向确认典型场景好友是需要对方同意私密聊天、朋友圈关注否单向即可微博、视频平台会话是自动创建客服、群聊、聊天室本文案例选择“好友”模型因为“交个朋友”语义上更强调双向确认。好友关系表会记录双向关系而不是只存一条单向关注。2. 环境准备与版本说明2.1 运行环境本文示例在以下环境测试但不是唯一组合你的项目请按实际情况调整。组件版本建议作用JDK17运行 Spring BootSpring Boot3.2.x后端框架Maven3.8依赖管理MySQL8.0主数据库Redis6.2缓存、Token、在线状态WebSocketSpring Boot 内置实时消息通道前端调试浏览器 / wscat / Postman验证接口如果电脑已经安装旧版 JDK建议先确认版本java -version mvn -v2.2 为什么这样选型Spring Boot 3.x 简化了配置和依赖管理适合快速迭代。Redis 在这里承担三个职责用户 Token 缓存、好友申请防重、在线状态记录。WebSocket 用于一对一聊天。相比 HTTP 轮询它实时性更好、省流量。MySQL 存核心业务数据。聊天记录先写库再推消息保证不丢消息。3. 需求分析与数据库设计3.1 功能拆分我们把项目拆成 4 个核心模块用户模块注册、登录、获取我的信息。匹配模块根据兴趣标签和城市推荐用户。好友模块发送好友申请、通过/拒绝、好友列表。消息模块WebSocket 实时聊天、离线消息拉取。3.2 数据库表设计先建立四张表user、friend_request、friend_relation、chat_message。创建数据库CREATE DATABASE IF NOT EXISTS friend_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE friend_db;用户表CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, phone VARCHAR(20) NOT NULL COMMENT 手机号, nickname VARCHAR(64) NOT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, city VARCHAR(32) DEFAULT COMMENT 城市, gender TINYINT DEFAULT 0 COMMENT 性别 0未知 1男 2女, bio VARCHAR(255) DEFAULT COMMENT 个人简介, tags VARCHAR(255) DEFAULT COMMENT 兴趣标签逗号分隔, status TINYINT DEFAULT 1 COMMENT 账号状态 1正常 0禁用, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;好友申请表CREATE TABLE friend_request ( id BIGINT PRIMARY KEY AUTO_INCREMENT, from_user_id BIGINT NOT NULL COMMENT 发起方, to_user_id BIGINT NOT NULL COMMENT 接收方, message VARCHAR(255) DEFAULT COMMENT 申请留言, status TINYINT DEFAULT 0 COMMENT 0待处理 1同意 2拒绝 3已撤销, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_to_status (to_user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT好友申请表;好友关系表CREATE TABLE friend_relation ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 用户A, friend_id BIGINT NOT NULL COMMENT 用户B, source_request_id BIGINT DEFAULT NULL COMMENT 来源申请单, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_friend (user_id, friend_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT好友关系表;聊天消息表CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, sender_id BIGINT NOT NULL, receiver_id BIGINT NOT NULL, content TEXT NOT NULL, msg_type TINYINT DEFAULT 1 COMMENT 1文本 2图片 3语音, status TINYINT DEFAULT 0 COMMENT 0未读 1已读, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_sender_receiver (sender_id, receiver_id, created_at), KEY idx_receiver_status (receiver_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT聊天消息表;3.3 关系状态机好友申请的状态机建议固定为待处理(0) - 同意(1) - 建立好友关系 待处理(0) - 拒绝(2) - 流程结束 待处理(0) - 已撤销(3) - 发起方撤回业务上要禁止重复发送“我申请过你你还没处理”的请求。这个判断可以通过 Redis 或数据库唯一约束来实现。4. 项目搭建创建 Spring Boot 工程4.1 推荐项目结构friend-app ├── pom.xml └── src/main ├── java/com/example/friend │ ├── FriendApplication.java │ ├── config/ │ │ ├── RedisConfig.java │ │ └── WebSocketConfig.java │ ├── controller/ │ │ ├── AuthController.java │ │ ├── FriendController.java │ │ └── MatchController.java │ ├── service/ │ │ ├── AuthService.java │ │ ├── FriendService.java │ │ ├── MatchService.java │ │ └── ChatService.java │ ├── handler/ │ │ └── FriendChatHandler.java │ ├── mapper/ │ │ ├── UserMapper.java │ │ ├── FriendMapper.java │ │ └── ChatMessageMapper.java │ ├── entity/ │ │ ├── User.java │ │ ├── FriendRequest.java │ │ ├── FriendRelation.java │ │ └── ChatMessage.java │ └── common/ │ ├── Result.java │ └── JwtUtil.java └── resources ├── application.yml └── mapper/*.xml4.2 添加依赖pom.xmlparent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies这里使用 MyBatis 是为了让 SQL 保持可见和可控便于你理解每条查询。如果平时用 JPA 或 MyBatis-Plus也可以替换核心逻辑一致。4.3 基础配置application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/friend_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver data: redis: host: localhost port: 6379 database: 0 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.friend.entity configuration: map-underscore-to-camel-case: true一个容易被忽略的点是map-underscore-to-camel-case: true它可以让数据库字段created_at自动映射到 Java 属性createdAt少写很多 ResultMap。5. 核心功能实现注册登录与 Token 鉴权5.1 登录思路真实项目一般接短信服务本文提供两种方式直接验证码开发环境可固定为 123456和 Redis 缓存验证码。Token 使用 JWT 生成登录后放入 Redis 以便实现登出失效。先写公共返回类。package com.example.friend.common; public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 0; r.message success; r.data data; return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.code 1; r.message message; return r; } public int getCode() { return code; } public void setCode(int code) { this.code code; } public String getMessage() { return message; } public void setMessage(String message) { this.message message; } public T getData() { return data; } public void setData(T data) { this.data data; } }5.2 AuthService 核心代码package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.User; import com.example.friend.mapper.UserMapper; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.concurrent.TimeUnit; Service public class AuthService { private final UserMapper userMapper; private final StringRedisTemplate redisTemplate; private static final SecretKey SECRET_KEY Keys.hmacShaKeyFor(friend-app-demo-secret-key-2026.getBytes(StandardCharsets.UTF_8)); public AuthService(UserMapper userMapper, StringRedisTemplate redisTemplate) { this.userMapper userMapper; this.redisTemplate redisTemplate; } /** * 发送验证码。开发环境直接返回 123456真实环境应接入短信服务。 */ public ResultString sendCode(String phone) { String code 123456; redisTemplate.opsForValue().set(sms:code: phone, code, 5, TimeUnit.MINUTES); return Result.ok(验证码已发送开发环境为 123456); } /** * 手机号 验证码登录。用户不存在则自动注册。 */ public ResultString login(String phone, String code) { String cached redisTemplate.opsForValue().get(sms:code: phone); if (cached null || !cached.equals(code)) { return Result.fail(验证码错误或已过期); } User user userMapper.findByPhone(phone); if (user null) { user new User(); user.setPhone(phone); user.setNickname(用户 phone.substring(phone.length() - 4)); userMapper.insert(user); } long userId user.getId(); String token Jwts.builder() .setSubject(String.valueOf(userId)) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 7L * 24 * 3600 * 1000)) .signWith(SECRET_KEY, SignatureAlgorithm.HS256) .compact(); redisTemplate.opsForValue().set(token:user: userId, token, 7, TimeUnit.DAYS); redisTemplate.delete(sms:code: phone); return Result.ok(token); } }5.3 登录接口的边界问题开发中最常见的错误是验证码无限发、接口被刷。生产环境至少要做三件事同一手机号 60 秒内只能发送一次。单 IP 每天限制发送次数。验证码在 Redis 里必须设置过期时间。上面代码演示了“过期时间 一次性校验”发送频率可以用简单的redisTemplate.hasKey控制。由于篇幅关系不展开。6. 核心功能实现交友匹配与好友申请6.1 推荐匹配规则匹配是“交个朋友”类产品最有意思的地方。推荐规则不宜太复杂先把基础版跑通优先匹配同性/异性时看用户设置。优先推荐同城用户。优先推荐兴趣标签重合度高的用户。本文用 SQL 实现一个简化版本排除自己和已经是好友的用户排除有未处理申请的用户再按标签相似度倒序。6.2 MatchMapper 查询!-- 文件路径src/main/resources/mapper/MatchMapper.xml -- select idrecommendUsers resultTypecom.example.friend.entity.User SELECT u.*, (LENGTH(u.tags) - LENGTH(REPLACE(u.tags, #{tags}, ))) AS match_score FROM user u WHERE u.status 1 AND u.id ! #{currentUserId} AND u.id NOT IN ( SELECT friend_id FROM friend_relation WHERE user_id #{currentUserId} ) AND u.id NOT IN ( SELECT to_user_id FROM friend_request WHERE from_user_id #{currentUserId} AND status 0 ) if testcity ! null and city ! AND u.city #{city} /if ORDER BY match_score DESC, u.created_at DESC LIMIT #{limit} /select这里用LENGTH函数做简单的标签重合度排序本质上不够严谨因为标签是逗号分隔字符串。生产环境更推荐把用户标签拆成独立的user_tag表或者引入向量检索。本文重点是演示整体流程。6.3 发送好友申请发送申请时必须做幂等校验。用一个 Redis key 记录 60 秒内的重复申请package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.FriendRequest; import com.example.friend.mapper.FriendRequestMapper; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.time.Duration; Service public class FriendService { private final FriendRequestMapper friendRequestMapper; private final StringRedisTemplate redisTemplate; public FriendService(FriendRequestMapper friendRequestMapper, StringRedisTemplate redisTemplate) { this.friendRequestMapper friendRequestMapper; this.redisTemplate redisTemplate; } /** * 发送好友申请 */ public ResultString apply(Long fromUserId, Long toUserId, String message) { if (fromUserId.equals(toUserId)) { return Result.fail(不能添加自己为好友); } String key friend:apply: fromUserId : toUserId; // 已存在好友关系 if (friendRequestMapper.existsFriendRelation(fromUserId, toUserId) 0) { return Result.fail(你们已经是好友了); } // 已存在待处理申请 if (friendRequestMapper.existsPendingRequest(fromUserId, toUserId) 0) { return Result.fail(好友申请已发送请等待对方处理); } // 60 秒内重复申请拦截 Boolean first redisTemplate.opsForValue().setIfAbsent(key, 1, Duration.ofSeconds(60)); if (first ! null !first) { return Result.fail(操作太频繁请稍后再试); } FriendRequest request new FriendRequest(); request.setFromUserId(fromUserId); request.setToUserId(toUserId); request.setMessage(message); request.setStatus(0); friendRequestMapper.insert(request); return Result.ok(申请已发送); } }setIfAbsent是 Redis 分布式锁的基础用法。如果两个请求同时进来只有一个能插入成功这就是幂等控制。6.4 处理好友申请同意申请时需要在一个事务里完成两件事把 friend_request 的 status 改成 1。在 friend_relation 插入两条关系记录我-他他-我。package com.example.friend.service; import com.example.friend.common.Result; import com.example.friend.entity.FriendRelation; import com.example.friend.mapper.FriendMapper; import com.example.friend.mapper.FriendRequestMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class FriendAcceptService { private final FriendRequestMapper friendRequestMapper; private final FriendMapper friendMapper; public FriendAcceptService(FriendRequestMapper friendRequestMapper, FriendMapper friendMapper) { this.friendRequestMapper friendRequestMapper; this.friendMapper friendMapper; } Transactional(rollbackFor Exception.class) public ResultString accept(Long toUserId, Long requestId) { FriendRequest request friendRequestMapper.findById(requestId); if (request null || !request.getToUserId().equals(toUserId)) { return Result.fail(申请不存在或无权操作); } if (request.getStatus() ! 0) { return Result.fail(申请已处理); } // 防止并发重复更新使用乐观更新 int updated friendRequestMapper.markAccepted(requestId); if (updated 0) { return Result.fail(申请已被处理); } // 建立双向关系 insertFriendRelation(request.getFromUserId(), request.getToUserId(), requestId); insertFriendRelation(request.getToUserId(), request.getFromUserId(), requestId); return Result.ok(已添加好友); } private void insertFriendRelation(Long userId, Long friendId, Long requestId) { FriendRelation relation new FriendRelation(); relation.setUserId(userId); relation.setFriendId(friendId); relation.setSourceRequestId(requestId); friendMapper.insertRelation(relation); } }注意markAccepted是带条件更新update idmarkAccepted UPDATE friend_request SET status 1 WHERE id #{id} AND status 0 /update这样即使并发点击数据库也会保证只能成功更新一次。这是避免“重复好友关系”的关键。7. 核心功能实现WebSocket 实时聊天与离线消息7.1 WebSocket 配置Spring Boot 中先注册一个 WebSocket 处理器。这里采用最简单的方式一个/chat/{token}端点通过路径参数携带用户身份。生产环境建议在握手阶段用 Header 或 Query 参数做 JWT 校验。package com.example.friend.config; import com.example.friend.handler.FriendChatHandler; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; Configuration EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { private final FriendChatHandler friendChatHandler; public WebSocketConfig(FriendChatHandler friendChatHandler) { this.friendChatHandler friendChatHandler; } Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(friendChatHandler, /chat/{token}) .setAllowedOrigins(*); } }setAllowedOrigins(*)仅用于开发测试。上线后必须限制为你的前端域名避免其他人直接连接你的 WebSocket 服务。7.2 在线会话管理器每个 WebSocket 连接都对应一个用户 ID。我们需要维护一个全局 MapuserId - WebSocketSession。package com.example.friend.handler; import com.example.friend.entity.ChatMessage; import com.example.friend.mapper.ChatMessageMapper; import com.example.friend.service.AuthService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Component; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.TextMessage; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.TextWebSocketHandler; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; Component public class FriendChatHandler extends TextWebSocketHandler { private static final MapLong, WebSocketSession ONLINE_SESSIONS new ConcurrentHashMap(); private final ObjectMapper objectMapper new ObjectMapper(); private final ChatMessageMapper chatMessageMapper; public FriendChatHandler(ChatMessageMapper chatMessageMapper) { this.chatMessageMapper chatMessageMapper; } Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 简化解析从路径参数获取 token String path session.getUri().getPath(); String token path.substring(path.lastIndexOf(/) 1); Long userId AuthService.parseUserIdFromToken(token); if (userId null) { session.close(CloseStatus.POLICY_VIOLATION); return; } ONLINE_SESSIONS.put(userId, session); System.out.println(用户上线 userId); } Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { JsonNode json objectMapper.readTree(message.getPayload()); Long senderId json.get(senderId).asLong(); Long receiverId json.get(receiverId).asLong(); String content json.get(content).asText(); // 好友关系校验 if (chatMessageMapper.isFriend(senderId, receiverId) 0) { session.sendMessage(new TextMessage({\code\:1,\message\:\你们不是好友无法聊天\})); return; } // 写库保证离线消息不丢 ChatMessage chatMessage new ChatMessage(); chatMessage.setSenderId(senderId); chatMessage.setReceiverId(receiverId); chatMessage.setContent(content); chatMessage.setMsgType(1); chatMessage.setStatus(0); chatMessageMapper.insert(chatMessage); // 接收方在线则实时推送同时插入历史消息 WebSocketSession targetSession ONLINE_SESSIONS.get(receiverId); if (targetSession ! null targetSession.isOpen()) { targetSession.sendMessage(new TextMessage(message.getPayload())); } } Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { ONLINE_SESSIONS.entrySet().removeIf(entry - entry.getValue().equals(session)); System.out.println(用户下线); } }7.3 为什么要先写库再推消息实时聊天最怕消息丢失。如果先推送再写库推送给对方成功但数据库写入失败对方刷新历史记录时消息就会消失。先写库再推送的缺点是会有毫秒级延迟但能保证消息最终一致。如果追求更高的实时性和吞吐量生产环境可以引入消息队列发送方 - WebSocket Handler - MQ - 消费者写库 推送在线用户本文示例先写库再推送给在线用户简单可靠适合中小型项目。7.4 离线消息处理接收方不在线时消息已经在第 7.2 节写入了数据库。下次用户登录后调用查询接口拉取未读消息即可。public ListChatMessage pullOfflineMessages(Long userId) { return chatMessageMapper.listUnread(userId); }更新已读状态UPDATE chat_message SET status 1 WHERE receiver_id #{userId} AND status 0这里要注意不要一次把所有未读消息全部标记已读否则用户可能来不及查看。更稳妥的做法是分页拉取客户端确认“已读”后再调用已读接口但复杂度会提高。8. 运行验证与接口演示8.1 启动项目环境准备就绪后直接启动主类package com.example.friend; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class FriendApplication { public static void main(String[] args) { SpringApplication.run(FriendApplication.class, args); } }mvn spring-boot:run看到如下日志说明启动成功Tomcat started on port(s): 8080 (http)8.2 登录接口测试使用 curl 模拟两个用户。curl -X POST http://localhost:8080/auth/sendCode \ -H Content-Type: application/json \ -d {phone:13800000001}收到验证码返回后调用登录接口curl -X POST http://localhost:8080/auth/login \ -H Content-Type: application/json \ -d {phone:13800000001,code:123456}返回结果类似{ code: 0, message: success, data: eyJhbGciOiJIUzI1NiJ9... }用同样的方式注册 13800000002 用户。登录后把两个 token 分别记为TOKEN_A和TOKEN_B。8.3 发好友申请与通过curl -X POST http://localhost:8080/friend/apply \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN_A \ -d {toUserId:2,message:你好我是黑夜排档来的}返回申请 ID 后继续调用curl -X POST http://localhost:8080/friend/accept \ -H Content-Type: application/json \ -H Authorization: Bearer $TOKEN_B \ -d {requestId:1}8.4 WebSocket 消息测试启动一个 Node 环境的命令行工具 wscat或直接在网页控制台测试。这里以 wscat 为例npx wscat -c ws://localhost:8080/chat/$TOKEN_B用户 A 发消息给用户 B{ senderId: 1, receiverId: 2, content: 晚上好交个朋友吗 }如果用户 B 在线会实时收到相同结构的消息。之后查询 B 的历史消息接口也能看到这条记录说明消息未丢失。9. 常见问题与排查思路问题现象常见原因解决思路项目启动报端口被占用8080 端口被其他进程使用更换server.port或停掉占用进程数据库连接失败MySQL 没启动、密码错误检查 application.yml 中的 url、用户名、密码Redis 连接超时Redis 服务未启动使用redis-cli ping验证WebSocket 连接立即断开Token 解析失败确认连接地址中的 token 是否完整发送聊天消息无响应双方不是好友关系先执行好友申请和通过流程好友申请重复入库并发点击使用 RedissetIfAbsent和数据库条件更新历史消息查不到消息未写库检查 chat_message 表中是否有记录排查顺序建议先看控制台日志有没有异常堆栈。确认 MySQL 和 Redis 是否正常运行。用 curl 调普通接口确认 HTTP 链路没问题。再连接 WebSocket确认 Token 鉴权是否通过。最后检查业务状态是否真的是好友。10. 最佳实践与工程建议10.1 安全与越权校验陌生人社交产品的越权风险很高。所谓越权就是 A 用户操作 B 用户的数据。本文示例已经在好友申请处理和聊天里做了基础判断。上线前必须全面检查查询好友列表时只能查自己的。处理好友申请时必须校验 toUserId 等于当前登录用户。删除好友、拉黑等操作也要校验关系归属。建议在 Controller 层统一从 Token 中解析 userId不要把 userId 完全信任前端的传参。10.2 接口幂等与防刷“交个朋友”场景最怕恶意刷申请、刷消息。工程上推荐发送好友申请增加冷却时间例如 60 秒内不能重复申请同一人。聊天消息也要限制发送频率例如每秒最多 5 条。注册登录接口增加图形验证码或行为验证。防刷的本质是“让攻击成本高于攻击收益”。判断频率上限时要结合业务高峰期数据不能拍脑袋。10.3 数据合规与隐私社交产品涉及用户手机号、聊天内容、地理位置合规是底线。手机号、聊天记录必须加密存储至少不能明文落库。数据库访问遵循最小权限线上账号不要使用 root。用户注销时要提供删除个人数据和聊天记录的途径。日志中不要打印手机号、Token 等敏感信息。在开发阶段就可以预留数据导出的接口后续配合合规审计会轻松很多。10.4 消息可靠性的进一步优化前面的代码是“先写库再推送”适合业务初期。当单机支撑不住时可以按以下顺序演进WebSocket 部署多实例用 Redis Pub/Sub 做跨节点消息转发。引入消息队列异步写库削峰填谷。聊天记录和在线状态强依赖 Redis做好持久化和高可用。历史消息分页查询优先查 Redis 热数据再查 MySQL 冷数据。在做多实例的时候ONLINE_SESSIONS这个本地 Map 会失效要用 Redis 的 Hash 或 Set 记录用户在线节点然后定向把消息路由到对应实例。这一步是社交后端从单机走向分布式的必经之路。10.5 日志与监控建议从第一天就打印结构化日志[chat][send] senderId1 receiverId2 msgId1001 cost5ms [chat][push] receiverId2 onlinetrue pushCost2ms监控层面至少关注三个指标好友申请接口的成功率和耗时。WebSocket 在线连接数。消息推送的平均延迟和失败数量。当在线人数上涨时这些指标能帮你快速定位瓶颈是数据库慢查询、Redis 连接不足还是网络带宽。11. 总结与学习路线从零搭建一个“交个朋友”社交后端核心收获可以归纳为四点第一业务状态机要先于代码设计清楚。好友申请的待处理、同意、拒绝、撤销没有状态机会很容易写出“一个用户被添加两次”的脏数据。第二幂等控制在社交场景里格外重要。重复申请靠 Redis 去重并发同意靠数据库条件更新两层防线都要有。第三实时聊天不能只看 WebSocket。跨节点在线状态、离线消息存储、接口频率限制这些才是生产环境真正考验人的地方。第四安全边界要主动设防。Token 鉴权、好友关系校验、越权拦截、敏感信息脱敏每一步都不能省。下一步你可以继续深入的方向包括用 Redis Streams 或 RocketMQ 重构消息推送链路理解异步解耦。引入 Netty 自研长连接网关对比 Spring WebSocket 的性能差异。把关注/拉黑功能加入关系模型完善用户之间的约束逻辑。学习向量检索或协同过滤算法替换掉本文的简单标签匹配。如果你最近也在写社交类项目建议先把这个单机版完整跑通再逐步升级。代码跑通后再谈架构优化比一开始就堆中间件务实得多。