简介:这是一套基于Java的医院预约挂号系统设计源码,面向Java学习者、毕业设计者以及需要快速搭建线上挂号原型的技术人员。系统覆盖用户端与医院管理端常见流程,包括医生排班查询、在线预约、预约状态查看、后台预约信息管理等,有助于简化就医环节、提升就诊体验。资源包共47个文件,大小499KB,以22个Java源文件为主,辅以8个XML配置、9张PNG界面截图、1个SQL数据库脚本及工程配置文件,Java文件承载核心业务逻辑,XML主要涉及界面布局或框架配置,SQL脚本提供建表与初始数据,整体结构清晰,适合对照源码理解前后端交互与数据库设计。目前已有382人学习,参考者可直接导入开发环境运行调试,或结合截图还原界面并二次开发。该资源尤其适合课程设计、毕业设计选题参考,可帮助读者快速掌握预约挂号系统的模块划分、数据表设计及接口调用思路。
1. 基于Java的医院预约挂号系统设计:先从“号源池”说起
三甲医院的预约挂号高峰期,同一科室的号源往往在几十秒内被抢完。按“先到先得”的规则,系统真正要解决的并不是存储,而是“当前这 1 个号该给谁”的并发判断。基于Java实现的预约挂号系统,通常由Spring Boot暴露HTTP接口,MySQL保存排班与预约数据,Redis承担瞬时流量下的锁与排队,所有业务都围绕着一个“号源池”来展开。标题里的“设计源码”,并不是让你去抄一个完整项目,而是把数据模型、Service层逻辑、更新号源时的并发控制这一条主链拆清楚。读完之后,你能用最精简的代码搭出可运行的骨架,也能在面试中回答“预约挂号如何防止超卖”这类java面试经典问题。
2. 医院预约挂号系统的表结构与数据模型设计
2.1 为什么要把排班与号源拆开?
很多从后台管理系统转过来的开发者,第一反应是建一张appointment表,里面直接放doctor_name、department_name、remain_count。等做到高峰期,就会发现一个致命问题:同一个医生的同一个半天,剩余号数是共享的,如果只把它放在预约记录里,扣减时要么依赖一条独立的“排班行”,要么就只能读统计,根本无法止损。
常见做法是拆成至少三张业务表:doctor_schedule负责描述“哪个医生在哪个半天出诊”,appointment_record负责保存每一条预约请求的结果,中间通过schedule_id关联。doctor_schedule里的remaining_slots是号源池的副本,version字段为乐观锁预留;appointment_record里的status负责标识预约是否有效。这样拆开以后,预约流程就变成了:查排班 → 扣减号源 → 插入预约记录,三个动作处于同一个数据库事务里,任何一步失败都能整个回滚。
为什么不把号源再单独拆一张表?因为对于中小型医院或科室,一个排班行对应一个号源池已经足够;只有需要支持“分时段候诊”“每5分钟一个号”时,才值得进一步拆成schedule_slot。在设计源码时先控制住这个粒度,后面扩展会容易很多。
2.2 建表SQL:排班表与预约记录表
下面是一套可以直接跑在MySQL 8.0的建表语句。为了演示,我把医生信息表省略,用doctor_id代表外部主键。
CREATE TABLE doctor_schedule ( id BIGINT NOT NULL AUTO_INCREMENT, doctor_id BIGINT NOT NULL COMMENT '医生ID', department_id BIGINT NOT NULL COMMENT '科室ID', schedule_date DATE NOT NULL COMMENT '出诊日期', period_type TINYINT NOT NULL COMMENT '1上午 2下午', total_slots INT NOT NULL DEFAULT 0, remaining_slots INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, PRIMARY KEY (id), UNIQUE KEY uk_doctor_date_period (doctor_id, schedule_date, period_type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='医生排班表'; CREATE TABLE appointment_record ( id BIGINT NOT NULL AUTO_INCREMENT, schedule_id BIGINT NOT NULL COMMENT '排班ID', patient_id BIGINT NOT NULL COMMENT '患者ID', patient_name VARCHAR(64) NOT NULL, appointment_no VARCHAR(32) NOT NULL COMMENT '预约号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0有效 1已取消', created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_schedule_appt_no (schedule_id, appointment_no), KEY idx_patient_time (patient_id, created_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';这里有两个关键设计。第一,doctor_schedule上加了uk_doctor_date_period唯一键,保证同一医生同一半天最多一条排班,后面用INSERT ... ON DUPLICATE KEY UPDATE初始化新的排班日期也很方便。第二,appointment_record的预约号采用schedule_id + 日期 + 自增序列生成,虽然表里也有自增主键,但对外暴露的预约号最好带上排班维度,方便患者取号时快速定位。
remaining_slots是核心字段,任何预约请求都必须先在这一行上做条件更新,而不是先查出总数再在Java代码里减一。后文的核心实现完全依赖这一点。
2.3 关键接口设计与参数表格
基于该表结构的REST接口,不需要设计得大而全,只要覆盖四个动作:查询可约排班、创建预约、取消预约、查询历史记录。下表是接口参数约定。
| 接口 | 方法 | 核心参数 | 返回说明 |
|---|---|---|---|
/api/schedules?doctorId&date | GET | doctorId(可选)、date(必填) | 返回可约排班列表及剩余数 |
/api/appointments | POST | scheduleId、patientId、patientName | 返回预约记录ID与预约号 |
/api/appointments/{id}/cancel | POST | id(路径参数) | 返回取消后的剩余号数 |
/api/patients/{patientId}/appointments | GET | patientId | 返回历史预约列表 |
查询接口不需要加锁,直接走MySQL普通索引;写接口则需要严格约束。实际开发中,GET /api/schedules会因为“上午/下午”“专家/普通”等维度产生复杂的动态SQL,可以在Mapper里用<where>标签拼条件,不影响核心表结构。
3. 核心预约流程的Java实现:从Controller到Service
3.1 Controller层:参数校验不要放在Service里
预约接口的入参只有scheduleId、patientId、patientName三个字段,但Java后端仍然要在Controller层做基础校验,避免脏数据直接进入事务。常见做法是使用jakarta.validation注解,把“字段必须填”这类非业务规则挡在Service之前。
@RestController @RequestMapping("/api/appointments") @RequiredArgsConstructor public class AppointmentController { private final AppointmentService appointmentService; @PostMapping public Result<Long> create(@RequestBody @Valid AppointmentCreateCmd cmd) { return Result.ok(appointmentService.createAppointment(cmd)); } @Data public static class AppointmentCreateCmd { @NotNull(message = "scheduleId不能为空") private Long scheduleId; @NotNull(message = "patientId不能为空") private Long patientId; @NotBlank(message = "patientName不能为空") private String patientName; } }这段代码的意图很简单:让Controller只负责HTTP协议转换,把业务判断下沉到Service。Result是统一返回体,这里略去。注意@RequiredArgsConstructor来自Lombok,它会在编译期生成构造器,避免写一长串手动注入代码。
3.2 Service层:先锁号源,再插入预约记录
真正决定系统是否正确的地方在createAppointment方法里。下面用“先更新号源、后插入记录”的顺序,让MySQL的行锁帮助系统挡住同一时间的两个请求。
@Slf4j @Service @RequiredArgsConstructor public class AppointmentService { private final DoctorScheduleMapper scheduleMapper; private final AppointmentRecordMapper appointmentMapper; @Transactional(rollbackFor = Exception.class) public Long createAppointment(AppointmentCreateCmd cmd) { DoctorSchedule schedule = scheduleMapper.findByIdForUpdate(cmd.getScheduleId()); if (schedule == null) { throw new BusinessException("排班不存在"); } if (schedule.getRemainingSlots() <= 0) { throw new BusinessException("号源已满"); } int updated = scheduleMapper.deductSlotsById(cmd.getScheduleId(), schedule.getVersion()); if (updated != 1) { throw new BusinessException("号源已被其他人抢走,请刷新后重试"); } AppointmentRecord record = new AppointmentRecord(); record.setScheduleId(cmd.getScheduleId()); record.setPatientId(cmd.getPatientId()); record.setPatientName(cmd.getPatientName()); record.setAppointmentNo(generateAppointmentNo(schedule)); appointmentMapper.insert(record); return record.getId(); } }逐行说明:
scheduleMapper.findByIdForUpdate使用SELECT ... FOR UPDATE锁定排班行,锁持续时间到事务提交。对于绝大多数预约系统,行锁就够了。if (schedule.getRemainingSlots() <= 0)是在数据库层面之外再挡一层,防止有人把“号源已满”错误当成数据库异常,给用户更明确的提示。scheduleMapper.deductSlotsById(cmd.getScheduleId(), schedule.getVersion())返回受影响的记录数。核心SQL是UPDATE doctor_schedule SET remaining_slots = remaining_slots - 1, version = version + 1 WHERE id = ? AND remaining_slots > 0 AND version = ?。如果版本号对不上或没余号,受影响行数就是0,此时直接抛异常让事务回滚。- 预约号生成规则可以考虑:日期(8位)+ 排班ID(6位)+ 当日序号(4位),后续退号再追号时也能区分。
从源码可读性角度,这个Service方法只有5步,没有把锁、扣减、插记录的逻辑揉进SQL的存储过程里,维护起来更直接。
3.3 取消预约与号源回补的幂等设计
取消预约是另一个容易写错的地方。很多实现直接UPDATE appointment_record SET status=1 WHERE id=?,然后再UPDATE doctor_schedule SET remaining_slots = remaining_slots + 1 WHERE id=?。用户连续点击两次取消,会导致号源被重复加回。
@Transactional(rollbackFor = Exception.class) public Long cancelAppointment(Long appointmentId) { AppointmentRecord record = appointmentMapper.findById(appointmentId); if (record == null || record.getStatus() != 0) { throw new BusinessException("预约记录不存在或已取消"); } int rows = appointmentMapper.cancelByIdAndStatus(appointmentId, 0); if (rows != 1) { throw new BusinessException("预约已取消,请勿重复操作"); } scheduleMapper.addSlots(record.getScheduleId(), 1); return scheduleMapper.findById(record.getScheduleId()).getRemainingSlots(); }这里的关键是cancelByIdAndStatus(appointmentId, 0),它携带条件status = 0,只有当前状态是“有效”时才会成功,影响行数为1,否则说明已经取消,直接向下游返回错误,不再回补。通过这个条件,把幂等性从业务代码转移到了SQL语句,避免出现Java代码里查两次再判断才能解决的“检查后又取消”并发问题。
3.4 Mapper层:update语句的version条件
为了让上面的Service跑起来,至少需要两个Mapper方法。使用MyBatis注解可以缩短代码量,工程化之后还是建议转XML。
@Mapper public interface DoctorScheduleMapper { @Select("SELECT * FROM doctor_schedule WHERE id = #{id} FOR UPDATE") DoctorSchedule findByIdForUpdate(@Param("id") Long id); @Update("UPDATE doctor_schedule SET remaining_slots = remaining_slots - 1, " + "version = version + 1 WHERE id = #{id} AND remaining_slots > 0 AND version = #{version}") int deductSlotsById(@Param("id") Long id, @Param("version") Long version); @Update("UPDATE doctor_schedule SET remaining_slots = remaining_slots + 1 " + "WHERE id = #{id}") int addSlots(@Param("id") Long id); }deductSlotsById的条件里同时有remaining_slots > 0和version = #{version},即使没有SELECT ... FOR UPDATE,也能防止超卖。但两者结合的好处是:先加锁读取让用户看到的是相对新的号源,再通过条件更新处理极端竞争;如果系统QPS不高,可以在Service里去掉findByIdForUpdate,只做一次“查 + 条件更新”,也能保证正确性。
需要特别说明的是,FOR UPDATE会让同一时刻所有请求排队等待这一行锁。如果平均事务时长是20ms,理论吞吐不会超过50 TPS。这就是下一章要讲为什么还需要更高阶的并发控制。
4. 并发控制:从乐观锁到Redis分布式锁的选型
4.1 超卖问题的产生与为什么不能只用@Transactional
只要系统只有一个MySQL实例,采用行锁方案就不会超卖。但当流量上来后,SELECT ... FOR UPDATE会把所有请求串行化,即使只锁一个医生半天,40个号源也需要40次完整的事务提交。三甲医院早上8点的放号时刻,同一排班的竞争密度仍然很大。
有些开发者会试图通过“先SELECT再UPDATE”来减少锁范围,把findByIdForUpdate去掉。这是错误示范:两个请求同时读到remaining_slots=1,都通过Java判断,然后分别执行UPDATE ... WHERE id=?,第二个请求会把remaining_slots更新成0,而两个预约记录都插入成功,这就是典型的超卖。在MySQL默认的RR隔离级别下,@Transactional只能保证事务内的快照一致,替代不了“条件更新”本身的原子性。
4.2 乐观锁的适用边界:什么时候够用
在上文的实现里,version字段就是乐观锁。优点是代码足够简单,不需要额外引入中间件;缺点是更新失败后需要重试,或者直接提示用户稍后再试。对于单机部署、QPS在几百以内的预约系统,乐观锁完全够用。
我在给一些二级医院做方案时,会直接用remaining_slots > 0做条件,不保留version,理由是业务标注“号源满后必须等线下退号”。这种情况下连版本号都可以省略,因为条件更新天然提供了原子保护。可一旦需要根据旧状态计算“是否还能候补”“是否超过放号比例”,保留version会让逻辑更清晰。
4.3 基于Redis分布式锁的防重入实现
当预约服务要部署多个副本,或者同一个号源同时被多通道(窗口、App、电话)访问时,MySQL单行锁依然正确,但会把压力集中在数据库。常见做法是在进入Service前先抢一把Redis锁,抢不到的直接返回“正在排队”,把大量并发挡在MySQL之外。
@Service @RequiredArgsConstructor public class AppointmentLockService { private final StringRedisTemplate stringRedisTemplate; private static final String LOCK_PREFIX = "appt:lock:"; public boolean tryLock(Long scheduleId, String requestId, long expireSeconds) { String lockKey = LOCK_PREFIX + scheduleId; Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(success); } public void unlock(Long scheduleId, String requestId) { String lockKey = LOCK_PREFIX + scheduleId; String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "redis.call('del', KEYS[1]) " + "return 1 else return 0 end"; DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class); stringRedisTemplate.execute(script, Collections.singletonList(lockKey), requestId); } }requestId可以用UUID.randomUUID().toString().replace("-", ""),它防止误删别人持有的锁。释放锁时使用Lua脚本,是因为“判断是否自己的锁”和“删除锁”必须原子执行;分开两条命令在极端情况下会删掉刚被其他线程重入的锁。锁超时时间不要拍脑袋,需要参考业务高峰期的平均耗时:假设一次预约事务耗时50ms,超时设成3秒就有50倍余量,不会被慢SQL拖到自动释放。
一旦走到Redis锁,Service里仍然保留UPDATE ... WHERE remaining_slots > 0,原因很简单:Redis锁不是数据库锁,它只是降低并发碰撞概率,真正写库时要靠条件更新防住最终超卖,两层叠加才可靠。
4.4 锁参数与候补队列的设置建议
下面这张表是实际项目中常用的参数起点,具体数值要根据压测结果调整。
| 参数 | 推荐值 | 设置依据 |
|---|---|---|
| Redis锁过期时间 | 3-5秒 | 大于平均事务耗时的5-10倍 |
| 锁重试间隔 | 100-200ms | 避免请求在短时间内无意义自旋 |
| 队列最大长度 | 128或256 | 防止内存溢出,超出后直接拒绝 |
| MySQL连接池最大数 | 20-50 | 与Tomcat线程数匹配,不宜超数据库连接上限 |
协调好这几个参数后,预约系统就能做到:少量请求进入MySQL事务,绝大部分请求在Redis层排队或失败返回,数据库的remaining_slots始终不会为负数。面试中问“如何设计秒杀系统”,这个套路也能平移过去。
5. 从设计源码到可运行:Maven配置、启动参数与排错技巧
5.1 最小依赖清单与启动参数
一个新项目只需要在pom.xml中引入四个关键依赖:spring-boot-starter-web、mybatis-spring-boot-starter、mysql-connector-j、spring-boot-starter-data-redis。不要一次性引入spring-boot-starter-security,预约系统对外提供的接口通常由网关鉴权,Server端尽量保持简单。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>启动时建议加上-Dspring.profiles.active=dev,并确认数据源地址。常见误区是本地连不上远程MySQL时,只知道改application.yml,却忽略了机器防火墙和connectTimeout,这两个问题往往报错信息完全相同。开发阶段可以使用spring.datasource.hikari.connection-timeout=3000快速暴露网络问题,而不是让请求卡30秒后才失败。
5.2 常见运行时异常与排查命令
| 异常信息 | 可能原因 | 排查命令/步骤 |
|---|---|---|
Too many connections | 连接池泄漏或超过MySQL上限 | SHOW VARIABLES LIKE 'max_connections',查看Pool配置 |
Deadlock found when trying to get lock | 多张表更新顺序不一致 | SHOW ENGINE INNODB STATUS,检查LATEST DETECTED DEADLOCK |
Data too long for column | 预约号生成超过32字符 | 在发号器处加断点,打印生成结果 |
Redis connection refused | 未启动Redis或密码错误 | redis-cli -h host -p port ping |
5.3 验证号源不超卖的自查技巧
压测工具很多,但最朴素的验证方式是直接用bash循环发起100个并发请求,然后统计数据库中的有效预约记录数。写一个临时脚本,假设接口地址为http://localhost:8080/api/appointments。
for i in $(seq 1 100); do curl -s -X POST http://localhost:8080/api/appointments \ -H "Content-Type: application/json" \ -d '{"scheduleId":1,"patientId":'$i',"patientName":"test_'$i'"}' & done wait mysql -uroot -p -e "SELECT COUNT(*) FROM appointment_record WHERE schedule_id=1 AND status=0;"执行后,如果schedule_id=1的排班总数是40,那查询结果必须是40,多一条少一条都说明逻辑有问题。这里的patientId用循环变量生成,恰好能保证每条记录的患者ID不相同。你可以把这个思路延伸到多个线程同时抢同一位医生的不同号源,检查最终记录不会超过total_slots。
这样一套基于Java的医院预约挂号系统设计,从表结构到并发控制再到可运行配置,就形成了完整的闭环。据此写出的源码,已经能支撑一次真实的放号高峰。
本文还有配套的精品资源,点击获取