简介:学生网上选课系统的设计与实现资料包,面向高校计算机相关专业学生及Java初学者,解决传统选课信息管理效率低、易出错的问题。系统涵盖教室管理、老师管理、课程管理、教学计划管理、选课管理、成绩管理、学生管理等核心模块,基于Spring Boot框架与MySQL数据库开发,前端采用Vue技术栈。压缩包共402个文件,包含Java源码、Vue页面组件、SQL数据库脚本及设计文档,另有SVG图标、PNG图片等资源,整体大小13.32MB,结构清晰便于按模块检索。已有88人学习下载,适合用于毕业设计、课程设计或项目实训。通过该资料可快速理解学生选课系统的前后端实现思路,掌握Spring Boot与Vue整合开发流程,同时获取可直接运行或二次开发的完整工程及配套数据库脚本与说明文档,有助于完成设计报告和系统演示。
1. 学生网上选课系统是什么?排课冲突、并发抢课与三类用户的管理闭环
一门限选 20 人的《软件工程导论》凌晨 0 点开放选课,30 个学生同时点鼠标,后台如果只做“先查人数再插入”,结果多半是选课记录出现 25 条,管理员对着数据库说不出哪 5 条是超录的。学生网上选课系统要解决的正是这个:把选课从教务 Excel 表搬到网页上,用数据库事务把“校验名额、写入选课记录、扣减名额”三步绑成一个整体,再让管理员和教师分别拿到发布课程、查看名单的入口。这个课题是数据库课程设计和毕业设计里出现频率最高的一类,交付物通常就是“代码 + 数据库脚本 + LW 说明文档”。理解它的核心表结构和事务边界之后,你完全可以把它改造成讲座预约、活动报名、会议室预订这类同样带“名额限制”的在线申请系统。
2. 从数据库设计说起:核心表结构、外键取舍与初始化 SQL
很多同学拿到这个题目第一反应是找后端框架,我反而建议先把表结构定下来。选课系统的业务复杂度不高,但数据关系是典型的多对多:一个学生可以选多门课,一门课可以被多个学生选。如果表结构没设计好,后面写再多接口,也会在“人数超限”“重复选课”“退课后名单对不上”这些地方反复返工。更关键的是,LW 文档里数据库设计占的分值很高,你把 ER 图和数据字典画清楚,整个项目的可信度就立住了一半。
2.1 三种角色的用例拆解:学生选课、教师确认、管理员排课
我先做用例拆分,而不是直接建表。系统里至少有三类人:学生、教师、管理员,他们的核心诉求完全不一样。
学生端的主要动作是浏览可选课程、选课、退课、查看个人已选列表。这里的核心约束是“不能重复选同一门课”和“课程名额不能超”。教师端的诉求是维护自己授课的课程信息、查看选了这门课的学生名单,偶尔还要手动调一下容量。管理员端的诉求是维护用户账号、审核教师发布的课程、设定选课时间段。很多做课程设计的同学把管理员和学生权限混在一起,导致后面前端按钮一会儿显示一会儿隐藏,逻辑越写越乱。正确做法是从数据库层面就把角色字段固定下来,前面提的“用户表加 role 字段”就是这个目的,它也是后端拦截器和前端菜单渲染的统一依据。
在画 ER 图的时候,用三个实体就够了:用户、课程、选课记录。选课记录就是学生和课程之间那个多对多关系的“中间表”。需要注意的是,选课记录不是简单的关联表,它还要承载选课时间、选课状态这类业务字段,所以它本身就是一张业务表,不是纯粹为了拆多对多硬凑出来的。
2.2 核心表设计:用户表、课程表、选课记录表的字段清单
我按最常跑的 MySQL 版本给你一套能直接建表、能直接做增删改查的 SQL。用户表里把学生、教师、管理员统一成一张表,用 role 区分,这样登录校验只需要写一次。课程表里用一个 teacher_id 指向用户表,再用一个 selected_count 字段记录已选人数,这个字段看起来冗余,但它就是后面防超卖的关键。
CREATE DATABASE IF NOT EXISTS course_select DEFAULT CHARACTER SET utf8mb4; USE course_select; -- 用户表:学生、教师、管理员统一存储 CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password VARCHAR(128) NOT NULL, role TINYINT NOT NULL COMMENT '1学生,2教师,3管理员', real_name VARCHAR(32) NOT NULL, major VARCHAR(64) DEFAULT NULL COMMENT '学生专业,教师可为空', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表'; -- 课程表 CREATE TABLE course ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, course_name VARCHAR(64) NOT NULL, teacher_id INT UNSIGNED NOT NULL, credit DECIMAL(2,1) NOT NULL DEFAULT 2.0, capacity INT NOT NULL COMMENT '总容量', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', class_time VARCHAR(32) DEFAULT NULL COMMENT '上课时间,用于冲突检测', status TINYINT NOT NULL DEFAULT 1 COMMENT '1可选,0关闭', PRIMARY KEY (id), KEY idx_teacher (teacher_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; -- 选课记录表 CREATE TABLE course_selection ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, student_id INT UNSIGNED NOT NULL, course_id INT UNSIGNED NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 1 COMMENT '1有效,0已退', PRIMARY KEY (id), UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course (course_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='选课记录表';这条建表语句里有三个细节你要看清楚。第一,user 表的主键是 id,但 username 上加了唯一索引,这保证账号不允许重复。第二,course_selection 表除了自增主键 id,还加了一个联合唯一索引 uk_student_course,它的含义是“同一个学生不能对同一门课产生两条有效记录”,这是防重复选课的数据库兜底。第三,course 表里 capacity 和 selected_count 都是 INT,后续更新已选人数时用selected_count = selected_count + 1这种原子操作,而不是先查出来再写回去。
2.3 选课表用复合主键还是自增主键:防重复与分页的平衡
这里很容易被老师追问,也是 LW 答辩时的高频问题:选课记录表用(student_id, course_id)做复合主键不行吗,为什么还要加一个自增 id?
复合主键在理论上完全成立,它天然保证了同一个学生选同一门课只存在一条记录,逻辑上更简洁。但在实际开发里,自增主键加唯一索引的做法更保险,原因有三点。第一,选课记录表还会有退课、统计、分页等操作,Java 侧用 MyBatis 之类的 ORM 操作单条记录时,习惯上按主键 id 定位,复合主键要写两列条件,容易漏条件把别的学生记录误删。第二,如果后续需要把“一条选课记录”对应“一条成绩记录”,自增 id 可以直接作为外键传给成绩表,复合主键做不到这一点。第三,联合唯一索引已经把重复选课的问题解决了,数据库层面并没有因此失去约束力。
所以我的建议是:保留自增主键,同时保留(student_id, course_id)唯一索引。这样既拿到了复合主键的防重复能力,又保留了单主键在代码和分页上的便利。这其实就是设计模式里“单一职责”的一种体现,主键只管定位行,唯一约束管业务规则。
2.4 初始化 SQL 脚本:课程数据、账号数据与“跑通全流程”的最小集
表建完必须有一批能直接登录的数据,不然项目给出去别人跑不起来。我一般会准备一套“最小可跑通集合”:一个学生、一个老师、一个管理员、两门课,其中一门课容量故意设成 1,用来测超员场景。密码在演示项目里直接写 MD5 值,方便在文档里说明,实际项目必须换加盐哈希。
INSERT INTO user (username, password, role, real_name, major) VALUES ('student01', MD5('123456'), 1, '张同学', '软件工程'), ('teacher01', MD5('123456'), 2, '李老师', '软件工程'), ('admin', MD5('admin123'), 3, '教务管理员', NULL); INSERT INTO course (course_name, teacher_id, credit, capacity, selected_count, class_time, status) VALUES ('软件工程导论', 2, 2.0, 20, 0, '周一 3-4节', 1), ('数据库原理', 2, 3.0, 30, 0, '周三 1-2节', 1); -- 往选课记录表里插一条数据,方便测试“已选课学生”的重复选课拦截 INSERT INTO course_selection (student_id, course_id, status) VALUES (1, 1, 1);这段初始化脚本里,student01 已经选了软件工程导论,所以你在写完选课接口后,拿这个账号再去选同一门课,就能立刻验证唯一索引是否生效。数据库导出给别人的时候,要把建表语句、初始化数据、以及后面要讲的存储过程或定时任务脚本分文件存放,LW 文档里按“数据字典”把它们逐字段解释一遍,这个项目的完整度立刻上一个档次。
3. 后端代码实现:选课事务、行锁与名额释放
数据库表结构定好之后,后端最核心的工作就是写选课接口。这个接口看起来只是 INSERT 一条记录,但它在并发下踩的坑最多。我在这一章会直接把选课、退课、定时释放名额三个接口写给你看,重点讲清楚事务边界和行锁怎么用。很多课程设计的项目能单机跑通,一部署到有人同时访问就挂,问题都出在这个接口上。
3.1 技术选型:Spring Boot + MyBatis 与 JSP/Servlet 的取舍
技术栈选择直接影响你做这个项目的速度和答辩时的说服力。我的判断是:如果时间紧、目标是“能跑通、好答辩”,直接选 Spring Boot + MyBatis + MySQL,前端用原生 HTML 加一点 Ajax 就够了,不要为了炫技引入分布式锁、消息队列那套东西。毕业设计的核心是讲清楚“业务逻辑怎么落进数据库”,框架只是工具。
如果是纯数据库课程设计,有时候老师要求必须用 JSP + Servlet 写,那也没问题,底层事务逻辑是一样的,只是把 HTTP 处理从注解换成 Servlet。Spring Boot 的好处是集成默认配置省事,内置的 HikariCP 连接池不用额外装。连接池这个参数在后端选课系统中极其关键:默认连接池大小只有 10,几十个学生同时抢课,数据库连接瞬间不够用,接口报错率直线上升。我会在后面的压测章节专门讲怎么调它。
MyBatis 比 JPA 在这个场景更适合,因为你要写SELECT ... FOR UPDATE这种更可控的 SQL,MyBatis 的 XML 能直接写原生语句,调试起来一目了然。项目结构上,控制层只做参数校验,业务逻辑放进 Service 层,事务写在 Service 方法上。
3.2 选课接口:查人数、插记录、扣名额三步包进同一个事务
选课接口最忌讳的写法是这样的:先 SELECT 查一下selected_count,判断小于 capacity,再 INSERT 选课记录,再 UPDATE course 表把人数加一。这中间任何一个环节并发进来都会出问题。正确做法是先把课程行锁住再操作,我一般会这样写:
@Service public class CourseSelectionService { @Resource private CourseMapper courseMapper; @Resource private CourseSelectionMapper selectionMapper; @Transactional(rollbackFor = Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 锁住课程行,并发请求在这里排队 Course course = courseMapper.selectByIdForUpdate(courseId); if (course == null) { throw new BizException("课程不存在"); } if (course.getStatus() == null || course.getStatus() != 1) { throw new BizException("课程不在可选状态"); } // 2. 在锁保护下做名额判断 if (course.getSelectedCount() >= course.getCapacity()) { throw new BizException("该课程人数已满"); } // 3. 插入选课记录,唯一索引兜底 try { selectionMapper.insertSelection(studentId, courseId); } catch (DuplicateKeyException e) { throw new BizException("你已经选过这门课了"); } // 4. 已选人数加一 courseMapper.increaseSelectedCount(courseId); } }对应的 MyBatis 映射里关键是那条加锁查询:
<select id="selectByIdForUpdate" resultType="course_select.entity.Course"> select id, course_name, capacity, selected_count, status from course where id = #{courseId} for update </select>这段代码能防超卖的核心在于SELECT ... FOR UPDATE。InnoDB 在事务里执行这语句后,会对 course 表里这条 id 对应的行加排他锁,第二个请求进来做同样查询时会一直等待,直到第一个事务提交或回滚。也就是说,同一时刻只有一个请求能通过名额校验,后续请求拿到的都是更新后的 selected_count。配合事务注解,一旦第 3 步插入失败,第 4 步的更新也会回滚,课程人数不会出现“记录没插进去,名额却少了一个”的脏状态。
有个细节要注意:事务必须从调用进入 Service 方法时就开始,所以你不能在 Controller 里先调一个查询再调这个 Service 方法,那样锁的范围就不对了。自研的BizException是运行时异常,才能触发 rollbackFor。还有一种简化写法是直接用UPDATE course SET selected_count = selected_count + 1 WHERE id = ? AND capacity > selected_count,受影响行数为 0 就报满员,这个思路也能用,但我还是推荐 FOR UPDATE,因为它的语义对 LW 文档更友好,答辩时更容易解释。
3.3 退课接口的幂等处理与定时释放名额
退课比选课简单,但一样有坑。退课的直观逻辑是 DELETE 选课记录,再把课程的已选人数减一。这里要注意的是“幂等”和“删改一致”:如果学生从来没选过这门课,后端不能直接返回成功,否则人数会被错误减成负数。
@Transactional(rollbackFor = Exception.class) public void dropCourse(Long studentId, Long courseId) { // 只删除状态为有效的记录 int deleted = selectionMapper.deleteActiveSelection(studentId, courseId); if (deleted == 0) { throw new BizException("当前没有选课记录,无法退课"); } // 删除成功后再把人数减回去 courseMapper.decreaseSelectedCount(courseId); }这里有一个容易被忽略的点:deleteActiveSelection的 SQL 条件必须是student_id = ? AND course_id = ? AND status = 1,不能只按课程 ID 删,否则一个学生多条历史记录会被一起删掉。我建议给 course_selection 表加上status字段而不是物理删除,这样你就可以保留学生大学四年完整的历史选课记录,教师端查名单时也只要过滤status = 1就行了。
定时释放名额这个功能不是所有选课系统都需要,但很多题目会写“课程名额按时释放”或“预选超时失效”。常见做法是在选课记录表加一个created_at字段,用 Spring 的定时任务扫描“创建时间超过 30 分钟且状态还是预选”的记录,把它们批量标记为失效,同时回退课程人数。定时任务上要加@EnableScheduling才能生效:
@Component public class SelectionTimeoutTask { @Resource private CourseSelectionMapper selectionMapper; @Resource private CourseMapper courseMapper; // 每 5 分钟执行一次 @Scheduled(cron = "0 0/5 * * * ?") public void releaseExpiredSelection() { List<Long> expiredIds = selectionMapper.listExpiredSelectionIds(30); if (expiredIds.isEmpty()) { return; } int updated = selectionMapper.batchUpdateStatus(expiredIds, 0); if (updated > 0) { courseMapper.batchDecreaseSelectedCount(expiredIds); } } }这段逻辑注意两点。第一,批量失效和批量扣减要放在同一个事务里,或者至少保证扣减次数不超过失效记录数。第二,定时任务和用户手动选课可能同时操作同一门课程,所以批量扣减的 SQL 里也要带条件WHERE selected_count > 0,防止负数。你可以把这两层保护写进 LW 的详细设计里,老师会觉得你对边界考虑得很全。
3.4 容易踩的锁顺序问题:两个选课请求同时进来的死锁隐患
FOR UPDATE 用对了并发安全就有六成保障,但要小心死锁。死锁的典型场景是:事务 A 选了课程 1,接着又要选课程 2;事务 B 选了课程 2,接着又要选课程 1。两个事务分别锁住了对方需要的课程行,互相等待,InnoDB 检测到死锁后会随机回滚一个事务,接口层收到死锁异常,用户看到“操作失败”。
解决方式很简单:在一个事务里如果操作多门课,必须规定一个统一的加锁顺序。我习惯在 Service 入口对课程 ID 列表做排序,按升序逐个加锁,这样所有事务都以同样的顺序获取锁,就不会出现循环等待。如果把“批量选课”做成一个后端接口,这条路很好走:先排序,再循环调用选课逻辑。如果你只想一次选一门课,死锁概率极低,但教师端批量调课时要注意。
另外要明白 FOR UPDATE 锁的是索引记录,如果 WHERE 条件没走索引,InnoDB 会把锁升级成表锁,整个课程表的写操作全部串行。所以主键查询没问题,但如果你用course_name去锁,一定要确保那个字段有索引,否则不能用它做并发控制的查询条件。
4. 前端页面与交互:从登录到个人课表的完整链路
后端逻辑再完善,前端页面烂一样拉低整个项目印象分。这个选课系统的前端不用做得花哨,但页面之间的跳转关系必须完整:登录页、可选课程列表页、个人课表页、教师端选课名单页。我见过很多课程设计的源码,后端接口写了一大堆,前端只有一个默认主页,点开连“选课”按钮都没实现,这种项目在答辩时基本是送分给老师挑刺。这一章我把最少能跑通的前端骨架和交互代码写出来,并告诉你配套 LW 里页面流程要怎么组织。
4.1 页面结构:登录、选课列表、个人课表三个页面的最小实现
如果是 Spring Boot 项目,前端文件放在src/main/resources/static和templates目录。一个标准的最小结构是三个 HTML 页面加一个公共样式文件:login.html、course_list.html、my_courses.html。教师端视图可以复用course_list.html,只是按钮区域换成“查看名单”,管理员视图则可以通过隐藏字段控制。
登录页是最容易被人忽略的页面,但很多课程设计都挂在登录校验上。我建议后端登录接口在成功之后把userId和role写进 Session,同时设置 Session 超时时间。前端登录跳转用普通表单提交就可以,不需要加 Ajax,简化一下代码量。
选课列表页的核心是展示课程 ID、名称、老师、已选人数、容量和操作按钮。我用原生 HTML 加 Thymeleaf 写一个最常用的循环,关键信息都能在页面上看到,后端把数据放进 Model 渲染即可。
<table> <tr> <th>课程名称</th><th>教师</th><th>已选/容量</th><th>操作</th> </tr> <tr th:each="c : ${courseList}"> <td th:text="${c.courseName}"></td> <td th:text="${c.teacherName}"></td> <td th:text="${c.selectedCount} + ' / ' + ${c.capacity}"></td> <td> <button th:attr="data-course-id=${c.id}" th:onclick="'selectCourse(this)'" th:disabled="${c.selectedCount >= c.capacity}">选课</button> </td> </tr> </table>这里th:disabled让已经满员的课程按钮在页面加载时就置灰,用户根本点不了。这是在 UI 层先做一层拦截,但它防不了并发,真正校验依然以第 3 章的后端接口为准。页面结构里还要有一个返回个人课表的入口,方便学生选完课立即看到结果。
4.2 用 Ajax 调选课接口:按钮置灰、错误提示与列表刷新
选课操作不能整页刷新,否则用户选完还要重新翻列表,体验很差。我一般把这些交互做成异步:点击选课按钮,前端对/api/select发送 POST 请求,后端返回 JSON。前端拿到结果后根据 code 字段弹提示,或者直接刷新当前页面。
function selectCourse(btn) { const courseId = btn.getAttribute('data-course-id'); // 防止用户连续点击导致同一请求提交多次 btn.disabled = true; fetch('/api/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ courseId: courseId }) }) .then(res => res.json()) .then(data => { if (data.code === 0) { alert('选课成功'); location.reload(); } else { alert(data.msg); // 选课失败要恢复按钮 btn.disabled = false; } }) .catch(err => { // 网络异常也要恢复按钮 btn.disabled = false; alert('网络异常,请重试'); }); }这段代码有两个细节值得写进项目里。第一,按钮一旦点击立即置灰,这是看得见的防重复提交,和数据库唯一索引形成前后端双保险;面试官或老师问起来,你可以很明确地说出这两层各自的作用。第二,失败时要把按钮恢复,否则用户没选上却再也点不了,只能刷新页面,体验很差。
还有一个容易被忽略的问题:fetch默认不带 Cookie,如果你的选课系统依赖 Session 做登录校验,需要在 fetch 里加credentials: 'same-origin',否则后端取不到登录用户,接口直接 401。很多同学做跨域前后端分离项目时在这里卡很久,其实解决方案也就是这么一行参数。
4.3 教师端与管理端:名单展示和课程发布的不同权限
教师端页面可以复用同一套后端模板,只是接口返回的数据范围变了。教师登录后只能看到自己担任教师的课程,这个过滤条件在 SQL 里通过teacher_id完成。每个课程列表项旁边放一个“学生名单”按钮,点击后弹出一个表格,列出选了这门课的学生姓名和学号。
管理员端需要的东西反而更少:一条用户列表、一个课程发布表单。课程发布表单的核心字段就是第 2 章 course 表的那几个字段:课程名称、教师、学分、容量、上课时间。发布动作本质上就是 INSERT 一条 course 记录,不需要复杂设计。管理员端还要有一个 user 管理入口,一般就是普通增删改查,把用户表的 account 和 role 展示出来。
前端页面如果只有这几张表,整个系统已经能串成一条完整业务线:管理员建课、教师查看名单、学生选课。如果还有富余时间,你可以再做一个“学生个人课表”,按周几和时间段把已选课程排列成表格,这个功能在 LW 里能作为亮点展示,但技术上不需要引入额外框架。
4.4 配套 LW 怎么写:页面流程描述与系统架构图的组织方式
这里说的 LW 对应的是交付清单里的说明文档,很多学校叫“课程设计报告”或“毕业设计论文”。代码写完之后,文档别直接照抄模板,要按你自己的系统结构来写。我习惯在文档里安排这么几个大块:引言、需求分析、系统设计、数据库设计、系统实现与测试。其中系统实现部分,每写一个功能,就配一张页面截图和一段核心代码,代码不需要贴全部,贴最关键的那几行并解释它解决什么问题。
页面流程部分我建议画一张简单的调用链图:用户在浏览器点击选课按钮,浏览器发 Ajax 请求到 Controller,Controller 调 Service,Service 调 Mapper,Mapper 操作数据库,数据结果再逐层返回。这张图不需要专门的画图工具,用编辑器的文本画出方框和箭头就够了,重点是让老师看到你理解请求是怎么串起来的。
数据库设计部分的组织方式也很固定:放一张 ER 图,然后给每个表做一个字段表格,字段名、类型、约束、说明四列对齐。写到这里时,把第 2 章那张建表 SQL 里的注释直接搬过去,就能保证数据库字段和文档描述完全一致,不会出现文档写“选课记录表没有唯一索引”,代码里却建了唯一索引这种低级失误。
5. 网上选课系统避坑指南:并发超选、中文乱码与部署失败的排查
这个系统做完之后,真正让你改到怀疑人生的往往不是功能没实现,而是这些隐蔽问题:并发超选、中文乱码、请求 403、页面加载巨慢、部署后白屏。这一章我按“现象、原因、解决”给你写清楚,每一条都是做课设的人血泪经验换来的。
5.1 同一课程被选超员:并发窗口让“先查再插”失效
现象:一门容量 20 的课,选课结束后 course_selection 表里存在 21 条有效记录,course 表里 selected_count 已经到 20,但多出来的 1 个人还是选上了,后台数据对不上。
原因:选课逻辑没有锁课程行。两个请求同时执行SELECT selected_count FROM course WHERE id = 1,都读到 19,都判断小于 20,然后都 INSERT,最终 selected_count 只更新到 20,但记录有两条漏网之鱼。
解决:用SELECT ... FOR UPDATE锁住课程行,或把“人数校验 + 扣减”合并成一条原子 UPDATE,比如UPDATE course SET selected_count = selected_count + 1 WHERE id = 1 AND selected_count < capacity,受影响行数为 0 时返回“人数已满”。如果你用的是 MyBatis,直接在第 3 章的 selectByIdForUpdate 上做代码改造即可,插入前一定要保证锁已经拿到。
5.2 中文课程名入库变成乱码,Navicat 里看字段全是问号
现象:课程管理页填“操作系统”,保存后页面回显变成“?????????”,数据库表里看到的中文全是问号,但数字和字母正常。
原因:数据库连接的 JDBC URL 少了字符集参数,或数据库表本身是 latin1 字符集。很多同学在 Navicat 里建数据库时直接点“确定”,默认字符集可能不是 utf8mb4,而项目里的连接串又是老旧的写法,两端字符集不一致就乱码。
解决:创建数据库时显式指定CHARACTER SET utf8mb4,JDBC 连接串里加useUnicode=true&characterEncoding=utf8mb4。如果你已经建了表,用ALTER TABLE course CONVERT TO CHARACTER SET utf8mb4转换一次,再清掉乱码数据重新导入。这一步做完记得重启项目,否则连接池里的旧连接还是老字符集。
5.3 Ajax 请求返回 403,登录状态失效与拦截器配置
现象:学生用浏览器登录系统后,选课列表页能打开,但点击“选课”按钮,控制台打印 403,刷新页面后又被强行跳回登录页。
原因:项目中配置了登录拦截器或 Spring Security,POST 请求被拦截器拦下。Spring Security 默认会开启 CSRF 防护,而你的前端从页面加载到 Ajax POST 没带 CSRF Token,于是 403。如果登录会话超时,这个现象更隐蔽,后端拦截器已失效,但前端还停留在旧页面。
解决:开发环境可以直接关闭 CSRF,但如果项目要求保留安全机制,前端必须从后端获取 CSRF Token 并在请求头里带上。同时把拦截器的白名单配置好,CSS、JS、登录接口、选课接口这些路径要能通过,否则页面样式和异步请求都会被拦截。如果是前后端分离,给接口加一个简单的 Token 头,把用户 ID 一起传过去,就不用依赖 Cookie Session 了。
5.4 选课列表加载慢,N+1 查询把数据库拖到连接池耗尽
现象:单机测试没问题,几十个人同时打开选课页面,页面转圈好几秒,数据库 CPU 打满,日志里大量数据库连接超时异常。
原因:列表页循环里执行了查询,比如每查一门课程,又查一次教师姓名,或者每查一条选课记录又查一次学生姓名。前者是 N+1 查询,后者是连接池被循环 SQL 占满。此时即使 HikariCP 连接池调到 50,也只会拖垮数据库。
解决:用一条 JOIN 语句把课程和教师信息一次查出来,例如SELECT c.*, u.real_name AS teacher_name FROM course c LEFT JOIN user u ON c.teacher_id = u.id。在 Java 层不要再循环查 Mapper。另外给 course_selection 表的外键字段加好索引,把慢查询日志打开,看哪条 SQL 消耗时间最长。这属于优化里性价比最高的改动,改完列表页耗时通常会降到 100ms 以内。
5.5 本地跑通、部署到服务器后白屏,端口与上下文路径的坑
现象:本地 Intellij 运行一切正常,打成 jar 包丢到服务器上,访问域名或 IP 显示 404,页面白屏,后端日志没报错。
原因:常见有三种原因。第一,项目设置了server.servlet.context-path=/select,你直接访问/自然 404;第二,你把 MySQL 地址写成了localhost,那台服务器上并没有数据库;第三,服务器端口被占用或防火墙没放行,你访问的 8080 根本没连上应用。
解决:先在服务器上执行nohup java -jar course-select.jar > log.out 2>&1 &,用tail -f log.out看启动日志,确认端口和数据库连接都正常。如果启用了 context-path,访问 URL 必须加上前缀。数据库连接串要用服务器能访问到的地址,不要用 localhost,Windows 本地连接和 Linux 服务器连接要分开配置。最后确认netstat -tlnp | grep 8080能看到进程监听,再排查 Nginx 或防火墙。
6. 并发压测和效果验证:给选课系统做一次上线前体检
代码全部写完、页面能跑通,这只是完成了 80%,最后一步是验证并发的正确性。我见过太多项目单机演示很流畅,老师现场让学生同时点两个按钮就露馅了。所以发布前我会用并发工具模拟“多人同时选同一门课”的场景,用数据证明系统没有超卖。
我用压测工具的方式是:准备一门容量为 10 的课程,然后用 Apache 自带的 ab 命令,模拟 50 个并发请求同时选这门课。由于系统有登录校验,先手动拿一个有效的 SessionId,写进 ab 的请求头里,让 50 个并发请求共用这个登录态。这样做可以过滤掉登录接口的性能干扰,把测试目的锁定在选课并发上。
ab -n 200 -c 50 -H "Cookie: JSESSIONID=xxxxx" \ -p select.json -T "application/json" \ http://localhost:8080/api/selectselect.json 的内容只有{"courseId":1}。测试结束后看两个关键数字:Failed requests 和 Time per request。如果 Failed requests 是 0,再进数据库核对两边的数据:
SELECT COUNT(*) FROM course_selection WHERE course_id = 1 AND status = 1; SELECT capacity, selected_count FROM course WHERE id = 1;这里的 count 必须等于 capacity 中的值,selected_count 也必须等于 10,才能说明并发控制真正生效。我通常还会把压测结果截图放进 LW 文档的测试章节,作为“系统具备并发选课能力”的直接证据。
如果压测结果发现 Failed requests 不低,优先看项目日志里有没有死锁异常或连接池超时异常。死锁异常说明锁顺序有问题,连接池超时则说明连接池上限太小。HikariCP 的默认配置在低并发场景很够用,但选课场景可以把 maximumPoolSize 调到 20 到 30,同时把 connection-timeout 设成 3000ms,避免用户长时间卡住。
这个项目做到最后,我自己的体会是:数据库设计和事务边界决定了一个选课系统能不能经得起同时点击,前端页面决定的只是好不好看。每次发布新功能前,我都会先跑一轮并发脚本验证数据一致性,再把这个测试记录写进文档。做完这一步,整个“代码 + 数据库 + LW”交付物才算真正闭环。希望帮到你。
本文还有配套的精品资源,点击获取