ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Django学生选课系统:事务、行锁与并发控制实战解析

2026/9/13 17:29:22 拓冰建站 浏览量
Django学生选课系统:事务、行锁与并发控制实战解析 简介基于Python语言与Django框架的学生选课管理系统实战项目面向刚接触Python Web开发的初学者帮助理解Django的模型-视图-控制器架构与完整开发流程。项目涵盖数据模型设计、视图逻辑、网址路由、模板渲染、表单处理、用户认证及对象关系映射数据库操作等核心模块代码结构清晰适合作为课程设计或毕业设计的参考蓝本。资源共五十七个文件压缩包大小约四十KB包含三十五个Python源码文件、十四个HTML模板及八个CSS样式文件覆盖后端逻辑、前端页面与基础样式。系统实现了学生登录、课程查询、选课操作等典型功能用户通过表单提交选课请求视图层执行业务逻辑并完成数据库读写完整演示了请求到响应的处理机制。目前已有七百八十七人学习下载对希望快速上手Django的初学者来说是一份可直接运行并改造学习的实用案例。1. 为什么说学生选课系统最难的其实是“这门课别选超了”“使用 Django、python 简单的实现学生选课管理系统”这类项目是后端初学者最容易遇到、也最容易低估的练手题。数据模型不复杂页面数量少业务规则看起来也就“选课、退课、看课表”三件事。但真把选课两个字落地时最先出问题的反而不是页面好不好看而是数据库里的课程容量和实际选课人数对不上两个请求同时提交选同一门只剩一个名额的课程系统里就会多出一条超卖记录。Django 的学生选课管理系统核心难点在于把“选课”这个动作做成一个不会出错的事务而不是把页面做得多花哨。这篇文章按一条最简单但可靠的路径完整走一遍三张表建模型、视图层处理业务、模板层只管展示、后台给教务做维护末尾再补并发验证方法。适合有 Python 基础、想用 Django 完成课设或内部小工具的工程师也适合想彻底搞懂事务与 ORM 用法的后端入门者。2. Django 数据建模用三个模型把学生、课程、选课记录摆清楚2.1 学生、课程、选课记录三类实体的字段怎么设计常见的学生选课管理系统最忌讳一上来就建五六张表。年轻工程师常常把“专业”“院系”“教师”全部拆成模型结果一个练手项目光外键就绕晕了自己。实际维护中最顺手的设计是三张表Student管学生Course管课程Enrollment管“谁在什么时候选了哪门课”。Student的关键字段是学号必须唯一。学号这类业务编号不建议用自增主键承担更常见的做法是保留自增id作主键同时给student_no加uniqueTrue因为学号可能被导入导出、被打印在纸条上自增 id 一旦迁移就会变业务编号不该承担主键职责。Course里除了课程名、教师、学分还要放两个容易被忽略的字段capacity表示容量selected_count表示当前已选人数。后者是一个冗余计数目的是避免每次看列表都去count()一次选课记录课程列表一多这个计数能省掉大量查询。from django.db import models class Student(models.Model): student_no models.CharField(学号, max_length20, uniqueTrue) name models.CharField(姓名, max_length50) enroll_year models.IntegerField(入学年份, default2025) email models.EmailField(邮箱, nullTrue, blankTrue) class Meta: verbose_name 学生 verbose_name_plural verbose_name def __str__(self): return f{self.student_no} {self.name}字段类型的选择里IntegerField存入学年份比DateField更合适因为教务系统里“2023 级”是一个整数概念用日期反而要处理 1 月 1 日这种无意义时刻。EmailField并不在数据库层做校验它的作用是让 Django 表单和 admin 在做数据校验时自动检查格式这是选型时要明白的一点。2.2 为什么选课记录要显式建模而不是用 ManyToMany学生和课程是多对多关系初学时的第一反应是直接挂ManyToManyField。但学生选课管理系统里选课记录通常需要保存附属信息选课时间、退课时间、成绩、选课状态。默认的自动关联表只有两个外键加不了这些内容。虽然ManyToManyField支持throughEnrollment参数指向显式模型但既然都要写一个中间模型了直接建Enrollment反而更清晰查询路径也好控制。另一个更实际的原因是防重约束。选课系统的硬规则是“一个学生同一门课只能选一次”这条规则不能只靠视图代码判断数据库层也必须兜底。在Enrollment.Meta里声明UniqueConstraint即使业务代码漏判数据库也会拒绝重复插入class Enrollment(models.Model): STATUS_CHOICES [ (selected, 已选), (dropped, 已退), ] student models.ForeignKey(Student, on_deletemodels.CASCADE, verbose_name学生) course models.ForeignKey(Course, on_deletemodels.CASCADE, verbose_name课程, related_nameenrollments) status models.CharField(状态, max_length20, choicesSTATUS_CHOICES, defaultselected) selected_at models.DateTimeField(选课时间, auto_now_addTrue) class Meta: verbose_name 选课记录 verbose_name_plural verbose_name constraints [ models.UniqueConstraint( fields[student, course], nameunique_student_course, ) ] def __str__(self): return f{self.student} - {self.course}这里有个容易被忽略的坑UniqueConstraint是全表唯一的。如果退课采用“把 status 改成 dropped”的软删除方案同一个人再次选同一门课时会因为旧记录还占着唯一索引而插入失败。为避免这个坑下面的业务逻辑里退课直接删记录这符合“简单实现”的目标——不保留历史也就不需要软删除。2.3 Course 字段的设计与迁移命令Course模型里那个selected_count字段是我强烈建议保留的但它不是纯字段而是一个需要业务代码维护的冗余计数。另一种方案是不存这个字段每次需要人数时用course.enrollments.count()实时算代码更简单但课程列表页每行都要多一次聚合查询数据量上来后响应会明显变慢。存冗余计数虽然多了一点维护成本换来的是列表页和选课校验都只查一行。字段类型是否必填作用注意点course_noCharField(max_length20, uniqueTrue)是课程编号业务编号别当主键nameCharField(max_length100)是课程名admin 里做搜索字段teacherCharField(max_length50)否授课教师简单系统用字符串即可creditDecimalField(max_digits2, decimal_places1)是学分用 Decimal 不用 FloatcapacityPositiveIntegerField是容量上限默认值建议给 50selected_countPositiveIntegerField是当前已选人数维护时容易忘见第三章semesterCharField(choices...)是开课学期列表筛选的常用维度建好模型后执行迁移注意先创建 app 再迁移python manage.py startapp course_system python manage.py makemigrations course_system python manage.py migratestartapp生成 app 目录后还要在项目settings.py的INSTALLED_APPS里加上course_system否则makemigrations会提示没有检测到模型变化。DecimalField用max_digits2, decimal_places1表示最大 9.9 学分能覆盖绝大多数课程学分别用FloatField浮点数的 0.1 在 MySQL 里会存成近似值打印和计算都可能出现 0.30000000000000004 这种问题。3. 选课与退课逻辑事务、行锁和唯一约束怎么配合3.1 选课动作的三重校验课程是否存在、是否已选、是否满员视图层写选课逻辑时一个常见误用是先查一遍课程再查一遍选课记录最后再判断容量三步之间没有任何保护。两个并发请求交叉执行时它们可能同时读到“剩余名额为 1”然后双双通过校验双双插入成功最终课程多出一个人。这就是超卖。要堵住这个洞三个校验必须放进同一个数据库事务里并且要在事务内部加行锁后再读容量。from django.db import transaction from django.shortcuts import get_object_or_404, render, redirect from django.views.decorators.http import require_POST from .models import Course, Enrollment, Student require_POST transaction.atomic def enroll(request): student get_object_or_404(Student, pkrequest.POST.get(student_id)) course get_object_or_404( Course.objects.select_for_update(), pkrequest.POST.get(course_id), ) if course.selected_count course.capacity: return render(request, course_system/message.html, {msg: 课程已满选课失败}) if Enrollment.objects.filter(studentstudent, coursecourse).exists(): return render(request, course_system/message.html, {msg: 你已经选过这门课}) try: Enrollment.objects.create(studentstudent, coursecourse, statusselected) except IntegrityError: return render(request, course_system/message.html, {msg: 重复选课请刷新后重试}) course.selected_count 1 course.save(update_fields[selected_count]) return redirect(my_courses, student_idstudent.pk)这段代码的先后顺序有讲究。select_for_update()必须在读取selected_count之前执行它会对查到的Course行加锁直到事务提交才释放。第二个请求执行到这行时会阻塞等待第一个请求提交后再继续此时读到的selected_count已经是更新后的值容量判断自然失效从而安全拦截。update_fields参数让save只更新指定列避免因为覆盖整个对象导致并发时把别人刚改过的字段还原回去。IntegrityError是最后一道防线。即使两个请求在极端情况下都通过了容量判断UniqueConstraint也会让第二个插入直接撞唯一索引报错。业务校验挡正常并发数据库约束挡代码漏洞两者缺一不可。3.2 select_for_update 的边界SQLite 与 MySQL 的差异select_for_update()是 Django 提供的关系型数据库行锁 API但它的实际效果取决于底层数据库。MySQL 的 InnoDB 引擎完全支持这是线上最常用的组合。SQLite 则不支持行锁select_for_update()不会报错也不会锁任何东西等于空操作。如果本地开发用 SQLite测试时发现并发超卖这属于正常现象不是代码逻辑错误。提示本地开发建议直接配 MySQL。pip install mysqlclient之后在settings.py里把数据库引擎改成django.db.backends.mysql并配置HOST、PORT、USER、PASSWORD。Linux 下装mysqlclient需要系统里有python3-dev和default-libmysqlclient-dev缺了会编译报错。行锁还会带来一个副作用锁等待。transaction.atomic块里如果还执行了其他慢查询比如发送邮件、调用外部接口锁的持有时间会拉长其他选课请求就会堆积。常见的做法是事务块里只放数据库读写操作任何外部调用都挪到事务提交之后再执行。3.3 退课与我的课表删除对象和 N1 查询处理退课是选课的逆操作同样要锁行否则会出现并发场景下selected_count被减错的情况。退课直接删除记录而不是改状态这样与唯一约束保持一致require_POST transaction.atomic def drop(request): student get_object_or_404(Student, pkrequest.POST.get(student_id)) course get_object_or_404( Course.objects.select_for_update(), pkrequest.POST.get(course_id), ) deleted_count, _ Enrollment.objects.filter( studentstudent, coursecourse, statusselected ).delete() if deleted_count: course.selected_count max(0, course.selected_count - 1) course.save(update_fields[selected_count]) return redirect(my_courses, student_idstudent.pk).delete()在 Django 里返回一个元组第一个值是删除的对象总数第二个值是按模型分组计数的字典。当多张表通过外键级联删除时总数和分组计数能帮你确认到底删了什么调试时很有用。这里判断deleted_count是为了避免重复退课时把已选人数减到负数max(0, ...)是第二层保护。“我的课表”查询要特别注意 N1 问题。直接遍历student.enrollment_set.all()再访问每条的course每行都会触发一次课程表查询。加一行select_related(course)就能让 Django 用一条LEFT JOIN把课程信息一次性查出来def my_courses(request, student_id): student get_object_or_404(Student, pkstudent_id) my_list ( Enrollment.objects.filter(studentstudent, statusselected) .select_related(course) ) return render(request, course_system/my_courses.html, { student: student, my_list: my_list, })4. 打通页面链路URL 路由、表单模板和后台管理的配置要点4.1 最小路由集course_list、enroll、drop、my_coursesDjango 页面链路总共四个入口课程列表页、选课提交、退课提交、我的课表。路由用path函数逐个声明每个都配上name这样模板里的{% url %}和视图里的reverse()都能按名字解析URL 结构调整时不用改模板from django.urls import path from . import views urlpatterns [ path(, views.course_list, namecourse_list), path(enroll/, views.enroll, nameenroll), path(drop/, views.drop, namedrop), path(student/int:student_id/, views.my_courses, namemy_courses), ]路由名URL 路径对应视图函数请求方式course_list/views.course_listGETenroll/enroll/views.enrollPOSTdrop/drop/views.dropPOSTmy_courses/student/ int:student_id /views.my_coursesGET选课和退课都要求 POST这是避免误触发的底线。int:student_id是路径转换器Django 会把 URL 里这一段自动转成整数传给视图函数非数字的请求直接返回 404省掉了手写类型转换和校验。课程列表页除了展示课程还要带上选课表单需要的数据。视图里把students和courses都查出来传给模板模板里用下拉框选学生用另一个下拉框选课程def course_list(request): courses Course.objects.all().order_by(course_no) students Student.objects.all() return render(request, course_system/course_list.html, { courses: courses, students: students, })4.2 模板里显示课程列表、表单和操作结果课程列表页的核心是一个表格加一个选课表单。表格展示课程号和剩余名额剩余名额用selected_count与capacity的差值实时计算出来满员的课程在模板里直接禁用提交按钮table thead tr th课程号/th th课程名/th th教师/th th学分/th th名额/th /tr /thead tbody {% for course in courses %} tr td{{ course.course_no }}/td td{{ course.name }}/td td{{ course.teacher }}/td td{{ course.credit }}/td td{{ course.selected_count }} / {{ course.capacity }}/td /tr {% endfor %} /tbody /table form methodpost action{% url enroll %} {% csrf_token %} select namestudent_id {% for student in students %} option value{{ student.id }}{{ student.student_no }} {{ student.name }}/option {% endfor %} /select select namecourse_id {% for course in courses %} option value{{ course.id }}{{ course.name }}/option {% endfor %} /select button typesubmit提交选课/button /form{% csrf_token %}是 Django 表单的强制项模板里漏掉它POST 会被 Django 的 CSRF 中间件直接拦截并返回 403。手写select时name属性决定了视图层request.POST.get()的读取键名value则是实际提交的值这两处和下面对齐否则视图里拿到的就是None。操作结果的提示用的是独立的message.html视图层把提示文案传进来这个页面在选课成功时并不会被使用因为成功走的是redirect跳回我的课表只有校验失败才渲染提示页。4.3 admin 后台让教务能自己维护基础数据学生和课程的基础数据维护不需要另外写页面Django 自带 admin 就可以。注册模型时给ModelAdmin配上展示字段、筛选条件和搜索框这就是 django admin 界面美化最常用的三件套from django.contrib import admin from .models import Course, Enrollment, Student admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display (course_no, name, teacher, credit, capacity, selected_count) list_filter (semester,) search_fields (course_no, name) admin.register(Student) class StudentAdmin(admin.ModelAdmin): list_display (student_no, name, enroll_year) search_fields (student_no, name) admin.register(Enrollment) class EnrollmentAdmin(admin.ModelAdmin): list_display (student, course, status, selected_at) list_filter (status, course)list_display控制后台列表页显示的列list_filter会在右侧生成筛选面板search_fields生成顶部搜索框。这三个属性配好教务人员录入课程、查看选课名单就不需要碰代码。特别注意 admin 里直接修改selected_count不会同步数据库记录这是冗余字段的固有代价。上线后的维护方式是在 admin 里手工调整数据时只改课程基本信息选课人数一律通过正常选课退课流程变化。5. 上线前验证与边界处理并发测试、时区与可维护性技巧5.1 两个终端并发选课的复现方法并发超卖能不能验证取决于业务入口好不好调用。最直接的做法是把选课逻辑收敛成模型上的一个方法让两个 Django shell 进程能同时调用它在同一个事务里执行。这个方法可以作为视图层选课流程的基础也方便写测试from django.db import transaction class CourseFull(Exception): pass class AlreadyEnrolled(Exception): pass class Course(models.Model): # 前面已有的字段省略 transaction.atomic def enroll_student(self, student): course Course.objects.select_for_update().get(pkself.pk) if course.selected_count course.capacity: raise CourseFull(课程已满) _, created Enrollment.objects.get_or_create( studentstudent, coursecourse, statusselected, ) if not created: raise AlreadyEnrolled(不能重复选课) course.selected_count 1 course.save(update_fields[selected_count])get_or_create依赖唯一约束实现原子性并发时只有一个请求能成功插入另一个会等锁结束再尝试此时因为记录已存在直接走createdFalse分支不会报IntegrityError。验证时先手动把某门课容量改成 1然后开两个终端同时执行python manage.py shell -c from course_system.models import Student, Course s Student.objects.get(pk1) c Course.objects.get(pk1) c.enroll_student(s) 在 MySQL 下最终只会有一个终端成功打印另一个抛出CourseFull课程人数停在 1。在 SQLite 下可能两个终端都通过校验最终两个学生都选上同一门课这正好能证明行锁在 SQLite 上不可用。5.2 三个容易出事的配置项时区、连接与静态文件TIME_ZONE不配置好selected_at存进去的时间会与本地时间差 8 小时。常见做法是TIME_ZONE Asia/Shanghai、USE_TZ True。USE_TZTrue时数据库里存的是 UTC模板渲染才转成当地时间所以调试时看到数据库里的时间“不对”不要慌先看页面上显示的时间。CONN_MAX_AGE决定数据库连接复用时长。线上用 MySQL 时如果设置成大于 0 的长连接事务里其他连接持有行锁过久会触发1213 Deadlock found错误。Django 不会自动重试视图层要捕获OperationalError提示用户稍后重试或者把CONN_MAX_AGE设为 0 让每个请求用短连接简单系统更省心。最后是静态文件。项目在本地开发没问题用宝塔或云服务器部署时把DEBUG改为False之后admin 的 CSS 会全部失效。STATIC_ROOT配好并执行python manage.py collectstatic再让 Nginx 把/static/指向收集目录这是部署阶段最容易卡人的一步。5.3 用自定义异常让视图层只剩异常捕获enroll_student抛出异常后视图层不需要关心校验细节只需捕获并翻译成用户提示。这样业务规则放在模型上视图只负责 IO 和页面跳转职责切得干净require_POST def enroll(request): student get_object_or_404(Student, pkrequest.POST.get(student_id)) course get_object_or_404(Course, pkrequest.POST.get(course_id)) try: course.enroll_student(student) except CourseFull: return render(request, course_system/message.html, {msg: 课程已满选课失败}) except AlreadyEnrolled: return render(request, course_system/message.html, {msg: 你已经选过这门课}) return redirect(my_courses, student_idstudent.pk)异常类型本身就成了接口文档测试时也可以按异常分支写用例而不是依赖返回值判断。这个结构再往后接 REST API 也很顺手把render换成JsonResponse异常翻译逻辑挪到中间件即可。至此一个能处理并发选课、后台可维护、部署不踩坑的 Django Python 学生选课管理系统就完整落到了代码里剩下的页面样式按学校色系调整即可。本文还有配套的精品资源点击获取