ARTICLE DETAIL

资讯详情

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

基于Java的医院预约挂号系统实战:排班、锁号与防超卖

基于Java的医院预约挂号系统实战:排班、锁号与防超卖 简介这份资源是基于Java的医院预约挂号系统完整源码包面向具备一定Java Web基础、希望深入理解大型医疗类应用架构的开发者与计算机专业学生。系统经过实际测试功能完备且界面美观涵盖用户注册登录与权限控制、医生信息维护与出诊时间设置、患者浏览医生并选择时段预约、数据库表结构设计、前后端动态交互、异常处理与日志记录、SQL注入与XSS防护以及缓存、负载均衡等性能优化思路可作为课程设计、毕业设计或二次开发的参考蓝本。压缩包为zip格式整体约37.3MB文件总数与类型明细上游暂未提供但可预期包含Java源码、配置文件及前端静态资源等。目前已有449人学习下载读者可通过研读源码掌握MVC设计模式、持久层框架应用、数据库设计与操作、安全性控制及服务化拆分等实践知识对提升Java开发技能和理解大型系统架构有较大帮助。1. 医院预约挂号系统用 Java 落地从一堆“八股文”到能跑起来的排班与锁号很多 Java 开发者做过的第一个“像样”的项目就是医院预约挂号系统。它不像商城那样堆页面也不像后台管理那样只做增删改查它真正难的地方在于号源是有限的、并发是真实的、时间是不能回退的。一个科室一天放 40 个号几百个人同时点谁拿到谁没拿到系统必须给出确定答案不能超卖也不能把已经取消的号锁死。这个标题里的“基于 Java”不是随便挂个语言标签它意味着你会用到 Spring Boot 做服务、MyBatis 做持久化、MySQL 做事务还要处理定时任务释放过期号源、行级权限区分患者/医生/管理员。适合谁适合已经会写 Controller 和 Service、但没认真想过“并发扣减”和“状态机”的 Java 工程师。下面按我实际做过的路径把选型、建表、锁号、排班、避坑一次讲透。2. 先定边界号源模型、状态机与 Java 技术栈选型2.1 号源不是库存排班表才是根很多人一上来就建一张appointment表字段里塞doctor_id、visit_date、time_slot然后靠count(*)判断有没有满。这是第一个翻车点。号源的本质是“排班计划 号别容量”不是预约记录本身。正确的做法是先有schedule排班表描述某医生某天上午/下午放多少个号、每个号多少钱、是否开放再有schedule_slot号源明细或者直接用schedule的total_slots和available_slots做扣减。预约记录appointment只是结果不能拿它当库存。为什么强调这个因为医院有停诊、替诊、节假日调整。如果号源只存在于预约记录里停诊时你根本不知道原本放了多少号、还剩多少。排班表是“计划”预约表是“执行”两者分开状态才能对得上。状态机也要先画清楚。一个预约单常见状态PENDING_PAYMENT待支付、BOOKED已预约、CANCELLED已取消、COMPLETED已就诊、NO_SHOW爽约。状态流转必须是单向的比如BOOKED - CANCELLED可以CANCELLED - BOOKED绝对不行。我一般会在 Service 层写一个AppointmentStateMachine类用枚举和Map定义合法迁移任何更新前先校验避免 SQL 直接update status ?把状态改乱。2.2 Java 技术栈怎么选Spring Boot MyBatis MySQL 够用热搜里常出现“spring boot mybatis 的 java 开源多商户跨境商城源码下载”虽然场景不同但技术组合是通的。医院挂号系统我推荐Spring Boot 2.7 或 3.x看团队习惯。3.x 要求 Java 17新项目可以直接上。MyBatis-Plus 或原生 MyBatis。原生 MyBatis 对复杂锁号 SQL 控制更细MyBatis-Plus 写单表 CRUD 快。我一般混合用单表用 Plus扣减库存手写 XML。MySQL 8.0InnoDB 引擎事务隔离级别用默认的 REPEATABLE READ。别用 MyISAM它不支持行锁并发扣减必超卖。Redis 做分布式锁或缓存号源余量但注意Redis 不是必须的。单机 MySQL 用update ... where available_slots 0就能扛住一般三甲医院日门诊量。上 Redis 是为了削峰和防重复提交不是替代数据库事务。定时任务用 Spring Task 或 Quartz。热搜里“java定时任务框架”问得多挂号系统里主要做两件事释放超时未支付的号、每天凌晨生成未来 7 天排班。简单场景 Spring Task 的Scheduled足够集群环境加分布式锁或改用 Quartz。提示不要一上来就上微服务。挂号系统核心表就几张单体 Spring Boot 加读写分离足够。拆微服务反而让事务一致性变难热搜里“java怎么保证数据一致性”在挂号场景就是本地事务加乐观锁别自己给自己加戏。2.3 数据库表设计四张核心表与关键字段下面是我实际用过的建表语句MySQL 8.0字符集utf8mb4。注意available_slots和version字段后面锁号要用。-- 医生表 CREATE TABLE doctor ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, title VARCHAR(50) DEFAULT NULL COMMENT 职称, department_id BIGINT NOT NULL, PRIMARY KEY (id), KEY idx_department (department_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 排班表某医生某天某时段放多少号 CREATE TABLE schedule ( id BIGINT NOT NULL AUTO_INCREMENT, doctor_id BIGINT NOT NULL, visit_date DATE NOT NULL, time_period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL, available_slots INT NOT NULL, fee DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1开放 0停诊, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_doctor_date_period (doctor_id,visit_date,time_period), KEY idx_date (visit_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 预约记录表 CREATE TABLE appointment ( id BIGINT NOT NULL AUTO_INCREMENT, schedule_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL COMMENT PENDING_PAYMENT/BOOKED/CANCELLED/COMPLETED/NO_SHOW, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, expire_time DATETIME DEFAULT NULL COMMENT 待支付过期时间, PRIMARY KEY (id), UNIQUE KEY uk_schedule_patient (schedule_id,patient_id,status), KEY idx_patient (patient_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 患者表简化 CREATE TABLE patient ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL, id_card VARCHAR(18) NOT NULL, phone VARCHAR(20) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_id_card (id_card) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明schedule表的available_slots是扣减目标version用于乐观锁。appointment表的唯一索引uk_schedule_patient防止同一患者对同一排班重复预约但注意status参与唯一索引会导致取消后还能再约这是故意的——取消后可以重新预约但同一状态不能重复。参数上time_period用 TINYINT 省空间fee用 DECIMAL 不用 FLOAT金额计算不能有误差。注意唯一索引里带status字段如果业务要求“取消后不能立即再约同一医生同一天”那就要在 Service 层加时间窗口判断不能只靠数据库。3. 核心链路实现锁号、防超卖与定时释放3.1 扣减号源一条带条件的 UPDATE 比任何锁都稳热搜里“java怎么保证数据一致性”在挂号系统里最直接的答案就是用数据库行锁加条件更新。不要先select再update那是典型的超卖写法。正确做法UPDATE schedule SET available_slots available_slots - 1, version version 1 WHERE id #{scheduleId} AND available_slots 0 AND status 1;在 MyBatis 里这样写update iddecreaseSlot UPDATE schedule SET available_slots available_slots - 1, version version 1 WHERE id #{scheduleId} AND available_slots 0 AND status 1 /updateService 层调用Service public class AppointmentService { Autowired private ScheduleMapper scheduleMapper; Autowired private AppointmentMapper appointmentMapper; Transactional(rollbackFor Exception.class) public Long book(Long scheduleId, Long patientId) { // 1. 扣减号源返回影响行数 int affected scheduleMapper.decreaseSlot(scheduleId); if (affected 0) { throw new BizException(号源已满或已停诊); } // 2. 插入预约记录状态待支付 Appointment appt new Appointment(); appt.setScheduleId(scheduleId); appt.setPatientId(patientId); appt.setStatus(PENDING_PAYMENT); appt.setExpireTime(LocalDateTime.now().plusMinutes(15)); appointmentMapper.insert(appt); return appt.getId(); } }逻辑说明decreaseSlot的WHERE available_slots 0是原子判断InnoDB 会对这一行加排他锁并发时只有一个事务能成功。affected 0说明号没了直接抛异常回滚。参数上Transactional必须加rollbackFor Exception.class否则受检异常不回滚。expireTime设 15 分钟给患者支付留时间。提示如果 QPS 很高这一行会成为热点。常见优化是把号源按时间段拆成多行比如每 10 个号一行扣减时随机选一行降低锁冲突。但大多数医院上午高峰也就几百并发单行足够。3.2 防重复预约唯一索引加 Redis 预校验同一患者对同一排班重复点击光靠数据库唯一索引会抛异常体验差。我一般在前置加一层 Redis 校验public boolean tryLockPatient(Long scheduleId, Long patientId) { String key book:lock: scheduleId : patientId; Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, 1, Duration.ofSeconds(10)); return Boolean.TRUE.equals(ok); }逻辑说明setIfAbsent对应 Redis 的SET NX EX10 秒内同一患者同一排班只能请求一次。参数上过期时间不能太长否则患者取消后想重约会被挡住10 秒够覆盖一次点击到返回。注意这层只是防抖最终一致性还是靠数据库唯一索引。3.3 定时释放超时未支付号源待支付订单 15 分钟不支付号源要还回去。用 Spring Task 每分钟扫一次Component public class AppointmentExpireTask { Autowired private AppointmentMapper appointmentMapper; Autowired private ScheduleMapper scheduleMapper; Scheduled(cron 0 * * * * ?) Transactional(rollbackFor Exception.class) public void releaseExpired() { ListAppointment expired appointmentMapper .selectExpiredPending(LocalDateTime.now()); for (Appointment appt : expired) { // 1. 更新预约状态为已取消 int updated appointmentMapper.cancelIfPending(appt.getId()); if (updated 0) { continue; // 已被其他线程处理 } // 2. 号源加回 scheduleMapper.increaseSlot(appt.getScheduleId()); } } }对应的 SQLselect idselectExpiredPending resultTypeAppointment SELECT * FROM appointment WHERE status PENDING_PAYMENT AND expire_time lt; #{now} LIMIT 200 /select update idcancelIfPending UPDATE appointment SET status CANCELLED WHERE id #{id} AND status PENDING_PAYMENT /update update idincreaseSlot UPDATE schedule SET available_slots available_slots 1, version version 1 WHERE id #{scheduleId} AND available_slots lt; total_slots /update逻辑说明cancelIfPending带状态条件防止把已支付的订单误取消。increaseSlot带available_slots total_slots防止加回超过总号源。参数上LIMIT 200避免一次扫太多锁表每分钟跑一次200 条够用。如果集群部署这个定时任务要加分布式锁否则多个节点同时跑会重复加号。注意Scheduled默认单线程如果上一个任务没跑完下一个会等。释放任务要快别在里面调远程接口。4. 排班生成、权限控制与接口防爬4.1 批量生成未来 7 天排班医生排班通常是固定周期比如每周一上午出诊。我一般写一个生成器按医生配置的模板批量插入public void generateSchedule(Long doctorId, LocalDate startDate, int days) { ListSchedule list new ArrayList(); for (int i 0; i days; i) { LocalDate date startDate.plusDays(i); // 假设模板周一至周五上午放 20 个号 if (date.getDayOfWeek().getValue() 5) { Schedule s new Schedule(); s.setDoctorId(doctorId); s.setVisitDate(date); s.setTimePeriod(1); s.setTotalSlots(20); s.setAvailableSlots(20); s.setFee(new BigDecimal(50.00)); s.setStatus(1); list.add(s); } } scheduleMapper.batchInsert(list); }逻辑说明batchInsert用INSERT INTO ... VALUES (...),(...)批量插入比循环单条快一个数量级。参数上days一般 7 到 14太长患者看不到太短来不及约。注意唯一索引uk_doctor_date_period会挡住重复生成所以生成前先查一下已存在的日期或者用INSERT IGNORE。4.2 行级权限患者只能看自己的预约热搜里“行级权限java”在挂号系统里就是数据隔离。患者查预约列表时SQL 必须带patient_idselect idselectByPatient resultTypeAppointmentVO SELECT a.*, s.visit_date, s.time_period, d.name AS doctor_name FROM appointment a JOIN schedule s ON a.schedule_id s.id JOIN doctor d ON s.doctor_id d.id WHERE a.patient_id #{patientId} ORDER BY a.create_time DESC /select逻辑说明patientId从登录态里取不能从请求参数取否则改个 ID 就能看别人预约。参数上分页用LIMIT别一次查全部。医生角色查自己排班的预约加doctor_id条件管理员才能查全量。4.3 接口防爬Controller 层做频率限制热搜里“java controller层 如何防护 防止爬虫”在挂号场景很实际因为号贩子会脚本抢号。我一般用拦截器加 Redis 计数Component public class RateLimitInterceptor implements HandlerInterceptor { Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String ip request.getRemoteAddr(); String key rate:book: ip; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, Duration.ofMinutes(1)); } if (count ! null count 10) { response.setStatus(429); return false; } return true; } }逻辑说明同一 IP 每分钟最多 10 次预约请求超过返回 429。参数上阈值根据医院实际调整太小会误伤正常用户太大挡不住脚本。注意getRemoteAddr在代理后拿到的是代理 IP需要配合X-Forwarded-For解析但别完全信任请求头容易被伪造。提示防爬只是提高成本不能根治。更有效的是实名认证加短信验证码但会降低体验。我一般只在抢号高峰开启验证码。5. 避坑与排查那些让我加班到凌晨的挂号问题5.1 号源超卖现象是 available_slots 变成负数现象并发压测时available_slots出现 -1、-2。原因用了先查后改的写法两个线程同时查到 1都以为还有号。解决改成UPDATE ... WHERE available_slots 0用影响行数判断。如果已经出现负数先手动UPDATE schedule SET available_slots 0 WHERE available_slots 0再补上唯一约束或检查代码。5.2 重复预约同一患者出现两条 BOOKED 记录现象患者点两次生成两条已预约。原因唯一索引uk_schedule_patient带了status两条都是BOOKED应该被挡住但如果第一次插入后状态还没提交第二次在另一个事务里查不到。解决确保唯一索引包含schedule_id和patient_id和status并且插入时捕获DuplicateKeyException返回友好提示。Redis 预校验只能减少不能替代数据库约束。5.3 定时任务重复释放号源加多了现象集群部署两个节点同一个过期订单被两个节点同时处理号源加回两次。原因Scheduled在每个节点都跑。解决加分布式锁用 RedisSET NX或数据库行锁。我一般用 Redis 锁包住整个释放任务锁过期时间设 5 分钟比任务执行时间长。5.4 支付回调后状态没更新患者付了钱还是待支付现象支付成功回调到了但appointment状态还是PENDING_PAYMENT定时任务把号释放了。原因回调接口没做幂等或者回调时订单已经被释放。解决回调里先查订单状态只有PENDING_PAYMENT才更新为BOOKED并且更新时带状态条件WHERE status PENDING_PAYMENT。如果返回 0 行说明已被释放要触发退款流程。5.5 停诊后已预约患者没通知现象医生临时停诊schedule.status改成 0但已经预约的患者不知道。原因只改了排班状态没处理关联预约。解决停诊时批量查该排班下BOOKED的预约发短信或站内信通知并允许患者改约。改约逻辑是取消原预约加号源再走新预约流程。别直接删预约记录否则对账对不上。6. 进阶技巧用状态机加对账任务兜住最后一道防线挂号系统跑久了最怕的不是并发而是状态不一致。我后来养成的习惯是任何状态变更都走状态机并且每天凌晨跑一个对账任务。状态机用枚举加Map定义合法迁移比如public enum AppointmentStatus { PENDING_PAYMENT, BOOKED, CANCELLED, COMPLETED, NO_SHOW; private static final MapAppointmentStatus, SetAppointmentStatus TRANSITIONS new HashMap(); static { TRANSITIONS.put(PENDING_PAYMENT, Set.of(BOOKED, CANCELLED)); TRANSITIONS.put(BOOKED, Set.of(CANCELLED, COMPLETED, NO_SHOW)); TRANSITIONS.put(CANCELLED, Set.of()); TRANSITIONS.put(COMPLETED, Set.of()); TRANSITIONS.put(NO_SHOW, Set.of()); } public boolean canTransferTo(AppointmentStatus target) { return TRANSITIONS.getOrDefault(this, Set.of()).contains(target); } }逻辑说明canTransferTo在 Service 层更新前调用不合法直接抛异常。参数上TRANSITIONS用Set.of不可变集合防止运行时被改。这个类很小但能挡住 90% 的状态乱改。对账任务每天凌晨 3 点跑检查三件事第一schedule.available_slots是否等于total_slots减去该排班下BOOKED和PENDING_PAYMENT的数量第二appointment里PENDING_PAYMENT且expire_time已过的是否都被取消第三已取消的预约是否都加回了号源。发现不一致就告警人工介入。我一般用 SQL 直接查-- 检查号源余量是否对得上 SELECT s.id, s.available_slots, s.total_slots - COUNT(a.id) AS expected FROM schedule s LEFT JOIN appointment a ON a.schedule_id s.id AND a.status IN (BOOKED, PENDING_PAYMENT) WHERE s.visit_date CURDATE() GROUP BY s.id HAVING s.available_slots ! expected;逻辑说明expected是理论剩余号源和available_slots比对不一致就是有问题。参数上只查visit_date CURDATE()的未来排班历史排班不查。这个查询在数据量大时加LIMIT别全表扫。最后说个我自己的习惯每次上线新版本前先跑一遍并发压测用 JMeter 或ab都行重点看available_slots会不会负、重复预约会不会出现。别等线上出问题再回滚挂号系统停半小时就是事故。希望帮到你。本文还有配套的精品资源点击获取
返回列表