☰
Java Swing + MySQL构建学生健康档案管理系统:从建表到批量导入全解析
2026/10/25 6:49:41 网站建设 项目流程

简介:一套基于MySQL与Java Swing实现的学生健康档案管理系统源码,面向计算机相关专业在校生、教师或初级开发者,适用于课程设计、毕业设计及项目初期演示。系统自上而下分为学生信息管理、体检信息管理和病历信息管理三大模块,健康数据涵盖病历与体检两类,支持学号、姓名、系别、诊断、日期以及身高、体重、胸围等字段维护,并集成Excel导入导出、多选删除、多选修改、高级条件查询及动态折线图数据分析等实用功能。压缩包共98个文件,以30个Java源码和38个class编译文件为主,辅以XML配置、SQL数据库脚本、Excel数据表、界面截图等,合计仅1.27MB,便于快速部署和二次开发。已有76人学习浏览,适合希望从完整项目中掌握Swing界面开发与MySQL数据库联动、理解系统设计到落地全流程的读者。

1. 学生健康档案管理系统为什么还在用Java swing + MySQL:先看清这套组合的适用边界

每年秋季入学,校医室要在一个月内给几千名新生建健康档案,身高体重、视力、血压、过敏史,全靠手工登记再录入系统。做过这类校内系统的工程师都清楚,Web方案在校园网里常常被审批和部署卡住,而Java swing加MySQL这套组合,恰好能在单机或小型局域网里把活干完。HealthArchives既是软件工程课设出现频率最高的题目,也是很多中小学卫生室正在用的务实方案。下面会顺着数据建模、界面搭建、数据库连接、问题排查这条线,把一套能真实跑起来的学生健康档案管理系统拆开讲透。如果你正打算用这套技术栈做课设、毕设或校内工具,下文可以直接照着复现。

2. 数据建模先行:HealthArchives的三张核心表与MySQL建表脚本

2.1 学生表、体检表、体质测试表怎么拆:字段冗余的取舍

健康档案系统的第一刀,是决定把数据竖着切还是横着切。常见做法是把学生信息和健康记录放进三张表:student保存姓名、学号、班级这类几乎不变的基础信息,health_record存每次体检的身高体重视力血压,physical_test存每学期的体质测试成绩。

如果只做课设,有人嫌三张表麻烦,直接塞成一张大宽表,每个学生一行,体检结果全部列出来。这个方案在最开始录入时的确省事,但撑不过第二次体检。第二年再测一次视力,宽表就要加字段,或者旧数据被覆盖。拆表的真正理由不是教科书上的范式要求,而是业务次数不对等:一个学生对应多次体检、多次体测,基础信息和测试记录是一对多关系,必须拆成主表和明细表。

字段设计上,我一般会坚持几条原则。学号用VARCHAR(20)而不是INT,因为很多学校学号带字母或前导零,整数类型会悄悄丢掉格式。身高体重用DECIMAL(5,2)而不是FLOAT,浮点数在显示和比较时会有诡异的小数尾巴,DECIMAL在MySQL里按字符串存储,计算精确。性别字段存CHAR(1)的M/F,界面显示中文,库里只存枚举值,省掉一堆字符集比较的坑。

2.2 外键与唯一键:健康档案有没有必要用外键约束

health_record和physical_test里都放一个student_id指向student.id,这是最直观的关联方式。要不要真的加外键约束,不同团队有不同答案。我的习惯是加,但不是为了查询效率,而是为了删除时的兜底。比如学生退学,校医室要从系统里清掉这个人连同历史记录,如果只靠业务代码删,漏删一处就会留下孤儿数据,之后统计全校近视率时莫名少几条。加了ON DELETE CASCADE,删student主表时MySQL自动清掉关联的体检和体测记录,省心。

外键的代价是每次写操作都要多一次约束检查,但对健康档案这个读多写少的场景可以忽略。另一个必须加的是UNIQUE(student_id, check_date)。同一学生同一天做一次体检是业务常识,这个唯一键直接拦住重复导入。现实中最大的重复来源不是人手录入,而是Excel数据被同事点了两遍导入,没有任何警告就产生了双份记录,统计BMI分布时数据直接翻倍。这个约束就是后悔药。

