Java Web门诊预约系统实战:从表设计到防超卖

发布时间:2026/10/4 14:14:10
Java Web门诊预约系统实战:从表设计到防超卖 简介本资源为基于SSM框架的医院门诊在线预约挂号管理系统完整项目代码面向计算机相关专业毕业设计学生及Java Web初学者帮助解决挂号预约类选题从设计到实现的全流程需求。项目采用Java语言开发技术栈涵盖Spring、SpringMVC、MyBatisPlus、Vue、Ajax、Maven与MySQLJDK版本为1.8数据库为MySQL 5.7可在Eclipse、MyEclipse或IDEA中运行。压缩包共640个文件约15.61MB其中152个java文件承载后端业务逻辑111个vue文件与44个js文件构成前端交互界面另有svg、jpg、png等图片素材及xml、css、sql等配置与样式文件并附有项目说明文档。系统实现用户信息管理、图片与视频素材管理等功能模块目录结构清晰便于按模块阅读与二次开发。目前已有140人学习下载适合需要完整赛题方案、代码参考与排错思路的读者使用。1. 从挂号窗口到浏览器一套 Java Web 门诊预约系统到底要解决什么医院门诊大厅早上七点半的场面做过医疗信息化的人都懂挂号窗口前排着长队导医台被围得水泄不通患者手里攥着身份证和医保卡反复问「今天还有没有专家号」。这套「医院门诊在线预约挂号管理系统」要干的事就是把这套线下排队逻辑搬到浏览器里——患者打开网页选科室、选医生、选时段、提交预约后台把号源扣减、生成挂号单、推给医生工作站。它属于典型的 Java Web 企业级项目技术栈通常落在 Spring Boot MyBatis MySQL 前端模板或前后端分离这一套上也是很多 Java 开发工程师面试时被追问「你做过什么项目」的高频答案。这篇文章面向三类人想拿它当课程设计或毕业设计的在校生、想复刻一套练手项目的 Java 初学者、以及需要快速评估这套系统能不能落地的初级开发。我会按「业务模型怎么建 → 数据库怎么设计 → 核心接口怎么写 → 并发和号源怎么防超卖 → 上线前怎么排查」的顺序讲中间给可抄的代码和参数最后收在几个能直接用的调优技巧上。不吹架构只讲能跑起来的东西。2. 先把业务模型立住科室、医生、排班、号源四张核心表怎么设计很多人一上来就写 Controller结果写到一半发现「一个医生一天到底放多少个号」这个问题没想清楚返工重来。血泪经验是挂号系统的复杂度不在增删改查而在「号源」这个会随时间变化、会被并发争抢的实体。所以先把领域模型定死再动手写代码。2.1 四个核心实体和它们的关系一套门诊预约系统剥掉花哨功能核心就是四个实体科室Department一级科室如内科、外科下面可能还有二级科室如心血管内科。用parent_id做自关联树。医生Doctor属于某个科室有职称主任医师/副主任医师/主治医师、挂号费、简介。排班Schedule医生在某个日期、某个时段上午/下午出诊这是「医生」和「时间」的交叉点。号源Slot / 号别排班下的具体号比如上午 8:00-8:30 有 5 个号。号源是真正被扣减的对象。关系是科室 1:N 医生医生 1:N 排班排班 1:N 号源。预约记录Appointment挂在号源上一个号源被预约一次就减一。提示不要把「号源数量」直接做成排班表的一个remain_count字段就完事。真实门诊里不同时段号量不同且退号要能精确回补到原时段所以号源最好独立成表一行代表一个可预约单元。2.2 建表 SQL 与字段说明下面是我一般会用的最小可用表结构MySQL 8.0 语法-- 科室表 CREATE TABLE department ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL COMMENT 科室名称, parent_id BIGINT DEFAULT 0 COMMENT 父科室ID0为顶级, sort_no INT DEFAULT 0 COMMENT 排序, status TINYINT DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 医生表 CREATE TABLE doctor ( id BIGINT PRIMARY KEY AUTO_INCREMENT, dept_id BIGINT NOT NULL COMMENT 所属科室, name VARCHAR(32) NOT NULL, title VARCHAR(32) COMMENT 职称, reg_fee DECIMAL(8,2) DEFAULT 0 COMMENT 挂号费, intro VARCHAR(512), status TINYINT DEFAULT 1, KEY idx_dept (dept_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排班表医生 日期 时段 CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, work_date DATE NOT NULL COMMENT 出诊日期, period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL COMMENT 总号量, UNIQUE KEY uk_doc_date_period (doctor_id, work_date, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 号源表排班下的具体号 CREATE TABLE slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, seq_no INT NOT NULL COMMENT 第几号, start_time TIME COMMENT 就诊开始时间, status TINYINT DEFAULT 0 COMMENT 0可约 1已约 2锁定 3停诊, version INT DEFAULT 0 COMMENT 乐观锁版本号, UNIQUE KEY uk_sch_seq (schedule_id, seq_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段设计里有几个点值得说清楚。schedule上的唯一索引uk_doc_date_period保证同一个医生同一天同一时段不会重复排班这是数据一致性的第一道闸。slot表用seq_no表示第几号配合start_time就能给患者展示「8:00 第 1 号、8:15 第 2 号」这种精确到分钟的候诊时间。version字段是为后面乐观锁准备的先埋上。2.3 号源生成排班创建后怎么批量铺号排班建好后号源不是手动一条条插的而是按规则批量生成。常见做法是一个时段固定时长比如上午 8:00-12:00 共 240 分钟总号量 N则每个号间隔240/N分钟。/** * 根据排班批量生成号源 * param schedule 排班对象含 totalSlots 和 period */ public void generateSlots(Schedule schedule) { int total schedule.getTotalSlots(); // 上午从 8:00 开始下午从 14:00 开始 LocalTime start schedule.getPeriod() 1 ? LocalTime.of(8, 0) : LocalTime.of(14, 0); int interval 240 / total; // 每个号间隔分钟数 ListSlot slots new ArrayList(total); for (int i 1; i total; i) { Slot s new Slot(); s.setScheduleId(schedule.getId()); s.setSeqNo(i); s.setStartTime(start.plusMinutes((long) interval * (i - 1))); s.setStatus(0); slots.add(s); } slotMapper.batchInsert(slots); // 建议用 foreach 批量插入 }逻辑说明interval用整数除法如果 240 不能被 total 整除会有余数实际项目里要么限制 total 是 240 的约数要么把余数摊到最后一个号。参数上period决定起始时间totalSlots决定号量这两个值来自排班录入界面。批量插入时记得在 MyBatis 里用foreach拼一条INSERT INTO slot (...) VALUES (...),(...),...比循环单条插入快一个数量级。3. 预约核心链路从选号到落库的接口怎么写模型立住之后真正体现功力的是预约这条链路。它要处理「查号 → 校验 → 扣减 → 生成挂号单 → 返回」五步每一步都有坑。3.1 查询可约号源的接口与分页患者进页面第一件事是查某医生某天还有哪些号。这个查询要快因为它是最高频的读操作。GetMapping(/slots) public ResultListSlotVO listSlots(RequestParam Long doctorId, RequestParam DateTimeFormat(patternyyyy-MM-dd) LocalDate date) { // 先查排班再查该排班下 status0 的号源 Schedule sch scheduleMapper.selectByDoctorAndDate(doctorId, date); if (sch null) { return Result.ok(Collections.emptyList()); } ListSlotVO slots slotMapper.selectAvailable(sch.getId()); return Result.ok(slots); }对应的 SQL 只查可约号避免把已约的也捞出来SELECT id, seq_no, start_time FROM slot WHERE schedule_id #{scheduleId} AND status 0 ORDER BY seq_no;逻辑说明这里没有做分页因为一个时段的号量通常不超过 50一次返回完全够用。如果号量很大比如某些专科一天几百号再加LIMIT。参数doctorId和date是前端传的注意日期格式用DateTimeFormat绑定否则LocalDate会报转换异常——这是新手最常翻的车之一。3.2 提交预约校验、扣减、生成挂号单提交预约是整个系统最需要保护的地方。核心逻辑是先校验号源是否可约再扣减再写预约记录三步必须在一个事务里。Transactional(rollbackFor Exception.class) public Appointment book(Long slotId, Long patientId) { // 1. 查号源带行锁悲观锁或后面用乐观锁 Slot slot slotMapper.selectByIdForUpdate(slotId); if (slot null || slot.getStatus() ! 0) { throw new BizException(该号源已被预约或不可约); } // 2. 扣减状态改为已约 int rows slotMapper.updateStatus(slotId, 1, slot.getVersion()); if (rows 0) { throw new BizException(号源已被抢走请重新选择); } // 3. 生成挂号单 Appointment appt new Appointment(); appt.setSlotId(slotId); appt.setPatientId(patientId); appt.setStatus(1); // 1已预约 appt.setCreateTime(LocalDateTime.now()); appointmentMapper.insert(appt); return appt; }逻辑说明selectByIdForUpdate走的是SELECT ... FOR UPDATE在事务内锁住这一行防止并发下两个请求同时读到status0。updateStatus里带上version做二次校验是悲观锁 乐观锁的双保险。参数slotId是号源主键patientId是当前登录患者。事务注解rollbackFor Exception.class必须加否则受检异常不会回滚这是 Java 事务的经典坑。3.3 退号与号源回补退号不是简单删记录而是把号源状态改回可约同时把预约记录标记为已取消。Transactional(rollbackFor Exception.class) public void cancel(Long appointmentId) { Appointment appt appointmentMapper.selectById(appointmentId); if (appt null || appt.getStatus() ! 1) { throw new BizException(预约不存在或已取消); } // 回补号源 slotMapper.updateStatus(appt.getSlotId(), 0, null); // 标记预约取消 appointmentMapper.updateStatus(appointmentId, 2); }逻辑说明回补时version传 null表示不校验版本直接改状态。这里有个业务规则要确认退号是否有时限比如就诊前 2 小时不能退。如果有就在cancel开头加时间判断。参数appointmentId是预约记录主键。4. 号源防超卖并发场景下的三种方案与选型挂号系统最怕的就是超卖——同一个号被两个人约到。这不是理论问题是真实门诊高峰期每秒几十个请求打进来时必然遇到的。下面三种方案我都用过说清楚各自适用场景。4.1 数据库悲观锁简单但吞吐有限就是上面SELECT ... FOR UPDATE的写法。优点是实现简单、绝对不超卖缺点是锁行期间其他请求阻塞高并发下响应变慢且必须在事务内使用事务一长锁就久。适用场景中小医院日预约量几千以内并发不高。参数上要注意FOR UPDATE必须命中索引否则会锁表——slot表按主键查天然走索引没问题。4.2 乐观锁适合读多写少乐观锁不加锁靠version字段在更新时校验UPDATE slot SET status 1, version version 1 WHERE id #{id} AND status 0 AND version #{version};如果返回影响行数为 0说明被别人抢先改了业务层重试或直接提示用户。优点是并发性能好缺点是冲突多时重试率高。参数version是查询时读到的值。适用场景号源竞争不是特别激烈或者你能接受用户重试。4.3 Redis 预扣减高并发下的常用做法真正扛高并发的方案是把号源库存放到 Redis用原子操作预扣再异步落库。// 号源初始化时写入 Rediskey slot:stock:{scheduleId} // value 剩余号量 public boolean preDeduct(Long scheduleId) { String key slot:stock: scheduleId; Long remain redisTemplate.opsForValue().decrement(key); if (remain null || remain 0) { // 扣成负数回补并拒绝 redisTemplate.opsForValue().increment(key); return false; } return true; }逻辑说明decrement是原子操作天然防并发。扣减成功后写一条消息到队列由消费者异步生成预约记录并更新数据库。参数scheduleId作为 key 维度也可以细到slotId。注意 Redis 和数据库的一致性——如果异步落库失败要有补偿机制把 Redis 库存加回去否则会少卖。注意三种方案不是互斥的。我一般会在 Redis 预扣这一层挡住绝大部分流量落库时再用乐观锁兜底双保险。5. 上线前必查挂号系统最容易翻车的五个坑功能写完不代表能用下面这几条是我踩过或见别人踩过的按「现象 → 原因 → 解决」列清楚。坑一并发下同一号源被约两次。现象是数据库里两条预约记录指向同一个slot_id。原因是没用锁或锁的范围不对两个事务同时读到status0。解决按第 4 章选一种防超卖方案最差也要在slot表加唯一约束UNIQUE(schedule_id, seq_no)配合状态更新。坑二退号后号源没回补号白白浪费。现象是患者退了号但该号显示不可约。原因是退号逻辑只改了预约状态忘了改slot.status。解决退号必须在一个事务里同时更新两张表且加日志记录回补动作。坑三日期时区导致排班查不到。现象是明明排了班前端查却是空。原因是数据库存的是DATEJava 用LocalDate但 JDBC 连接串没配时区或者前端传的是带时间的字符串。解决连接串加serverTimezoneAsia/Shanghai前端统一传yyyy-MM-dd后端用DateTimeFormat绑定。坑四事务里做了远程调用导致锁持有过久。现象是高峰期预约接口大面积超时。原因是在Transactional方法里调了短信、支付等外部接口事务迟迟不提交行锁一直不放。解决把远程调用挪到事务提交之后用TransactionSynchronizationManager的afterCommit回调或者干脆异步发消息。坑五号源批量生成时 total 为 0 导致除零。现象是创建排班直接 500。原因是240 / total里 total 为 0。解决入参校验totalSlots 0并在生成前判断别指望前端一定传对。6. 让这套系统更耐用的两个进阶技巧功能跑通只是及格线真正让一套挂号系统在生产环境站住脚的往往是几个不起眼的细节。这里说两个我反复用到的技巧。技巧一用数据库唯一索引做最后一道防线。不管你前面用了多严密的锁都建议在appointment表上加一个唯一索引UNIQUE(slot_id, status)的变体——更准确的做法是加一个「有效预约」的冗余列比如active_flag已取消的置 0有效预约置 1然后UNIQUE(slot_id, active_flag)。这样即使代码有 bug数据库也会拒绝第二条有效预约。这是黑匣子式的兜底出问题时至少数据不会脏。技巧二号源查询加本地缓存但退号要主动失效。号源列表是读多写少的典型可以在 Service 层用 Caffeine 做 5 秒本地缓存减少数据库压力。但退号、预约成功后必须主动invalidate对应 key否则患者会看到过期数据。参数上缓存时间别设太长5 到 10 秒足够因为号源变化频繁。// Caffeine 缓存示例 CacheLong, ListSlotVO slotCache Caffeine.newBuilder() .expireAfterWrite(5, TimeUnit.SECONDS) .maximumSize(1000) .build(); public ListSlotVO getSlotsWithCache(Long scheduleId) { return slotCache.get(scheduleId, id - slotMapper.selectAvailable(id)); } // 预约或退号成功后调用 public void evictSlotCache(Long scheduleId) { slotCache.invalidate(scheduleId); }逻辑说明expireAfterWrite控制写入后 5 秒过期maximumSize防止内存无限增长。get方法的第二个参数是加载函数缓存未命中时自动查库。参数scheduleId作为缓存 key粒度合适——太细按 slotId缓存条目爆炸太粗按医生失效范围过大。最后说个我自己的习惯每次改完预约或退号逻辑我都会写一个并发测试用 50 个线程同时抢同一个号跑 100 轮确认数据库里永远只有一条有效预约。这个测试比任何代码审查都管用后悔药就是提前把并发测了。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询