简介:这是一份面向在线考试系统开发者的数据库设计文档,以PDF形式呈现,聚焦于考试系统数据建模这一关键环节,适合后端工程师、数据库设计人员以及计算机相关专业的学生参考。文档详细列出了用户管理表、学生信息表、考试管理表、课程管理表、试卷管理表、成绩管理表以及单选、多选、判断、填空四类题库表,共计十张核心数据表;每张表均包含字段名称、字段说明、类型、位数、主键及非空约束,并明确了各表之间的关联字段,方便直接转化为SQL建表语句。资源共包含1个PDF文件,大小仅71KB,已有272人学习。通过学习这份设计,读者可快速理解在线考试系统的数据模型与关联关系,也能在开发或课程设计中复用这套表结构,减少从零建模的工作量。
1. 在线考试系统 MySQL 数据库设计:一份 PDF 算不清的账,全在表里
做在线考试系统,最容易被低估的就是 MySQL 数据库设计。业务方拿一份《在线考试系统数据库设计(表).pdf》过来说“照着建表就行”,等你真建完就会发现:题库、试卷、考试场次、学生答案、成绩单这五条线只要有一处关联错了,后面的统计、补考、人工判分全得返工。常见翻车不是表不够,而是先建 user 表、question 表,然后边开发边补字段,最后用一张“万能表”扛所有业务。这个方案的核心,是把在线考试系统拆成以考试为中心的关系模型:用户与权限、题库与试题、试卷快照、考试记录与答题明细。适合谁看?用 MySQL 做毕设、接手在线考试系统维护、准备给业务方做库表评审的开发。下面不追新特性,只讲能落地的建表脚本和排查路径。
2. 拆业务模型:在线考试系统的核心实体与关系边界
2.1 先定主链路:题库、试卷、考试、作答、成绩五条线
先别急着写 CREATE TABLE。把业务操作按顺序理一遍:教师维护题库 → 组装试卷 → 发布考试场次 → 给班级分配考生 → 学生登录作答 → 交卷判分 → 成绩查询导出。这条主链路对应五组核心表:题库、试卷、考试、考试学生、答题明细。每组表之间不是生硬的主从关系,而是有明确的生命周期。
比如“试卷总分”,可以从 paper_question 里 SUM 出来,但考试列表页要展示几十张卷子的总分,每次都 SUM 会拖慢查询。所以 paper 表要冗余 total_score 字段。字段冗余不是错,错的是不知道冗余的更新点在哪里。我一般只在“创建后不再频繁变化”的实体上做冗余,比如试卷总分、考试题量。学生成绩这种高频更新的值,冗余到 exam_student 表里,但必须以 answer_record 聚合结果为准,否则两边对不上。
另一个重点是“状态”。在线考试系统不是简单的增删改查,考试有“未开始、进行中、已结束”,试卷有“草稿、已发布”,学生有“未参加、已参加、已交卷”。每个状态字段都要在数据库层给默认值,并且应用层更新时带上旧状态条件,防止乱序请求覆盖。后面第五章的交卷更新会专门说。
2.2 用户信息表为什么要拆出角色和班级
很多 PDF 设计里,用户信息表只有一张,角色用 role 字段区分。小系统可以这么干,但一旦出现“老师只能看自己班学生”“教研组长可以改全校试卷”,单表角色字段就撑不住了。我当时接手过一个系统,用户表加了 is_admin、is_teacher、is_teacher_manager 三个布尔字段,结果组合权限逻辑全写在 Java 里,SQL 根本没法查“谁是这所学校的命题老师”。
更合理的做法是:用户信息表只存账号、姓名、密码哈希等稳定属性,角色单独建表,用户通过 role_id 挂角色。班级单独建表,学生用户存 class_id,老师用户可以为 NULL。这样“按班级导出学生名单”“统计某班平均分”直接走 class_id 索引,不需要从用户表里解析字符串。
还要注意:用户信息表的主键,我建议用 BIGINT UNSIGNED AUTO_INCREMENT,不要直接拿 username 做主键。用户名可能变更,或者一个学生在不同学期有不同班级,用自增主键做关联最稳。学号/工号可以加唯一索引,但那是业务唯一键,不是物理主键。这是很多数据库设计 PDF 里最容易忽略的地方。
2.3 题库与试题:试卷复用的前提是内容与集合分离
网上很多考试系统表设计把“题库”和“试题”混在一张表,bank_id 字段并不存在。这样带来的问题是:教师 A 和教师 B 各自建了一百道题,没有归属概念,换人接管后无从筛选题目。正确的做法是 question_bank 和 question 分开,题库表管“集合属性”,试题表管“题目内容”。
题目内容本身也要细分。单选、多选、判断、填空、简答这几种题型,存储方式不一样。客观题需要选项和标准答案,主观题只需要参考答案和分值。选项我建议用 JSON 字段存,而不是建 option_id、option_content 多张关联表。原因很简单:题目选项只在展示和判分时用,不会被单独查询。JSON 字段配合 MySQL 8.0 的 json 类型可以支持查询,但如果你用的是 MySQL 5.7,用 TEXT 存 JSON 也够用,应用层解析就行。
标准答案不能存明文?不是安全原因,而是格式统一问题。单选存“A”,多选存“A,B,C”,判断存“1/0”,填空可能多个空,简答存文本。统一用 answer 字段存 TEXT,判分逻辑在应用层根据 question_type 分别处理。不要把每个题型单独建列,否则加一种题型就要 ALTER TABLE 一次。
2.4 试卷是模板、考试是场次:快照表早点留
同一套试卷可以被不同班级、不同时间使用。所以 paper 表描述“试卷有什么题、总分多少、及格线多少”,exam 表描述“什么时候考、考多久、哪些人考”。这是两个生命周期完全不同的实体。
但真正的生产事故发生在“改题”。老师发布考试后,发现试卷里第三题题干打错了,直接在 paper_question 里改掉了题目内容。此时已经进入考场的考生,看到的是新题,而数据库里答案关联的题目内容也变成了新题。如果考试还没交卷,这个改动会让考生答案对不上。
解决方式:发布考试时,把 paper_question 复制一份到 exam_question 快照表。考试期间所有题目展示、判分都从 exam_question 读取,paper_question 随便改,不影响已经发布的考试。快照表字段可以比 paper_question 多一个 exam_id,表示这次考试考的是哪一版题目。这个设计必须在表结构评审时定下来,上线后补快照非常痛苦。
2.5 关系边界:外键到底建不建
MySQL 里外键是个争议话题。很多人说生产环境禁用物理外键,因为删除和导入时有锁顺序问题;也有人说不用外键会出一堆孤儿数据。我自己的标准是:配置类、基础资料类表用物理外键,流水类、高频写入类表用逻辑外键。
比如 question_bank 的 owner_id 指向 sys_user,班级表、角色表这种低频更新的基础表,可以加 FOREIGN KEY。而 answer_record、exam_student 这种每秒并发插入的流水表,不加物理外键,只建普通索引。原因是 InnoDB 在外键检查时会加共享锁,高并发交卷场景下容易造成锁等待。逻辑外键靠应用层在写入前校验,配合定时任务扫描异常数据。
另一个容易被忽略的点:外键字段的类型和长度必须完全一致。INT UNSIGNED 和 BIGINT UNSIGNED 连 JOIN 都会隐式转换,索引失效。建表时统一约定:用户主键 BIGINT UNSIGNED,班级主键 INT UNSIGNED,所有引用字段保持一致,能省掉大量排查时间。
3. 落地成表:在线考试系统的 MySQL 建表脚本与字段说明
3.1 用户信息表、班级表、角色表:先建三张基础表
先建班级表,因为用户表要引用它。班级表字段不多,但 grade_name 建议保留,方便做“2024 届高三”这类筛选。
CREATE TABLE sys_class ( class_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '班级ID', class_name VARCHAR(50) NOT NULL COMMENT '班级名称', grade_name VARCHAR(20) NOT NULL DEFAULT '' COMMENT '年级/届别', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (class_id), KEY idx_grade_name (grade_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='班级表';班级表使用 INT UNSIGNED 足够,班级数量不可能超过千万。grade_name 单独建索引,方便按届别统计。create_time 用 DEFAULT CURRENT_TIMESTAMP,插入时不用显式传值。
然后是用户信息表,这是在线考试系统数据库设计里最核心的基础表。
CREATE TABLE sys_user ( user_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录账号', password_hash VARCHAR(100) NOT NULL COMMENT 'BCrypt加密哈希', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', role_id SMALLINT UNSIGNED NOT NULL COMMENT '角色ID', class_id INT UNSIGNED NULL COMMENT '学生所属班级,非学生为NULL', user_no VARCHAR(32) NULL COMMENT '学号或工号', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (user_id), UNIQUE KEY uk_username (username), UNIQUE KEY uk_user_no (user_no), KEY idx_class_id (class_id), CONSTRAINT fk_user_class FOREIGN KEY (class_id) REFERENCES sys_class (class_id), CONSTRAINT fk_user_role FOREIGN KEY (role_id) REFERENCES sys_role (role_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表';这里 user_id 用 BIGINT UNSIGNED,是为了和 answer_record 等流水表关联时留足空间。username 加了唯一索引,登录查询直接命中唯一键。password_hash 只存哈希,不要存明文。update_time 使用 ON UPDATE CURRENT_TIMESTAMP,每次更新自动刷新。
注意 sys_user 外键同时引用了 sys_class 和 sys_role,但 sys_role 在 sys_user 之前还没建,所以建表时要先建 sys_role。
CREATE TABLE sys_role ( role_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '角色ID', role_code VARCHAR(30) NOT NULL COMMENT '角色编码', role_name VARCHAR(30) NOT NULL COMMENT '角色名称', PRIMARY KEY (role_id), UNIQUE KEY uk_role_code (role_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色表';角色表只有几行数据,不需要冗余时间字段。role_code 建议用 ADMIN、TEACHER、STUDENT,应用层判断权限时读 code,不要读 role_id,避免环境不同角色 ID 不一致。
3.2 学科、题库与试题:题干、选项与答案的存法
先建学科表,题库和试题都会引用学科。
CREATE TABLE subject ( subject_id SMALLINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '学科ID', subject_name VARCHAR(30) NOT NULL COMMENT '学科名称', PRIMARY KEY (subject_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学科表';学科数据固定,插入语文、数学、英语等即可。接下来是题库表。
CREATE TABLE question_bank ( bank_id INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '题库ID', bank_name VARCHAR(100) NOT NULL COMMENT '题库名称', subject_id SMALLINT UNSIGNED NOT NULL COMMENT '学科ID', owner_id BIGINT UNSIGNED NOT NULL COMMENT '创建人用户ID', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (bank_id), KEY idx_subject_id (subject_id), KEY idx_owner_id (owner_id), CONSTRAINT fk_bank_subject FOREIGN KEY (subject_id) REFERENCES subject (subject_id), CONSTRAINT fk_bank_owner FOREIGN KEY (owner_id) REFERENCES sys_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题库表';owner_id 指向 sys_user,但业务上只允许 TEACHER 角色创建题库,这个约束放应用层。数据库层如果建 CHECK 也无法查角色表,所以不强行加。status 默认 1,停用的题库不应再被新试卷引用。
试题表是题库下面真正的核心表。
CREATE TABLE question ( question_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '试题ID', bank_id INT UNSIGNED NOT NULL COMMENT '所属题库ID', subject_id SMALLINT UNSIGNED NOT NULL COMMENT '学科ID', question_type TINYINT UNSIGNED NOT NULL COMMENT '1单选 2多选 3判断 4填空 5简答', content TEXT NOT NULL COMMENT '题干内容', options_json TEXT NULL COMMENT '选项JSON,如[{"key":"A","text":"..."}]', answer TEXT NOT NULL COMMENT '标准答案或参考答案', score DECIMAL(5,2) NOT NULL DEFAULT 5.00 COMMENT '默认分值', difficulty TINYINT NOT NULL DEFAULT 3 COMMENT '难度1-5', status TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (question_id), KEY idx_bank_type (bank_id, question_type), KEY idx_subject_difficulty (subject_id, difficulty), KEY idx_status (status), CONSTRAINT fk_question_bank FOREIGN KEY (bank_id) REFERENCES question_bank (bank_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试题表';options_json 用 TEXT 存,因为选项不是查询条件。content 用 TEXT 而不是 VARCHAR,题干可能很长,VARCHAR 最大 65535 字节但不够灵活。answer 也用 TEXT,填空题可能存多个空答案。idx_bank_type 是复合索引,支持“某个题库下按题型筛题”的组卷场景。subject_id 和 difficulty 的复合索引,支持“从某学科随机抽指定难度题目”。注意随机抽题需要 ORDER BY RAND(),这个索引只负责筛选,不负责排序。
3.3 试卷表和试题关联表:把每道题的分值钉死
试卷表描述试卷本身。
CREATE TABLE paper ( paper_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '试卷ID', paper_name VARCHAR(100) NOT NULL COMMENT '试卷名称', subject_id SMALLINT UNSIGNED NOT NULL COMMENT '学科ID', total_score DECIMAL(6,1) NOT NULL DEFAULT 0 COMMENT '试卷总分', pass_score DECIMAL(6,1) NOT NULL DEFAULT 60 COMMENT '及格分', duration_minutes SMALLINT UNSIGNED NOT NULL DEFAULT 120 COMMENT '建议时长(分钟)', question_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '题目数量', status TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已发布', creator_id BIGINT UNSIGNED NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (paper_id), KEY idx_subject_status (subject_id, status), KEY idx_creator_id (creator_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷表';total_score 是冗余字段,插入题目后需要维护。question_count 同理。为什么冗余?因为列表页显示试卷卡片时,不可能每次 SUM。维护时机在应用层组卷完成时计算,不要靠数据库触发器,触发器在 MySQL 中调试麻烦,批量导入时容易漏。
试卷和试题是多对多关系,中间表要额外存题号和本题分值。
CREATE TABLE paper_question ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID', paper_id BIGINT UNSIGNED NOT NULL COMMENT '试卷ID', question_id BIGINT UNSIGNED NOT NULL COMMENT '试题ID', seq SMALLINT UNSIGNED NOT NULL COMMENT '题号,从1开始', score DECIMAL(5,2) NOT NULL COMMENT '本题分值', PRIMARY KEY (id), UNIQUE KEY uk_paper_question (paper_id, question_id), UNIQUE KEY uk_paper_seq (paper_id, seq), KEY idx_question_id (question_id), CONSTRAINT fk_pq_paper FOREIGN KEY (paper_id) REFERENCES paper (paper_id), CONSTRAINT fk_pq_question FOREIGN KEY (question_id) REFERENCES question (question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷题目关联表';uk_paper_question 防止同一道题被重复加入同一张试卷;uk_paper_seq 保证同一试卷内题号唯一。这两个唯一约束很关键,能拦截掉应用层组卷时的重复数据。seq 表示题目顺序,必须显式插入和查询,因为 MySQL 默认不保证无 ORDER BY 时的顺序稳定。
考试发布后,还要复制一份试卷到考试快照表,结构类似。
CREATE TABLE exam_question ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID', exam_id BIGINT UNSIGNED NOT NULL COMMENT '考试场次ID', question_id BIGINT UNSIGNED NOT NULL COMMENT '试题ID', seq SMALLINT UNSIGNED NOT NULL COMMENT '题号,从1开始', score DECIMAL(5,2) NOT NULL COMMENT '本题分值', PRIMARY KEY (id), UNIQUE KEY uk_exam_question (exam_id, question_id), KEY idx_exam_seq (exam_id, seq) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试题目快照表';这张表不设物理外键,因为它要在发布考试时从 paper_question 复制,并且要保留历史快照,哪怕 question 表里的题目后来被删了,快照也不能跟着删。所以这里只能用逻辑外键。score 字段在判分时使用,不要再去 JOIN paper_question 取分值,否则快照就失去意义。
3.4 考试场次、考试学生与答题明细:三张流水表
考试场次表描述一次具体的考试。
CREATE TABLE exam ( exam_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '考试场次ID', exam_name VARCHAR(100) NOT NULL COMMENT '考试名称', paper_id BIGINT UNSIGNED NOT NULL COMMENT '使用的试卷ID', class_id INT UNSIGNED NULL COMMENT '指定班级,NULL表示全校', start_time DATETIME NOT NULL COMMENT '开考时间', end_time DATETIME NOT NULL COMMENT '结束时间', duration_minutes SMALLINT UNSIGNED NULL COMMENT '考试时长,NULL则使用试卷时长', allow_retry TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许重考', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (exam_id), KEY idx_paper_id (paper_id), KEY idx_status_time (status, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试场次表';class_id 为空表示面向所有学生。start_time 和 end_time 是业务核心判断条件。status 可以后台定时任务扫描更新,也可以应用层在查询时按当前时间实时推导,具体看业务对准确度的要求。考试过程中考试状态变化频率低,不建物理外键,避免和 paper 的草稿/发布状态耦合。
考试学生表保存“谁参加了哪场考试”,以及最终的考试成绩状态。
CREATE TABLE exam_student ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID', exam_id BIGINT UNSIGNED NOT NULL COMMENT '考试场次ID', student_id BIGINT UNSIGNED NOT NULL COMMENT '学生用户ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未参加 1已参加 2已交卷', submit_time DATETIME NULL COMMENT '交卷时间', total_score DECIMAL(6,1) NULL COMMENT '总分,NULL表示未出分', need_judge TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要人工判分', judge_status TINYINT NOT NULL DEFAULT 0 COMMENT '0未批 1待批 2已批', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_exam_student (exam_id, student_id), KEY idx_student_id (student_id), KEY idx_exam_status (exam_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试学生表';uk_exam_student 保证同一位学生在一场考试里只有一条汇总记录。allow_retry 如果开启,重考需要新建一场 exam_id,而不是复用同一场考试,否则这个唯一约束会挡掉重考。total_score 是冗余汇总值,由判分流程写入。need_judge 表示试卷里有没有主观题,方便人工判分列表筛选。
答题明细表记录每一道题的作答内容。
CREATE TABLE answer_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '自增ID', exam_id BIGINT UNSIGNED NOT NULL COMMENT '考试场次ID', student_id BIGINT UNSIGNED NOT NULL COMMENT '学生用户ID', question_id BIGINT UNSIGNED NOT NULL COMMENT '试题ID', answer_content TEXT NULL COMMENT '学生答案', is_correct TINYINT NULL COMMENT '客观题是否正确,1是0否,主观题为NULL', get_score DECIMAL(5,2) NOT NULL DEFAULT 0 COMMENT '本题得分', judge_status TINYINT NOT NULL DEFAULT 0 COMMENT '0自动已判 1待主观判 2已判', submit_time DATETIME NOT NULL COMMENT '作答时间', PRIMARY KEY (id), UNIQUE KEY uk_exam_student_question (exam_id, student_id, question_id), KEY idx_exam_student (exam_id, student_id), KEY idx_question_id (question_id), KEY idx_exam_judge (exam_id, judge_status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='答题明细表';uk_exam_student_question 是这道防线,它让同一场考试同一个学生对同一道题只能有一条答案。如果应用层重复提交,MySQL 会直接报唯一键冲突,而不是产生两条脏数据。idx_exam_judge 支持人工判分人员按“某场考试待判题目”分页查询。answer_record 与 exam_question 之间用逻辑外键,因为要保留已删除题目的历史答题数据。
4. 在线考试系统数据库设计的避坑记录:五条血泪经验
4.1 题目顺序飘忽:没排序导致试卷每次进都不一样
现象:同一张试卷,学生 A 进入考试时第一题是单选题,学生 B 进入时第一题变成了判断题。老师怀疑组卷逻辑有玄学 bug。
原因:这一般不是组卷 bug,而是查询题目没有 ORDER BY seq。MySQL 的 SELECT 在不指定 ORDER BY 时,返回顺序取决于执行计划和存储引擎的扫描顺序,主键顺序只能作为参考,不能当作业务顺序。paper_question 中虽然插入了 seq,但应用层 JOIN 后没有按 seq 排序,数据库就按 InnoDB 的聚簇索引顺序返回。
解决:所有展示题目、导出试卷的 SQL,都要显式 ORDER BY pq.seq。如果分页查询,可以在 paper_question 的复合索引上做覆盖:SELECT question_id, seq, score FROM paper_question WHERE paper_id = ? ORDER BY seq。这条语句用 uk_paper_seq 索引即可,避免回表。
4.2 答题明细出现同题两条数据:唯一约束没建
现象:交卷高峰时,学生快速点击“保存”和“交卷”,后台接口并发调用了两次,answer_record 表里同一个 exam_id + student_id + question_id 出现了两条记录。EXPLAIN 看不出问题,但成绩统计 SUM 时分数翻倍。
原因:应用层先 SELECT 查一下有没有记录,没有就 INSERT。两个请求同时通过 SELECT 都发现没有记录,于是各自 INSERT。这就是典型的“先查后插”竞态,事务隔离级别也拦不住。
解决:建 UNIQUE KEY uk_exam_student_question,写入时用 INSERT ... ON DUPLICATE KEY UPDATE,让数据库自己处理重复。不要用 REPLACE INTO,REPLACE 会先 DELETE 再 INSERT,导致自增 ID 断裂,并且会破坏已有审计数据。用 ON DUPLICATE KEY UPDATE 只更新答案内容和提交时间,不会删行。
4.3 改一题、毁一场考试:试卷没有快照
现象:线上考试进行到一半,老师发现第二题题干有个错别字,直接改了 question 表。考完统计时,明明选 A 的学生被判错,因为标准答案也被顺手改了。
原因:question 表是公共题库,可被多张试卷引用。改题时影响的是所有引用它的试卷,包括已经发布、正在进行的考试。如果判分逻辑直接从 question 读 answer,就会用新答案判旧答卷。
解决:发布考试时,把 paper_question 复制到 exam_question 快照。判分、展示考卷全部从 exam_question 取题干和分值。question 表只作为题库内容,线上考试不直接引用。快照表里也可以冗余题干文本,但通常不需要,因为考试期间题目基本不会改;如果怕老师改题,可以把题干也复制进去,代价是存储量变大。
4.4 中文变问号、JOIN 变慢:字符集与排序规则混乱
现象:导入学生姓名后,所有中文变成问号;另一个现象是 sys_user 关联 sys_class 时,明明两边都有索引,执行计划却显示全表扫描。
原因:字符集不一致是最常见的原因。表 A 建库时用了 utf8,表 B 用了 utf8mb4,两个表 JOIN 时 MySQL 需要做字符集转换,导致索引失效。中文问号则是连接串没有指定 characterEncoding,或字段字符集不是 utf8mb4。
解决:建库时统一 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci。MySQL 8.0 也可以用 utf8mb4_0900_ai_ci,但为了兼容 5.7,我更习惯 unicode_ci。所有 CREATE TABLE 都显式写字符集,不要依赖全局配置。已经乱的库,用 ALTER TABLE 把表、字段、连接串一次性统一,然后重启连接池。
4.5 存储过程批量判分把数据库锁死
现象:交卷后触发一个存储过程,循环 update answer_record 和 exam_student,考完半小时数据库出现 ERROR 1205 lock wait timeout exceeded,随后大量连接堆积。
原因:存储过程里每个 update 单独提交或放在一个大事务里,随着题量增多占用锁时间变长。如果 update 语句没有走 exam_id、student_id 索引,InnoDB 只能锁住更多行,甚至升级为锁表。在线考试系统不适合用存储过程批量判分,这不是 MySQL 的强项。
解决:判分放到应用层,比如 Java 里用批量 SQL:先一次性查出 exam_question 标准答案,然后在内存里判分,最后按 batch 更新 answer_record。每次提交控制在 100 题以内,事务短,锁范围小。数据库只负责存储,不要试图用存储过程解决所有业务逻辑。
5. 从空库到可测试:初始化脚本与三张必跑统计查询
5.1 建库、选字符集、设隔离级别
拿到空 MySQL 实例,第一步是建库。不要直接在生产实例上跑 PDF 里的建表脚本,先建一个独立 database。
CREATE DATABASE IF NOT EXISTS exam_db DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;这里把默认字符集和排序规则写死在 DDL 里,后续 CREATE TABLE 不指定也能继承。如果已有别的库,注意不要混用字符集。
事务隔离级别保持 InnoDB 默认的 REPEATABLE READ,不要为了“性能”改成 READ-UNCOMMITTED。在线考试系统要求成绩准确,脏读不可接受。MySQL 的 REPEATABLE READ 在标准事务下足够用,唯一要注意的是长事务,判分和交卷的更新必须短平快。
SET GLOBAL transaction_isolation = 'REPEATABLE-READ'; SET SESSION transaction_isolation = 'REPEATABLE-READ';MySQL 5.7 用 tx_isolation,8.0 用 transaction_isolation。生产环境一般不改全局,这条命令更多是确认你的客户端连接参数没有被设置成 READ-COMMITTED。
5.2 灌入一套最小可运行的种子数据
建完表后,先插入基础数据,再插入业务数据。顺序不能乱,因为外键约束存在。下面的脚本演示最常用的最小集。
INSERT INTO sys_role (role_code, role_name) VALUES ('ADMIN', '管理员'), ('TEACHER', '教师'), ('STUDENT', '学生'); INSERT INTO subject (subject_name) VALUES ('语文'), ('数学'), ('英语'); INSERT INTO sys_class (class_name, grade_name) VALUES ('高三(1)班', '2024届'), ('高三(2)班', '2024届'); INSERT INTO sys_user (username, password_hash, real_name, role_id, class_id, user_no) VALUES ('admin', '$2a$10$e0MYzXyjpJS7Pd0RVvHwHe1HlwVtUYJX9WZ8P0zzZQqGzDn7zS1W', '系统管理员', 1, NULL, '10001'), ('teacher01', '$2a$10$e0MYzXyjpJS7Pd0RVvHwHe1HlwVtUYJX9WZ8P0zzZQqGzDn7zS1W', '王老师', 2, NULL, '20001'), ('stu0001', '$2a$10$e0MYzXyjpJS7Pd0RVvHwHe1HlwVtUYJX9WZ8P0zzZQqGzDn7zS1W', '张三', 3, 1, '20240001');这里 password_hash 是 BCrypt 格式,实际生成要用加密工具,不要用明文。user_no 加了唯一索引,导入学生时重复学号会直接报错,这比应用层判断更可靠。
接着插入一道题和一张简单试卷。
INSERT INTO question_bank (bank_name, subject_id, owner_id) VALUES ('高三数学题库', 2, 2); INSERT INTO question (bank_id, subject_id, question_type, content, options_json, answer, score) VALUES (1, 2, 1, '函数 f(x)=x^2 的对称轴是?', '[{"key":"A","text":"x=0"},{"key":"B","text":"x=1"},{"key":"C","text":"y=0"},{"key":"D","text":"y=1"}]', 'A', 5.00); INSERT INTO paper (paper_name, subject_id, total_score, pass_score, duration_minutes, question_count, status, creator_id) VALUES ('高三数学月考', 2, 100.0, 60, 120, 20, 1, 2); INSERT INTO paper_question (paper_id, question_id, seq, score) VALUES (1, 1, 1, 5.00);paper 表里 question_count 为 20,但只插了一道题。这是为了演示而简化,正式数据要保证 question_count 和 paper_question 实际数量一致。所以组卷接口里应该用 COUNT 结果回写这两个字段,而不是手工维护。
5.3 三张必跑统计查询:分数段、缺考名单、得分一致性
数据库设计得再漂亮,最终要回答业务问题。这类统计是上线第一天就会被问到的,先把 SQL 备好。
分数段统计,最常用的是按优秀、及格、不及格分组。
SELECT CASE WHEN total_score >= 90 THEN '优秀' WHEN total_score >= 60 THEN '及格' ELSE '不及格' END AS score_level, COUNT(*) AS student_count FROM exam_student WHERE exam_id = 1 AND status = 2 GROUP BY score_level ORDER BY score_level;这段 SQL 使用 total_score 字段做分组。注意 CASE WHEN 的别名可以用于 GROUP BY 吗?MySQL 允许,但为了兼容性,一般建议把 CASE 语句完整写在 GROUP BY 后。这里能直接跑,是因为 MySQL 对 GROUP BY 支持别名。更稳妥的写法是外面套一层子查询。
缺考名单,用于考试结束后向班主任导出。
SELECT u.user_no, u.real_name, c.class_name FROM exam_student es JOIN sys_user u ON u.user_id = es.student_id LEFT JOIN sys_class c ON c.class_id = u.class_id WHERE es.exam_id = 1 AND es.status = 0 ORDER BY c.class_id, u.user_no;status = 0 表示未参加。LEFT JOIN 是为了兼容学生班级为 NULL 的异常数据,避免漏报。
得分一致性校验,这是数据库设计完成后最该跑的验证 SQL。
SELECT es.exam_id, es.student_id, es.total_score AS saved_score, COALESCE(SUM(ar.get_score), 0) AS calc_score FROM exam_student es LEFT JOIN answer_record ar ON ar.exam_id = es.exam_id AND ar.student_id = es.student_id WHERE es.exam_id = 1 AND es.status = 2 GROUP BY es.exam_id, es.student_id, es.total_score HAVING ABS(saved_score - calc_score) > 0.01;HAVING 条件筛选出“汇总分对不上”的记录。这个查询在每次改完判分逻辑后都要跑一遍。0.01 是浮点误差容忍度,不要用等号判断 DECIMAL 求和,因为 SUM 的结果在小数位上可能因数据类型产生细微偏差。
5.4 上线前用 explain 验证索引是否生效
建好表、灌完数据,不代表查询就一定快。上线前用 EXPLAIN 把核心查询都过一遍。
EXPLAIN SELECT u.user_no, u.real_name, ar.answer_content FROM answer_record ar JOIN sys_user u ON u.user_id = ar.student_id WHERE ar.exam_id = 1 AND ar.student_id = 10003;重点看 type 列。出现 ALL 说明全表扫描。出现 ref 或 range 表示用到了索引。再看 possible_keys 和 key 是否一致。如果 key 为 NULL,检查字段类型是否一致、是否用了函数包裹、字符集是否统一。这条 SQL 本应通过 uk_exam_student 快速定位到单行,如果变成 ALL,多半是 student_id 在 answer_record 里是 VARCHAR,而 sys_user 里是 BIGINT,隐式转换导致索引失效。
6. 最后一件事:把索引、并发和变更脚本收成习惯
6.1 索引不是越多越好,但外键和 where 字段必须建
在线考试系统里,最核心的访问路径是“某场考试、某个学生、某道题”。answer_record 的唯一索引已经把这条路封住了。其他表不要每个字段都加索引,尤其是 TEXT 字段不能直接加普通索引。判分查询经常按照 exam_id + judge_status 过滤,已经建了 idx_exam_judge,不要再单独建 exam_id 单列索引,复合索引最左前缀可以复用。索引是空间换时间,一个表超过八个索引之后,插入性能下降明显。
6.2 交卷接口用状态位做乐观更新
学生重复点击交卷,最容易造成成绩覆盖。与其加分布式锁,不如用一条 UPDATE 带状态条件。
UPDATE exam_student SET status = 2, submit_time = NOW(), total_score = ? WHERE exam_id = ? AND student_id = ? AND status = 1;如果影响行数为 0,说明这条记录不是本次要交卷的状态,要么已经交过了,要么还没开始考试。应用层拿到影响行数后直接提示“请勿重复交卷”,不需要再 SELECT 一次。这个套路也适用于人工判分完成状态的流转,只要把 status 换成 judge_status 即可。
6.3 每次 DDL 变更都留脚本
我早年在生产库直接执行 ALTER TABLE,没留脚本,后来对账对到凌晨。现在习惯是:所有结构变更都写进 migration 目录,文件名带日期和用途。哪怕是加一个索引,也要写成可重复执行的脚本。
ALTER TABLE answer_record ADD KEY idx_exam_judge_status (exam_id, judge_status);一条 ALTER 语句,放在变更记录里,和代码一起走发布流程。这样半年后有人问“这个字段哪来的”,翻 migration 记录就能找到答案。数据库设计不是一锤子买卖,在线考试系统尤其如此,因为考试场次、题库内容会持续演进。希望帮到你。
本文还有配套的精品资源,点击获取