☰
网上选课系统双技术栈实战:SSM与Django核心逻辑全解析
2026/10/7 3:15:33 网站建设 项目流程

1. 一个毕设标题背后的双技术栈真相:Java+SSM和Django为什么会同时出现

我拿到这个项目标题的第一反应是:这个题目大概率是毕设市场上标准的“一题双卖”——同一个网上选课系统的业务需求,分别用Java(SSM)和Python(Django)各做了一套实现,源码、论文、调试文档配套齐全,学生拍到之后,根据自己的技术基础选其中一套来跑、来改、来答辩就可以了。

为什么我敢这么判断?因为一个正常的企业级项目根本不需要同时用SSM和Django实现同一套逻辑,那是重复造轮子。但在毕业设计这个场景里,双技术栈的选题有非常现实的意义:

  • 学生背景差异大:有的只学过Java Web,有的只学过Python Web,双版本可以覆盖更多人群。
  • 答辩老师偏好不同:有的教研室以Java为主,有的以Python数据分析方向为主,提供双版本等于上了双保险。
  • 扩展性好:学生拿到源码后,如果某套跑不起来,还能切换到另一套,不至于“死在一个坑里”。

从标题本身看,“网上选课系统”这个业务在所以毕设选题里属于难度适中、演示效果好、逻辑清晰的典型题目。它不像电商系统那样要处理支付和库存,也不像社交平台那样要处理复杂的关系链,但它具备一个完整信息系统的所有核心要素:多角色权限、时间窗口状态、并发冲突、一对多/多对多关系。选课系统中“一门课被很多人抢选”、“一个学生选多门课”、“选课截止后状态切换”这些业务规则恰到好处地覆盖了数据库设计、事务控制、状态管理等关键知识点,用来答辩完全撑得住场面。

因此,这篇文章我不打算只讲某个版本的代码怎么跑,而是把这两个技术栈的实现逻辑、核心表设计、选课冲突处理、交付物组织方式、调试经验全部拆开讲一遍。无论你最终选择的是SSM还是Django版本,都能在这篇文章里找到自己需要的东西。

2. 网上选课系统的核心业务骨架:先搞懂需求再谈技术

很多同学拿到毕设源码的第一件事就是启动Tomcat跑起来,然后对着页面发呆——“这个系统到底有哪些功能?表结构为什么这么设计?选课的流程到底是什么?”

我建议你反过来:先理业务,再看代码。因为理解了业务骨架,代码里那些看似绕弯的写法立刻就通透了。

2.1 三类角色的权限边界

网上选课系统最基本的参与者有三类:学生、教师、管理员。每一类角色管的事完全不同,这也是系统里所有权限判断的出发点。

学生端是最核心的使用方。学生登录后要做的事情包括:浏览本学期开放的课程列表、查看课程详情(上课时间、授课教师、剩余容量、已选人数)、提交选课申请、退选已选课程、查看自己的课表、查看成绩。这里有一个关键的业务规则:学生能看到的课,必须是“选课周期内”开放的课;学生能退选的课,必须是“还没过退选截止时间”的课。这些判断散落在不同接口里,千万别指望前端帮我挡住,后端每个请求都要校验。

教师端相对简单:教师可以查看自己负责的课程列表、查看选课学生名单、录入成绩(平时分+期末分,最终按权重合成总评)。有些系统还允许教师维护课程的基本信息,比如上课地点和教学大纲,但这属于扩展功能,核心是查看选课名单和录成绩。

管理员端是整个系统的“上帝视角”。管理员负责维护基础数据——学生信息、教师信息、课程信息的增删改查;维护开课计划——哪个学期开哪些课、哪个老师教哪门课、课程容量是多少;维护选课窗口——什么时候开始选、什么时候截止、能不能补选、能不能退选。管理员是唯一能直接操作选课状态的角色,学生和教师都不行。

这三类角色对应到数据库设计上,典型做法是建一张user表,用role字段区分身份(0=管理员,1=学生,2=教师),同时把学生、教师的详细信息分别放在student和teacher表里,通过user_id外键关联。这个设计很朴素,但完全够用——比建三张独立的登录表要合理得多,因为角色的公共字段(账号、密码、姓名、邮箱)只需要一份存储。

提示:有些系统会把角色做成role表 +user_role关联表,用来支持“一个用户多个角色”。但选课系统的实际业务里,一个用户几乎不可能既是学生又是教师,所以直接在user表里放role字段就够了,别过度设计。

