
在医院排队挂号的场景每个人都不陌生凌晨抢号、窗口排长队、医生临时停诊、患者跑冤枉路……这些矛盾最终都指向一个问题号源这种稀缺医疗资源缺乏一个公开、可控、可追溯的分配机制。基于JAVA的医院预约挂号管理系统就是把科室、医生、排班、号源、预约、支付、就诊记录这一整条链路搬到线上让患者提前锁定时段让医生按固定模板出诊让管理员实时掌握全院号源状态。它不是普通练手项目里的增删改查真正的难点在于排班建模、号源冲突控制、状态流转和异常兜底。这篇文章我会从实际落地角度把这套系统的核心设计、数据库表结构、并发防重方案、定时任务和常见坑位完整拆一遍适合正在做毕业设计、Java转行练手项目、或者帮中小医院做信息化方案的朋友对照复现。1. 项目定位与整体设计思路1.1 需求拆解医院排队的本质是号源分配问题我接手过不少类似项目很多人一上来就画表、写Controller结果做到一半发现预约这个动作远没有想象中简单。先理清需求背后的业务本质。患者端要的不是能挂号这三个字而是查科室、按日期筛选可约医生、看某个医生某天的剩余号源、选定一个时间段提交预约、支付、收到凭证、按时到院签到必要时退号。医生端要的是维护个人排班模板、临时停诊/加号、查看当日预约清单、记录接诊状态。管理员端要的是维护科室和医生资料、审核排班、查看全院预约统计、处理异常订单。这几条需求摆出来后你会发现核心不是用户管理也不是支付对接而是号源的生命周期管理。一个号源从排班产生、被锁定、被支付、被占用到退号释放、停诊作废每一步都牵扯跨表状态变更。设计系统的第一步就是把号源当作一个独立的资源对象来对待而不是简单在预约单上挂个医生ID。1.2 角色划分与核心流转链路系统内主要有三类角色患者普通用户、医生出诊人员、管理员平台运营方权限边界非常清楚。患者只能操作自己的预约单医生只能看自己的排班和接诊列表管理员负责基础数据维护。如果把业务链路画成一句话那就是管理员维护科室医生 → 医生提交排班模板 → 系统按模板生成某日期的号源 → 患者选择号源并创建预约单 → 支付成功后号源锁定 → 就诊日签到核销 → 过时未就诊标记爽约。这中间任何一步失败都要保证号源和预约单的状态不出现两边不一致。很多人会忽略爽约和退号这两个反向流程恰恰是它们最容易把数据搞乱。我的建议是给预约单定义一个状态机待支付、已支付、已取消、已完成、已爽约全部用状态字段表达禁止业务代码里直接删表记录。删除数据会破坏排班统计和就诊历史正确做法是逻辑取消。退号时不仅要把预约单置为已取消还要把对应号源恢复为可预约状态这个联动逻辑必须在同一个事务里完成。1.3 技术选型为什么Spring Boot MyBatis-Plus是稳妥组合这套系统技术栈选择上我推荐Spring Boot 2.7 MyBatis-Plus MySQL 8.0的组合前端可以用Vue 3 Element Plus做管理端小程序或手机H5给患者用。不选原生Servlet/JSP是因为Spring Boot的自动装配和内嵌Tomcat能大幅减少配置成本让项目重点落在业务逻辑上不选重一点的Spring Cloud是因为单体应用在中小医院场景下完全够用别给自己制造分布式难题。MyBatis-Plus相比原生MyBatis的优势很明显分页插件、逻辑删除、字段自动填充、条件构造器Wrapper都能直接省掉很多样板代码。特别是逻辑删除在预约系统里几乎是刚需——患者删除预约记录不影响管理员统计但物理删除会让报表断档。需要注意的是逻辑删除字段会影响唯一索引设计比如某个表如果对某医生某时段建了唯一索引逻辑删除后旧记录还在再去插入同一时段会撞索引这个问题我在后面第4节会展开说。数据库层面核心业务表加上必要的定时任务之间建议用一个独立的status字段配合update_time做幂等控制避免并发下重复支付或重复取消。整体来说技术选型的判断标准只有一个业务闭环能不能清晰落地而不是用了多新的框架。2. 数据库设计与核心表结构2.1 六大核心表的职责划分我把核心表按基础数据—业务数据—记录数据三层来设计。基础数据包括用户表、医生表、科室表业务数据包括排班表和号源表记录数据就是预约单表。实际做的时候医生表可以直接复用用户表加一个doctor_flag但为了权限清晰我建议独立拆表通过user_id关联避免一张表塞太多不同角色的字段。排班表负责定义哪个医生在哪个时间段出诊它存的是长期模板比如每周一上午。号源表才是真正可被预约占用的资源它由排班表在某个具体日期批量生成每个号源有唯一的时段起止时间、号源编号、状态和版本号。这样设计的好处是修改排班模板不影响已经产生的号源历史数据可追溯。预约表是核心中的核心。它至少要包含患者ID、号源ID、排班ID、医生ID、科室ID、预约日期、时段起止、状态、订单金额、创建时间、支付时间、取消时间。这个表要走索引尤其是(doctor_id, appoint_date, status)这个组合索引用于患者查询医生某天剩余号源以及(user_id, status)用于患者查看自己的预约历史。2.2 一张关键表预约单的状态与幂等保证预约单的状态我不建议用数字直接用字符串语义表达比如PENDING_PAY、PAID、CANCELLED、FINISHED、NO_SHOW。代码里用枚举统一管理数据库里存字符串取出来转枚举避免魔法值散落在代码里。最容易被忽视的是唯一约束。一个患者在同一时间段、同一医生下只能有一条预约这个约束必须在数据库层面兜底而不是只靠Service层if判断。我建议在预约表上建唯一索引(user_id, source_id)同时利用source_id本身一个号源只能被预约一次的逻辑在号源表上用状态字段控制。关于幂等前端提交预约时一定要带上一个客户端生成的requestId后端在创建预约单前先查这个requestId是否已存在存在就直接返回已创建的预约单。这样即使患者手抖连点两次提交、或者网络重试都不会产生重复订单。不要小看这一步真实场景下移动端弱网重试非常频繁。2.3 关键SQL可参考的结构长这样-- 排班表 CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, week_day TINYINT NOT NULL COMMENT 1-7 周一到周日, start_time TIME NOT NULL, end_time TIME NOT NULL, slot_minutes INT NOT NULL COMMENT 每个号源的时长单位分钟, total_slots INT NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_doctor_week (doctor_id, week_day) ) COMMENT 医生排班模板表; -- 号源表 CREATE TABLE schedule_source ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, appoint_date DATE NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, source_no INT NOT NULL COMMENT 当日第几个号源, status TINYINT NOT NULL DEFAULT 0 COMMENT 0可约 1锁定 2已用 3停诊, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_schedule_date_no (schedule_id, appoint_date, source_no), KEY idx_date_status (appoint_date, status) ) COMMENT 具体日期号源表; -- 预约单表 CREATE TABLE appointment_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, request_id VARCHAR(64) NOT NULL UNIQUE, user_id BIGINT NOT NULL, source_id BIGINT NOT NULL, schedule_id BIGINT NOT NULL, doctor_id BIGINT NOT NULL, appoint_date DATE NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, status VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL DEFAULT 0, pay_time DATETIME NULL, cancel_time DATETIME NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_status (user_id, status), KEY idx_doctor_date (doctor_id, appoint_date), UNIQUE KEY uk_user_source (user_id, source_id) ) COMMENT 预约订单表;这三张表搭起来之后整个预约主链路就能跑通了。相信我建表时多考虑联合唯一索引和状态字段后面写并发控制和统计查询时会省很多力气。3. 预约冲突控制并发与数据一致性3.1 超卖问题的本质同一号源被多人同时锁定先说清楚为什么这是个高并发问题。患者抢热门专家号时很可能有几十个人同时盯着同一个时段点提交。代码里最常见的错误写法是先select查号源状态如果可约就update预约再insert订单。这在并发场景下必然出问题——两个请求同时查到可约同时走插入逻辑最终一个号源被挂了两单。这本质上和电商秒杀的超卖问题一模一样检查与扣减不是原子操作。解决思路也很直接要么把检查和扣减放进同一个原子动作要么在数据库层面加约束让第二次操作直接失败。3.2 三种控制方案乐观锁、行级锁、Redis预扣减第一种是乐观锁。在号源表加version字段更新时带上版本条件UPDATE schedule_source SET status 1, version version 1 WHERE id #{sourceId} AND status 0 AND version #{oldVersion}受影响行数为1表示抢到了号为0表示被别人抢先。这种方案实现简单但高并发下会让大量请求做无效重试用户体验一般。第二种是行级锁也就是SELECT ... FOR UPDATE。开启事务后先把对应号源的行锁住再检查状态然后更新和插入订单最后提交释放锁。这是我在绝大多数项目里选用的方案因为挂号系统本身并发量远没有秒杀那么夸张数据库行锁完全扛得住而且代码可读性好Transactional public boolean createOrder(Long sourceId, Long userId) { ScheduleSource source sourceMapper.selectForUpdate(sourceId); if (source null || source.getStatus() ! 0) { return false; } sourceMapper.lockSource(sourceId); appointmentOrderMapper.insert(buildOrder(source, userId)); return true; }注意selectForUpdate的SQL一定要走主键或唯一索引否则会锁住整张表拖垮所有请求。这个细节排查起来很难发现因为只有压测时才能看到吞吐量骤降。第三种是Redis预扣减用DECR命令先占住号源异步回写数据库。这套方案适合大流量秒杀但引入Redis的同时引入了缓存一致性、过期、降级一堆问题。中小医院系统没必要上除非你明确为了简历上多写一行Redis。3.3 项目实践建议锁 唯一索引双重兜底我的最终方案是数据库行锁为主、唯一索引兜底。即使某段代码不小心漏了锁预约表上的(user_id, source_id)唯一索引也会让重复预约直接抛异常保证数据不会写坏。还有个细节容易被忽略锁和唯一索引要在同一个事务里生效。如果事务没提交锁不会释放唯一索引的检查也会读到旧数据。所以事务边界一定要把锁定号源和插入预约单包含在一起不能先insert再update更不能用REQUIRES_NEW把这两个操作拆开。我在初版就踩过这个坑检查发现更新成功了但订单没插进去最后定位到是事务传播级别设置错误。4. 医生排班与号源池设计4.1 排班模板不是每个医生每天都出诊医生排班在需求阶段很容易被简化为管理员给医生选个日期、填个时段但真实医院里医生是按每周固定出诊周期来工作的比如张三医生每周一和周四上午坐诊。如果让管理员每天手动给全院医生配号工作量会大到不现实。所以排班分两层模板层和实例层。模板层记录医生每周几出诊、出诊时段、单个号源时长、总号数实例层在某个具体日期由定时任务根据模板批量生成。举个例子模板是周一上午9:00-12:00号源时长20分钟那就会生成9:00-9:20、9:20-9:40、9:40-10:00等9个号源挂在具体日期下面。模板的好处还有一点医生临时停诊时只需停用当天的号源模板下周自动恢复不用重新建班。加号也简单管理员在实例层手动插入一个号源即可。需求变来变去的时候这套模型能扛住大多数变化。4.2 号源数量与时段计算号源数量不能直接写死要按出诊总时长除以单个号源时长来计算。定时任务生成号源时支持两种配置固定时段列表或者开始时间结束时间间隔分钟自动拆分。自动拆分时要注意边界比如9:00到12:00、间隔20分钟最后一个号源是11:40-12:00正好12:00结束不要多拆一个跨过边界的号源。每次生成号源前要先清掉该医生该日期下状态为可约的旧号源否则重复执行定时任务会导致号源翻倍。我习惯用schedule_id appoint_date做去重判断已存在有效号源就跳过只在没有号源时才生成。排班生成不是越频繁越好我设置的定时任务是每天凌晨2点生成未来7天的号源。4.3 冲突校验同一医生同一时段不能重叠医生可以在不同科室出诊吗现实里有可能但那意味着时段不能冲突。我的做法是在doctor_schedule表上做校验新增排班模板时检查该医生在同一个week_day下的时间段是否存在重叠。这里要注意一个MySQL日期函数容易踩的坑不能用字符串直接比较08:00和08:00:00的边界要把时间统一成TIME类型再用start_time new_end_time AND end_time new_start_time这个区间重叠条件来判断。简洁的表达式是start_time #{endTime} AND end_time #{startTime}两边都取开区间避免刚好相邻的两个时段被误判为冲突。5. 权限认证与前端接口设计5.1 JWT登录与角色鉴权多角色的系统一定要做权限控制。我选择的是JWT Spring Security的组合登录接口返回token后续请求在Header里带Authorization即可。JWT的无状态特性适合前后端分离服务端不需要保存会话也方便患者端在手机上长期保持登录状态。角色权限一共三级用枚举控制PATIENT、DOCTOR、ADMIN。医生接口和管理员接口都要求对应角色才能访问Spring Security里直接配置antMatchers按URL前缀区分模块比如/api/patient/、/api/doctor/、/api/admin/**。需要注意的是JWT里不要放敏感数据只放userId和role列表token过期时间患者端设7天管理端设2小时。5.2 患者端核心接口的响应设计接口设计遵循一个原则让前端少做计算。例如获取医生某天剩余号源这个接口不能只返回号源列表让前端自己数应该直接返回当天总号数、已约数、剩余数以及每个号源的可约状态。这样小程序前端渲染时直接展示剩余数量几乎不需要额外逻辑。再看创建预约接口的异常响应我统一使用Result 包装包含code、message、data。code为200表示成功业务异常用具体枚举码比如1001表示号源不可约、1002表示重复预约、1003表示排班已停诊。前端拿到1001时提示该号源已被抢走请刷新后重选这种体验比服务端抛一个500让前端弹系统错误强得多。5.3 管理端用到的统计报表管理员最关心的三件事各科室预约量排行、各医生出诊总量、每日预约转化率。这些统计SQL要提前写好别临时在mapper里拼字符串。例如科室预约量排行SELECT d.dept_name, COUNT(o.id) AS order_count FROM appointment_order o JOIN doctor_info d ON o.doctor_id d.id WHERE o.appoint_date BETWEEN #{startDate} AND #{endDate} AND o.status IN (PAID, FINISHED) GROUP BY d.dept_name ORDER BY order_count DESC报表查询请求量大但数据实时性要求不高建议加一层Redis缓存过期时间5到10分钟。我在实际项目中见过有人直接查MySQL做看板每次打开报表页面都把数据库打得告警加个过期缓存之后压力瞬间消失。6. 定时任务与消息通知6.1 超时未支付订单自动取消患者创建预约单后如果在15分钟内未支付系统要自动取消并释放号源。这个逻辑用Spring自带的Scheduled就能实现定时器每2分钟扫描一次待支付且创建时间超过15分钟的订单。SQL大致这样status PENDING_PAY AND create_time DATE_SUB(NOW(), INTERVAL 15 MINUTE)。拿到超时订单后遍历处理将预约单置为CANCELLED将对应号源状态改回可约。这两步必须放在同一个事务里。定时任务执行时要注意分布式环境下的重复执行问题如果以后部署多实例建议引入XXL-Job或者用MySQL行锁防止两个节点同时处理同一批订单。6.2 就诊前提醒推送提醒类消息我用的是阿里云短信和微信公众号模板消息双通道关键节点有三个预约成功通知、就诊前一天的提醒、医生停诊改签通知。提醒任务的SQL是查明天就诊且状态为PAID的订单在晚上8点统一发送。注意短信发送是慢操作要异步处理不要阻塞主流程。6.3 定时任务里的事务与异常捕获定时任务最容易出问题的地方是异常静默。我在初版就遇到过某天凌晨批量生成号源的代码抛了异常但外层try-catch把错误吞掉了结果全院第二天所有号源都没生成患者端一片空白直到早上才被电话打爆。所以定时任务里一定要做完整的log记录执行完成后写入一张task_log表记录每个批次生成的号源数、失败数、耗时和异常堆栈。宁可多打日志也不要让故障静默。7. 常见问题与排查技巧实录7.1 问题速查表我把开发过程中几个高频问题整理成表格遇到可以直接对照排查现象可能原因解决思路两个人同时抢到同一个号源未使用锁或唯一索引失效检查事务是否包裹住selectForUpdate和insert排班生成后号源翻倍定时任务未做存在性判断生成前按schedule_iddate查询去重已存在则跳过逻辑删除后插入同一时段报唯一索引冲突旧记录未真正删除且唯一索引包含逻辑删除字段把delete_flag加入唯一索引或在查询条件中过滤掉已删除记录统计报表打开很慢报表SQL没有索引且每次都查实时库加组合索引引入Redis过期缓存定时任务处理订单时有重复多实例同时执行任务用XXL-Job分片执行或在任务入口加分布式锁JWT过期后前端一直跳登录页前端未刷新token或未处理401错误全局拦截器判断401调用刷新接口失败再跳登录7.2 状态机与事务的经典坑位事务不起作用是最难排查的问题之一。我遇到过多个场景同一个类内部调用方法导致事务失效、catch了异常导致事务回滚不触发、错误地把RuntimeException try-catch吞掉。解决方案是事务方法放在不同类里通过Spring容器调用或者使用TransactionTemplate手动管理异常捕获后要么up要么手动回滚绝对不能直接吞掉。还有一个容易忽略的点是事务方法必须public不能是private。7.3 从开发到演示的注意事项如果这个项目用于毕设答辩或者面试展示我建议预先造好看的数据录入6到8个科室、每个科室2到3个医生、配置好一周的排班模板、手动生成未来三天的号源并造几条不同状态的预约单。演示时先展示排班生成前后的号源变化再现场演示两个账号同时抢同一号源时只有一方成功最后打开管理端看统计报表。这一套流程下来比任何口头解释都有说服力。至于演示环境本地用Docker跑MySQL和Redis前端用Nginx代理或直接npm run dev整体启动流程控制在三分钟内。答辩前务必跑一遍全部流程我见过太多人现场演示时因为数据库没启动、端口被占用、前端跨域配置错误而翻车这些都属于典型的环境问题提前检查比临时救场靠谱得多。做完这套系统我个人最深的体会是预约挂号的难点从来不在会写CRUD而在对业务状态流转的把控。号源从创建到锁定到释放每一步都要考虑如果用户此时取消了怎么办如果医生临时停诊怎么办如果两个操作同时到达怎么办。把这些边界问题想清楚系统才算真正可用。最后再分享一个小习惯每个核心接口的业务方法写完先别急着联调手动造几条异常数据走一遍流程看状态是否符合预期这个自测动作能省掉后期至少一半的联调沟通时间。整个设计思路同样适用于体检预约、疫苗预约、驾考预约这类场景核心逻辑只换一层皮就能复用。