相亲网站这类项目在培训班和毕业设计里都快被做烂了但真正能把前后端分离、权限控制、双向匹配、聊天这类模块讲清楚的文章其实很少。这次借一个 SpringBoot Vue3 MyBatis MySQL 的完整系统来聊聊涵盖数据库设计、后端接口落地、前端对接和部署要避开的坑。先说明白这套系统是什么它是一个基于前后端分离架构的相亲交友平台后端用 SpringBoot 提供 RESTful API前端用 Vue3 Vite Element Plus 搭界面持久层用 MyBatis 做 SQL 映射数据存储落在 MySQL 8.0。功能上覆盖了用户注册登录、个人资料完善、条件筛选、心动匹配、站内消息、会员功能这些相亲网站的核心模块。不管你是准备做毕业设计、想系统梳理 SpringBoot Vue3 全栈流程还是打算把这个项目改成其他领域的信息匹配类应用这篇文章都适合你先收藏再慢慢看。1. 系统功能边界与整体技术选型逻辑1.1 相亲网站的模块边界该划到哪很多同学一上来就照着婚恋App抄功能什么直播、红娘一对一、线下活动报名全塞进去结果发现代码量失控、数据库表结构乱成一锅粥。我的建议是做一个 80 分功能深度的系统把核心闭环跑通比堆砌 30 个半成品功能要值钱得多。这套系统的功能边界这样划分用户端注册、登录、个人资料基本信息、择偶要求、自我描述、照片墙、用户检索、心动匹配、站内私信。管理端用户审核、举报处理、会员订单管理、基础数据统计。公共模块短信验证码模拟发送、文件上传头像与照片、统一异常处理、JWT 鉴权。有朋友会问为什么不加实时聊天WebSocket 不是更好吗我的回答是轮询加一张 message 表在业务上完全够用而且能避开 WebSocket 连接管理、心跳重连、离线消息推送这一大坨复杂度。当你把轮询聊天的坑踩完之后再上 WebSocket 会从容很多。项目是给人用的不是给技术表演的。1.2 为什么是 SpringBoot Vue3 MyBatis 这套组合这个选型不是追新而是权衡了招聘市场需求、学习成本和生态成熟度之后的结果。后端部分SpringBoot 3.x 是目前 Java 服务端的绝对主流。它通过自动配置把 Spring 家族的复杂度封裝起来内嵌 Tomcat一个 fat jar 就能跑起来部署成本低。Spring Security 或者 Sa-Token 做鉴权JWT 做无状态令牌和 Vue3 前端的配合非常顺畅。持久层我特意选了 MyBatis 而不是 MyBatis-Plus原因有两个。第一相亲网站的查询条件组合非常灵活——年龄区间、身高区间、城市、学历、收入、是否有房有车这些都是动态拼接的 SQL。MyBatis 的if、where、choose标签在这种场景下是天然利器控制力最强。第二面试的时候MyBatis 的动态 SQL、一级缓存二级缓存、#{}和${}的区别这些都是 Java 面试八股文的常客你用 MP 写惯了反而说不清底层机制。当然项目里单表增删改查的冗余代码确实多所以我用 MyBatis Generator 生成了基础 mapper复杂查询自己写 SQL两不误。前端选 Vue3 没有悬念。组合式 APIComposition API配合script setup语法糖代码组织比 Options API 清晰一个量级。Element Plus 组件库对后台管理系统和这种 C 端页面都够用。Vite 开发服务器秒级启动代理转发配置比 Webpack 省心太多。构建产物还是静态文件扔到 Nginx 里就行。1.3 前后端分离的工程结构里谁负责什么前后端分离的本质是职责边界清晰不是简单地把代码分成两个文件夹。这套系统里前端只干三件事渲染数据、收集用户输入、维护页面状态。后端只干三件事校验参数、处理业务、返回 JSON。谁都不许越界。工程结构上我分成了三个仓库也可以放一个仓库的三个目录matchmaking-backend SpringBoot 工程 matchmaking-frontend Vue3 工程 matchmaking-sql 数据库脚本目录后端按 controller - service - mapper 三层分包entity 放实体dto 放接收参数vo 放返回视图。有同学喜欢把 entity 直接返回前端我强烈建议不要。因为数据库字段和前端展示字段大概率不一致比如用户表里的password、phone这种敏感字段一旦直接序列化出去就是安全事故。所以我的原则是Controller 层绝不直接返回 Entity一律转 VO。2. 数据库设计相亲网站最核心的表关系与索引规划2.1 六张核心表的设计思路相亲网站的业务核心是用户和关系所有功能都围绕这两个词展开。这套系统的核心表我设计成这样user 用户表 user_profile 用户档案表1对1 user_photo 用户照片表1对多 user_match 匹配记录表 message 私信表 member_order 会员订单表为什么用户基本信息表和档案表要拆开因为登录只需要手机号、密码、状态这几个字段而档案字段特别多——身高、体重、学历、职业、年收入、房车情况、自我介绍、择偶要求可能二十多个字段。如果全塞一张表查询列表页时每次都要SELECT *哪怕用不上也白白消耗 IO。拆开之后列表页只查 user 表 profile 表的必要字段详情页再全量查性能会好很多。user_match 表是相亲网站的关系核心它记录一次心动匹配行为CREATE TABLE user_match ( id bigint NOT NULL AUTO_INCREMENT, from_user_id bigint NOT NULL COMMENT 发起匹配的用户, to_user_id bigint NOT NULL COMMENT 被匹配的用户, match_type tinyint NOT NULL COMMENT 1-喜欢 2-超级喜欢, status tinyint NOT NULL DEFAULT 0 COMMENT 0-待回应 1-互相喜欢 2-已拒绝, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_from_to (from_user_id, to_user_id), KEY idx_to_user (to_user_id, status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这个设计的精妙之处在uk_from_to唯一索引。它保证了 A 对 B 只能发起一次匹配不会出现重复记录。当 B 也发起对 A 的匹配时业务代码里要做反向查询——查from_user_id B and to_user_id A是否存在存在就把两边的状态都更新为互相喜欢。这就实现了相亲网站的配对成功。为了查反向记录效率高我在to_user_id上建了联合索引idx_to_user (to_user_id, status)避免全表扫描。2.2 用户检索场景的索引优化相亲网站最常见的操作是什么是条件筛选。用户打开首页设置年龄 25-30、身高 165-175、城市上海、本科以上学历点搜索后端就要拼 SQL 查出来。这种场景下索引和 SQL 怎么写直接影响用户体验。我的建议是给高频筛选字段建联合索引而不是给每个字段单独建索引。但这里有个问题——用户筛选条件组合千变万化你不可能为所有组合都建索引。实际项目中我采用了两条路并行第一条路限制条件个数。前端最多允许用户同时选择 5 个筛选条件且年龄区间和城市必选。这样后端 SQL 就不会出现只按年收入筛选这种极端情况索引命中的概率大大提升。第二条路用 MyBatis 动态 SQL 保证where子句的顺序和联合索引一致。比如联合索引是idx_city_age_edu (city, age, education)那么 MyBatis 里的 SQL 就要严格按这个顺序拼接条件select idsearchUser resultTypecom.example.vo.UserSearchVO SELECT u.id, u.nickname, u.avatar, p.age, p.height, p.education, p.city FROM user u INNER JOIN user_profile p ON u.id p.user_id where if testcity ! null and city ! AND p.city #{city} /if if testminAge ! null AND p.age gt; #{minAge} /if if testmaxAge ! null AND p.age lt; #{maxAge} /if if testeducation ! null and education ! AND p.education #{education} /if /where ORDER BY p.last_active_time DESC LIMIT #{offset}, #{pageSize} /select有同学在索引失效上吃过亏我这里提醒一句age BETWEEN #{minAge} AND #{maxAge}这种写法和age #{minAge} AND age #{maxAge}在优化器眼里是一样的不用担心。真正要担心的是在索引列上做函数运算比如WHERE YEAR(create_time) 2024这一下索引就废了。所以时间范围查询请直接写区间条件不要写函数。2.3 私信表的分页与区分度问题message 表也是一个容易设计翻车的地方。最简单的方案是建一张大表字段就id, from_user_id, to_user_id, content, create_time, is_read完事。但用户量稍微一上来这张表会膨胀得很快而且会话列表页要查我和每个人最后一条聊天内容SQL 写起来很别扭。我的做法是加一张conversation会话表CREATE TABLE conversation ( id bigint NOT NULL AUTO_INCREMENT, user_a bigint NOT NULL COMMENT 会话中的用户A约定user_a user_b, user_b bigint NOT NULL COMMENT 会话中的用户B, last_message varchar(500) DEFAULT NULL COMMENT 最后一条消息内容, last_message_time datetime DEFAULT NULL COMMENT 最后一条消息时间, unread_count int NOT NULL DEFAULT 0 COMMENT 未读消息数, PRIMARY KEY (id), UNIQUE KEY uk_users (user_a, user_b) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这个表维护会话维度的信息message 表只负责存明细。会话列表页一条 SQL 直接查 conversation 表按last_message_time倒序性能极好。为什么user_a user_b这是为了避免同一个会话被建两次。A 给 B 发消息时业务代码先判断A B是否成立成立则(A, B)不成立则(B, A)然后去 conversation 表找记录存在就复用不存在就新建。唯一索引uk_users保证了这个逻辑并发安全。设计数据库表的顺序是有门道的——先梳理业务实体和关系画 ER 图再逐表设计字段类型、默认值、索引最后再回头审视所有 SQL 语句看有没有潜在的全表扫描。顺序反了后面改表结构牵一发动全身特别痛苦。3. 后端实现SpringBoot MyBatis 的核心接口与代码落地3.1 工程结构与认证方案后端工程我用 SpringBoot 3.2 JDK 17 MyBatis 3.5 MySQL 8.0。结构如下com.example.match ├── common -- 通用返回体、异常处理、工具类 ├── config -- 配置类拦截器、CORS、MyBatis插件 ├── controller -- 接口层 ├── service -- 业务层 ├── mapper -- MyBatis接口 ├── entity -- 数据库实体 ├── dto -- 接收前端参数 ├── vo -- 返回前端视图 └── interceptor -- 登录拦截器认证方案选了 JWT 拦截器的组合不引入 Spring Security因为对于这个体量的项目Spring Security 的过滤器链配置成本高于收益。流程是用户登录成功后后端用 userId 过期时间 密钥生成 token 返回前端存到 localStorage每次请求在 header 带Authorization: Bearer token后端写一个LoginInterceptor从 header 取 token解析成功后把 userId 塞到 request attribute 里方便后续业务代码取用。核心代码很简单Component public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token) token.startsWith(Bearer )) { JwtPayload payload JwtUtil.parseToken(token.substring(7)); if (payload ! null) { request.setAttribute(userId, payload.getUserId()); return true; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); return false; } }注册登录这一块密码处理要额外强调——必须加盐哈希不要用 MD5。MD5 撞库太容易了一定用BCryptPasswordEncoderSpring Security 里的类可以单独引或者 Hutool 的BCrypt。盐值会自动嵌进哈希结果里校验时直接用相同方法再算一次比对即可。手机验证码这块其实是个模拟逻辑后端生成 6 位随机数存 Redis设 5 分钟过期实际生产环境接阿里云短信或腾讯云短信 SDK 就行接口签名完全一致。3.2 业务缓存与 MyBatis 缓存的血泪教训这个项目里有几个高频读取的数据我会做缓存处理比如用户详情和管理端的首页统计数据。缓存第一选择必然是 Redis因为 SpringBoot 整合 Redis 非常成熟StringRedisTemplate开箱即用。但这里我要吐槽 MyBatis 的二级缓存这也是网上大量踩坑帖的来源。MyBatis 的一级缓存是 SqlSession 级别的同一个 SqlSession 内查询相同 SQL 才会命中Spring 管理下 SqlSession 用完就销毁所以一级缓存基本等于没有。二级缓存是 namespace 级别的默认不开启。你以为开启二级缓存就能提升性能天真。如果两个表关联查询其中一个表的更新没有触发另一个 namespace 的缓存失效你就查到脏数据了。我的建议是在 SpringBoot MyBatis 项目里尽量别开 MyBatis 二级缓存直接用 Redis 做业务缓存。因为 MyBatis 的缓存不可控而 Redis 缓存可以精确设置 key、过期时间、手动更新。这样出了问题你至少知道去哪里排查而不是在一个黑盒里找 bug。3.3 动态匹配算法的落地方式相亲网站的匹配逻辑是这个系统的灵魂实现方式有很多种我采用的是标签权重打分方案。具体思路是用户完善资料时选择自己的标签如运动美食旅行电影阅读和期望对方的标签。匹配时先按城市 年龄区间 性别做基础筛选然后对候选人计算标签匹配得分[ score \sum_{tag \in 候选人的标签 \cap 我的期望标签} weight_{tag} ]权重怎么定两个方式一是固定权重表每个标签一个基础分二是更进阶的根据用户对某个标签的偏好程度动态调整——用户在择偶要求里越强调某个标签这个标签的权重越高。我用的是后者将用户期望中关键词出现次数做归一化作为权重。这样匹配结果更个性化也更有说服力。匹配接口的返回格式是候选人列表 匹配度百分比 共同标签清单。前端展示效果很好用户看到你们有3个共同标签比看到一堆用户列表更有点击欲望。3.4 使用 MyBatis Generator 少写重复代码写 mapper 接口、mapper.xml、entity 类这种体力活做多了就想偷懒。MyBatis GeneratorMBG就是干这个的。配置好数据源和生成目录之后可以一键生成基础的单表 CRUD 代码包括selectByPrimaryKey、insertSelective、updateByPrimaryKeySelective等常用方法。注意insertSelective和updateByPrimaryKeySelective这两个方法它们只更新非 null 字段在 前端传什么就更新什么 的场景下非常安全可以避免把 null 覆盖到数据库里。plugin groupIdorg.mybatis.generator/groupId artifactIdmybatis-generator-maven-plugin/artifactId version1.4.2/version configuration configurationFilesrc/main/resources/generatorConfig.xml/configurationFile overwritetrue/overwrite verbosetrue/verbose /configuration /plugin这里有个细节——overwrite 配置为 true 后重复生成会覆盖同名文件。所以我把生成的代码和手写的扩展代码分开放生成的 mapper 接口和 xml 保持原样手写的复杂查询方法另建一个子接口或直接在 xml 里追加不要混在一起否则重新生成时你手写的代码会被覆盖。我吃过这个亏写了两百行的自定义 SQL一次生成全没了。4. 前端工程Vue3 Element Plus 从零到对接后端4.1 Vite 工程搭建与项目目录划分前端工程用 Vite 创建npm create vitelatest matchmaking-frontend -- --template vue装依赖时我会额外装这些vue-router4路由pinia状态管理axiosHTTP 请求element-plusUI 组件库element-plus/icons-vue图标sass样式预处理然后按模块建目录src ├── api -- 每个模块的接口封装 ├── assets -- 静态资源 ├── components -- 通用组件 ├── router -- 路由配置 ├── stores -- pinia store ├── utils -- 工具函数request.js封装等 ├── views -- 页面组件 │ ├── home -- 首页 │ ├── user -- 个人中心 │ ├── match -- 匹配页 │ ├── message -- 消息页 │ └── admin -- 管理后台 └── App.vueAPI 层务必集中管理不要在每个页面里直接调 axios。比如用户模块我会在src/api/user.js里导出所有用户相关接口import request from /utils/request export function getUserDetail(userId) { return request({ url: /api/user/${userId}/detail, method: get }) } export function updateUserProfile(data) { return request({ url: /api/user/profile, method: put, data }) }这样改接口路径、加统一参数、做接口鉴权拦截都只动一个文件。4.2 请求封装与跨域踩坑axios 请求封装是我每次写项目都会重点打磨的模块。它承担三件事自动携带 token、统一处理错误码、统一处理 HTTP 状态码。// src/utils/request.js import axios from axios import { ElMessage } from element-plus import router from /router import { useUserStore } from /stores/user const request axios.create({ baseURL: /api, timeout: 15000 }) request.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers.Authorization Bearer ${userStore.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code ! 200) { if (res.code 401) { userStore.logout() router.push(/login) } ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res }, error { if (error.response?.status 401) { ElMessage.error(登录已过期请重新登录) router.push(/login) } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default request跨域这个问题前后端分离项目的经典配置。后端如果不用 CORS 过滤器前端请求就会因为跨域被浏览器拦下来。我是前后端配合处理的后端在config包下加了 CORS 配置前端利用 Vite 的 proxy 做代理转发请求时写/apiVite 开发服务器把/api开头的请求转发到http://localhost:8080。// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })有同学经常会纠结既然后端已经允许跨域了前端为什么还要配代理其实这两个解决的不是同一个问题。CORS 是浏览器层面的跨域安全策略代理是让开发服务器帮你转发请求浏览器看到的请求是同源的。生产环境中前端构建成静态文件后由 Nginx 提供服务Nginx 再配置/api反向代理到后端服务同样可以规避跨域问题。所以 Vite 代理是为了开发环境Nginx 反代是为了生产环境后端 CORS 配置是为了万一前端直接请求后端的场景兜底。三个都配好这套方案在各环境都不会有跨域包袱。4.3 核心页面逻辑拆解首页展示推荐用户列表用到的组件有卡片式用户信息、标签、匹配度进度条。数据来自/api/match/recommend接口携带分页参数上拉加载更多。这里要特别注意防抖——用户快速滚动时多次触发接口不能每次都发请求要等上一次请求完成后再允许下一次加载。个人资料页是大头它是一个多步骤表单分三块基本信息、形象展示照片、择偶要求。表单校验用 Element Plus 的 Form 组件校验规则动态生成比如年龄必须为数字且在18-60之间。照片上传用了el-upload后端接口接收 multipart 文件存储到服务器本地目录返回访问 URL。开发环境前端用 Vite 跑在 5173图片访问的是后端 8080 端口所以上传后的图片 URL 要拼上后端地址。为了避免硬编码我在前端的.env.development里配置了VITE_API_BASE_URL用环境变量管理后端地址。消息页是轮询的核心场景。页面加载后用setInterval每 5 秒调用一次未读消息数接口有新的未读消息就刷新会话列表。这里要记得在组件卸载onUnmounted时清除定时器否则页面切换后定时器还在跑白白浪费请求不说还可能因为 Vue 组件已销毁而报错。互动逻辑里面最核心的就是喜欢按钮。用户点击喜欢后前端调/api/match/like接口后端会执行一次反向匹配检查Override public MatchResult like(Long fromUserId, Long toUserId) { // 检查对方是否已经喜欢我 UserMatch reverseMatch userMatchMapper.findMatch(toUserId, fromUserId); if (reverseMatch ! null) { userMatchMapper.updateStatus(fromUserId, toUserId, 1); userMatchMapper.updateStatus(toUserId, fromUserId, 1); return MatchResult.success(true); // 互相喜欢 } userMatchMapper.insert(fromUserId, toUserId, 1); return MatchResult.success(false); // 已发送喜欢 }这个like操作一定是事务性的。我把它放在Transactional注解的方法里防止查到了反向记录但更新状态时失败导致的数据不一致。在开发时我用 IDEA 的 Debug 模式模拟并发请求测试了几次如果没有事务确实会出现两边状态不同步的情况这个坑一定要提前堵住。4.4 管理后台的权限思路管理后台不单独建一个前端工程而是在同一个 Vue3 项目里通过路由和权限来区分。前端路由分了两个区域用户端路由和 admin 路由。用户在登录接口返回的数据里带了role字段前端根据 role 动态添加路由。具体做法是在路由守卫beforeEach里判断router.beforeEach((to, from, next) { const userStore useUserStore() if (to.meta.requiresAuth !userStore.token) { next(/login) return } if (to.path.startsWith(/admin) userStore.role ! ADMIN) { next(/) return } next() })后端再配合一个判断 role 的拦截器对/api/admin/**接口单独拦截。双保险情况下哪怕有人绕过前端直接请求后端接口也会被拦截这个安全习惯很重要。5. 数据库与SQL细节把 MySQL 用到位5.1 建库建表时的编码与排序规则MySQL 8.0 建库一个容易被忽略但影响很大的配置是默认字符集和排序规则。我的统一规范是CREATE DATABASE matchmaking CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;为什么是 utf8mb4因为用户填的个人介绍、自我介绍里很可能有 emoji 表情和生僻字旧的 utf8 字符集存不下四字节字符插入时就报错Incorrect string value。utf8mb4 是 utf8 的超集能完整支持 Unicode 所有字符包括 emoji。排序规则utf8mb4_unicode_ci在排序和比较时对重音符号、大小写不敏感适合中英文混合内容。如果在字段比较时需要区分大小写可以单独用utf8mb4_bin。建表时在 CREATE TABLE 语句也明确指定这两个参数不要依赖全局默认值避免以后迁移环境时行为不一致。5.2 存储过程到底要不要用MySQL 存储过程这个话题在很多公司的开发规范里是被慎用甚至禁用的。我的观点是简单的数据校验和统计功能别用存储过程但确有必要时可以用。本项目里有一个场景我用了存储过程——会员数量统计和管理端报表。因为这个统计逻辑涉及多张表的聚合计算而且由 MySQL 计划任务每晚定时执行将统计结果写入一张汇总表。这种情况下存储过程可以保证统计逻辑的原子性和执行顺序。但对于核心业务逻辑比如匹配算法、支付回调我坚决不用存储过程。原因很现实存储过程难以调试、难以做单元测试、逻辑藏在数据库里对后来维护的人不友好。Java 代码里实现业务逻辑IDE 里打断点、查日志、看异常堆栈体验完全是两个世界。面试时如果被问到存储过程的优缺点可以把这两条正反面讲清楚比背八股文强很多。5.3 SQL 性能优化的几个关键动作相亲网站的用户量大起来之后慢查询是肯定要面对的。我在开发和压测过程中整理了几个高频优化手段第一用慢查询日志定位问题 SQL。MySQL 的slow_query_log开启后执行时间超过阈值的 SQL 会记录在日志里SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;配合mysqldumpslow工具可以快速找出 TOP N 慢 SQL这是调优的第一步没有数据支撑的优化都是瞎猜。第二对高频联表查询的字段建立联合索引但要注意索引区分度。比如性别字段的区分度极低只有两个值单独建索引没有意义甚至可能导致优化器放弃索引走全表扫描。要建就建(city, gender, age)这种能真正过滤数据的联合索引。第三分页深翻页问题。LIMIT 100000, 20表示数据库要扫描 100020 行后丢弃前 100000 行代价很大。优化方案是延迟关联先只查主键再根据主键回表查数据SELECT * FROM user INNER JOIN ( SELECT id FROM user WHERE city 上海 AND age BETWEEN 25 AND 30 ORDER BY last_active_time DESC LIMIT 100000, 20 ) AS tmp ON user.id tmp.id;这样内层查询只扫描主键索引比直接SELECT * 大偏移量快很多。注意这里只是一个通用优化思路实际优化还要结合EXPLAIN的执行计划来看。6. 部署上线从本地到服务器的完整链路6.1 环境准备与 Java/MySQL 安装部署前服务器上需要准备的东西有JDK 17、MySQL 8.0、Nginx、Redis如果用了缓存。JDK 安装很简单用包管理器或直接解压 JDK 的 tar.gz 包然后配置JAVA_HOME和PATH环境变量export JAVA_HOME/usr/local/jdk-17 export PATH$JAVA_HOME/bin:$PATH注意 Java 环境变量配置是面试高频考点尤其是 Windows 下的JAVA_HOME、Path、CLASS_PATH三个变量的配置流程。开发机上建议直接用 IDEA 自带的 JDK省心服务器上用 openjdk 17 就行没必要装 Oracle JDK。MySQL 8.0 的安装我强烈建议用官方 yum/apt 仓库而不是下载源码包编译。装完之后第一步一定是执行mysql_secure_installation脚本它会引导你设置 root 密码、移除匿名用户、禁止 root 远程登录。在这个项目里因为前后端分离后端服务连接数据库用的是专门的match_app用户只授予业务需要的库表权限不要用 root 连业务库。这是最基本的数据库安全底线。6.2 Nginx 配置前后端分离部署前端构建之后npm run build得到一个 dist 目录把它上传到服务器的/usr/share/nginx/matchmaking下。Nginx 配置如下server { listen 80; server_name yourdomain.com; # 前端页面 root /usr/share/nginx/matchmaking; index index.html; # 解决 Vue Router history 模式刷新 404 的问题 location / { try_files $uri $uri/ /index.html; } # 反向代理后端接口 location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这里最关键的是try_files $uri $uri/ /index.html;这一句。Vue Router 如果用 html5 history 模式URL 不带 #用户直接访问http://yourdomain.com/user/123这个路径时Nginx 会去找服务器上是否存在对应的静态文件找不到就 404。加了 try_files 之后所有不存在的路径都会回退到 index.html由前端路由接管页面正常渲染。后端 jar 包的启动方式我建议用nohupnohup java -jar matchmaking-backend.jar --spring.profiles.activeprod /var/log/matchmaking.log 21 把日志重定向到文件方便排查问题。更健壮的方式是使用 systemd 管理服务设置自动重启但中小项目用 nohup 先跑起来也行。启动后记得用tail -f /var/log/matchmaking.log观察日志确认 SpringBoot 正常启动后再去验证接口。6.3 上线前必做的安全检查清单部署到公网之后有一些安全设置是绕不开的修改 MySQL root 密码设置强密码开启防火墙限制 3306 端口只允许内网访问。后端配置spring.profiles.activeprod生产环境数据库密码不要写在application.yml里用环境变量或配置中心注入。Nginx 开启 Gzip 压缩gzip on; gzip_types text/plain text/css application/json application/javascript;所有上传文件路径必须在应用目录内并且对上传文件做类型校验防止用户上传可执行的恶意文件。对 JWT 密钥设置足够长的随机字符串并定期更换。一旦泄露攻击者可以伪造任意用户的 token这是致命问题。7. 我踩过的坑联调、部署与排错实录7.1 前端明明请求了后端就是收不到这个问题排查了很久。现象是前端控制台显示请求已发出但后端日志里完全没记录接口也没有命中断点。后来查了一圈是 Nginx 配置的问题——proxy_pass http://127.0.0.1:8080;后面没有加/api/导致转发出去的 URL 变成http://127.0.0.1:8080/user/list而后端接口路径是/api/user/list自然匹配不上。正确的写法是proxy_pass http://127.0.0.1:8080/api/;让它保留 /api 前缀。排查这类问题时最快的定位方法是看后端的 access log确认请求是否真正到达后端如果没到达问题就在 Nginx 层到达了但返回 404就是路径映射的问题。7.2 MyBatis 控制台看不到 SQL联调阶段我习惯打开 MyBatis 的 SQL 日志这样可以直观看到每次请求执行的 SQL 语句和参数。SpringBoot MyBatis 的配置是mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl原理是 MyBatis 通过log-impl指定日志实现类StdOutImpl会把 SQL 和参数直接打印到控制台。如果项目里还用了 logback 等日志框架可以把 mybatis 的 mapper 接口日志级别设为 debug效果更可控。在 IDEA 里还有一个非常好用的插件MyBatis Log Free安装后执行接口时它会直接输出完整的、可直接在数据库客户端执行的 SQL省去你手动把?替换成参数的烦恼。联调时靠这个插件排查动态 SQL 拼接问题效率提高一大截。7.3 数据库表不存在应用启动失败这个问题其实是我特意提到的热词关联SpringBoot MyBatis 项目当表不存在时能不能自动建表答案是MyBatis 本身没有这个能力。你可以用 SpringBoot 的spring.sql.init机制来执行建表脚本在配置里指定spring: sql: init: mode: always schema-locations: classpath:db/schema.sql这个配置会在应用启动时执行 schema.sql 中的建表语句。但问题来了——每次启动都会执行如果表已存在建表语句会报错。解决办法是在 schema.sql 的建表语句里加上CREATE TABLE IF NOT EXISTS。这种方式只适合开发环境生产环境请务必使用 Flyway 这类迁移工具做版本管理而不是每次启动都执行脚本否则数据丢失的风险没人替你兜底。7.4 大小写、字符集、端口冲突的连锁问题Linux 服务器上部署时MySQL 的lower_case_table_names参数默认值是 0区分表名大小写但 Windows 上默认是 1不区分。如果你的开发环境是 Windows写 SQL 时表名大小写不规范部署到 Linux 服务器上极有可能报Table xxx doesnt exist。这类问题的排查方法很简单执行SHOW TABLES;看真实表名然后在 SQL 里严格对齐大小写。另外阿里云服务器默认安全组只放行 80/443 端口如果后端端口没有加到安全组规则里公网访问会被挡在外面这种数据包丢了的问题半天都查不出来。8. 个人项目复盘与简历亮点提炼8.1 这个项目在面试时的高频考点拿着这套相亲网站源码去面试面试官主要会从下面几个角度问数据库索引user_match 表的联合索引为什么这么设计索引失效的情况有哪些事务点赞匹配时为什么加Transactional两个用户同时点赞会不会死锁安全JWT 的原理token 被窃取了怎么办密码如何安全存储缓存Redis 用在哪些场景为什么不用 MyBatis 二级缓存性能检索变慢了你如何定位慢查询 SQL 怎么优化每一块都做到有理有据能讲清为什么这样设计比背一百道八股文更有说服力。面试官听得出你是真做过还是背过表达时多讲当时我遇到了什么问题怎么排查和解决的这是抓住注意力的关键。8.2 面向简历的项目亮点包装写简历的时候不建议写实现了用户管理、信息展示、私信聊天这种流水账。可以这样提炼项目描述独立设计并实现前后端分离的相亲交友平台涵盖用户体系、动态匹配、实时消息、管理后台等核心模块。 核心成果 - 设计基于标签权重的匹配算法结合用户偏好动态调整权重显著提升匹配精确度 - 通过联合索引设计与延迟关联优化将列表查询响应时间从 1.2s 降至 200ms 以内 - 基于 JWT Redis 实现无状态认证与会话管理支持 token 自动续期与黑名单控制 - 采用 Vue3 组合式 API 封装通用业务组件实现前端工程化构建产物体积降低 30%。数字有真实数据支撑才有意义精确到秒、毫秒级的数据比性能大大提升这种形容词更有说服力。优化前后各测一次接口响应时间把数据贴上去面试官看到这个细节就知道你是动过手的人。8.3 推荐的扩展方向这套系统如果继续做下去有几个值得扩展的方向引入 Redis 的 GEO 地理位置能力实现附近的人相亲场景非常契合。将消息系统升级为 WebSocket实现真正的实时聊天并加入正在输入状态、消息已读回执。给匹配算法增加协同过滤因素——比如喜欢 A 的人也喜欢 B这是推荐系统的雏形。对接真实短信服务商替换模拟验证码逻辑。增加每日推荐定时任务SpringBoot 整合 Quartz每天 9 点把 10 个候选用户推给用户站内信或者邮件。我自己实际写这套系统的时候最大的体会是项目不一定要多炫技但每一个模块都得能自圆其说。你用的每一个技术选型、每一张表的设计、每一个缓存策略都应该能回答为什么。这套系统的价值可能不在于代码量有多壮观而在于它是一个完整的、可运行的、能讲清楚来龙去脉的闭环作品。拿去扩展、拿去面试、拿去接外包项目都够用了。