2.2 核心数据表:学生、课程、选课记录三张表如何联动

把需求翻译成数据库结构,最核心的表其实是三张:

用户表(user):用户ID、账号、密码(建议MD5或加盐哈希存储)、角色、姓名、邮箱/手机号。

课程表(course):课程ID、课程名称、课程编号、学分、授课教师ID(外键到teacher)、上课时间(通常用星期+节次描述,比如周一 1-2节)、上课地点、课程容量、已选人数、开课学期、课程状态(0=停用,1=启用)。

选课记录表(course_selection):选课ID、学生ID(外键到student)、课程ID(外键到course)、选课时间、成绩(默认NULL,教师录入)、退选标记(0=正常,1=已退选)。

为什么说这三张表是核心?因为整个系统的所有业务逻辑都围绕它们转:

  • 学生提交选课 = 往course_selection插入一条记录 + 更新course表的selected_count字段。
  • 学生退选 = 更新course_selection的is_deleted标记 + 更新course表的selected_count字段。
  • 教师查询选课名单 = 通过course_id查course_selection,再关联student表拿学生信息。
  • 管理员开课 = 往course表插入记录,初始selected_count=0。

这里有一个设计细节值得展开:退选为什么用逻辑删除而不是物理删除?

因为如果直接DELETE掉选课记录,那门课的选课历史、学生的最终成绩(如果已录)就全没了。而且学校查选课流水时,需要“选过又退了”的完整记录。用is_deleted字段打标记,既能保留历史,又能在统计“实际在选人数”时统一用WHERE is_deleted = 0过滤,一举两得。这个设计在毕设答辩时也是一个可以直接讲给评委听的亮点。

2.3 选课状态机:选课窗口如何控制系统的打开与关闭

很多同学忽略了一个关键点:选课系统不是7x24小时都能选课的。现实中,学校的选课系统只在规定的时间窗口内开放,比如“2024年9月1日 8:00 —— 2024年9月10日 23:59”,过了这个窗口,学生就不能再提交或退选课程了。

这意味着系统里必须有一个“选课状态控制”的机制。我的建议是:在admin_config表(或system_config表)里存两个字段——start_time和end_time,管理员通过后台设置,后端代码在每次处理选课/退选请求时,先比对当前时间是否在窗口内。

这个判断我在两种技术栈里的写法都不太一样(后面详述),但核心逻辑就一段伪代码:

当前时间是否在 startTime 和 endTime 之间? 是 -> 允许操作 否 -> 返回“当前不在选课时间范围内”

别小看这个窗口判断,它实际上定义了一个选课状态机:未开始(选课按钮置灰)→ 进行中(可选可退)→ 已结束(所有选课/退选接口拒绝服务)。在SSM版本里,这个判断可能写在一个CheckTimeInterceptor或AOP切面里;在Django版本里,可能写成视图里的一小段判断逻辑或一个自定义的decorator。无论放哪里,业务规则只有一个——过了截止时间,谁都别想改选课记录。

注意:有的系统还设计了“补选阶段”(统计漏选学生后再开放一次窗口),本质上就是把这个状态机扩展成“选课阶段-补选阶段-查询阶段”三态。如果你的系统有这个需求,就再增加一个phase字段标记当前所处阶段,比单纯用时间戳判断更灵活。

3. SSM版本落地实录:从配置文件到选课请求的完整链路

如果你最终选了Java这套,SSM(Spring + SpringMVC + MyBatis)是老牌经典的组合,网上资料多、遇到问题好搜。但正因为经典,很多同学对它的理解停留在“会用注解”层面,不理解一条请求从Tomcat进来后到底经过了哪些环节。下面按一条“学生提交选课”的请求链路,把SSM的落地过程从头到尾讲清楚。

3.1 项目结构和依赖配置:SSM的骨架搭建

SSM项目的基础结构是标准的Maven Web工程:

pom.xml src/main/java ├── com.example.controller # Controller层 ├── com.example.service # Service层(接口 + 实现类) ├── com.example.dao # MyBatis的Mapper接口 ├── com.example.entity # 实体类(对应数据库表) ├── com.example.utils # 工具类 └── com.example.interceptor # 拦截器(可选) src/main/resources ├── jdbc.properties # 数据库连接配置 ├── spring-mvc.xml # SpringMVC配置 ├── spring-mybatis.xml # Spring整合MyBatis配置 └── mapper # MyBatis的XML映射文件 src/main/webapp/WEB-INF └── web.xml