2.3 一版直接能跑的MySQL DDL脚本与字段类型说明

下面是HealthArchives的最小库表结构,MySQL 5.7和8.0都能直接跑。字符集统一用utf8mb4,不是utf8,因为utf8mb4才完整支持中文和emoji,utf8在MySQL里只是别名,而且容量更小。

-- HealthArchives 核心表结构,兼容 MySQL 5.7+ / 8.0 CREATE DATABASE IF NOT EXISTS health_archives DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE health_archives; -- 学生基础信息表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键', student_no VARCHAR(20) NOT NULL UNIQUE COMMENT '学号,业务唯一', name VARCHAR(50) NOT NULL COMMENT '姓名', gender CHAR(1) NOT NULL DEFAULT 'M' COMMENT '性别 M/F', birth_date DATE NULL COMMENT '出生日期', grade VARCHAR(20) NULL COMMENT '年级,如 2024级', class_name VARCHAR(20) NULL COMMENT '班级,如 01班', phone VARCHAR(20) NULL COMMENT '联系电话', blood_type VARCHAR(4) NULL COMMENT '血型 A/B/AB/O', allergy_history TEXT NULL COMMENT '过敏史,逗号分隔', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_grade_class (grade, class_name) ) ENGINE=InnoDB COMMENT='学生基础信息表'; -- 健康体检记录表,一个学生多条记录 CREATE TABLE health_record ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL COMMENT '学生主键', check_date DATE NOT NULL COMMENT '体检日期', height DECIMAL(5,2) NOT NULL COMMENT '身高 cm', weight DECIMAL(5,2) NOT NULL COMMENT '体重 kg', vision_left DECIMAL(4,2) NULL COMMENT '左眼视力', vision_right DECIMAL(4,2) NULL COMMENT '右眼视力', systolic INT NULL COMMENT '收缩压 mmHg', diastolic INT NULL COMMENT '舒张压 mmHg', heart_rate INT NULL COMMENT '静息心率', diagnosis VARCHAR(500) NULL COMMENT '医生结论', doctor VARCHAR(50) NULL COMMENT '体检医生', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_date (student_id, check_date), KEY idx_check_date (check_date), CONSTRAINT fk_hr_student FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE ) ENGINE=InnoDB COMMENT='健康体检记录表'; -- 体质测试记录表,每学期一次 CREATE TABLE physical_test ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, test_date DATE NOT NULL COMMENT '测试日期', run_50m DECIMAL(4,2) NULL COMMENT '50米跑 秒', standing_jump DECIMAL(5,2) NULL COMMENT '立定跳远 cm', sit_and_reach DECIMAL(4,1) NULL COMMENT '坐位体前屈 cm', vital_capacity INT NULL COMMENT '肺活量 ml', pull_up INT NULL COMMENT '引体向上 次', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_pt_student_date (student_id, test_date), KEY idx_test_date (test_date), CONSTRAINT fk_pt_student FOREIGN KEY (student_id) REFERENCES student(id) ON DELETE CASCADE ) ENGINE=InnoDB COMMENT='体质测试记录表';

之所以都用InnoDB,是因为现在MySQL 8.0的默认引擎就是InnoDB,支持事务和外键,MyISAM已经没理由选了。体检日期和体测日期单独建索引,因为查询几乎都带日期范围,比如"查2025年秋季入学体检的所有记录"。DECIMAL(4,2)表示总位数4位、小数2位,视力最大能存到99.99,足够;DECIMAL(5,2)身高能到999.99厘米,不会溢出。过敏史用TEXT,但要注意TEXT字段不能有默认值,写入时应用层传空字符串而不是NULL。

建完表后建议顺手做一次校验,查一下表都落在哪个库、字符集对不对。我一般用这条命令确认:

SHOW CREATE TABLE student\G

