公务员考试报名系统这种东西,听起来就是个典型的 Java Web 练手项目,但你真去做了就会发现,它比想象中复杂得多。我接这个项目的时候,客户给的需求只有一句话:“做一个能报名、能考试的系统”,可等我把需求梳理完,发现背后牵扯到角色权限、海量题库管理、在线组卷、防作弊策略、成绩统计这些硬骨头,每一个都能单独拎出来写一篇论文。这篇文章我就把整个设计过程和踩坑经历全部撸一遍,从需求建模到数据库设计,从核心代码实现到最终部署,给那些准备做类似系统或者正在做毕设的朋友一个可以完整复现的参考。
1. 需求没想清楚就动手,返工是必然的
很多人在拿到这类系统需求时,第一反应就是建表、写接口,结果做到一半发现业务逻辑根本对不上,只能推倒重来。我这次学乖了,先花了一周时间把业务流程走了一遍,把所有的角色、动作、状态都画出来,确认没问题了才开始敲代码。
1.1 公务员考试系统的角色模型拆解
公务员考试系统绝对不是“管理员 + 考生”这么简单的双角色模型。以我这次做的系统为例,角色至少有四类:
- 超级管理员:负责系统配置、管理员账号管理、数据统计,一般不会直接操作业务数据。
- 业务管理员:负责科目管理、题目维护、试卷配置、考试场次发布、成绩复核。这个角色是日常工作最多的。
- 考生:注册、登录、报名考试、在线答题、查看成绩和错题解析。考生又分成两种:社会考生和应届生,部分场次对考生类型有报名限制。
- 阅卷员:主观题人工评阅,按题目分配阅卷任务,阅卷过程要避免看到考生身份信息。
这一点在设计中非常关键——如果你一开始只设计了用户表加角色字段,后面要扩展出来阅卷员、多级管理员的权限体系,改动成本非常高。我在刚开始就用了 RBAC(基于角色的访问控制)模型,用户表和角色表分离,再通过用户角色关联表做多对多绑定,这样后面无论加什么新角色,只需要在角色表里插入一条新数据再分配菜单权限,不用改代码结构。
1.2 系统核心业务流程梳理
除了角色,业务流程也必须先从全局走一遍。公务员考试和普通在线考试最大的不同在于:它有严格的报名资格审核环节,需要上传证明材料、缴纳考试费用、确认报名信息,之后还有准考证打印、笔试、成绩公布、面试资格复审、体检政审等一系列环节。整个系统的核心业务链是这样的:
- 管理员发布考试公告和职位表
- 考生注册并完善个人信息(含学历、专业、工作经历等)
- 考生选择报考职位并提交资格审查材料
- 管理员审核考生资格,审核通过后考生缴费
- 管理员编排考场和准考证号,系统自动生成准考证
- 考生在线参加笔试(客观题+主观题)
- 客观题自动判分,主观题分配阅卷员人工判分
- 系统合成成绩并开放查询
这个流程里最容易出问题的是第4步和第6步:资格审查的时限要求很严格,报名截止后不能再提交材料;而在线考试环节又要防止超时交卷、断网重连等情况。这两个细节我在后面单独讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型不是越新越好,能扛住业务才是硬道理
技术选型这一步,我其实纠结了很久。一开始我想直接用 Spring Cloud 那套微服务架构,但仔细评估后就放弃了——公务员考试系统的用户量虽然可能很大,但它属于典型的“高并发短时突发”场景,高峰期主要集中在报名公告发布后的三天和考试当天,平时系统几乎没什么压力。对这种场景,微服务的分布式复杂度反而成了负担。
2.1 后端架构:Spring Boot 3 + MyBatis-Plus 的组合逻辑
最终我选择了 Spring Boot 3 + MyBatis-Plus + MySQL 8.0 + Redis 这套组合。理由很简单:Spring Boot 3 的生态成熟、社区资料多,遇到问题容易找到解决方案;MyBatis-Plus 相比 MyBatis 原生框架,提供了代码生成器、分页插件、条件构造器这些基础设施,能少写至少30%的重复 CRUD 代码。
项目结构上我采用了经典的分层架构,但针对考试系统做过调整:
code复制com.exam
├── common // 通用工具类、统一返回结果、异常处理
├── config // 配置类:Redis、拦截器、CORS、WebMVC
├── controller // 接口层:负责参数校验和结果封装
├── service // 业务逻辑层:核心事务和业务规则
├── mapper // 数据访问层:MyBatis-Plus 接口
├── entity // 数据库实体
├── dto // 前端交互数据传输对象
├── vo // 视图对象:组合和展示数据
└── utils // 工具类:JWT、导出Excel、加密等
有一点值得强调:我在实际项目里会给每个请求都定义独立的 DTO(数据传输对象),而不是直接复用实体类。比如创建题目时前端传来的字段是 questionContent、optionA 这种,但数据库里存的是 content、option_a,如果直接拿实体类接收前端参数,字段名就会很混乱。DTO 做一层隔离后,后续一旦数据库表结构调整,不会影响到接口层的参数协议。
2.2 前端选型和前后端分离的接口约定
前端我用了 Vue 3 + Element Plus + Vite。选 Element Plus 的原因很简单:它的表格、表单、树形控件、上传组件都非常适合后台管理类系统,能极大压缩开发时间。这里我不建议为了炫技去用 React 或者 Angular——考试系统最重要的是稳定和易维护,而不是框架的新颖度。
前后端通信的接口约定我在这里统一说明一下,因为它直接影响到后端的统一返回格式设计。我定义了一个泛型返回体:
java复制@Data
public class Result<T> {
private Integer code; // 200成功,其他为失败
private String message; // 提示信息
private T data; // 数据负载
public static <T> Result<T> success(T data) {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
}
这个返回体前端会做一个 axios 响应拦截器统一处理,只要 code 字段等于 200 才走正常逻辑,其他情况直接弹出 message 提示。这样做的好处是:后端任何异常都能被前端无缝捕获,不用每个接口都写 try-catch。
3. 数据库设计:考试系统的核心是表之间的关系
我觉得数据库设计是整个系统最关键也最容易被低估的部分。很多初学者习惯把所有字段塞进一个大表里,比如把题目内容、选项、答案、解析都放在一张 question 表里,看起来简单粗暴,但实际运营和维护时会非常痛苦。公务员考试系统的题目是会根据考试大纲不断变化的,而且题目类型有单选、多选、判断、案例分析、公文写作、申论等,数据结构差异很大。
3.1 题库模块的表结构设计
题库相关的表我拆成了四张:
- question_bank:题库表,用于区分行测、申论、公共基础等不同科目
- question:题目主表,存储题目通用字段
- question_option:题目选项表,一对多关联
- question_answer:题目答案表,一题一条
主表 question 的关键字段如下:
code复制id:主键
bank_id:所属题库ID
type:题目类型(1单选 2多选 3判断 4简答 5案例分析 6申论)
content:题干内容(TEXT类型)
analysis:答案解析(TEXT类型)
difficulty:难度等级(1-5)
score:默认分值
creator_id:创建人
create_time:创建时间
status:状态(0草稿 1已发布 2已归档)
这里有个设计习惯我想单独说一下:我不推荐把题目内容和选项直接放在一个字段里用分隔符拼接。比如有的教程会让你在 content 字段里写 "题目内容|||A.选项A|||B.选项B" 这种方式。程序员写起来确实方便,但一旦你要改某个选项的文字、统计某个选项被选择的次数、做按选项维度的试卷分析时,这种结构就完全没法用。数据库字段设计要考虑到未来的检索和分析需求,不能只图一时方便。
3.2 试卷模块的组卷策略存储
关于试卷,我一开始想得很简单:一张 paper 表加一张 paper_question 关联表,存题目ID和分数就行了。但真正做下去才发现不够——考试要支持随机组卷,即每个人的试卷题目顺序不同、甚至题目本身不同(防止相邻考生抄袭)。所以要额外考虑“试卷模板”和“已生成试卷”的分离设计。
我采用了三张表:
- paper:试卷模板表,存储试卷的基本信息(试卷名、总分、时长、适用考试场次ID)
- paper_rule:组卷规则表,存储每个题型的抽题数量和分值规则(如单选题抽20题,每题1分)
- exam_paper:考试场次的实际试卷实例表,这个表在考试发布当天根据规则生成,每名考生生成一份独立的试卷,题目的顺序由乱序算法打乱
实际试卷实例表的结构是这样的:
code复制id
exam_id:考试场次ID
user_id:考生ID
paper_id:对应的试卷模板ID
question_ids:按照实际顺序排列的题目ID集合(JSON数组)
status:0未开始 1答题中 2已完成
start_time:开考时间
end_time:交卷时间
你可能觉得 question_ids 存成 JSON 数组不太规范,但实际业务需求就是要求快速加载整张试卷,如果用另一张关联表逐行读取题目再拼装,接口响应会慢很多。这种反范式设计在“性能优先”的场景下是合理的,我的经验是:读多写少且读取时以整单为单位的业务,直接把明细冗余在订单表里,比每次去 join 明细表快得多。
3.3 报名审核模块的表设计
考试系统最绕不开的就是报名模块,因为公务员考试报名的实质是“职位-考生”的匹配关系。我设计了这几张核心表:
- position:职位表,包含招录单位、岗位名称、招录人数、学历要求、专业要求、基层工作年限要求等
- recruitment_plan:招录计划表,一场考试对应多个职位
- application:报名申请表,记录考生ID、职位ID、状态、提交时间
- application_material:报名材料表,存储考生上传的证明文件地址(学历证明、身份证扫描件等)
在报名申请表上,状态流转是比较复杂的:
code复制0-草稿:考生填了一半,还没提交
1-待审核:已提交,等待管理员审核
2-已通过:资格审核通过,进入缴费环节
3-已拒绝:审核未通过,管理员填写拒绝原因
4-已缴费:完成缴费
5-已退费:报名结束后主动申请退费
6-已取消:考试场次取消或考生主动取消报名
这个状态机在代码里我要反复约束,不能出现“待审核直接跳到已缴费”这种非法流转。我建议在 Service 层做一个专门的状态流转校验方法,每个状态变更都必须走该方法,在方法里判断当前状态和目标状态之间是否合法,避免后续维护人员绕过校验随意改状态。
还有一个容易被忽视的细节:公务员考试规定“报考人员只能选择一个职位报名”,所以在提交报名申请的时候,必须做唯一性校验。我在 application 表上加了 (user_id, recruitment_plan_id) 的唯一索引,同时还要在 Redis 里做一个分布式锁防止并发场景下同一考生重复提交。单机环境下用 synchronized 也行,但万一以后要做集群部署,Redis 锁不需要改代码。
4. 核心模块的实现逻辑与关键代码
数据库设计好后,就进入具体的编码实现了。这一章我重点讲几个容易踩坑的模块:在线考试答题、组卷算法、防作弊策略、成绩统计和导出。这几个模块如果写不好,系统在真实考试时大概率会出事故。
4.1 在线考试答题:时间控制和断点续答的细节
在线考试和普通的 CRUD 接口有个显著区别——答题过程是长事务,而且对时间精度要求很高。考试开始时,系统记录 start_time,同时 Redis 里存一个倒计时 Key,过期时间就是考试时长。考生每提交一次答案(或者前端定时每5秒)调用一次“保存答题进度”接口,把答题明细写入 answer_record 表。
这里的关键是防超时。我在后端做双重判断:交卷接口首先比较服务器当前时间与考试记录里的截止时间,如果当前时间晚于截止时间,直接拒绝交卷并自动强制交卷;另外,在 Redis 里维护一个倒计时,每次答题接口调用前先检查这个 Key 是否还存在,如果已经过期则自动把考试成绩归档。
java复制@Override
@Transactional(rollbackFor = Exception.class)
public SubmitResult submitExam(SubmitExamRequest request) {
// 1. 校验当前时间是否超过截止时间
ExamRecord examRecord = examRecordMapper.selectById(request.getExamRecordId());
if (examRecord.getEndTime().isBefore(LocalDateTime.now())) {
throw new BizException("考试时间已结束,系统将自动交卷");
}
// 2. 校验Redis中的倒计时是否过期
String timeKey = "exam:countdown:" + request.getExamRecordId();
if (Boolean.FALSE.equals(redisTemplate.hasKey(timeKey))) {
throw new BizException("考试时间已结束,系统将自动交卷");
}
// 3. 更新答题明细(这里直接使用批量更新)
answerRecordMapper.batchInsertOrUpdate(request.getAnswerList());
// 4. 计算客观题分数
return calculateAndSaveScore(request.getExamRecordId());
}
关于答题过程中的“断点续答”,很多系统做得特别粗糙,考生刷新页面或者网络断了就丢失答题进度,体验极差。我的方案是:前端 Route 切换或者页面 beforeunload 事件触发时,自动把当前页面的全部已填答案保存到草稿箱,同时定时器每 30 秒做一次增量保存。后端用 answer_record 表记录所有答题明细,考试期间答案可以反复修改,只需要更新时间戳即可,最终交卷时以最后一次提交为准。
4.2 组卷算法:固定模板 + 随机抽题 + 乱序排列
公务员考试的试卷通常有两种出卷方式:一种是完全固定试卷,所有考生题目一样;另一种是题目顺序随机打乱,甚至从题库中随机抽取不同题目,以降低作弊概率。实际系统我做了两种模式的支持,组卷引擎的算法逻辑是:
- 从
paper_rule表读取该试卷模板下每种题型的抽取规则 - 根据规则从题目表中查询符合条件的题目集合(例如:题型=单选、难度<=3、所属题库=行测)
- 使用
Random或者更好的洗牌算法从题目集合中抽取指定数量的题目 - 对抽出的题目再次进行乱序排列,排序结果写入
exam_paper表的question_ids字段
随机抽题这步,我在开发时用过 MySQL 的 ORDER BY RAND(),测试的时候没发现问题,但一旦题库数据量超过 5 万条,这种写法会带来严重的性能问题——它会扫描全表并生成随机排序。后来我改成了彩票抽奖式的随机ID方案:先查出符合条件的题目 ID 集合,放在内存里(题目表通常不会超过几十万条,内存中放 ID 列表完全够用),然后用 Collections.shuffle() 洗牌,再取前 N 个。这样既能保证随机性,又不会让数据库承受全表排序的压力。
洗牌算法我推荐用 Java 自带的 Collections.shuffle(),它底层是 Fisher-Yates 算法,时间复杂度 O(n),分布均匀。面试时如果被问到组卷系统的随机性,一定要抓住“伪随机种子”这个点:如果直接对同一个 List 调用两次 shuffle(),结果大概率不同,但如果你通过 new Random(42) 指定相同种子,洗牌结果就是一样的。这个特性可以用来实现“模拟卷重现”,方便老师在考试后进行试卷讲评。
4.3 防作弊策略:从多角度替监考老师分忧
在线防作弊是考试系统里最难做的一块,因为它本质上是一个“技术手段无法完全解决”的问题。但通过多种手段叠加,至少能压制大多数作弊行为。我在这个系统里做了三层防护:
第一层:登录态和 IP 限制。 考生账号登录后,把 session 绑定到 IP 和 User-Agent 上,如果答题过程中 IP 频繁切换,触发风控标记,提醒监考老师关注。这个策略简单有效,虽然用代理可以把 IP 固定住,但真实考场中能想到这层的学生并不多。
第二层:切屏检测。 前端监听 visibilitychange 事件和 blur 事件,一旦考生切出浏览器窗口(比如去搜索答案),系统就记录一次切屏行为并警告。切屏超过三次则触发强制交卷机制。后端同样需要配合记录行为日志。
第三层:防替考和答非所问。 开考前摄像头采集人脸照片,答题过程中每隔一段时间(比如5分钟)自动抓拍一次,考试结束后把抓拍照片按时间轴拉出来,管理员可以快速查看是否有不同的人出现。这个功能我用的是现成的 OCR 和活体检测 SDK,没有自己去训练模型。
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 记录切屏事件并上报后端
navigator.sendBeacon('/api/exam/behavior/log', JSON.stringify({
examRecordId: getExamRecordId(),
type: 'switch_window',
time: Date.now()
}));
}
});
我觉得在线考试系统的防作弊,核心思想不是“堵死一切”,而是“留下证据”。真正要彻底防作弊,需要的是在线监考系统(摄像头实时画面+AI行为分析),而不是单靠代码就能实现的。所以在做系统时,我提前和客户达成了共识:系统负责做好行为记录和数据留痕,具体判定是否作弊由监考人员结合视频和日志来人工完成。
4.4 成绩计算与导出:客观题自动判分,主观题人工批阅
成绩计算看起来不难,客观题直接比对答案就行,但实际开发时有几个细节要注意:
- 多选题和判断题的判分规则:多选题通常“少选得部分分,多选、错选不得分”,这个计分逻辑要用代码精细控制。
- 主观题评分需要人工介入:系统要支持阅卷员按照题目逐份批改,所有同一道主观题的答案集中展示,评分后系统自动汇总总分。
- 雷同卷检测:对客观题的答题序列做相似度比对,如果两张试卷的答案序列完全相同或者只有个别差异,标记为“雷同卷候选”,供管理员人工复核。
主观题阅卷功能我建议做一个独立的模块,类似于“流水线式阅卷”的界面:左边是考生答卷内容,右边是评分框,上方展示评分标准。每次只展示一道主观题的全部考生答案,不需要阅卷员在多个考生之间来回切换,效率和准确性都更高。
成绩导出功能我用的是 EasyExcel 库,导出模板按照客户要求的格式定制。这里要提醒一点:Excel 导出的数据量如果很大(比如一场考试有几万人),直接在接口里同步生成导出文件会非常慢,容易导致请求超时。我的做法是用线程池异步处理导出任务,生成完成后把文件地址存入数据库,前端轮询获取下载链接。
5. 权限安全设计:这种系统最容易出安全事故
涉及个人信息和考试数据的系统,安全设计如果做不好,后果非常严重。公务员考试系统里保存着考生的身份证号、手机号、家庭住址、学历证书照片等敏感信息。我从一开始就把安全设计放到了和业务功能同等重要的位置。
5.1 JWT + Redis 双 Token 机制
用户的登录态管理我采用了 JWT(JSON Web Token)和 Redis 结合的方案。访问令牌(Access Token)有效期设为 30 分钟,刷新令牌(Refresh Token)有效期设为 7 天。每次请求时,后端先解析 JWT 验证签名和有效期,再把 Token 中的用户 ID 去 Redis 里查一次,确认该 Token 是否在有效会话列表中。如果用户修改密码或被管理员强制下线,直接从 Redis 里删除该用户的 Key,所有已签发的 Token 立即失效。
为什么不直接用 JWT 自包含状态?因为 JWT 一旦签发,在过期之前是无法撤销的,这就意味着如果考生的账号被盗或者管理员要封禁某个考生,签出去的 Token 依然有效,这是一个严重的安全隐患。增加 Redis 这一层校验就能完美解决这个问题,代价仅仅是每次请求多了一次 Redis 查询,而 Redis 的响应速度在毫秒级,完全可以接受。
java复制@Component
public class JwtAuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String [token](https://taotoken.net?utm_source=general) = request.getHeader("Authorization");
if (StringUtils.isBlank(token)) {
throw new BizException(401, "未登录或登录已过期");
}
// 解析JWT
Claims claims = JwtUtil.parseToken(token);
Long userId = claims.get("userId", Long.class);
// 校验Redis中是否存在该用户的会话
String sessionKey = "login:token:" + userId;
if (Boolean.FALSE.equals(redisTemplate.hasKey(sessionKey))) {
throw new BizException(401, "登录状态已失效,请重新登录");
}
// 放入ThreadLocal供后续业务使用
UserContext.set(userId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
UserContext.clear();
}
}
这里我用 ThreadLocal 来保存当前登录用户 ID,这样后续业务代码里不需要在各个方法之间传这个参数,直接 UserContext.get() 就能取到。注意一定要在请求完成后的 afterCompletion 里调用 UserContext.clear(),否则高并发场景下线程池复用会导致用户数据串号,这是非常严重的安全漏洞。
5.2 敏感数据加密存储与脱敏展示
身份证号、手机号这类敏感信息,在数据库里绝对不能明文存储。我用 AES 对称加密算法加密后存储,密钥统一配置在环境变量或者配置中心里,不写在代码里。查询数据时,根据用户权限决定是否返回明文——比如考生本人可以查看自己的完整身份证号,但管理员查看考生列表时只能看到脱敏后的格式(前3位后4位,中间用星号代替)。
说起来容易,实际写代码时踩了一个坑:用 MyBatis-Plus 的自动填充功能或者自定义 TypeHandler 来做加解密,会导致数据库查询无法针对身份证号做模糊查询。比如管理员要搜索“身份证号前6位是110101”的考生,如果数据库里存的是密文,SQL 的 LIKE 就无法匹配。我的解决方案是:在考生表里冗余一个字段 id_card_hash,存储身份证号的去敏哈希值(比如只取前6位和后4位拼起来做哈希),搜索时用同样算法计算出目标哈希值精确匹配,这样既保留检索能力又保证了安全性。
5.3 接口权限控制与越权漏洞防御
很多新手在做权限控制时只在菜单和按钮上做了控制,忽略了接口层的鉴权,导致攻击者可以通过直接调用接口 URL 越权访问数据。我在项目里使用了 Spring Security + 自定义权限注解的方式,在需要权限的接口上明确标注所需角色:
java复制@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/user-list")
public Result<PageResult<UserVO>> getUserList(@RequestParam Integer pageNum,
@RequestParam Integer pageSize) {
return Result.success(userService.getUserPage(pageNum, pageSize));
}
除了角色权限,还有一个非常重要的越权点是“水平越权”——也就是考生A试图通过修改请求参数中的 ID 来查看考生B的数据或者答卷。这种情况角色权限根本控制不住,因为A和B的角色都是“考生”。我的做法是:在 Service 层统一对数据归属做校验,所有根据 ID 查询个人数据的接口,都必须把当前登录用户 ID 从 ThreadLocal 里取出来作为查询条件,而不是直接相信前端传的 ID。例如:
java复制public ExamRecordVO getExamRecordDetail(Long recordId) {
ExamRecord record = examRecordMapper.selectById(recordId);
if (record == null || !record.getUserId().equals(UserContext.get())) {
throw new BizException("无权访问该考试记录");
}
return convertToVO(record);
}
这种校验虽然啰嗦,但是安全系统的底线。我建议在代码评审时,把“查找涉及 ID 的业务方法里有没有归属校验”作为一个必查项。
6. 性能优化与部署实战
系统开发完成只是第一步,真正让它稳定跑起来才是关键。公务员考试系统的访问特征前面提到过,是“短时高并发”——报名公告发出后的几个小时内,可能会有大量考生同时访问系统提交报名信息。如果服务器顶不住,直接把系统卡死,那麻烦就大了。
6.1 缓存热数据,降低数据库压力
我在系统里主要用了两级缓存策略:
- 一级缓存(Redis):用来缓存热点数据,如考试公告、职位列表、题目详情、系统配置等。职位列表的查询频率很高,而且数据变更不频繁,非常适合放在 Redis 中。如果后台管理员修改了职位信息,代码里主动删掉对应的 Redis Key,下次查询时重新加载,保证缓存与数据库的一致性。
- 二级缓存(本地缓存 Caffeine):对于系统配置项(比如考试时限、报名开关状态)这类极其高频且几乎不变化的数据,我用 Caffeine 在 JVM 内存中缓存,性能比 Redis 还高一个量级。通过
@Scheduled定时任务每 5 分钟刷新一次配置缓存。
这里要提醒一个很隐蔽的坑:Redis 缓存穿透——攻击者或者误操作连续请求一个数据库中根本不存在的 ID,比如一个人反复请求 positionId=99999999 的职位详情。由于缓存中没有该 Key,每次请求都会打到数据库上,数据库就会被这些无效查询拖垮。解决方案是“缓存空值”:如果查询结果为空,也把这个空结果和短过期时间(比如30秒)写入 Redis,这样后续请求不会再穿透到数据库。
6.2 数据库索引设计与慢查询优化
MySQL 的索引设计我遵循了几个原则:
- 最左前缀法则:联合索引
(recruitment_plan_id, status, create_time)用于高效查询某个招录计划下的报名记录。 - 区分度高的字段优先:状态字段(如 status)的区分度很低,不适合单独建索引。我通常把状态字段放在联合索引的中间位置。
- 大字段不要建索引:像
content这样的 TEXT 类型字段无法直接建普通索引,有全文检索需求时使用 MySQL 全文索引或额外的搜索引擎(如 Elasticsearch)。
另外,报名高峰期最容易出现的慢查询是“分组统计某场考试的报名人数”,因为 COUNT(*) 在 InnoDB 下需要全表扫描。我专门做了优化:在招录计划表 recruitment_plan 上增加一个冗余字段 applicant_count,每次报名成功或取消时,在事务内同步更新这个计数字段,查询时直接读它就行了。
6.3 部署方案:Nginx + 双实例 + Docker
部署环境我用的是 CentOS 7 服务器,Docker 容器化部署。前端用 Nginx 反向代理并托管静态文件,后端启动两个 Spring Boot 实例,用 Nginx 负载均衡指向 8081 和 8082 端口,外部统一通过 80 端口访问。如果将来并发更高,只需要继续增加实例并更新 Nginx 配置即可。
nginx复制upstream exam_backend {
server 127.0.0.1:8081 weight=1;
server 127.0.0.1:8082 weight=1;
keepalive 32;
}
server {
listen 80;
server_name exam.example.com;
# 前端静态资源
location / {
root /var/www/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端接口反向代理
location /api/ {
proxy_pass http://exam_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 60s;
proxy_read_timeout 120s;
}
}
我在生产环境踩过一个大坑:Nginx 默认的 proxy_read_timeout 是 60 秒,但考试提交试卷接口因为要同时做客观题判分、成绩存储、行为记录等多步操作,经常超过 60 秒,导致考生交卷时前端收到 504 错误。后来我把超时时间调到了 120 秒,并且把交卷接口改成异步处理——先快速返回“交卷成功”,后台线程继续做性能消耗较大的统计和归档操作。这个优化非常有效,考生几乎感觉不到交卷的延迟。
6.4 线上事故复盘:一场模拟考试的宕机教训
第一次压力测试的时候,系统直接被我打崩了。当时我用 JMeter 模拟了 2000 个并发用户同时提交报名信息,结果数据库连接池瞬间被打满,接口响应时间从 50ms 飙升到 8 秒,最后直接 OOM(内存溢出)。
排查后发现有两个原因:
- 数据库连接池配置太小。Spring Boot 默认的 HikariCP 连接池大小是 10,2000 并发的情况下连接肯定不够用。我把最大连接数调整到了
maximum-pool-size=50,同时在数据库端也调整了 max_connections 参数。 - 我用 JSON 字符串存了一些列表缓存,每次请求反序列化时都会产生大量临时对象,频繁触发 Full GC。解决方法是在缓存 value 上改用 Protostuff 序列化,同时把堆内存从 2G 调到了 4G。
压测之后我把结论写进了项目文档:这类系统不能只做功能测试,必须做容量评估和性能压测,否则真实高峰来临时就是事故现场。
7. 项目复盘:如果重做一次,我会优化哪些地方
整个项目从需求调研到上线部署,前后花了将近三个月时间。如果现在让我重做一次,我会在几个方面做改进:
第一,引入消息队列。 目前的架构里,报名成功后发送短信/邮件通知是同步调用的,如果短信服务响应慢,会拖慢整个报名接口。更好的方案是引入 RabbitMQ 或者 RocketMQ,报名成功后把通知任务丢进队列,异步执行。这样还能顺便做流量削峰,报名高峰期的请求先进队列排队,不会被瞬间打垮。
第二,增加审计日志。 虽然系统里做了简单的操作日志,但审计功能还不够完善。比如管理员修改了某道题的答案、调整了某位考生的成绩,这类操作应该记录详细的 before/after 数据,方便日后追溯。可以考虑接入现成的审计框架或者自己写 AOP 切面统一记录。
第三,考虑数据冷热分离。 随着考试次数积累,往年的考试数据会越来越多。如果不做归档,单表数据量过大会严重影响查询性能。可以按年份做分表,或者把已结束考试的报名数据和成绩数据迁移到历史库中,定期清理无用数据。
第四,微服务化改造要留好接口。 虽然现阶段单体架构足够用,但如果未来要支持更复杂的业务场景(比如引入在线面试系统、资格复审流转动辄涉及多个部门),单体应用会越来越臃肿。建议在设计阶段就把服务边界画好,至少保证核心模块(题库、考试、报名)之间的依赖是单向的,后面拆微服务时不用大改业务代码。
我个人在实际操作中体会最深的一点是:这类管理系统,技术难点其实不在某个单一功能上,而在把所有模块串起来之后的整体稳定性与安全性。单看每个接口都很简单,但一旦涉及高并发、权限边界、数据一致性、缓存失效这些问题,细节里的坑一个接一个。建议准备做一个完整项目的朋友,一定要在开发前把数据库表结构、状态流转图、接口约定写得足够细,宁可多花一周做设计,也不要花一个月在开发中反复改。
