☰
Java医院挂号系统:Servlet+JDBC实战与并发一致性保障
2026/9/28 22:13:41 网站建设 项目流程

简介:这是一套基于Java技术栈开发的医院预约挂号系统源码,面向Java Web初学者与中级开发者,聚焦医疗信息化场景下的典型业务实现,如用户认证、医生排班、在线预约、数据持久化与前后端交互。资源包为ZIP格式,大小37.3MB,虽未提供具体文件明细,但根据描述可推知包含Spring Boot后端工程、MySQL建表脚本、HTML/CSS/JS前端页面及Log4j日志配置等核心模块,覆盖MVC分层架构与常见安全防护实践。已有449人学习下载,反映出该案例在教学与项目复现中的实用价值。读者可完整掌握从数据库ER设计、MyBatis动态SQL编写、Spring Security权限控制,到Bootstrap响应式界面集成的全流程开发逻辑,并借鉴其异常处理机制、时间调度策略与防注入措施,为构建同类医疗服务平台提供可运行、可调试、可拓展的参考范本。

1. 为什么一个“基于Java的医院预约挂号系统”至今仍是校招面试官最爱拆解的实战项目?

不是因为它有多炫酷——它没有用Spring Cloud做微服务,没接入医保区块链,也不跑在K8s上;恰恰相反,它用的是最朴素的Servlet+JDBC+JSP技术栈,数据库就一张MySQL,连Redis缓存都省了。但正因如此,它成了检验Java工程师「真实工程肌肉」的试金石:你能把用户从点击“预约科室”到收到短信提醒的完整链路,稳稳地落在事务边界里吗?当300人同时抢号时,库存扣减和号源锁定怎么不超挂、不漏挂、不重复挂?挂号成功后,如何让医生端实时看到新增待接诊列表,而不用F5刷新?这些不是理论题,是每天在三甲医院信息科后台真实发生的压力点。本项目适合两类人:一是刚学完JDBC和Servlet想验证自己能否串起完整业务流的新手;二是准备Java后端面试、需要拿一个“可讲清每行代码责任”的项目背书的求职者。它不追求技术新潮,但每一步都踩在Java Web开发的硬核关节上——事务控制、并发安全、表单防重提、状态机驱动流程、前后端数据契约设计。下面,我们就从零开始,把这套系统真正跑起来、调通、压测出瓶颈、再加固。


2. 搭建最小可运行环境:用最简依赖跑通挂号主流程

2.1 环境与工具链:JDK 8 + Tomcat 8.5 + MySQL 5.7 是黄金组合

这个系统诞生于2018年前后,当时主流企业仍大量使用JDK 8(LTS),Tomcat 8.5对Servlet 3.1支持成熟,MySQL 5.7的InnoDB事务和行锁机制已足够支撑挂号核心逻辑。不要强行升级到JDK 17或Tomcat 10——你会掉进Servlet API包名变更(jakarta.servlet vs javax.servlet)、驱动类名迁移(com.mysql.cj.jdbc.Driver)、以及JSP EL表达式解析器兼容性等黑匣子坑里。我一般会新建一个独立目录hospital-booking,结构如下:

hospital-booking/ ├── src/ # Java源码 ├── WebContent/ # JSP/HTML/JS/CSS静态资源(对应Tomcat webapps) ├── lib/ # 手动放入mysql-connector-java-5.1.47.jar、jstl-1.2.jar └── build.xml # Ant构建脚本(比Maven轻量,避免新手被pom.xml绕晕)

提示:WebContent是Eclipse动态Web项目默认根目录,不是src/main/webapp。若用IDEA,请在Project Structure → Modules → Web → Web Resource Directory中手动指定为WebContent,否则JSP编译路径错位。

2.2 数据库初始化:四张表撑起挂号骨架

挂号系统本质是状态流转系统,核心实体只有四个:用户(patient)、医生(doctor)、科室(department)、号源(schedule)。其中schedule表设计是成败关键——它必须承载“可约时段+剩余号数+状态机”三重职责。建表SQL如下(直接执行):

