
简介这是基于JspServlet技术栈开发的医院预约挂号系统完整项目适合Java Web课程设计、毕业设计以及想系统学习传统Servlet开发模式的初学者。项目前端覆盖首页展示、用户注册、医生查询与公告信息等就医环节后台管理支持医院信息、科室信息、医生信息、会员信息及系统公告的增删改查功能覆盖完整配合部署教程、软件工具与数据库脚本即可开箱使用。压缩包共1451个文件以JavaScript脚本、HTML页面、JSP视图、Java类与CSS样式为主同时包含jar依赖包、XML与Properties配置文件和SQL数据库脚本整体约31.95MB目录结构与常见Web工程一致便于按模块检索定位。运行环境选用JDK1.8、MySQL5.7和Tomcat7/8/9开发工具支持Eclipse与IDEA技术栈涵盖bootstrap、jQuery、Servlet等常用组件已有134人学习下载对理解JSPServlet前后端交互、预约挂号业务逻辑和后台管理开发均有不错参考价值。1. 为什么还要用 JSPServlet 做医院预约挂号系统每年这时候都有大量计算机专业的学生在做同一个课设题目医院预约挂号系统。你如果直接搜代码能搜到几百个 Spring Boot 版本但真正被导师问到“介绍一下你 Servlet 层的并发处理”时很多人答不上来。反直觉的是JSPServlet 这套老技术恰恰是这类业务场景里最容易讲清楚、也最快能跑出完整闭环的方案用户注册登录、科室与医生展示、排班号源查询、预约挂号、退号、后台管理一套流程走下来不依赖任何自动化配置的黑盒框架每一行代码都是自己写的出问题能直接定位到行号。这篇不是把教科书目录复述一遍而是按我实际搭这类系统的顺序走先讲清楚了表结构和角色边界再落到挂号这个核心动作的事务处理和号源扣减最后把 JSP 页面的渲染方式、字符集、并发超卖这些天天踩的坑拆开。适合两类人一类是课设选了预约挂号题、想做到“能在导师面前把流程讲明白”的学生另一类是给小型门诊部做内部预约后台、不想被 Spring 全家桶拖累的一线开发。跟着走完你会得到一套能真正运行、能演示预约闭环的最小系统而不是一个演示完就删的 demo。2. 系统拆解三端角色划分与五张核心表的数据库设计2.1 物理分层Servlet 管请求、Service 管事务、JSP 管渲染很多新手拿到这个题目第一反应是“我直接用 JSP 连接数据库一个页面搞定所有功能”。这样确实三天能跑通但排班和挂号混在一个页面里改一个地方就牵连一片。常见做法是严格拆三层Servlet 层只接收请求参数、调用 Service、把结果放进 request 域、转发到 JSP不做 SQL 拼接Service 层写业务判断比如号源是否充足、是否重复挂号、退号是否在允许时间内DAO 层用 JDBC 访问 MySQL只负责 SQL 执行和结果集封装。这套分层不是学院派讲究而是当你被导师追问“挂号成功但号源没扣减怎么办”时能直接指出是 DAO 层 update 语句没执行成功还是 Service 层忘了开启事务。开发环境我用的是 JDK 8 Tomcat 8.5 MySQL 5.7Servlet 用 3.1 规范直接通过WebServlet注解注册路由不需要写 web.xml 映射。这是我推荐的组合JDK 版本越低兼容性越好Tomcat 8.5 对注解支持成熟MySQL 5.7 是课设和门诊部署最常见的版本。2.2 角色设计一张用户表区分患者、医生、管理员预约挂号系统天然有三类人患者要注册登录、查科室、挂号医生要看自己的排班和患者列表管理员要维护科室、医生信息、放号。很多初学设计会建三张用户表我不推荐。患者、医生、管理员本质上都是“账号 密码 基本信息”区别只在于业务扩展字段拆三张表只会让登录逻辑写出三个分支。我的做法是一张user表存所有账号用role字段区分0 表示患者1 表示医生2 表示管理员。医生的职称、简介、所属科室这些扩展信息放到单独的doctor表里通过user_id外键关联。管理员不需要扩展字段直接在user表里建几条记录即可。这样登录时只需要查一张表拿到role后决定跳转到哪个首页。患者表也不需要单独建患者的所有扩展信息就是姓名、手机号、身份证号全放在user表里。2.3 五张核心表科室、用户、医生、排班、挂号单整个业务流程可以浓缩成五张表科室表、用户表、医生表、排班表、挂号单表。排班表是号源的载体挂号单表是业务结果这两张表是整个系统的命脉。建表 SQL 如下我用utf8mb4字符集避免中文和 emoji 特殊字符入库时报错。CREATE DATABASE hospital_appointment DEFAULT CHARACTER SET utf8mb4; CREATE TABLE department ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 科室名称, intro VARCHAR(255) COMMENT 科室简介 ) ENGINEInnoDB; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(30) NOT NULL UNIQUE COMMENT 登录名, password VARCHAR(64) NOT NULL COMMENT MD5加密后的密码, real_name VARCHAR(30) COMMENT 真实姓名, phone VARCHAR(20), id_card VARCHAR(18) COMMENT 身份证号, role TINYINT NOT NULL DEFAULT 0 COMMENT 0患者 1医生 2管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE doctor ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, dept_id INT NOT NULL, title VARCHAR(30) COMMENT 职称主任医师/副主任医师, intro VARCHAR(255) COMMENT 医生简介, FOREIGN KEY (user_id) REFERENCES user(id), FOREIGN KEY (dept_id) REFERENCES department(id) ) ENGINEInnoDB; CREATE TABLE schedule ( id INT PRIMARY KEY AUTO_INCREMENT, doctor_id INT NOT NULL, work_date DATE NOT NULL COMMENT 出诊日期, period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL DEFAULT 30 COMMENT 总号源数, remain_slots INT NOT NULL DEFAULT 30 COMMENT 剩余号源数, status TINYINT DEFAULT 0 COMMENT 0正常 1停诊, UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period), FOREIGN KEY (doctor_id) REFERENCES doctor(id) ) ENGINEInnoDB; CREATE TABLE appointment ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL COMMENT 患者用户ID, doctor_id INT NOT NULL, schedule_id INT NOT NULL, appoint_date DATE NOT NULL COMMENT 冗余就诊日期, period TINYINT NOT NULL COMMENT 冗余上午/下午, status TINYINT DEFAULT 0 COMMENT 0待就诊 1已完成 2已退号 3爽约, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_schedule (user_id, schedule_id), FOREIGN KEY (schedule_id) REFERENCES schedule(id) ) ENGINEInnoDB;建表时有几个细节值得说。schedule表上的唯一键uk_doctor_date_period保证一位医生同一个半天只能有一条排班记录这是后台放号时不产生重复排班的第一道防线appointment表上的uk_user_schedule唯一键保证同一个患者对同一条排班只能挂一次号比在 Java 代码里先查询再判断要可靠得多数据库层面直接拦截。另外appoint_date和period字段有冗余这是故意的——查询“我的挂号记录”时不用再 join 排班表去取日期列表页的性能和代码复杂度都能降下来。2.4 JDBC 连接与 DAO 基础封装有了表结构还不够得有稳定的数据库连接方式。我不建议在每次请求里直接DriverManager.getConnection()那样并发一高连接就耗尽。最轻量的方案是使用 Tomcat 内置的连接池在META-INF/context.xml里配置 JNDI 数据源DAO 里通过Context.lookup获取连接。如果不想引入 JDNI也可以用 Apache DBCP2 在工具类里静态初始化连接池。下面是 DBCP2 方式的工具类核心代码够用且不依赖 Spring。public class DBUtil { private static BasicDataSource dataSource; static { dataSource new BasicDataSource(); dataSource.setDriverClassName(com.mysql.jdbc.Driver); dataSource.setUrl(jdbc:mysql://localhost:3306/hospital_appointment?useUnicodetruecharacterEncodingutf8useSSLfalse); dataSource.setUsername(root); dataSource.setPassword(root); dataSource.setInitialSize(5); dataSource.setMaxTotal(20); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }这段代码里的useUnicodetruecharacterEncodingutf8是整个项目中文不乱码的前提少了它页面端做的所有字符集设置都白搭。initialSize和maxTotal分别代表连接池初始连接数和最大连接数课设场景 5 和 20 就够如果部署环境是门诊部真实使用把maxTotal调到 50 也不会有压力。注意 MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver8.0.13 之后还要在连接串上补serverTimezoneAsia/Shanghai才能连上否则报时区错误。3. 核心挂号链路Servlet 层的事务控制与号源预扣3.1 登录检查与请求转发一个 Filter 挡住所有未登录请求预约挂号这个动作涉及扣减号源绝对不允许未登录用户直接操作。一个常见的失误是在每个 Servlet 里复制粘贴登录判断代码一旦漏掉一个接口就留下越权漏洞。正确做法是写一个过滤器只对/patient/*和/book这类需要患者身份的路径做拦截。WebFilter(/patient/*) public class LoginFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); Object user session null ? null : session.getAttribute(user); if (user null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }代码逻辑很直接从 session 取user对象取不到就重定向到登录页。这里有两个细节需要注意。第一req.getSession(false)中参数false表示“如果当前没有 session 就返回 null而不是新建一个”避免给未登录用户无谓地创建 session 对象。第二判断用户是否登录时不能只判断 session 是否为 null因为用户可能访问过其他页面已有 session但从未登录过所以必须取session.getAttribute(user)再判断。这个过滤器是保护号源数据的第一道大门。3.2 防止超卖一条 UPDATE 语句完成号源预扣挂号的业务逻辑表面上是“先查剩余号源如果大于 0 就减 1再插入挂号单”。但这个流程在高并发下有一个致命问题两个请求同时查询都看到剩 1 个号都执行更新结果两个人都挂号成功数据库里remain_slots变成负数。这就是教科书里的超卖问题。解决方案不是用 Java 的synchronized关键字因为那只能锁住单台 Tomcat 进程内的线程未来如果部署多个实例就会失效正确做法是把“检查并扣减”合并成一条带条件的 SQL让数据库的行锁来保证原子性。WebServlet(/book) public class BookServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(application/json;charsetUTF-8); HttpSession session req.getSession(false); Object userObj session null ? null : session.getAttribute(user); if (userObj null) { resp.getWriter().write({\code\:401,\msg\:\请先登录\}); return; } int userId ((User) userObj).getId(); int scheduleId Integer.parseInt(req.getParameter(scheduleId)); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 原子扣减号源只有 remain_slots 0 时才会更新成功 String deductSql UPDATE schedule SET remain_slots remain_slots - 1 WHERE id ? AND remain_slots 0 AND status 0; PreparedStatement ps conn.prepareStatement(deductSql); ps.setInt(1, scheduleId); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); resp.getWriter().write({\code\:500,\msg\:\号源已满或已停诊\}); return; } // 2. 插入挂号单 String insertSql INSERT INTO appointment (user_id, doctor_id, schedule_id, appoint_date, period, status) SELECT ?, doctor_id, id, work_date, period, 0 FROM schedule WHERE id ?; PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setInt(1, userId); ps2.setInt(2, scheduleId); ps2.executeUpdate(); conn.commit(); resp.getWriter().write({\code\:200,\msg\:\预约成功\}); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); resp.getWriter().write({\code\:500,\msg\:\预约失败请重试\}); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } } }这段代码里最关键的是第一步的UPDATE ... WHERE id ? AND remain_slots 0以及status 0这个条件。MySQL 执行 UPDATE 时会在这条记录上加排他锁两个并发请求同时执行时后一个会等前一个提交后才能执行此时remain_slots已减一如果减到了 0后一个请求的 WHERE 条件remain_slots 0不再满足影响行数为 0事务回滚返回“号源已满”。整个过程不需要SELECT预检查一条语句就把检查和扣减合并了。status 0是为了在医生停诊时禁止继续挂号停诊后管理员把排班状态改成 1患者端自然无法下单。3.3 事务边界挂号单插入失败时号源要回补很多人写这个功能时会犯一个顺序错误先插入挂号单再扣减号源。如果扣减失败数据库里就多了一条没有对应号源的挂号单。反过来先扣号源再插单如果插单失败但没做事务回滚号源就凭空少了一个。所以必须把“扣号源”和“插挂号单”放进同一个数据库事务里。上面代码的关键来自conn.setAutoCommit(false)这一行它告诉连接不要每次都自动提交而是等两个 SQL 都成功后再统一commit任何一个环节抛异常就rollback号源自动回补。这里还要注意插入挂号单的 SQL 写法它用的是INSERT ... SELECT从排班表里直接取doctor_id、work_date、period而不是在 Java 里把这些字段作为参数传入。这样有两个好处一是减少了一次查询二是保证写入的排班信息和排班表完全一致不会出现前端传参篡改日期或医生 ID 的风险。如果插入时违反uk_user_schedule唯一键会抛出SQLException事务回滚同样不会扣号源。4. 页面渲染用 JSTL/EL 取代 scriptlet把 JSP 变成纯粹的展示层4.1 医生列表页别再用% %拼 HTMLJSP 页面最常见的坏味道是一个页面里塞满% for (int i 0; i list.size(); i) { %这样的 Java 片段页面代码和 Java 代码混在一起缩进混乱哪怕改一个颜色都要在十几对尖括号里找位置。正确的做法是用 EL 表达式和 JSTL 标签库做遍历和判断Servlet 只需要把数据放进 request 域JSP 负责展示。下面是一个医生列表页的核心代码展示医生姓名、职称、科室和预约按钮。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % % page contentTypetext/html;charsetUTF-8 languagejava % div classdoctor-list c:forEach items${doctorList} vardoc div classdoctor-card div classdoctor-name${doc.name}/div div classdoctor-title${doc.title}/div div classdoctor-dept${doc.deptName}/div p classdoctor-intro${doc.intro}/p !-- 点击跳转到排班页按医生ID查未满的号源 -- a href${pageContext.request.contextPath}/patient/schedule?doctorId${doc.id}查看排班/a /div /c:forEach c:if test${empty doctorList} p暂无医生信息请稍后再来。/p /c:if /divc:forEach items${doctorList} vardoc替代了 Java 的 for 循环${doc.name}本质是调用医生对象的getName()方法不需要在页面里写out.print()。c:if test${empty doctorList}处理列表为空的场景避免页面渲染出一块空白区域让用户以为是加载失败。${pageContext.request.contextPath}是项目根路径比如部署后访问路径是/hospital它就输出/hospital所有链接和表单提交都加上这个前缀才能保证在任意层级页面跳转时不会变成相对路径拼接错误。4.2 个人信息展示页面与挂号记录配合 JSTL 输出用户状态预约挂号系统里患者登录后最常看的就是个人信息展示页面和自己的挂号记录列表。挂号记录列表可以说是整个系统里信息密度最高的页面要显示就诊科室、医生姓名、就诊日期、上午还是下午、挂号状态以及退号入口。Servlet 端查询时这样做查出当前用户 ID 的所有挂号记录同时 join 医生表和排班表组装成一个AppointmentVO对象放入 request 域。VO 里包含appointmentId、doctorName、deptName、appointDate、periodDesc、statusDesc等展示字段JSP 不需要直接操作 doctor 和 schedule 两个实体。table classappointment-table thead tr th科室/thth医生/thth就诊日期/thth时段/thth状态/thth操作/th /tr /thead tbody c:forEach items${appointmentList} varappt tr td${appt.deptName}/td td${appt.doctorName}/td tdfmt:formatDate value${appt.appointDate} patternyyyy-MM-dd//td td${appt.periodDesc}/td td c:choose c:when test${appt.status 0} span classstatus-pending待就诊/span /c:when c:when test${appt.status 2} span classstatus-canceled已退号/span /c:when c:otherwise span classstatus-done已完成/span /c:otherwise /c:choose /td td c:if test${appt.status 0} a href${pageContext.request.contextPath}/patient/cancel?appointmentId${appt.appointmentId} onclickreturn confirm(确认退号号源将被释放。)退号/a /c:if /td /tr /c:forEach /tbody /table这段 JSP 处理了三个常见展示场景日期用fmt:formatDate格式化为yyyy-MM-dd避免输出默认的Thu Jan 01 00:00:00 CST 2026形式状态用c:choose多分支判断比c:if嵌套清晰得多退号操作只在状态为“待就诊”时显示已完成或已退号的记录不提供入口。这里体现了一个原则JSP 里不写业务判断业务判断全部在 Servlet 里把状态翻译好页面只做展示。个人信息展示页面同理用户资料、手机号、身份证号直接在 JSP 里用${user.phone}输出不要把密码字段放进去。4.3 静态资源与相对路径的坑为什么页面样式总是丢JSP 页面引用 CSS 和 JS 时最容易踩的坑是link relstylesheet hrefcss/style.css。表面看没问题但如果你当前的浏览器 URL 是/hospital/patient/schedule浏览器解析相对路径时会以为是/hospital/patient/css/style.css结果 404。解决方式有两种一种是在head里设置base href${pageContext.request.contextPath}/让页面上所有相对路径都以项目根目录为基础另一种是每个 link、script、img 标签都写完整前缀。我推荐第二种写的时候繁琐一点但语义更明确。如果整个项目里还有${pageContext.request.contextPath}没写全的地方部署后多半会出现页面没有样式、图片裂开、接口 404 连在一起的问题。5. 避坑指南JSPServlet 项目的 5 个高频翻车现场5.1 中文乱码页面乱成一锅粥现象页面显示中文变成一串问号或者数据库里存的中文读出来是乱码有的只在表单提交时乱有的 GET 请求就乱。原因一条链路三个环节都要指定字符集缺一个就出错。浏览器解析页面时靠contentType的charsetUTF-8Tomcat 接收请求参数时靠request.setCharacterEncoding(UTF-8)MySQL 存储时靠连接串的characterEncodingutf8和建表时的utf8mb4。这一步是最容易被忽略的很多人改了页面和数据库但忘了在 Servlet 的doPost最开头写request.setCharacterEncoding(UTF-8)。解决避免每个 Servlet 都写一行写一个全局编码过滤器映射所有 URL。WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { request.setCharacterEncoding(UTF-8); response.setCharacterEncoding(UTF-8); chain.doFilter(request, response); } }注意response.setCharacterEncoding(UTF-8)要在这里统一设置否则每个 Servlet 都要重复写resp.setContentType(text/html;charsetUTF-8)。GET 请求的参数编码在 Tomcat 8.5 之后默认就是 UTF-8不需要额外配置如果是老 Tomcat 7 部署需要在server.xml的 Connector 上加URIEncodingUTF-8。5.2 同一秒并发挂号号源被扣成负数现象用 JMeter 或者 Postman 开十几个线程同时打挂号接口数据库里remain_slots字段变成了负数。原因Service 层写成先SELECT remain_slots判断大于 0再UPDATE减 1。两个并发请求同时 select看到的都是 1然后都做 update结果是没有判断条件的SET remain_slots remain_slots - 1负数就出现了。解决回到第 3 章的做法把判断合并进 UPDATE 的 WHERE 条件里WHERE remain_slots 0。执行一条 UPDATE 时记录行锁会阻塞并发后一个请求更新时条件已经不满足。这里还要注意appointment表上的唯一键uk_user_schedule它的作用是防止同一个用户对同一个排班重复点击挂号按钮产生的重复记录。两行代码分别是乐观并发控制和数据库约束缺一不可。5.3 Session 时不时丢登录状态说没就没现象用户登录后正常浏览过了一会儿点挂号就跳回登录页有时部署重新发布一下所有用户都要重新登录。原因Session 默认存活时间是 30 分钟web.xml里如果没配session-config就是容器默认值。更常见的问题是重启 Tomcat 时内存里的 Session 全部丢失这在开发环境无所谓在门诊部生产环境就是事故。还有页面里没写request.getSession()而是用request.getSession(false)一旦 session 刚好过期就拿到 null跳转逻辑就生效了。解决在web.xml里显式配置 session 超时时间并保证登录成功后把用户对象放进 session 然后重定向到主页。session-config session-timeout120/session-timeout /session-config把超时时间设为 120 分钟能覆盖上午的门诊时段。生产环境如果重启频繁把登录状态存进 Redis 再配置一层监听器是后面第 6 章要讲的扩展但课设和门诊内部系统先学会配置 session 超时更实际。5.4 JSP 页面 Java 代码过多编译报错后找不到行号现象JSP 页面里嵌了大段%脚本一会儿要 import 这个类一会儿要转换日期格式结果一启动项目页面直接 500控制台报错信息指到一个 JSP 编译出来的 Java 文件的某一长行痛苦不堪。原因JSP 本质是一个 servlet容器会把页面转译成一个 Java 类再编译。页面里的 scriptlet 代码越多转译后的问题越难排查而且 JSP 里写for循环调试时每一行都要在浏览器刷新才能看到效果。解决强制自己遵守两条规则JSP 里不出现import java.*业务数据全部由 Servlet 组装成 VO 放到 request 域需要判断和循环时只用 JSTL 的c:forEach、c:if、c:choose。如果 JSP 里非要取数据库连表数据说明 Servlet 层的查询逻辑没做完整应该回去改 Servlet而不是在页面上补 Java 代码。5.5 停诊后号源还能挂退号后台源不回流现象管理员把某位医生的某天排班标记为停诊但用户还能在页面上看到并挂号患者取消挂号剩余号源竟然没有加回去。原因页面展示排班时没过滤status 0的停诊记录退号操作只是把appointment表里的状态改成 2没有同时执行UPDATE schedule SET remain_slots remain_slots 1。这两条操作是不同的业务表非常容易被遗漏。解决查询排班的 SQL 里加上AND status 0条件退号这里必须用事务包裹两条 SQL并且在扣回号源前确认挂号单状态确实是 0否则用户重复点击退号按钮会导致号源无限加回去。判断逻辑是先UPDATE appointment SET status 2 WHERE id ? AND status 0看影响行数等于 1 才去UPDATE schedule加号源等于 0 说明已经退过了直接返回“已退号”。6. 进阶并发挂号与停诊退号的扩展点怎么让这套系统更经得起追问课设答辩或者门诊实际使用时最常被追问的就是并发问题这里有一个真正显功力的扩展点把退号后的号源回流做成不超卖的回补方案。很多人的做法是用户在页面点击退号Servlet 收到请求后 update 挂号单状态为 2再 update 排班表remain_slots 1。但如果用户手滑点了两次退号两个请求同时到达第一次已经改状态第二次依然走到 update号源就被错误地多加了一次。所以要认真写这段逻辑用“状态拦截 事务”来兜底我在第 5.5 小节里已经给出的思路实际代码可以直接这样组织。真正值得考虑的是给整个系统加一层缓存来抗并发峰值。挂号这个动作天然高并发集中在早上 8 点到 9 点之间Tomcat 默认线程池 200 个连接理论并发能力已经能扛住多数门诊部场景。但数据库连接池的最大连接数如果只有 20 个高峰期 20 个请求同时执行事务其余的请求就会排队等待。可以把maxTotal调大到 50 或 100同时把 Redis 引入作为号源展示层的缓存用户查看排班时读的是 Redis 里的remain_slots真正挂号时再回源数据库扣减。这样列表页不会把数据库读夸核心的扣减仍然由第 3 章那条 UPDATE 保证安全。另一个容易被忽略的进阶点是停诊自动退号。管理员把某排班置为停诊时往往还需要通知所有已挂号的患者。在 Servlet 层做这件事很容易在一个事务里把该排班下所有status 0的挂号单批量更新为 2然后把remain_slots清零status置为 1。通知逻辑可以放在更新之后异步执行项目不大时直接用内存线程池即可。如果你在答辩或汇报时能展示这两块一条 UPDATE 的原子扣减、一次事务里的批量退号导师不会再怀疑你是不是只抄了个 CRUD。这套系统做下来的诀窍其实很简单把业务约束交给数据库把并发控制交给条件更新把页面展示交给 JSTL不要把逻辑四处散落。我做完这个系统之后给自己留了一个习惯每隔半个月重新看一遍schedule表上的 WHERE 条件检查有没有哪里漏了状态过滤因为这种系统往往不是第一次上线时出的问题而是某次加需求改代码时不小心带进去的。希望帮到你。本文还有配套的精品资源点击获取