看输出里的ENGINE=InnoDB和DEFAULT CHARSET=utf8mb4是否符合预期。如果是从旧库拷过来的表,最容易出问题的是字符集还是latin1,字段全是中文却显示乱码,这个在后面排错章节会再展开。另外,外键字段的索引不需要手动加,MySQL在创建外键时如果发现字段上没有索引,会自动补一个,这也是选InnoDB的一个顺手好处。

3. 用Java swing搭界面骨架:登录窗口、建档表单与SwingWorker的正确用法

3.1 Swing的层级与单线程规则:把JDBC放进事件线程会翻车

Swing界面有一套固定的层级:JFrame是顶层窗口,里面放contentPane,contentPane上摆JPanel,JPanel里再塞按钮、文本框、标签。布局管理器负责决定组件位置,常见选择是BorderLayout、GridLayout、GridBagLayout。HealthArchives这类管理系统的窗口基本可以只靠这三种布局完成,不需要使用绝对坐标布局,因为窗口在尺寸变化时布局管理器会自动重排,绝对定位反而会在高DPI屏上显示得一塌糊涂。

Swing有一个和普通Java程序完全不同的运行规则:所有组件创建和状态更新都必须在事件分发线程(EDT,Event Dispatch Thread)上做,包括new JFrame()、setText()、setVisible()。这是Swing自身的线程模型决定的,把界面操作放到其他线程会引发不确定的渲染问题。

新手常见的翻车写法是在main里直接new LoginFrame().setVisible(true),然后点登录按钮时在按钮回调里同步执行JDBC查询,查几千条学生记录回来,期间整个窗口拖不动、点任何按钮都没反应。这不是简单的性能问题,而是违反了Swing单线程规则:耗时操作阻塞了EDT,事件队列排队的重绘请求全部卡住。正确姿势是主入口用SwingUtilities.invokeLater,耗时查询放进SwingWorker后台线程。

3.2 登录窗口:从JFrame到登录校验的完整实现

登录窗口是HealthArchives的门面,也是把Swing最基本的组件走一遍的好例子。用JTextField接收用户名,JPasswordField接收密码,JButton触发校验。这里值得注意的点是JPasswordField,它没有getText(),只有getPassword()返回char[],取完要转成String再统计,原因是为了让密码在堆里的生命周期可控,虽然实际用的时候没几个人坚持这个习惯。

