☰
SpringBoot + Vue3 实现高考志愿专业推荐系统:从数据建模到评分算法落地
2026/10/24 5:56:11 网站建设 项目流程

每年高考结束后到志愿填报的阶段,学校、学生、家长三头都在焦头烂额。我在某高校信息中心做技术支持的时候,亲眼见过老师们用Excel表格全校排专业推荐顺序,几十个专业、上千个学生,靠眼睛比对成绩和意向,通宵加班还有漏掉的人。后来我们决定做一套专业选择系统,用SpringBoot把推荐逻辑和数据存取搭起来,前端用Vue做交互界面,前后端彻底分离。这套系统上线后,原先三天的汇总工作压缩到半天,学生也能在系统里看到匹配度排序,算是把“拍脑袋填志愿”变成“看数据选方向”。这篇博文就把整个开发和落地的过程梳理一遍,包括架构选型、数据建模、推荐评分算法的实现,以及在部署环境里遇到的几个有代表性的问题。

1. 立项背景:为什么需要一套专业选择系统来替代手工汇总

1.1 手工推荐流程的痛点

每年的专业选择窗口期非常短。学生需要在几天之内确认自己的志愿顺序,而学校这边要同时完成“成绩筛选+意向汇总+专业容量核对”,压力全砸在教务科几个老师身上。我见过最原始的做法是:班主任收学生手写表格,教务干事把所有数据录入Excel,再用VLOOKUP匹配上一年的录取分数线和专业热度排名,最终生成一份按总分降序的分配建议表。

这套流程的问题很明显。第一,成绩和意向分布在不同的Excel文件里,跨表引用稍微有点格式差异就查不到数据,多个文件合并时经常出现行错位。第二,专业热度每年都在变,去年很热门的专业今年投档人数反而少,Excel表里的静态排名根本无法反映这种动态变化。第三,只有最终汇总名单,没有过程数据,学生如果想调整志愿顺序,又要从头再来一遍。

1.2 系统的功能边界

我们做这套系统时定了几个原则:不替代人工决策,只辅助决策;不做全自动填报,只做推荐和记录;不追求复杂算法,先保证数据可靠、逻辑透明。整体功能拆成了学生端和管理端两块:

学生端: 1. 注册登录,维护个人基础信息与各科成绩 2. 填写兴趣倾向标签(如编程、实验、写作) 3. 查看专业推荐列表,支持按综合评分排序、按专业类型筛选 4. 查看每个专业的匹配明细(成绩匹配分、兴趣匹配分、就业匹配分) 5. 提交意向专业,可调整排序后重新提交 管理端: 1. 专业信息维护(专业名称、所属院系、招生人数上限) 2. 专业画像配置(热度系数、就业率、深造率) 3. 权重参数配置(成绩权重、兴趣权重、就业权重) 4. 学生填报数据统计与导出

这套设计的核心价值在于:把以前散落在Excel里的成绩、意向、专业容量数据收拢到同一个业务闭环里,推荐结果不再是“某老师的主观经验”,而是基于权重模型的量化输出。

2. 技术选型复盘:SpringBoot + Vue 这对组合在这个项目里的分工逻辑

2.1 为什么没选更重的方案

项目启动时其实讨论过要不要上微服务、要不要引入工作流引擎。后来我们都否了。原因很直接:系统的用户规模撑死几千人同时在线,核心流程只有“填信息-看推荐-提交志愿”三个环节,用微服务拆分纯属给自己增加维护成本。SpringBoot单体应用就能把所有接口管理得清清楚楚,JVM堆内存给个1GB都绰绰有余。

选Vue而不是传统服务端模板,主要看重两点。一是Vue的组件化开发让页面复用变得容易,专业卡片、评分进度条、标签筛选器这些都是重复出现的UI块,抽成组件后在多个页面里直接引用。二是我们不需要每次点击都刷新整个页面,推荐列表的排序切换、筛选联动如果用服务端渲染来做,体验会很拖沓,Vue的双向绑定和计算属性天然适合这种交互密集型场景。

