1. 项目缘起与整体设计思路拆解
做交友类平台这件事,我从接需求到真正跑通上线,前后折腾了大半年。最开始拿到“基于SSM架构的交友网站”这个题目时,很多人第一反应是“又一个烂大街的毕设模板”,但我实际做下来发现,交友场景对后端的要求远比想象中复杂——它不像电商那样以商品为核心,也不像博客那样以内容为核心,它的核心是人与人的关系链,这就决定了数据模型、权限控制、匹配逻辑、实时性要求都跟常规管理系统完全不是一个路子。这篇文章我把整个项目的设计取舍、核心实现、踩过的坑全部摊开讲,适合正在做Java Web实战项目、想理解SSM真实落地场景的开发者参考,也适合想搞清楚“一个交友平台后端到底要处理哪些事”的产品或运营同学。
先把这个项目是什么说清楚。它是一个基于Spring + SpringMVC + MyBatis经典组合搭建的交友平台后端,前端走的是JSP加少量Ajax的混合渲染路线,数据库用MySQL,缓存用Redis做会话和热点数据,文件存储走本地磁盘加Nginx静态代理。核心功能包括用户注册登录、个人资料维护、好友关系管理、站内私信、动态发布与互动、简单的兴趣标签匹配推荐。听起来功能列表不算长,但每一个模块背后都有需要仔细琢磨的设计点,比如好友关系到底是单向还是双向、私信要不要做已读回执、推荐是按标签硬匹配还是加权打分,这些决策直接决定了表结构和工作量。
为什么选SSM而不是Spring Boot?这个问题我被问过很多次。坦白讲,如果纯粹从开发效率出发,Spring Boot的自动配置和起步依赖确实省事,但这个项目的约束条件是“需要体现对Java Web底层机制的理解”,SSM的XML配置虽然繁琐,但它把Spring的IoC容器、MVC的请求分发、MyBatis的SQL映射这三层暴露得很清楚,调一个问题你能顺着配置文件一路摸到根上。对于想打牢基础的人来说,SSM的“笨”反而是优点。我在实际开发中就因为手写MyBatis映射文件,被迫把每条SQL的执行计划都看了一遍,后来做私信列表分页时才发现联合索引的顺序写反了,这种问题在高度封装的框架里很容易被掩盖。
整体架构上我采用的是经典的三层结构加一层切面:Controller层负责参数校验和视图返回,Service层承载业务逻辑和事务边界,DAO层通过MyBatis与数据库交互,另外用Spring AOP统一处理登录拦截、操作日志和异常兜底。这个分层看起来教科书,但真正落地时最容易出问题的是Service层的事务粒度。交友场景里“添加好友”这个动作,表面上是往好友表插一条记录,实际上还要更新双方的好友计数、写入一条通知消息、可能还要触发推荐权重的调整,这几个操作必须在一个事务里,否则会出现“好友加上了但对方没收到通知”的脏状态。我一开始把通知写入放在Controller里异步调用,结果测试时频繁出现通知丢失,后来老老实实收进Service的事务方法里才稳定下来。
2. 核心数据模型与关键细节解析
2.1 用户与资料表的设计取舍
用户表是整个平台的地基,我见过不少同类项目把用户表和资料表合成一张大表,字段堆到三四十个,查询时SELECT *一把梭。这种做法在小数据量下没感觉,但交友平台的特点是资料字段多且更新频繁,头像、签名、兴趣标签、职业、城市这些信息用户会反复修改,如果和账号核心信息(手机号、密码哈希、注册时间、状态)混在一起,每次改个签名都要锁整行,高并发下很容易成为瓶颈。所以我把用户拆成两张表:user_account存账号核心信息,user_profile存展示型资料,两者用user_id一对一关联。
这个拆分带来的直接好处是查询隔离。登录校验只碰user_account,浏览他人主页只碰user_profile,缓存策略也可以分开做——账号信息缓存时间长,资料信息因为更新频繁缓存时间短。代价是每次展示完整用户卡片时需要一次关联查询,我用的是MyBatis的resultMap做嵌套映射,一条SQL带出两张表的数据,实测在几万用户量级下单次查询稳定在十几毫秒。
密码存储这块必须单独强调。我见过太多项目直接存明文或者简单MD5,这在交友平台上是绝对不能接受的,因为用户资料里往往包含手机号等敏感信息,一旦库被拖走后果很严重。我的做法是BCrypt加盐哈希,每个用户注册时生成独立盐值,登录时用BCrypt.checkpw比对。BCrypt的好处是计算慢,天然抵抗暴力破解,代价是单次校验大约几十毫秒,所以登录接口我做了频率限制,同一IP一分钟内超过五次失败就暂时拒绝,这个逻辑用Redis的计数器加过期时间实现,几行代码的事但能挡掉大部分脚本撞库。
2.2 好友关系的双向存储与状态机
好友关系是交友平台最核心也最容易设计错的地方。常见的错误做法是只存一条记录表示“A和B是好友”,查询某人好友列表时用WHERE user_id = ? OR friend_id = ?,这种写法在数据量小时没问题,但索引利用效率低,而且无法表达“申请中”“已通过”“已拉黑”这些中间状态。我采用的是双向存储加状态字段的方案:A申请加B为好友时,插入两条记录,一条是(from=A, to=B, status=0)表示A发出的申请,另一条是(from=B, to=A, status=1)表示B收到的待处理申请,状态值0到3分别对应申请中、待处理、已通过、已拒绝。
这个设计乍看冗余,但查询极其高效:查A的好友列表就是WHERE from_user = A AND status = 2,走(from_user, status)联合索引,毫秒级返回;查A收到的申请就是WHERE to_user = A AND status = 1,同样走索引。状态机的流转在Service层用事务保证,A发申请时两条记录同时插入,B同意时两条记录同时更新为已通过,任何一步失败整体回滚。这里有个细节我踩过坑:并发重复申请。A快速点两次申请按钮,可能插入两组记录,解决办法是在(from_user, to_user)上建唯一索引,插入冲突时捕获异常返回“已申请过”,比先查后插的写法更可靠,因为查和插之间存在时间窗口。
拉黑逻辑我单独用一张user_block表处理,而不是塞进好友表的状态里。原因是拉黑是单向的且优先级最高,A拉黑B之后,B看不到A的动态、发不了私信、也搜不到A,但A对B的关系不受影响。把拉黑独立出来后,所有涉及两人交互的查询都要先过一遍拉黑校验,我把它封装成一个RelationChecker工具类,在私信发送、动态可见性判断、好友申请入口统一调用,避免遗漏。
2.3 私信会话与消息表的分层
私信模块的表设计我改了三版才定下来。第一版是单表存所有消息,字段有发送者、接收者、内容、时间,查询两人对话时用WHERE (from=A AND to=B) OR (from=B AND to=A) ORDER BY time,这个写法在消息量上万后明显变慢,因为OR条件很难走索引。第二版加了会话概念,建conversation表存会话双方和最后一条消息摘要,message表存具体消息并关联会话ID,查询对话变成WHERE conversation_id = ? ORDER BY time,走索引很快,但每次发消息都要更新会话表的最后消息和时间,写放大明显。
最终版我做了折中:保留会话表用于会话列表展示(只存双方ID、最后消息摘要、最后时间、未读数),消息表按会话ID建索引,发消息时先写消息表再异步更新会话表摘要。异步更新用Spring的@Async加线程池,失败时记录日志并定时补偿。这个方案在测试中单会话千条消息的加载时间从最初的秒级降到百毫秒内。未读数我用Redis的Hash结构存,key是用户ID,field是会话ID,value是未读条数,用户打开会话时清零,这样会话列表页不用每次count消息表,性能提升很明显。
注意:消息内容我做了敏感词过滤,用的是本地词库加DFA算法,过滤在Service层写入前完成,命中后替换为星号并记录审计日志。这个环节不能省,交友平台的私信是风险高发区。
3. 实操过程与核心环节实现
3.1 环境搭建与SSM整合配置
项目的基础环境是JDK 8、Maven 3.6、MySQL 5.7、Redis 5、Tomcat 8.5。选JDK 8不是守旧,而是SSM这套组合在JDK 8上的生态最稳,很多老版本的依赖在更高版本上会有反射相关的兼容问题,对于以学习理解为目的的项目没必要给自己找麻烦。Maven的pom.xml里核心依赖包括spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector、druid连接池、jedis、commons-fileupload、jstl,版本我统一锁在Spring 5.2.x和MyBatis 3.5.x这一组经过大量项目验证的搭配上。
整合的关键在三个配置文件。applicationContext.xml负责Spring容器,配数据源、SqlSessionFactory、事务管理器、Mapper扫描;spring-mvc.xml负责MVC层,配注解驱动、视图解析器、静态资源放行、拦截器;web.xml负责容器启动时加载前两个配置并注册DispatcherServlet和字符编码过滤器。这里有个新手常犯的错:把Mapper扫描配在spring-mvc.xml里,导致Service层注入Mapper时找不到Bean,因为两个容器是父子关系,子容器能看见父容器的Bean但反过来不行。正确做法是Mapper扫描和事务配置都放在applicationContext.xml,spring-mvc.xml只管Controller相关的。
数据源我用Druid,主要是看中它的监控面板和连接池状态查看能力,调试时能直观看到活跃连接数、慢SQL统计。核心配置如下:
<bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="url" value="jdbc:mysql://localhost:3306/friend_platform?useUnicode=true&characterEncoding=utf8&useSSL=false"/> <property name="username" value="root"/> <property name="password" value="******"/> <property name="initialSize" value="5"/> <property name="minIdle" value="5"/> <property name="maxActive" value="20"/> <property name="maxWait" value="60000"/> <property name="validationQuery" value="SELECT 1"/> <property name="testWhileIdle" value="true"/> </bean>连接池参数不是拍脑袋定的。maxActive=20是根据Tomcat默认最大线程数200和单次请求平均持有连接时间估算的,实际压测时如果发现等待连接超时,优先查慢SQL而不是盲目调大连接数,因为连接数过大反而会拖垮数据库。testWhileIdle配合validationQuery能自动剔除失效连接,避免MySQL的wait_timeout断开空闲连接后应用拿到坏连接报错。
3.2 登录拦截与Session管理的落地
登录态管理我用的是Session加Redis的方案。用户登录成功后,服务端生成一个UUID作为token,把用户ID和基础信息序列化后存进Redis,key是login:token:{uuid},过期时间设30分钟,同时把token写进Cookie返回给浏览器。拦截器在每次请求时从Cookie取token,去Redis查对应用户信息,查到就放行并刷新过期时间(滑动过期),查不到就重定向到登录页。
为什么不用纯Session?因为Tomcat自带的Session在重启后会丢失,而且多实例部署时无法共享。用Redis存token本质上是把Session外置,好处是重启不丢、可水平扩展、能主动踢人下线(删key即可)。这里有个细节:滑动过期的时间刷新不能每次请求都写Redis,否则高频请求下Redis写压力很大。我的做法是只在剩余过期时间少于一半时才刷新,比如30分钟过期,前15分钟内不刷新,这样写操作减少一大半,用户体验不受影响。
拦截器的配置要注意放行路径,登录页、注册接口、静态资源、验证码接口必须排除,否则会陷入重定向死循环。我用的是SpringMVC的<mvc:exclude-mapping>逐个列出,虽然啰嗦但比在拦截器里写if判断清晰。另外,Ajax请求被拦截时不能返回302重定向,否则前端拿到的是一段HTML而不是JSON,我的处理是拦截器里判断请求头X-Requested-With,是Ajax就返回401状态码加JSON提示,前端统一拦截401跳登录。
3.3 兴趣标签匹配推荐的实现
推荐这块我没上复杂的协同过滤,一是数据量不够,二是SSM项目里引入推荐算法库会让依赖变重。我用的是标签加权打分的轻量方案:用户注册时选择兴趣标签(从预设的几十个标签里多选),每个标签有一个权重,用户对某标签的互动越多(发过相关动态、给相关动态点过赞),该标签权重越高。推荐时计算当前用户与其他用户的标签向量余弦相似度,取相似度最高的前若干人作为推荐列表,再叠加“同城优先”“活跃度加权”两个因子做微调。
具体实现上,标签关系存在user_tag表,字段是用户ID、标签ID、权重值。余弦相似度的计算放在Service层,用Java直接算,因为候选集我做了预筛选——只取同城且最近七天活跃的用户,通常几百人量级,遍历计算完全扛得住。算完的结果缓存进Redis,key是recommend:{userId},过期时间一小时,用户每次进推荐页先读缓存,缓存没有再计算。这个方案实测在几千用户的数据集上,推荐接口响应时间稳定在两百毫秒内,效果上虽然比不上协同过滤精准,但冷启动阶段足够用,而且逻辑透明、容易调参。
提示:标签权重更新我用的是异步队列,用户点赞或发动态时往队列里丢一个事件,后台线程消费后更新权重,避免主流程被拖慢。队列用
LinkedBlockingQueue加单线程消费者实现,简单够用,没必要上消息中间件。
3.4 动态发布与可见性控制
动态模块的难点不在发布本身,而在可见性控制。交友平台的动态通常有“公开”“仅好友可见”“仅自己可见”三档,查询动态流时必须根据当前用户与发布者的关系动态过滤。如果每次查询都join好友表判断关系,SQL会变得很复杂且慢。我的做法是在动态表里冗余一个visibility字段,查询时用MyBatis的动态SQL拼条件:公开的直接查,好友可见的需要子查询当前用户的好友ID集合,仅自己的加user_id = 当前用户条件。
好友ID集合我缓存在Redis的Set里,key是friends:{userId},好友关系变更时同步更新。查询动态流时先从Redis取出好友ID列表,拼进SQL的IN条件。这里要注意IN列表不能太长,好友上千时SQL会很长,我的处理是超过五百个好友就分批查询再合并结果,虽然多几次查询但避免了超长SQL。动态的点赞和评论我用两张关联表,点赞表在(dynamic_id, user_id)上建唯一索引防止重复点赞,评论表按动态ID建索引,评论的回复关系用parent_id自关联实现两级结构,再深的层级产品上也没必要。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
实际开发和测试过程中遇到的问题我整理成下面这张表,基本都是同类项目容易踩的坑,按现象、原因、解决思路三列对照,方便快速定位。
| 现象 | 根因分析 | 解决思路 |
|---|---|---|
| 登录后刷新页面又跳回登录页 | Cookie的path或domain配置不对,或token写入Redis失败 | 检查Cookie作用域,确认Redis连接正常,看服务端日志有无序列化异常 |
| 好友申请重复插入 | 并发点击导致先查后插的时间窗口 | 在(from_user, to_user)建唯一索引,捕获DuplicateKeyException |
| 私信列表加载慢 | 消息表按时间排序无合适索引,或未读数实时count | 建(conversation_id, create_time)联合索引,未读数走Redis |
| 动态流出现已拉黑用户内容 | 拉黑校验遗漏在某个查询分支 | 统一封装RelationChecker,所有动态查询前置调用 |
| 中文乱码 | 数据库、连接、请求三处编码不一致 | 库表utf8mb4,JDBC加characterEncoding,web.xml配CharacterEncodingFilter |
| 图片上传后访问404 | 上传目录与Nginx静态映射目录不一致 | 统一配置上传根路径,Nginx的alias指向同一目录并检查权限 |
| 定时任务重复执行 | 多实例部署时每个实例都跑定时任务 | 用Redis分布式锁或数据库行锁保证单实例执行 |
4.2 几个值得展开的排查实录
乱码问题我遇到过最隐蔽的一次是:页面显示正常,但存进数据库的部分表情符号变成问号。原因是数据库字符集用了utf8而不是utf8mb4,utf8在MySQL里最多存三字节,而emoji是四字节。解决办法是把库、表、连接三处的字符集全部改成utf8mb4,JDBC连接串加characterEncoding=utf8(驱动会自动协商),这个改动要连历史数据一起迁移,用ALTER TABLE ... CONVERT TO CHARACTER SET utf8mb4逐表处理。教训是建库时字符集就要定对,后期改代价不小。
私信未读数不准这个问题排查了很久。现象是用户读完消息后未读数偶尔不清零。查下来是异步更新会话摘要和同步清零未读之间存在竞态:用户打开会话触发清零,同时对方发来新消息触发未读加一,两个操作顺序不确定导致结果错乱。解决思路是把未读数的增减都收进Redis的原子操作,用HINCRBY做加一,清零用HDEL删字段,并且给清零操作加一个短时间的防抖——用户打开会话后三秒内的新消息不计入未读,因为用户大概率还在看这个会话。这个防抖逻辑虽然牺牲了一点精确性,但换来了体验上的稳定。
推荐列表重复推荐同一个人,原因是缓存过期后重新计算时,已建立好友关系的人没有从候选集里剔除。修复方法是在计算相似度前先过滤掉已是好友和已拉黑的关系,过滤用的就是前面缓存的好友ID集合和拉黑集合。这个bug提醒我:推荐逻辑的输入集清洗比算法本身更重要,脏输入会让再好的算法也输出垃圾。
4.3 性能与安全的实操心得
性能方面,我最大的体会是索引要跟着查询走,而不是跟着字段走。项目初期我给很多字段单独建了索引,结果发现联合查询时用不上。后来我把慢查询日志打开,逐条分析执行计划,按实际SQL的where和order by组合重建索引,比如私信列表的(conversation_id, create_time)、好友申请的(to_user, status, create_time),重建后整体接口平均响应时间下降约四成。另外分页查询一定要避免大偏移量的LIMIT 100000, 20,改用游标方式WHERE id < last_id ORDER BY id DESC LIMIT 20,翻到深页时性能差距非常明显。
安全方面除了前面说的密码哈希和敏感词过滤,还有两点值得强调。一是越权访问,所有涉及资源ID的接口都要校验归属,比如删除动态要确认动态属于当前登录用户,改资料要确认改的是自己的资料,这个校验我统一放在Service层入口,用AOP加自定义注解@CheckOwner实现,避免每个方法手写。二是文件上传,除了限制类型和大小,还要把上传文件重命名成UUID加后缀,绝不能直接用用户传的文件名,否则可能被构造出路径穿越的文件名覆盖系统文件。上传目录也要配置成不可执行脚本,防止有人传上来一个伪装成图片的脚本文件。
5. 项目扩展方向与个人实践体会
这个项目做完之后我又陆续加了一些东西,有些是锦上添花,有些确实提升了实用性。比如把站内通知从轮询改成基于长连接的推送,用Spring的DeferredResult做异步请求挂起,有新消息时唤醒对应请求返回,比前端定时轮询省资源也更及时。再比如给动态加了简单的图片九宫格展示,前端用CSS Grid布局,后端只存图片URL列表的JSON字符串,展示时前端解析,避免了为图片单独建关联表的复杂度。
如果要把这个平台往更完整的方向推,我觉得有三个方向值得投入。第一是关系链的深度挖掘,比如“共同好友数”这个指标,在好友申请页展示“你们有3个共同好友”能显著提升通过率,实现上就是两个好友集合求交集,用Redis的SINTER一条命令搞定。第二是内容审核的自动化,现在敏感词是本地词库,后续可以接入图像识别做头像和动态图片的合规检测,这块要考虑成本和误判率,不能一刀切。第三是数据统计看板,把注册转化、日活、消息量这些指标做成图表,运营侧会非常需要,技术上用定时任务跑聚合SQL写进统计表,前端用ECharts渲染即可。
我个人在实际操作中的体会是,SSM这套东西的价值不在于它多先进,而在于它逼着你把每一层都摸清楚。做这个项目的过程中,我因为手写SQL映射被迫理解了索引原理,因为配事务被迫理解了传播行为和隔离级别,因为调拦截器被迫理解了Servlet容器的请求生命周期,这些理解在后来看其他框架时都能迁移过去。如果你也在做类似的项目,我的建议是不要急着堆功能,先把用户、关系、消息这三张核心表的模型想透,把事务边界划清楚,把索引按真实查询建好,剩下的功能都是在这套地基上添砖加瓦。地基不稳,功能越多坑越多。