ARTICLE DETAIL

资讯详情

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

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

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 配置压成了注解和 starterMyBatis 或 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 号源表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 挂号单表 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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: 0serverTimezoneAsia/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 ResultRegistrationVO 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_count5used_count4此时两个请求同时进来。两个线程都读到used_count4都判断「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_countused_count1, versionversion1 // 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 里先查一次「该患者该排班是否已有未取消的挂号单」。唯一索引更可靠但要注意status2已取消时应该允许重新挂号所以索引不能简单加在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。解决连接串加serverTimezoneAsia/Shanghai同时确认 MySQL 服务器时区。如果用的是 Docker 起的 MySQL容器默认时区可能是 UTC需要在启动参数里加--default-time-zone08:00。5.5 分页查询越翻越慢现象挂号记录列表翻到后面几页响应时间从几十毫秒涨到几秒。原因用了LIMIT offset, sizeoffset 很大时 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做本地去重但只适用于单机部署。缓存方面科室列表、医生列表这类变动少的数据适合放 Rediskey 用dept:list、doctor:list:{deptId}过期时间设 10 分钟。号源余量不建议缓存因为一致性要求高缓存和数据库不同步会导致超卖。如果一定要缓存余量用 Redis 的DECR做预扣减然后异步同步到数据库但这条链路复杂没有足够测试不要上生产。最后说一个我自己的习惯每次改完挂号核心逻辑我都会手动构造三个场景验证——余号为 1 时两人同时抢、取消后立即重挂、同一请求重复提交。这三个场景覆盖了超卖、状态回滚和幂等跑通了基本就不会出大问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表