简介这是一套面向Java Web初学者的在线医疗预约挂号管理系统源码采用JSPMySQL技术栈实现适合课程设计、毕业设计或JSP框架入门实践。系统按管理员、用户、医生三类角色划分权限管理员可查看预约折线图、管理医生/患者/科室信息并处理排班申请用户能浏览科室列表、在线预约并接收验证码邮件医生则可登录查看排班与患者队列。源码压缩包共206个文件、约1010KB其中72个JSP页面负责前端展示55个Java类处理业务逻辑与数据库操作72个PNG图片用于界面素材另含HTML、properties配置及Markdown说明文档目录结构清晰便于按模块拆解学习。这套系统覆盖面完整从前端页面到后端数据访问均有对应实现适合作为JSP Web开发入门到进阶的参考项目。目前已有53人浏览学习适合想通过完整项目理解JSP、Servlet、MySQL交互及角色权限管理的开发者一次下载即可获得可直接运行的全套代码与配置。1. 基于JSPMySQL的在线医疗预约挂号系统老技术栈为什么还值得写在线医疗预约挂号系统的难点不在页面展示而在“医生—日期—时段—余号”这条数据链查询是只读的预约提交却要处理并发冲突。JSP MySQL 的组合至今仍是课程设计、源码站和中小医院院内挂号的常见选型JSP 管多页面流转MySQL 用唯一索引和行锁把“同一排班不超卖”固化在数据库层。完整项目通常覆盖三类角色用户注册登录、按科室查医生、预约与取消管理员维护科室、医生和排班系统本身要保证余号准确。开发难点集中在建表设计、事务边界和部署版本匹配页面反而是最轻的部分。能写透这个系统JSP/Servlet、JDBC、MySQL 三样基础基本就达标了。下面按落地顺序展开先立表结构再实现挂号主流程接着用 Tomcat 部署排错最后做并发验证与参数调整。2. MySQL 端设计科室、医生、排班、预约四张表怎么挂建库的第一步不是写代码而是把主数据关系画清楚。科室是一级分类医生挂在科室下排班挂在医生下预约单挂在排班上用户单独一张表。这样下来一共五张表t_dept、t_doctor、t_user、t_schedule、t_appointment。很多源码项目把“可约日期”直接塞进医生表用一个逗号分隔的字符串存多个日期这种做法在查询余号、判断时段时非常被动后面每加一个功能都要拆字符串。不如一次把排班表建好。2.1 建表脚本关键约束写在库上而不是只写在代码里先给出可直接执行的建库建表脚本默认存储引擎 InnoDB字符集 utf8mb4CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE hospital_db; CREATE TABLE t_dept ( id INT AUTO_INCREMENT PRIMARY KEY, dept_name VARCHAR(50) NOT NULL, dept_desc VARCHAR(255) ) ENGINEInnoDB; CREATE TABLE t_doctor ( id INT AUTO_INCREMENT PRIMARY KEY, dept_id INT NOT NULL, doctor_name VARCHAR(50) NOT NULL, title VARCHAR(30) COMMENT 职称主任医师、副主任医师等, reg_fee DECIMAL(6,2) DEFAULT 0.00, intro VARCHAR(500), KEY idx_dept_id (dept_id), CONSTRAINT fk_doctor_dept FOREIGN KEY (dept_id) REFERENCES t_dept(id) ) ENGINEInnoDB; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), id_card VARCHAR(18), UNIQUE KEY uk_username (username) ) ENGINEInnoDB; CREATE TABLE t_schedule ( id INT AUTO_INCREMENT PRIMARY KEY, doctor_id INT NOT NULL, work_date DATE NOT NULL, time_slot VARCHAR(10) NOT NULL COMMENT 上午/下午, total_num INT NOT NULL DEFAULT 20, remain_num INT NOT NULL DEFAULT 20, UNIQUE KEY uk_doctor_date_slot (doctor_id, work_date, time_slot), CONSTRAINT fk_schedule_doctor FOREIGN KEY (doctor_id) REFERENCES t_doctor(id) ) ENGINEInnoDB; CREATE TABLE t_appointment ( id INT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, schedule_id INT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待就诊 1已完成 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), CONSTRAINT fk_appoint_user FOREIGN KEY (user_id) REFERENCES t_user(id), CONSTRAINT fk_appoint_schedule FOREIGN KEY (schedule_id) REFERENCES t_schedule(id) ) ENGINEInnoDB;脚本里最值得说的是 t_schedule 和 t_appointment 的拆分。为什么不在 t_doctor 上直接存“哪天可约”因为一位医生一天可以有上午、下午两套班次班次对应不同的总号数和余号如果只在医生表上加一个日期字段等于把一维数据强行压缩成一个字符串字段后续统计、筛选都会失控。t_schedule 就是“医生在某天某时段放了多少号”的最小单位。t_schedule 上的唯一键uk_doctor_date_slot(doctor_id, work_date, time_slot)是本库最重要的约束之一。管理员在后台重复录入同一医生的同一班次时数据库会直接拒绝。t_appointment 不直接冗余 doctor_id、work_date、time_slot而是通过 schedule_id 与排班表关联好处是改排班时间时不需要同步海量预约单。用户一次预约对应一条 t_appointment 记录order_no 保证单号唯一。2.2 唯一索引、行锁与“超卖”的实际边界需要说清一个容易误判的点uk_doctor_date_slot 只能防止“重复排班”不能防止“预约超卖”。因为同一个 schedule 通常有 20 个号20 个用户同时提交时预约表里插入 20 条不同 order_no 的记录并不会触发任何唯一键冲突。真正防止超卖靠两件事事务里对 t_schedule 行执行SELECT ... FOR UPDATE让同一排班的预约操作串行执行扣减余号时使用带条件判断的UPDATE ... WHERE remain_num 0影响行数为 0 就视为约满。这两步都会在下一章的提交预约方法里具体体现。如果项目里只写了“先 select 余号判断大于 0 再 insert”高并发下必然出现两个请求同时读到 remain_num1各自认为还有号最后超卖。这类系统的 MySQL 面试题也基本围绕索引、事务隔离级别和行锁来问能把这一步讲清楚说明确实理解了表设计的初衷。2.3 JDBC 连接 MySQL驱动、时区、连接池参数表结构写好后第一道部署坎在 JDBC 连接配置。常见的 jdbc.properties 长这样jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/hospital_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password123456MySQL 8.x 的驱动类名是 com.mysql.cj.jdbc.Driver5.7 时代常用 com.mysql.jdbc.Driver。如果复制老项目的配置却把驱动 jar 换成了 MySQL 8 的版本启动时会直接报 ClassNotFoundException。URL 参数里三个值得注意characterEncodingutf8 控制参数和查询结果的字符集MySQL 8 默认认证插件是 caching_sha2_password不显式设置 allowPublicKeyRetrievaltrue 会报 “Public Key Retrieval is not allowed”serverTimezoneAsia/Shanghai 解决时区不一致问题。不少课设项目直接用DriverManager.getConnection()每次新建连接一个预约请求就要经历 TCP 握手、认证、断开高峰期很容易出现 “Too many connections”。实际部署时建议套一层连接池DBCP 或 c3p0 都行最小配置如下initialSize5 maxActive50 maxIdle10 maxWait3000maxActive 是连接池最多同时持有的物理连接数maxWait 是拿不到连接时的等待毫秒数。maxActive 设太小高并发预约时线程会卡在获取连接这一步maxWait 设 0 则无限等页面表现为请求一直转圈。连接池参数和 Tomcat 线程数需要一起调第五节会给出具体参照值。3. 在线挂号主流程JSP 页面查询与 Servlet 事务怎么写挂号主流程按页面可以拆成四跳登录 → 科室列表 → 医生列表 → 排班与余号 → 提交预约。前三跳以查询为主最后一跳是整个系统的核心事务。实现上采用最常见的 Servlet 转发 JSP 方式不引入 Spring 等框架DAO 层负责 SQLServlet 负责参数解析和跳转。3.1 科室到医生列表的分页查询与页面参数传递医生列表页通过请求参数 deptId 拿到科室 ID从 t_doctor 查出该科室下所有医生。医生数量到几百位以后不分页的页面会变得很长所以 DAO 里的查询必须带上 LIMIT。下面是一个典型的分页查询方法public ListDoctor findByDept(int deptId, int offset, int pageSize) { String sql SELECT id, doctor_name, title, reg_fee, intro FROM t_doctor WHERE dept_id ? ORDER BY id LIMIT ?, ?; ListDoctor list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, deptId); ps.setInt(2, offset); // (当前页-1) * 页大小 ps.setInt(3, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Doctor d new Doctor(); d.setId(rs.getInt(id)); d.setDoctorName(rs.getString(doctor_name)); d.setTitle(rs.getString(title)); d.setRegFee(rs.getBigDecimal(reg_fee)); list.add(d); } } } catch (SQLException e) { LOGGER.error(query doctor by dept failed, e); } return list; }必须用 PreparedStatement 而不是字符串拼接 SQL这是此类系统能过代码评审的底线。LIMIT 的两个参数分别是偏移量和页大小(page-1) * pageSize 由调用方算好传入。挂号费字段用 BigDecimal 而不是 float 或 double避免金额精度问题。此外记得单独执行一条SELECT COUNT(*) FROM t_doctor WHERE dept_id ?拿总记录数再算出总页数传给 JSP 渲染分页按钮。预约表单页还有一个细节值得做用户填写一半时误关页面会丢失数据可以用 onbeforeunload 给出离开确认。需要注意现代浏览器出于安全限制不再允许自定义提示文案只保留默认提示但仍能阻止误触跳转。window.onbeforeunload function (e) { e.preventDefault(); e.returnValue ; };这段代码只对刷新、关闭、跳转这类操作生效浏览器崩溃或强制结束进程时拦不住。把它放在填写完挂号信息后的 confirm 页面里能减少一部分误操作投诉。3.2 提交预约事务先锁排班行、再插预约单、最后扣余号提交预约是整个系统最敏感的一段代码。前面表设计里提到的行锁和条件更新在这里落地public void createAppointment(int userId, int scheduleId) throws Exception { Connection conn DBUtil.getConnection(); conn.setAutoCommit(false); try { // 1. 锁住排班行同 schedule 的并发请求在这里排队 String lockSql SELECT doctor_id, remain_num FROM t_schedule WHERE id ? FOR UPDATE; try (PreparedStatement ps conn.prepareStatement(lockSql)) { ps.setInt(1, scheduleId); try (ResultSet rs ps.executeQuery()) { if (!rs.next() || rs.getInt(remain_num) 0) { throw new IllegalStateException(该时段号源已满); } } } // 2. 生成订单号并插入预约单 String orderNo REG System.currentTimeMillis() (int)(Math.random() * 900 100); try (PreparedStatement ps conn.prepareStatement( INSERT INTO t_appointment (order_no, user_id, schedule_id, status) VALUES (?, ?, ?, 0))) { ps.setString(1, orderNo); ps.setInt(2, userId); ps.setInt(3, scheduleId); ps.executeUpdate(); } // 3. 条件更新余号影响行数为 0 说明余号已被抢空 try (PreparedStatement ps conn.prepareStatement( UPDATE t_schedule SET remain_num remain_num - 1 WHERE id ? AND remain_num 0)) { ps.setInt(1, scheduleId); if (ps.executeUpdate() 0) { throw new IllegalStateException(号源刚刚被抢完); } } conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); DBUtil.close(conn); } }这段代码的关键点有三处。第一SELECT ... FOR UPDATE会把 t_schedule 中对应行锁住其他事务操作同一行时必须等待相当于把并发预约串行化。如果漏掉这一句两个请求可能同时读到 remain_num1。第二第三步的 UPDATE 自带remain_num 0条件即使第一步因为某种原因读到旧值这里也会挡住最后一刻的并发。第三insert 和 update 必须在同一个事务里任意一步失败都要 rollback否则会出现预约单插入成功但余号没扣的脏数据。finally 块里的conn.setAutoCommit(true)不能省。使用连接池时如果归还连接前 autocommit 仍是 false下一次从池中拿到这条连接会带着之前的事务状态导致后续一系列 SQL 隐式开启事务排查起来非常隐蔽。3.3 取消预约归属校验与余号回补“我的预约”页面支持取消状态为“待就诊”的预约。取消不是单纯把 status 改成 2还要把号源回补到排班表。同样要走事务且要校验归属public void cancelAppointment(int appointmentId, int userId) { Connection conn DBUtil.getConnection(); try { conn.setAutoCommit(false); // 锁定并校验归属状态不是 0 的预约不允许取消 String lockSql SELECT id, schedule_id FROM t_appointment WHERE id ? AND user_id ? AND status 0 FOR UPDATE; // 查无记录则直接返回或抛异常 String updateAppoint UPDATE t_appointment SET status 2 WHERE id ? AND status 0; // 更新预约单状态 String recoverSchedule UPDATE t_schedule SET remain_num remain_num 1 WHERE id ?; // 执行 updateAppoint 和 recoverSchedule再 commit } catch (Exception e) { conn.rollback(); throw e; } }cancel 的 SQL 条件要同时满足“预约单存在、属于当前登录用户、status0”。用户只能取消自己的预约这是权限问题status0 保证已完成的预约不能被重复取消这是状态机问题。状态流转保持单向0 待就诊 → 2 已取消0 待就诊 → 1 已完成不允许 1 或 2 再回到 0。回补的号会重新出现在排班余号里供其他用户预约这也是预期行为。预约和取消这两个方法写明白JSPMySQL 项目里最重的业务逻辑就完成了。之后迁移到 Spring Boot 或 SSM核心的 SQL 和事务边界都可以原样保留差别仅仅是连接管理和注解方式。4. 从源码 ZIP 到 Tomcat 跑通JSP 编译位置与高频报错拿到一个源码 ZIP顺序是解压、建库、导入数据、配置 JDBC、部署到 Tomcat。这一章的排错经验来自最常见的部署失败现场。先确认 MySQL 版本和连接驱动版本再谈 Tomcat 里的路径和报错。4.1 MySQL 安装配置与导入建表脚本MySQL 8.x 安装完先改两行配置。以 my.cnf 为例在 [mysqld] 段加入[mysqld] character_set_serverutf8mb4 default-time-zone08:00修改后重启 MySQL 服务。字符集统一为 utf8mb4 是从源头避免中文乱码default-time-zone 与 JDBC URL 里的 serverTimezone 配套否则容易在插入时间字段时提示时区相关的通信异常。建库导入用两条命令mysql -u root -p -e CREATE DATABASE hospital_db DEFAULT CHARACTER SET utf8mb4; mysql -u root -p hospital_db hospital.sql第二条命令把表结构和初始数据一次性灌入。如果导入时报Unknown collation: utf8mb4_0900_ai_ci说明脚本里的排序规则和本地 MySQL 版本不一致把建表语句中的utf8mb4_0900_ai_ci替换为utf8mb4_general_ci再执行。这坑在 MySQL 5.7 和 8.0 之间迁移时非常常见。4.2 部署到 Tomcat 与 JSP 编译后的 class 文件位置源码通常以动态 Web 工程或 Maven 工程形式提供。IDE 里配置 Tomcat 运行或打成 war 放到 Tomcat 的 webapps 目录。有一点要特别提醒如果源码是较早期的 JSP 项目不要直接部署到 Tomcat 10 及以上版本。Tomcat 10 把包名从 javax.* 切换成了 jakarta.*旧项目编译时会报找不到 servlet 包。用 Tomcat 9 部署这类 JSP 项目最省事。JSP 不是每次请求都重新编译的。首次访问某个 JSP 页面时Tomcat 的 Jasper 引擎会把 JSP 翻译成一个 Java 源文件再编译成 class 执行。编译缓存默认位置是$CATALINA_BASE/work/Catalina/localhost/项目上下文/org/apache/jsp/目录下能找到类似index_jsp.java、doctor_005flist_jsp.java的文件其中_005f是下划线转义。想确认 JSP 最终被翻译成了什么 Servlet 代码直接打开这个 java 文件即可。修改 JSP 后不生效时删除当前上下文对应的 work 缓存目录并重启 Tomcat是屡试不爽的强制重编译手段。页面报 “Unable to compile class for JSP” 时日志里会给出 java 文件第几行失败顺着行号找 scriptlet 里的变量或 JSTL 标签引入问题比反复刷新页面高效得多。4.3 高频报错对照驱动、连接、时区下面四个报错覆盖了此类项目九成以上的启动失败场景报错现象根本原因处理方式ClassNotFoundException: com.mysql.cj.jdbc.DriverWEB-INF/lib 下没有对应驱动 jar或驱动类名写错确认 jar 已放 lib 目录MySQL 8 用 cj 类名MySQL 5.x 用 com.mysql.jdbc.DriverPublic Key Retrieval is not allowedMySQL 8 默认 caching_sha2_password 认证插件需要密钥交换JDBC URL 加 allowPublicKeyRetrievaltrueuseSSLfalseThe server time zone value ... is unrecognized数据库与 JVM 时区未对齐URL 加 serverTimezoneAsia/Shanghai或改 my.cnf查出来或者存进去的中文全是?页面编码、JDBC 编码、表字符集三者不一致统一 UTF-8JDBC URL 加 characterEncodingutf8确认表是 utf8mb4排查顺序也有固定套路先看 Tomcat 日志里的 Caused by 行再看 JDBC URL 参数最后看数据库表字符集。中文乱码问题不建议靠猜测改代码直接在 MySQL 命令行执行SHOW CREATE TABLE t_doctor\G看表定义里是不是DEFAULT CHARSETutf8mb4通常在十秒内就能定位是库的问题还是连接的问题。5. 并发预约验证与上线前的三个参数调整系统可以正常挂号之后还要做两件事才算收尾验证并发正确性调整线上参数。5.1 用 ab 并发打同一排班验证余号与预约数守恒手工点按钮只能验证功能存在验证不了“并发不超卖”。部署完成后可以用 ab 对预约提交接口做一次小规模压测ab -n 100 -c 20 http://localhost:8080/hospital/appoint?scheduleId1userId100参数含义是总共发送 100 个请求同一时刻最多 20 个并发全部打到同一个排班。压测结束后用两条 SQL 核对结果SELECT COUNT(*) FROM t_appointment WHERE schedule_id 1 AND status 0; SELECT remain_num, total_num FROM t_schedule WHERE id 1;如果预约数加上剩余号数正好等于初始总号数说明事务逻辑正确。如果预约数超过 total_num或者 remain_num 出现负数说明第二步的条件更新丢了回第 3 章的代码里检查是否写出了WHERE remain_num 0。压测里有一部分请求返回“号源已满”是预期结果不代表系统故障。5.2 上线前需要改的三个参数线程、连接池、MySQL 连接数单体 JSP 应用在并发层面通常只需要关注三个参数参数位置推荐值说明maxThreadsTomcat server.xml 的Connector200线程数不是越大越好超过该值请求会在 Tomcat 层排队maxActive / maxWait数据源连接池配置50 / 3000 毫秒连接池不够会卡在 getConnection等待不应无上限max_connectionsMySQL my.cnf300给后台管理和预留运维连接留出余量maxThreads 调到 200 已经能覆盖几千注册用户的医院预约场景。连接池 maxActive 保持小于 maxThreads比如 50避免每个 Tomcat 线程都抢着占用一条数据库连接。MySQL 的 max_connections 修改后执行SHOW VARIABLES LIKE max_connections确认最终生效值。设置完成后重启 Tomcat 和 MySQL再用 5.1 的 ab 命令重新压测一组数据确认成交订单和余号守恒且页面平均耗时没有明显上涨这套参数就可以作为上线基准值。本文还有配套的精品资源点击获取