2.2 实际的项目分层结构

最终落地的项目结构是这样的,前端和后端完全分离,两个独立的代码仓库:

backend/ ├── src/main/java/com/univ/major/ │ ├── controller/ // 接口层,只做参数接收和结果封装 │ ├── service/ // 业务逻辑层,推荐评分核心在这里 │ ├── mapper/ // MyBatis-Plus持久层 │ ├── entity/ // 数据库实体 │ ├── config/ // 拦截器、跨域配置、权重配置 │ └── common/ // 统一返回值、异常处理、工具类 ├── src/main/resources/ │ ├── mapper/ // XML格式的复杂SQL │ └── application.yml └── pom.xml frontend/ ├── src/ │ ├── views/ // 页面级组件 │ ├── components/ // 通用组件(专业卡片、评分条等) │ ├── router/ // 路由配置 │ ├── store/ // Pinia/Vuex状态管理 │ ├── api/ // axios请求封装 │ └── utils/ // 格式化、鉴权工具 └── vite.config.ts

后端这层我坚持用MyBatis-Plus而不是JPA。原因是我在中小型管理项目中试过JPA,虽然CRUD写起来很爽,但一旦涉及多条件的动态查询,比如“按专业大类+分数范围+热度排序过滤”,JPA的方法命名要么变得越来越长,要么就得写原生SQL还是得用EntityManager。MyBatis-Plus的LambdaQueryWrapper在这种场景下就是降维打击,条件拼装直观,SQL也更容易排查。

3. 数据建模是这类系统的命门:从考生画像到专业画像的表设计

3.1 核心表结构与设计意图

专业选择系统表面上看是“推荐”,实际本质是“数据匹配”。学生侧有一组属性,专业侧有一组属性,系统要做的是计算两组属性之间的匹配程度。因此数据库设计的出发点就是把这两组属性字段化、指标化。

学生信息表:

CREATE TABLE student_profile ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(32) UNIQUE NOT NULL COMMENT '学号/考号', real_name VARCHAR(32) NOT NULL, gender TINYINT DEFAULT 0, total_score DECIMAL(6,1) NOT NULL COMMENT '高考总分/综合分', subject_scores JSON COMMENT '各科成绩,格式: {"math": 135, "phys": 88}', interest_tags VARCHAR(255) COMMENT '兴趣标签,逗号分隔,如:编程,阅读,实践', grade_year VARCHAR(8) COMMENT '届别,如2025', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_total_score (total_score) ) COMMENT '学生画像表';

专业信息表:

CREATE TABLE major_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, major_name VARCHAR(64) NOT NULL COMMENT '专业名称', category VARCHAR(32) COMMENT '专业大类,如:计算机类、临床医学类', department VARCHAR(64) COMMENT '所属院系', capacity INT DEFAULT 0 COMMENT '招生容量上限', heat_level DECIMAL(4,2) DEFAULT 0 COMMENT '热度系数,范围0-1', employment_rate DECIMAL(5,2) DEFAULT 0 COMMENT '就业率百分比', further_study_rate DECIMAL(5,2) DEFAULT 0 COMMENT '深造率百分比', avg_salary DECIMAL(8,1) DEFAULT 0 COMMENT '毕业起薪均值(元/月)', description TEXT COMMENT '专业介绍,用于前端展示', status TINYINT DEFAULT 1 COMMENT '1启用 0停用', KEY idx_category (category), KEY idx_heat (heat_level) ) COMMENT '专业画像表';

这里有个设计细节值得说明:subject_scores为什么用JSON字段而不是拆成一行列?在教学管理场景里,不同省份的考试科目组合差异极大,有的看物理,有的看历史,拆成固定列会导致大量空字段。JSON字段在MyBatis-Plus里可以直接映射成Map类型,序列化反序列化交给Jackson处理,取数逻辑在Java代码里解析反而更灵活。

3.2 填报记录与幂等约束