-- 用户表(简化版,仅含必要字段) CREATE TABLE patient ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, id_card CHAR(18) UNIQUE NOT NULL, phone CHAR(11) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 医生表 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(20) NOT NULL, dept_id BIGINT NOT NULL, title VARCHAR(10) COMMENT '职称:主任医师/副主任医师/主治医师', available BOOLEAN DEFAULT TRUE ); -- 科室表 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(30) NOT NULL, code VARCHAR(10) UNIQUE COMMENT '科室编码,如G01' ); -- 号源表:核心!注意status和remaining字段的语义 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, date DATE NOT NULL COMMENT '就诊日期', time_slot ENUM('morning','afternoon') NOT NULL COMMENT '上午/下午', remaining INT NOT NULL DEFAULT 0 COMMENT '剩余号数,初始为号段容量', status ENUM('open','closed','full') DEFAULT 'open' COMMENT '开放中/已关闭/已约满', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_doctor_date (doctor_id, date), INDEX idx_date_status (date, status) );

关键点说明:

  • schedule.remaining必须为INT且不允许NULL,否则并发扣减时remaining - 1可能变成NULL - 1 = NULL,导致逻辑失效;
  • status用ENUM而非TINYINT,强制约束状态值,避免代码中写错status=3这种玄学bug;
  • 复合索引idx_doctor_date用于医生排班查询,idx_date_status用于首页按日期筛选开放号源——挂号首页加载慢,90%源于这里没索引。

2.3 启动Tomcat并验证JSP页面渲染

将WebContent下所有文件(含index.jsp,login.jsp,book.jsp)复制到Tomcat的webapps/ROOT/目录,启动Tomcat(bin/startup.bat或./startup.sh),访问http://localhost:8080/。若看到首页轮播图+科室列表,说明JSP容器工作正常。此时检查Tomcat日志logs/catalina.out,确认无ClassNotFoundException: com.mysql.jdbc.Driver报错——若有,说明lib/mysql-connector-java-5.1.47.jar未正确放入WEB-INF/lib/(注意:不是项目根目录的lib/,而是WebContent/WEB-INF/lib/)。


3. 核心业务逻辑落地:挂号动作的原子性与状态闭环

3.1 预约挂号Servlet:三层校验缺一不可

挂号不是简单插入一条记录,而是跨表、跨状态、跨事务的复合操作。BookServlet需完成三步校验:

  1. 号源存在性校验:根据doctor_id+date+time_slot查schedule,确认status='open'且remaining > 0;
  2. 用户合法性校验:检查patient.id_card是否已在库中(防止黄牛用不同手机号注册);
  3. 并发安全性校验:用数据库行锁保证同一号源不被超挂。

