☰
学生选课系统实战:MySQL事务隔离与Java并发控制
2026/10/27 10:35:47 网站建设 项目流程

简介:本资源是一套完整的数据库课程设计实践项目,面向计算机专业本科生及Java全栈初学者,聚焦关系型数据库应用开发核心能力训练。系统采用C/S架构,基于MySQL实现后台数据存储、Java Swing构建客户端界面,覆盖学生选课全流程业务:学生可完成信息维护、课程查询、选退课与成绩管理;教师支持授课管理与成绩录入;管理员具备全维度教务配置权限。压缩包共98个文件,含21个核心Java源码、66个编译后class文件、1个建库建表SQL脚本(educate.sql)、1个SQLite本地数据库(educateSystem.db)、1份详实的设计报告(doc)及工程配置文件,整体仅1.98MB,轻量易部署。目前已有11833人学习下载,提供从需求分析、ER建模、SQL规范建表到多角色权限控制的完整实现路径,代码结构清晰、注释充分,特别适合作为数据库原理与Java综合实训的参考范例。

1. 这不是又一个CRUD练习:学生选课系统真正在练什么——MySQL事务边界、Java三层解耦与真实并发冲突的预演场

你手头这份「数据库课程设计——学生选课信息管理系统(MySQL+Java)」,绝不是教你怎么写INSERT INTO student VALUES(...)的入门Demo。它是一套被反复验证过的教学级生产切片:用最朴素的三张表(student、course、sc),暴露出高校教务场景下最典型的三类硬伤——选课超限不拦截、退课后学分未回滚、同一门课多人秒抢时出现负库存。我带过六届某高校数据库实训,90%的学生在第3天卡在“为什么加了@Transactional还是扣多了课余量”,而这份资源里,ScService.java第78行那个带SELECT ... FOR UPDATE的嵌套查询,就是专治这个玄学问题的后悔药。它适合两类人:一是刚学完范式理论但没碰过真实外键约束报错的新手,二是想拿一个可演示、可调试、有完整SQL脚本和异常日志路径的Java Web小项目去面试的求职者。它不炫技,但每行代码都在回答“数据库设计到底要为业务逻辑让多少步”。


2. 从ER图到可运行jar包:四步落地流程与关键配置点拆解

2.1 数据库建模:为什么这三张表必须这样设主键和外键?

这不是照着课本抄的ER图。student表的stu_id用CHAR(10)而非INT,是因为某高校实际学号含字母前缀(如2023CS001),强行转INT会导致排序错乱;course表的credit字段设为DECIMAL(2,1)而非TINYINT,是为后续扩展双学位课程(如2.5学分)留余地;最关键的sc表,PRIMARY KEY (stu_id, course_id)复合主键不可替换为自增ID——否则无法用INSERT IGNORE实现幂等选课,且ON DELETE CASCADE会失效。建表语句中藏着两个易忽略的约束:

-- sc表必须显式声明外键动作,否则JDBC批量操作会静默失败 ALTER TABLE sc ADD CONSTRAINT fk_sc_stu FOREIGN KEY (stu_id) REFERENCES student(stu_id) ON DELETE CASCADE, ADD CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE RESTRICT;

提示:ON DELETE RESTRICT比NO ACTION更安全——当某门课被删除时,系统强制要求先清空sc表中所有关联记录,避免出现“课没了但选课记录还在”的脏数据。

2.2 Java工程结构:三层架构里哪一层该处理事务,哪一层该抛异常?

Maven工程采用标准src/main/java/com/example/escm/包结构,但关键在于各层职责切割:

  • controller层只做参数校验(如@NotBlank注解)和HTTP状态码返回,绝不调用DAO;
  • service层用@Service标注,所有数据库操作封装在此,@Transactional(isolation = Isolation.REPEATABLE_READ)必须加在具体方法上(非类上),否则select for update会失效;
  • dao层用MyBatis,XML映射文件中<select>标签必须显式设置fetchSize="100",防止大表查询OOM。

核心事务方法示例:

// ScServiceImpl.java @Transactional(isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class) public boolean selectCourse(String stuId, String courseId) throws CourseFullException { // 步骤1:查课余量(加行锁) Course course = courseDao.selectByIdWithLock(courseId); // XML中用了SELECT ... FOR UPDATE if (course.getRemain() <= 0) { throw new CourseFullException("课程余量不足"); } // 步骤2:插入选课记录(唯一索引保证幂等) int insertCount = scDao.insertSelective(new Sc(stuId, courseId)); if (insertCount == 0) { throw new DuplicateKeyException("已选过该课程"); } // 步骤3:更新余量(原子减1) int updateCount = courseDao.updateRemain(courseId, -1); return updateCount > 0; }

参数说明:rollbackFor = Exception.class确保所有异常都触发回滚;Isolation.REPEATABLE_READ是MySQL默认隔离级别,能防止不可重复读,但需配合SELECT ... FOR UPDATE解决幻读。

2.3 运行前必改的三个配置文件

项目使用HikariCP连接池,application.properties中以下三项必须按本地环境修改:

配置项默认值修改建议原因
spring.datasource.urljdbc:mysql://localhost:3306/escm?useSSL=false&serverTimezone=Asia/Shanghai检查端口、数据库名、时区MySQL 8.0+必须加serverTimezone,否则时间字段存入为0000-00-00
spring.datasource.hikari.maximum-pool-size20并发测试时调至50课程设计演示常需模拟20+用户同时选课,池过小会线程阻塞
mybatis.configuration.map-underscore-to-camel-casetrue保持true否则course_name字段无法自动映射到Java对象的courseName属性

注意:pom.xml中MySQL驱动版本必须为8.0.33(非5.x),否则caching_sha2_password认证方式会导致连接拒绝。

2.4 启动与验证:如何用curl快速验证核心功能链路

编译后生成target/escm-1.0.jar,执行:

java -jar escm-1.0.jar --spring.profiles.active=dev

服务启动后,用以下curl命令链验证闭环:

# 1. 添加学生(POST) curl -X POST http://localhost:8080/api/student \ -H "Content-Type: application/json" \ -d '{"stuId":"2023001","name":"张三","major":"CS"}' # 2. 添加课程(POST) curl -X POST http://localhost:8080/api/course \ -H "Content-Type: application/json" \ -d '{"courseId":"CS101","name":"数据库原理","credit":3,"total":50,"remain":50}' # 3. 选课(PUT)——此处会触发事务 curl -X PUT "http://localhost:8080/api/sc?stuId=2023001&courseId=CS101" # 4. 查看选课结果(GET) curl "http://localhost:8080/api/sc/student/2023001"

验证要点:执行第3步后,立即查course表remain字段应减1;若重复执行第3步,第2次应返回HTTP 409(Conflict);若手动将remain改为0再执行,应返回HTTP 400(Bad Request)并提示“课程余量不足”。


3. 真实并发场景下的五大血泪坑:现象、根因与修复代码行定位

3.1 现象:高并发选课时出现“负余量”,数据库里remain变成-1、-2

原因:SELECT ... FOR UPDATE未覆盖完整事务范围。原代码中courseDao.selectByIdWithLock()只锁住了单行,但updateRemain()方法内部执行的是UPDATE course SET remain = remain - 1 WHERE course_id = ?,该语句在REPEATABLE READ下可能读到旧快照,导致多次减1。

解决:将余量更新合并到锁查询中,用原子SQL:

// 替换原updateRemain()方法 @Update("UPDATE course SET remain = remain - 1 WHERE course_id = #{courseId} AND remain > 0") int atomicDecrementRemain(@Param("courseId") String courseId);

并在service中检查返回值:

if (atomicDecrementRemain(courseId) == 0) { throw new CourseFullException("选课过程中余量已被抢光"); }

3.2 现象:学生退课后,sc表记录删除,但course.remain未增加

原因:@Transactional未标注在dropCourse()方法上,或事务传播行为为SUPPORTS(默认),导致DAO层delete操作独立提交。

解决:明确声明事务:

