news 2026/10/7 9:25:43

SpringBoot医院挂号系统实战:数据模型与并发扣减号源

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot医院挂号系统实战:数据模型与并发扣减号源

简介:这是一套基于SpringBoot的医院挂号就诊系统完整源码,面向计算机专业学生、Java初学者及需要课程设计或毕业设计参考的开发者,帮助解决挂号、就诊信息管理等场景下的系统搭建与学习需求。资源包共770个文件,约34.42MB,涵盖101个Java后端源码、60个Vue组件、157个JavaScript脚本、50个CSS样式及33个HTML页面,另含svg图标、gif与png图片素材、xml配置、yml配置、字体文件与少量音视频素材,前后端分离结构清晰。系统采用Java、SpringBoot、Vue、Ajax、Maven、MySQL与MyBatisPlus技术栈,配套文档包含绪论、相关技术介绍、可行性分析、系统流程、性能需求、整体结构与数据库设计等章节,并给出用户信息管理、图片素材管理、视频素材管理等模块实现。已有162人学习,适合需要完整项目结构、数据库设计与功能实现参考的读者,可据此快速理解挂号就诊业务的开发流程与代码组织方式。

1. 医院挂号就诊系统到底在解决什么问题:从排队三小时到点一下手机

早上七点半,门诊大厅已经排起长队,挂号窗口前挤满了人,而真正坐在诊室里的医生可能还没开始叫号。这个场景几乎每家二级以上医院都出现过,也是「医院挂号就诊系统」这类项目被反复提起的根本原因。它要解决的核心问题不是「把线下表格搬到线上」,而是把号源、患者、医生、科室、排班、缴费、就诊记录这几条数据流串成一条闭环:患者在线选科室和医生、锁定号源、生成挂号单、到院签到、医生叫号、开处方、缴费、取药。任何一个环节断掉,系统就退化成「一个能看不能用的网页」。

用 Java + SpringBoot 做这套系统,是当前高校课程设计和中小型项目里最常见的组合。原因很直接:SpringBoot 把 Spring 生态里最烦人的 XML 配置压成了注解和 starter,MyBatis 或 MyBatis-Plus 负责把数据库表映射成对象,前端用 Vue 或 Thymeleaf 都能接。对新手来说,能在一台普通笔记本上跑通;对熟手来说,这套结构能直接扩成真实业务。本文按「先想清楚数据模型 → 再搭工程 → 再写核心挂号逻辑 → 再避坑 → 最后做压测和扩展」的顺序讲,每一步都给可抄的代码和参数,读完你应该能自己从零搭出一个能挂号、能叫号、能查记录的版本。

2. 先把数据模型定死:号源、排班、挂号单三张表怎么设计

2.1 为什么表设计错了后面全是返工

我见过太多人一上来就写 Controller,结果写到「取消挂号要恢复号源」时发现表里根本没有可恢复的字段。医院挂号就诊系统的数据模型有三个核心实体:医生排班(schedule)、号源(source)、挂号单(registration)。排班描述「某医生在某天上午在某个科室出诊」,号源描述「这次排班放多少个号、已用多少个」,挂号单描述「某患者挂了某次排班的某个号」。

关键点在于:号源不要和排班合并成一张表。合并之后,一次排班只能有一个总号数,无法支持「普通号 20 个、专家号 5 个」这种同一时段多类型号源。拆开之后,排班是「时间地点人物」,号源是「库存」,挂号单是「流水」,三者职责清晰,取消挂号时只需要把对应号源的used_count减一,不用动排班。

2.2 建表 SQL 与字段说明

下面这套表结构是我在多个类似项目里验证过的精简版,字段够用且不臃肿。注意version字段,后面处理并发抢号要用。

