之前花了大半个月,把一个基于 Spring Boot 的医考答题练习系统从立项到部署完整走了一遍。真正做完才发现,这类系统最难的并不是把试题列表怼到页面上,而是业务规则又杂又细:一道题干可能只有几十个字,但围绕它的选题、练习、判分、错题回看、考情统计,会牵扯出四五张表,稍不注意就变成一堆临时凑出来的 SQL 和难维护的状态字段。我把自己实际落地时的设计思路、实现细节、踩坑记录整理成下文,给要做同类系统的朋友当个参照。
这套东西对后端开发者、想学 Spring Boot 完整项目交付的人,以及要接医考类需求的团队都算有点参考价值。它没有吹毛求疵的高深算法,重点在于把传统刷题场景拆成可落地的数据模型和接口逻辑,并保证“练习”和“模拟考试”两条主链路都走得通。下面直接进入正题。
1. 医考答题系统到底在解决什么问题
1.1 医考练习和通用刷题产品的差异在哪里
市面上的通用刷题软件,大多是把题目按题库一放,用户选个分类就开始刷,顶多再加个答题卡和简单的正确率统计。但放到医考场景里,需求会立刻变得不一样。
医考类题目有几个明显特点:题干专业性强,经常带英文缩写、数值范围、临床案例描述;题型不止单选,还涉及多选、判断、配伍题,甚至图片判读;答案必须严谨,不能像普通知识竞赛一样“差不多就行”,一道错题最好还能给出出处和解析。也就是说,用户需要的不是单纯的“选择题机器”,而是一套能支撑查漏补缺的学习工具。
另外,医考备考用户的学习场景高度碎片化,可能白天上班晚上才有空刷几道题。如果系统不记录练习进度、不自动沉淀错题、不按章节维度统计薄弱点,用户一旦隔几天再登录,会发现自己根本不知道上次练到哪儿,学习体验会大打折扣。
1.2 从用户视角走一遍业务闭环
把一个备考者真正会用到的流程列出来,后端要支撑的能力就非常清晰了:
| 用户行为 | 后端需要支撑的能力 |
|---|---|
| 选择科目、章节、难度 | 题库维度建模,支持按条件筛选题目 |
| 点击开始练习 | 按规则随机组卷,避免简单全表随机 |
| 逐题作答并保存进度 | 答题记录持久化,支持断点继续 |
| 提交整卷 | 自动判分、统计总分与正确率 |
| 查看解析与正确答案 | 题目详情与答案解析接口 |
| 打开错题本 | 自动沉淀错题,支持错题重练 |
| 查看学习统计 | 按日期、科目维度统计练习量、正确率 |
这个闭环听起来不复杂,但每个环节背后都有数据模型设计的问题。如果只按“题目表 + 答题记录表”来设计,做到后面几乎一定会返工。所以我在动手写代码前,先花了不少时间把主链路画清楚,再倒推表结构。
1.3 最小可交付的范围
第一版我给自己划定的范围很克制,核心就五块:题库管理、答题练习、自动判分、错题本、基础统计。权限这块用最简单的 JWT 登录解决,不做复杂角色体系;数据统计先只做按日和按科目两个维度,不搞大屏可视化。先保证核心链路能跑通,其余功能等基础稳定了再迭代。
砍需求这件事特别重要。很多项目死在“第一版就想做得大而全”,尤其是考试类系统,稍微加上智能组卷、主观题批改、多维分析,工期就会成倍增长。对于大多数中小型项目,先交付一个“能刷题、能判分、能沉淀错题”的稳定版本,价值已经足够。
2. 技术选型和工程结构:为什么用 Spring Boot 做底盘最稳
2.1 技术栈选型的逻辑
这套系统后端我选了 Spring Boot + MyBatis-Plus + MySQL 的组合,Redis 作为可选缓存,登录认证用 JWT。选型理由其实很简单:项目规模没有达到需要微服务拆分的程度,单体应用是性价比最高的方案。
Spring Boot 胜在自动配置和成熟生态,基本上一个起步依赖就能把 Web、事务、参数校验、日志全带上,省去大量无意义的 XML 配置。MyBatis-Plus 则在单表操作和条件构造器上非常顺手,像按科目、题型、难度组合筛选题目,用 QueryWrapper 写起来比手拼 SQL 高效得多,同时也不丧失灵活定制 SQL 的能力。
MySQL 依然是最稳的选择,毕竟答题系统最核心的数据关系不算复杂,事务支持、可靠性和运维成本都让人放心。Redis 在初期并不是必需品,我把它定位成“热点题库缓存 + 验证码存储”,没有在一开始就强行引入,避免增加部署复杂度。
有人可能会问,为什么不用 Spring Data JPA?我的看法是,JPA 在关联关系复杂的业务里确实方便,但考试系统有大量条件查询、批量插入和报表统计类 SQL,MyBatis-Plus 的表达方式更直接,性能也更可控。选哪个没有绝对对错,看团队习惯和业务侧重即可。
2.2 工程目录结构
后端工程我按照常见的分层结构来组织,包名就用了一个中性的com.medexam,大家可以根据自己公司域名改。结构如下:
com.medexam ├── common │ ├── config # 全局配置:跨域、Jackson、MyBatisPlus │ ├── constant # 常量定义 │ ├── exception # 统一异常与错误码 │ └── util # 通用工具类 ├── controller # 接口层 ├── service # 业务逻辑层 │ └── impl ├── mapper # 数据访问层 ├── entity # 数据库实体 ├── dto # 请求参数对象 ├── vo # 返回视图对象 └── MedExamApplication.javacontroller 只负责参数接收和数据返回,真正业务逻辑全部下沉到 service。这样做的好处是:接口报错时能快速定位问题,核心判分逻辑也方便单独做单元测试。entity 和 dto/vo 严格分开,避免数据库实体直接暴露给前端,这是很多初学者容易忽略的细节。
2.3 application.yml 里几个关键配置
配置文件的坑比想象中多,我贴一个核心片段:
server: port: 8080 spring: datasource: url: jdbc:mysql://127.0.0.1:3306/med_exam?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 hikari: maximum-pool-size: 20 minimum-idle: 5 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl连接串里serverTimezone=Asia/Shanghai必须显式指定,否则高版本 MySQL 驱动会报时区错误。HikariCP 连接池参数要根据并发量调整,我见过很多项目默认配置不调,刷题高峰一来就直接“连接不可用”。MyBatis-Plus 的逻辑删除配置很重要,医考题库只做软删除,一旦误删题目,后期恢复成本极高。
3. 数据表设计:题库、答题记录、错题本这三条主线怎么落地
3.1 question 表:题目到底怎么存
题目表是整个系统的地基。我设计了这些核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| bank_id | bigint | 题库 ID,比如“内科基础”“外科总论” |
| type | tinyint | 题目类型:1 单选,2 多选,3 判断,4 案例分析 |
| content | text | 题干正文 |
| options | json | 选项内容,比如{"A":"发热","B":"咳嗽"} |
| answer | varchar | 标准答案,单选“A”,多选“A,C,D” |
| analysis | text | 答案解析 |
| difficulty | tinyint | 难度等级 |
| subject_id | bigint | 科目 ID |
| deleted | tinyint | 逻辑删除标记 |
| create_time | datetime | 创建时间 |
| update_time | datetime | 更新时间 |
题目表创建语句大致如下:
CREATE TABLE `question` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `bank_id` bigint(20) NOT NULL, `type` tinyint(4) NOT NULL COMMENT '1单选 2多选 3判断 4案例分析', `content` text NOT NULL, `options` json DEFAULT NULL, `answer` varchar(50) NOT NULL, `analysis` text, `difficulty` tinyint(4) DEFAULT '1', `subject_id` bigint(20) DEFAULT NULL, `deleted` tinyint(1) DEFAULT '0', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `idx_bank_type_difficulty` (`bank_id`,`type`,`difficulty`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试题表';这里有几个细节值得说明。选项用 JSON 存储,而不是单独拆一张选项表,是因为考试题目选项结构固定,极少需要单独查询某个选项,JSON 存储能显著减少表数量和关联查询。答案字段用字符串存,单选直接A,多选A,B,C,判断T或F,虽然简单粗暴,但实际用下来非常高效。难度字段加上 type、bank_id 的联合索引,是因为最常用的查询就是“从这个题库、这个题型、这个难度里抽 N 道题”。
3.2 answer_record 与 exam_paper:答题行为链路
试卷表和答题记录表是练习题系统的核心。exam_paper用来记录一次完整的练习或考试场次,字段包括 id、user_id、bank_id、paper_type(练习或考试)、total_count、correct_count、score、status、start_time、submit_time。每次用户点击“开始练习”,后端就生成一条试卷记录,后续所有答题行为都挂在这个 paper_id 下。
答题记录表我设计了唯一索引,这是防重复提交的关键:
CREATE TABLE `answer_record` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL, `paper_id` bigint(20) NOT NULL, `question_id` bigint(20) NOT NULL, `user_answer` varchar(50) DEFAULT NULL, `is_correct` tinyint(1) DEFAULT NULL, `score` decimal(6,2) DEFAULT '0.00', `answer_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_paper_question` (`user_id`,`paper_id`,`question_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题记录表';很多人不知道唯一索引能用来做幂等控制。当同一个用户对同一道题提交两次时,数据库层的唯一约束会直接拦住第二次插入,这样即使前端连点两次提交、网络重试,也不会出现一条题记录被插两遍的脏数据。
3.3 wrong_question 与统计表:别一边刷题一边全表 JOIN
错题本不能靠每次现算。用户练完一套题,系统要把答错的题同步到错题表:user_id、question_id、wrong_count、last_wrong_time、source_paper_id。之后用户打开错题本时,只需要按时间倒序查这张表,而不是从 answer_record 里筛所有错误记录。
统计能力我单独做了简单的日报表:study_day、user_id、subject_id、question_count、correct_count、duration_seconds。每次交卷时,在事务里把这几个数值更新到当天记录中。这样用户查看“七日练习概况”时,一条 SQL 就能搞定,不用去几十万条答题记录里聚合。
4. 核心逻辑实现:随机组卷、答题提交、交卷判分的完整链路
4.1 随机抽题:别写裸的 ORDER BY RAND()
这是第一个容易翻车的点。新手最喜欢写的随机抽题 SQL 是:
SELECT * FROM question ORDER BY RAND() LIMIT 20这种写法数据量小的时候没问题,但题库一旦过万,ORDER BY RAND()会让数据库做全表扫描和临时排序,接口响应直接变得不可接受。而且无约束的随机抽题很容易抽取大量同题型题目,组卷质量非常差。
我的做法是,先按条件查出符合条件的题目 ID 列表,在内存中洗牌后抽取,再按 ID 批量查出完整题目:
public List<Question> generatePaper(Long bankId, Integer count, Integer type, Integer difficulty) { List<Long> allIds = questionMapper.selectIdList(bankId, type, difficulty); if (allIds.size() < count) { throw new BizException("当前条件下题目数量不足"); } Collections.shuffle(allIds); List<Long> pickedIds = allIds.subList(0, count); return questionMapper.selectBatchIds(pickedIds); }在题目量较小的情况下,内存洗牌完全够用,而且支持按题型、难度按比例抽取。如果题库达到几十万道,可以改为“先随机取一个 ID 偏移量,再按主键范围查询”,但大多数医考练习系统还到不了这个规模,没必要提前优化。
4.2 答题提交:增量保存与断点续练
用户答题时,一种做法是全部答完再一次性提交,但这个体验对练习模式很不友好。练习中途用户可能切出去接个电话,回来页面刷新,如果没做进度保存,这次练习就废了。
我们的方案是“增量上报”。每答完一题,前端调用保存接口,把这个用户在这次 paper 中对某道题的答案写入 answer_record。若不存在的记录就插入,已存在则更新用户答案。这样即使用户中途关闭页面,下次打开还能按 paper_id 查出之前做过的题目和答案,实现断点续练。
保存接口内部用到了数据库的INSERT ... ON DUPLICATE KEY UPDATE语义,配合之前建好的唯一索引,避免先查再插造成的并发覆盖问题。考试模式下由于有时间限制,我改成按“交卷时统一提交”,但底层仍然使用批量插入,并用唯一索引保证不重复。
4.3 自动判分:答案标准化比对
自动判分是核心中的核心。这里最容易出问题的并不是判断逻辑本身,而是用户答案的格式严重不统一。有的人多选提交A,C,有人提交ac,还有人把选项中间写成中文逗号A,C,如果直接比对字符串,必然出现误判。
我写了一个统一的答案标准化方法:
private boolean checkAnswer(String userAnswer, String rightAnswer) { if (StringUtils.isBlank(userAnswer)) { return false; } String left = normalizeAnswer(userAnswer); String right = normalizeAnswer(rightAnswer); if (right.startsWith("[") && right.endsWith("]")) { Set<String> rightSet = new HashSet<>(Arrays.asList( right.substring(1, right.length() - 1).split(","))); Set<String> userSet = new HashSet<>(Arrays.asList( left.substring(1, left.length() - 1).split(","))); return rightSet.equals(userSet); } return left.equals(right); } private String normalizeAnswer(String answer) { return answer.trim() .replace(" ", "") .replace(",", ",") .replace("。", "") .toUpperCase(Locale.ROOT); }多选判断使用集合相等,这样无论用户填A,C还是C,A,只要答案内容一致就算正确,不依赖顺序。对于案例分析这种主观题,自动判分做不了,系统会在交卷后把 score 留空,并在接口标识needManualReview=1,留给管理员人工批改。
4.4 交卷判分的整体事务处理
交卷接口把判分、错题沉淀、统计更新放在一个事务里。简要流程是:先校验试卷状态,防止已交卷的试卷再次交卷;然后查出该试卷全部答题记录,逐题比对标准答案,计算总分和正确数;再将错题插入错题表;最后更新当天学习统计。
这里有个经验:事务里不要做远程调用、不要做太耗时的循环,否则数据库连接容易长期占用。我实测过,一次交卷涉及几十道题的判分,纯内存循环耗时极低,完全没问题。但如果题目量极大,可以把判分改成异步处理,先返回“已接收”,再通过异步任务计算分数,前端轮询结果。
5. 真实项目里踩过的坑:并发提交、脏答案、慢 SQL 与数据库连接
5.1 连点两次提交,成绩被算两遍
这个坑我印象最深。前端一旦没有做按钮防重复点击,用户手一抖点了两次“交卷”,后端就会收到两个交卷请求。如果接口没有幂等保护,用户可能在同一场模拟考试中得到两个不同成绩,错题本也会被重复写入。
解决方式分两层。接口层使用一个提交流水号,前端发起交卷时先请求一个 token,交卷时带上 token,后端用唯一约束防止同一 token 被处理两次。数据层则靠试卷表的状态字段,交卷时通过 SQLUPDATE exam_paper SET status='FINISHED' WHERE id=#{paperId} AND status='DOING'的受影响行数来判断是否重复提交。受影响行数为 0,说明已经被交卷过,直接返回“请不要重复交卷”。
5.2 答案和解析里的脏数据
另一个常见坑是题库数据不干净。即使我从一开始强调答案统一格式,实际导入题库时还是会出现A、A。、a等各种诡异格式。判分逻辑虽然做了标准化处理,但脏数据如果出现在解析文本里还看不出问题,一旦出现在选项 JSON 或答案字段中,就会导致用户莫名其妙被扣分。
所以我在题库管理后台加了一个“数据体检”功能,定时扫描题目表,检查答案字段是否合法、选项 JSON 是否完整、多选答案是否都在选项范围内。这是个很笨但非常有效的功能,线上系统跑了几个月,数据质量问题大部分靠它提前发现。
5.3 慢 SQL:N+1 查询比想象中更可怕
开发初期,我为了省事,加载一套试卷的时候循环查询每一道题的详情,结果交卷记录只加载 20 道题就要发 20 多条 SQL。数据库连接池差点被打满。后来改成一次性查出题目 ID 列表,再用IN批量查询题目表,将接口响应时间从秒级降到了几十毫秒。
类似的问题还出现在错题本接口上。如果只查错题表,再逐条去查题目关联信息,同样会踩 N+1。正确做法是连表查询或者先批量取题目 ID,再二次批量补齐。用 MyBatis-Plus 时,可以手动写一个带IN查询的 Mapper 方法,避免逐条循环。
5.4 连接池参数和时区导致的环境差异
本地运行好好的,部署到服务器之后偶尔出现“连接失败”或时间差 8 小时的问题。核对下来,一是 MySQL 连接串没配serverTimezone=Asia/Shanghai,二是 HikariCP 连接池最大连接数设置得太小。虽然问题看起来都不大,但一旦在答题高峰暴露出来,排查成本很高。
这里我的建议是:从第一天就把这些配置固定好,不要依赖本机环境;数据库账号密码放到环境变量或者配置中心,避免开发环境密码误上生产。生产环境至少做一次半小时的简单压测,看看连接池和事务是否能扛住集中交卷的流量。
6. 从开发到部署:环境初始化、打包和上线自检清单
6.1 本地开发环境清单
开发这套系统,我本地的环境如下:
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | 1.8 或 17 | 根据 Spring Boot 主版本选择 |
| Maven | 3.8+ | 依赖管理与构建 |
| MySQL | 5.7 或 8.0 | 主数据库 |
| Redis | 5.0+ | 缓存与验证码,可选 |
| 前端工具 | 任意 | 页面调试,可并行开发 |
如果没有装 Redis,可以把缓存相关代码改成本地 Map,第一版不会受太大影响。但生产环境建议还是开 Redis,因为试卷题目的热点数据会被频繁访问,缓存命中后数据库压力会小很多。
6.2 使用 Maven 打包 Spring Boot 应用
Spring Boot 应用打可执行 Jar 是最常规的发布方式。在pom.xml中引入spring-boot-maven-plugin之后,执行:
mvn clean package -DskipTests打包后会在target目录生成一个可执行 Jar。选择 Jar 而不是 War 的原因很简单:不需要再单独部署一个 Web 容器,一个 Java 进程就能跑起来,部署运维成本最低。跑起来就是:
java -jar target/med-exam-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod通过--spring.profiles.active=prod指定生产环境配置,可以和开发配置完全隔离。
6.3 systemd 和 Docker 两种部署姿势
服务器上如果直接放一个 Jar,我建议用 systemd 托管进程。一个极简的 service 文件:
[Unit] Description=med-exam-service After=network.target mysql.service [Service] User=medexam ExecStart=/usr/bin/java -jar /home/medexam/app/med-exam.jar --spring.profiles.active=prod Restart=always RestartSec=5 [Install] WantedBy=multi-user.target这样进程挂了能自动拉起,开机也会自动启动,比直接nohup java -jar靠谱得多。
如果团队已经统一用容器,也可以写一个极简 Dockerfile:
FROM maven:3.9-jdk-17 AS build WORKDIR /app COPY . . RUN mvn clean package -DskipTests FROM openjdk:17-jre COPY --from=build /app/target/*.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]无论用哪种方式,数据库脚本都要单独管理,不要在服务启动时自动建表建库。容器每次启动都执行初始化脚本,很容易在升级时把已有数据搞出问题。
6.4 上线前的自查点
最后分享一份上线前我每次都会检查的清单:
- 跨域配置是否只对前端域名开放,而不是
*; - JWT 过期时间是否合理,登录态是否能在刷新后保持;
- MySQL 是否已开启定时备份,至少每日一次;
- 应用日志是否按天分割,避免单日志文件无限增长;
- 题库是否做了必填校验,答案格式是否统一;
- 关键接口是否做了简单压测,连接池参数够不够用;
- 生产数据库连接信息是否通过环境变量注入,避免硬编码。
这份清单我不只在这个项目上用,后来接手其他后端项目也一直在复用。
7. 后续迭代建议:从题库导入到多端复用
7.1 题库导入与题目识别
系统跑起来之后,第一个必然遇到的问题就是:题库内容从哪来、怎么低成本录入。手动一条条录入的维护成本太高,我建议后续做一个 Excel 批量导入模板,同时支持 Word 文档粘贴识别。
更进一步可以引入 OCR 识别能力,把纸质资料或截图里的题目自动转成结构化数据。但这个方向投入比较大,更适合题库量达到一定规模、且确实要长期运营的团队来做。前期先把 Excel 导入做好,已经能解决大部分实际痛点。
7.2 学习数据分析与复习计划
医考备考用户最需要知道自己哪里弱。目前系统只做了基础的按日统计,后续可以增加“考点掌握度”分析:把题目映射到考点标签,统计用户每个考点的正确率,当某个考点连续多次错误时,自动推荐该考点的练习题。这个功能看起来不复杂,但要先把题目标签体系建起来,属于一次整理、长期受益的事情。
7.3 小程序/H5 等移动端适配
很多用户习惯用手机刷题。目前后端接口设计已经按前后端分离做了,JWT 登录、统一返回结构、按需分页都具备,所以后续接小程序、H5 或者移动端 App 的成本并不高。如果想要一套代码覆盖多端,可以考虑做 uni-app 或类似跨端方案,但核心后端逻辑基本不需要改动。
对我个人来说,这次做得最有价值的决定,就是在一开始就忍住没做大而全的设计,而是把题库、答题记录、错题、统计这四条核心数据链路老老实实建扎实了。后面每一次迭代要加新功能时,底子都没塌,反而越用越顺手。如果你正准备做同类系统,我建议也从这条路线开始。