ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Django在线考试系统:数据库建模、随机组卷与并发交卷实战

Django在线考试系统:数据库建模、随机组卷与并发交卷实战 简介基于 Django 的在线考试系统完整项目采用前后端分离架构面向高校毕业设计、课程作业及需要快速搭建考试平台的开发者。系统实现管理员、教师、学生三角色权限分级管控支持班级与课程关联绑定覆盖题库创建、手动/随机组卷、考试发布、在线答题、自动阅卷与成绩统计等完整流程支持单选、多选、判断、填空、简答及编程题编程题可实现在线运行 Python 代码。资源包共 607 个文件、21.63MB其中 py 为后端业务逻辑vue 与 js 构成前端界面svg 为图标资源sql 为数据库初始化脚本另有运行启动脚本与配置说明便于本地部署与二次开发。压缩包附带数据库文档及完整源码前后端目录划分清晰可帮助读者快速定位各功能模块深入理解在线考试系统的整体设计思路。目前已有 66 人学习下载适合计算机相关专业学生参考借鉴。1. 在线考试系统到底难在哪并发交卷、成绩可信和数据库文档期末季帮朋友救火改一个 django 在线考试系统最常见的翻车不是功能没做完而是 60 个人同时交卷数据库直接锁死成绩对不上。这个场景对 django 项目实战新手特别有代表性单机开发时一切正常一放到真实考场就暴露并发和一致性问题。这篇笔记把基于 python django 的在线考试系统设计与实现从头拆一遍覆盖数据建模、随机组卷、自动判分、数据库文档和线上排错。你会看到每张表为什么存在、交卷为什么必须幂等、数据库文档到底该写哪些东西以及一组真金白银踩过的坑。目标很直接照着这套设计你能跑通一个能真实交付的考试系统而不是只停留在 CRUD 演示。2. Django 端到端建模6 张表把一场考试从头管到尾2.1 先画实体关系考试、题库、试卷、会话、答题、成绩在线考试系统的核心不是“考试”这一个概念而是六个实体。很多人第一版只做了 Exam 和 Question 两张表等做到一半才发现缺了“考生这场考试进行到哪了”的关键记录。我建议一开始就按考试生命周期来划分考试定义考什么、题库与试卷怎么考、考试会话考生考到哪、答题明细每道题答了什么、最终成绩得了几分。这六张表分别是 Exam考试场次、Question题目、Paper试卷、ExamAttempt一次考试会话、AnswerRecord逐题作答记录、成绩可以直接挂在 ExamAttempt 上也可以独立成 Grade 表。我的做法是成绩字段放在 ExamAttempt 里因为一次会话对应一条成绩不需要再多一次关联如果需要按学期做成绩汇总分析再单独建视图或聚合表。一个容易忽略的设计点Paper 和 Exam 是一对多还是多对多如果允许同一场考试有 A/B 卷、或者随机抽题后每个考生试卷不同Paper 必须单独存在且 Paper 与 Question 是多对多关系。很多简单系统把试卷直接挂在 Exam 下一套题所有人共用功能上没问题但一旦要做防作弊的乱序抽题就要重构表结构。2.2 落成 Django 模型可以直接抄的核心模型代码我习惯用 startproject 后 startapp exam 的方式组织代码所有考试相关模型放一个 app 里。下面是这套系统的核心模型字段上做了删减保留最关键的部分from django.db import models from django.contrib.auth.models import User from django.utils import timezone class Exam(models.Model): 考试场次一场考试的定义 STATUS_CHOICES ( (draft, 草稿), (published, 已发布), (finished, 已结束), ) title models.CharField(考试名称, max_length100) duration models.PositiveIntegerField(考试时长(分钟), default60) start_at models.DateTimeField(开始时间, defaulttimezone.now) end_at models.DateTimeField(结束时间) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultdraft) created_by models.ForeignKey(User, nullTrue, blankTrue, on_deletemodels.SET_NULL) created_at models.DateTimeField(auto_now_addTrue) class Meta: indexes [models.Index(fields[status, start_at])] def __str__(self): return self.title class Question(models.Model): 题库单道题目 TYPE_CHOICES ( (single, 单选), (multi, 多选), (judge, 判断), (short_answer, 简答), ) DIFFICULTY_CHOICES ((1, 易), (2, 中), (3, 难)) exam models.ForeignKey(Exam, related_namequestion_bank, on_deletemodels.CASCADE) type models.CharField(题型, max_length20, choicesTYPE_CHOICES) content models.TextField(题干) # 选项和答案都存 JSON前端拿到后动态渲染 options models.JSONField(选项, defaultlist) # [{key: A, text: ...}] answer models.JSONField(标准答案, defaultlist) # [A] 或 [A, C] difficulty models.PositiveSmallIntegerField( 难度, choicesDIFFICULTY_CHOICES, default2) score models.PositiveIntegerField(分值, default2) created_at models.DateTimeField(auto_now_addTrue) def __str__(self): return f{self.get_type_display()}: {self.content[:20]} class Paper(models.Model): 试卷一次实际发放的题目集合 exam models.ForeignKey(Exam, related_namepapers, on_deletemodels.CASCADE) questions models.ManyToManyField(Question, related_namepapers) version models.CharField(卷号, max_length10, defaultA) created_at models.DateTimeField(auto_now_addTrue) class ExamAttempt(models.Model): 考试会话考生某一次考试的完整状态 STATUS_CHOICES ( (doing, 进行中), (finished, 已交卷), (expired, 超时), ) exam models.ForeignKey(Exam, related_nameattempts, on_deletemodels.CASCADE) student models.ForeignKey(User, related_nameattempts, on_deletemodels.CASCADE) paper models.ForeignKey(Paper, related_nameattempts, on_deletemodels.CASCADE) start_time models.DateTimeField(defaulttimezone.now) submit_time models.DateTimeField(nullTrue, blankTrue) status models.CharField(状态, max_length10, choicesSTATUS_CHOICES, defaultdoing) score models.DecimalField(总分, max_digits6, decimal_places2, nullTrue, blankTrue) ip_address models.GenericIPAddressField(交卷IP, nullTrue, blankTrue) class Meta: # 高频查询一场考试下所有考生、一个考生的所有考试 indexes [ models.Index(fields[exam, status]), models.Index(fields[student, exam]), ] class AnswerRecord(models.Model): 答题明细每道题考生的作答结果 attempt models.ForeignKey(ExamAttempt, related_nameanswers, on_deletemodels.CASCADE) question models.ForeignKey(Question, related_nameanswer_records, on_deletemodels.CASCADE) submitted_answer models.JSONField(考生答案, defaultlist) is_correct models.BooleanField(nullTrue, blankTrue) gained_score models.DecimalField(得分, max_digits5, decimal_places2, default0) class Meta: indexes [models.Index(fields[attempt, question])] # 同一会话同一道题只允许一条记录 constraints [ models.UniqueConstraint(fields[attempt, question], nameuniq_attempt_question) ]抛开字段本身有几个设计决策直接影响后续功能复杂度。第一Question 的 options 和 answer 都用 JSONField而不是单独建一个选项表。原因稍后展开核心是“快照”语义——题目可以被修改但考生交卷那一刻看到什么、应该怎么判必须以当时的数据为准。第二ExamAttempt 独立存在是因为考试进行中需要频繁更新状态如果状态直接写在 User 和 Exam 的关联表里每场考试的每个人都要维护一行记录逻辑会散。第三AnswerRecord 加唯一约束防止前端重复提交导致同一道题出现两条记录这是成绩错乱的主要源头之一。2.3 为什么这样设计外键、级联删除和快照语义先说外键删除策略。Question 的 exam 字段用 CASCADE意思是删除一场考试会连带删除题库这符合直觉但 AnswerRecord 的 attempt 也用 CASCADE意味着如果某条考试会话被误删答题明细和成绩都没了。实际运维里这种删除是灾难性的我后来把关键业务表改成 on_deletePROTECT宁可报错也不能静默删数据。开发阶段图省事用 CASCADE 没关系上线前必须过一遍每张表的删除策略。再说 JSONField 存答案。常见的候选方案是“题目-选项”两张表选项作为子表关联题目。这样做有一个麻烦如果出题人修改了某个选项的文本历史试卷的判分逻辑会跟着变。在线考试系统里考生作答的内容必须是对当时试卷的如实记录。把选项存成一个 JSON 列表等于给每个问题拍了一张快照改题库不会污染已经生成的试卷这是线上系统稳定性的关键。最后一个取舍是关于分表。有人把成绩单独做成 Grade 表关联 Exam 和 Student理由是方便做成绩统计。我的经验是先放在 ExamAttempt 上等真的需要做年级排名、多次考试成绩对比时再写聚合查询或单独表都不迟。过早拆表只会让代码徒增 join。3. 随机组卷和自动判分两个硬骨头的可抄实现3.1 随机组卷按题型和难度从题库抽题随机组卷的常见业务规则是指定单选多少道、多选多少道、判断多少道每道题分值固定按难度比例分布。实现思路是先按条件把题库筛一遍再用 random.sample 从候选题里抽取。这里有个坑题库数量不够时 random.sample 会直接抛 ValueError所以要先做数量校验缺多少题要能明确告诉出题人。import random import uuid def build_random_paper(exam, rule): 按规则生成一张随机试卷。 rule 示例 { types: {single: 10, multi: 5, judge: 5}, difficulties: [1, 2, 3] } # 先把候选题取出来内存中做随机不依赖数据库层面的 ORDER BY RANDOM() candidates list(exam.question_bank.filter( type__inrule[types].keys(), difficulty__inrule[difficulties], )) picked [] for qtype, count in rule[types].items(): pool [q for q in candidates if q.type qtype] if len(pool) count: raise ValueError( f题型 {qtype} 题库不足需要 {count} 道实际只有 {len(pool)} 道 ) picked.extend(random.sample(pool, count)) # 随机乱序打乱题目顺序降低相邻考生抄袭概率 random.shuffle(picked) paper Paper.objects.create(examexam, versionR uuid.uuid4().hex[:4].upper()) paper.questions.set(picked) # 走多对多中间表 return paper这段代码有两个参数细节要说清楚。一个是 random.sample(pool, count) 的 pool 必须去重如果题库里存在内容相同的题目抽题结果会重复最好的习惯是在题库表加唯一约束。另一个是 random.shuffle(picked) 打乱顺序之后题目在试卷上的展示顺序取决于 paper.questions 多对多关系的默认排序如果不希望每次都按主键排需要在取题时显式 order_by(paper_question____order)或者给多对多字段加 through 模型来固化顺序。随机组卷的性能问题往往出在“题库很大”的时候。把几万道题全部查出来放进内存再筛选django 项目实战新手很容易这么写。常见做法是先用 filter 把条件收窄到几千道以内再一次性取出如果题库到十万级就需要在数据库层面做随机抽样PostgreSQL 可以用ORDER BY RANDOM() LIMIT nMySQL 用ORDER BY RAND()但都不建议在低配机器上直接跑大表随机排序。3.2 考试会话倒计时、刷新防丢和超时兜底考试会话的倒计时不能信前端。前端 JavaScript 里 setTimeout 攒出来的剩余时间非常容易被篡改考生改一下系统时间或者清掉页面状态倒计时就失灵了。正确做法是前端只负责展示进考场时从后端拿deadline_at start_time duration渲染时每秒根据服务器返回的标准时间计算差值。后端每个接口都要校验会话状态和截止时间。考生刷新页面时前端要从后端拉取当前 attempt 的状态和剩余秒数而不是从 localStorage 里恢复答案。我在实际项目里见过考生开着浏览器不动等倒计时走完前端停在答题页后端却没有标记超时最后交卷接口还能正常提交。这属于典型的边界遗漏。def get_attempt_context(attempt): 返回考试页面需要的时间信息和状态 from django.utils import timezone now timezone.now() if attempt.status finished: return {status: finished, score: attempt.score} deadline_at attempt.start_time timedelta(minutesattempt.exam.duration) if now deadline_at or now attempt.exam.end_at: # 超时兜底强制交卷自动判分 attempt.status expired attempt.submit_time now attempt.score grade_attempt(attempt) attempt.save(update_fields[status, submit_time, score]) return {status: expired, score: attempt.score} return { status: doing, remaining_seconds: int((deadline_at - now).total_seconds()), server_now: int(now.timestamp()), }这个函数体现了“后端兜底”的思路所有时间判断都发生在服务端前端拿到的只是一个展示用数字。它有三个关键点一是 deadline_at 的起点是 attempt.start_time而不是考试定义里的开始时间因为有的考生会晚几分钟进入考场二是超时自动交卷的顺带判分流程和正常交卷复用同一个 grade_attempt 函数避免两套逻辑后期分叉三是返回 server_now 时间戳前端可以用它校准本地时钟偏差。实际部署时还需要一个后台任务定时扫超时会话比如 celery beat 每 30 秒跑一次把到点没交卷的会话标记为 expired。如果不想引入消息队列也可以在考生下一次请求接口时做懒检查但这种做法在考试结束后无法保证所有会话状态收敛成绩统计会缺数据。3.3 自动判分与人工阅卷混合多选判分规则要想清楚自动判分听起来简单单选判断直接比对答案字符串多选的坑比较多完全一致才给分还是漏选给一半选错给零分每种规则都有团队用。我见过最稳妥的做法是单选和判断完全一致才得分多选漏选给一半分选错不给分。判分逻辑必须写成纯函数便于单测覆盖边界。def grade_attempt(attempt): 对整场会话判分客观题直接判主观题留待人工阅卷。 total Decimal(0.00) for record in attempt.answers.select_related(question): q record.question if q.type short_answer: # 主观题保持待阅卷状态 record.is_correct None record.gained_score Decimal(0.00) record.save(update_fields[is_correct, gained_score]) continue right set(q.answer) submitted set(record.submitted_answer) if q.type single: # 单选必须且只能提交一个选项 record.is_correct (len(submitted) 1 and right submitted) elif q.type judge: record.is_correct (right submitted) elif q.type multi: # 多选漏选给一半分选错或有多余选项给零分 if right submitted: record.is_correct True record.gained_score q.score elif submitted and submitted.issubset(right): record.is_correct True record.gained_score Decimal(q.score) / 2 else: record.is_correct False record.gained_score Decimal(0.00) record.save(update_fields[is_correct, gained_score]) continue record.gained_score Decimal(q.score) if record.is_correct else Decimal(0.00) record.save(update_fields[is_correct, gained_score]) total record.gained_score return total这段判分函数有几个容易写错的地方。第一set 比较天然忽略顺序单选和多选都不会被选项顺序干扰。第二多选“漏选给一半”是业务判断不是程序必然逻辑我用 issubset 判断 submitted 是 right 的真子集同时排除了空集合因为空提交不应该给一半分。第三每道题都调一次 save(update_fields...) 性能一般但一场考试一般就几十道题可读性优先。如果题量到几百上千就改成批量 update。还有个细节人工阅卷页面需要能看到主观题答案和对应考生信息但不能把标准答案一起展示给阅卷人造成干扰。常见的做法是在后台管理系统里单独做一个 Marking 视图过滤question__typeshort_answer且is_correct IS NULL的记录阅卷人打分后回写到 AnswerRecord.gained_score然后重新累加总分。累加用聚合查询即可不用再遍历全部判断题。4. 数据库文档怎么写ER 图、索引与交卷事务边界4.1 一份能直接交付的数据库文档应该包含什么标题里带了“数据库文档”四个字这往往是对毕设或者项目交付的真实要求。许多初学者把数据库文档理解成几张截图Navicat 的 ER 图、几张建表 SQL。实际项目评审要看的远不止这些。按交付标准一份完整的数据库文档至少应该包含四块实体关系说明、每张表的字段字典、索引与约束清单、事务边界说明。字段字典是重点。一个字段要写清楚名称、类型、长度、是否可空、默认值、含义说明。举例来说Question 表的 answer 字段存的是 JSON 数组如果文档里不注明“多选择题答案示例[A,C]”接手的人根本不知道这个字段的数据形态。我用下面的表格来呈现字段字典每一张表一张表评审时观感最好。字段类型可空默认值说明idBigAutoField否-主键examForeignKey(Exam)否-所属考试级联删除typeCharField(20)否-题型single/multi/judge/short_answercontentTextField否-题干文本optionsJSONField是list选项数组判断题为空列表answerJSONField否list标准答案数组形式difficultySmallIntegerField否21易 2中 3难scoreIntegerField否2单题分值索引清单要说明“为什么建这个索引”。数据库文档不是把 Django 迁移生成的索引照抄一遍而是要写清楚业务查询模式。比如 ExamAttempt 表的 (exam, status) 联合索引对应的查询是“列表页展示某场考试所有考生及交卷状态”这个索引能把一场考试的上万条会话记录快速过滤到进行中或已交卷。4.2 建索引的思路让 django ORM 常见查询走对路给 Django 模型加索引可以写在 model Meta 的 indexes 里这一节我把三类高频查询和对应索引设计放在一起看。第一类高频查询是“某场考试的成绩列表”。界面通常要显示考生姓名、得分、交卷时间、考试用时而且按得分倒序。对应 ORM 是ExamAttempt.objects.filter(examexam_id, statusfinished).order_by(-score)。这里 exam_id 是外键Django 默认会为外键建索引status 一般区分度不高所以我的索引设计是 (exam, status)然后排序字段 score 不需要单列索引因为查询已经被过滤到几百条以内了。第二类高频查询是“某个考生未完成的考试”。对应 ORM 是ExamAttempt.objects.filter(studentstudent_id, statusdoing)。这个查询模式出现在考生首页建 (student, status) 联合索引。注意不要把这两类高频查询的索引合并成一个 (exam, student, status)因为 exam 和 student 同时筛选的场景几乎没有联合索引必须遵循“最左前缀”原则否则某些查询走不上索引。第三类高频查询是“交卷时的批量读取”。交卷接口要一次性把一张试卷的所有题目和考生提交的答案取出来用于判分。对应 ORM 是attempt.answers.select_related(question).all()。AnswerRecord 表要覆盖 attempt_id question_id 的组合索引这个我在模型里已经写了。select_related 把 Question 表 join 进来避免像新手常犯的 N1 问题——在 for 循环里每读一道题答案就查一次 Question。下面这段代码展示如何主动检查索引是否生效from django.db import connection with connection.cursor() as cursor: cursor.execute(EXPLAIN QUERY PLAN SELECT * FROM exam_attempt WHERE exam_id %s AND status finished, [exam_id]) for row in cursor.fetchall(): print(row) # 看到 USING INDEX 说明走中索引把这段放到管理命令里配合不同数据量的测试库能直观看到哪些查询走了全表扫描。我用这个方式发现过一个隐蔽问题Django 对外键默认建了单列索引但我的查询里用了 exam 和 status 组合条件在 SQLite 上只能走到 exam 单列索引再用 status 过滤效率依然可以但换成 PostgreSQL 后执行计划会变。4.3 交卷事务边界原子性、行锁和幂等考试系统最容易出错的数据操作就是“交卷”这个聚合动作写入所有答题记录、更新会话状态、计算成绩这一串操作不能中途断电否则成绩和答题记录对不上。Django 里用transaction.atomic()包住整个交卷视图即可原子性是最基本的要求。真正隐蔽的是并发问题。考生连续快速点击两次交卷按钮两个请求同时到达服务器如果都用“先查状态再更新”的写法两个请求可能同时读到 doing 状态同时进入判分逻辑最后成绩被覆盖。解决办法是select_for_update()行锁把会话行锁住后一个请求必须等前一个事务提交后才能继续。from django.db import transaction from django.utils import timezone transaction.atomic def submit_exam(attempt_id, submitted_map): 交卷入口保证幂等和原子性。 # select_for_update 锁住这一行防止并发重复交卷 attempt ExamAttempt.objects.select_for_update().get(pkattempt_id) # 幂等判断已经交过卷直接返回不重复判分 if attempt.status finished: return attempt # 批量写入答题记录全部替换 records [ AnswerRecord( attemptattempt, question_idqid, submitted_answerjson.dumps(answer, ensure_asciiFalse), ) for qid, answer in submitted_map.items() ] AnswerRecord.objects.bulk_create( records, ignore_conflictsTrue, # 已存在的记录直接跳过 ) attempt.status finished attempt.submit_time timezone.now() attempt.score grade_attempt(attempt) attempt.save(update_fields[status, submit_time, score]) return attempt事务和幂等是两个容易被混淆的问题。事务解决的是“中途失败不留半截数据”幂等解决的是“同一个请求执行两次结果一致”。代码里的状态判断保证第二次交卷请求进来后直接返回原成绩不会再次判分覆盖结果。bulk_create 配合 ignore_conflicts 可以防止答题记录重放导致唯一约束报错代价是会静默跳过数据不一致的记录如果你的业务要求“交卷失败必须报错”可以把 ignore_conflicts 去掉让唯一约束做最后一道防线。还有一个与事务边界相关的删除问题。Django 执行查询-删除对象时有个经典误导ExamAttempt.objects.filter(examexam).delete()会级联删除相关的 AnswerRecord看起来很方便。但这个操作是直接在 SQL 层执行的不会触发模型里自定义的 delete() 方法如果你在模型里重写了 delete() 用来写审计日志这个 delete() 不会被调用。删除考试数据前要三思最好用软删除字段而不是物理删除。5. 在线考试高频翻车现场5 个坑与排查方法5.1 现象交卷高峰整个服务卡死请求排队超时原因几乎是固定的SQLite 数据库的写锁机制。SQLite 同一时刻只允许一个写事务交卷高峰几十个人同时提交每个事务还要读一堆数据再写一堆数据锁冲突快速累积。开发环境用 SQLite 完全够线上环境换成 PostgreSQL 或 MySQL 是基本常识。解决方法是双管齐下。第一生产环境数据库换成 PostgreSQL并把连接数调大Django 配置里设置 CONN_MAX_AGE 让数据库连接复用。第二缩短短事务的持有时间交卷事务里只做必要的动作成绩计算这种 CPU 密集操作尽量在事务外提前算好或者拆成两次写。我最开始就是交卷时把判分循环也写在事务里面30 道题判 30 次 save事务被拖得特别长。5.2 现象倒计时显示和真实剩余时间对不上有的考生多答了几分钟这是时区问题。Django 的 USE_TZTrue 时所有 DateTimeField 存的是 UTC 时间模板里渲染时会自动转本地时间。问题出在考生本地时间准确与否只要页面拿本地时间自己去减考生把系统时间往后改倒计时立刻失效。解决方法是所有时间计算只认后端。前端拿到 server_now 时间戳和剩余秒数后每秒钟基于本地流逝量渲染但最终校验由后端完成。后续的请求里后端判断当前 UTC 时间是否超过 deadline_at超过直接拒绝提交并进入超时流程。这一点在 3.2 节已经写过排查时重点看前端代码里有没有直接用 new Date() 做截止判断。5.3 现象考生明明答了题交卷后成绩单显示空白这个坑出在答案提交方式上。最常见的设计失误是答案只存在前端内存里每答一题不往后端保存交卷时一次性全部提交。一旦考生中途刷新页面或者断网所有草稿灰飞烟灭。另一个翻车点是前端每答一题就发一个异步请求但没有做失败重试网络抖动丢掉一两题没有任何提示。解决方法是分层保存。第一层每道题作答后过一个防抖时间我一般用 3 秒自动保存一次第二层页面 beforeunload 时能用 navigator.sendBeacon 把当前卷面发送到后端临时存储第三层交卷接口正常提交并检测到已有答题记录时不直接覆盖而是返回冲突提示。这三层叠起来答案丢失的概率能压到极低。5.4 现象主观题答案里带了一段 JavaScript阅卷页面弹出广告这是 XSS 注入。考生在简答题里提交了scriptalert(xss)/script或一段img onerror...阅卷后台直接渲染这段文本时被浏览器执行。在线考试系统的安全审查往往只关注登录和权限忽略了文本型内容的转义。解决方法是两个层面。存储时不处理展示时必须转义——Django 模板默认会 escape但如果你用了mark_safe或者把答案塞进前端框架的 v-html、dangerouslySetInnerHTML就会绕过保护。多行文本编辑如果允许富文本用 bleach 库做白名单过滤只保留 p、strong、a 这类安全标签其余全部剥离。这个坑排查起来最隐蔽因为问题通常要等考生刻意提交恶意内容才会触发。5.5 现象成绩统计页面加载特别慢几百人成绩转了好几秒这是典型的 N1 查询问题。成绩列表要显示考生姓名和答题明细代码里如果先取 500 个 attempt再在模板里逐个取 attempt.student.username就会产生 500 次额外查询。Django 的 ORM 已经把这种问题包装得很友好了但也正因此很多人根本不知道性能损耗在哪。解决方法是在查询阶段一次性捞全。ExamAttempt.objects.filter(examexam).select_related(student, paper)答题明细则用prefetch_related(answers__question)分组预取。还有个容易被忽略的地方是模板渲染时对record.question.type调用get_type_display()这个方法是查内存映射的不会触发额外查询但如果题目的选择题选项存在 JSON 里模板每次包一层 json.loads 也会拖慢渲染建议把解析逻辑放到 model 的 property 方法里缓存。6. 从能跑通到能交付题库批量导入与并发压测的进阶做法做在线考试系统跑通核心流程只算完成了第一版离“能交付”至少还差两件事题库录入效率和并发可靠性。题库动辄几百上千道题人工在后台一面一面地加根本不现实。我一般用 Excel 文件批量导入一个模板对应题目、选项、答案、题型、难度和分值后台解析后写入数据库。import openpyxl from django.db import transaction transaction.atomic def import_questions_from_excel(exam_id, file_path): wb openpyxl.load_workbook(file_path, data_onlyTrue) sheet wb.active errors [] objs [] for row in sheet.iter_rows(min_row2, values_onlyTrue): # 约定列顺序题型题干选项A选项B选项C选项D答案难度分值 q_type, content, opt_a, opt_b, opt_c, opt_d, answer, difficulty, score row if not content: continue options [ {key: A, text: opt_a}, {key: B, text: opt_b}, {key: C, text: opt_c}, {key: D, text: opt_d}, ] # 判断题没有选项选项字段留空 if q_type judge: options [] objs.append(Question( exam_idexam_id, typeq_type, contentcontent.strip(), optionsoptions, answeranswer.split(,), # 多选用逗号分隔如 A,C difficultyint(difficulty), scoreint(score), )) Question.objects.bulk_create(objs, batch_size500) return len(objs), errors导入脚本有几个细节一个是transaction.atomic 保证导入中途出错时不会留半批脏数据另一个是答案字符串长度校验我遇到过答案顺序写反导致整批判分错误的案例所以导入后要抽查已导入的题目是否符合预期。如果题库量特别大不要把导入逻辑放在 Django 请求线程里跑页面会超时常见做法是丢给 celery 异步任务导入完成后发送通知。导入完之后就是压测。我习惯写一个几十行的 Python 脚本模拟并发交卷用 threading 起 50 个线程同时请求交卷接口每个线程带不同的考生 token 和答案数据。压测关注两个指标一个是接口成功率另一个是成绩是否幂等——重复提交的请求收到的响应和首次提交一致。下面是一个简化的压测思路import threading import requests BASE_URL http://127.0.0.1:8000/api/exam/submit/ def do_submit(attempt_id, token): r requests.post(BASE_URL, json{attempt_id: attempt_id}, headers{ Authorization: fToken {token} }) print(r.status_code, r.text[:100]) for i in range(50): t threading.Thread(targetdo_submit, args(1000 i, ftoken-{i})) t.start()这个脚本不是为了得到精确的 TPS而是用来暴露两件事50 个线程同时打进来SQLite 是不是直接报 database is locked重复点击交卷按钮的时候查询结果里会不会出现两条成绩。跑通这个验证才有底气把系统交付给真实考场使用。我最早那版系统就是没跑压测上线当天 40 人同时交卷把 SQLite 写锁打爆最后手动改数据库才挽回成绩。那次之后养成了一个习惯每个写接口默认问一句“并发下会不会踩自己”这个习惯帮我避开了后面很多次翻车。希望帮到你。本文还有配套的精品资源点击获取
返回列表