简介:在线考试系统数据库设计文档,以PDF形式提供,面向需要设计考试系统数据库的开发人员、毕业设计学生及Java/.NET等后端学习者。文档基于MySQL(描述中亦提及SQL Server环境),详细规划了用户管理、学生信息、考试管理、课程管理、试卷管理、成绩管理以及单选/多选/判断/填空四类题库等10张核心数据表,对每张表的字段名、类型、位数、主键及是否允许为空均作了明确说明,可作为在线考试系统开发时的建表参照和课程设计参考,也可直接复制用于SQL脚本初始化数据层。资源仅含1个PDF文件,体积约71KB,内容紧凑、表结构清晰,便于直接查阅与复用。目前已有272人学习下载,适合正在搭建考试平台或学习数据库表结构设计的读者直接复用表结构,快速完成数据层设计。
1. 在线考试系统数据库设计:一张表撑不起一场考试
在线考试系统的 MySQL 数据库设计,难点从来不在“建几张表”,而在一场考试从组卷、报名、答题到判分的过程中,表与表之间的状态和关系能不能一直保持正确。很多团队前期只把题库表建完就开工,结果联调阶段发现并发交卷时答题记录重复、成绩表总分对不上,最后只能靠临时 SQL 洗数据。我习惯的做法是先拆业务流再落表:用户、题库、试卷、考试、答题、成绩六个核心域分开建,配合状态机和唯一键把并发口子堵住。这篇笔记会给出完整的建表脚本、三处关系陷阱的取舍,以及五个值得记进团队规范里的翻车现场。适合正在做在线考试系统后端、或者要重构旧考试模块数据库的同学。
2. 从业务流到表结构:先画出试卷与答题的状态机
设计考试系统的库,第一件事不是打开 MySQL 写 CREATE TABLE,而是把业务从头到尾走一遍:教师创建考试并组卷,系统发布考试,学生在规定时间内报名和答题,交卷后客观题自动判分、主观题人工评阅,最后发布成绩归档。这条链路里每个节点对数据的要求都不一样,直接一张大表硬扛,后面改起来全是血的教训。
2.1 核心实体识别:考试不是“一张表”
我先按业务动作把实体切成六个域:用户域管谁在考试,题库域管考什么,试卷域管怎么组卷,考试域管一场考试的时间与状态,答题域管学生写了什么,成绩域管最终结果。六个域各司其职,JOIN 的时候边界才清晰。
| 核心域 | 建议表名 | 一句话职责 |
|---|---|---|
| 用户域 | sys_user | 学生、教师、管理员统一存放 |
| 题库域 | question | 题干、选项、答案、解析、难度 |
| 试卷域 | paper / paper_question | 试卷主表与试卷题目关联 |
| 考试域 | exam | 考试场次、时间、状态 |
| 答题域 | answer_record | 每个学生每道题的作答明细 |
| 成绩域 | exam_result | 学生一场考试的总分与判分状态 |
这套拆分常见于企业内部的考试中台,也适合课程教学场景。关键原则是:试卷与考试分离、答题与成绩分离。试卷可以被多场考试复用,答题记录要保留每一次作答的原始痕迹,成绩表只是最终快照。三者的生命周期不同,塞进同一张表会让后续的统计查询和状态更新互相踩脚。
2.2 状态机:考试从草稿到已归档的六个状态
考试表的 status 字段是我最在意的一个设计,它不能省。很多新手直接用 start_time 和 end_time 判断考试是否在进行中,听起来没问题,但遇到提前发布、临时延期、人工归档就全乱套。我一般用六个状态:
| status 值 | 含义 | 触发时机 |
|---|---|---|
| 0 | 草稿 | 创建考试但未发布 |
| 1 | 已发布未开始 | 管理员点击发布 |
| 2 | 进行中 | 定时任务或启动接口触发 |
| 3 | 判分中 | 考试时间截止,交卷入口关闭 |
| 4 | 已出分 | 全部主观题判完并发布成绩 |
| 5 | 已归档 | 考试数据冻结,仅供查询 |
状态流转必须是单向的,不允许从 4 跳回 2。每一条流转都配合操作日志记录操作人、时间和原因。后台定时任务负责把到期考试从 2 推到 3,前端展示只读 status 字段,不参与判断。这样设计之后,查询正在进行的考试直接WHERE status = 2,走索引干净利落,不用在两个时间字段上做范围比较。
2.3 数据字典先行:先定字段命名规范再建表
建表前我会先花半小时定一套字段约定,后面写 JOIN 和排查问题时能省一整天的沟通成本。基本规范如下:主键统一叫xxx_id,类型 BIGINT 自增;时间字段统一create_time/update_time,用 DATETIME 而不是 TIMESTAMP,避免 2038 年问题;分数和金额统一DECIMAL(5,1),不用 FLOAT 和 DOUBLE,避免浮点误差;状态字段统一 TINYINT 并加上注释说明每个取值含义;所有表都带delete_flag做软删除,默认 0,删除置 1。这里有个容易忽略的细节:软删除标记不要参与唯一键。比如用户表的 username 做了唯一键,一旦用户被软删除,同名用户再注册就会撞唯一键。我通常把唯一键设计成(username, delete_flag),或者删除时把 username 改写为带时间戳的名字。
3. 核心表 DDL:考试系统 MySQL 建表的完整脚本
状态机和数据字典定完,建表就是体力活了。下面这份 DDL 是我在一个企业培训考试模块里沉淀下来的骨架,去掉了业务专属字段,保留最核心的结构。全部基于 MySQL 8.0,存储引擎 InnoDB,字符集 utf8mb4。
3.1 用户与角色表:角色不建三张表
用户表只要一张,角色用字段区分,不要拆成 student 表、teacher 表、admin 表三张。原因是三类角色的基础字段几乎一样,拆表只会让查询权限时多做一次 UNION。真正需要区分的是业务权限,那属于应用层或者权限表的事,不该由用户表结构承担。
CREATE TABLE sys_user ( user_id BIGINT AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL COMMENT '登录名', pass_hash VARCHAR(128) NOT NULL COMMENT '密码哈希值', real_name VARCHAR(50) NOT NULL COMMENT '姓名', role TINYINT NOT NULL DEFAULT 2 COMMENT '角色:1教师 2学生 3管理员', org_id BIGINT DEFAULT NULL COMMENT '班级/部门ID,外键逻辑关联', status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1正常 0禁用', delete_flag TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0未删 1已删', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (user_id), UNIQUE KEY uk_username (username, delete_flag), KEY idx_role (role) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';字段pass_hash只存哈希摘要,字段名不叫 password,提醒后续接手的人别往里面塞明文。唯一键(username, delete_flag)解决了我前面说的软删除后再注册的冲突问题。角色字段用 TINYINT 存枚举值,应用层做映射,查询和索引都比字符串高效。COLLATE=utf8mb4_0900_ai_ci是 MySQL 8.0 的默认排序规则,如果你的环境是 5.7,改成utf8mb4_general_ci即可,注意同一实例里排序规则尽量统一,否则关联比较时容易报字符集不一致的错误。
3.2 题库与试卷表:题目和试卷的多对多拆分
题目表单独存放,和试卷之间用一张关联表连接。题目表里我用 JSON 字段存选项,而不是拆成 option_a、option_b 这种固定列,原因很简单:判断题和填空题没有选项,主观题只有文本,固定列会浪费大量空间,而且新增一种题型要改表结构。JSON 在 MySQL 5.7 之后支持良好,配合应用层序列化完全够用。
CREATE TABLE question ( question_id BIGINT AUTO_INCREMENT COMMENT '题目ID', question_type TINYINT NOT NULL DEFAULT 1 COMMENT '题型:1单选 2多选 3判断 4填空 5主观', content TEXT NOT NULL COMMENT '题干内容', options JSON DEFAULT NULL COMMENT '选项JSON,如{"A":"选项内容","B":"选项内容"}', answer VARCHAR(500) DEFAULT NULL COMMENT '客观题标准答案,单选存"A",多选存"A,B"', analysis TEXT DEFAULT NULL COMMENT '答案解析', difficulty TINYINT NOT NULL DEFAULT 3 COMMENT '难度系数:1最易 5最难', subject_id BIGINT DEFAULT NULL COMMENT '科目ID,逻辑关联', creator_id BIGINT DEFAULT NULL COMMENT '创建人ID', 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_subject_diff (subject_id, difficulty), KEY idx_creator (creator_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='题目表';试卷表和题目关联表是配套的。同一道题可以出现在多张试卷里,同一张试卷包含多道题,这是典型的多对多关系,必须拆中间表。中间表里除了两个外键,还要带上“该题在这张试卷里的分值”和“排序号”,因为同一道题在不同试卷里分值可能不同。
CREATE TABLE paper ( paper_id BIGINT AUTO_INCREMENT COMMENT '试卷ID', paper_name VARCHAR(100) NOT NULL COMMENT '试卷名称', total_score DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '试卷总分,冗余字段', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1已使用 2停用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (paper_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='试卷表'; CREATE TABLE paper_question ( id BIGINT AUTO_INCREMENT COMMENT '关联ID', paper_id BIGINT NOT NULL COMMENT '试卷ID', question_id BIGINT NOT NULL COMMENT '题目ID', score DECIMAL(5,1) NOT NULL DEFAULT 2.0 COMMENT '该题在此试卷中的分值', sort_no INT NOT NULL DEFAULT 0 COMMENT '题目在试卷中的排序号', PRIMARY KEY (id), UNIQUE KEY uk_paper_question (paper_id, question_id), KEY idx_question (question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='试卷题目关联表';uk_paper_question是防止重复抽题的关键约束。组卷脚本无论执行多少遍,同一道题在一张试卷里都只可能出现一次。sort_no单独存,不用题目 ID 排序,因为题目 ID 的插入顺序和试卷展示顺序是两回事,人工调整题目顺序时只需要 UPDATE 这一行的sort_no。
3.3 答题与成绩表:交卷时的数据落法
考试表关联试卷,记录一场考试的时间窗和状态。答题记录表只存“某位考生在某场考试中对某道题的作答”,成绩表存“某位考生在某场考试的最终结果”。这两个表是考试系统的核心,设计时重点看唯一键和冗余策略。
CREATE TABLE exam ( exam_id BIGINT AUTO_INCREMENT COMMENT '考试ID', exam_name VARCHAR(100) NOT NULL COMMENT '考试名称', paper_id BIGINT NOT NULL COMMENT '关联试卷ID', start_time DATETIME NOT NULL COMMENT '考试开始时间', end_time DATETIME NOT NULL COMMENT '考试结束时间', duration_min INT NOT NULL DEFAULT 120 COMMENT '考试时长(分钟)', status TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0草稿 1已发布 2进行中 3判分中 4已出分 5已归档', creator_id BIGINT NOT NULL COMMENT '创建人ID', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (exam_id), KEY idx_paper (paper_id), KEY idx_status_time (status, start_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='考试表';CREATE TABLE answer_record ( record_id BIGINT AUTO_INCREMENT COMMENT '答题记录ID', exam_id BIGINT NOT NULL COMMENT '考试ID', user_id BIGINT NOT NULL COMMENT '用户ID', question_id BIGINT NOT NULL COMMENT '题目ID', user_answer VARCHAR(2000) DEFAULT NULL COMMENT '学生答案:客观题存选项编号,主观题存文本', is_correct TINYINT DEFAULT NULL COMMENT '客观题判分:1正确 0错误 NULL待判', score DECIMAL(5,1) DEFAULT NULL COMMENT '该题得分,主观题人工判分后写入', submit_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '交卷时间', PRIMARY KEY (record_id), UNIQUE KEY uk_exam_user_question (exam_id, user_id, question_id), KEY idx_exam_user (exam_id, user_id), KEY idx_question (question_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='答题记录表';CREATE TABLE exam_result ( result_id BIGINT AUTO_INCREMENT COMMENT '成绩ID', exam_id BIGINT NOT NULL COMMENT '考试ID', user_id BIGINT NOT NULL COMMENT '用户ID', objective_score DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '客观题得分', subjective_score DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '主观题得分', total_score DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '总分冗余', status TINYINT NOT NULL DEFAULT 0 COMMENT '成绩状态:0待判 1已判 2已发布', submit_time DATETIME DEFAULT NULL COMMENT '实际交卷时间', finish_time DATETIME DEFAULT NULL COMMENT '判分完成时间', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (result_id), UNIQUE KEY uk_exam_user (exam_id, user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='考试成绩表';答题记录表上的uk_exam_user_question是防重复交卷的命门。前端重复提交、接口重试、网络抖动导致的重复请求,都会撞上这个唯一键,配合INSERT ... ON DUPLICATE KEY UPDATE就能做到幂等写入。成绩表里的total_score是有意冗余的,不为了省一条 SUM 查询,而是因为成绩一旦发布,后续即使题目被调整或答案被修正,历史成绩也不能跟着变。成绩表是快照,不是视图。
4. 三处关系陷阱与索引设计:为什么连表查询越来越慢
结构定了,真正容易埋雷的是表和表之间的关系处理。我见过太多系统上线三个月后慢查询频发,问题都出在当初建表时没想清楚这三处关系。
4.1 试卷和题目:到底要不要中间表
答案是必须拆。有些同学图省事,把题目直接作为字段塞进试卷表,比如question_1_id、question_2_id、question_score_1这种设计。表面上查询一张表就够,实际上灾难刚刚开始。第一,题目数量没法固定,字段会越加越多,加一次不改表;第二,同一道题想被两场考试复用,数据得复制两份;第三,想统计试卷里单选、多选各占多少分,要写一堆 UNION 或 CASE WHEN。中间表加上唯一键和分值字段之后,这些全都变成普通查询。
-- 反例:把题目塞进试卷表的错误示范 CREATE TABLE paper_wrong ( paper_id BIGINT PRIMARY KEY, paper_name VARCHAR(100), question_1 BIGINT, question_2 BIGINT, question_3 BIGINT, score_1 DECIMAL(5,1), score_2 DECIMAL(5,1), score_3 DECIMAL(5,1) );一旦题目数量超过三,这张表就废了。正确做法就是上一章的paper_question中间表,查询某张试卷的所有题目时,JOIN 后按sort_no排序即可。中间表上不要建物理外键,只建普通索引。物理外键会在插入、删除时产生额外的锁检查和级联操作,在线考试系统高并发交卷阶段,外键就是性能杀手。完整性交给应用层事务去保证。
4.2 答题记录和成绩表:该冗余什么
答题记录是明细,成绩表是汇总。两者的字段重复是合理的,exam_result.objective_score就是answer_record里某场考试某学生所有客观题得分的加总。冗余的代价是更新时必须保证一致,我给判分脚本套事务,明细和汇总同时更新,任何一步失败整体回滚。
START TRANSACTION; UPDATE answer_record SET score = 3.0, is_correct = 1 WHERE exam_id = 1001 AND user_id = 55 AND question_id = 200; UPDATE exam_result SET subjective_score = 3.0, total_score = objective_score + 3.0 WHERE exam_id = 1001 AND user_id = 55; COMMIT;这里的逻辑说明:先更新明细,再更新汇总,事务保证两步的原子性。两行 UPDATE 的 WHERE 条件都带exam_id和user_id,因为成绩表是按考试和学生唯一的,答题记录是按考试、学生、题目唯一的。如果total_score不冗余,查成绩时每次都要 SUM 所有答题记录,数据量大时这条查询会非常慢。但冗余就要承担一致性的责任,判分并发量高时改用批量事务,每批 500 条,避免长事务锁住整个表。另外注意 MySQL 默认的 REPEATABLE READ 隔离级别下,事务里先 SELECT 再 UPDATE 可能产生间隙锁,我一般把判分事务改成直接 UPDATE 不预查询,减少锁范围。
4.3 索引怎么加:从 WHERE 和 JOIN 反推
索引设计没有玄学,我从实际查询反推。先列出系统最高频的 SQL,再给表加索引。常见场景有三个:查询某场考试进行中、查询某场考试某学生的答题明细、成绩发布后的分页列表。
-- 场景一:查询进行中的考试 SELECT exam_id, exam_name, end_time FROM exam WHERE status = 2 ORDER BY start_time DESC; -- 场景二:查询某学生某场考试的答题明细 SELECT question_id, user_answer, score FROM answer_record WHERE exam_id = 1001 AND user_id = 55; -- 场景三:后台成绩分页列表 SELECT user_id, total_score, submit_time FROM exam_result WHERE exam_id = 1001 AND status = 1 ORDER BY submit_time DESC LIMIT 20;场景一命中的索引是idx_status_time (status, start_time),状态过滤后按时间排序,左前缀原则下这是一个复合索引。场景二命中uk_exam_user_question,因为最左列是 exam_id,接着是 user_id,查询正好用上。场景三需要(exam_id, status, submit_time)方向的复合索引,但注意status区分度低,放在中间会影响索引效率。实际中我会用(exam_id, submit_time)加(exam_id, status)两个索引替代,让每个索引的过滤条件都有价值。给 TEXT 和 JSON 字段加索引没有意义,JSON 字段查询要依赖生成列,不建生成列就别指望走索引。
5. 在线考试数据库设计的避坑指南:五个翻车现场
这一章整理的是我在考试系统数据库上真实踩过的坑,每个都花了不止一个晚上排查。现象、原因、解决一条条写清楚,希望后来者少走弯路。
5.1 TEXT 存选项导致无法聚合统计
现象:想统计某道题 A、B、C、D 各被选了多少次,用来分析题目难度,结果 SQL 写出来慢到超时,因为user_answer字段里存的是完整的选项文本而不是选项编号。 原因:设计时图省事,把选项内容和用户答案原样写入,字符串长且重复度高,GROUP BY成了全表扫描,而且语义上根本没法聚合。 解决:answer_record.user_answer只存选项编号,单选存'A',多选存'A,B',选项的具体文本留在question.options的 JSON 里。统计时直接GROUP BY exam_id, question_id, user_answer,走索引,数据量再大也只是枚举值聚合。
5.2 考试时间判断写在 SQL 里导致索引失效
现象:查询“我当前正在进行的考试”时用了WHERE now() BETWEEN start_time AND end_time,数据量过万后响应时间超过一秒。 原因:在时间字段上做范围比较,start_time和end_time两个字段的联合判断让优化器无法稳定走索引,而且当 end_time 上有函数运算时索引直接失效。 解决:不在 SQL 里做时间判断,考试状态由定时任务维护。每 30 秒扫描一次exam表,把已到开始时间的置为 2,已到结束时间的置为 3。业务查询只对status字段过滤,走索引稳定高效。时间字段只用于展示和兜底校验。
5.3 自动提交与事务边界混乱导致成绩错乱
现象:判分脚本跑了一半进程崩溃,恢复后成绩表显示总分已经更新,但答题记录里的明细分数还是 NULL,学生成绩页面数据对不上。 原因:每条 UPDATE 都是自动提交,明细和汇总中间隔着网络 IO 或者计算逻辑,没有事务包住,一旦中断就会出现半更新状态。 解决:判分任务整体包事务,明细和总分一起提交或一起回滚。数据量大时按学生分批,每批 500 个事务,出错的批次整体重跑。重跑逻辑里用UPDATE ... WHERE score IS NULL做过滤,只处理没有判过的记录,天然支持断点续跑。
5.4 试卷抽题和交卷同时发生导致题目重复
现象:某次考试学生打开试卷发现第 8 题和第 25 题一模一样,系统也没有报错。 原因:组卷脚本用随机抽题后循环 INSERT,但没有在paper_question上建唯一键,脚本被并发触发两次后插入了重复数据。 解决:uk_paper_question (paper_id, question_id)唯一键兜底,插入用INSERT IGNORE或者INSERT ... ON DUPLICATE KEY UPDATE,遇到重复自动跳过并重新抽题。这个坑属于典型的“约束兜底”,应用层再严谨也挡不住并发,数据库层面的唯一键才是最后一道防线。
5.5 备份恢复时外键约束导致导入顺序翻车
现象:把生产库备份恢复到测试环境,导入时报外键约束错误,恢复失败。 原因:建表时为了图省事设置了物理外键,恢复了 10 分钟,报错说某个父表的数据还没插入。 解决:生产环境不建物理外键,只建普通索引,完整性由应用层保证。如果老系统已经有物理外键,临时恢复时在导入脚本前先执行SET FOREIGN_KEY_CHECKS = 0;,导入完成再恢复为 1。我个人的习惯是“物理外键只存在于文档里”,表关系画在 ER 图上,不写进 DDL,这样备份恢复、分库分表迁移时都能少掉一半麻烦。
6. 一套验收脚本:用 SQL 验证数据完整性
表建完不是终点,上开发环境之前,我会跑一组验收 SQL,验证最核心的两个能力:重复交卷的幂等性和报名名额的原子扣减。这两关过了,基本的高并发底线就有了。
6.1 用两组 SQL 验证幂等性和并发扣减
幂等性验证模拟同一道题的重复提交。线上会有网络重试、用户双击交卷按钮,请求打进来两次,数据库必须只留一条记录。
-- 模拟同一道题被重复提交两次 INSERT INTO answer_record (exam_id, user_id, question_id, user_answer) VALUES (1001, 55, 200, 'A') ON DUPLICATE KEY UPDATE user_answer = VALUES(user_answer); INSERT INTO answer_record (exam_id, user_id, question_id, user_answer) VALUES (1001, 55, 200, 'A') ON DUPLICATE KEY UPDATE user_answer = VALUES(user_answer); -- 验证结果应为 1,而不是 2 SELECT COUNT(*) FROM answer_record WHERE exam_id = 1001 AND user_id = 55 AND question_id = 200;逻辑说明:第二次 INSERT 撞上唯一键uk_exam_user_question,ON DUPLICATE KEY UPDATE变成更新操作,不会新增记录。如果报错,先检查唯一键是否真的建立在三个字段上。
并发扣减验证用条件更新模拟报名名额。学生报名时有一条名额上限,必须保证并发下不超卖。
-- 模拟报名:名额扣减的原子操作 UPDATE exam SET remain_seats = remain_seats - 1 WHERE exam_id = 1001 AND remain_seats > 0; -- 影响行数为 1 表示扣减成功,行数为 0 表示名额已满 SELECT ROW_COUNT();这里的逻辑说明:UPDATE的 WHERE 条件里带上remain_seats > 0,数据库在行锁内判断并更新,多并发请求不会同时扣到负数。比“先 SELECT 查询剩余名额再 UPDATE”稳得多,后者在并发下必然超卖。开发环境验证时开两个终端同时执行,观察影响行数之和是否等于初始名额。
6.2 预留的扩展位:多语言试卷与独立判分记录
最后说几个我可以提前预留的扩展设计,不一定现在就用,但表结构上留好口子能省一次重构。多语言试卷场景下,question表加一个lang_code字段,试卷表记录语言策略。主观题多人阅卷场景下,独立建一张判分记录表,存阅卷人、得分、评语和时间,别把多个阅卷人的数据塞进answer_record,因为一个学生一道题可能有多个阅卷结果。考场编排和反作弊场景下,建exam_session表关联考试和用户,扩展登录设备、摄像头监控记录。
我个人的习惯是:所有建表脚本带版本号放进迁移文件,不直接改生产库;每次发布前跑一遍第 6.1 节的验收 SQL;生产环境关闭 MySQL 的AUTOCOMMIT后,所有批量写操作必须声明事务边界,宁慢勿错。最后,SQL_SAFE_UPDATES 在测试环境一定要开,手滑全表更新这种事,我犯过一次,希望后来的你别再犯。希望帮到你。
本文还有配套的精品资源,点击获取