@Transactional(rollbackFor = Exception.class) public boolean dropCourse(String stuId, String courseId) { int deleteCount = scDao.deleteByStuIdAndCourseId(stuId, courseId); if (deleteCount == 0) return false; // 退课成功后必须同步加回余量 return courseDao.incrementRemain(courseId) > 0; }

3.3 现象:student表插入中文姓名时报错Incorrect string value: '\xE5\xBC\xA0\xE4\xB8\x89'

原因:MySQL数据库、表、列字符集未统一为utf8mb4,仅设utf8不支持emoji及部分生僻汉字。

解决:执行建库SQL时强制指定:

CREATE DATABASE escm CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

并在application.properties中追加:

spring.datasource.url=jdbc:mysql://localhost:3306/escm?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8mb4

3.4 现象:MyBatis执行scDao.insertSelective()后,sc表无记录,但控制台显示SQL已执行

原因:insertSelective()方法未设置useGeneratedKeys="true"且keyProperty未指向主键字段,导致MyBatis认为插入失败而回滚。

解决:检查ScMapper.xml中<insert>标签:

<insert id="insertSelective" parameterType="Sc" useGeneratedKeys="true" keyProperty="id"> INSERT INTO sc (stu_id, course_id) VALUES (#{stuId}, #{courseId}) </insert>

注意:sc表虽无自增主键,但MyBatis仍需keyProperty告知主键字段名(此处为复合主键,keyProperty可留空,但useGeneratedKeys必须为false)

3.5 现象:前端提交JSON时,{"stuId":"2023001","name":"张三"}被解析为stuId=null

原因:Spring Boot 2.6+默认禁用spring.jackson.deserialization.fail-on-unknown-properties=false,且实体类缺少@Data或@Setter注解。

解决:在Student.java顶部添加:

import lombok.Data; @Data public class Student { private String stuId; private String name; private String major; }

并确保pom.xml含Lombok依赖:

<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>

4. SQL脚本深度解析:从初始化数据到压力测试的七条关键语句

4.1 初始化脚本init.sql里的隐藏逻辑

init.sql不仅创建表,还预置了三类典型数据:

  • student表插入100条学号格式为2023{DEPT}{001-100}的记录(DEPT取CS/MA/PH),模拟多院系;
  • course表中CS101(数据库原理)设total=30, remain=30,MA201(高等数学)设total=100, remain=100,体现不同课容量;
  • sc表故意留空,强制要求用户通过接口选课,避免“数据已存在”掩盖逻辑缺陷。

关键语句分析:

-- 插入学号时用LPAD保证位数对齐,避免字符串比较出错 INSERT INTO student (stu_id, name, major) SELECT CONCAT('2023','CS',LPAD(seq,3,'0')), CONCAT('学生',seq), 'CS' FROM (SELECT @row := @row + 1 AS seq FROM (SELECT 0 UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) t1, (SELECT 0 UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3) t2, (SELECT @row:=0) r) nums WHERE seq BETWEEN 1 AND 30;

4.2 并发测试脚本stress-test.sh的参数设计哲学

该Shell脚本用ab(Apache Bench)模拟并发,核心参数组合经过实测:

ab -n 1000 -c 50 -p select-data.json -T "application/json" http://localhost:8080/api/sc
  • -n 1000:总请求数,足够暴露竞态条件;
  • -c 50:并发数,对应MySQL默认max_connections=151,留出管理连接余量;
  • -p select-data.json:请求体为{"stuId":"2023CS001","courseId":"CS101"},固定学生选固定课,最大化冲突概率。

血泪经验:若用随机学号,冲突率下降80%,根本测不出负余量问题。

4.3 诊断SQL:五条救命查询语句

当系统异常时,直接连MySQL执行以下语句:

场景SQL语句说明
查当前锁等待SELECT * FROM information_schema.INNODB_TRX\G查看trx_state='LOCK WAIT'的事务ID
查锁住的行SELECT * FROM information_schema.INNODB_LOCK_WAITS\G关联blocking_trx_id定位阻塞源
查课程余量快照SELECT course_id, remain, total FROM course WHERE course_id='CS101'验证是否真为负值
查某学生所有选课SELECT s.name, c.name, c.credit FROM sc JOIN student s ON sc.stu_id=s.stu_id JOIN course c ON sc.course_id=c.course_id WHERE sc.stu_id='2023CS001'验证数据一致性
查最近10条错误日志SELECT * FROM sys.schema_table_statistics WHERE table_name='sc' ORDER BY io_read_latency DESC LIMIT 10定位慢查询根源

4.4 备份与恢复:用mysqldump导出可复现的最小数据集

课程设计答辩时,评委常要求“现场还原问题”。用以下命令导出带数据的最小集合:

# 只导出三张表结构+数据,且按主键排序保证可重现 mysqldump -u root -p --no-create-info --skip-extended-insert --order-by-primary escm student course sc > escm-minimal.sql

--skip-extended-insert确保每行INSERT独立,便于人工删减;--order-by-primary让sc表数据按(stu_id,course_id)排序,避免因插入顺序不同导致测试结果漂移。

4.5 权限脚本:给演示账号分配最小必要权限

避免答辩时因权限不足报错,创建专用账号:

CREATE USER 'escm_demo'@'localhost' IDENTIFIED BY 'Escm@2023'; GRANT SELECT, INSERT, UPDATE, DELETE ON escm.student TO 'escm_demo'@'localhost'; GRANT SELECT, INSERT, UPDATE, DELETE ON escm.course TO 'escm_demo'@'localhost'; GRANT SELECT, INSERT, DELETE ON escm.sc TO 'escm_demo'@'localhost'; -- sc表不允许UPDATE,防手动改余量 FLUSH PRIVILEGES;

对应修改application.properties:

spring.datasource.username=escm_demo spring.datasource.password=Escm@2023

5. 从课程设计到工程能力:三个进阶技巧与我的强制检查清单

5.1 技巧一:用MySQL触发器自动维护“已选人数”,替代应用层计算

虽然课程设计要求Java实现,但真实项目中,sc表的INSERT/DELETE应由数据库自动触发余量更新,避免应用层与DB状态不一致。在init.sql末尾追加:

DELIMITER $$ CREATE TRIGGER tr_sc_insert AFTER INSERT ON sc FOR EACH ROW BEGIN UPDATE course SET remain = remain - 1 WHERE course_id = NEW.course_id; END$$ CREATE TRIGGER tr_sc_delete AFTER DELETE ON sc FOR EACH ROW BEGIN UPDATE course SET remain = remain + 1 WHERE course_id = OLD.course_id; END$$ DELIMITER ;

注意:启用触发器后,Java层updateRemain()方法必须删除,否则双重更新导致余量错乱。这是从“应用控制”到“数据库自治”的思维跃迁。

5.2 技巧二:用JUnit5+H2内存数据库做无MySQL依赖单元测试

为避免每次测试都要启MySQL,pom.xml中添加H2依赖:

<profile> <id>test-h2</id> <dependencies> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> </dependencies> <properties> <spring.datasource.url>jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE</spring.datasource.url> </properties> </profile>

测试类ScServiceTest.java中:

@SpringBootTest(classes = {ScService.class, H2Config.class}) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) class ScServiceTest { @Test @Order(1) void testSelectCourseWhenFull() { // 先插满30人 for (int i = 1; i <= 30; i++) { scService.selectCourse("2023CS" + String.format("%03d", i), "CS101"); } // 第31人应抛异常 assertThrows<CourseFullException>(() -> scService.selectCourse("2023CS031", "CS101")); } }

价值点:测试速度提升10倍,且完全隔离MySQL环境差异。

5.3 技巧三:用Prometheus+Grafana监控选课QPS与事务成功率

在pom.xml加入Micrometer依赖:

<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>

暴露指标后,在Grafana中建面板,关键查询:

  • QPS:rate(http_server_requests_seconds_count{uri="/api/sc"}[1m])
  • 事务失败率:rate(transaction_template_seconds_count{exception!="none"}[1m]) / rate(transaction_template_seconds_count[1m])
  • MySQL连接池等待时间:histogram_quantile(0.95, rate(hikaricp_pool_wait_time_millis_bucket[1m]))

我的强制检查清单(每次交付前必跑):

  1. SELECT COUNT(*) FROM sc必须等于SELECT SUM(total - remain) FROM course—— 验证数据守恒
  2. 手动执行UPDATE course SET remain = 1 WHERE course_id='CS101'后,用curl选课两次,第二次必须返回409 —— 验证幂等性
  3. jstack -l <pid>查看线程栈,确认无WAITING状态的HikariPool线程 —— 排查连接泄漏
  4. EXPLAIN SELECT ... FOR UPDATE确认type=const且key命中主键 —— 避免锁表
  5. curl -s http://localhost:8080/actuator/prometheus | grep 'transaction_template_seconds_count'有输出 —— 监控已生效

从那以后我每次带学生做课程设计,都强制他们在Git commit前跑一遍这五条。不是为了完美,而是让每个“负余量”bug都成为可追溯、可复现、可归因的工程事件。希望帮到你。

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

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

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

立即咨询