简介:这是一套基于Java Swing与MySQL开发的学生信息管理系统实战项目,面向Java初学者、课程设计学生及小型管理类应用入门开发者,解决学生数据录入、维护与查询等基础业务场景需求。资源包共48个文件,含15个核心Java源码、22个编译后Class字节码、1个建库建表SQL脚本(db_stu.sql)、4张界面截图JPG、3个依赖JAR包(含jgoodies-forms-1.8.0.jar)以及项目配置文件,完整呈现从GUI设计、数据库连接到CRUD功能实现的全流程结构,压缩包仅1.04MB,轻量易部署。已有2293人学习下载,适合快速上手Swing桌面开发与MySQL集成实践。购买者可获得作者全程环境配置支持、代码调试协助及无Bug运行保障,并附带清晰目录划分——src存放源码、bin存放编译结果、lib管理第三方依赖、images存储界面资源,便于理解典型Java桌面项目工程组织规范。
1. 为什么一个“学生信息管理系统”能成为Java工程师的通关密钥:从CRUD到可维护架构的实战跃迁
你可能觉得,“Java + MySQL 学生信息管理系统”是教科书里最老套的课设题目——增删改查、登录注册、导出Excel,三周就能交差。但真实情况是:某高校计算机系连续三年有超40%的毕业生,在实习面试中被要求现场重构自己当年写的“学生系统”:加权限控制、接Redis缓存、拆成微服务模块、补单元测试覆盖率……结果近半数人卡在事务失效、SQL注入漏洞、并发修改丢失数据这些“基础坑”上。这不是考算法,而是考工程直觉。这个项目从来不是练手玩具,它是Java生态下数据库交互、分层设计、异常治理、事务边界的最小完整闭环。它不追求炫技,但每一步都踩在企业级开发的真实断点上:JDBC连接池怎么配才不拖垮MySQL?MyBatis的#{}和${}在姓名模糊查询里到底该用哪个?Spring Boot的@Transactional在service方法里加了却没生效,日志里连事务ID都不打印——问题不在代码,而在你没真正理解“连接-会话-事务-连接池”的四层生命周期。本文就带你用一套可运行、可调试、可扩展的最小可行系统,把这四个层次掰开揉碎,不讲概念,只讲你改哪行代码、看哪条日志、抓哪个线程堆栈就能定位问题。
2. 从零搭建可调试的Java+MySQL学生系统:环境、依赖与分层骨架
2.1 环境与版本选择:为什么选Spring Boot 2.7.x而非3.x?
当前主流生产环境仍以JDK 8/11为主,而Spring Boot 3.x强制要求JDK 17+,且默认移除了Hibernate 5.x兼容层。对于学生系统这类需快速验证、本地调试、低学习成本的场景,Spring Boot 2.7.18(2023年10月发布的LTS版本)是更稳妥的选择:它支持JDK 8+,内嵌Tomcat 9.0.x对Windows/macOS兼容性极佳,且MyBatis Starter版本稳定(2.2.2),避免了3.x中因Jakarta EE命名空间变更导致的XML映射文件报错。Maven依赖配置如下:
<!-- pom.xml --> <properties> <java.version>11</java.version> <spring-boot.version>2.7.18</spring-boot.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> <version>${spring-boot.version}</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> <version>8.0.33</version> <!-- 注意:8.0.33修复了时区解析bug,避免"Server returns invalid timezone" --> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid-spring-boot-starter</artifactId> <version>1.2.16</version> <!-- Druid比HikariCP更适合教学:提供监控页面、SQL防火墙、慢SQL日志 --> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.2.2</version> </dependency> </dependencies>提示:
mysql-connector-java必须显式指定8.0.33版本。若使用Spring Boot 2.7.x默认的8.0.32,当MySQL服务器时区为Asia/Shanghai时,启动会抛出java.sql.SQLException: Server returns invalid timezone。这是MySQL驱动已知bug,非配置错误。
2.2 数据库建模:一张表如何暴露事务设计缺陷?
学生表看似简单,但字段设计直接决定后续扩展成本。以下为生产级最小字段集(非教学简化版):
-- 创建数据库(注意字符集) CREATE DATABASE IF NOT EXISTS student_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; USE student_db; -- 学生主表(含逻辑删除、创建时间、更新时间) CREATE TABLE `student` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', `student_id` varchar(20) NOT NULL COMMENT '学号(业务主键,唯一)', `name` varchar(50) NOT NULL COMMENT '姓名', `gender` tinyint NOT NULL DEFAULT '0' COMMENT '性别:0-未知,1-男,2-女', `birth_date` date DEFAULT NULL COMMENT '出生日期', `class_name` varchar(50) NOT NULL COMMENT '班级名称', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0-已删除,1-正常', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `updated_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_student_id` (`student_id`), KEY `idx_class_status` (`class_name`,`status`) -- 按班级+状态查询高频,联合索引提升性能 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='学生基本信息表';关键设计点:
student_id作为业务主键并建唯一索引:避免用自增ID做业务标识(如导出Excel时暴露内部序号),同时防止重复插入。status逻辑删除字段:物理删除会破坏外键关联和审计追溯,逻辑删除是企业标配。但注意:所有查询SQL必须显式添加WHERE status = 1,否则列表页会显示已删除学生。utf8mb4字符集:支持emoji和生僻字(如“䶮”、“堃”),避免插入时报Incorrect string value。idx_class_status联合索引:学生列表页常按班级筛选,且只查正常状态,该索引可覆盖查询,避免回表。
2.3 分层代码骨架:为什么Controller不直接调Service?
采用经典四层结构(Controller → Service → Mapper → Entity),但每一层职责必须严格隔离:
// Entity层:仅数据载体,无业务逻辑 @Data @Builder @NoArgsConstructor @AllArgsConstructor public class Student { private Long id; private String studentId; private String name; private Integer gender; private LocalDate birthDate; private String className; private Integer status; private LocalDateTime createdTime; private LocalDateTime updatedTime; } // Mapper层:仅SQL操作,无事务、无校验 @Mapper public interface StudentMapper { // 根据学号查询(用于登录、详情页) Student selectByStudentId(@Param("studentId") String studentId); // 分页查询(带逻辑删除过滤) List<Student> selectList(@Param("className") String className, @Param("status") Integer status); // 插入(返回主键ID) @Options(useGeneratedKeys = true, keyProperty = "id") int insert(Student student); // 更新(必须检查version或updated_time防并发覆盖) int updateByIdSelective(Student student); }逻辑说明:
updateByIdSelective使用MyBatis的<set>动态SQL,只更新非null字段,避免误将姓名置空。参数说明:@Param注解在多参数时必需,否则MyBatis无法绑定;@Options开启主键回填,插入后student.getId()可获取数据库生成ID。
// Service层:事务边界、业务校验、跨表操作 @Service public class StudentService { @Autowired private StudentMapper studentMapper; @Autowired private StudentValidator validator; // 独立校验器,解耦校验逻辑 /** * 新增学生:事务内完成学号唯一性校验+插入 * 注意:此处@Transactional不可省略,否则校验通过后插入失败,学号已被占用却无回滚 */ @Transactional(rollbackFor = Exception.class) public Result<Long> addStudent(Student student) { // 1. 业务校验(非空、学号格式、班级是否存在等) Result<Void> validateResult = validator.validateForAdd(student); if (!validateResult.isSuccess()) { return Result.fail(validateResult.getMessage()); } // 2. 检查学号是否已存在(防止并发插入重复) Student existing = studentMapper.selectByStudentId(student.getStudentId()); if (existing != null && existing.getStatus() == 1) { return Result.fail("学号 " + student.getStudentId() + " 已存在"); } // 3. 执行插入 int rows = studentMapper.insert(student); if (rows != 1) { return Result.fail("新增失败"); } return Result.success(student.getId()); } }逻辑说明:
@Transactional标注在public方法上,由Spring AOP代理生效。rollbackFor = Exception.class确保所有Exception及其子类触发回滚(默认只对RuntimeException回滚)。关键点:校验与插入必须在同一事务内,否则高并发下“先查无、再插”会因时间差导致重复数据。
// Controller层:仅协议转换、参数解析、异常统一封装 @RestController @RequestMapping("/api/student") public class StudentController { @Autowired private StudentService studentService; @PostMapping public Result<Long> add(@Valid @RequestBody Student student) { // @Valid触发表单校验(如@NotBlank),失败时抛MethodArgumentNotValidException return studentService.addStudent(student); } @GetMapping("/list") public Result<List<Student>> list(@RequestParam(required = false) String className) { // 默认查所有正常状态学生 List<Student> list = studentService.listStudents(className, 1); return Result.success(list); } }参数说明:
@Valid启用JSR-303校验,需在Entity字段加注解(如@NotBlank(message="姓名不能为空"));@RequestParam(required = false)表示班级参数可选,符合前端筛选交互习惯。
3. 数据库连接与事务深度剖析:Druid监控、事务传播与隔离级别实战
3.1 Druid连接池配置:为什么maxActive=20比默认的8更安全?
Spring Boot默认HikariCP连接池,但Druid提供更直观的监控能力,对初学者排查连接泄漏至关重要。application.yml关键配置:
spring: datasource: url: jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver type: com.alibaba.druid.pool.DruidDataSource # Druid专属配置 druid: # 连接池基础参数 initial-size: 5 min-idle: 5 max-active: 20 # 核心参数:最大连接数。设为20而非默认8,避免并发请求时频繁创建销毁连接 max-wait: 60000 # 获取连接最大等待时间(ms),超时抛SQLException time-between-eviction-runs-millis: 60000 # 检测连接空闲的间隔 min-evictable-idle-time-millis: 300000 # 连接最小空闲时间(5分钟),超时则回收 # SQL监控与防火墙 web-stat-filter: enabled: true url-pattern: "/druid/*" stat-view-servlet: enabled: true url-pattern: "/druid/*" login-username: admin login-password: admin123 reset-enable: false # 生产环境必须false,禁用重置功能逻辑说明:
max-active=20是经验值。本地开发时,若同时打开多个Postman请求、IDEA调试断点、浏览器刷新,8个连接极易耗尽,导致后续请求阻塞在getConnection(),表现为接口超时。20个连接在单机开发足够,且不会过度消耗MySQL资源(MySQL默认max_connections=151)。
3.2 事务传播行为:为什么saveStudent()调用另一个service方法时事务会失效?
事务失效是学生系统最高频Bug。典型场景:StudentService.addStudent()中调用ClassService.checkClassExists()校验班级是否存在,但checkClassExists()未加@Transactional,导致整个事务回滚时,班级校验的SQL不参与回滚——表面看是学生没加进去,实际班级表可能被意外修改。
根本原因:Spring事务基于代理(Proxy),只有通过代理对象调用的方法才能被AOP拦截。this.checkClassExists()是this引用调用,绕过代理,事务失效。
正确解法:声明式事务 + 显式代理调用
@Service public class StudentService { @Autowired private StudentMapper studentMapper; @Autowired private ClassService classService; // 通过Spring注入,非this调用 @Transactional(rollbackFor = Exception.class) public Result<Long> addStudent(Student student) { // 正确:通过注入的classService调用,走代理 Result<Void> classCheck = classService.checkClassExists(student.getClassName()); if (!classCheck.isSuccess()) { return Result.fail(classCheck.getMessage()); } // ... 插入逻辑 return Result.success(student.getId()); } }参数说明:
@Transactional的propagation默认为REQUIRED(如果当前存在事务,则加入;不存在则新建)。rollbackFor必须显式指定,否则IOException等检查异常不会回滚。
3.3 隔离级别实战:READ_COMMITTED如何解决“幻读”但引发“不可重复读”?
MySQL默认隔离级别为REPEATABLE READ,但在学生系统中,它会导致一个反直觉问题:管理员在后台批量修改班级名称,而教师端正在查看该班级学生列表,教师看到的列表班级名仍是旧值(MVCC快照),直到事务结束。这在实时性要求高的教务场景中不可接受。
解决方案:将学生查询事务设为READ_COMMITTED,确保每次SELECT都读取最新已提交数据:
@Service public class StudentService { @Transactional(isolation = Isolation.READ_COMMITTED) public List<Student> listStudents(String className, Integer status) { return studentMapper.selectList(className, status); } }逻辑说明:
READ_COMMITTED下,每次SELECT生成新快照,解决“不可重复读”,但可能遇到“幻读”(两次查询间有新记录插入)。对学生列表页,幻读影响极小(新学生加入班级本就是预期行为),而不可重复读会导致教师看到过期班级名,引发沟通混乱。这是典型的业务权衡,非技术优劣。
4. 常见问题排查:5个血泪经验总结的“玄学”故障与根因定位
4.1 现象:新增学生成功,但数据库里created_time和updated_time全是0000-00-00 00:00:00
原因:MySQL 5.7+默认启用NO_ZERO_DATE模式,禁止插入零日期。而MyBatis未设置jdbcType=TIMESTAMP,导致LocalDateTime字段映射为NULL,MySQL将其转为零日期并报warning(但不抛异常)。
解决:
- 在
application.yml的JDBC URL中添加zeroDateTimeBehavior=convertToNull - 在MyBatis XML映射文件中为时间字段显式指定JDBC类型:
<!-- StudentMapper.xml --> <insert id="insert" parameterType="Student" useGeneratedKeys="true" keyProperty="id"> INSERT INTO student (student_id, name, gender, birth_date, class_name, status, created_time, updated_time) VALUES ( #{studentId}, #{name}, #{gender}, #{birthDate}, #{className}, #{status}, #{createdTime,jdbcType=TIMESTAMP}, <!-- 关键:显式声明jdbcType --> #{updatedTime,jdbcType=TIMESTAMP} ) </insert>4.2 现象:模糊查询name LIKE '%张%'返回空,但数据库里明明有“张三”
原因:MyBatis中#{}预编译占位符会将%张%作为字符串整体传入,而MySQL的LIKE需要通配符在SQL中拼接。若写成name LIKE #{name},实际执行name LIKE '?%'(问号被转义)。
解决:两种安全方案任选其一
✅ 推荐:在Java层拼接通配符,SQL保持#{}
// Service中 String likeName = "%" + name + "%"; List<Student> list = studentMapper.selectByNameLike(likeName);<!-- Mapper.xml --> <select id="selectByNameLike" resultType="Student"> SELECT * FROM student WHERE name LIKE #{name} AND status = 1 </select>❌ 禁止:用${}拼接(SQL注入高危!)
<!-- 危险!绝对不要这样写 --> SELECT * FROM student WHERE name LIKE '%${name}%'4.3 现象:Druid监控页面显示“ActiveCount=20”,但应用完全卡死,无任何日志
原因:连接池耗尽,所有线程阻塞在DruidDataSource.getConnection(),等待可用连接。常见于未关闭ResultSet/Statement的循环查询,或事务未提交长期持有连接。
排查:
- 访问
http://localhost:8080/druid,查看“SQL监控”页,找执行时间超10s的慢SQL - 查看“数据源”页的“ActiveCount”和“PoolingCount”,若ActiveCount=20且PoolingCount=0,确认耗尽
- 在代码中强制添加连接关闭钩子(开发环境):
// 在Druid配置Bean中 @Bean public DruidDataSource dataSource() { DruidDataSource dataSource = new DruidDataSource(); // ... 其他配置 dataSource.setRemoveAbandonedOnBorrow(true); // 归还时检测连接是否被废弃 dataSource.setRemoveAbandonedTimeoutMillis(60000); // 超过60秒未归还则强制回收 return dataSource; }4.4 现象:@Transactional方法抛出Exception,数据库却未回滚
原因:异常被方法内部try-catch吞掉,未向上抛出。Spring事务切面只能捕获到传播到代理层的异常。
解决:
- 方案1(推荐):绝不
catch业务异常,让其自然抛出 - 方案2:
catch后重新throw,或throw new RuntimeException(e)
@Transactional public void updateStudent(Student student) { try { studentMapper.updateByIdSelective(student); } catch (Exception e) { // 错误:吞掉异常,事务不回滚 log.error("更新失败", e); // 正确:重新抛出运行时异常 throw new RuntimeException("更新学生失败", e); } }4.5 现象:MySQL报错Packet for query is too large,插入大文本字段失败
原因:MySQL默认max_allowed_packet=4MB,当学生备注字段存入超长文本(如HTML报告),超出限制。
解决:
- 临时调整(重启MySQL后失效):
SET GLOBAL max_allowed_packet = 64*1024*1024; -- 64MB- 永久生效:修改MySQL配置文件
my.cnf(Linux)或my.ini(Windows)
[mysqld] max_allowed_packet = 64M- Java端配合:在JDBC URL中添加
maxAllowedPacket=67108864
5. 进阶技巧:用Druid SQL防火墙堵住90%的注入漏洞,以及一个让导师眼前一亮的“后悔药”设计
5.1 Druid SQL防火墙:三行配置实现企业级防护
学生系统常被要求支持“按姓名、班级、状态组合查询”,前端传参易被构造恶意SQL。Druid内置防火墙可拦截危险语法,无需修改一行业务代码:
# application.yml druid: filter: wall: enabled: true config: multi-statement-allow: false # 禁止;分隔多条SQL delete-allow: false # 禁止DELETE(学生系统无需物理删除) drop-table-allow: false # 禁止DROP alter-table-allow: false # 禁止ALTER select-allow: true # 允许SELECT insert-allow: true # 允许INSERT update-allow: true # 允许UPDATE merge-allow: false # 禁止MERGE(高危)启用后,若攻击者传参name=张三'; DROP TABLE student; --,Druid会在SQL解析阶段直接抛出WallFilterException: illegal sql,请求根本不会到达MySQL。这是比MyBatis#{}更底层的防护,覆盖所有JDBC调用路径。
5.2 “后悔药”设计:用MySQL Binlog实现学生信息变更追溯
学生信息被误删/误改是高频事故。与其依赖备份恢复(耗时长、数据丢失),不如在应用层实现轻量级操作日志。利用MySQL Binlog解析,记录每次INSERT/UPDATE/DELETE的原始SQL和影响行:
- 添加Binlog解析依赖(轻量级,非Debezium):
<dependency> <groupId>com.github.shyiko</groupId> <artifactId>mysql-binlog-connector-java</artifactId> <version>0.21.0</version> </dependency>- 创建操作日志表:
CREATE TABLE `student_operation_log` ( `id` bigint NOT NULL AUTO_INCREMENT, `operation_type` varchar(10) NOT NULL COMMENT 'INSERT/UPDATE/DELETE', `table_name` varchar(50) NOT NULL COMMENT 'student', `sql_text` text NOT NULL COMMENT '原始SQL', `affected_rows` int NOT NULL COMMENT '影响行数', `operator_ip` varchar(45) DEFAULT NULL COMMENT '操作者IP', `created_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_table_time` (`table_name`,`created_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;- 在Service层关键方法中记录(异步,避免阻塞主流程):
@Service public class StudentService { @Autowired private OperationLogService logService; // 异步日志服务 @Transactional public Result<Long> addStudent(Student student) { // ... 业务逻辑 // 异步记录日志(不影响主事务) CompletableFuture.runAsync(() -> { String sql = "INSERT INTO student (student_id,name,...) VALUES (?,?,...)"; logService.saveLog("INSERT", "student", sql, 1, getRemoteIp()); }); return Result.success(student.getId()); } }技巧说明:
CompletableFuture.runAsync使用ForkJoinPool.commonPool(),需注意线程池大小。生产环境应自定义线程池并配置拒绝策略,避免日志线程堆积OOM。
5.3 一个真实踩坑后的习惯:所有SQL操作后必加SELECT ROW_COUNT()
MySQL的ROW_COUNT()函数返回上一条DML语句影响的行数。学生系统中,我们常假设UPDATE一定成功,但实际可能因WHERE条件不匹配导致0行更新——用户以为改成功了,其实什么都没变。
标准写法模板:
int rows = studentMapper.updateByIdSelective(student); if (rows == 0) { return Result.fail("未找到ID为" + student.getId() + "的学生,更新失败"); } else if (rows > 1) { // 理论上不会发生,但加此判断可发现主键冲突等严重问题 log.error("UPDATE student 影响{}行,主键异常", rows); return Result.fail("系统异常,请联系管理员"); }这个习惯让我在某次模拟项目X中提前发现了一个致命BUG:updateByIdSelective的WHERE id = ?条件被误写成WHERE student_id = ?,导致同班级多个学生被批量覆盖。ROW_COUNT()返回远大于1,立刻暴露问题。
希望帮到你。
本文还有配套的精品资源,点击获取