关键代码片段(BookServlet.java):

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { Long doctorId = Long.parseLong(req.getParameter("doctorId")); String dateStr = req.getParameter("date"); String timeSlot = req.getParameter("timeSlot"); String idCard = req.getParameter("idCard"); Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 自定义工具类,获取连接 conn.setAutoCommit(false); // 开启事务 // 步骤1:SELECT FOR UPDATE 锁定号源行(关键!) String lockSql = "SELECT id, remaining, status FROM schedule WHERE doctor_id = ? AND date = ? AND time_slot = ? FOR UPDATE"; ps = conn.prepareStatement(lockSql); ps.setLong(1, doctorId); ps.setDate(2, Date.valueOf(dateStr)); ps.setString(3, timeSlot); rs = ps.executeQuery(); if (!rs.next()) { throw new RuntimeException("号源不存在或已关闭"); } long scheduleId = rs.getLong("id"); int remaining = rs.getInt("remaining"); String status = rs.getString("status"); if (!"open".equals(status) || remaining <= 0) { throw new RuntimeException("号源已约满或不可约"); } // 步骤2:检查用户是否已挂号(同身份证当天不能重复挂同一医生) String checkSql = "SELECT COUNT(*) FROM booking WHERE patient_id = (SELECT id FROM patient WHERE id_card = ?) AND schedule_id = ?"; ps = conn.prepareStatement(checkSql); ps.setString(1, idCard); ps.setLong(2, scheduleId); rs = ps.executeQuery(); if (rs.next() && rs.getInt(1) > 0) { throw new RuntimeException("您今日已预约该医生,请勿重复提交"); } // 步骤3:扣减号源并创建挂号记录(原子操作) // 先更新schedule.remaining String updateSql = "UPDATE schedule SET remaining = remaining - 1 WHERE id = ?"; ps = conn.prepareStatement(updateSql); ps.setLong(1, scheduleId); int updated = ps.executeUpdate(); if (updated != 1) { throw new RuntimeException("号源扣减失败"); } // 再插入booking记录 String insertSql = "INSERT INTO booking (patient_id, schedule_id, status, create_time) VALUES (?, ?, 'confirmed', NOW())"; ps = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS); ps.setLong(1, getPatientIdByIdCard(conn, idCard)); // 查询患者ID ps.setLong(2, scheduleId); ps.executeUpdate(); conn.commit(); // 提交事务 req.setAttribute("msg", "预约成功!请按时就诊。"); req.getRequestDispatcher("success.jsp").forward(req, resp); } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ex) {} } req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("book.jsp").forward(req, resp); } finally { DBUtil.close(rs, ps, conn); } }

逻辑说明:

  • SELECT ... FOR UPDATE是InnoDB行锁的核心指令,它会锁住查到的schedule行,直到事务结束。后续并发请求若查同一行,会被阻塞等待,从而避免超挂;
  • getPatientIdByIdCard()需确保内部也使用同一Connection,否则跨连接事务失效;
  • booking.status设为'confirmed'而非'success',为后续退号、改约留状态扩展空间(状态机思维)。

3.2 号源释放机制:退号不是DELETE,而是状态回滚

用户退号时,不能简单删booking记录——这会导致schedule.remaining无法恢复。正确做法是:

  • 将booking.status更新为'cancelled';
  • 同事务内执行UPDATE schedule SET remaining = remaining + 1 WHERE id = ?。

这样既保留挂号审计日志,又保证号源池准确。退号Servlet中必须复用与预约相同的SELECT FOR UPDATE锁号源逻辑,否则可能出现“退号时号源已被他人抢走”的数据不一致。


4. 并发与数据一致性避坑:那些让系统在高流量下集体翻车的细节

4.1 常见问题排查:现象、原因与解决

现象1:高峰期出现“预约成功但号源没减少”,或“显示约满却还能提交”
  • 原因:schedule.remaining字段未加数据库层面的CHECK (remaining >= 0)约束,且应用层未做remaining < 0的二次校验。当并发极高时,两个线程同时读到remaining=1,各自执行remaining-1,最终写入0两次,实际应为-1。
  • 解决:在UPDATE schedule语句中加入条件WHERE remaining > 0,并检查executeUpdate()返回值是否为1。若为0,说明已被其他事务抢先扣减,需抛异常重试。
// 替换原updateSql String updateSql = "UPDATE schedule SET remaining = remaining - 1 WHERE id = ? AND remaining > 0"; ps = conn.prepareStatement(updateSql); ps.setLong(1, scheduleId); int updated = ps.executeUpdate(); if (updated == 0) { throw new RuntimeException("号源已被抢光,请刷新后重试"); }
现象2:Tomcat频繁报java.lang.OutOfMemoryError: Metaspace
  • 原因:JSP页面过多(如每个科室一个JSP),且未关闭JSP自动重编译(<init-param><param-name>development</param-name><param-value>true</param-value></init-param>在web.xml中)。每次修改JSP,Tomcat会生成新ClassLoader加载新字节码,旧ClassLoader无法回收,Metaspace爆满。
  • 解决:生产环境务必设置<param-value>false</param-value>,并删除work/Catalina/localhost/ROOT/目录下的JSP编译缓存。
现象3:医生排班页面加载极慢(>5秒)
  • 原因:SELECT * FROM schedule s JOIN doctor d ON s.doctor_id = d.id JOIN department dept ON d.dept_id = dept.id未加WHERE s.date = ?条件,导致全表扫描。
  • 解决:前端传入date参数,SQL中强制添加AND s.date = ?,并确保schedule.date有索引(已建)。
现象4:用户输入身份证号18位,但数据库CHAR(18)存入后末尾多空格,导致SELECT id FROM patient WHERE id_card = ?查不到
  • 原因:MySQLCHAR类型会右补空格,而Java字符串比较不忽略空格。
  • 解决:将id_card字段改为VARCHAR(18),或在查询时用TRIM(id_card) = TRIM(?)(性能差,不推荐)。
现象5:短信发送成功,但挂号记录未入库(事务未提交)
  • 原因:短信调用放在conn.commit()之后,若短信接口超时或网络抖动,用户看到“发送成功”但实际挂号失败,造成客诉。
  • 解决:短信发送必须放在事务内,作为最后一步。若短信失败,整个事务回滚,用户看到“预约失败,请重试”。

5. 前后端协同与体验优化:让系统不只是能用,而是好用

5.1 表单防重复提交:前端拦截 + 后端令牌双保险

用户手抖连点“预约”按钮,极易造成重复挂号。解决方案分两层:

  • 前端:按钮点击后立即置灰并显示“提交中...”,用JavaScript禁用按钮;
  • 后端:生成一次性Token存入Session,表单提交时携带,Servlet校验后立即失效。

BookServlet开头添加:

String token = req.getParameter("token"); if (token == null || !token.equals(req.getSession().getAttribute("form_token"))) { throw new RuntimeException("表单已过期,请刷新页面"); } req.getSession().removeAttribute("form_token"); // 一次性消费

JSP中生成Token:

<% String token = java.util.UUID.randomUUID().toString(); session.setAttribute("form_token", token); %> <input type="hidden" name="token" value="<%=token%>" />

注意:此方案依赖Session,若部署多台Tomcat需配置Session共享(如Redis),否则负载均衡下Token校验会失败。单机部署可忽略。

5.2 号源实时可见性:用AJAX轮询替代页面刷新

医生端查看“今日待接诊”列表,若用F5刷新,体验差且增加DB压力。改为每10秒AJAX请求一次:

// doctor-dashboard.jsp function loadTodayBookings() { fetch('/GetTodayBookingsServlet?date=' + getCurrentDate()) .then(res => res.json()) .then(data => { document.getElementById('booking-list').innerHTML = data.map(b => `<div>${b.patientName} ${b.timeSlot}</div>`).join(''); }); } setInterval(loadTodayBookings, 10000);

GetTodayBookingsServlet只需查booking b JOIN patient p ON b.patient_id = p.id WHERE b.status='confirmed' AND DATE(b.create_time) = ?,结果转JSON返回。关键点:SQL中DATE(b.create_time)不能出现在WHERE条件左侧(会失效索引),应改为b.create_time >= ? AND b.create_time < ?,传入当天0点和次日0点时间戳。

5.3 数据导出:用POI生成带格式的Excel挂号清单

医院管理员常需导出某日挂号明细。ExportBookingServlet用Apache POI生成Excel,重点在于单元格样式和日期格式:

HSSFWorkbook wb = new HSSFWorkbook(); HSSFSheet sheet = wb.createSheet("挂号清单"); HSSFRow header = sheet.createRow(0); String[] headers = {"患者姓名", "身份证号", "就诊日期", "时段", "医生"}; for (int i = 0; i < headers.length; i++) { HSSFCell cell = header.createCell(i); cell.setCellValue(headers[i]); // 设置标题背景色 HSSFCellStyle style = wb.createCellStyle(); style.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); cell.setCellStyle(style); } // 查询数据并写入 List<Booking> list = getBookingsByDate(date); for (int i = 0; i < list.size(); i++) { HSSFRow row = sheet.createRow(i + 1); Booking b = list.get(i); row.createCell(0).setCellValue(b.getPatientName()); row.createCell(1).setCellValue(b.getIdCard()); row.createCell(2).setCellValue(new SimpleDateFormat("yyyy-MM-dd").format(b.getDate())); row.createCell(3).setCellValue("morning".equals(b.getTimeSlot()) ? "上午" : "下午"); row.createCell(4).setCellValue(b.getDoctorName()); } // 自动列宽 for (int i = 0; i < headers.length; i++) { sheet.autoSizeColumn(i); } resp.setContentType("application/vnd.ms-excel"); resp.setHeader("Content-Disposition", "attachment; filename=booking-" + date + ".xls"); wb.write(resp.getOutputStream());

提示:POI 3.17(对应JDK 8)支持.xls格式,若需.xlsx,需升级POI 4.x+,但会引入更多依赖冲突。本项目保持.xls即可满足医院办公场景。


6. 面试级深度验证:用JMeter压测暴露真实瓶颈,并针对性加固

6.1 构建可复现的压测场景

挂号系统最脆弱环节是号源扣减。我们用JMeter模拟100用户在30秒内抢同一个号源(doctor_id=1, date='2024-06-01', time_slot='morning',初始remaining=50)。测试计划结构:

  • Thread Group:100线程,Ramp-up Period=30秒,Loop Count=1;
  • HTTP Request:POST/BookServlet,参数含doctorId=1&date=2024-06-01&timeSlot=morning&idCard=11010119900307231X;
  • View Results Tree:观察响应内容是否含“预约成功”或“号源已被抢光”;
  • Aggregate Report:关注90% Line响应时间、Error %。

首次压测结果往往触目惊心:Error %高达35%,90% Line超2秒。这不是代码问题,而是数据库连接池瓶颈。

6.2 连接池调优:从默认20连接到动态伸缩

Tomcat自带BasicDataSource默认最大连接数仅20,远低于100并发。在DBUtil.java中重构连接池:

private static DataSource dataSource; static { BasicDataSource ds = new BasicDataSource(); ds.setUrl("jdbc:mysql://localhost:3306/hospital?useSSL=false&serverTimezone=Asia/Shanghai"); ds.setUsername("root"); ds.setPassword("123456"); ds.setInitialSize(20); // 初始连接数 ds.setMaxActive(100); // 最大活跃连接数(匹配JMeter线程数) ds.setMaxIdle(50); // 最大空闲连接数 ds.setMinIdle(10); // 最小空闲连接数 ds.setMaxWait(10000); // 获取连接超时10秒 ds.setTestOnBorrow(true); ds.setValidationQuery("SELECT 1"); dataSource = ds; }

关键参数解释:

  • setMaxActive(100):必须≥压测线程数,否则线程排队等待连接,响应时间飙升;
  • setMinIdle(10):保持10个空闲连接常驻,避免高频创建销毁开销;
  • setValidationQuery("SELECT 1"):借连接时执行简单SQL检测连接有效性,防止MySQL的wait_timeout断连。

6.3 SQL执行计划分析:揪出隐藏的全表扫描

压测后开启MySQL慢查询日志(slow_query_log=ON,long_query_time=1),发现SELECT ... FROM schedule WHERE doctor_id = ? AND date = ? AND time_slot = ?未走索引。用EXPLAIN分析:

EXPLAIN SELECT id FROM schedule WHERE doctor_id = 1 AND date = '2024-06-01' AND time_slot = 'morning';

若type为ALL,说明索引失效。原因通常是date字段类型为DATE,但传入参数是String,MySQL隐式转换导致索引失效。解决方案:在Servlet中用java.sql.Date.valueOf(dateStr)转为Date对象传参,确保类型严格匹配。

6.4 终极加固:引入本地缓存减少DB压力

号源状态(open/closed/full)变化频率低,但查询频次高。在ScheduleService.java中加入Guava Cache:

private static final LoadingCache<String, Schedule> scheduleCache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(key -> { // key格式:doctorId:date:timeSlot,如"1:2024-06-01:morning" String[] parts = key.split(":"); return queryScheduleFromDB(Long.parseLong(parts[0]), Date.valueOf(parts[1]), parts[2]); }); public Schedule getSchedule(Long doctorId, Date date, String timeSlot) { String key = doctorId + ":" + new SimpleDateFormat("yyyy-MM-dd").format(date) + ":" + timeSlot; return scheduleCache.get(key); }

注意:缓存需在号源状态变更(如管理员手动关闭号源)时主动invalidate(key),否则出现脏数据。本项目中,管理员操作走独立Servlet,调用scheduleCache.invalidate(key)即可。

我带过的实习生里,80%栽在“以为写完CRUD就完工”——其实真正的工程能力,是在压测报告里读懂Error %背后的连接池泄漏,在慢查询日志里定位那条没走索引的SQL,在并发日志里捕捉到remaining变成负数的瞬间。这个医院预约系统,从来不是教你怎么写Java,而是逼你直面数据一致性这个永恒命题。现在,你可以打开IDE,照着这篇笔记,把BookServlet里的SELECT FOR UPDATE亲手敲一遍,然后用JMeter砸它——当看到Error %从35%降到0.2%,你会明白什么叫“落地”。希望帮到你。

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

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

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

立即咨询