简介:这份资源是面向高校计算机相关专业学生的数据库课程设计完整项目,以Java语言开发学校题库管理系统,适合正在准备课程设计、需要参考数据库表结构设计与Java桌面端实现的学习者。项目围绕课程、题型、章节与习题四大核心对象展开,涵盖基本信息管理、按题型或章节录入习题、存储过程统计各课程各题型习题数量、视图查询课程使用题型、题号自动生成、建立日期默认值、自动抽题组套并借助触发器累加抽取次数,以及表间参照完整性约束等典型数据库知识点,是一套贴近教学要求的综合实践方案。资源包共66个文件,约432KB,包含9个Java源码文件、35个class编译文件、1个sql建库脚本、4个xml配置、2个doc说明文档及若干界面图片,覆盖源码、数据库脚本与设计文档,便于直接运行与二次修改。目前已有83人学习,适合作为课程设计参考或数据库综合练习素材。
1. 题库管理系统到底在管什么:从一次期末组队翻车说起
数据库课程设计里选“学校题库管理系统”的人不少,但真正把它做出区分度的不多。我见过一个小组,选题时觉得“不就是增删改查”,结果中期检查被导师问了三个问题就卡住了:一道题被多个老师引用时怎么保证不重复入库?组卷时随机抽题怎么保证知识点覆盖而不是全抽到同一章?学生提交的答案和标准答案不一致但意思对,系统认不认?这三个问题背后其实是数据建模、约束设计和检索策略,不是简单的 CRUD。
这个系统要解决的核心诉求很明确:把题目作为可复用资产管起来,让老师能按知识点、难度、题型快速组卷,让学生能在线练习并拿到反馈。适合两类人——正在做数据库课程设计、需要一套能讲清楚设计理由的选题的同学,以及想用 Java 把“题库”这个场景跑通、顺便练手 JDBC 和事务的开发者。下面我按实际做项目的顺序,把表怎么设计、Java 层怎么写、组卷算法怎么落地、哪里容易翻车,一层层拆开。
2. 表结构定生死:六张核心表与三个容易后悔的字段设计
题库系统的复杂度不在代码量,在表关系。我一般会把整个系统拆成六张核心表:科目表、知识点表、题目表、选项表、试卷表、试卷题目关联表。下面逐张说清楚字段和设计理由。
2.1 题目表为什么不能把选项塞进一个字段
新手最容易犯的错是题目表里放一个options字段,用 JSON 或逗号拼接存四个选项。这样做的直接后果是:你没法用 SQL 按选项内容检索,统计某个干扰项被选了多少次也要在应用层解析字符串。正确做法是拆出选项表。
-- 题目表:一道题的基本属性 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_id BIGINT NOT NULL COMMENT '所属科目', knowledge_id BIGINT NOT NULL COMMENT '所属知识点', q_type TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4简答', difficulty TINYINT NOT NULL DEFAULT 3 COMMENT '1-5,5最难', stem TEXT NOT NULL COMMENT '题干', answer TEXT NOT NULL COMMENT '标准答案,多选用逗号分隔选项标号', created_by BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_subject_knowledge (subject_id, knowledge_id), INDEX idx_difficulty (difficulty) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;subject_id和knowledge_id上建联合索引,是因为组卷时最常用的查询就是“某科目某知识点下抽 N 道题”。difficulty单独建索引,用于按难度分层抽题。q_type用 TINYINT 而不是字符串,省空间且比较快,但要在应用层维护枚举映射,别直接往数据库里写魔法数字。
-- 选项表:只对选择题有意义 CREATE TABLE question_option ( id BIGINT PRIMARY KEY AUTO_INCREMENT, question_id BIGINT NOT NULL, opt_label CHAR(1) NOT NULL COMMENT 'A/B/C/D', opt_content VARCHAR(500) NOT NULL, is_correct TINYINT(1) DEFAULT 0, UNIQUE KEY uk_question_label (question_id, opt_label), FOREIGN KEY (question_id) REFERENCES question(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_question_label这个唯一约束很关键。没有它,同一道题可能出现两个 A 选项,组卷时前端渲染直接乱掉。ON DELETE CASCADE保证删题时选项自动清理,不用在 Java 层手动删两次。
2.2 试卷与题目关联表:冗余字段该加就得加
试卷和题目是多对多关系,需要中间表。但中间表不能只放两个外键。
CREATE TABLE paper ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, subject_id BIGINT NOT NULL, total_score INT NOT NULL DEFAULT 0, duration INT NOT NULL COMMENT '考试时长,分钟', status TINYINT DEFAULT 0 COMMENT '0草稿 1已发布', created_by BIGINT NOT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL COMMENT '该题在本卷中的分值', sort_no INT NOT NULL COMMENT '题号顺序', UNIQUE KEY uk_paper_question (paper_id, question_id), INDEX idx_paper_sort (paper_id, sort_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;score字段必须放在关联表而不是题目表。同一道题在平时练习卷里值 2 分,在期末卷里可能值 5 分,分值属于“这道题在这张卷子里”的属性。sort_no控制题号顺序,组卷时按知识点抽题后要重新排序,不能依赖自增 id。uk_paper_question防止同一道题在一张卷子里出现两次——这是组卷算法必须配合的约束。
2.3 知识点表的层级设计:parent_id 够不够用
知识点通常是树形结构:第一章 → 第一节 → 具体考点。用parent_id自关联是最简做法。
CREATE TABLE knowledge_point ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_id BIGINT NOT NULL, parent_id BIGINT DEFAULT 0 COMMENT '0表示顶层', name VARCHAR(100) NOT NULL, level TINYINT NOT NULL DEFAULT 1, INDEX idx_subject_parent (subject_id, parent_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;level字段是冗余的,但值得加。查询“某科目下所有二级知识点”时,用level=2比递归查 parent_id 快得多。代价是移动节点时要更新子树所有节点的 level,但知识点树很少变动,这个代价可以接受。
注意:如果课程设计要求支持“知识点跨科目复用”,那 subject_id 就不该放在知识点表上,而要再拆一张科目-知识点关联表。多数学校题库场景不需要这个复杂度,先按单科目归属做。
3. Java 层怎么落地:JDBC 连接、DAO 模式与事务边界
表建好之后,Java 这边最容易出问题的是连接管理和事务。课程设计通常不允许用 Spring 全家桶,那就老老实实写 JDBC,但要把工具类抽干净。
3.1 连接池不一定要上,但连接必须能关
很多课程设计直接DriverManager.getConnection每次新建连接,跑单机演示没问题,一旦组卷时循环抽题、每次抽题都开连接,性能立刻塌。我一般会写一个极简的连接持有工具,不引入第三方池。
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/question_bank?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PWD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new RuntimeException("驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } // 统一关闭,避免每个 DAO 里写三遍 try-catch public static void close(Connection conn, Statement st, ResultSet rs) { try { if (rs != null) rs.close(); } catch (SQLException ignored) {} try { if (st != null) st.close(); } catch (SQLException ignored) {} try { if (conn != null) conn.close(); } catch (SQLException ignored) {} } }serverTimezone必须显式指定,否则 MySQL 8 驱动会报时区异常,这是血泪经验。characterEncoding=utf8配合建表时的utf8mb4,保证题干里的特殊符号不乱码。关闭顺序是 ResultSet → Statement → Connection,反过来关可能抛异常。
3.2 组卷抽题:一条 SQL 还是多次查询
组卷的核心逻辑是“按知识点和难度抽题”。有两种写法:一种是在 Java 里循环每个知识点分别查,另一种是用一条 SQL 带条件批量查再在内存里分配。我倾向后者,因为减少数据库往返。
/** * 按知识点抽题:每个知识点抽 count 道,难度在 [minDiff, maxDiff] 之间 */ public List<Question> pickByKnowledge(long subjectId, long knowledgeId, int minDiff, int maxDiff, int count) throws SQLException { String sql = "SELECT id, q_type, difficulty, stem, answer FROM question " + "WHERE subject_id = ? AND knowledge_id = ? " + "AND difficulty BETWEEN ? AND ? " + "ORDER BY RAND() LIMIT ?"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; List<Question> list = new ArrayList<>(); try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); ps.setLong(1, subjectId); ps.setLong(2, knowledgeId); ps.setInt(3, minDiff); ps.setInt(4, maxDiff); ps.setInt(5, count); rs = ps.executeQuery(); while (rs.next()) { Question q = new Question(); q.setId(rs.getLong("id")); q.setQType(rs.getInt("q_type")); q.setDifficulty(rs.getInt("difficulty")); q.setStem(rs.getString("stem")); q.setAnswer(rs.getString("answer")); list.add(q); } } finally { DBUtil.close(conn, ps, rs); } return list; }ORDER BY RAND()在题目量小的时候够用,但数据量上万后性能会明显下降,因为它要对全表生成随机数再排序。课程设计阶段题目通常几百到几千道,可以接受。如果要做优化,可以先用WHERE id >= (SELECT FLOOR(RAND() * (SELECT MAX(id) FROM question)))取随机起点再 LIMIT,但那样抽题分布不均匀,需要额外处理。
LIMIT ?用占位符传参是 MySQL 支持的,但某些旧版本驱动不认,如果报语法错误就改成字符串拼接并做整数校验。
3.3 保存试卷必须在一个事务里
组卷完成后要把试卷和题目关联一起写入。如果先插试卷、再循环插关联,中途失败就会留下一张空卷。必须包事务。
public long savePaper(Paper paper, List<PaperQuestion> pqList) throws SQLException { Connection conn = null; PreparedStatement psPaper = null; PreparedStatement psPQ = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 String sqlPaper = "INSERT INTO paper(title, subject_id, total_score, duration, status, created_by) " + "VALUES(?,?,?,?,?,?)"; psPaper = conn.prepareStatement(sqlPaper, Statement.RETURN_GENERATED_KEYS); psPaper.setString(1, paper.getTitle()); psPaper.setLong(2, paper.getSubjectId()); psPaper.setInt(3, paper.getTotalScore()); psPaper.setInt(4, paper.getDuration()); psPaper.setInt(5, paper.getStatus()); psPaper.setLong(6, paper.getCreatedBy()); psPaper.executeUpdate(); rs = psPaper.getGeneratedKeys(); long paperId = 0; if (rs.next()) paperId = rs.getLong(1); String sqlPQ = "INSERT INTO paper_question(paper_id, question_id, score, sort_no) VALUES(?,?,?,?)"; psPQ = conn.prepareStatement(sqlPQ); for (PaperQuestion pq : pqList) { psPQ.setLong(1, paperId); psPQ.setLong(2, pq.getQuestionId()); psPQ.setInt(3, pq.getScore()); psPQ.setInt(4, pq.getSortNo()); psPQ.addBatch(); } psPQ.executeBatch(); conn.commit(); // 全部成功才提交 return paperId; } catch (SQLException e) { if (conn != null) conn.rollback(); // 任何一步失败整体回滚 throw e; } finally { if (conn != null) conn.setAutoCommit(true); DBUtil.close(conn, psPaper, null); DBUtil.close(null, psPQ, rs); } }Statement.RETURN_GENERATED_KEYS用来拿刚插入试卷的自增 id,这是关联表写入的前提。addBatch+executeBatch把 N 条关联插入合并成一次网络往返,比逐条插入快一个数量级。rollback放在 catch 里,保证试卷和关联要么都在要么都不在。
注意:
setAutoCommit(true)要在 close 之前恢复,否则连接如果被复用会带着错误的事务状态。虽然这里每次新建连接,但养成习惯没坏处。
4. 组卷算法与查重:随机抽题怎么不抽出一张废卷
组卷不是随机抽满就行。一张合理的试卷要满足知识点覆盖、难度分布、题型配比三个约束,还要避免同一道题重复出现。
4.1 按知识点配额抽题的基本流程
我一般把组卷拆成四步:确定每个知识点的抽题数量 → 按知识点和难度抽题 → 合并去重 → 排序编号。第一步的配额可以平均分配,也可以按知识点权重分配。
/** * 组卷主流程 * @param subjectId 科目 * @param totalCount 总题数 * @param knowledgeIds 参与组卷的知识点 */ public List<PaperQuestion> generatePaper(long subjectId, int totalCount, List<Long> knowledgeIds) throws SQLException { int perKnowledge = totalCount / knowledgeIds.size(); int remainder = totalCount % knowledgeIds.size(); List<Question> picked = new ArrayList<>(); Set<Long> usedIds = new HashSet<>(); // 去重 for (int i = 0; i < knowledgeIds.size(); i++) { int need = perKnowledge + (i < remainder ? 1 : 0); List<Question> part = pickByKnowledge(subjectId, knowledgeIds.get(i), 1, 5, need); for (Question q : part) { if (usedIds.add(q.getId())) { // add 返回 false 说明已存在 picked.add(q); } } } // 如果去重后不够,从剩余题目里补 if (picked.size() < totalCount) { List<Question> extra = pickRandom(subjectId, totalCount - picked.size(), usedIds); picked.addAll(extra); } // 组装成 PaperQuestion 并编号 List<PaperQuestion> result = new ArrayList<>(); int sortNo = 1; for (Question q : picked) { PaperQuestion pq = new PaperQuestion(); pq.setQuestionId(q.getId()); pq.setScore(scoreOf(q.getQType())); // 按题型给分 pq.setSortNo(sortNo++); result.add(pq); } return result; }usedIds这个 Set 是去重的关键。add方法返回 false 表示元素已存在,直接跳过。remainder处理除不尽的情况,把余数分摊到前几个知识点。补题逻辑是兜底,防止某个知识点题目不够导致整卷题数不足。
scoreOf按题型给分,比如单选 2 分、多选 3 分、简答 10 分。这个映射建议放在配置文件或常量类里,别硬编码在方法里。
4.2 难度分布控制:别让一张卷子全是难题
纯随机抽题可能抽出一张全难题或全简单题的卷子。要控制难度分布,可以按比例分层抽取。
| 难度等级 | 占比 | 说明 |
|---|---|---|
| 1-2(易) | 40% | 基础概念题 |
| 3(中) | 40% | 理解应用题 |
| 4-5(难) | 20% | 综合分析题 |
按这个比例,一张 20 题的卷子应该抽 8 道易题、8 道中等题、4 道难题。实现时对每个难度区间分别调用pickByKnowledge,把 count 按比例算好传进去。
int easyCount = (int) Math.round(totalCount * 0.4); int midCount = (int) Math.round(totalCount * 0.4); int hardCount = totalCount - easyCount - midCount; List<Question> easyPart = pickByKnowledge(subjectId, kid, 1, 2, easyCount); List<Question> midPart = pickByKnowledge(subjectId, kid, 3, 3, midCount); List<Question> hardPart = pickByKnowledge(subjectId, kid, 4, 5, hardCount);这样每个知识点内部也有难度梯度。如果某个难度区间题目不够,pickByKnowledge返回的 list 会小于请求数量,需要在合并后检查总数并触发补题。
4.3 查重:同一道题不能在一张卷子里出现两次
查重有两层。第一层是组卷时的内存去重,用上面的usedIds解决。第二层是数据库约束,paper_question表上的uk_paper_question唯一索引兜底。如果应用层去重有 bug,插入时会抛DuplicateKeyException,事务回滚,不会产生脏数据。
还有一种查重是“同一道题不能在同一学生的多张练习卷里反复出现”。这个需求要看课程设计是否要求,如果要求,就得在抽题时排除该学生最近做过的题目,需要额外一张答题记录表。
CREATE TABLE answer_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, question_id BIGINT NOT NULL, paper_id BIGINT NOT NULL, user_answer TEXT, is_correct TINYINT(1), answered_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_student_question (student_id, question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;抽题时加一个NOT EXISTS子查询排除该学生做过的题。这个查询在数据量大时会慢,但课程设计阶段学生和题目数量都有限,可以接受。
5. 避坑与排查:那些让答辩当场卡壳的问题
这一章列几个我在做这类系统时真实踩过的坑,每个都按现象、原因、解决来说。
5.1 中文题干乱码:从建表到连接要全链路统一
现象:题目录入时正常,查询出来题干里的中文变成问号或乱码。
原因:乱码可能出现在三个环节——数据库建表字符集、JDBC 连接字符集、Java 源文件编码。任何一处不是 utf8mb4,中文就会断。
解决:建表时显式写DEFAULT CHARSET=utf8mb4;JDBC URL 加characterEncoding=utf8;Java 源文件保存为 UTF-8;MySQL 配置文件里character-set-server=utf8mb4。四处都对齐后乱码消失。如果已经建了表,用ALTER TABLE question CONVERT TO CHARACTER SET utf8mb4改。
5.2 组卷抽题数量不够:LIMIT 返回的行数小于请求数
现象:请求抽 10 道题,实际只返回 6 道,试卷题数不足。
原因:ORDER BY RAND() LIMIT ?在符合条件的行数少于请求数时,只会返回实际存在的行数,不会报错。某个知识点下题目本来就少,或者难度区间过滤后剩得不多。
解决:在pickByKnowledge返回后检查list.size(),如果小于请求数量,记录缺口,从其他知识点或放宽难度区间补题。补题逻辑要放在组卷主流程里,不能指望单次查询一定满足。
5.3 事务没生效:自动提交没关或异常被吞
现象:保存试卷时关联插入失败,但试卷记录已经写进数据库,出现空卷。
原因:要么setAutoCommit(false)没执行,要么 catch 块里 rollback 之前异常被别的 try-catch 吞掉了,要么 rollback 本身也抛异常没处理。
解决:确认setAutoCommit(false)在第一条 SQL 之前执行;catch 里先 rollback 再 rethrow;rollback 用独立的 try-catch 包住,避免 rollback 失败掩盖原始异常。测试时故意在关联插入时传一个不存在的 question_id,看试卷是否回滚。
5.4 外键约束导致删题失败
现象:删除一道已经被试卷引用的题目时,数据库报外键约束错误。
原因:paper_question表有指向question的外键,且没有设置级联删除。这是保护机制,防止试卷里的题目被删后出现悬空引用。
解决:不要物理删除已被引用的题目,改用逻辑删除——在question表加is_deleted字段,删除时置 1,查询时过滤is_deleted = 0。这样既保留历史试卷的完整性,又让题目不再出现在新组卷中。
5.5 批量插入性能差:循环里单条 executeUpdate
现象:保存一张 50 题的试卷要好几秒,日志里看到 50 条 insert 语句逐条执行。
原因:每条executeUpdate都是一次数据库往返,50 条就是 50 次网络通信。
解决:用addBatch攒批,最后executeBatch一次提交。注意 batch 大小控制在 500 到 1000 条以内,太大可能超出驱动或数据库的包大小限制。如果确实要插很多,分批 executeBatch。
6. 从能跑到好用:三个让答辩加分的进阶技巧
课程设计做完基本功能只是及格线,想拿高分得在细节上体现思考。下面三个技巧是我觉得投入产出比最高的。
6.1 用视图简化组卷查询
组卷时经常要联查题目、知识点、科目三张表。与其在 Java 里拼 SQL,不如建一个视图。
CREATE VIEW v_question_detail AS SELECT q.id, q.q_type, q.difficulty, q.stem, q.answer, k.name AS knowledge_name, s.name AS subject_name FROM question q JOIN knowledge_point k ON q.knowledge_id = k.id JOIN subject s ON q.subject_id = s.id WHERE q.is_deleted = 0;之后查询直接SELECT * FROM v_question_detail WHERE subject_name = ? AND knowledge_name = ?,SQL 短了,也不容易漏 join 条件。视图的代价是每次查询都展开,但题库系统读多写少,这个代价划算。
6.2 给题干加全文索引做模糊搜索
老师找题时经常只记得题干里几个关键词。LIKE '%关键词%'在数据量大时无法走索引,全表扫描。MySQL 的全文索引可以解决。
ALTER TABLE question ADD FULLTEXT INDEX ft_stem (stem) WITH PARSER ngram;ngram解析器是中文全文检索的关键,不指定的话默认按空格分词,中文会被当成一个整词。建好之后用MATCH(stem) AGAINST('关键词' IN BOOLEAN MODE)查询。注意全文索引对短词和停用词有限制,测试时用实际题干验证召回效果。
6.3 用 EXPLAIN 验证你的索引有没有被用上
写完查询别急着跑,前面加EXPLAIN看执行计划。
EXPLAIN SELECT id, stem FROM question WHERE subject_id = 1 AND knowledge_id = 5 AND difficulty BETWEEN 2 AND 4 ORDER BY RAND() LIMIT 10;重点看type列是不是ref或range,key列有没有命中你建的索引,rows列扫描行数是否合理。如果type是ALL,说明全表扫描,索引没生效,要检查查询条件顺序和索引列顺序是否匹配。ORDER BY RAND()一定会导致Using filesort,这是它的固有代价,能接受就用,不能接受就换随机起点方案。
我自己的习惯是每写一条带 WHERE 的查询就顺手 EXPLAIN 一下,这个动作花不了几秒,但能提前发现大部分性能问题。课程设计答辩时如果导师问“你这个查询走索引了吗”,你能直接打开执行计划讲,比背概念有说服力得多。
希望帮到你。
本文还有配套的精品资源,点击获取