拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java医院挂号系统:Servlet+JDBC实战与并发一致性保障

Java医院挂号系统:Servlet+JDBC实战与并发一致性保障 简介这是一套基于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上恰恰相反它用的是最朴素的ServletJDBCJSP技术栈数据库就一张MySQL连Redis缓存都省了。但正因如此它成了检验Java工程师「真实工程肌肉」的试金石你能把用户从点击“预约科室”到收到短信提醒的完整链路稳稳地落在事务边界里吗当300人同时抢号时库存扣减和号源锁定怎么不超挂、不漏挂、不重复挂挂号成功后如何让医生端实时看到新增待接诊列表而不用F5刷新这些不是理论题是每天在三甲医院信息科后台真实发生的压力点。本项目适合两类人一是刚学完JDBC和Servlet想验证自己能否串起完整业务流的新手二是准备Java后端面试、需要拿一个“可讲清每行代码责任”的项目背书的求职者。它不追求技术新潮但每一步都踩在Java Web开发的硬核关节上——事务控制、并发安全、表单防重提、状态机驱动流程、前后端数据契约设计。下面我们就从零开始把这套系统真正跑起来、调通、压测出瓶颈、再加固。2. 搭建最小可运行环境用最简依赖跑通挂号主流程2.1 环境与工具链JDK 8 Tomcat 8.5 MySQL 5.7 是黄金组合这个系统诞生于2018年前后当时主流企业仍大量使用JDK 8LTSTomcat 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强制约束状态值避免代码中写错status3这种玄学bug复合索引idx_doctor_date用于医生排班查询idx_date_status用于首页按日期筛选开放号源——挂号首页加载慢90%源于这里没索引。2.3 启动Tomcat并验证JSP页面渲染将WebContent下所有文件含index.jsp,login.jsp,book.jsp复制到Tomcat的webapps/ROOT/目录启动Tomcatbin/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需完成三步校验号源存在性校验根据doctor_iddatetime_slot查schedule确认statusopen且remaining 0用户合法性校验检查patient.id_card是否已在库中防止黄牛用不同手机号注册并发安全性校验用数据库行锁保证同一号源不被超挂。关键代码片段BookServlet.javaprotected 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); // 开启事务 // 步骤1SELECT 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的二次校验。当并发极高时两个线程同时读到remaining1各自执行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(号源已被抢光请刷新后重试); }现象2Tomcat频繁报java.lang.OutOfMemoryError: Metaspace原因JSP页面过多如每个科室一个JSP且未关闭JSP自动重编译init-paramparam-namedevelopment/param-nameparam-valuetrue/param-value/init-param在web.xml中。每次修改JSPTomcat会生成新ClassLoader加载新字节码旧ClassLoader无法回收Metaspace爆满。解决生产环境务必设置param-valuefalse/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 typehidden nametoken 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.statusconfirmed 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); } // 查询数据并写入 ListBooking 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; filenamebooking- 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_id1, date2024-06-01, time_slotmorning初始remaining50。测试计划结构Thread Group100线程Ramp-up Period30秒Loop Count1HTTP RequestPOST/BookServlet参数含doctorId1date2024-06-01timeSlotmorningidCard11010119900307231XView 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?useSSLfalseserverTimezoneAsia/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_logON,long_query_time1发现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但传入参数是StringMySQL隐式转换导致索引失效。解决方案在Servlet中用java.sql.Date.valueOf(dateStr)转为Date对象传参确保类型严格匹配。6.4 终极加固引入本地缓存减少DB压力号源状态open/closed/full变化频率低但查询频次高。在ScheduleService.java中加入Guava Cacheprivate static final LoadingCacheString, 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%你会明白什么叫“落地”。希望帮到你。本文还有配套的精品资源点击获取
返回列表