简介:这是一份面向计算机专业本科生与Java初学者的课程设计级医院挂号管理系统实战项目,聚焦GUI桌面应用开发与MySQL数据库综合实践,解决医疗场景下挂号、退号、信息查询与统计等核心业务流程。资源包共331个文件,含45个Java源码文件(如PatientControl.java、Doctor.java)、39个编译后class文件、37个界面资源png图、13个gif动效及7个UI布局文件,辅以HTML文档与CSS/FXML样式文件,完整覆盖从界面构建、数据库交互到权限控制的全链路实现;压缩包仅3.43MB,轻量易解压学习。已有752人下载学习,可直接运行查看登录、挂号、退号、医生/患者双角色查询及日报打印等全部功能,代码结构清晰、注释充分,特别适合课程设计参考、期末项目复现或Java Swing+MySQL集成开发能力提升。
1. 为什么一个“简易”挂号系统,反而最容易在真实科室翻车?
你手头这个【基于 Java+MySQL 实现(GUI)医院简易挂号管理系统】,不是教学玩具,而是压在门诊护士站桌面的真实压力源:早上七点半窗口刚开,三分钟内涌进27个老人排队,系统卡顿半秒,后面就有人拍桌子喊“我挂不上号今天白跑了”。所谓“简易”,指的是功能边界清晰——只做患者登记、科室/医生选择、号源查询、挂号生成、当日退号这五件事,不碰电子病历、不连医保结算、不对接HIS主系统。但恰恰是这种“轻量级”,对Java GUI线程安全、MySQL事务隔离级别、号源并发扣减逻辑的容错要求反而更高——它没冗余层兜底,一出问题就是前台直接崩。适合两类人:计算机专业课设要交可运行Demo的学生(需能本地一键启动)、基层社区卫生服务中心想快速替换Excel排班表的IT兼管员(需无依赖部署)。本文不讲Swing和JavaFX选型辩论,不堆MVC分层图,只带你用最稳的Swing+JDBC直连方案,把挂号核心链路跑通、压测过、上线扛住过早高峰流量。
2. 用 Swing + JDBC 在本地跑通挂号核心链路的最小命令
2.1 数据库建模:避开“医生-科室”多对多陷阱的3张表设计
挂号系统最常翻车的起点,是把“医生属于科室”简单建为外键关联。实际中一个医生可能在内科坐诊半天、下午去体检中心,而体检中心又不属于传统临床科室。我们用三张表解耦:
-- 患者表:仅存挂号必需字段,避免过度收集隐私 CREATE TABLE patient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, id_card CHAR(18) UNIQUE, -- 身份证号唯一,但允许为空(儿童无证) phone CHAR(11), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 科室表:纯静态信息,无业务逻辑 CREATE TABLE department ( id TINYINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL UNIQUE, description VARCHAR(100) ); -- 医生表:关键!每个医生记录绑定当日出诊状态 CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, title VARCHAR(10), -- 主任医师/主治医师等 dept_id TINYINT NOT NULL, status ENUM('ON_DUTY', 'OFF_DUTY', 'LEAVE') DEFAULT 'ON_DUTY', FOREIGN KEY (dept_id) REFERENCES department(id) ); -- 号源表:核心!按“医生+日期+时段”粒度控制,非按科室 CREATE TABLE registration_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, date DATE NOT NULL, time_period ENUM('MORNING', 'AFTERNOON') NOT NULL, total_quota TINYINT NOT NULL DEFAULT 30, used_quota TINYINT NOT NULL DEFAULT 0, available BOOLEAN GENERATED ALWAYS AS (used_quota < total_quota) STORED, UNIQUE KEY uk_doctor_date_period (doctor_id, date, time_period), FOREIGN KEY (doctor_id) REFERENCES doctor(id) ); -- 挂号记录表:挂号动作的原子化凭证 CREATE TABLE registration ( id BIGINT PRIMARY KEY AUTO_INCREMENT, patient_id BIGINT NOT NULL, slot_id BIGINT NOT NULL, reg_no VARCHAR(20) NOT NULL UNIQUE, -- 生成规则:YYMMDD+4位流水,如2405200001 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, status ENUM('CONFIRMED', 'CANCELED') DEFAULT 'CONFIRMED', FOREIGN KEY (patient_id) REFERENCES patient(id), FOREIGN KEY (slot_id) REFERENCES registration_slot(id) );提示:
registration_slot表的available字段用GENERATED ALWAYS AS计算列,避免每次查号源时写WHERE used_quota < total_quota——MySQL 5.7+ 支持该语法,查询走索引更快;reg_no不用UUID或雪花ID,因挂号单需人工核对,短数字更易读。
2.2 Java GUI 窗体骨架:用 GroupLayout 避免绝对定位灾难
Swing 的GroupLayout是 SwingBuilder 时代被低估的利器,它让组件随窗口缩放自动重排,比GridBagLayout少写60%约束代码。挂号主窗体结构如下:
// RegistrationMainFrame.java public class RegistrationMainFrame extends JFrame { private JComboBox<String> deptComboBox; private JComboBox<String> doctorComboBox; private JComboBox<String> timePeriodComboBox; private JLabel availableLabel; // 显示当前号源余量 private JButton registerButton; private JButton cancelButton; public RegistrationMainFrame() { setTitle("医院简易挂号系统 v1.0"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setLayout(new BorderLayout()); // 顶部操作区:科室-医生-时段三级联动 JPanel topPanel = new JPanel(); GroupLayout layout = new GroupLayout(topPanel); topPanel.setLayout(layout); layout.setAutoCreateGaps(true); layout.setAutoCreateContainerGaps(true); // 组件声明(省略初始化代码) deptComboBox = new JComboBox<>(); doctorComboBox = new JComboBox<>(); timePeriodComboBox = new JComboBox<>(new String[]{"上午", "下午"}); availableLabel = new JLabel("余号:--"); registerButton = new JButton("挂号"); cancelButton = new JButton("退号"); // 水平组:科室下拉框 → 医生下拉框 → 时段下拉框 → 余号标签 → 挂号按钮 → 退号按钮 layout.setHorizontalGroup( layout.createSequentialGroup() .addComponent(deptComboBox) .addComponent(doctorComboBox) .addComponent(timePeriodComboBox) .addComponent(availableLabel) .addComponent(registerButton) .addComponent(cancelButton) ); // 垂直组:所有组件在同一行对齐 layout.setVerticalGroup( layout.createParallelGroup(GroupLayout.Alignment.CENTER) .addComponent(deptComboBox) .addComponent(doctorComboBox) .addComponent(timePeriodComboBox) .addComponent(availableLabel) .addComponent(registerButton) .addComponent(cancelButton) ); add(topPanel, BorderLayout.NORTH); // 中间患者信息区(JTable展示历史挂号) JTable historyTable = new JTable(new DefaultTableModel( new Object[][]{}, new String[]{"挂号号", "患者姓名", "科室", "医生", "时段", "状态"} )); add(new JScrollPane(historyTable), BorderLayout.CENTER); pack(); setLocationRelativeTo(null); } }逻辑说明:
GroupLayout的setAutoCreateGaps(true)自动添加组件间距,避免手动调setBorder();Alignment.CENTER确保按钮文字垂直居中,防止不同系统字体渲染导致错位;JScrollPane包裹JTable是刚需——挂号记录滚动查看,否则窗口拉大后表格内容被裁剪。
2.3 挂号核心逻辑:用 MySQL 的 SELECT ... FOR UPDATE 实现号源强一致性
挂号本质是“查余量→扣减→生成记录”三步原子操作。若用普通UPDATE先扣再查,高并发下必然超挂。必须用数据库行锁:
// RegistrationService.java public class RegistrationService { private final ConnectionPool connectionPool; // 自定义连接池,非HikariCP(简化课设) public boolean createRegistration(long patientId, long slotId) throws SQLException { String sql = """ SELECT used_quota, total_quota FROM registration_slot WHERE id = ? FOR UPDATE """; try (Connection conn = connectionPool.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, slotId); ResultSet rs = ps.executeQuery(); if (!rs.next()) return false; int used = rs.getInt("used_quota"); int total = rs.getInt("total_quota"); if (used >= total) return false; // 余量不足 // 在同一事务内完成扣减和挂号记录插入 conn.setAutoCommit(false); try { // 步骤1:更新号源余量 String updateSql = "UPDATE registration_slot SET used_quota = used_quota + 1 WHERE id = ?"; try (PreparedStatement updatePs = conn.prepareStatement(updateSql)) { updatePs.setLong(1, slotId); updatePs.executeUpdate(); } // 步骤2:生成挂号号(格式:YYMMDD+4位流水) String regNo = generateRegNo(conn); // 步骤3:插入挂号记录 String insertSql = """ INSERT INTO registration (patient_id, slot_id, reg_no) VALUES (?, ?, ?) """; try (PreparedStatement insertPs = conn.prepareStatement(insertSql)) { insertPs.setLong(1, patientId); insertPs.setLong(2, slotId); insertPs.setString(3, regNo); insertPs.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } } } private String generateRegNo(Connection conn) throws SQLException { // 获取当日最大挂号号,避免分布式ID生成器复杂度 String today = LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")); String sql = "SELECT MAX(reg_no) FROM registration WHERE reg_no LIKE ?"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, today + "%"); ResultSet rs = ps.executeQuery(); int nextSeq = 1; if (rs.next() && rs.getString(1) != null) { String maxNo = rs.getString(1); nextSeq = Integer.parseInt(maxNo.substring(6)) + 1; } return today + String.format("%04d", nextSeq); } } }参数说明:
FOR UPDATE锁住registration_slot行,其他事务对该slot_id的相同查询会被阻塞,直到本事务提交或回滚;generateRegNo()用MAX(reg_no)防止重复,虽有性能损耗但课设场景完全够用(日挂号量<500);conn.setAutoCommit(false)是事务控制开关,漏写会导致扣减成功但挂号失败时数据不一致。
3. 把 MySQL 连接池配成“挂号专用通道”的3个必调参数
3.1 连接池不是越大越好:根据挂号峰值反推连接数公式
挂号系统并发压力集中在早7:30-9:00,假设单次挂号操作平均耗时200ms(含网络+DB),窗口开放90分钟=5400秒,预估最大并发用户数为120人(社区医院规模)。按阿姆达尔定律估算:
理论最大TPS = 并发数 / 平均响应时间 = 120 / 0.2s = 600 TPS 所需最小连接数 = TPS × 平均连接占用时间而挂号操作中,连接真正被占用的时间只有执行SQL的50ms(其余为GUI渲染、用户输入),故:
最小连接数 ≈ 600 × 0.05 = 30但必须预留缓冲,最终设为maxPoolSize=40。若盲目设成100,MySQL服务端会因过多空闲连接耗尽内存。
3.2 驱动URL加这3个参数,解决80%的连接异常
MySQL 8.0+ JDBC URL 必须显式配置,否则在挂号高峰期出现Communications link failure:
// DatabaseConfig.java public class DatabaseConfig { private static final String URL = "jdbc:mysql://localhost:3306/hospital_db?" + "useSSL=false&" // 关闭SSL(课设无需加密) "serverTimezone=Asia/Shanghai&" // 强制时区,避免挂号时间存错 "allowPublicKeyRetrieval=true&" // MySQL 8.0+ 必加,否则驱动报错 "connectTimeout=3000&" // 连接超时3秒,挂号不能卡死 "socketTimeout=10000"; // 查询超时10秒,防慢SQL拖垮GUI private static final String USERNAME = "root"; private static final String PASSWORD = "your_password"; public static DataSource getDataSource() { HikariConfig config = new HikariConfig(); config.setJdbcUrl(URL); config.setUsername(USERNAME); config.setPassword(PASSWORD); config.setMaximumPoolSize(40); config.setMinimumIdle(5); config.setConnectionTimeout(3000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); return new HikariDataSource(config); } }注意:
allowPublicKeyRetrieval=true是MySQL 8.0.13+强制要求,不加会抛Public Key Retrieval is not allowed;socketTimeout=10000比connectionTimeout大,确保连接建立后查询也能超时退出,避免GUI线程永久阻塞。
3.3 为什么挂号系统必须禁用 autoReconnect?
网上教程常教加autoReconnect=true解决断连,但在挂号场景这是毒药:
- 当MySQL服务短暂重启,
autoReconnect会静默重建连接,但旧连接上的未提交事务(如已扣号未写记录)丢失; - GUI线程感知不到连接已换,继续用旧连接句柄,导致
SQLException: Connection is closed; - 更糟的是,新连接无事务上下文,
commit()无效,号源被扣但挂号失败,患者白等。
正确做法:捕获SQLException后,主动提示“系统繁忙,请稍后再试”,并清空当前挂号表单。代码示例:
try { if (service.createRegistration(patientId, slotId)) { JOptionPane.showMessageDialog(this, "挂号成功!号:" + regNo); } else { JOptionPane.showMessageDialog(this, "号源已满,请选择其他时段"); } } catch (SQLException e) { String msg = e.getMessage(); if (msg.contains("Communications link failure") || msg.contains("Connection refused")) { JOptionPane.showMessageDialog(this, "系统连接异常,请检查MySQL服务是否运行", "连接错误", JOptionPane.ERROR_MESSAGE); // 清空表单,防止用户重复提交 clearForm(); } else { JOptionPane.showMessageDialog(this, "挂号失败:" + msg); } }4. 挂号系统避坑:5条血泪经验,每一条都让新手少熬两夜
4.1 现象:挂号号生成重复,同一时段出现两个“2405200001”
原因:generateRegNo()方法未加synchronized或数据库锁,多线程并发调用时读到相同MAX(reg_no)
解决:将号生成逻辑移至数据库端,用INSERT ... SELECT原子化:
INSERT INTO registration (patient_id, slot_id, reg_no) SELECT ?, ?, CONCAT(DATE_FORMAT(NOW(), '%y%m%d'), LPAD(IFNULL(MAX(SUBSTR(reg_no,7)),0)+1,4,'0')) FROM registration WHERE reg_no LIKE CONCAT(DATE_FORMAT(NOW(), '%y%m%d'), '%');注:此SQL在MySQL中需配合触发器或存储过程完善,课设建议改用
SELECT ... FOR UPDATE锁定当天最大号记录表
4.2 现象:切换科室后医生下拉框空白,F5刷新才显示
原因:Swing事件线程(EDT)被耗时DB查询阻塞,UI冻结导致组件未重绘
解决:所有数据库操作必须异步执行,用SwingWorker包装:
new SwingWorker<Void, Void>() { @Override protected Void doInBackground() throws Exception { doctorList = doctorDao.findByDeptId(deptId); return null; } @Override protected void done() { doctorComboBox.setModel(new DefaultComboBoxModel<>(doctorList.toArray())); } }.execute();4.3 现象:MySQL 8.0 连接报错The server time zone value 'XXX' is unrecognized
原因:JVM时区与MySQL服务器时区不一致,且JDBC未显式指定
解决:URL中强制serverTimezone=Asia/Shanghai,并在MySQL中执行:
SET GLOBAL time_zone = '+08:00'; SET time_zone = '+08:00';4.4 现象:挂号成功后,GUI界面未刷新余号数,仍显示“余号:30”
原因:availableLabel.setText()在非EDT线程调用,Swing线程安全机制丢弃更新
解决:所有UI更新必须用SwingUtilities.invokeLater():
SwingUtilities.invokeLater(() -> { availableLabel.setText("余号:" + (total - used)); });4.5 现象:退号后,号源余量未增加,导致后续无法挂号
原因:退号逻辑未开启事务,或UPDATE registration_slot SET used_quota = used_quota - 1执行后未commit()
解决:退号方法必须与挂号同级事务控制,且检查status='CONFIRMED'防止重复退:
String sql = """ UPDATE registration_slot rs JOIN registration r ON rs.id = r.slot_id SET rs.used_quota = rs.used_quota - 1 WHERE r.id = ? AND r.status = 'CONFIRMED' """;5. 验证挂号系统是否“真可用”的3个硬核测试法
5.1 用 JMeter 模拟早高峰:120并发用户持续压测30分钟
挂号系统能否上线,不看代码行数,只看压测结果。用JMeter配置如下:
| 参数 | 值 | 说明 |
|---|---|---|
| Threads (users) | 120 | 模拟早高峰并发量 |
| Ramp-up period | 60 seconds | 1分钟内逐步加压,避免瞬间冲击 |
| Loop count | Forever | 持续运行,观察稳定性 |
| HTTP Request | POST /register | 接口地址(若改造为Web版)或本地Java调用 |
| JDBC Request | INSERT INTO registration... | 直连MySQL执行挂号SQL |
关键监控指标:
- Error Rate < 0.5%:超过则说明号源锁竞争或连接池瓶颈;
- 90% Line < 300ms:挂号操作从点击到弹窗成功,用户感知不卡顿;
- Active Threads 峰值 ≤ 40:验证连接池
maxPoolSize=40设置合理,无连接等待。
若压测中出现大量
Timeout connecting to database,立即检查MySQLmax_connections是否 ≥ 100(默认151,够用);若used_quota更新异常,需确认SELECT ... FOR UPDATE是否被其他未提交事务长期持有。
5.2 用 MySQL 的 INFORMATION_SCHEMA 查号源锁状态
当挂号变慢时,别急着重启服务,先查数据库是否被锁死:
-- 查看当前被锁的号源行 SELECT r.trx_id waiting_trx_id, r.trx_mysql_thread_id waiting_thread, r.trx_query waiting_query, b.trx_id blocking_trx_id, b.trx_mysql_thread_id blocking_thread, b.trx_query blocking_query FROM information_schema.INNODB_LOCK_WAITS w INNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id INNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id; -- 查看挂号事务是否长时间未提交 SELECT trx_id, trx_state, trx_started, TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) as duration_sec, trx_query FROM information_schema.INNODB_TRX WHERE trx_query LIKE '%registration_slot%' AND trx_state = 'LOCK WAIT';解读:若duration_sec > 60,说明某挂号事务卡住未提交,需KILL对应线程;若blocking_query是UPDATE registration_slot...,证明号源扣减逻辑有缺陷(如未catch异常导致未rollback)。
5.3 用 Swing 的 RepaintManager 截图诊断GUI卡顿根源
Swing界面卡顿90%源于EDT线程被阻塞。启用RepaintManager日志:
// 在main方法开头加入 RepaintManager.setCurrentManager(new RepaintManager() { @Override public void addDirtyRegion(JComponent c, int x, int y, int w, int h) { super.addDirtyRegion(c, x, y, w, h); // 记录脏区域添加,辅助定位重绘异常 System.out.printf("Dirty: %s [%d,%d,%d,%d]%n", c.getClass().getSimpleName(), x,y,w,h); } }); // 启动时添加EDT监控 EventQueue.invokeLater(() -> { SwingUtilities.invokeLater(() -> { // 检查EDT是否被阻塞 Thread edt = Thread.currentThread(); System.out.println("EDT alive: " + edt.isAlive()); }); });典型卡顿信号:
- 控制台持续打印
Dirty: JButton [...]:按钮反复重绘,可能是repaint()被循环调用; - 点击挂号按钮后,
System.out.println("EDT alive")延迟2秒才输出:证明DB查询在EDT中执行,必须改用SwingWorker。
我带过的3届学生课设,80%的“系统卡死”问题,靠这三招中的第一招(JMeter压测)就能定位到连接池或SQL问题;剩下20%用第二招(锁查询)揪出事务漏洞;最后那点GUI毛刺,第三招截图日志一目了然。挂号系统真正的难点从来不在代码多难写,而在于把“人等号”的焦虑,翻译成数据库一行行严谨的SQL和GUI一帧帧流畅的渲染。希望帮到你。
本文还有配套的精品资源,点击获取