学生提交意向专业是并发敏感的操作,因为一旦多个学生同时对同一热门专业提交,数据库层面必须保证不超容量。填报记录表我设计了一个联合唯一索引来拦住重复提交:

CREATE TABLE major_application ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, major_id BIGINT NOT NULL, priority_rank INT DEFAULT 0 COMMENT '志愿顺序,1为第一志愿', match_score DECIMAL(6,3) DEFAULT 0 COMMENT '该专业的匹配评分', apply_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0 COMMENT '0待审核 1通过 2驳回', UNIQUE KEY uk_student_major_rank (student_id, priority_rank), KEY idx_major_status (major_id, status) ) COMMENT '专业填报记录表';

uk_student_major_rank这个唯一索引解决的正是重复提交的坑:同一个学生不可能对同一个志愿顺序提交两条记录,数据库层面直接把这种脏数据挡掉。我在开发过程中认真体会过这个设计的意义,后面会专门讲一次我在测试环境踩到的重复提交问题,就是一开始没建这个索引导致的。

3.3 数据来源与清洗链路

设计上说得再好,数据源不干净都是白搭。我们的数据主要来自三处:学生成绩从学校教务系统导出的Excel里批量导入,专业画像数据由各院系教学秘书填报维护,热度系数则对应用志愿填报平台的历史投档数据,取近两年的投档人数与计划人数的比值归一化。

导入这块我写了专门的import_service,流程是:上传Excel -> 用EasyExcel读流 -> 逐行校验 -> 错误行返回提示 -> 合法数据批量插入。校验规则包括学号不能重复、总分不能为负、各科分数不能超过单科满分等。第一次上线时我们忽略了某些高职单招班的学号格式,导致几千行数据校验失败,后来在校验逻辑里加了一个grade_year字段的白名单机制才解决。数据清洗的经验是:不要相信任何来源的Excel,字段类型、空值、格式错误全都得在导入层搞定。

4. 专业匹配推荐引擎:多因子综合评分的实现思路

4.1 评分因子怎么确定

推荐系统做到后面很容易陷入“模型越复杂越好”的误区,但在这类管理场景里,师生们更在乎的是“为什么推荐这个专业给我”。所以我们的评分模型没有用协同过滤,也没有用机器学习,而是采用了可解释性强的多因子加权评分模型。每个学生与每个专业之间的匹配分由四个维度的得分加权求和:

综合匹配分 = 成绩匹配分 * w1 + 兴趣匹配分 * w2 + 就业前景分 * w3 + 深造前景分 * w4

权重默认值是w1=0.45, w2=0.25, w3=0.20, w4=0.10,可以在管理端动态调整。权重之和恒等于1,调整后所有学生的推荐结果都会实时重算。这里有个业务上的考量:成绩匹配始终是最高权重,因为志愿填报首先要过门槛线,兴趣和就业权重相对居中是考虑到“不能完全只看分,也不能完全不顾就业”。

成绩匹配分的计算逻辑分两种情况:

// 场景A: 专业设置了科目门槛要求,如临床医学要求物理和化学 // 先判断是否满足门槛,不满足直接0分,满足则按总分线性归一化 public double calcScoreMatch(StudentProfile student, MajorInfo major) { String required = major.getRequiredSubjects(); if (StringUtils.hasText(required)) { Map<String, String> subjectScoreMap = parseSubjectScores(student.getSubjectScores()); for (String subject : required.split(",")) { if (subjectScoreMap.get(subject) == null) { return 0.0; } } } // 按近三年该专业录取平均分做基准,学生总分超过基准分数越高得分越高 double reference = major.getAvgAdmissionScore(); if (reference <= 0) reference = 500; double ratio = student.getTotalScore() / reference; return Math.min(ratio, 1.5) / 1.5 * 100.0; }

