用Django从零搭建在线考试系统:核心设计与部署实战 这个在线考试与评估系统我从立项到上线前后大概用了六周。需求本身听起来不复杂——学生登录、在线答题、自动判分、成绩导出教师端能维护题库、配置试卷、查看评估结果。但真正把Django这套框架用顺把考试场景下的并发、数据一致性、权限控制这些细节做扎实才是最耗精力的地方。这篇文章我会按真实开发顺序把核心设计、数据模型、组卷与判分逻辑、部署实战以及踩过的坑完整过一遍给准备用Django做实际Web项目的开发者一条能直接参考的路径。1. 项目概述与整体设计思路在开始写代码之前我花了不少时间拆解需求。用户的原始需求往往就是一句“做一个能考试的系统”但如果直接动手后面一定会翻车。我拿到需求后第一件事是和业务方把三个问题聊清楚谁来用、考什么、结果怎么用。谁来用决定了权限模型。学生要登录、选课、参加考试、查成绩教师要建题库、配试卷、批主观题、看统计分析管理员要管用户、管课程、管全局参数。考什么决定了题型结构。我们这次要覆盖单选、多选、判断、填空、简答五种题型后面还可能要扩展到问答和案例分析。结果怎么用决定了评估模块的深度。不只是给个总分还要有班级平均分、及格率、每个知识点的掌握率方便老师调整教学计划。1.1 为什么选Django而不是Flask或FastAPI很多人技术选型时会下意识选FastAPI觉得异步性能高。但以我的实操经验考试系统这种业务形态Django反而更合适原因有三点。第一Django自带Admin后台。教师端的大部分需求比如维护题库、查看考试记录、导出成绩用Admin做定制就能覆盖大部分场景不需要单独开发一套管理界面排期紧的时候能省出大量时间。第二Django的ORM和迁移体系处理结构化业务数据非常顺手尤其是外键关系复杂的场景——课程、题目、试卷、答题明细之间层层关联用ORM维护比手写SQL清晰得多。第三Django的auth和权限框架是现成的用户、分组、权限、会话这一套安全体系开箱即用在线考试系统对这类能力的依赖程度非常高。那FastAPI和Flask就完全不能用吗当然不是。如果团队想走前后端分离、接口要对第三方开放、或者有大量流式交互场景FastAPI值得考虑。但我这次是传统多页面加局部刷新的形态服务端渲染为主Django的模板系统反而降低了前端复杂度。记住一个原则工具是给业务形态服务的不是给技术热度服务的。1.2 角色权限与模块划分系统按角色和功能边界切成四个核心模块每个模块对应一个Django app。这样应用边界清晰后期维护不用在一个巨型app里来回翻。模块app名核心功能主要角色用户中心users登录、注册、个人信息、用户组所有角色试题管理question_bank题库维护、题型管理、知识点标签教师/管理员考试服务exam组卷、考试场次、在线答题、自动判分学生/教师评估分析assessment成绩查询、统计分析、报表导出教师/管理员Django里创建app的命令很简单django-admin startproject online_exam . python manage.py startapp users python manage.py startapp question_bank python manage.py startapp exam python manage.py startapp assessment这里我在项目根目录用了.参数是为了让manage.py和项目配置在根目录下部署时路径管理更方便。创建完app后记得先到 INSTALLED_APPS 里注册否则migrate会提示找不到模型这个细节新手比较容易漏。1.3 整体架构与运行环境架构上我采用的是经典的Django多页面加局部AJAX刷新同时保留一组JSON接口给前端做异步操作。基础环境如下Django 4.2 LTS生产环境首选安全维护周期长Python 3.10MySQL 8.0Redis 7.0做缓存、会话存储和倒计时状态Nginx Gunicorn服务器操作系统麒麟V10兼容模式x86架构关于Django版本我要多说两句。Django 5.0已经发布某些场景下确实更现代但我这次没有上核心原因是4.2是LTS版本官方支持周期长生产环境出问题时能覆盖的安全补丁时间窗口足够大。技术选型时LTS版本永远是业务系统的默认优先级。新特性带来的收益在考试系统这种对稳定性要求极高的场景里远低于版本升级带来的兼容性风险。2. 数据模型设计与ORM实战数据模型是整个系统的地基。考试数据的正确性高度依赖表结构和外键关系设计尤其要守住“历史数据不能被修改破坏”这条审计原则。这一章我复盘一下模型设计过程以及ORM操作中最容易出问题的“删除对象”环节。2.1 核心模型关系梳理用户模型我没有直接改Django内置的User而是用Profile扩展的方式把学号工号、班级、身份角色等业务字段放到扩展表里。这样在项目初期能避免一个继承自AbstractUser的模型带来的迁移包袱。当然如果你很确定后续要加大量用户业务字段继承AbstractUser也完全可以关键是团队要对模型演进路线达成一致。题库模型要覆盖多题型我用一张Question表题型字段区分单选、多选、判断、填空、简答题干用TextField存储选项用JSONFieldMySQL里映射成JSON类型答案用TextField存。知识点独立建表和题目做多对多关联方便按知识点维度做评估分析。试卷模型分两层。一层是Paper表存试卷基本信息一层是PaperQuestion表存试卷和题目的关联关系同时把题目内容做一次快照。为什么一定要快照因为题库里的题目会不断修改如果直接关联题库表一份5月发出去的试卷到6月批改时题目可能已经变了这完全不符合考试规范。快照会把题目原文、选项、答案、分值在组卷那一刻固定下来之后题库怎么改都不影响历史考试。考试场次和答题明细用三张表ExamSession定义一场考试的起止时间、时长、参加班级ExamRecord记录每个学生在这场考试里的总体状态和总分AnswerDetail记录每一道题的具体作答、判分依据和得分。核心模型代码如下# exam/models.py 核心模型节选 from django.db import models from django.contrib.auth.models import User from django.utils import timezone class Course(models.Model): name models.CharField(课程名称, max_length128) teacher models.ForeignKey(User, on_deletemodels.CASCADE, related_namecourses) students models.ManyToManyField(User, related_namestudent_courses, blankTrue) created_at models.DateTimeField(defaulttimezone.now) class Question(models.Model): TYPE_CHOICES ( (single, 单选题), (multiple, 多选题), (judge, 判断题), (blank, 填空题), (short_answer, 简答题), ) type models.CharField(题型, max_length20, choicesTYPE_CHOICES, db_indexTrue) stem models.TextField(题干) options models.JSONField(选项, defaultdict, blankTrue) answer models.TextField(参考答案) difficulty models.IntegerField(难度, default3, help_text1-55为最难) score models.DecimalField(默认分值, max_digits5, decimal_places1, default5.0) knowledge_points models.ManyToManyField(KnowledgePoint, blankTrue) author models.ForeignKey(User, on_deletemodels.PROTECT, nullTrue, blankTrue) is_active models.BooleanField(启用, defaultTrue) created_at models.DateTimeField(defaulttimezone.now) class Paper(models.Model): name models.CharField(试卷名称, max_length256) course models.ForeignKey(Course, on_deletemodels.CASCADE, related_namepapers) total_score models.DecimalField(总分, max_digits6, decimal_places1, default100.0) generated_at models.DateTimeField(auto_now_addTrue) creator models.ForeignKey(User, on_deletemodels.PROTECT) class PaperQuestion(models.Model): paper models.ForeignKey(Paper, on_deletemodels.CASCADE, related_namequestions) question models.ForeignKey(Question, on_deletemodels.PROTECT) stem_snapshot models.TextField(题干快照) options_snapshot models.JSONField(选项快照, defaultdict, blankTrue) answer_snapshot models.TextField(答案快照) score models.DecimalField(本题分值, max_digits5, decimal_places1) order models.IntegerField(题目顺序, default0)这段代码有几个细节要解释一下。Question里的author外键我用了PROTECT意思是如果题目已经被某个教师创建并关联到历史试卷就不能随手把教师账号删除除非先把题目移除或转移这是审计需求的基本要求。PaperQuestion也是同样道理被历史试卷引用的题目不允许物理删除。2.2 删除对象Django ORM的删除策略怎么选Django ORM里删除对象通常就两个入口单条对象调用obj.delete()QuerySet调用queryset.delete()。很多新手在写外键字段时对on_delete参数没有概念随便一写到后期才发现删一个父对象把整片数据带走了。on_delete 策略含义适用场景CASCADE级联删除父对象删除后子对象同步删除课程下面没有独立保留价值的临时记录PROTECT存在关联对象时禁止删除父对象题目存在答卷引用时不允许删除题目SET_NULL父对象删除后子对象外键置空需字段允许null题目被删除后试卷快照字段仍有内容只清空外键SET_DEFAULT删除后置为默认值需要字段有default需要把关联归到一个默认对象的场景DO_NOTHING数据库层面不做处理容易产生脏数据基本不推荐拿“删除教师账号”举例。教师关联了课程、试卷、大量题目如果不加思考直接CASCADE删除教师会连带删除课程和试卷甚至间接删掉学生的考试记录这是一场数据灾难。所以我在关键外键上全部采用PROTECTDjango会抛出ProtectedError提醒我先处理关联业务数据或者改用软删除。软删除是考试系统里很值得做的机制。用is_active布尔字段标记题目是否有效查询时默认过滤掉。为什么不用物理删除因为考题一旦被历史试卷做过快照它的原文仍然有审计价值但题库里已经不能再用于新组卷。物理删除会把所有痕迹抹掉后续要审计、要做题目分析都无从谈起。还有一个性能细节要提醒queryset.delete()并不是直连数据库执行DELETEDjango会先收集所有关联对象并走信号因此在大表上批量删除会非常慢甚至可能锁表。如果确定要物理清理大批量数据我建议直接在维护窗口用数据库脚本执行或者用Django的_raw_delete()绕过ORM行为但要非常清楚自己在做什么。2.3 查询优化避免N1与索引设计考试系统中最大的性能陷阱是ORM查询的N1问题。比如查询某场考试的答题明细时常规写法是这样的records ExamRecord.objects.filter(session_id1) for record in records: print(record.student.username)这样每取一条record就要查一次student表。100个学生就是101条SQL响应时间指数上升。解决办法是select_relatedrecords ExamRecord.objects.filter(session_id1).select_related(student)select_related会通过JOIN把关联数据一次性查出来适合所有一对一和多对一关系。而面对多对多或者反向查询场景要用prefetch_relatedpapers Paper.objects.prefetch_related(questions).filter(course_id1) # 之后访问 paper.questions.all() 不会再产生额外SQLprefetch_related本质上是先查主表再查关联表最后在Python内存里做配对所以它会返回整表数据。数据量特别大时也要注意内存占用。索引方面我主要加了三个Question的type字段索引题目常按题型筛选、ExamRecord的(student, session)联合索引学生考试记录是最高频查询、AnswerDetail的(record, question)联合索引。考试查询最典型的一个场景是“查询某学生在某场考试里的所有作答”没有联合索引时全表扫描会非常难受加了联合索引后这个查询能稳定在毫秒级。成绩统计报表也做了优化。教师端页面涉及大量聚合SQL我没有让它在业务高峰期实时计算而是用定时任务把每日统计结果缓存到Redis或独立的统计表里页面上直接读缓存。对于日均几千人考试的规模这个优化的收益非常直观。3. 核心业务功能实现这一章是系统的重头戏。从组卷到判分再到成绩评估每一步都有很多非显性需求我把实现逻辑和当时踩过的坑都写出来。3.1 动态组卷与抽题策略组卷不是随机抽几道题就行。每次生成的试卷在难度、知识点覆盖上要基本一致否则学生会觉得A班考完发现偏难、B班考完发现偏简单。我采用“按维度配额”的抽题方案。组卷参数由教师在前端传入总分、各题型数量、难度区间、知识点覆盖权重。后端拿到参数后先按题型分组再在每个分组内按难度分层抽样最后根据知识点权重做一轮校验调整。from django.db.models import Count import random def build_paper(paper, spec): spec: {types: {single: 10, multiple: 5, judge: 10}, difficulty: (2, 4)} questions [] for qtype, count in spec[types].items(): qs Question.objects.filter( typeqtype, is_activeTrue, difficulty__rangespec[difficulty], ).order_by(?)[:count * 2] selected select_by_knowledge_points(qs, count) questions.extend(selected) random.shuffle(questions) for idx, q in enumerate(questions): PaperQuestion.objects.create( paperpaper, questionq, stem_snapshotq.stem, options_snapshotq.options, answer_snapshotq.answer, scorepaper.total_score / len(questions), # 均分模式 orderidx, )代码里有几个细节。第一order_by(?)在MySQL里是随机排序但题库数据量超过10万时性能会明显下降。我建议改成按主键随机偏移量切分比如取一个随机id作为起点id__range(offset, offset 500)再在内存里随机筛选。第二组卷时要多取20%的候选题目再做知识点覆盖筛选避免出现某类知识点抽不到题的情况。第三创建PaperQuestion时一定要生成快照这个前面已经强调过。组卷时最容易被忽略的是多个批次之间的难度一致性。如果两个班不同批次考试试卷难度波动超过0.5成绩对比就没有意义。所以我写了一个难度校验方法组卷完成后计算整套试卷的平均难度如果和目标难度的偏差超过阈值就重新抽题最多重试3次。3.2 在线答题与时间控制在线答题最怕两件事误操作丢数据、超时交卷。解决误操作我用了草稿箱机制——学生每次切换题目都把当前题的答案AJAX保存到服务器而不是提交时才一次性写入。解决超时则是前端倒计时与后端时间校验双保险。后端的时间校验逻辑很简单但很重要。考试提交时不能只信前端传来的“还剩多少时间”因为前端时间可被篡改。正确做法是服务端根据ExamSession的开始时间和时长计算截止时间再判断当前提交时间是否已经超时from django.utils import timezone def can_submit(session, student): now timezone.now() start session.start_time end start timezone.timedelta(minutessession.duration_minutes) record ExamRecord.objects.filter(sessionsession, studentstudent).first() if record and record.submitted_at: return False, 已交卷不能重复提交 if now start: return False, 考试尚未开始 if now end: return True, 已超时按当前作答自动交卷 return True, ok考试过程中的防作弊我没有做得很复杂但基础项必须有切换浏览器标签页超过5次就记录一次可疑行为同一账号在同一考试时段内仅允许一个会话新登录会把旧会话剔除Cookie和Session的过期时间与会话时长保持一致避免考试中途被强制下线。这些能力Django的session框架天然支持只要设置好SESSION_COOKIE_AGE和中间件即可。Redis在这里承担了倒计时同步和答题状态缓存的作用。学生每次进入考试页面先从Redis读剩余时间再由前端每秒递减同时每30秒向后端同步一次。这样即使学生刷新页面也不会丢失剩余时间。把时间状态的最终裁决放在Redis是因为它支持原子操作多台进程部署时不会各自维护一份不一致的时间副本。3.3 自动评分与人工复核评分模块是整个系统正确性的核心。按题型不同评分策略差异很大我分开讲。客观题方面单选题和判断题最简单直接比对参考答案。多选题要留意漏选的处理。我的规则是全部选对得满分漏选得一半分多选或错选不得分。这个规则必须和业务方提前确认因为漏选算多少分直接关系到最终成绩的公平性。填空题方面学生答案千奇百怪我做了三层归一化去首尾空格、统一全角半角、英文大小写统一。然后做精确匹配匹配不上时再做一次包含匹配。比如参考答案是“Django框架”学生答“django框架”经过归一化后也能匹配成功。如果还是匹配不上就标记为待人工复核。主观题方面简答题和论述题不可能完全自动判分。我采用“关键词加权计分”策略把参考答案按语义切分成几个要点每个要点关联一组关键词。学生答案命中几个要点就按比例得分生成一个初步分数。但对论述题初判分只能作为参考最终分数必须由教师在人工复核页确认后提交。系统用status字段标记这条答题明细是“自动判分通过”还是“待复核”教师复核完才更新总分。交卷时的数据一致性特别重要。我用Django的transaction.atomic包裹整个“保存答题明细 计算总分 更新考试记录”的流程from django.db import transaction transaction.atomic def score_exam(record, answers): total 0 for paper_q, student_answer in answers: detail AnswerDetail.objects.create( recordrecord, paper_questionpaper_q, student_answerstudent_answer, raw_scorepaper_q.score, statuspending, ) score judge_question(paper_q, student_answer) detail.score score detail.save(update_fields[score, status]) total score record.total_score total record.status finished record.submitted_at timezone.now() record.save(update_fields[total_score, status, submitted_at])这个事务有两个作用一是保证任何一步写库失败都不会产生半截脏数据二是对“重复交卷”做了第一道防线。更完整的防重还要在ExamRecord表的(student, session)上加唯一约束从数据库层面彻底杜绝同一学生同一场考试出现两条记录。3.4 成绩评估与统计分析考完试不是出完分就结束了。评估模块才是这个系统区别于普通答题工具的价值所在。我做的统计分析指标包括班级平均分、及格率、最高分最低分、每道题的正确率、每个知识点的掌握率、以及简易版题目难度和区分度。难点在知识点掌握率的聚合逻辑。因为一道题可能关联多个知识点我按知识点聚合时会把这道题的得分率平分到它关联的各个知识点上这样统计出来的“某知识点掌握率”才相对合理。成绩导出我用的openpyxl导出的Excel包含两个sheet一个是全班成绩明细一个是按知识点维度生成的小题得分分析方便老师打印出来讲评。导出时特别注意了内存问题。我之前在导出全校2000名学生的数据时一次性把所有记录加载进内存结果服务器卡了几分钟。后来改成按批次迭代查询配合Excel的写入函数问题就解决了。评估分析在页面上我用的是轻量级图表库不做重前端。教师端能看到分数分布直方图、知识点雷达图和一屏概览数据卡片。这些统计结果都加了一层缓存缓存时间设置为5分钟因为教师的查看频率不高5分钟的延迟完全不影响体验。4. 前后端交互与管理端实现考试系统的开发重点大部分在后端但管理端的效率和前端的交互体验直接决定了教师愿不愿意用这套系统。这一章聊聊我在这个方向上的具体做法。4.1 基于Django Admin搭建教师端我在前面提过教师端的大部分页面直接用Django Admin改造完成。但直接打开原生Admin给业务老师用是不行的需要做定制。我把Question的ModelAdmin做成了列表页直接展示题型、难度、知识点标签并提供“按课程筛选”“按难度筛选”两个list_filter再加上“批量启用禁用”“批量导出题目”两个action操作。教师每天维护题库的操作基本都能在列表页完成。# question_bank/admin.py from django.contrib import admin from .models import Question admin.register(Question) class QuestionAdmin(admin.ModelAdmin): list_display (id, type, stem_short, difficulty, is_active) list_filter (type, difficulty, is_active) search_fields (stem,) actions (make_inactive, export_questions) admin.display(description题干) def stem_short(self, obj): return (obj.stem[:30] ...) if len(obj.stem) 30 else obj.stem admin.action(description批量禁用选中题目) def make_inactive(self, request, queryset): queryset.update(is_activeFalse) admin.action(description导出选中题目) def export_questions(self, request, queryset): passAdmin定制时最容易踩的坑有两个。一是不要在ModelAdmin的列表页做太耗时的操作比如给每行都实时去统计答题人数列表行数过百后会直接卡死。二是通过action批量修改数据时默认的queryset.update不会触发模型save方法如果业务里有信号逻辑要注意。我的建议是Admin只承担管理配置功能不要让它在列表页承担复杂的业务计算。4.2 wangeditor接入题库富文本题目题干经常要包含图片和公式纯文本输入完全不够用。我给Admin和教师端自定义表单接入了wangeditor这个编辑器在轻量级富文本场景下很合适。接入流程很常规在模板里引入wangeditor的JS和CSS初始化编辑器实例然后把编辑器内容同步到Django表单的textarea隐藏域。题目题干字段在模型里是TextField表单提交时接收编辑器返回的HTML内容入库前用bleach库做标签白名单过滤不允许script、iframe这些标签出现防止XSS注入。这个过滤不能省因为教师端虽然可信但如果你后面开放了学生提交反馈之类的功能注入风险就会冒出来。图片上传是另一个重点。wangeditor默认上传图片到服务器需要自定义上传接口。我专门写了一个upload_image接口接收文件后校验大小和类型只允许jpg、png、gif单个文件不超过2MB然后存储到MEDIA目录返回给前端一个JSON格式的URL。为什么限制2MB因为考试系统题干里的配图通常尺寸不大过大图片会拖慢页面加载。如果让编辑器走base64编码把图片塞进HTML数据库很快会膨胀所以必须走独立上传接口。4.3 防作弊与安全防护的工程化实现在线考试和线下考场一样要有规则约束也要有技术手段辅助。我做了几层基础防护虽然不算铁桶但常规作弊手段基本能拦截。会话安全方面Django的CSRF中间件必须开着。考试提交是典型的POST表单场景没有CSRF校验攻击者伪造一个表单就能替学生提交答案。同时我在登录环节增加了失败次数限制连续5次失败就锁定账号15分钟弱口令碰撞攻击基本能挡掉。事务唯一约束前面提过了这里的重点是考试页面的切屏记录。我用JavaScript监听visibilitychange事件页面每次从隐藏切换到可见就向后端上报一次后端把切换次数累加到ExamRecord的可疑行为字段。这个数据不会直接影响成绩但教师可以据此决定是否要对某些学生进行人工复核。还有一个细节容易被忽略学生提交的答题内容入库之前一定要做输入长度校验和清洗。答题内容是用户可控输入如果不过滤就等于在服务器上开了一个口子。我在AnswerDetail的student_answer字段上加了多个表单校验器限制最大长度并用bleach.clean()把可能的HTML标签全部转义。5. 部署上线与国产化适配实战部署环节经常被项目开发周期挤压但实际上部署方案的好坏直接决定了系统上线后的稳定性。这一章讲依赖管理、麒麟系统适配和Nginx Gunicorn的部署全流程。5.1 项目初始化与依赖安装一个Django项目一定要用虚拟环境管理依赖我习惯用venv。创建虚拟环境后先安装基础依赖python3 -m venv venv source venv/bin/activate pip install --upgrade pip pip install django4.2.* pip install pymysql pip install redis pip install openpyxl pip install gunicorn pip install bleach单独说下pymysqlMySQL官方驱动mysqlclient在部分Linux环境下编译容易出错pymysql是纯Python实现装起来省心。不过pymysql需要在项目__init__.py里做一次猴子补丁才能被Django识别# online_exam/__init__.py import pymysql pymysql.install_as_MySQLdb()很多从Windows开发切到Linux部署的同事会卡在这一步报错信息通常是No module named MySQLdb。依赖锁定方面我用requirements.txt把已安装的包和版本号固定下来。网上经常能看到装了一堆没用包的例子比如pymodbus这种工业通信库除非系统真要对接设备否则完全不用装。依赖树越精简攻击面越小排查问题越容易。这个原则在部署生产环境时尤其重要。5.2 麒麟操作系统上的适配这个项目最终部署在基于麒麟V10的服务器上部署过程中确实踩了一些坑。这里只聊技术层面的适配。麒麟V10通常自带Python 3.6或3.8具体看版本分支。如果系统自带的Python版本低于3.8Django 4.2无法运行因为它要求Python 3.8以上。这时候不要盲目卸载系统自带的Python因为很多系统工具依赖它。正确做法是用pyenv或者直接编译一个独立版本的Python到 /usr/local 目录。另外麒麟V10的软件源里部分编译依赖包可能不完整。安装Python编译版本前需要确保gcc、make、zlib-devel、openssl-devel这些基础包已经装好。我有一个经验生产服务器的Python环境不要轻易升级小版本比如从3.8.10升到3.8.17这种无明确安全修复需求的操作尽量不要做因为系统里的venv和依赖库可能因为Python路径变化全部失效。部署形态上这个系统是纯Web服务不依赖图形界面所以我保持最小化安装不装桌面组件。这样能显著减少系统维护和兼容性问题的出现概率。5.3 Nginx Gunicorn全流程生产环境不建议用Django自带的runserver那是开发调试用的性能和安全性都不够。我的标准部署方案是Nginx反向代理加Gunicorn作为WSGI服务器MySQL存储数据Redis做缓存。Gunicorn的配置要点是worker数量。合理公式是2 * CPU核数 1比如4核机器跑9个worker。worker不是越多越好因为每个worker都是独立进程既占内存也争抢CPU。我实际测试过4核8G环境下9个worker并发处理考试交卷请求完全够用。gunicorn online_exam.wsgi:application \ --bind 127.0.0.1:8000 \ --workers 9 \ --worker-class gthread \ --threads 4 \ --timeout 60worker-class选gthread模式是因为Django的视图多数是同步阻塞的线程模式比纯同步模型能更高效地处理少量IO等待。timeout设置为60秒避免某个请求因为数据库慢查询被Gunicorn提前杀掉。Nginx配置的核心是静态文件和反向代理server { listen 80; server_name exam.example.com; location /static/ { alias /opt/online_exam/static/; expires 7d; } location /media/ { alias /opt/online_exam/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }部署上线前记得执行python manage.py collectstatic收集静态文件否则Nginx的static目录会是一片空白。还有settings.py里的ALLOWED_HOSTS一定要配置实际域名或IP否则Django返回DisallowedHost错误。这个错在接手别人项目时特别常见。数据库备份我用了crontab定时任务每天凌晨2点导出一次MySQL全量数据同时把MEDIA目录里的上传图片做增量备份。考试数据属于高价值数据备份策略宁可冗余不可缺失。我见过不止一次因为磁盘故障导致历史考试数据全部丢失的事故所以这个项目里把备份脚本放在了第一位。6. 常见问题与排查技巧实录最后这部分我把这个项目里实际遇到的高频问题和排查思路整理成速查表也给准备面试的同学附上几个加分回答思路。6.1 数据库并发引发的“重复交卷”问题上线第一周就遇到一个问题。某个班级在考试结束前最后一分钟十几个学生同时点交卷结果有3个学生生成了两条ExamRecord记录成绩统计直接乱了。排查后原因很清晰交卷接口先查询是否存在记录不存在则创建。这个“先查后插”在并发场景下不是原子操作两个请求同时查到“不存在”就都执行了插入。解决方案是在数据库层面加唯一约束同时保留事务# 迁移中给 ExamRecord 加联合唯一约束 # unique_together (student, session)然后在代码里捕获IntegrityError遇到重复提交就直接读取已有记录返回而不是报500错误。这样即使前端连续点击多少次交卷后端都能保证只有一条有效记录。我把这个机制也扩展到答题明细的保存接口上防止同一次考试里重复提交某道题的答案。6.2 Django版本升级踩坑项目后期我从Django 3.2升级到4.2遇到的坑不算多但都很有代表性。第一个坑是Django 4.0移除了USE_L10N配置项settings里如果还写着这个变量启动时会报DeprecationWarning。第二个坑是django.utils.translation.ugettext_lazy被移除了改成gettext_lazy这个改动对中文网站影响很大因为所有之前用的ugettext_lazy都必须批量替换。第三个坑是Django 4.1开始时区处理更严格如果数据库里存的datetime都是naive类型升级后比较时间会报错。这里给一个通用建议升级前先看官方发布说明的“Backwards incompatible changes”章节然后用项目里尽量全面的测试跑一遍回归。在线考试系统这种数据密集型业务绝不能在生产服务器上直接执行迁移和升级操作。6.3 面试高频问题速答盘点因为接触过不少面试者我发现Django的面试题其实非常集中。结合这个项目我把高频题目的回答思路整理出来。问题一Django一次请求的完整生命周期是什么回答要点请求先经过Nginx静态文件处理动态请求走Gunicorn到WSGI appDjango通过URLconf路由到视图视图里用ORM操作数据库最后通过模板或JsonResponse返回响应结束后中间件依次执行process_response。问题二Django ORM的N1查询问题怎么解决回答要点说明现象再讲select_related用JOIN解决外键、prefetch_related用两次查询解决多对多最后结合考试系统的题目列表场景举一个实际优化案例。问题三中间件能做什么回答要点Django中间件在请求前后都能做处理我的系统里用中间件统一做了接口耗时统计、Redis缓存读取、以及考试期间的访问控制。问题四如果让你设计一个在线考试系统核心表结构有哪些回答要点围绕用户、题库、试卷、考试记录四层展开强调试卷快照和删除策略这两个实践点。面试官对有快照意识的候选人印象普遍好。问题五Cookie、Session和Token怎么选回答要点Django默认服务端渲染用Session存储用户状态考试答题的临时状态存在Redis如果开放API给App端再用Token方案要结合具体场景说明。我在实际招聘里特别喜欢问这类“结合业务场景的设计题”因为这类题能真正区分一个人是背过面试题还是真的做过系统。回答时只要能落到具体业务细节上比如快照、事务、联合唯一约束基本就是有实战经验的特征。最后分享一个我个人的经验。在线考试系统这类项目开发周期其实比很多人想象的要长不是因为功能多而是因为任何涉及成绩、权限、审计的数据流都不能出错。我第一次做类似系统时把删除策略和试卷快照都放在最后处理结果中途改了一次模型就引发了一连串外键问题整整加班一周才收拾干净。后来我养成了一个习惯拿到业务需求先把核心模型关系、删除策略、数据漫游规则这三点定下来再开始写业务代码。这个顺序一旦确定后面开发速度会快很多系统上线后的维护成本也会低得多。如果你正在规划类似的项目希望这篇记录能帮你少走几步弯路。