在pom.xml里需要引入的依赖主要是这些:spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector-java(或druid连接池)、javax.servlet-api(Tomcat编译期用)、jstl(JSP页面用)、jackson-databind(JSON序列化)。

spring-mybatis.xml是最容易让人懵的地方。它的作用是:把数据源、事务管理器、MyBatis的SqlSessionFactory、Mapper扫描器全部整合进Spring容器。核心配置我这么写:

<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"> <property name="driverClassName" value="${jdbc.driver}"/> <property name="url" value="${jdbc.url}"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <mybatis:scan base-package="com.example.dao"/>

这里有几个关键点需要理解,否则你就是“配对了但不知道为什么”:

  • mapperLocations告诉MyBatis去哪里找SQL映射XML文件,如果路径写错了,启动时Mapper接口和SQL文件关联不上,会直接报BindingException。
  • mybatis:scan把dao包下的接口全部注册成Spring的Bean。扫描成功之后,Controller、Service里才能用@Autowired注入Mapper接口。
  • **DruidDataSource**负责数据库连接池管理,比直接用DriverManager的方式性能好得多,而且可以在jdbc.properties里配置初始连接数、最大连接数、超时时间等参数。

3.2 选课请求的请求链路:Controller到Mapper的四层递进

以“学生提交选课”为例子,这条请求在SSM里会经历以下四个环节。

第一步:Controller接收前端请求

Controller只做三件事:接收参数、调用Service、根据Service返回结果决定跳转页面或返回JSON。我习惯用@RequestMapping+@ResponseBody的方式,让接口直接返回JSON,方便前后端分离;如果项目使用JSP,则返回视图名称,由InternalResourceViewResolver解析。

@Controller @RequestMapping("/selection") public class SelectionController { @Autowired private CourseSelectionService selectionService; @RequestMapping(value = "/select", method = RequestMethod.POST) @ResponseBody public Result selectCourse(@RequestParam("courseId") Integer courseId, @RequestParam("studentId") Integer studentId) { try { selectionService.selectCourse(studentId, courseId); return Result.success("选课成功"); } catch (BusinessException e) { return Result.error(e.getMessage()); } catch (Exception e) { return Result.error("系统异常,请稍后重试"); } } }

注意:Result是一个自定义的统一返回对象,包含code、message、data三个字段。毕设系统里用统一返回结构,前端拿JSON时逻辑统一,不会出现“这个接口返回字符串、那个接口返回Map”的混乱。

第二步:Service处理业务规则

Service是业务逻辑的主战场。在selectCourse方法里,必须依次完成这些校验:

  1. 当前时间是否在选课窗口内;
  2. 课程是否存在、是否启用;
  3. 该学生是否已经选过这堂课(防止重复提交);
  4. 课程当前已选人数是否达到容量上限;
  5. 插入选课记录,同时更新selected_count。

这里最容易犯的错误是把所有校验写在Controller里。虽然功能也能跑,但一旦多个Controller都要用到“判断课程是否可选”的逻辑,就出现重复代码了。正确做法是:Controller只收参,业务规则全部下沉到Service里。

第三步:Service实现类完成事务控制

选课操作涉及插入选课记录和更新课程已选人数两步,必须放在同一个数据库事务里,保证原子性。MySQL的InnoDB引擎默认支持事务,但代码层面还要加上@Transactional注解才能让Spring帮我们管理事务边界:

@Service @Transactional public class CourseSelectionServiceImpl implements CourseSelectionService { @Autowired private CourseSelectionDao courseSelectionDao; @Autowired private CourseDao courseDao; @Override public void selectCourse(Integer studentId, Integer courseId) { // 1. 校验选课窗口 // 2. 校验课程状态与容量 // 3. 判断是否重复选课 // 4. 插入选课记录 // 5. 更新课程已选人数 selected_count = selected_count + 1 } }

@Transactional注解放在类上表示类中所有public方法都会被事务拦截。Spring通过AOP在方法开始前开启事务、方法成功后提交、方法抛出异常时回滚。这个特性在我们稍后要讲的并发冲突处理中至关重要。

第四步:MyBatis Mapper执行SQL

最后一步是数据访问层。MyBatis的Mapper接口只定义方法签名,真正的SQL写在XML里,我用<sql>标签和<if>标签可以很灵活地拼条件:

<select id="countByStudentAndCourse" resultType="int"> SELECT COUNT(*) FROM course_selection WHERE student_id = #{studentId} AND course_id = #{courseId} AND is_deleted = 0 </select> <update id="increaseSelectedCount"> UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count &lt; capacity </update>

提示:selected_count < capacity这个条件加在UPDATE语句里非常有价值——它能防止两个学生同时选最后一门课时,数据库层面出现“超选”的错误。即使两个请求同时进入Service层并都通过了校验,到了UPDATE语句时,InnoDB的行锁会让其中一个请求的UPDATE匹配不到行(因为另一个请求已经让selected_count达到capacity),从而让这个请求的影响行数为0。代码里只需要检查updateResult > 0就知道是否还有容量,这也是下面“并发冲突”章节的地基。

3.3 SSM版本的坑:事务失效、路径扫描、JSON乱码的避坑办法

SSM这套组合虽然经典,但坑也多,我把这几年带毕设常见的几个问题列出来,如果你启动或运行时遇到类似症状,可以直接对照排查。

坑一:事务注解不生效。典型症状是@Transactional明明写了,但插入成功后课程人数没更新成功,数据不一致。原因通常是:spring-mybatis.xml里没有启用<tx:annotation-driven>,或者WebApplicationContext根本没加载到transactionManager这个Bean。另一个隐蔽原因是事务方法被同类内部调用——AOP代理只拦截外部入口调用,如果this.selectCourse()在同类里被另一个方法调用,事务不会生效。解决办法是把事务方法放在独立的Bean里调用,或者自己注入自身代理。

坑二:静态资源被DispatcherServlet拦截。前端页面里的CSS、JS、图片突然全部404。原因很可能是web.xml里把DispatcherServlet映射到了/,导致所有请求都走SpringMVC,静态资源被当成Controller请求处理。解决办法是在spring-mvc.xml里加<mvc:default-servlet-handler/>,或单独配置资源映射路径。

坑三:JSON输出乱码。接口返回的中文全部变成???。这个问题的根源是SpringMVC的StringHttpMessageConverter默认使用ISO-8859-1编码。两个修法:一是@RequestMapping里加produces = "application/json; charset=utf-8",二是在spring-mvc.xml里定义一个StringHttpMessageConverter并设置为UTF-8,我推荐第二种,一劳永逸:

<mvc:annotation-driven> <mvc:message-converters> <bean class="org.springframework.http.converter.StringHttpMessageConverter"> <property name="supportedMediaTypes"> <list> <value>text/plain;charset=UTF-8</value> <value>text/html;charset=UTF-8</value> <value>application/json;charset=UTF-8</value> </list> </property> </bean> </mvc:message-converters> </mvc:annotation-driven>

4. Django版本的核心差异:ORM思维、Admin后台、认证系统

如果你选了Python这套,恭喜你,Django在“做管理类信息系统”这件事上确实比SSM省事不少。尤其是有自带的Admin后台和用户认证模块,很多SSM里要手写的东西,Django开箱即用。但省事不等于没逻辑,下面把Django版本里那些和SSM“同业务不同写法”的地方逐一对比。

4.1 Django项目结构与SSM的对应关系

Django项目的典型结构长这样:

manage.py myproject/ ├── settings.py # 对应SSM的spring-mybatis.xml + spring-mvc.xml ├── urls.py # 对应SpringMVC的@RequestMapping路由分发 ├── wsgi.py course_system/ # 一个app,对应SSM里的一个模块(如com.example.selection包) ├── models.py # 对应数据库表,用ORM类描述 ├── views.py # 对应Controller ├── urls.py # 该app自己的路由表 ├── admin.py # 注册Admin后台管理模型 └── forms.py # 表单校验(对应SSM里的@Valid或手动校验)

一句话总结:Django的models.py= MyBatis的mapper XML + entity类,views.py= Controller,admin.py= 半个现成的管理员后台。

4.2 用ORM实现选课关系:模型定义与事务写法

Django的连接数据库、建表方式比MyBatis更自动化。在models.py里定义好模型类后,跑makemigrations和migrate两条命令,Django就自动生成对应的数据库表,不需要写CREATE TABLE。

选课系统的三张核心表用Django定义是这样的:

from django.db import models class User(models.Model): ROLE_CHOICES = ( (0, '管理员'), (1, '学生'), (2, '教师'), ) username = models.CharField(max_length=50, unique=True) password = models.CharField(max_length=128) # 存hash值 role = models.IntegerField(choices=ROLE_CHOICES, default=1) name = models.CharField(max_length=50) email = models.EmailField(blank=True) class Course(models.Model): name = models.CharField(max_length=100) course_no = models.CharField(max_length=20, unique=True) credit = models.FloatField(default=2.0) teacher = models.ForeignKey('Teacher', on_delete=models.CASCADE) schedule = models.CharField(max_length=50) # 例如"周一 1-2节" location = models.CharField(max_length=50) capacity = models.IntegerField(default=60) selected_count = models.IntegerField(default=0) semester = models.CharField(max_length=20) status = models.IntegerField(default=1) # 0停用 1启用 class CourseSelection(models.Model): student = models.ForeignKey('Student', on_delete=models.CASCADE) course = models.ForeignKey('Course', on_delete=models.CASCADE) select_time = models.DateTimeField(auto_now_add=True) score = models.FloatField(null=True, blank=True) is_deleted = models.IntegerField(default=0) # 0正常 1退选

这里有一点必须提醒:on_delete=models.CASCADE在你删除教师或课程时,会级联删除相关的选课记录。对于选课系统来说,删除课程通常意味着“这门课不开”,那它的选课记录确实应该清理掉,级联删除是合理的。但如果删除的是学生,他的历史选课记录也一并没了,成绩也就没了。这个规则按学校教务处的要求来定,有的学校选择SET_NULL保留历史,那就得把外键字段设成null=True,否则执行迁移时会报错。

Django的ORM在事务控制上比SSM更简洁——用一个with transaction.atomic()装饰器或上下文管理器就能把代码块包进统一事务:

from django.db import transaction from django.utils import timezone def select_course(student_id, course_id): # 所有的校验逻辑:时间窗口、课程状态、容量、重复选课 with transaction.atomic(): # 插入选课记录 CourseSelection.objects.create(student_id=student_id, course_id=course_id, select_time=timezone.now()) # 更新课程已选人数,加一个条件防止超选 updated = Course.objects.filter(id=course_id, selected_count__lt=models.F('capacity') ).update(selected_count=models.F('selected_count') + 1) if updated == 0: raise ValueError('课程已满员')

关键点在于F('selected_count')的用法——它让“从当前值基础上+1”这个操作在数据库层面完成,而不是先在Python里读出selected_count值、加1、再写回去。用F表达式的好处是避免“读-改-写”三步之间的并发间隙,和SSM版本里UPDATE course SET selected_count = selected_count + 1 WHERE ...是一个道理,都是为了防超选。

4.3 为什么Django的Admin后台能帮你省掉一半工作量

SSM版本里,管理员端的课程管理、学生管理、教师管理页面,要写Controller、JSP、JS一大堆代码。但Django自带一个功能全面的Admin后台,注册模型后自动生成增删改查界面。

在admin.py里写:

from django.contrib import admin from .models import Course, Student, Teacher, CourseSelection @admin.register(Course) class CourseAdmin(admin.ModelAdmin): list_display = ('name', 'course_no', 'teacher', 'capacity', 'selected_count', 'status') search_fields = ('name', 'course_no') list_filter = ('semester', 'status')

这段代码就给管理员生成了一个带搜索、筛选、分页的课程管理页面。如果你是个人开发者给学校做内部系统,Admin后台几乎直接就是管理端了,连前端页面都不用写。

但有两个坑必须提醒:

坑一:Admin后台的权限体系。Django的Admin默认只有is_staff=True且登录后的用户才能访问。你的学生、教师账号如果走普通注册流程,默认is_staff=False,是进不了Admin页面的。所以毕设答辩时如果要演示Admin功能,需要创建一个is_staff=True的管理员账号(createsuperuser命令)。

坑二:Admin后台不代表全部管理功能。毕设演示时,如果你只用Admin后台管理数据,course_selection的选课记录、成绩录入这些操作虽然也能在Admin里做,但界面不够贴近“教务管理”的业务场景。现实中,我建议至少把“选课管理”(按课程查看选课名单、按学生查看课表)和“成绩录入”这两个页面单独实现,Admin后台只用来做基础数据维护。这样既展示了Django的ORM开发效率,又能让评委看到你写了核心业务代码,而不是光靠自带后台撑场面。

4.4 Django做选课系统时的登录鉴权方案

Django自带一套用户认证体系,对选课系统来说,最佳实践是复用auth.User,再通过OneToOneField扩展学生/教师信息。

from django.contrib.auth.models import User from django.db import models class Student(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE) student_no = models.CharField(max_length=20, unique=True) major = models.CharField(max_length=100) grade = models.CharField(max_length=20)

这样做的最大好处是:直接使用Django的login、logout、login_required装饰器做登录状态管理,不用自己写Session或Token逻辑。比如学生选课接口,加一个@login_required就能保证只有登录用户能访问:

from django.contrib.auth.decorators import login_required @login_required def select_course(request): # request.user 就是当前登录用户 ...

几行代码免去了自己在Session里存用户ID、每次取出来判断登录状态的工作量。对比SSM版本要写一个LoginInterceptor,确实省事很多。但要注意一点:auth.User的username字段直接复用,学生学号里经常有特殊字符,需要在创建账号时做转换或校验,避免和密码格式冲突。

5. 选课并发与业务冲突:最容易翻车也最值得写进论文的地方

我刚拿到这个项目时,把注意力放在“怎么把页面跑起来”上,觉得选课嘛,就是往表里插一行数据。直到有朋友让我帮他的毕设压测,才发现并发选课才是这个系统技术含量最高的地方。两个学生同时点击“选课”按钮,恰好只剩下最后一个名额,如果处理不当,就会出现“都选上了”的数据不一致。这个场景最适合写进毕业论文,因为它既有业务价值又有技术深度。

5.1 超选问题的本质:读改写三步的竞态条件

超选的本质是“读改写”三步操作之间存在竞态窗口。伪代码是这样的:

1. 查:SELECT COUNT(*) FROM course_selection WHERE course_id = ? 2. 判断:count < capacity 吗? 3. 插入:INSERT INTO course_selection ...

如果两个请求同时在步骤1读到count = 59(而capacity是60),都通过了步骤2的判断,然后在步骤3都插入成功,数据库里就有61条选课记录了。这就是“超选”。

解决办法有两个层面,我在代码里是两层防护一起做的:

防御一:限制性UPDATE。这也是前面反复提过的做法,核心是在更新课程表时带上容量条件:

UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND selected_count < capacity

如果影响行数为0,说明这条UPDATE请求到达数据库时课程已经满了,哪怕Java/Python层通过SELECT读到“未满”也没用。数据库的行锁让UPDATE串行化,这是兜底。

防御二:事务隔离。在SSM里用@Transactional把“查重-插记录-更新人数”包在一个事务里;在Django里用transaction.atomic()做同样的事。配合数据库默认的REPEATABLE READ(MySQL)或READ COMMITTED(PostgreSQL),能保证同一事务内多次查询看到一致的快照。

但要提醒你一个陷阱:只靠@Transactional是不够的。如果两个事务同时开始,都SELECT到“未满”,然后同时UPDATE,InnoDB的行锁会让第二个UPDATE阻塞等待,最终因为selected_count < capacity条件不满足而影响0行。但如果你的UPDATE语句不带容量条件,两个事务都能成功更新行数——这时候就出乱子了。所以关键不是加锁本身,而是把业务校验条件压进UPDATE语句。

5.2 乐观锁 vs 悲观锁:选课系统该选哪种

关于锁的选型,很多教材喜欢抽象地讲乐观锁和悲观锁的区别,但放到选课系统里,结论其实很明确。

悲观锁(SELECT ... FOR UPDATE)会给选中的行加锁,其他事务要等锁释放才能操作。它的优点是数据一致性绝对可靠,缺点是并发能力差。选课场景的特点是高频短事务,锁等待时间短但请求量巨大,悲观锁容易成为性能瓶颈。

乐观锁(版本号或条件更新)不加锁,而是在提交时检查冲突。我们前面的selected_count < capacity本质上就是一种乐观锁方案——不锁课程记录,只是在UPDATE时校验条件。它的优点是吞吐量高,缺点是有冲突时靠重试或直接报错。

我的实际建议是选课写入用条件更新式的乐观锁,插入前查询用普通校验就够了。不要为了显得高级去用SELECT FOR UPDATE锁课程行——那样确实不会超选,但会让并发时的其他事务排队等待,在“抢课”这样的高并发场景下体验很差。这个取舍在答辩时可以讲清楚,能展示你对并发控制有真实思考,而不是背概念。

5.3 友好的冲突提示:容量不足、重复选课、窗口关闭的差异化处理

并发冲突在业务层的表现不能只有“系统异常”一句话。我在设计时就区分了三种不同的拒绝原因,让前端能给出不同提示:

异常类型判断逻辑用户提示
选课窗口已关闭当前时间 < 开始时间 或 > 结束时间“当前不在选课时间范围内”
课程已满员限制性UPDATE影响行数为0“该课程已满员,请选择其他课程”
重复选课同一学生该课程存在未删除记录“您已选择过该课程”

在SSM里,我自定义了BusinessException(继承RuntimeException,带message字段),Service校验不通过就throw new BusinessException("..."),Controller捕获后在Result对象里带上提示信息。在Django里,可以直接开一个全局异常处理后,让不同Exception返回不同的JSON错误码。这比所有失败都返回“失败”两个字体验好很多,答辩演示时也更有说服力。

6. 交付物地图:源码、LW、调试文档、讲解如何配套组织

这个项目标题里明确写了“源码+LW+调试文档+讲解等”。很多同学不知道这些交付物分别是什么、怎么配套使用,拿到手后经常是“源码能跑,但论文不知道怎么改”。我把这一整套东西的实际使用方法理清楚。

6.1 源码的结构与启动方式:两种技术栈各自的运行前提

SSM版本:拿到源码后,第一件事不是启动Tomcat,而是先改jdbc.properties里的数据库连接信息,然后在MySQL里执行项目附带的init.sql脚本建库建表并插入初始演示数据。之后用IDEA打开Maven工程,让它自动下载依赖(首次会比较慢),配好本地的Tomcat(或直接用tomcat7-maven-plugin插件一键启动),把Artifact部署到Tomcat的webapps目录,启动后访问http://localhost:8080/即可。

Django版本:先pip install -r requirements.txt装依赖,然后修改settings.py里的DATABASES配置,执行python manage.py makemigrations和python manage.py migrate建表,再执行python manage.py createsuperuser创建管理员账号。最后python manage.py runserver就能在浏览器打开。如果项目里附带了data.json这类初始数据文件,用loaddata命令导入即可。

提示:两个版本共用一个MySQL实例是可以的,但数据库结构不同(Django建的表名是应用名_模型名这种格式),所以千万别把两个版本的建表脚本同时跑在同一个库上。建议分别建两个数据库:比如course_system_ssm和course_system_django。

6.2 LW(论文/设计文档)该怎么和源码对应起来

“LW”在毕设圈里指的是论文或设计说明书。这个文档通常包含:引言(背景与意义)、相关技术介绍、需求分析、系统设计(架构图+数据库设计)、系统实现(核心代码与截图)、系统测试、总结致谢。

很多同学拿到LW后直接改个名字就交了,结果答辩时老师随便问两句就露馅——因为他根本不了解论文里写了什么。我的建议是花一天时间做这样几件事:

  • 读一遍LW的“需求分析”章节,对照源码里的功能清单,确认每个功能点对应到哪个Controller/View、哪个页面。
  • 读“数据库设计”章节,把ER图里每张表和源码里的Model/Entity类对照起来。能答出“为什么这张表有is_deleted字段”,比背一百遍概念都有用。
  • 写一段“技术难点”的口述稿。论文里写了并发控制、事务管理等内容的,就对着源码里的@Transactional或transaction.atomic()准备一段“我在实现时如何解决超选”的阐述。

6.3 调试文档的价值:别把它当成摆设

调试文档通常记录的是“项目启动过程中可能遇到的问题及解决方法”。这份东西在项目配好环境、正常跑起来之后,确实显得“没用”;但只要你换一台电脑、换一个数据库版本,它立刻变成救命稻草。

我拿到调试文档后的固定流程是:先按文档列出的顺序走一遍环境配置,每遇到一个错误就对照文档排查。如果文档里没写某个错误,就把解决方案补充进去。如果你将来要把这个项目发给同学或学弟学妹,这份文档的价值就体现出来了——它能大幅减少“为什么我这跑不起来”这样的重复问题。

6.4 讲解演示时最容易出彩的三个片段

从引导答辩准备来看,选课系统的演示不需要把每个功能都点一遍,而是要有节奏地突出亮点。我建议重点讲这三个片段:

