news 2026/9/13 17:29:20

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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_nounique=True,因为学号可能被导入导出、被打印在纸条上,自增 id 一旦迁移就会变,业务编号不该承担主键职责。Course里除了课程名、教师、学分,还要放两个容易被忽略的字段:capacity表示容量,selected_count表示当前已选人数。后者是一个冗余计数,目的是避免每次看列表都去count()一次选课记录,课程列表一多,这个计数能省掉大量查询。

from django.db import models class Student(models.Model): student_no = models.CharField("学号", max_length=20, unique=True) name = models.CharField("姓名", max_length=50) enroll_year = models.IntegerField("入学年份", default=2025) email = models.EmailField("邮箱", null=True, blank=True) 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支持through="Enrollment"参数指向显式模型,但既然都要写一个中间模型了,直接建Enrollment反而更清晰,查询路径也好控制。

另一个更实际的原因是防重约束。选课系统的硬规则是“一个学生同一门课只能选一次”,这条规则不能只靠视图代码判断,数据库层也必须兜底。在Enrollment.Meta里声明UniqueConstraint,即使业务代码漏判,数据库也会拒绝重复插入:

class Enrollment(models.Model): STATUS_CHOICES = [ ("selected", "已选"), ("dropped", "已退"), ] student = models.ForeignKey(Student, on_delete=models.CASCADE, verbose_name="学生") course = models.ForeignKey(Course, on_delete=models.CASCADE, verbose_name="课程", related_name="enrollments") status = models.CharField("状态", max_length=20, choices=STATUS_CHOICES, default="selected") selected_at = models.DateTimeField("选课时间", auto_now_add=True) class Meta: verbose_name = "选课记录" verbose_name_plural = verbose_name constraints = [ models.UniqueConstraint( fields=["student", "course"], name="unique_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_length=20, unique=True)课程编号业务编号,别当主键
nameCharField(max_length=100)课程名admin 里做搜索字段
teacherCharField(max_length=50)授课教师简单系统用字符串即可
creditDecimalField(max_digits=2, decimal_places=1)学分用 Decimal 不用 Float
capacityPositiveIntegerField容量上限默认值建议给 50
selected_countPositiveIntegerField当前已选人数维护时容易忘,见第三章
semesterCharField(choices=...)开课学期列表筛选的常用维度

建好模型后执行迁移,注意先创建 app 再迁移:

python manage.py startapp course_system python manage.py makemigrations course_system python manage.py migrate

startapp生成 app 目录后,还要在项目settings.pyINSTALLED_APPS里加上course_system,否则makemigrations会提示没有检测到模型变化。DecimalFieldmax_digits=2, decimal_places=1表示最大 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, pk=request.POST.get("student_id")) course = get_object_or_404( Course.objects.select_for_update(), pk=request.POST.get("course_id"), ) if course.selected_count >= course.capacity: return render(request, "course_system/message.html", {"msg": "课程已满,选课失败"}) if Enrollment.objects.filter(student=student, course=course).exists(): return render(request, "course_system/message.html", {"msg": "你已经选过这门课"}) try: Enrollment.objects.create(student=student, course=course, status="selected") 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_id=student.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,并配置HOSTPORTUSERPASSWORD。Linux 下装mysqlclient需要系统里有python3-devdefault-libmysqlclient-dev,缺了会编译报错。

行锁还会带来一个副作用:锁等待。transaction.atomic块里如果还执行了其他慢查询,比如发送邮件、调用外部接口,锁的持有时间会拉长,其他选课请求就会堆积。常见的做法是事务块里只放数据库读写操作,任何外部调用都挪到事务提交之后再执行。

3.3 退课与我的课表:删除对象和 N+1 查询处理

退课是选课的逆操作,同样要锁行,否则会出现并发场景下selected_count被减错的情况。退课直接删除记录而不是改状态,这样与唯一约束保持一致:

@require_POST @transaction.atomic def drop(request): student = get_object_or_404(Student, pk=request.POST.get("student_id")) course = get_object_or_404( Course.objects.select_for_update(), pk=request.POST.get("course_id"), ) deleted_count, _ = Enrollment.objects.filter( student=student, course=course, status="selected" ).delete() if deleted_count: course.selected_count = max(0, course.selected_count - 1) course.save(update_fields=["selected_count"]) return redirect("my_courses", student_id=student.pk)

.delete()在 Django 里返回一个元组,第一个值是删除的对象总数,第二个值是按模型分组计数的字典。当多张表通过外键级联删除时,总数和分组计数能帮你确认到底删了什么,调试时很有用。这里判断deleted_count是为了避免重复退课时把已选人数减到负数,max(0, ...)是第二层保护。

“我的课表”查询要特别注意 N+1 问题。直接遍历student.enrollment_set.all()再访问每条的course,每行都会触发一次课程表查询。加一行select_related("course")就能让 Django 用一条LEFT JOIN把课程信息一次性查出来:

def my_courses(request, student_id): student = get_object_or_404(Student, pk=student_id) my_list = ( Enrollment.objects.filter(student=student, status="selected") .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_courses

Django 页面链路总共四个入口:课程列表页、选课提交、退课提交、我的课表。路由用path函数逐个声明,每个都配上name,这样模板里的{% url %}和视图里的reverse()都能按名字解析,URL 结构调整时不用改模板:

from django.urls import path from . import views urlpatterns = [ path("", views.course_list, name="course_list"), path("enroll/", views.enroll, name="enroll"), path("drop/", views.drop, name="drop"), path("student/<int:student_id>/", views.my_courses, name="my_courses"), ]
路由名URL 路径对应视图函数请求方式
course_list/views.course_listGET
enroll/enroll/views.enrollPOST
drop/drop/views.dropPOST
my_courses/student/ int:student_id /views.my_coursesGET

选课和退课都要求 POST,这是避免误触发的底线。<int:student_id>是路径转换器,Django 会把 URL 里这一段自动转成整数传给视图函数,非数字的请求直接返回 404,省掉了手写类型转换和校验。

课程列表页除了展示课程,还要带上选课表单需要的数据。视图里把studentscourses都查出来传给模板,模板里用下拉框选学生,用另一个下拉框选课程:

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_countcapacity的差值实时计算出来,满员的课程在模板里直接禁用提交按钮:

<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 method="post" action="{% url 'enroll' %}"> {% csrf_token %} <select name="student_id"> {% for student in students %} <option value="{{ student.id }}">{{ student.student_no }} {{ student.name }}</option> {% endfor %} </select> <select name="course_id"> {% for course in courses %} <option value="{{ course.id }}">{{ course.name }}</option> {% endfor %} </select> <button type="submit">提交选课</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(pk=self.pk) if course.selected_count >= course.capacity: raise CourseFull("课程已满") _, created = Enrollment.objects.get_or_create( student=student, course=course, status="selected", ) if not created: raise AlreadyEnrolled("不能重复选课") course.selected_count += 1 course.save(update_fields=["selected_count"])

get_or_create依赖唯一约束实现原子性:并发时只有一个请求能成功插入,另一个会等锁结束再尝试,此时因为记录已存在,直接走created=False分支,不会报IntegrityError。验证时先手动把某门课容量改成 1,然后开两个终端同时执行:

python manage.py shell -c " from course_system.models import Student, Course s = Student.objects.get(pk=1) c = Course.objects.get(pk=1) c.enroll_student(s) "

在 MySQL 下,最终只会有一个终端成功打印,另一个抛出CourseFull,课程人数停在 1。在 SQLite 下,可能两个终端都通过校验,最终两个学生都选上同一门课,这正好能证明行锁在 SQLite 上不可用。

5.2 三个容易出事的配置项:时区、连接与静态文件

TIME_ZONE不配置好,selected_at存进去的时间会与本地时间差 8 小时。常见做法是TIME_ZONE = "Asia/Shanghai"USE_TZ = TrueUSE_TZ=True时数据库里存的是 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, pk=request.POST.get("student_id")) course = get_object_or_404(Course, pk=request.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_id=student.pk)

异常类型本身就成了接口文档,测试时也可以按异常分支写用例,而不是依赖返回值判断。这个结构再往后接 REST API 也很顺手:把render换成JsonResponse,异常翻译逻辑挪到中间件即可。至此,一个能处理并发选课、后台可维护、部署不踩坑的 Django + Python 学生选课管理系统就完整落到了代码里,剩下的页面样式按学校色系调整即可。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 17:28:50

理解rebase和代码合并操作流程

工具操作入口 idea里合并代码选项提供了rebase和merge&#xff0c;其中有一个rebase xx onto yy&#xff0c;这个的意思是把xx分支进行rebase&#xff0c;参照的分支是yy分支最新记录重新变更提交记录&#xff0c;开始的开始点是公共的第一个祖先节点&#xff0c;使变更记录变得…

作者头像 李华
网站建设 2026/9/13 17:25:00

51单片机气体监测系统:ADC0832+LCD12864仿真与硬件闭环实现

简介&#xff1a;本资源是一套面向电子类专业学生与单片机初学者的完整焊机气体监测系统设计资料&#xff0c;聚焦焊接安全场景下的实时气体状态感知与智能保护逻辑实现。资源包含Proteus仿真工程、Keil C源码、AD原理图及配套论文&#xff0c;覆盖从硬件选型、传感器信号采集&…

作者头像 李华
网站建设 2026/9/13 17:24:48

车规级CAN超时丢包抖动的本质与根因诊断

1. 车规级CAN通信的“容错”不是容错&#xff0c;是设计哲学你有没有遇到过这样的场景&#xff1a;整车厂发来一份故障报告&#xff0c;写着“某ECU在冷启动后30秒内偶发报文超时&#xff0c;持续2~3帧&#xff0c;之后自动恢复”&#xff0c;附带一段CANoe抓取的MF4日志&#…

作者头像 李华