-- 医生排班表 CREATE TABLE `schedule` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `doctor_id` BIGINT NOT NULL COMMENT '医生ID', `dept_id` BIGINT NOT NULL COMMENT '科室ID', `work_date` DATE NOT NULL COMMENT '出诊日期', `time_slot` TINYINT NOT NULL COMMENT '0上午 1下午', `status` TINYINT DEFAULT 1 COMMENT '1正常 0停诊', PRIMARY KEY (`id`), UNIQUE KEY `uk_doctor_date_slot` (`doctor_id`,`work_date`,`time_slot`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 号源表 CREATE TABLE `source` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `schedule_id` BIGINT NOT NULL, `type` TINYINT NOT NULL COMMENT '0普通 1专家', `total_count` INT NOT NULL COMMENT '总号数', `used_count` INT NOT NULL DEFAULT 0 COMMENT '已用号数', `price` DECIMAL(10,2) NOT NULL, `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', PRIMARY KEY (`id`), KEY `idx_schedule` (`schedule_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 挂号单表 CREATE TABLE `registration` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `patient_id` BIGINT NOT NULL, `source_id` BIGINT NOT NULL, `schedule_id` BIGINT NOT NULL, `reg_no` VARCHAR(32) NOT NULL COMMENT '挂号流水号', `status` TINYINT DEFAULT 0 COMMENT '0待就诊 1已就诊 2已取消', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_reg_no` (`reg_no`), KEY `idx_patient` (`patient_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

uk_doctor_date_slot这个唯一索引很关键,它从数据库层面挡住「同一医生同一天同一时段被排两次班」的脏数据。source表的version是乐观锁字段,registration表的reg_no是给患者看的挂号流水号,用时间戳加随机数生成即可,不要用自增 ID 直接暴露。

2.3 实体类与 MyBatis-Plus 映射

用 MyBatis-Plus 能省掉大量单表 CRUD 代码。实体类上@TableName对应表名,主键用@TableId(type = IdType.AUTO)。这里以号源为例:

@Data @TableName("source") public class Source { @TableId(type = IdType.AUTO) private Long id; private Long scheduleId; private Integer type; private Integer totalCount; private Integer usedCount; private BigDecimal price; @Version private Integer version; // 乐观锁,交给 MyBatis-Plus 处理 }

@Version注解配合 MyBatis-Plus 的OptimisticLockerInnerInterceptor插件,更新时会自动带上version条件。参数上要注意:usedCount和totalCount都用Integer而不是int,因为查询未命中时返回 null 比返回 0 更容易排查问题。实体类字段名和表字段的驼峰下划线转换由map-underscore-to-camel-case: true控制,在application.yml里默认开启,不用额外写@TableField。

3. 用 SpringBoot 搭出可运行骨架:依赖、配置、分层

3.1 依赖怎么选:别一上来就堆全家桶

新建 SpringBoot 项目时,spring-boot-starter-web、mybatis-plus-boot-starter、mysql-connector-java、lombok这四个是必需的。很多人会顺手把spring-boot-starter-security、spring-boot-starter-data-redis也加进去,结果项目还没跑起来就被安全拦截和 Redis 连接报错卡住。我的建议是:第一版只加必需依赖,跑通挂号主流程后再逐个引入。

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

版本上有个血泪经验:SpringBoot 3.x 要求 JDK 17 起步,而很多课程设计环境还是 JDK 8。如果你不确定环境,直接用 SpringBoot 2.7.x 配 JDK 8,兼容性最稳。MyBatis-Plus 3.5.x 对两者都支持,但 3.5.3.1 之后的版本对 SpringBoot 3 的适配更完整,选之前先确认你的 JDK 版本。

3.2 application.yml 里必须改的四个参数

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/hospital?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0

serverTimezone=Asia/Shanghai不加会报时区错误,这是新手最常见的翻车点。log-impl打开 SQL 打印,调试阶段必开,上线前关掉。逻辑删除字段deleted如果你表里没建,要么加上,要么把这三行删掉,否则 MyBatis-Plus 会往不存在的字段上拼条件。

3.3 分层结构:Controller 只做参数校验

标准分层是controller → service → mapper。Controller 里只做参数接收和校验,业务逻辑全部放 Service。挂号这种涉及库存扣减的操作,一定要放在 Service 层并加事务。

@RestController @RequestMapping("/api/registration") public class RegistrationController { @Autowired private RegistrationService registrationService; @PostMapping("/create") public Result<RegistrationVO> create(@RequestBody @Valid RegistrationDTO dto) { // 参数校验交给 @Valid,业务逻辑下沉到 Service return Result.ok(registrationService.createRegistration(dto)); } }

@Valid配合 DTO 上的@NotNull、@Min等注解,能在进入 Service 之前挡掉大部分非法参数。Result是统一返回包装类,包含code、msg、data三个字段。这里不要图省事直接返回实体类,否则后续加字段会污染接口契约。

4. 挂号核心逻辑:并发扣减号源怎么做才不超卖

4.1 超卖是怎么发生的

假设某专家号源total_count=5,used_count=4,此时两个请求同时进来。两个线程都读到used_count=4,都判断「4 < 5 可以挂」,然后都执行used_count = 4 + 1 = 5。结果两个人都挂号成功,但号源只增加了 1,实际卖出 6 个号。这就是典型的并发超卖。

解决思路有三层:数据库乐观锁、Redis 预扣减、分布式锁。对中小型项目,乐观锁足够;对号源紧张的三甲医院场景,Redis 预扣减更合适。这里先讲乐观锁方案,因为它不引入额外中间件,最容易落地。

4.2 乐观锁扣减的完整代码

@Service public class RegistrationServiceImpl implements RegistrationService { @Autowired private SourceMapper sourceMapper; @Autowired private RegistrationMapper registrationMapper; @Override @Transactional(rollbackFor = Exception.class) public RegistrationVO createRegistration(RegistrationDTO dto) { // 1. 查询号源 Source source = sourceMapper.selectById(dto.getSourceId()); if (source == null) { throw new BizException("号源不存在"); } // 2. 校验余号 if (source.getUsedCount() >= source.getTotalCount()) { throw new BizException("号源已挂完"); } // 3. 乐观锁更新:update source set used_count=used_count+1, version=version+1 // where id=? and version=? and used_count < total_count int rows = sourceMapper.increaseUsedCount(source.getId(), source.getVersion()); if (rows == 0) { // 更新失败说明被其他线程抢先,提示重试 throw new BizException("当前挂号人数较多,请重试"); } // 4. 生成挂号单 Registration reg = new Registration(); reg.setPatientId(dto.getPatientId()); reg.setSourceId(source.getId()); reg.setScheduleId(source.getScheduleId()); reg.setRegNo(generateRegNo()); reg.setStatus(0); registrationMapper.insert(reg); return convertToVO(reg); } private String generateRegNo() { return System.currentTimeMillis() + String.format("%04d", new Random().nextInt(10000)); } }

对应的 Mapper 方法用注解写更直观:

@Update("UPDATE source SET used_count = used_count + 1, version = version + 1 " + "WHERE id = #{id} AND version = #{version} AND used_count < total_count") int increaseUsedCount(@Param("id") Long id, @Param("version") Integer version);

WHERE条件里同时带version和used_count < total_count是双保险:version挡住并发覆盖,used_count < total_count挡住余号为 0 时的误更新。@Transactional保证扣减和插入挂号单在同一个事务里,任何一步失败都回滚。注意rollbackFor = Exception.class,默认只回滚运行时异常,加上这个才能覆盖受检异常。

4.3 参数怎么调:重试次数和事务隔离级别

乐观锁失败后直接抛异常让用户重试,体验不好。可以在 Service 外层加一层重试,重试 3 次,每次间隔 50ms。重试次数不要超过 5 次,否则高并发下会放大数据库压力。事务隔离级别用默认的REPEATABLE_READ即可,MySQL InnoDB 下配合乐观锁已经够用,改成READ_COMMITTED反而可能引入幻读问题。

如果号源竞争非常激烈,比如放号瞬间几千人抢,乐观锁重试率会飙升。这时候常见做法是引入 Redis:放号时把号源数量写入 Redis,用DECR原子操作预扣减,扣减成功再异步落库。但 Redis 和数据库的一致性需要额外处理,中小项目不建议第一版就上。

5. 避坑与排查:这五个问题几乎每个人都会遇到

5.1 挂号成功但号源没减

现象:接口返回成功,挂号单也生成了,但source表的used_count没变。原因:increaseUsedCount的@Update注解方法没有被 MyBatis 扫描到,或者 Mapper 接口没加@Mapper注解,Spring 没把它注册成 Bean。解决:在启动类上加@MapperScan("com.xxx.mapper"),或者在每个 Mapper 接口上加@Mapper。两者选其一,不要重复。

5.2 同一患者重复挂号

现象:同一个患者对同一次排班提交两次,生成两条挂号单。原因:只在前端做了按钮防抖,后端没有幂等校验。解决:在registration表上加唯一索引UNIQUE KEY uk_patient_schedule (patient_id, schedule_id, status),或者在 Service 里先查一次「该患者该排班是否已有未取消的挂号单」。唯一索引更可靠,但要注意status=2(已取消)时应该允许重新挂号,所以索引不能简单加在patient_id + schedule_id上,需要配合业务查询。

5.3 取消挂号后号源没恢复

现象:患者取消挂号,挂号单状态变成已取消,但号源used_count没减回去。原因:取消逻辑只更新了registration表,忘了同步更新source表。解决:取消操作和挂号操作一样,要放在同一个事务里,先更新挂号单状态,再执行used_count = used_count - 1。注意减的时候要加used_count > 0条件,防止减成负数。

5.4 时间字段差 8 小时

现象:数据库里存的create_time比实际时间早 8 小时。原因:JDBC 连接串没配serverTimezone,或者配成了UTC。解决:连接串加serverTimezone=Asia/Shanghai,同时确认 MySQL 服务器时区。如果用的是 Docker 起的 MySQL,容器默认时区可能是 UTC,需要在启动参数里加--default-time-zone=+08:00。

5.5 分页查询越翻越慢

现象:挂号记录列表翻到后面几页,响应时间从几十毫秒涨到几秒。原因:用了LIMIT offset, size,offset 很大时 MySQL 要扫描并丢弃前面所有行。解决:改成基于游标的分页,用WHERE id > last_id ORDER BY id LIMIT size。或者在create_time上加索引,配合WHERE create_time < last_time查询。对挂号记录这种按时间倒序展示的场景,游标分页比 offset 分页快一个数量级。

6. 从能跑到能用:压测、缓存和接口幂等的进阶做法

项目跑通之后,下一步是验证它能不能扛住真实流量。我一般会用 JMeter 或 wrk 对挂号接口做压测,重点看三个指标:TPS、错误率、数据库连接池等待时间。压测时把spring.datasource.hikari.maximum-pool-size从默认的 10 调到 20,观察 TPS 是否线性增长;如果没增长,说明瓶颈在数据库锁而不是连接数。

接口幂等是另一个必须补的点。除了前面说的唯一索引,更通用的做法是让前端在提交挂号时带一个requestId,后端用 Redis 的SETNX做去重,key 是reg:request:{requestId},过期时间设 5 分钟。这样即使前端重复提交,后端也只会处理一次。Redis 没引入之前,可以用ConcurrentHashMap做本地去重,但只适用于单机部署。

缓存方面,科室列表、医生列表这类变动少的数据适合放 Redis,key 用dept:list、doctor:list:{deptId},过期时间设 10 分钟。号源余量不建议缓存,因为一致性要求高,缓存和数据库不同步会导致超卖。如果一定要缓存余量,用 Redis 的DECR做预扣减,然后异步同步到数据库,但这条链路复杂,没有足够测试不要上生产。

最后说一个我自己的习惯:每次改完挂号核心逻辑,我都会手动构造三个场景验证——余号为 1 时两人同时抢、取消后立即重挂、同一请求重复提交。这三个场景覆盖了超卖、状态回滚和幂等,跑通了基本就不会出大问题。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/7 9:25:37

microG 华为设备指南:完整跑通 Google 服务替代方案的实操教程

microG 华为设备指南&#xff1a;完整跑通 Google 服务替代方案的实操教程 【免费下载链接】GmsCore Free implementation of Play Services 项目地址: https://gitcode.com/GitHub_Trending/gm/GmsCore 华为手机上装不了 Google 服务&#xff0c;地图没定位、应用弹&qu…

作者头像 李华
网站建设 2026/10/7 9:25:00

题解:洛谷 P2142 高精度减法

本文分享的必刷题目是从蓝桥云课、洛谷、AcWing等知名刷题平台精心挑选而来,并结合各平台提供的算法标签和难度等级进行了系统分类。题目涵盖了从基础到进阶的多种算法和数据结构,旨在为不同阶段的编程学习者提供一条清晰、平稳的学习提升路径。 欢迎大家订阅我的专栏:算法…

作者头像 李华
网站建设 2026/10/7 9:24:41

skills不是插件:智能体最小自治单元的契约化设计与GKE落地

1. 这不是“技能列表”&#xff0c;而是一套可执行、可验证、可进化的智能体能力操作系统你点开这个标题&#xff0c;大概率不是想查“skills”这个词的英文释义。你真正关心的是&#xff1a;为什么最近所有技术社区都在密集讨论 skills&#xff1f;为什么 Google Cloud 文档里…

作者头像 李华
网站建设 2026/10/7 9:24:08

PaddleX 版本演进全解析:从 v1.0 到 v3.0 的核心能力升级与更新要点

人工智能大模型低代码计算机视觉深度学习NLP模型推理服务RAG 【免费下载链接】PaddleX All-in-One Development Tool based on PaddlePaddle 项目地址&#xff1a; https://gitcode.com/paddlepaddle/PaddleX 点击查看 免费下载 本篇技术指南以仓库 docs/CHANGELOG.md 为骨架&…

作者头像 李华
网站建设 2026/10/7 9:22:38

告别tail -f焦虑:用Ponytail实现多文件实时日志合并与高亮排查

大家平时排查问题的时候&#xff0c;应该都有过这种体验&#xff1a;日志文件在服务器上飞速滚动&#xff0c;先用tail -f盯半天&#xff0c;发现还得再开一个终端去grep&#xff0c;切来切去不超过五分钟&#xff0c;眼睛就先花了。我前阵子处理一个定时任务偶发失败的问题&am…

作者头像 李华