  • 角色切换:分别以管理员、教师、学生身份登录,演示同一套系统在不同视角下的功能差异。这能展示你对权限模型的理解。
  • 选课状态控制:先展示学生选课成功,再去Admin后台把选课窗口关闭,再演示学生选课被拒。这展示了状态机设计的完整性。
  • 防超选演示:临时把课程容量改成1,开两个浏览器窗口分别登录不同学生账号,几乎同时点选课,看系统如何拒绝其中之一。这比讲一万字并发理论都直观。

7. 我在调试这类系统时踩过的坑:给后来者的一份实操排雷清单

这块算是我个人经验里的“血泪史”了。各种花式报错都碰过,整理一份排雷清单,按技术栈分类,方便你遇到问题直接来对照。

7.1 版本兼容性问题:SSM的版本玄学与Django的版本陷阱

SSM这套里,Spring、MyBatis、JDK、Tomcat这四者之间存在隐性版本依赖。我的经验是:JDK 8 + Spring 5.x + MyBatis 3.5.x + Tomcat 8.5/9是多年验证过的稳定组合。如果你用JDK 11或17,Spring的CGLIB代理反射机制在某些版本里会有兼容问题。如果用了Spring 6,那要求JDK 17+,而且SpringMVC的核心包、拦截器命名都变了,网上旧教程的很多写法直接失效。拿到源码第一件事,先看pom.xml里的spring版本和你的JDK版本是否匹配,别盲启动,否则报错会报得你怀疑人生。

Django的版本坑主要在Django 4.x/5.x上,urls.py的写法已经全面使用path()而非过时的url()。如果你在代码里看到from django.conf.urls import url,这篇代码大概率是Django 2.x时代的,跑在Django 4上会直接报错。同样,Django 3.0以后,中间件配置从MIDDLEWARE_CLASSES改成了MIDDLEWARE,旧代码需要修改后才能跑起来。

7.2 中文乱码与环境编码:大一统的UTF-8方案

做完这个项目,我对“编码问题”总结出一条铁律:全链路UTF-8,一个环节都不能漏。包括四个环节:

  1. 数据库连接URL加?useUnicode=true&characterEncoding=UTF-8;
  2. JSP页面顶部加<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>;
  3. Java源码文件保持UTF-8编码,IDEA里在Settings > File Encodings全设为UTF-8;
  4. Tomcat的server.xml里Connector加URIEncoding="UTF-8"。

Django这边简单一些,因为Django本身默认UTF-8。但有一个坑:MySQL表如果建库时用了latin1之类的旧编码,Django写入中文时也会乱。建库时强制指定utf8mb4:

CREATE DATABASE course_system_django DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

7.3 演示数据的重要性:生产环境没有问题,演示时才翻车

毕设项目里最容易被低估的是初始演示数据的质量。很多源码自带的init.sql里只有两三个测试用户、四五门课程,“跑起来”没问题,但答辩演示时就很尴尬——老师想看“选课名单”,页面上只有两个人;想分页看课程列表,翻两页就到头了。

我建议你花半小时扩充一份完整的演示数据:学生至少20个,教师至少5个,课程至少15门(覆盖不同学分、不同时间、不同容量),选课记录至少30条(包括正常选课、已退选、已录成绩的状态)。这样无论从哪个入口演示,数据都足够撑场面。这个习惯也适用于你以后接任何外包项目——给用户交付的空数据库和充满合理演示数据的数据库,体验是完全不同的。

7.4 数据库连接池超时:Django里一个容易被忽视的定时任务

最后提一个不算热门但实际困扰我的问题:Django + MySQL默认连接超过8小时空闲会被MySQL主动断开,然后程序报“MySQL server has gone away”。如果你只做毕设演示,这个问题无所谓;但如果挂机测试时长超过8小时,或用了定时任务(比如每天固定时间自动关闭选课窗口),就会踩到这个坑。解决办法是在settings.py里设置:

DATABASES = { 'default': { 'ENGINE': 'django.db.backends.mysql', 'CONN_MAX_AGE': 3600, # 1小时,小于MySQL的wait_timeout 'OPTIONS': { 'init_command': "SET sql_mode='STRICT_TRANS_TABLES'", }, } }

或者用一次性connection.close()成本更低的手段:定时任务每次执行前检查连接是否可用,不可用就重连。这不难,但能避免线上演示时突然白屏的尴尬。

我在实际带这个项目过程的体会是:选课系统虽然看起来是个“老掉牙”的毕设题目,但它的业务完整性、技术覆盖面(权限、事务、并发、状态机、交付组织)决定了它非常适合作为学习Web开发的一个综合练手项目。无论SSM还是Django,本质都是“把业务规则落到数据操作上”这一件事。把核心逻辑吃透,再遇到任何管理信息系统,你都能触类旁通。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询