public class LoginFrame extends JFrame { private JTextField userField; private JPasswordField pwdField; private JButton loginBtn; private JLabel statusLabel; public LoginFrame() { super("HealthArchives 登录"); setSize(360, 220); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLocationRelativeTo(null); setResizable(false); JPanel form = new JPanel(new GridBagLayout()); GridBagConstraints gbc = new GridBagConstraints(); gbc.insets = new Insets(6, 8, 6, 8); gbc.gridx = 0; gbc.gridy = 0; form.add(new JLabel("用户名:"), gbc); gbc.gridx = 1; userField = new JTextField(15); form.add(userField, gbc); gbc.gridx = 0; gbc.gridy = 1; form.add(new JLabel("密码:"), gbc); gbc.gridx = 1; pwdField = new JPasswordField(15); form.add(pwdField, gbc); gbc.gridx = 0; gbc.gridy = 2; gbc.gridwidth = 2; loginBtn = new JButton("登录"); form.add(loginBtn, gbc); gbc.gridy = 3; statusLabel = new JLabel(" "); form.add(statusLabel, gbc); setContentPane(form); LoginAction action = new LoginAction(); loginBtn.addActionListener(action); pwdField.addActionListener(action); } private class LoginAction implements ActionListener { @Override public void actionPerformed(ActionEvent e) { String username = userField.getText().trim(); String password = new String(pwdField.getPassword()).trim(); // 耗时查询放入后台线程,避免阻塞 EDT 导致窗口白屏 new SwingWorker<Boolean, Void>() { @Override protected Boolean doInBackground() { return new AdminDao().checkLogin(username, DigestUtil.md5(password)); } @Override protected void done() { try { if (get()) { new MainFrame().setVisible(true); dispose(); } else { statusLabel.setText("用户名或密码错误"); } } catch (Exception ex) { statusLabel.setText("登录异常,请检查数据库连接"); } } }.execute(); } } }

上面这段代码有几处值得细说。pwdField.addActionListener(action)让用户在密码框里直接按回车就能触发登录,不用非得挪鼠标点按钮,这个交互细节很多课设都没做。new SwingWorker<Boolean, Void>()的doInBackground方法跑在后台线程,正好避开EDT;done方法切回EDT执行,界面更新安全。密码比较用的DigestUtil.md5只是拿来做演示,不要真的在正式系统里用裸MD5,它防不住字典攻击,至少要用加盐的SHA-256或BCrypt,但课设里写SQL验证时保留明文比对是另一回事。

登录校验的核心在AdminDao里,SQL按用户名和哈希后的密码查一行,查到返回true。这个DAO的写法放到第4章和第4章后面一起讲,界面层到这里已经能把登录流程闭环。

3.3 学生建档表单:GridLayout布局和表单校验落地

登录之后的主界面分两个区域:左侧是导航列表,右侧是内容面板。最常见做法是用JMenuBar做菜单栏,再用JSplitPane把窗口切成导航和内容两块。建档表单放在内容区,用BorderLayout做外层,中央部分用GridLayout(0, 2, 8, 8)排两列字段,底部放按钮栏。

public class StudentFormPanel extends JPanel { private JTextField studentNoField, nameField, phoneField; private JComboBox<String> genderBox; private StudentDao studentDao = new StudentDao(); public StudentFormPanel() { setLayout(new BorderLayout()); JPanel center = new JPanel(new GridLayout(0, 2, 8, 8)); center.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12)); center.add(new JLabel("学号:")); studentNoField = new JTextField(18); center.add(studentNoField); center.add(new JLabel("姓名:")); nameField = new JTextField(18); center.add(nameField); center.add(new JLabel("性别:")); genderBox = new JComboBox<>(new String[]{"M", "F"}); center.add(genderBox); center.add(new JLabel("联系电话:")); phoneField = new JTextField(18); center.add(phoneField); add(center, BorderLayout.CENTER); JButton saveBtn = new JButton("保存建档"); JPanel bottom = new JPanel(new FlowLayout(FlowLayout.RIGHT)); bottom.add(saveBtn); add(bottom, BorderLayout.SOUTH); saveBtn.addActionListener(e -> saveStudent()); } private void saveStudent() { String studentNo = studentNoField.getText().trim(); if (studentNo.isEmpty()) { JOptionPane.showMessageDialog(this, "学号不能为空", "校验", JOptionPane.WARNING_MESSAGE); return; } Student s = new Student(); s.setStudentNo(studentNo); s.setName(nameField.getText().trim()); s.setGender(String.valueOf(genderBox.getSelectedItem())); s.setPhone(phoneField.getText().trim()); try { studentDao.save(s); JOptionPane.showMessageDialog(this, "建档成功", "提示", JOptionPane.INFORMATION_MESSAGE); clearForm(); } catch (SQLException ex) { if (ex.getErrorCode() == 1062) { JOptionPane.showMessageDialog(this, "学号已存在", "冲突", JOptionPane.ERROR_MESSAGE); } else { JOptionPane.showMessageDialog(this, "保存失败: " + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); } } } }

GridLayout(0, 2, 8, 8)的第一参数为0表示行数自适应,列数固定为2,第三、第四参数是组件之间的水平和垂直间距。表单里放两列,标签一列、输入框一列,窗口拉伸时行高会自动扩展,符合建档表单从上到下阅读的节奏。性别用JComboBox限定输入范围,比TextField更省心。保存按钮回调里的错误捕获,对MySQL的Duplicate entry做了单独判断,错误码1062是唯一的冲突码,学号重复时提示更友好。

界面层到这里已经能完成学生建档的主要交互。剩下来的事情是让界面和MySQL真正打通。之前说的AdminDao、StudentDao不落地的话,按钮点了也白点,下一章就把数据库访问层完整补上。

4. 用JDBC把手脚伸进MySQL:连接驱动、DAO封装与批量导入健康记录

4.1 驱动与JDBC URL参数:时区、UTF-8、批量重写一个都不能少

Java连MySQL的官方路径是JDBC驱动,需要用对应的驱动类名和连接串。MySQL 5.7时代用com.mysql.jdbc.Driver,MySQL 8.0以后驱动换成了com.mysql.cj.jdbc.Driver,旧类名在8.0驱动里已经被移除。如果你的项目用的是mysql-connector-j 8.0.x,还写旧类名,运行时必定报ClassNotFoundException。

连接串参数是这个项目最容易被忽略的部分。直接写jdbc:mysql://127.0.0.1:3306/health_archives在MySQL 8.0下大概率报错,因为必须带时区参数。我常用的完整连接串如下:

public class DbUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/health_archives" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8" + "&rewriteBatchedStatements=true"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError( "请检查 mysql-connector-j 是否已加入 classpath"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

参数逐个说明。useSSL=false表示本地单机不启用SSL证书握手,省掉一串警告日志;serverTimezone=Asia/Shanghai解决时区差8小时的问题,不写这个参数MySQL 8.0直接拒绝连接;characterEncoding=utf8保证Java里的中文按UTF-8写进表,配合库表的utf8mb4才不乱码;rewriteBatchedStatements=true是批量导入提速的关键,它让驱动把多条INSERT自动拼成一条多VALUES语句发送,下一节的性能数据全靠这个参数撑住。

这个工具类按单机桌面系统的规模写已经够用。有人会问要不要上连接池,答案是暂时不用。连接池解决的是频繁建连的开销,但HealthArchives同时在线操作的人通常只有几个校医,每次操作建连、用毕关闭,延迟可以忽略。引入连接池反而要处理池参数配置、依赖引入,课设和校内工具都会被拖累。

4.2 DAO封装:为什么这个项目用PreparedStatement就够,不需要ORM

数据访问层最常见的选择是直接拼SQL、用MyBatis或Hibernate。HealthArchives这个规模我建议放弃ORM,原因是表结构固定、查询模式少,JDBC原生操作API足够。更关键的是Java手写JDBC能让人把SQL执行过程看清,出现问题时能定位到具体是哪一行。

PreparedStatement是唯一值得用的执行方式,不要用Statement拼SQL字符串。拼接的SQL有注入风险,比如在用户名文本框里输入' or '1'='1,拼出来就是恒真条件,直接把所有记录查出来。PreparedStatement用问号占位,参数由驱动转义,注入被天然挡住。

public class HealthRecordDao { public int insert(HealthRecord record) throws SQLException { String sql = "INSERT INTO health_record " + "(student_id, check_date, height, weight, vision_left, vision_right, " + " systolic, diastolic, heart_rate, diagnosis, doctor, create_time) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, record.getStudentId()); ps.setDate(2, java.sql.Date.valueOf(record.getCheckDate())); ps.setBigDecimal(3, record.getHeight()); ps.setBigDecimal(4, record.getWeight()); ps.setBigDecimal(5, record.getVisionLeft()); ps.setBigDecimal(6, record.getVisionRight()); ps.setInt(7, record.getSystolic()); ps.setInt(8, record.getDiastolic()); ps.setInt(9, record.getHeartRate()); ps.setString(10, record.getDiagnosis()); ps.setString(11, record.getDoctor()); return ps.executeUpdate(); } } public List<HealthRecord> findByStudentId(int studentId) throws SQLException { String sql = "SELECT * FROM health_record " + "WHERE student_id = ? ORDER BY check_date DESC"; List<HealthRecord> list = new ArrayList<>(); try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, studentId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { HealthRecord r = new HealthRecord(); r.setId(rs.getInt("id")); r.setStudentId(rs.getInt("student_id")); r.setCheckDate(rs.getDate("check_date").toLocalDate()); r.setHeight(rs.getBigDecimal("height")); r.setWeight(rs.getBigDecimal("weight")); r.setVisionLeft(rs.getBigDecimal("vision_left")); r.setVisionRight(rs.getBigDecimal("vision_right")); r.setSystolic(rs.getInt("systolic")); r.setDiastolic(rs.getInt("diastolic")); r.setHeartRate(rs.getInt("heart_rate")); r.setDiagnosis(rs.getString("diagnosis")); r.setDoctor(rs.getString("doctor")); list.add(r); } } } return list; } }

插值和查询用了try-with-resources,Connection、PreparedStatement、ResultSet都自动关闭,不用手写finally还容易写漏。java.sql.Date.valueOf(record.getCheckDate())是把Java 8 LocalDate转成JDBC日期的标准做法,反过来查出来的rs.getDate("check_date").toLocalDate()取回LocalDate。视线里nullable字段用getBigDecimal和getInt时驱动可能返回null,最好在实体类里用包装类型Integer、BigDecimal,避免自动拆箱产生NullPointerException。

4.3 批量导入5000条体检数据:事务与批处理参数

开学季最常发生的操作是拿Excel批量导入全校体检表。如果逐条调insert,5000条记录要发出5000次完整的请求响应往返,一个慢的网络环境下要几十秒;用JDBC批处理可以在事务里连续执行,配合第4.1节里提到的rewriteBatchedStatements参数,速度能缩短到一两秒。

public void batchInsert(List<HealthRecord> records) throws SQLException { String sql = "INSERT INTO health_record " + "(student_id, check_date, height, weight, vision_left, vision_right, " + " systolic, diastolic, heart_rate, diagnosis, doctor, create_time) " + "VALUES (?,?,?,?,?,?,?,?,?,?,?,NOW())"; try (Connection conn = DbUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement(sql)) { int count = 0; for (HealthRecord r : records) { ps.setInt(1, r.getStudentId()); ps.setDate(2, java.sql.Date.valueOf(r.getCheckDate())); ps.setBigDecimal(3, r.getHeight()); ps.setBigDecimal(4, r.getWeight()); ps.setBigDecimal(5, r.getVisionLeft()); ps.setBigDecimal(6, r.getVisionRight()); ps.setInt(7, r.getSystolic()); ps.setInt(8, r.getDiastolic()); ps.setInt(9, r.getHeartRate()); ps.setString(10, r.getDiagnosis()); ps.setString(11, r.getDoctor()); ps.addBatch(); count++; if (count % 500 == 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } } }

这段代码有三个关键参数行为。conn.setAutoCommit(false)把自动提交关掉,让所有插入在同一个事务里,任何一条失败都能回滚,不会出现导入一半留下脏数据的局面。每500条执行一次executeBatch并commit,是事务日志大小和单次网络往返的折中,5000条记录分10批次提交,既不撑爆内存,也不至于让事务过大。rewriteBatchedStatements参数在这里起作用,它让驱动把500条INSERT重写为一条多VALUES的INSERT,网络开销降低一个数量级。

还有一个隐藏坑是MySQL的max_allowed_packet,批量插入的SQL文本长度超过这个值会报错。默认值一般是64MB,对这5000条管理记录完全够用,但如果导入几万条还不分批,很可能踩到。批量导入的批次不建议超过1000条,500条是最稳的平衡点。

5. HealthArchives避坑实录:从ClassNotFoundException到假死卡顿的5个排错案例

5.1 ClassNotFoundException: com.mysql.cj.jdbc.Driver

**现象:**点登录按钮后,控制台弹出ClassNotFoundException: com.mysql.cj.jdbc.Driver,窗口还在,但登录没有任何反应。

**原因:**mysql-connector-j的jar包没有加入classpath。常见于Eclipse里引用了Libraries但打包成jar时没带进去,或者项目里装的是老驱动,类名还写着com.mysql.jdbc.Driver,而驱动包却是8.x。还有一种情况是maven依赖没刷新,本地仓库里下载失败。

**解决:**先在IDE里检查项目的依赖列表,确认mysql-connector-j版本存在。如果用的是IntelliJ IDEA,File → Project Structure → Modules → Dependencies里看有没有带mysql关键字的jar。手动下载驱动时要把jar文件放到项目根目录的lib文件夹,然后右键Add as Library。驱动类名也一并核对:8.x必须用com.mysql.cj.jdbc.Driver,6.x和后面的版本用com.mysql.jdbc.Driver。最简单的自查命令是在Java代码里打印Class.forName的完整包名,对得上再说。

5.2 学生姓名写进MySQL变成???乱码

**现象:**通过swing窗口录入"张三",打开Navicat看到的是"???",或者反过来,数据库里是中文,界面上显示乱码。

**原因:**三层编码不一致。建库时如果用了latin1,中文字节存进去就只能显示问号;JDBC连接串没带characterEncoding=utf8,Java这边发过去的字符被按ISO-8859-1解释;还有IDEA控制台默认GBK,看日志时误以为数据存错了。

**解决:**先确认MySQL的全局字符集,执行SHOW VARIABLES LIKE 'character_set_server';,如果结果不是utf8mb4,需要在my.cnf里配置character-set-server=utf8mb4后重启服务。已经建错的表可以用ALTER TABLE student CONVERT TO CHARACTER SET utf8mb4;转换。JDBC连接串保持4.1节的样子,characterEncoding=utf8必须写上。IDEA里在VM options加-Dfile.encoding=UTF-8,保证编译期字符集一致。我自己的排查顺序是:先看数据库存储的是不是正常中文,再看应用层打印的日志,最后统一连接串参数,绝大多数乱码问题都是这三者之间不统一。

5.3 点登录后窗口假死白屏

**现象:**点登录按钮后,整个窗口拖不动,标题栏变白,过几秒才恢复,偶尔直接无响应。

**原因:**登录校验的JDBC查询跑在了事件分发线程上。点击事件在EDT里执行,执行体不结束,界面线程就无法处理重绘、鼠标移动这些事件。数据库查询如果卡了DNS解析或连接超时,窗口就僵在那里。这是swing项目最容易翻车的线程问题,也是面试题里常问的Swing单线程规则的实际案例。

**解决:**把耗时操作放到SwingWorker后台线程,参照3.2节的做法。关键原则是doInBackground里不碰任何UI组件,所有组件更新只能在done或process方法里做。改完后还有个检验技巧:查询期间试着拖动窗口,如果能正常拖动说明没有卡EDT。如果查询本身很快,感觉不到差别,可以把线程sleep个500毫秒验证,能拖动就说明线程模型命中了。

5.4 Communications link failure与MySQL服务未启动

**现象:**程序运行很久后第一次查询抛Communications link failure,或者一启动程序就报连接被拒。有时在Windows上执行net start mysql,提示服务正在启动随后又停止。

**原因:**MySQL默认wait_timeout是8小时,连接客户端空闲太久,服务端已主动断开连接,本地持有的旧连接还在用,自然报连接失效。如果连本机的MySQL都连不上,多半是服务根本没启动,或者MySQL 8.0在Windows上安装后服务名不叫mysql而叫MySQL80,net start写错名字也会失败。

**解决:**HealthArchives这种单机小系统,不需要连接池保活,每次用DbUtil.getConnection新建、用毕关闭就是最可靠的方式。Windows下在服务面板找到MySQL服务,确认已启动;命令行可以用net start MySQL80指定准确的Windows服务名。如果连接超时比较频繁,在连接串后面加&connectTimeout=3000&socketTimeout=5000,让失败快速暴露而不是卡几分钟。wait_timeout不用特意改大,每次用新连接反而更省心。

5.5 体检记录重复导入,统计翻倍

**现象:**同一批Excel数据导入两次后,某个学生的体检记录变成两份,查询体重均值、近视率时数据明显比实际多。

**原因:**导入程序没有先查重,建表时也没加唯一约束。第一遍导入后没有检查student_id和check_date是否已经存在,第二遍就会把同一个体检日期的记录再插一遍。

**解决:**建表脚本里的UNIQUE KEY uk_student_date (student_id, check_date)就是为这个场景设计的。加了之后第二次插入会抛Duplicate entry '1-2025-09-10' for key 'uk_student_date',错误码1062。应用层捕获这个异常后可以提示"该批次已导入",也可以改成INSERT ... ON DUPLICATE KEY UPDATE做覆盖更新。后者的写法是把体检指标字段重新赋值,适用于"重复导入时用最新数据覆盖"的业务规则。我倾向在批量导入前先按(student_id, check_date)过滤掉已存在的记录,干净利落,数据库里也不会产生无谓的更新日志。

6. HealthArchives进阶:EXPLAIN验证索引、导出CSV与mysqldump备份习惯

6.1 用EXPLAIN确认查询走了哪条索引

建档和导入完成后,最常跑的查询是"按年级班级查一段时间内的体检记录"。这类语句如果没走索引,数据量到几万条就会明显变慢。我用EXPLAIN验证执行计划:

EXPLAIN SELECT s.name, h.check_date, h.height, h.weight FROM health_record h JOIN student s ON h.student_id = s.id WHERE s.grade = '2024级' AND h.check_date BETWEEN '2025-09-01' AND '2025-09-30' ORDER BY h.check_date;

看输出里的type列和key列。type如果是ALL就是全表扫描;如果出现index或者range,说明用上了idx_check_date或外键索引。排序在数据量大时也要盯一眼,ORDER BY的字段顺序要和索引顺序一致,否则MySQL会生成临时文件排序。之前建表时给grade、class_name建过复合索引,给check_date建过单列索引,大部分查询都能命中,这是设计阶段省下的后期优化。

6.2 体检数据导出CSV:BOM是Excel不乱码的关键

查询结果导出成CSV给班主任发过去是刚需。用Java直出CSV比引入POI轻量得多,唯一要注意的是Excel打开UTF-8编码的CSV文件时会按本地编码解析,中文会乱。解决办法是先写一个UTF-8的BOM头:

public void exportToCsv(List<HealthRecord> records, File target) throws IOException { try (OutputStreamWriter fw = new OutputStreamWriter( new FileOutputStream(target), StandardCharsets.UTF_8)) { fw.write('\ufeff'); // BOM,让 Excel 识别 UTF-8 编码 fw.write("学号,姓名,检查日期,身高,体重,视力左,视力右\r\n"); for (HealthRecord r : records) { fw.write(String.format("%s,%s,%s,%.2f,%.2f,%s,%s\r\n", r.getStudentNo(), r.getName(), r.getCheckDate(), r.getHeight(), r.getWeight(), r.getVisionLeft(), r.getVisionRight())); } } }

\ufeff在文件最前面写入字节顺序标记,Excel拿到这个签名会主动用UTF-8打开。记录之间用\r\n回车换行是CSV的标准行结束符,避免在Windows下Excel认为所有内容挤在一行里。如果诊断字段里可能包含逗号或引号,导出时要用双引号包起来,这是CSV的老坑,字段内容里有逗号时Excel会把后半段当成下一列。

6.3 给系统留后悔药:mysqldump定时备份

单机系统的数据说丢就丢,硬盘坏、误删表、DATABASE被重置的教训我见过太多次。HealthArchives不用上复杂的备份方案,mysqldump就够:

mysqldump -uroot -p --single-transaction \ --default-character-set=utf8mb4 health_archives \ > /backup/health_archives_$(date +%Y%m%d_%H%M%S).sql

--single-transaction保证在备份过程中不锁表,导出的数据一致性对InnoDB有效。--default-character-set=utf8mb4让备份文件里的中文在恢复时不出乱码。恢复时执行mysql -uroot -p health_archives < backup.sql就能完整还原。Windows上用任务计划程序定时跑一个bat,Linux下用crontab,每天凌晨备份一次。做了备份之后,操作数据库前手一抖也敢大胆试了。

做这类系统的最大教训,是解决新需求永远比修旧问题省力。建表时多写一个唯一键、连接串上认真配好编码和时区、耗时操作一律丢给SwingWorker,这些习惯一开始就养好,后面不会在开学季手忙脚乱。希望帮到你。

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

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

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

立即咨询