十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSM+JSP+MySQL选课系统开发:从表设计到并发控制完整实践

SSM+JSP+MySQL选课系统开发:从表设计到并发控制完整实践 简介基于SSMSpringMVCMyBatis与JSP、MySQL技术栈构建的学生信息管理系统源码主要面向Java Web初学者及有毕业设计或课程设计需求的学习者。项目围绕选课业务展开覆盖用户登录、信息维护、课程选择、分页查询等典型功能能够帮助快速理解SSM三层架构下的前后端交互方式也为后续扩展二次开发提供了清晰骨架。压缩包内共266个文件整体大小约2.64MB其中包含54个Java源码文件、37个JSP页面、13个XML配置以及JS、CSS、SVG等前端静态资源和2个SQL脚本既能查看后端业务逻辑也能直接初始化数据库并快速运行。前端借助Bootstrap与particles.js完成页面与动效增删改环节通过AJAX校验主键是否可用并已配置登录拦截数据层使用PageHelper分页代码分层清晰输入约束较完整。资源目前已有806人浏览学习适合用其梳理SSM整合流程、借鉴项目结构也可直接作为课程设计或选题参考。1. 学生选课业务在 SSMjspmysql 组合里最值得较真的环节选课这个功能在 SSM 项目里代码量通常不到三分之一但出故障的概率占了绝大多数。标配是 student、course、course_selection 三张表做关联页面层用 JSP 回显课程列表和选课状态背后用 SpringMVC 接收请求、MyBatis 读写 MySQL。真正需要较真的不是框架怎么搭而是三件事容量不能超卖、同一学生不能重复提交、事务失败时已选人数和选课记录不能不一致。如果把这三件事只在 Service 里写几行 if 判断就完事并发一上来立刻破防。这篇按实际落地的顺序展开先把表结构和索引定死再把 SSM 的配置链路讲清楚然后写选课这一段业务逻辑最后用页面回显和并发验证收尾。对新手照着能跑通对老手来说重点看事务边界、锁行为和唯一键兜底这几个位置。2. 选课系统数据库建模student、course、course_selection 三张表与约束2.1 student 与 course 表结构capacity 和 selected_count 的冗余设计学生表和课程表的字段设计第一个要定住的原则是业务关联不依赖自增主键而是用编号字段。很多选课系统在联调时出现替换数据后关联错乱原因就是代码里到处用 id 做业务判断。主键 id 只负责物理唯一性业务上统一走 student_no 和 course_no这样后面做数据导入、对接教务接口时重复数据能在业务层直接识别出来。课程表里有一个容易忽略的设计点capacity 和 selected_count 都要作为独立字段存放在 course 表而不是在查询时用 COUNT(*) 实时算已选人数。CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL COMMENT 学号业务唯一, name VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT 加密后存储, major VARCHAR(50) COMMENT 专业, grade VARCHAR(4) COMMENT 年级, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_no (student_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生信息表; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL COMMENT 课程编号业务唯一, course_name VARCHAR(100) NOT NULL, teacher VARCHAR(50), credits DECIMAL(3,1), capacity INT NOT NULL DEFAULT 0 COMMENT 课程容量, selected_count INT NOT NULL DEFAULT 0 COMMENT 当前已选人数冗余字段, schedule VARCHAR(100) COMMENT 上课时间地点, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程信息表;selected_count是典型的冗余字段。好处是选课页面列表页只需要查 course 一张表就能展示“已选/容量”不用对每个课程做子查询 COUNT坏处是冗余字段需要靠事务和锁来保证一致性这就是第四章要解决的事。这里要注意字符集选 utf8mb4 而不是 utf8MySQL 的 utf8 实际最多存 3 字节遇到生僻字或表情符号会报错。密码字段用 64 位长度是为了给 BCrypt 或加盐哈希留空间不要图省事直接明文存 password。2.2 course_selection 选课记录表唯一键和退选方案的选择选课记录表是这节课的核心表。它表达的是学生和课程的多对多关系同时记录了选课时间。建表时最重要的一件事是把 UNIQUE(student_no, course_no) 加上这是防重复选课的最后一道物理防线和业务层判断互不替代。CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL, course_no VARCHAR(20) NOT NULL, select_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 0 COMMENT 0有效 1退选, UNIQUE KEY uk_student_course (student_no, course_no), KEY idx_course_no (course_no), KEY idx_select_time (select_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录表;这里有一个很多人踩过的坑加了 status 字段做退选标记却保留唯一键 (student_no, course_no)会导致退选后再次选同一门课时插入冲突。我在实际项目里通常建议退选直接物理删除记录再把 course.selected_count 减 1。这种做法最直观事务回滚也最干净历史选课记录如果需要保留可以另建日志表不要在选课主表上做软删除。如果产品确实要求保留完整选课历史就不能再用物理删除而是要把唯一键调整成 (student_no, course_no, status) 或者去掉唯一键、在插入前用当前有效记录做唯一性查询。调整后并发兜底能力变弱所以更推荐主表物理删除加审计日志的方案。2.3 高频查询的索引设置与结果映射注意点选课模块高频查询主要是三类学生查询自己的选课列表、按课程编号查选课学生、课程列表分页。前两类都走 course_selection 表查询条件分别是 student_no 和 course_no所以这两个字段上的索引是必须的。查询场景WHERE 条件推荐索引当前学生的选课列表student_no select_timeuk_student_course 已覆盖再按 select_time 排序查某门课的选课学生course_noidx_course_no课程列表分页course_name 模糊匹配course_name 前缀索引数据量小可不加把索引加在 course_selection 上时还要注意一个顺序问题唯一键 uk_student_course 本身是 (student_no, course_no) 的联合索引所以按 student_no 单独查选课列表时这个索引可以直接用不需要额外加 idx_student_no。反过来按 course_no 查时联合索引就用不上必须单独建 idx_course_no。这一条可以直接用 EXPLAIN 验证看到 type 为 ref 且key_len符合预期就对了。3. SSM 框架整合与 MyBatis Mapper 配置从 web.xml 到动态 SQL3.1 web.xml 与 SpringMVC 容器编码过滤器要配在 DispatcherServlet 前面SSM 项目里最容易最先出错的就是中文乱码。MySQL 端已经用了 utf8mb4Tomcat 端的请求和响应编码如果没统一JSP 页面提交选课请求时中文姓名和课程名过了三层就变成问号。常见的做法是在 web.xml 里把 CharacterEncodingFilter 放在 DispatcherServlet 之前并设置forceEncodingtrue让请求和响应的编码都走 UTF-8。filter filter-nameencodingFilter/filter-name filter-classorg.springframework.web.filter.CharacterEncodingFilter/filter-class init-param param-nameencoding/param-name param-valueUTF-8/param-value /init-param init-param param-nameforceEncoding/param-name param-valuetrue/param-value /init-param /filter filter-mapping filter-nameencodingFilter/filter-name url-pattern/*/url-pattern /filter-mapping servlet servlet-namedispatcher/servlet-name servlet-classorg.springframework.web.servlet.DispatcherServlet/servlet-class init-param param-namecontextConfigLocation/param-name param-valueclasspath:spring-mvc.xml/param-value /init-param load-on-startup1/load-on-startup /servlet servlet-mapping servlet-namedispatcher/servlet-name url-pattern//url-pattern /servlet-mappingDispatcherServlet 的url-pattern这里一定要用/而不是/*。用/时SpringMVC 只接管 Controller 映射的地址JSP、CSS、JS 这些静态资源由容器默认处理用/*时连 JSP 的渲染请求都会被 DispatcherServlet 拦截页面会变成空白或出现类型不匹配的报错。这类问题在日志里看到的往往是“无法解析视图”或 404排查方向却根本不是 Mapper 或 SQL而是映射配置。3.2 applicationContext.xml 里把 DataSource、SqlSessionFactory、Mapper 扫描串起来SSM 的容器划分是隐蔽但关键的一步applicationContext.xml 配置数据源、事务、Service、Mapperspring-mvc.xml 只配置 Controller 和视图解析器。如果两个配置文件都扫描了同一个包Controller 会被创建两份事务切面会失效选课时插入记录成功但回滚不执行这才是最危险的。bean iddataSource classcom.alibaba.druid.pool.DruidDataSource property namedriverClassName valuecom.mysql.cj.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/student_course?useUnicodetrueamp;characterEncodingutf8mb4amp;useSSLfalseamp;serverTimezoneAsia/Shanghai/ property nameusername valueroot/ property namepassword valueroot/ property nameinitialSize value5/ property namemaxActive value20/ property namemaxWait value60000/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mappers/*.xml/ property nameconfiguration bean classorg.apache.ibatis.session.Configuration property namemapUnderscoreToCamelCase valuetrue/ /bean /property /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.example.mapper/ /bean tx:annotation-driven transaction-managertransactionManager/ bean idtransactionManager classorg.springframework.jdbc.datasource.DataSourceTransactionManager property namedataSource refdataSource/ /bean连接串里的serverTimezoneAsia/Shanghai是 MySQL 8.x 驱动必带的参数不加会出现时区差 8 小时或启动报错。mapperLocations告诉 MyBatis 去哪里找 XML 文件basePackage则是扫描 Mapper 接口两者职责不同但经常被记混。这里把mapUnderscoreToCamelCase设为 true 后查询结果里selected_count能自动映射到实体的selectedCount字段如果没有这一行你会发现容量一直显示 0但数据库里明明有值。这个坑在选课系统里出现频率极高因为它不报错只静默返回 null。3.3 课程列表查询的动态 SQL 与 resultType 的选择课程列表页要支持按课程名模糊查询、按教师过滤还要在选课高峰期稳定分页。这里用动态 SQL 比在 Java 里拼接字符串要可靠得多参数始终走#{}不会出现注入问题。select idselectCourseList parameterTypemap resultTypecom.example.entity.Course SELECT course_no, course_name, teacher, credits, capacity, selected_count FROM course where if testcourseName ! null and courseName ! AND course_name LIKE CONCAT(%, #{courseName}, %) /if if testteacher ! null and teacher ! AND teacher #{teacher} /if /where ORDER BY course_no LIMIT #{offset}, #{pageSize} /selectwhere标签会自动去掉第一个条件前面的 AND比where 11的写法更规范也省去手写条件拼接时容易出现的多余 AND 错误。CONCAT(%, #{courseName}, %)是标准做法MyBatis 里不要写成%${courseName}%$是字符串拼接不经过预编译等于把输入直接放进 SQL注入风险很高。LIMIT #{offset}, #{pageSize}的 offset 需要在 Controller 或 Service 里通过(pageNum - 1) * pageSize计算也可以借助 PageHelper 帮我们算下一章在 Controller 部分再展开。关于 resultType 和 resultMap 的选择我一般只在字段别名、多表 join 返回自定义结构时才用 resultMap单表查询且开启了驼峰映射时resultType 更简洁。注意实体类的credits字段和数据库DECIMAL(3,1)对应时Java 类型用BigDecimal如果用Double在金额和学分展示中会出现精度问题选课系统里虽不严重但统一的习惯更省事。4. 选课核心业务事务边界、FOR UPDATE 锁与唯一键兜底4.1 选课 Service 的完整实现与事务回滚范围选课这个动作不是单条 INSERT而是“查课程余量 查是否已选 插入选课记录 已选人数加一”四步操作的组合。任何一个环节失败前面已经做的操作都要撤销所以整个方法必须包在事务里。Service public class CourseSelectionService { Autowired private CourseMapper courseMapper; Autowired private CourseSelectionMapper selectionMapper; Transactional(rollbackFor Exception.class) public void selectCourse(String studentNo, String courseNo) { Course course courseMapper.selectByCourseNoForUpdate(courseNo); if (course null) { throw new BusinessException(课程不存在); } if (course.getSelectedCount() course.getCapacity()) { throw new BusinessException(课程已满选课失败); } Integer count selectionMapper.countByStudentAndCourse(studentNo, courseNo); if (count ! null count 0) { throw new BusinessException(不能重复选同一门课); } selectionMapper.insert(studentNo, courseNo); courseMapper.increaseSelectedCount(courseNo); } }Transactional(rollbackFor Exception.class)在这里必须写rollbackFor因为 Spring 默认只在 RuntimeException 时回滚而自定义的BusinessException如果不继承 RuntimeException抛出后事务不会回滚会出现“选课记录插进去了但课程已选人数没更新”这种半成品状态。事务的边界就是整个方法先查出课程并加锁然后在锁保护下做校验最后执行写操作。如果校验失败抛出异常后面的 insert 和 update 都不会执行事务也回滚为一个干净状态。4.2 SELECT ... FOR UPDATE 的锁行为与使用前提selectByCourseNoForUpdate这条查询和普通查询最大的区别是加了行级锁。执行这条 SQL 后其他事务再对同一个 course_no 执行 FOR UPDATE 查询时会被阻塞直到当前事务提交或回滚。select idselectByCourseNoForUpdate resultTypecom.example.entity.Course SELECT course_no, course_name, teacher, credits, capacity, selected_count FROM course WHERE course_no #{courseNo} FOR UPDATE /select这样设计的并发场景是两个学生同时提交最后一门课的选课请求。如果没有锁两个事务都能查到selected_count capacity - 1然后双双执行插入和更新导致超卖。加上 FOR UPDATE 后事务 B 在查课程时就会等待事务 A 提交事务 A 把 selected_count 更新为满员并提交事务 B 再拿到锁执行查询时看到余量为 0直接抛出“课程已满”。锁的粒度是 course 表中 course_no 对应的一行对选课这种低频写操作来说成本可接受。使用前提有两个。一是该方法必须有事务FOR UPDATE 的锁在事务提交时才释放脱离事务调用这个方法锁会立即释放等于没锁。二是course_no本身是唯一索引MySQL InnoDB 引擎在唯一索引等值查询时可以退化成行锁不会锁住整个表。如果查询条件不是唯一索引或者走了全表扫描锁范围会扩大可能锁住多行甚至间隙日志里会出现锁等待超时排查方向可以先看这条 SQL 的 EXPLAIN 是否命中索引。4.3 重复选课的唯一键兜底与异常转换行级锁只能保护容量不超卖防不了重复选课。两个并发请求即使加了 FOR UPDATE但第二个事务必须等第一个提交后才能去查已选记录这时第一个事务已经插入了选课记录所以第二个事务的业务校验能发现重复这已经够用。真正需要兜底的是绕过校验或者时序异常的边界情况靠数据库的 UNIQUE(student_no, course_no) 强制拦截。try { selectionMapper.insert(studentNo, courseNo); } catch (DuplicateKeyException e) { throw new BusinessException(不能重复选同一门课); }MyBatis 对重复键异常会包装成DuplicateKeyException这是DataAccessException的子类直接捕获它转换成友好提示即可。这里要注意单独捕获DuplicateKeyException时异常已经被事务管理器感知抛出新异常后整个事务照样回滚不会因为“已经 try 住”导致数据半提交。其实更稳妥的做法是不单独 catch让自定义的BusinessException在 Controller 层统一处理Mapper 层让异常自然抛出Java 代码更干净也不容易漏掉事务回滚。我在实现时一般直接依赖唯一键作为最终防线不在业务层做过度判断前端提示放在全局异常处理器里统一翻译。5. JSP 页面回显、分页传参与并发场景验证5.1 用 JSTLEL 渲染课程列表已选状态由 Controller 预处理JSP 页面里最忌讳的是写 Java scriptlet 去查数据库。选课列表页的标准做法是Controller 在查课程列表的同时查出当前学生已选的 course_no 集合放进同一个模型里JSP 用 EL 和 JSTL 做纯展示判断。这样做页面代码简洁也避免了 JSP 里出现业务逻辑。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body table border1 tr th课程号/th th课程名/th th教师/th th学分/th th已选/容量/th th操作/th /tr c:forEach items${pageInfo.list} varcourse tr td${course.courseNo}/td td${course.courseName}/td td${course.teacher}/td td${course.credits}/td td${course.selectedCount}/${course.capacity}/td td c:choose c:when test${course.selected} span已选/span /c:when c:otherwise form action${pageContext.request.contextPath}/selection/add methodpost input typehidden namecourseNo value${course.courseNo}/ button typesubmit选课/button /form /c:otherwise /c:choose /td /tr /c:forEach /table /body /html这里用${course.selected}而不是在 JSP 里做集合判断原因是 Controller 已经把当前学生的已选课程集合转换为每个 course 对象的selected属性。页面上每个未选课程是一个独立的 POST 表单提交后由 SpringMVC 把 courseNo 绑定到 Controller 的参数上。需要注意#{}和${}在 JSP 里的语义和 MyBatis 不同JSP 里${}是 EL 表达式专门用来取对象属性和做简单运算不要再套一层方法调用否则页面直接报异常。5.2 Controller 分页参数与 PageHelper避免 XML 里重复写 limit分页如果手写 LIMIT每个 Mapper 都要传 offset 和 pageSize而且排序不稳定时翻页会重复或漏数据。用 PageHelper 可以只在 Service 层启动分页Mapper 的 XML 里不写 LIMIT查询逻辑保持完整。RequestMapping(/course/list) public String courseList( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String courseName, Model model) { PageHelper.startPage(pageNum, pageSize); ListCourse courses courseMapper.selectCourseList(courseName); PageInfoCourse pageInfo new PageInfo(courses); model.addAttribute(pageInfo, pageInfo); return course/list; }PageHelper.startPage只对下一条查询生效所以必须在 Mapper 调用之前执行中间不要穿插其他数据库操作。分页 SQL 由 PageHelper 自动拼接 LIMIT它通过拦截器完成不影响 XML 里的if动态条件。XML 里如果手写 LIMIT 和 PageHelper 同时出现结果是双重分页页面上每页数据量比预期少很多这是分页模块最常见的问题。查询课程列表时SQL 里已经固定了ORDER BY course_no分页每页顺序一致翻页时不会出现记录漂移。5.3 并发压力验证观察 selected_count 与 InnoDB 锁等待系统写完不能只在浏览器里点一下“选课”就算验证过。并发是选课系统绕不开的检验场景用 curl 批量提交是成本最低的方法。for i in $(seq 1 30); do curl -s -X POST http://localhost:8080/selection/add \ -d studentNoS202300${i}courseNoC001 done wait这段脚本模拟 30 个不同学生同时对同一门课发起选课。验证点有两个。先说容量课程 C001 的容量如果是 25压测结束后查询SELECT selected_count, capacity FROM course WHERE course_noC001selected_count 必须小于等于 25且与course_selection表中 C001 的有效记录数一致。如果 selected_count 超过 capacity说明 FOR UPDATE 没生效或事务边界放错了位置。另一个验证点是选课记录总数SELECT COUNT(*) FROM course_selection WHERE course_noC001应该正好等于压测中能成功选课的人数而不是 30因为一部分请求会因为满员或重复而失败。压测后要清理 InnoDB 的锁状态重点看有没有事务残留。进入 MySQL 执行SHOW ENGINE INNODB STATUS输出里TRANSACTION段落如果长时间存在LOCK WAIT相关事务说明代码的事务没有及时提交。正常情况下SHOW PROCESSLIST里的Sleep连接过多时还要检查连接池的maxActive和maxWait参数是否配置合理。另一处值得关注的是唯一键被触发后的返回结果JSP 页面提示“不能重复选同一门课”而不是数据库驱动的 500 异常说明全局异常处理和事务回滚链路都对上了。这套验证做完再移交选课模块的基本可靠性才算有底了。本文还有配套的精品资源点击获取
返回列表