尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java+SpringBoot高考志愿填报智能辅助系统设计与实现

Java+SpringBoot高考志愿填报智能辅助系统设计与实现 简介高考志愿填报是考生和家长在短时间内面临的海量信息筛选与决策难题。如何用技术手段将零散的院校数据、专业组信息、历年录取位次高效整合并转换为可理解的推荐结果是这类业务系统的核心价值所在。SpringBoot作为Java生态中主流的微服务开发框架提供了快速构建数据接口、整合缓存与限流组件的能力。结合基于位次法和线差法的加权评分算法系统能够为考生提供“冲稳保”三档志愿推荐并通过缓存预热、Sentinel限流与降级策略应对出分日的流量高峰。从数据表结构设计、同位分换算、推荐引擎实现到Docker部署这套方案完整展示了如何用工程化手段解决真实业务场景中的复杂问题。文章围绕Java与SpringBoot技术栈完整呈现了一个高可用、可解释的高考志愿填报智能辅助系统的落地过程。 高考出分那晚我接到表姐的电话。她家孩子考了612分省排名一万一千多全家对着三本厚厚的招生计划翻到凌晨两点越翻越慌——去年这个位次能冲哪些学校哪些专业组里有坑服从调剂到底要不要勾电话那头的声音透着疲惫“你搞技术的能不能做个东西帮我们筛一筛”这就是我做这个JavaSpringBoot高考志愿填报智能辅助系统的起点。说实话网上的填报工具不少但要么数据陈旧要么逻辑黑箱要么卡在付费墙上。作为开发者我更想做一个自己能掌控、逻辑透明、数据可校验的系统。这篇文章不是毕业设计说明书是我从需求分析到功能落地、从算法设计到扛住流量压力的完整实录里面包含了完整的表结构设计、推荐算法代码、并发方案以及我踩过的坑。如果你正准备做类似的系统或者想用SpringBoot做一个真正有业务深度的项目这份内容可以帮你少走很多弯路。1. 出分后的那72小时志愿填报系统的业务真相1.1 志愿填报的核心痛点不是“选学校”而是“时间不够”先看现实。多数省份的志愿填报窗口只有3到5天考生和家长要面对的是全国2800多所普通高校、500多个本科专业、每个学校3到5个专业组、每个专业组6个专业志愿。一个普通考生需要从至少30到50个候选院校专业组里排出顺序还要考虑冲稳保梯度、专业冷热、地域偏好、选科限制、体检受限条件……这些信息分散在各省考试院官网、学校招生网、往年录取统计里靠人工整理根本做不完。这个系统的核心价值不是替考生做决定而是在72小时里把“信息检索概率预判志愿排序”这三件事的耗时从几十个小时压缩到几十分钟。我最初和几个高三班主任聊需求时他们提到一个很关键的细节大多数家长不是不知道要冲稳保而是不知道“怎么判断一所学校够不够稳”以及“冲的学校到底有多大可能性录取”。这说明系统不能只做一个查询工具必须内置一套可解释的推荐逻辑。1.2 智能辅助的边界不替用户决策只帮用户看清概率系统设计之初我给自己定了一条红线只做数据整理和概率预测不做最终决策。原因很简单志愿填报涉及大量无法量化的因素——比如某个学生就是想去某所大学的某个专业哪怕概率只有20%也愿意冲再比如家庭对地域有明确要求只考虑省内或特定城市。这些主观因素不应该被算法覆盖。所以系统里所有推荐结果都带有“冲”、“稳”、“保”标签和录取概率估算值用户可以根据自己的偏好组合调整。甚至在志愿表生成功能里我特意保留了手动拖拽排序的能力算法只负责给每个志愿项打分和标注风险等级最终的排列顺序由用户自己确认。这个设计在真实使用中反馈很好家长觉得系统“讲道理”而不是“替我做主”。1.3 我从需求调研中得出的功能优先级在动手写代码之前我花了一周时间调研需求对象包括高三班主任、往届考生、高校招生办老师。调研结果直接决定了功能模块的优先级排序第一优先级按分数/位次查询往年可报院校及专业录取概率。这是刚需中的刚需。第二优先级院校专业组详情页包含选科要求、招生计划数、学费、历年分数线折线图。第三优先级一键生成志愿表按冲稳保比例排序支持导出。第四优先级同分/同位次考生去向统计帮助用户了解“和我同分的人去了哪里”。第五优先级性格测评、专业解读、就业数据等延展内容作为增强模块后期再加。这个优先级排序很关键。很多同类系统一上来就堆功能结果核心查询逻辑反而做得粗糙。我见过一个竞争对手的产品首页放了生涯规划测评、专业薪资排行、院校VR全景但连“输入位次查学校”都要等五秒才出结果——这就是主次颠倒。2. 表结构设计与数据工程这个系统的地基比算法更花功夫2.1 核心表结构院校、专业、分数线、选科要求怎么关联高考志愿填报系统的数据模型看起来简单实际上有不少坑尤其是新高考改革后“院校专业组”的概念让传统的“学校-专业”二元结构彻底不够用了。我先给出一版经过实践验证的核心表设计。院校表school——存放学校基础信息CREATE TABLE school ( id bigint(20) NOT NULL AUTO_INCREMENT, school_code varchar(20) NOT NULL COMMENT 院校代码, school_name varchar(100) NOT NULL COMMENT 院校名称, province varchar(50) DEFAULT NULL COMMENT 所在省份, city varchar(50) DEFAULT NULL COMMENT 所在城市, level tinyint(4) DEFAULT NULL COMMENT 院校层次: 1-985, 2-211, 3-双一流, 4-普通本科, 5-专科, is_985 tinyint(1) DEFAULT 0, is_211 tinyint(1) DEFAULT 0, is_double_first_class tinyint(1) DEFAULT 0, school_type varchar(20) DEFAULT NULL COMMENT 综合/理工/师范/医药等, tags varchar(255) DEFAULT NULL COMMENT 标签逗号分隔, created_at datetime DEFAULT CURRENT_TIMESTAMP, updated_at datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_school_code (school_code), KEY idx_province (province), KEY idx_level (level) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT院校信息表;专业表major——注意这里存的是“专业目录”不区分学校CREATE TABLE major ( id bigint(20) NOT NULL AUTO_INCREMENT, major_code varchar(20) NOT NULL COMMENT 专业代码, major_name varchar(100) NOT NULL COMMENT 专业名称, category varchar(50) DEFAULT NULL COMMENT 学科门类, subcategory varchar(50) DEFAULT NULL COMMENT 专业类, degree_type varchar(20) DEFAULT NULL COMMENT 授予学位: 工学/理学/管理学等, study_years tinyint(4) DEFAULT 4 COMMENT 学制, PRIMARY KEY (id), UNIQUE KEY uk_major_code (major_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT专业目录表;院校专业组表school_major_group——新高考的核心概念。一个学校可以拆成多个专业组每个组有独立的选科要求、招生代码、招生计划CREATE TABLE school_major_group ( id bigint(20) NOT NULL AUTO_INCREMENT, school_id bigint(20) NOT NULL COMMENT 所属院校ID, group_code varchar(20) NOT NULL COMMENT 专业组代码如物理化学组, group_name varchar(100) DEFAULT NULL COMMENT 专业组名称, province varchar(20) NOT NULL COMMENT 招生省份, year int(11) NOT NULL COMMENT 招生年份, subject_requirement varchar(100) DEFAULT NULL COMMENT 选科要求如物理必选、物理化学, enrollment_count int(11) DEFAULT 0 COMMENT 招生计划数, min_score int(11) DEFAULT NULL COMMENT 最低录取分, min_score_rank int(11) DEFAULT NULL COMMENT 最低录取位次, avg_score int(11) DEFAULT NULL COMMENT 平均录取分, avg_score_rank int(11) DEFAULT NULL COMMENT 平均录取位次, PRIMARY KEY (id), KEY idx_school_year_province (school_id, year, province), KEY idx_year_rank (year, min_score_rank) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT院校专业组表;专业组内专业明细表group_majorCREATE TABLE group_major ( id bigint(20) NOT NULL AUTO_INCREMENT, group_id bigint(20) NOT NULL COMMENT 专业组ID, major_id bigint(20) NOT NULL COMMENT 专业ID, enrollment_count int(11) DEFAULT 0 COMMENT 该专业在组内的招生人数, tuition_fee decimal(10,2) DEFAULT NULL COMMENT 学费/年, remarks varchar(500) DEFAULT NULL COMMENT 备注如体检限制等, PRIMARY KEY (id), KEY idx_group_id (group_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT专业组-专业关联表;这里必须强调一个我踩过的坑最初我把“专业组”和“专业”混在一张表里用冗余字段拼装结果查询专业组列表时SQL写得很痛苦而且后面做“某一选科组合可报哪些专业组”的筛选时性能直接崩了。后来拆成四张表逻辑清晰了索引也能有效命中。做这种多维度关联的业务宁可多拆几张表也不要在初期图省事过度冗余。2.2 数据从哪来爬虫采集人工校对的双轨策略整个项目最耗时、最无聊、也最核心的工作不是写代码而是造数据。高考数据有几个特点分散各省考试院、各高校招生网、各教育类公众号、格式不统一有的给分数段、有的给位次段、有的干脆只给最低分、更新频繁每年6月出招生计划、7月出录取结果、部分批次还有征集志愿。我的做法是双轨制爬虫自动采集用Python写爬虫定时抓取各省考试院官网、阳光高考平台和部分高校招生网的数据解析成JSON存到临时目录。不同站点结构差异很大爬虫要按站点单独写解析器这块工作量至少在40%。人工校对补全爬下来的数据经常有格式错误、乱码、缺字段。我拉了两个志愿者配合校对重点核对院校代码、专业组代码、选科要求这三个最容易出错的字段。另外有相当一部分学校的数据在公开渠道根本查不到只能打电话问招办或查纸质版招生计划——这部分数据我会在系统里标注“人工录入”置信度标记为低。数据量上全国本科院校约1300所含民办平均每校3到5个专业组每个组6到10个专业三年数据累计大约20万条专业组记录、上百万条专业明细。这些数据全部导入MySQL后大约占2到3GB完全在单机可处理范围内。2.3 位次换算与同位分数据预处理中最容易出错的环节这里我要专门讲一个所有志愿填报系统都绕不开的技术点——同位分换算。不同年份的高考分数不能直接比较比如2019年理科600分可能排全省8000名2023年600分可能排到12000名。所以系统里所有分数比较都必须先换算成“同位分”——也就是把今年的分数按照位次映射到往年等价分数上。算法逻辑是找到考生今年分数对应的全省位次R查找目标年份的一分一段表找到位次最接近R的分数FF就是今年的分数在目标年份的“同位分”用F去跟该年份院校录取分数线比较。代码实现如下Service public class ScoreConvertService { Resource private RankSegmentMapper rankSegmentMapper; /** * 将指定分数按照位次映射到目标年份的同位分 * * param province 省份 * param subjectType 科类物理类/历史类/理科/文科 * param currentYear 当前年份 * param score 当前年份的分数 * param targetYear 目标年份 * return 目标年份的同位分 */ public Integer convertToEquivalentScore(String province, String subjectType, int currentYear, int score, int targetYear) { // 1. 查当前年份一分一段表找到该分数对应的位次 RankSegment currentSegment rankSegmentMapper.findByProvinceAndYearAndSubjectAndScore( province, currentYear, subjectType, score); if (currentSegment null) { throw new BusinessException(未找到当前年份一分一段数据); } int rank currentSegment.getRankStart(); // 2. 查目标年份的一分一段表找到位次最接近的分数 ListRankSegment targetSegments rankSegmentMapper.findByProvinceAndYearAndSubject( province, targetYear, subjectType); RankSegment nearest null; int minDiff Integer.MAX_VALUE; for (RankSegment seg : targetSegments) { // 取两个位次端点的中位数作为代表位次 int segRank (seg.getRankStart() seg.getRankEnd()) / 2; int diff Math.abs(segRank - rank); if (diff minDiff) { minDiff diff; nearest seg; } } return nearest null ? null : nearest.getScore(); } }这段代码逻辑并不复杂但有两个细节必须注意。第一一分一段表里的位次是分段区间比如600分有235人位次从1000到1234取中位数还是端点会影响最终结果特别是在分数密集的高分段这个误差可能直接导致冲稳判断翻转。我用的是取中点逻辑并在结果页展示“等效分范围”而不是单一值。第二新高考省份分为物理类和历史类两类的位次不可混用必须分开建表和查询否则会把物理类考生跟历史类考生拉进同一个排序池子结果完全失真。3. 冲稳保推荐算法从经验规则到可解释的评分模型3.1 为什么不用“深度学习”而用规则加权评分在做算法选型时我认真考虑过是否用机器学习模型来做录取概率预测。调研了一圈发现一个根本性障碍训练数据严重不足且分布不均衡。每个省份每年的录取数据只有几十万条而且“未被录取的考生数据”完全拿不到——你能查到的只有“谁被录取了”不知道“谁报了但没被录取”这种只知其一不知其二的数据没法做二分类训练。所以我最终选择了规则引擎加权评分方案。这套方案基于两个被多年验证的志愿填报经验法则位次法如果考生今年位次优于院校往年录取位次则录取概率大反之则小。线差法考生分数与批次控制线的差值对比院校往年录取分数与当年批次控制线的差值。每种方法单独都有局限。位次法在招生计划大幅变动时不准确线差法在题目难度波动大时失真。我的做法是把两个指标结合起来配合招生计划变化系数做加权最后输出一个百分制的“录取概率指数”。3.2 评分模型的具体设计对于一个院校专业组我计算四个维度的得分维度一位次匹配度权重40%/** * 位次匹配度计算 * 考生位次 院校往年录取位次时匹配度高 * 得分范围: 0-100 */ public double calcRankMatchScore(int candidateRank, int schoolMinRank, int schoolAvgRank) { // 用往年平均录取位次作为基准最低位次作为兜底 int baseRank schoolAvgRank 0 ? schoolAvgRank : schoolMinRank; if (candidateRank baseRank) { // 考生位次优于平均录取位次得分从60起步越优越高 double ratio (double) baseRank / candidateRank; return Math.min(100, 60 (ratio - 1) * 80); } else { // 考生位次低于平均录取位次得分随差距递减 double ratio (double) candidateRank / baseRank; return Math.max(0, 60 - (ratio - 1) * 120); } }维度二线差匹配度权重20%/** * 线差匹配度计算 * 考生线差 考生分数 - 批次线 * 院校线差 院校往年录取平均分 - 对应年份批次线 */ public double calcLineDiffScore(int candidateScore, int batchLine, int schoolAvgScore, int schoolYearBatchLine) { int candidateDiff candidateScore - batchLine; int schoolDiff schoolAvgScore - schoolYearBatchLine; if (candidateDiff schoolDiff) { return 60 Math.min(40, (candidateDiff - schoolDiff) * 10); } else { return Math.max(0, 60 - (schoolDiff - candidateDiff) * 15); } }维度三招生计划变化权重20%/** * 招生计划变化系数 * 今年扩招则录取概率上升缩招则下降 */ public double calcPlanChangeScore(int thisYearCount, int lastYearCount) { if (lastYearCount 0) { return 50; } double changeRatio (double) (thisYearCount - lastYearCount) / lastYearCount; // 扩招10%加5分缩招10%减5分封顶±20分 return 50 changeRatio * 50; }维度四专业热度系数权重20%专业热度来自系统内用户收藏次数、搜索次数、往年报考人数三者的加权归一化值。这个维度有点“用脚投票”的意思——热门专业确实会更难录取。数据量小的时候直接用静态标签代替数据积累后再切动态计算。最终的综合评分public RecommendationScore calcOverallScore(RecommendationContext ctx) { double rankScore calcRankMatchScore(ctx.getCandidateRank(), ctx.getSchoolMinRank(), ctx.getSchoolAvgRank()); double lineDiffScore calcLineDiffScore(ctx.getCandidateScore(), ctx.getBatchLine(), ctx.getSchoolAvgScore(), ctx.getSchoolYearBatchLine()); double planChangeScore calcPlanChangeScore(ctx.getThisYearCount(), ctx.getLastYearCount()); double hotScore ctx.getMajorHotScore(); double overall rankScore * 0.4 lineDiffScore * 0.2 planChangeScore * 0.2 hotScore * 0.2; String level; if (overall 60) { level 稳; } else if (overall 45) { level 冲; } else { level 保; } return new RecommendationScore(overall, level, rankScore, lineDiffScore, planChangeScore, hotScore); }注意这个等级的阈值设定是有意为之。传统经验里“冲”通常指录取概率偏低但有机会的学校“保”指几乎必然录取的学校。我的系统里60分以上算“稳”45到60分算“冲”45分以下算“保”——这里“保”的逻辑和直觉不同但实际是对的综合评分低往往是因为学校的往年录取位次远优于考生位次即考生成绩远超该校门槛所以反而安全。这也是很多初做志愿系统的开发者最容易搞反的地方。3.3 为什么每个推荐项都要解释给用户看做工程的人都知道模型再准用户不信就是零。志愿填报关乎孩子前途家长对系统的第一反应是“凭什么信你”。所以我的系统有个强制设计每个推荐结果的每个维度得分都必须显示出来并且附上文字解释。比如推荐结果卡片上会显示“您今年全省位次10532该校去年平均录取位次8293位次匹配度偏低”“该校今年招生计划80人比去年扩招14%录取概率有所上升”“该校去年平均线差38分您当前线差45分线差匹配度良好”这个设计还有一个意想不到的好处——它倒逼推荐算法必须保持逻辑简单透明不能搞成不可解释的黑箱。用户如果发现某条推荐不合理可以直接根据显示的子维度判断是哪个指标出了问题反馈给我修正。系统上线后我收到的大量真实反馈就是这样一点点把算法校准到合理范围的。4. 核心接口与SpringBoot工程实践4.1 项目结构与核心技术栈整个项目采用**SpringBoot 3.x MyBatis-Plus Redis MySQL Nacos可选**的组合。选SpringBoot 3.x是因为它基于Jakarta EE生态更新且自带GraalVM原生镜像支持后续如果要做启动优化有空间。MyBatis-Plus负责数据持久化它提供的LambdaQueryWrapper写条件查询非常顺手省去大量XML配置。工程按模块划分不是按层级划分gaokao-helper/ ├── gaokao-common // 通用工具、统一返回体、异常处理 ├── gaokao-admin // 管理后台数据导入、院校管理、用户管理 ├── gaokao-api // 用户端接口查询、推荐、志愿表 ├── gaokao-recommend // 推荐引擎评分算法、位次换算 ├── gaokao-scheduler // 定时任务数据同步、缓存预热 └── gaokao-web // 前端静态资源代理Vue打包产物这种模块化划分在真实项目里比简单的controller/service/mapper分层更好用因为推荐引擎和数据采集、业务接口是完全不同的关注点混在一起会互相拖累。特别是爬虫和定时任务这类IO密集型的代码不应该和用户请求处理放在同一个JVM进程里抢资源——虽然我目前是单体部署但模块边界清晰后续拆微服务时只需要把模块直接抽出即可。4.2 核心接口设计查询、推荐、志愿表三大链路接口一位次查询院校GET /api/v1/schools/recommend 参数: province: 省份 subjectType: 选科类型physics/history score: 分数 rank: 位次可选不传则根据分数推断 batchLine: 批次线 page: 页码 size: 每页数量 返回: { data: { total: 235, list: [{ schoolId: 1234, schoolName: 某理工大学, groupCode: 003, groupName: 物理化学专业组, recommendLevel: 冲, recommendScore: 55.3, minScoreLastYear: 598, minRankLastYear: 8821, matchReasons: [...], riskTags: [单科成绩要求] }] } }接口二生成志愿表POST /api/v1/volunteer-plan/generate 请求体: { province: 广东省, subjectType: physics, score: 612, rank: 10532, batchLine: 532, preferredCities: [广州, 深圳, 杭州], excludeMajors: [护理学, 材料化学], strategy: standard, // standard: 冲20%稳50%保30% planSize: 45 } 返回: { planId: 88213, groups: [{ part: 冲, items: [{...}] }, { part: 稳, items: [{...}] }, { part: 保, items: [{...}] }] }接口三志愿表详情与调整GET /api/v1/volunteer-plan/{planId} PUT /api/v1/volunteer-plan/{planId}/items/{itemId}/order这个调整接口是给用户手动拖拽排序用的后端每次调整都重新计算一次当前志愿表整体的“梯度合理性”。如果用户把三个“冲”的志愿排到了“保”的后面系统会给出提示“建议将保底志愿放在最后避免被提前锁定。”这个校验规则很简单但很能体现系统的“辅助”属性。4.3 缓存设计别让一分一段表成为性能瓶颈系统里最热的数据是一分一段表和院校专业组列表。一分一段表每省每年只有几百条记录但接口调用频率极高——每当用户输入一个分数系统就要查一次一分一段表进行位次换算如果加上跨年份同位分换算可能要查多张表。我的方案是启动时全量加载一分一段表到Rediskey设计为rankSegment:{province}:{year}:{subjectType}value直接用JSON字符串存整表。换算时从Redis取整表到JVM内存做二分查找而不是逐条查数据库。实测下来单次换算从平均80ms降到了5ms以下。对于院校专业组搜索结果用SpringBoot的Cacheable做二级缓存Service public class SchoolSearchService { Cacheable(value schoolRecommend, key #province : #subjectType : #score, unless #result null || #result.total 0) public PageResultSchoolRecommendVO recommendSchools(String province, String subjectType, int score, int page, int size) { // 复杂查询逻辑 } }缓存过期时间设为10分钟因为高考出分头几天分数线数据不会变10分钟足够保证数据一致性同时避免缓存风暴。另外所有院校专业组的详情页都做了静态化处理——第一次访问时生成HTML静态页面存到Nginx服务器后续直接走静态文件不再打到Java应用。这个优化在出分后第三天、流量最高峰时效果显著应用服务器CPU占用率直接降了60%。4.4 SpringBoot中的定时任务与数据同步志愿填报系统的数据不是一次性导入就完事的。出分前各省会陆续发布招生计划和批次线出分后各批次录取结果陆续公布部分省份还有征集志愿。这就要求系统具备自动化数据同步能力。我在gaokao-scheduler模块里用Scheduled注解实现了几个定时任务每天凌晨2点增量爬取各省考试院公告解析新增或变更的招生计划每小时检查高校官网的录取分数线发布情况有更新则推送通知到管理员微信通过Server酱每15分钟从Redis拉取用户搜索日志统计专业热度更新专业热度评分。这里有一个容易踩的坑Scheduled默认是单线程执行的如果某个任务执行时间过长会阻塞其他定时任务。我的做法是给不同的任务自定义线程池Configuration public class ScheduleConfig implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(Executors.newScheduledThreadPool(8)); } }另外如果任务执行过程中抛异常SpringBoot默认会记录日志但不会重试。对于爬虫任务这种失败是家常便饭——目标网站偶尔超时、反爬策略变化、页面结构改版。所以我在爬虫任务里加了内部重试机制和失败告警连续失败3次会发邮件通知避免数据静默缺失。5. 出分当天的流量冲击高并发与安全防护实战5.1 高考出分日的流量画像先说数据这个系统在非出分日QPS大概稳定在50到100之间每天UV一两千压力不大。但高考出分当天流量曲线是陡峭的垂直拉升——很多省份出分时间是晚上6点到8点考生和家长会在一个小时内集中涌入。我监测到的峰值QPS是平时的80到100倍换句话说如果平时最大QPS是100出分瞬间可能到8000到10000。这个流量画像决定了架构设计的基本思路不能按平时的量来设计必须有“高峰期预案”。但也不可能按峰值10000去无限堆机器——成本不允许也没必要。合理的策略是“削峰限流降级”三板斧。5.2 Redis缓存预热的实战方案出分前两小时系统会执行一个缓存预热任务Component public class CachePreheatJob { Resource private StringRedisTemplate stringRedisTemplate; Resource private SchoolRecommendMapper schoolRecommendMapper; /** * 出分日前2小时开始预热每10分钟执行一次 * 预热内容包括: 一分一段表、热门院校专业组列表、前一年录取数据 */ Scheduled(cron 0 0/10 16-20 * * ?) public void preheat() { // 1. 预热一分一段表 ListProvince provinces loadAllProvinces(); for (Province p : provinces) { for (String subject : new String[]{physics, history}) { for (int year currentYear - 3; year currentYear; year) { String key buildRankSegmentKey(p.getCode(), year, subject); if (!stringRedisTemplate.hasKey(key)) { ListRankSegment segments rankSegmentMapper.findAllByProvinceYearSubject( p.getCode(), year, subject); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(segments), 24, TimeUnit.HOURS); } } } } // 2. 预热过去3年热度Top500的院校专业组详情 ListLong hotSchoolIds schoolRecommendMapper.selectHotSchoolIds(500); for (Long schoolId : hotSchoolIds) { String key buildSchoolDetailKey(schoolId); if (!stringRedisTemplate.hasKey(key)) { SchoolDetailVO detail schoolDetailService.getDetail(schoolId); stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(detail), 6, TimeUnit.HOURS); } } } }预热的时间点选择很关键。太早预热数据可能过期太晚预热来不及生效。我实测下来出分前4小时开始预热、每10分钟跑一轮的效果最好。还要注意预热时的加载顺序先预热一分一段表体量小、被依赖多再预热院校详情体量大、单次查询重最后预热推荐接口的聚合结果。另一个容易遗漏的点是连接池大小的估算。预热任务本身就是高频IO操作如果数据库连接池配置太小预热任务会反过来拖垮正常业务。我当时的方案是把预热任务单独用一个独立的DruidDataSource最大连接数30与业务连接池隔离互不干扰。5.3 限流降级Sentinel在项目中的落地引入Sentinel做限流是出分日压测之后做出的决定。压测数据很难看单机QPS超过800时Tomcat线程池开始排队接口响应时间从200ms飙到3s然后雪崩——线程池满、数据库连接耗尽、应用无响应。配置Sentinel的核心是三个维度spring: cloud: sentinel: transport: dashboard: localhost:8080 datasource: ds1: nacos: server-addr: localhost:8848 dataId: gaokao-sentinel-rules groupId: DEFAULT_GROUP rule-type: flow规则文件里定义了[ { resource: /api/v1/schools/recommend, grade: 1, count: 1000, controlBehavior: 1, warmUpPeriodSec: 60 }, { resource: /api/v1/volunteer-plan/generate, grade: 1, count: 300, controlBehavior: 2, maxQueueingTimeMs: 500 }, { resource: /api/v1/rank-convert, grade: 0, count: 1 } ]其中warmUpPeriodSec: 60表示60秒的预热时间——这是为了平滑流量从平时到峰值的陡增避免刚刚限流阈值从100升到1000时把系统打崩。generate接口用的controlBehavior: 2是排队等待模式因为志愿表生成是重操作需要跑完整推荐算法排队比直接拒绝更友好。grade: 0那一条是关联限流当/api/v1/rank-convert达到1 QPS时触发对应接口的限流。压测时我特别验证了一个场景限流触发时用户端看到什么。最差的做法是直接返回“系统繁忙”。我的降级策略是返回缓存结果——哪怕已经过期了也至少有内容可看同时在页面上提示“数据更新于X分钟前”。对于志愿表生成这样的写操作降级方案是引导用户稍后重试而不是给一个残次品结果。5.4 安全防护XSS、SQL注入与数据授权教育类系统涉及未成年人数据安全标准不能放松。SpringBoot项目里我的实践是XSS防护使用Jsoup对用户输入的富文本内容进行过滤。不是只在前端过滤后端必须再做一层——攻击者完全可以绕过前端直接调接口。我在全局过滤器里对HttpServletRequest的输入流做二次包装用Jsoup清理HTML标签和事件属性。Component public class XssFilter implements Filter { private final XssCleaner xssCleaner new XssCleaner(); Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssHttpServletRequestWrapper((HttpServletRequest) request), response); } class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { // 重写 getParameterValues / getInputStream / getReader // 统一调用 xssCleaner.clean() } }SQL注入使用MyBatis-Plus时如果按官方推荐的方式用#{}占位符天然防注入。但有些复杂的动态SQL我会自己拼接字符串这时候就必须格外小心。我的底线是所有用户输入只能出现在#{}里绝不直接拼接进SQL。另外MyBatis-Plus的QueryWrapper在某些版本有apply方法拼接自定义SQL的坑尽量不要让用户参数进入apply。数据授权考生查询自己的志愿表必须校验登录态和资源所有权。我用的Spring Security JWT方案JWT里带上userId和过期时间每个写操作都从Token里解析userId再跟资源表中的ownerId比对。有一个典型的越权场景是用户A通过修改接口参数中的planId尝试读取或修改用户B的志愿表。解决方案就是不能信任前端传的userId必须从登录态解析。6. Docker部署与真实世界的坑6.1 部署架构单机Docker Compose足够虽然系统号称“高并发”但实际上真正的高流量窗口一年只有几天为了这几天上K8s集群纯属浪费。我的部署方案是一台4核8G的云服务器Docker Compose把MySQL、Redis、Nginx、Java应用分别跑在独立容器里。version: 3.8 services: mysql: image: mysql:8.0 container_name: gaokao-mysql environment: MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} MYSQL_DATABASE: gaokao ports: - 3306:3306 volumes: - mysql_data:/var/lib/mysql - ./init-sql:/docker-entrypoint-initdb.d command: --character-set-serverutf8mb4 --collation-serverutf8mb4_unicode_ci restart: always redis: image: redis:7.0 container_name: gaokao-redis command: redis-server --requirepass ${REDIS_PASSWORD} ports: - 6379:6379 volumes: - redis_data:/data restart: always app: build: . container_name: gaokao-app depends_on: - mysql - redis environment: SPRING_PROFILES_ACTIVE: prod DB_HOST: mysql DB_PORT: 3306 REDIS_HOST: redis REDIS_PORT: 6379 ports: - 8080:8080 restart: always nginx: image: nginx:1.25 container_name: gaokao-nginx ports: - 80:80 - 443:443 volumes: - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/static:/usr/share/nginx/html - ./nginx/ssl:/etc/nginx/ssl depends_on: - app restart: always volumes: mysql_data: redis_data:这个架构的后端扩容方式是“应用单节点Docker镜像复制”如果第二年流量进一步增长可以在一周内改成应用双节点Nginx负载均衡。但第一年这个配置足够覆盖出分日的流量顶峰前提是缓存和限流做得好。6.2 我踩过的四个部署坑坑一Docker容器时区问题。SpringBoot应用在Docker里默认使用UTC时区而高考出分、批次线公布都跟北京时间强相关。如果容器时区不对定时任务会在凌晨执行所有时间戳展示都是错的。解决方案是在Dockerfile里显式设置FROM eclipse-temurin:17-jre ENV TZAsia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime echo $TZ /etc/timezone坑二Java应用在容器内内存溢出。我用eclipse-temurin:17-jre基础镜像默认JVM堆内存可能跟宿主机内存的25%绑定但因为Docker容器有memory限制JVM可能拿不到它以为能拿到的内存直接报java.lang.OutOfMemoryError: Insufficient memory。解决ENTRYPOINT [java, -Xms1024m, -Xmx2048m, -XX:MaxMetaspaceSize512m, -jar, /app.jar]JVM参数直接写在启动命令行里不用交给容器自动感知。说穿了就是容器环境下的JVM不要依赖默认值一切显式指定。坑三数据库连接池初期配置太小。我用的HikariCP默认最大连接池大小是10对平时够用但出分日预热任务正常请求同时跑瞬间就不够了。后面改成spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000注意connection-timeout不能太大否则高峰时大量线程会排队等数据库连接拖垮整个应用。3秒拿不到连接直接报错让Sentinel限流兜底比无限等待好。坑四Nginx静态资源缓存与动态接口的冲突。我把前端Vue打包产物放到Nginx静态目录但index.html用nginx -g daemon off启动后Vue路由的history模式需要配置try_files。加上location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://app:8080; 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刷新页面到/plan/88213会直接404。这个坑开发环境遇不到因为Vue dev server自带history fallback只有部署到Nginx才暴露。6.3 日志与监控出分日盯什么出分日当天我不会花太多时间看业务日志而是看几个核心指标JVM堆内存曲线如果堆内存持续上涨且GC频繁大概率是缓存键设计有问题或存在内存泄漏。Tomcat活跃线程数峰值时线程数不能长时间占满默认的200个否则说明有慢查询或外部依赖阻塞。MySQL慢查询日志压测后发现大部分慢查询集中在school_major_group表上原因是按年份、省份、位次区间查询时复合索引没有覆盖全。后来调整了索引顺序为(year, province, min_score_rank)慢查询数量降了一个数量级。监控工具用的是SpringBoot Actuator Prometheus Grafana三件套Docker Compose里加一个prometheus容器和grafana容器。出分日当天我把Grafana的告警规则设置成JVM内存使用超过85%持续5分钟告警接口错误率超过5%持续3分钟告警TPS从峰值陡降50%以上告警可能发生雪崩其中第三个规则最有用。TPS陡降通常比错误率上升更早暴露问题——限流生效时错误率可能不高但吞吐量已经断崖式下跌。7. 这个系统的边界与后续演进思路7.1 单一数据源的局限与应对坦率说这个系统最薄弱的环节是数据覆盖和时效性。公开渠道能拿到的录取数据通常只有各校各专业组的“最低分/最低位次”拿不到“录取人数分布”、“专业录取分数中位数”、“调剂比例”这类更精细的分布数据。而志愿填报的风险恰恰藏在分布里——某个专业组最低位次是8000但录取人数里90%的位次都在5000以内只有最后1个名额录到8000位这种情况按往年最低位次判断“接近”其实是很危险的。我目前的做法是在专业组详情页额外展示“近三年录取位次稳定性”指标如果三年位次波动超过30%就标记“波动较大建议谨慎参考”。同时鼓励用户多参考“平均录取位次”而不是“最低录取位次”。7.2 数据积累与智能迭代这个系统有一个其他工具不具备的优势——用户的行为数据可以反哺推荐算法。用户搜索院校、收藏专业、调整志愿顺序、最终确认提交这些行为都是极其宝贵的信号。比如大量高分考生最终选择了某校某专业说明该专业实际热度高于静态标签用户把某个“稳”档院校反复往前移说明这类院校在某些隐性维度地域、宿舍条件、保研率上更受欢迎提交志愿表时用户删除最多的专业可能存在“看起来热但实地了解后不满意”的信息差。这些行为数据的积累逻辑在系统里已经预留每个操作都会上报埋点存入操作日志表。算法侧需要做的只是定期离线统计更新“专业热度系数”和“院校好感度”形成动态权重。这比任何静态爬取的数据都更能反映真实报考偏好。7.3 想做的三个后续方向如果时间允许我计划在下一版中做三件事第一多用户志愿表对比。同一个分数段的好友之间对比志愿表帮助发现信息盲区。这个功能要做匿名化和隐私保护技术上不难难在产品设计。第二录取结果回溯校准。每年录取结束后把系统推荐结果与实际录取结果做对照分析用真实数据校准评分模型参数。这需要投入人力维护数据更新但这是系统“越用越准”的唯一路径。第三适合普通开发者的算法包。把冲稳保推荐算法、位次换算法、同位分计算法封装成独立的Java SDK让更多有数据源的教育机构能够快速接入而不是每家都从零造轮子。不过我始终记得做这个项目最初的定位——它不是用来替代任何人做决定的“智能大脑”而是帮百万考生家庭在72小时里把信息查清楚、把概率算明白的“信息助手”。这个边界守住了系统的价值就不会跑偏。如果这个项目给你带来了启发或者你做类似系统时遇到了具体问题欢迎在评论区交流。数据造数、算法实现、部署维护每一个环节都有无数可以优化的细节也是这类“小而深”的项目最锻炼人的地方。本文还有配套的精品资源点击获取
返回列表