兴趣匹配分的核心是把学生的兴趣标签和专业画像里预设的标签集合做交集。比如说学生勾选了“编程、逻辑、实验”,计算机科学与技术专业的画像标签是“编程、算法、逻辑”,交集命中两个,那兴趣匹配分就是命中数除以专业标签总数的百分比,上限100分。这套逻辑虽然简单,但胜在直观,学生点开专业详情页能看到“你的兴趣标签与该专业匹配度极高/一般/较低”的提示,根本不需要懂任何算法。

4.2 评分计算代码的结构设计

推荐计算的接口不是一次性查全表然后内存里循环,那样数据量大时会拖垮接口。我们分了两个阶段:

@Service public class RecommendServiceImpl implements RecommendService { @Override public RecommendResult recommend(Long studentId, PageQuery query) { StudentProfile student = studentMapper.selectById(studentId); if (student == null) { throw new BizException("学生信息不存在"); } // 阶段一: 先用成绩门槛过滤掉不可能匹配的专业 List<MajorInfo> eligibleMajors = majorMapper.selectByEligibility(student.getTotalScore()); // 阶段二: 对过滤后的专业逐个打分 List<RecommendVO> voList = eligibleMajors.stream() .map(major -> buildRecommendVO(student, major)) .sorted(Comparator.comparing(RecommendVO::getMatchScore).reversed()) .collect(Collectors.toList()); return new RecommendResult(voList); } private RecommendVO buildRecommendVO(StudentProfile student, MajorInfo major) { double scoreMatch = calcScoreMatch(student, major); double interestMatch = calcInterestMatch(student, major); double careerMatch = calcCareerMatch(major); double studyMatch = calcStudyMatch(major); double finalScore = scoreMatch * scoreWeight + interestMatch * interestWeight + careerMatch * careerWeight + studyMatch * studyWeight; // 保留两位小数,避免浮点数误差 double result = Math.round(finalScore * 100.0) / 100.0; return RecommendVO.builder() .majorId(major.getId()) .majorName(major.getMajorName()) .category(major.getCategory()) .scoreMatch(formatScore(scoreMatch)) .interestMatch(formatScore(interestMatch)) .careerMatch(formatScore(careerMatch)) .studyMatch(formatScore(studyMatch)) .matchScore(result) .build(); } }

这个实现的优点是每个专业卡片的四个评分条都真实对应一段计算逻辑,前端展示时可以直接取数;缺点是每个学生的推荐计算都是循环里逐个算,当专业表有几千条记录时,会出现轻微的耗时。后来我们给major_info表加了一个status=1的启用状态筛选,同时将selectByEligibility里的总分门槛条件进一步下推到SQL层,实际接口耗时从700ms左右降到300ms以内,这个量级的用户体验完全可接受。

4.3 为什么不用协同过滤和基于内容推荐

可能有读者会问:这个评分模型看起来就是业务规则加权,哪来的“推荐算法”?说实话,在这个项目里我不建议上协同过滤。协同过滤需要足够多的“学生-专业-选择结果”行为数据,但我们的系统每次招生季才运行一次,产生的行为数据稀疏得可怜,同一批学生在下一届根本不重复出现,协同过滤的矩阵根本没法有效构建。基于内容的推荐和我们现在的评分模型本质上是同一种思路,区别只是我们把业务规则显式地变成了可配置的权重项。

对于用户规模不大、更新频率极低、且需要高解释性的场景,简单加权模型优于复杂模型。这是我在这个项目里学到的核心经验:别为了“看起来高科技”去选择不合适的算法。

5. 前端从流程到体验:Vue页面怎么把“选择”这件事做得实用

5.1 核心页面设计与交互流程

前端界面的关键不是好看,而是让用户在有限的操作路径里完成“了解自己-看匹配结果-提交志愿”这一整套动作。我们的路由设计如下:

/login 学生登录页 /student/profile 学生信息维护页 /major/recommend 专业推荐主页 /major/detail/:id 专业详情页 /application/submit 志愿填报页 /admin/major 专业管理页 /admin/config 权重配置页 /admin/stats 数据统计页

推荐主页是信息密度最高的页面,顶部是学生的总成绩和核心标签展示,中间是筛选条件区(专业大类下拉框、评分区间滑块、排序切换按钮),下面是推荐专业卡片流。

专业卡片我抽成了一个独立组件MajorCard.vue,结构大致是这样:

<template> <div class="major-card" @click="goDetail(major.majorId)"> <div class="major-card-header"> <span class="major-name">{{ major.majorName }}</span> <span class="major-score">{{ major.matchScore }}分</span> </div> <div class="score-row" v-for="item in scoreItems" :key="item.label"> <span>{{ item.label }}</span> <el-progress :percentage="Number(item.value)" :stroke-width="10" /> </div> <div class="major-tags"> <el-tag v-for="tag in major.tags" :key="tag" size="small">{{ tag }}</el-tag> </div> <el-button type="primary" size="small" @click.stop="addToApplication(major)"> 加入志愿 </el-button> </div> </template>

el-progress组件在这里不仅承担了视觉功能,还天然地传递了每个维度的得分高低,用户不用看具体数值就能快速感知“我在成绩上很匹配但兴趣上一般”。这个卡片组件在推荐页、专业详情页、志愿确认页被复用了三次,做到了一次开发多处使用。

5.2 筛选与排序的状态管理细节

筛选和排序的交互在Vue里有一个容易写乱的逻辑点:筛选条件变了以后,应该走前端本地过滤还是重新请求后端接口?我们的结论是:推荐列表的第一版数据由后端计算返回,前端拿到完整列表后,后续的大类筛选、区间筛选、排序切换全部在前端用计算属性完成,不重新发请求。

这样设计的理由很实际。后端算一次评分要对几百个专业做循环计算,高并发下扛不住反复点击筛选造成的重复计算;而第一版返回的数据已经包含所有专业和评分,前端本地做过滤的代价就是内存里一次filter遍历,百万级数据都不会有明显卡顿。本地计算属性写法:

const filteredMajors = computed(() => { let list = recommendList.value.slice() if (filterCategory.value !== '全部') { list = list.filter(item => item.category === filterCategory.value) } if (sortType.value === 'score') { list.sort((a, b) => b.matchScore - a.matchScore) } else if (sortType.value === 'heat') { list.sort((a, b) => b.heatLevel - a.heatLevel) } return list })

这里藏着一个小坑:sort是原地修改数组,如果直接用recommendList.value.sort(),会污染原始数据,之后再切回“默认排序”就失效了。所以每次排序前必须先list = recommendList.value.slice()拷贝一份。

5.3 axios封装与鉴权状态

前端的接口请求统一走api/request.js的封装,拦截器里做三件事:请求头追加Token、响应码401时跳转登录页、500时统一弹出错误提示。

http.interceptors.response.use( (res) => { const { code, data, message } = res.data if (code === 200) return data ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) }, (err) => { if (err.response?.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error('服务器开小差了,请稍后重试') return Promise.reject(err) } )

Token的有效期设置为12小时,正好覆盖一个完整的填报周期,到期后用户需要重新登录。为什么设这么短而不是一个月?因为专业选择系统涉及学生的个人信息和成绩数据,Token一旦泄露影响范围很大,短时令牌至少能降低风险暴露窗口。

6. 开发到上线踩过的坑:四个真实问题与完整排查过程

6.1 中文乱码与排序错乱:字符集和排序规则

第一个让我印象深刻的坑出现在部署测试环境后,往major_info表插入中文专业名称时,通过接口查询返回的全是问号。排查过程不算复杂:先检查了application.yml的数据库连接串,确认characterEncoding=utf8已经加了;后来登录MySQL看服务端编码,发现数据库实例的character_set_server是latin1,新建数据库时又没有显式指定字符集,导致表和字段都继承成了latin1。

修法是重建数据库并显式指定字符集和排序规则:

CREATE DATABASE major_select_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

同时所有表的DEFAULT CHARSET也统一改成utf8mb4。这里顺带说一个容易忽略的点:如果只改表级字符集而不改字段级字符集,VARCHAR字段还会保持原来的latin1,改完库后记得把所有字段一并ALTER掉。

中文排序错乱是另一个坑:按专业名称排序时,“信息管理”和“计算机”的先后顺序完全不符合拼音规则。原因是MySQL默认的utf8mb4_general_ci排序规则对中文是按Unicode编码排序的,不是按拼音。如果业务上有按拼音排序的需求,可以在查询时强制转拼音排序,但我们的系统里专业排序走的是评分热度,不是名称,所以这个坑只在管理端的专业列表里出现过,最后用前端排序解决了,没有继续深究数据库拼音排序的问题。

6.2 推荐接口慢排查:从N+1查询到索引缺失

开发阶段推荐接口在本地测试只有几百毫秒,部署到测试环境后同一个接口居然跑到2.8秒。我先看了日志里的SQL执行时间,发现大量单条SQL的时间都不到10ms,但SQL总条数非常多。再开启MyBatis-Plus的SQL日志输出,一眼看出问题:查一次专业列表只需要一条SQL,但在循环里给每个专业查院系名称、查标签集合,每查一个专业就多发几条SQL,几百个专业叠加起来就是上千条SQL,N+1问题实锤。

解决方式分两层。第一层是业务代码层面,大部分关联数据其实支持批量查询,比如院系名称这个字段完全可以冗余在major_info表里,不需要单独查院系表;标签集合数据结构上虽然是一对多,但直接在一次查询里用GROUP_CONCAT聚合返回:

SELECT m.id, m.major_name, GROUP_CONCAT(mt.tag_name) AS tag_names FROM major_info m LEFT JOIN major_tag mt ON m.id = mt.major_id GROUP BY m.id

第二层是SQL性能层面,SELECT * FROM major_info WHERE status = 1 AND total_score >= #{score}这类高频查询在测试环境的MySQL里走了全表扫描。给status字段加一个普通索引后,扫描行数从几千降到了几十,配合应用层的批量查询,整个推荐接口耗时终于从2.8秒降到0.4秒左右。

6.3 志愿提交的重复数据:唯一索引的威力

志愿填报页上线第一天,就有学生反馈“我明明只提交了一次,志愿列表里出现了两条一模一样的记录”。我看日志发现是前端的提交按钮没有做防重复处理,用户快速点了两下,后端接口被调用了两次。后端的insert语句因为没有唯一约束,两条记录都插入成功了。

这个问题从后端修复最可靠。我们给major_application表加上了uk_student_major_rank (student_id, priority_rank)联合唯一索引,同时在Service层加了一个判断逻辑,提交前先查询是否存在同学生的同志愿顺序记录,存在则抛异常“志愿顺序重复”。前端也加了提交中状态的按钮禁用:

const submitting = ref(false) async function handleSubmit() { if (submitting.value) return submitting.value = true try { await submitApplication() } finally { submitting.value = false } }

前后端双重防护之后,重复提交的问题彻底不再出现了。这个教训很实在:前端防抖从来都只是体验优化,数据库的唯一索引才是数据完整性的真防线。

6.4 前端路由刷新404:Nginx配置的try_files

第四个坑是典型的部署问题。前端使用Vue Router的history模式,在本地开发一切正常,部署到Nginx后,从推荐页点击进入详情页没有任何问题,但刷新详情页就直接跳到404页面。原因是history模式下,URL的路径是真实的服务端路径模拟出来的,Nginx默认找不到对应的静态文件,就返回404。

修复方案是给Nginx配置加一个try_files指令:

location / { root /opt/frontend/dist; index index.html; try_files $uri $uri/ /index.html; }

这条配置的意思是:当请求路径命中文件或目录时正常返回,否则一律回退到index.html,由Vue Router接管后续路由。加了这一行之后,任意前端路径刷新都能正确加载页面。

7. 部署环境与上线后的运行表现

7.1 部署架构与配置文件要点

整个系统部署在学校的内部服务器上,后端打jar包由systemd托管,前端构建后的静态文件交给Nginx。数据库单独一台实例,前期数据量不大,MySQL单机完全够用。

后端application.yml里有几个配置项值得单独说明:

server: port: 9080 tomcat: max-threads: 800 accept-count: 200 max-connections: 2000 spring: datasource: url: jdbc:mysql://10.0.0.10:3306/major_select_db?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai username: major_app password: xxxxxx hikari: maximum-pool-size: 50 minimum-idle: 10 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true

serverTimezone=Asia/Shanghai这个参数在测试环境踩过一次坑,不设置的话,MySQL驱动会把服务器时区当成UTC,导致所有存入的时间字段和本地时间差8小时。maximum-pool-size设置为50是基于压测结果定的,连接数再高的意义不大,反而会因为MySQL端的最大连接数限制产生额外等待。

7.2 压测结果与容量评估

上线前的压测我用了开源工具模拟300个并发用户执行“登录-查看推荐-提交志愿”这条核心路径。压测数据显示:后端接口的P95响应时间在820ms左右,Tomcat线程池没有打满,数据库的活跃连接数峰值在30左右,CPU使用率最高65%,整体容量充足。

压测过程中暴露了一个小问题:学生的兴趣标签更新接口没有加缓存,用户频繁改动标签时每次都会触发一次更新数据库和重算推荐,在并发场景下数据库写锁竞争明显。优化方式是把兴趣标签的读取改成Redis缓存,更新标签时先更新缓存再异步落库。但后来考虑到学生改标签的频率极低,最终没有引入Redis,而是把接口逻辑改成了“保存学生信息后需要手动触发推荐刷新”,把重算压力从高频操作中解耦出去。

7.3 数据备份与异常恢复

对这类管理系统来说,最重要的不是性能而是数据不丢。每天的凌晨2点,定时任务会对major_application表做增量备份,每周日凌晨做一次全量备份,备份文件保留最近4周。恢复演练做过一次,从备份文件恢复全库大概用了3分钟,业务上完全可接受。

另外在管理端做了一个操作审计日志功能,所有管理员对权重参数、专业容量的修改都会记录操作人、操作时间、修改前后的值。这个功能在中期配合检查时派上过大用场,能快速定位“是谁把某个专业的容量误改成了0”。虽然开发成本不高,但建议同类系统一定要保留。

8. 后续迭代方向与我对这套系统的复盘体会

系统上线运行了两个完整的招生季,教学秘书的工作量明显降下来了。回顾开发和维护过程,有几点体会比较深。

第一,这类系统的成败往往不取决于某个算法多高级,而取决于数据质量。如果学生成绩导入不准确,专业画像参数更新不及时,再好用的推荐算法都没有意义。所以后续迭代一定要加强数据源对接,最好能直接从教务系统里定时同步成绩数据,省去人工导Excel这个环节。

第二,评分权重参数化是我最满意的一个设计决策。开过一次专业建设委员会,有老师提出就业率权重太低,我们在管理端把w3从0.2调到0.3,所有学生的推荐列表实时刷新,现场讨论的效率非常高。这种“改配置就能改变业务结果”的能力,在实际场景里比任何炫技都实用。

第三,前端推荐页面后续可以加一个“对比模式”,允许学生勾选三到五个专业,在一个表格里横向对比专业介绍、历年分数线、就业数据和评分明细。这个功能在第一版里没做,原因是当前页面的信息密度已经足够,但实际学生用户的反馈里提到对比需求挺多,属于下一版优先考虑的功能。

最后再分享一个部署上的小习惯:每次升级版本时在Nginx那边做蓝绿目录切换,先构建新版本到dist_new目录,验证无误后用软链接切过去,出问题时一条命令就能回滚到上一个版本。这个操作在管理系统迭代频率不高的情况下尤其实用,能避免很多不必要的线上事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询