ARTICLE DETAIL

资讯详情

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

医院门诊在线预约挂号管理系统Java代码:高并发防超卖与数据库设计实战

医院门诊在线预约挂号管理系统Java代码:高并发防超卖与数据库设计实战 简介这是一套面向高校计算机专业毕业设计与课程实践的医院门诊在线预约挂号管理系统完整源码采用Java语言与SSM框架开发前端使用Vue配合Ajax交互后端基于Spring、SpringMVC、MyBatisPlus数据库为MySQL 5.7开发环境兼容Eclipse、MyEclipse与IDEAJDK版本为1.8适合需要完成毕设或学习Web全栈开发的学生与开发者参考。压缩包共640个文件约15.61MB其中152个java文件承载后端业务逻辑111个vue文件与44个js文件构成前端页面与交互另有162个svg、36个jpg、32个png等图片素材用于界面展示25个xml与1个sql文件负责配置与建库目录结构清晰便于按模块查阅。资源内附项目说明文档与数据库脚本可帮助读者快速理解预约挂号、用户信息管理等核心功能的实现思路并对照源码完成环境搭建与二次开发。目前已有140人学习下载适合作为毕设参考或SSM加Vue技术栈的实战练习材料。1. 门诊挂号系统为什么总在早高峰崩从一次真实排障说起早上七点半门诊大厅还没开门自助机和手机端已经同时开始刷号。八点整后台日志里Connection is not available, request timed out after 30000ms开始刷屏HikariCP 连接池被打满挂号接口平均响应从 200ms 飙到 8s部分请求直接 502。这不是段子是很多医院门诊在线预约挂号管理系统上线后第一个月最典型的翻车现场。医院门诊在线预约挂号管理系统本质是把「选科室—选医生—选时段—锁号—支付—生成就诊凭证」这条链路搬到 Web 上用 Java 做后端、浏览器和移动端做入口。它要解决的核心问题不是「能不能挂号」而是「几百人同时抢同一个专家号时号源不超卖、数据库不崩、用户不重复提交」。适合谁看正在做医院门诊在线预约挂号管理系统 Java 代码的开发者、需要把 Web 挂号系统落地的技术负责人以及准备用这个项目练手或应对面试的 Java 工程师。下面按「先立住模型再跑通链路最后处理并发和坑」的顺序拆开讲。2. 号源模型与数据库设计先定表结构再写 Java 代码2.1 号源不是一条记录而是「排班 时段 余量」三层很多初版设计把号源做成一张schedule表字段是doctor_id、date、remain然后靠update schedule set remain remain - 1 where id ? and remain 0扣减。这个做法在低并发下能跑但一旦要支持「上午/下午」「普通/专家」「医保/自费」不同号池就会把表撑爆。常见做法是拆成三层doctor_schedule医生某天在某科室出诊记录doctor_id、dept_id、work_date、period上午/下午、total_slots。schedule_slot具体时段比如 08:00-08:30记录schedule_id、start_time、end_time、capacity、remaining。registration_order挂号订单记录slot_id、patient_id、order_no、status、create_time。这样拆的好处是余量扣减只发生在schedule_slot这一行锁粒度小订单表只追加不做更新竞争。下面是一段可抄的建表 SQLMySQL 8.0 可直接执行。-- 医生排班表一个医生一天一个时段一条记录 CREATE TABLE doctor_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, doctor_id BIGINT NOT NULL, dept_id BIGINT NOT NULL, work_date DATE NOT NULL, period TINYINT NOT NULL COMMENT 1上午 2下午, total_slots INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1正常 0停诊, UNIQUE KEY uk_doctor_date_period (doctor_id, work_date, period) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 时段号源表余量扣减在这里做 CREATE TABLE schedule_slot ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, start_time TIME NOT NULL, end_time TIME NOT NULL, capacity INT NOT NULL, remaining INT NOT NULL, version INT NOT NULL DEFAULT 0, KEY idx_schedule (schedule_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 挂号订单表只追加不更新余量 CREATE TABLE registration_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, slot_id BIGINT NOT NULL, patient_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), UNIQUE KEY uk_patient_slot (patient_id, slot_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;逻辑说明uk_patient_slot是防重复挂号的最后一道防线同一患者同一时段只能有一条订单。version字段留给乐观锁后面并发扣减会用到。参数上capacity和remaining都用INT不要用TINYINT专家号加号后可能超过 127。utf8mb4是为了存患者姓名里的生僻字。2.2 Java 实体与 MyBatis 映射别让字段名和数据库打架表建好后Java 侧用 Spring Boot MyBatis 是常见组合。实体类字段用驼峰数据库用下划线靠map-underscore-to-camel-case: true自动映射。下面给出ScheduleSlot实体和对应的 Mapper 接口重点看扣减余量的 SQL。// ScheduleSlot.java public class ScheduleSlot { private Long id; private Long scheduleId; private LocalTime startTime; private LocalTime endTime; private Integer capacity; private Integer remaining; private Integer version; // getter/setter 省略 }// ScheduleSlotMapper.java Mapper public interface ScheduleSlotMapper { // 乐观锁扣减version 不匹配就返回 0 Update(UPDATE schedule_slot SET remaining remaining - 1, version version 1 WHERE id #{id} AND remaining 0 AND version #{version}) int deductWithVersion(Param(id) Long id, Param(version) Integer version); // 悲观锁扣减先 select ... for update Select(SELECT * FROM schedule_slot WHERE id #{id} FOR UPDATE) ScheduleSlot selectForUpdate(Param(id) Long id); Update(UPDATE schedule_slot SET remaining remaining - 1 WHERE id #{id} AND remaining 0) int deduct(Param(id) Long id); }逻辑说明deductWithVersion是乐观锁适合冲突不激烈的时段selectForUpdate是悲观锁适合专家号这种秒杀场景。参数上remaining 0必须写在WHERE里不能先查再判断否则并发下一定超卖。version从查询结果里带出来扣减失败就重试重试次数建议 3 次超过就返回「号源紧张」。2.3 索引和字段类型三个容易写错的细节第一work_date用DATE不要用VARCHAR否则按日期范围查排班会全表扫描。第二start_time和end_time用TIME不要用字符串排序和比较都更稳。第三registration_order的create_time加普通索引方便按时间统计挂号量。如果医院要求保留三年数据订单表要提前按年分表否则单表过亿后count都会变慢。3. 挂号链路实现从选时段到生成订单的完整 Java 代码3.1 接口拆分查询、锁号、确认支付三步走把挂号拆成三个接口而不是一个「一键挂号」大接口原因是每一步的并发压力和失败处理都不一样。查询排班是读多写少可以加缓存锁号是写操作要控制并发确认支付是最终一致性要能补偿。常见做法是GET /api/schedule/list?deptIddate查排班和余量走 Redis 缓存缓存 key 用schedule:dept:{deptId}:{date}过期时间 60 秒。POST /api/registration/lock锁号扣减remaining生成待支付订单返回orderNo。POST /api/registration/confirm支付回调后确认订单把status从 0 改成 1。下面给出锁号接口的核心 Service 代码用悲观锁 唯一索引兜底。Service public class RegistrationService { Autowired private ScheduleSlotMapper slotMapper; Autowired private RegistrationOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public String lockSlot(Long slotId, Long patientId) { // 1. 悲观锁查时段 ScheduleSlot slot slotMapper.selectForUpdate(slotId); if (slot null || slot.getRemaining() 0) { throw new BizException(号源已挂完); } // 2. 扣减余量 int rows slotMapper.deduct(slotId); if (rows 0) { throw new BizException(号源紧张请重试); } // 3. 生成订单唯一索引防重复 String orderNo REG System.currentTimeMillis() String.format(%04d, new Random().nextInt(10000)); RegistrationOrder order new RegistrationOrder(); order.setOrderNo(orderNo); order.setSlotId(slotId); order.setPatientId(patientId); order.setStatus(0); try { orderMapper.insert(order); } catch (DuplicateKeyException e) { throw new BizException(您已挂过该时段); } return orderNo; } }逻辑说明selectForUpdate在事务里锁住这一行其他请求排队等待避免超卖。deduct的remaining 0是二次校验。DuplicateKeyException捕获的是uk_patient_slot冲突说明同一患者重复提交。参数上Transactional的rollbackFor要写Exception.class否则受检异常不会回滚。订单号用时间戳加随机数生产环境建议用雪花算法避免高并发下重复。3.2 Redis 缓存排班别把余量缓存成脏数据排班查询走 Redis 能扛住早高峰的读压力但余量不能只信缓存。常见做法是缓存里只存capacity和start_time这些不变字段remaining每次从数据库读或者缓存里存余量但设置 5 秒过期扣减后主动删缓存。下面是一段缓存读取代码。public ListSlotVO listSlots(Long deptId, String date) { String key schedule:dept: deptId : date; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseArray(cached, SlotVO.class); } ListSlotVO list slotMapper.selectByDeptAndDate(deptId, date); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 60, TimeUnit.SECONDS); return list; }逻辑说明缓存过期时间 60 秒是折中值太短起不到缓存作用太长余量显示不准。扣减成功后要调redisTemplate.delete(key)让下次查询回源。参数上SlotVO里remaining字段建议加JsonFormat保证时间格式统一。3.3 支付回调与订单状态机别让「待支付」变成死单锁号后订单是待支付用户 15 分钟不付就要释放号源。常见做法是用延迟队列或者定时任务扫超时订单。下面是一个简单的定时任务每 5 分钟扫一次。Scheduled(cron 0 */5 * * * ?) public void cancelTimeoutOrders() { // 查出 15 分钟前待支付的订单 ListRegistrationOrder list orderMapper.selectTimeoutOrders( LocalDateTime.now().minusMinutes(15)); for (RegistrationOrder order : list) { // 更新订单为已取消 orderMapper.updateStatus(order.getId(), 2); // 回滚余量 slotMapper.increaseRemaining(order.getSlotId()); } }逻辑说明selectTimeoutOrders要加status 0和create_time ?条件避免扫全表。回滚余量用increaseRemainingSQL 是UPDATE schedule_slot SET remaining remaining 1 WHERE id ?。参数上定时任务间隔根据医院放号频率调整专家号可以缩短到 1 分钟。注意回滚和取消要在同一个事务里否则可能取消成功但余量没加回来。4. 并发扣减与防超卖乐观锁、悲观锁和 Redis 预扣怎么选4.1 三种方案对比别一上来就上 Redis方案实现方式适用场景缺点悲观锁select ... for update专家号秒杀冲突激烈排队等待响应慢乐观锁version字段 重试普通号冲突少重试次数多时体验差Redis 预扣decr原子操作极高峰值如放号瞬间要处理缓存和数据库一致性我一般会先用悲观锁把链路跑通压测发现 QPS 上不去再换 Redis 预扣。Redis 预扣的写法是放号时把remaining写入 Redis扣减用decr扣到 0 就拒绝然后异步同步到数据库。下面是一段预扣代码。public boolean preDeduct(Long slotId) { String key slot:remaining: slotId; Long remain redisTemplate.opsForValue().decrement(key); if (remain null || remain 0) { // 扣成负数回补 redisTemplate.opsForValue().increment(key); return false; } return true; }逻辑说明decrement是原子操作不会超卖。扣成负数说明已经没号要increment回补。参数上Redis key 要设过期时间比如 24 小时避免冷号源占内存。数据库同步用消息队列失败要重试。4.2 压测参数用 JMeter 跑出真实瓶颈压测时线程数从 100 开始逐步加到 500观察TPS和RT。如果RT在 200ms 以内说明连接池够用如果RT飙升到 2s 以上先看 HikariCP 的maximumPoolSize一般设为CPU 核数 * 2 磁盘数。数据库连接数也要调MySQL 默认 151高并发下要改到 500 以上。下面是一段 HikariCP 配置。spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000逻辑说明connection-timeout设 3 秒超过就快速失败避免请求堆积。max-lifetime要小于数据库的wait_timeout否则会拿到失效连接。参数上maximum-pool-size不是越大越好50 到 100 之间根据压测结果调。4.3 分布式锁多实例部署时的兜底如果挂号服务部署了多个实例悲观锁只能锁住单个数据库连接跨实例要靠分布式锁。常见做法是用 Redisson 的RLock锁 key 用lock:slot:{slotId}等待时间 3 秒持有时间 10 秒。Autowired private RedissonClient redissonClient; public String lockSlotWithDistributedLock(Long slotId, Long patientId) { RLock lock redissonClient.getLock(lock:slot: slotId); try { if (!lock.tryLock(3, 10, TimeUnit.SECONDS)) { throw new BizException(系统繁忙请重试); } return doLockSlot(slotId, patientId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(中断异常); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }逻辑说明tryLock的等待时间要小于接口超时时间避免用户等太久。isHeldByCurrentThread判断防止释放别人的锁。参数上持有时间 10 秒要大于业务执行时间否则锁提前释放会出问题。5. 避坑与排查挂号系统上线后最容易翻车的 5 个点5.1 现象余量显示有号点进去提示「已挂完」原因Redis 缓存里的余量和数据库不一致扣减后没删缓存或者缓存过期时间太长。解决扣减成功后立即delete缓存 key缓存过期时间控制在 60 秒以内。如果用了 Redis 预扣数据库同步延迟要监控超过 1 秒就告警。5.2 现象同一患者生成两条订单原因uk_patient_slot唯一索引没建或者建了但字段顺序不对。解决确认唯一索引是(patient_id, slot_id)不是(slot_id, patient_id)。另外前端要防重复提交按钮点击后置灰后端用orderNo做幂等。5.3 现象支付回调后订单还是待支付原因回调接口没做幂等或者事务没提交。解决回调里先查订单状态已支付就直接返回成功。更新状态和写支付流水要在同一个事务里Transactional别漏。如果用了消息队列消费失败要进死信队列人工处理。5.4 现象早高峰数据库连接池打满原因慢 SQL 占着连接不释放或者连接池配置太小。解决开slow_query_log抓慢 SQL重点看排班查询和订单插入。连接池maximum-pool-size调到 50 以上connection-timeout设 3 秒。如果还是不够加 Redis 缓存扛读流量。5.5 现象定时任务回滚余量时把已支付的订单也取消了原因selectTimeoutOrders没过滤status 0或者时间条件写错。解决SQL 里必须带status 0 AND create_time ?回滚前再查一次订单状态确认还是待支付才回滚。定时任务加日志记录每次取消的订单号方便对账。6. 用 JUnit Testcontainers 验证超卖一个能复现的测试技巧写完扣减逻辑怎么证明真的不超卖靠手工点页面不靠谱我一般用 JUnit 5 Testcontainers 起一个真实 MySQL开 100 个线程同时扣减同一个时段最后断言remaining等于 0 且订单数等于capacity。下面是一段可复现的测试代码。Testcontainers SpringBootTest class RegistrationConcurrencyTest { Container static MySQLContainer? mysql new MySQLContainer(mysql:8.0) .withDatabaseName(hospital) .withUsername(test) .withPassword(test); DynamicPropertySource static void props(DynamicPropertyRegistry registry) { registry.add(spring.datasource.url, mysql::getJdbcUrl); registry.add(spring.datasource.username, mysql::getUsername); registry.add(spring.datasource.password, mysql::getPassword); } Autowired private RegistrationService registrationService; Test void testNoOversell() throws InterruptedException { Long slotId 1L; int capacity 50; int threads 100; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { final long patientId i 1; pool.submit(() - { try { registrationService.lockSlot(slotId, patientId); success.incrementAndGet(); } catch (Exception ignored) { } finally { latch.countDown(); } }); } latch.await(); pool.shutdown(); // 成功数不能超过 capacity assertTrue(success.get() capacity); // 数据库余量不能为负 ScheduleSlot slot slotMapper.selectById(slotId); assertTrue(slot.getRemaining() 0); } }逻辑说明Testcontainers起真实 MySQL避免 H2 和 MySQL 行为差异。CountDownLatch让 100 个线程同时开始模拟真实抢号。断言success capacity和remaining 0只要有一个不满足就说明超卖。参数上capacity设 50、线程设 100是为了制造冲突。如果测试通过再把线程加到 500 压一轮。这个测试跑一次大概 30 秒建议放进 CI每次改扣减逻辑都跑。我自己的习惯是先写测试再改代码测试红了才说明改对了。另外Testcontainers需要本地有 DockerCI 环境要提前装好。如果公司网络拉镜像慢可以提前docker pull mysql:8.0。最后说个血泪经验别在测试环境用生产库的配置maximum-pool-size和connection-timeout要和生产一致否则测试通过、上线照样崩。希望帮到你。本文还有配套的精品资源点击获取
返回列表