简介:这是一套基于Java微服务架构实现的网上预约挂号系统完整源码,面向Java后端开发者、微服务学习者及医疗信息化项目实践者,旨在解决传统就医挂号流程繁琐、排队耗时长等现实痛点。资源包共89个文件,含66个核心Java业务逻辑与配置类、10个MyBatis-Plus映射XML、3个properties配置文件,以及Swagger接口文档、Nginx负载均衡配置、数据库建表脚本(MySQL+MongoDB)等关键支撑文件,整体压缩包仅2.22MB,轻量易部署。已有634人下载学习,适合用于微服务技术栈综合实训、毕业设计或中小型医疗平台二次开发。读者可直接运行并深入理解Spring Cloud Alibaba(Nacos注册中心、Sentinel限流、Feign调用)、Redis缓存挂号状态、RabbitMQ异步通知、多数据源整合等典型场景实现,目录结构按模块分层清晰,含详细项目介绍文档与MyBatis-Plus入门笔记,具备强教学性与工程参考价值。
1. 这不是又一个Spring Boot单体Demo:Java微服务挂号系统如何真正解耦业务边界
你下载的Java基于微服务架构的网上预约挂号系统源码.zip,大概率不是教学用的“一个Main类启动全功能”Demo。它背后是医院挂号场景里真实存在的并发瓶颈——号源秒杀、医生排班强一致性、患者身份多系统校验、支付状态跨域同步。单体架构下改一个挂号规则要重启整个应用,而这个源码包里,挂号服务(Booking)、号源服务(Schedule)、患者服务(Patient)、支付服务(Payment)各自独立部署、独立数据库、通过OpenFeign或RestTemplate通信,甚至可能已集成Nacos注册中心与Sentinel限流。它面向的是有3年以上Java开发经验、正从单体转向分布式落地的工程师:你需要的不是“怎么跑起来”,而是“为什么拆成这5个服务”“服务间数据怎么不一致”“本地调试时怎么绕过网关”。本文不讲CAP理论推导,只聚焦你能立刻验证的微服务挂号链路——从患者点击“预约张主任今日下午号”开始,到最终生成挂号单并推送短信,每一步在哪个服务里执行、参数怎么传、失败后怎么回滚。
2. 拆解微服务边界:为什么挂号系统必须按业务域切分而非技术层
2.1 医疗业务语义驱动的服务划分逻辑
挂号系统不是简单CRUD,它天然存在四个强隔离的业务域:
- 患者域:身份证核验、医保卡绑定、历史就诊记录查询——需对接卫健委实名库,强合规要求,数据库字段含敏感信息,必须独立部署加密存储;
- 号源域:医生排班、科室号段分配、实时余号计算——高并发读写,每秒数百次余号查询,需Redis缓存+本地缓存双写,且号源释放逻辑(如退号)必须原子性;
- 预约域:锁号、生成订单、关联检查单——核心交易链路,涉及分布式事务,不能因支付服务延迟导致号源被重复占用;
- 支付域:对接银联/微信/医保平台,异步回调通知——外部依赖不可控,必须解耦,失败时需触发挂号单状态机回退。
提示:若源码中所有服务共享同一MySQL实例(哪怕不同schema),或使用全局事务管理器(如Seata AT模式但未配置undo_log表),说明它只是“伪微服务”——服务进程隔离了,但数据和事务仍紧耦合,无法实现弹性伸缩。
2.2 基于Spring Cloud Alibaba的实际服务拓扑
该源码包典型结构如下(路径可变,但职责不变):
booking-service/ → 预约入口,含挂号下单、退号接口 schedule-service/ → 号源管理,提供余号查询、锁号、释放号源API patient-service/ → 患者信息,提供身份证校验、档案查询 payment-service/ → 支付网关,处理回调、更新订单状态 gateway/ → Spring Cloud Gateway,路由+JWT鉴权+限流 config-server/ → 配置中心,管理各服务数据库连接池参数关键配置文件application.yml中必现以下片段:
spring: cloud: nacos: discovery: server-addr: 127.0.0.1:8848 # 服务注册地址 namespace: public # 命名空间隔离测试/生产环境 config: server-addr: 127.0.0.1:8848 # 配置中心地址若缺失nacos配置,或所有服务spring.application.name相同(如都叫hospital-system),则服务发现失效,Feign调用会直接报Load balancer does not have available server for client。
2.3 MyBatis Plus在微服务中的差异化使用策略
MyBatis Plus不是简单替换JDBC模板——在微服务中,它必须配合分库分表与读写分离:
- 患者服务:使用
@TableName("t_patient")+@TableId(type = IdType.ASSIGN_ID),主键用雪花算法,避免跨库ID冲突; - 号源服务:余号查询走
QueryWrapper构建动态SQL,但禁止在schedule-service中JOIN患者表——必须通过Feign调用patient-service接口获取患者姓名; - 支付服务:回调接口需幂等,MyBatis Plus的
UpdateWrapper必须带版本号字段version,防止重复支付更新订单状态。
验证方式:打开schedule-service的Mapper接口,搜索@Select注解——若存在SELECT * FROM t_schedule JOIN t_doctor,说明违反微服务数据边界,应改为先查号源再远程调医生服务。
3. 本地联调四步法:绕过Nacos注册中心直连调试挂号链路
3.1 启动顺序与端口规划(避免8080端口冲突)
微服务本地调试最常见失败点是端口抢占。该源码包默认端口通常为:
| 服务名 | 端口 | 关键启动参数 |
|---|---|---|
| gateway | 8080 | --spring.profiles.active=dev |
| booking-service | 8081 | --server.port=8081 --spring.cloud.nacos.discovery.register-enabled=false |
| schedule-service | 8082 | --server.port=8082 --spring.cloud.nacos.discovery.register-enabled=false |
| patient-service | 8083 | --server.port=8083 --spring.cloud.nacos.discovery.register-enabled=false |
| payment-service | 8084 | --server.port=8084 --spring.cloud.nacos.discovery.register-enabled=false |
注意:
register-enabled=false是关键——它让服务不向Nacos注册,但保留Nacos配置拉取能力(config.enabled=true),避免配置丢失。
3.2 Feign客户端直连配置(跳过服务发现)
在booking-service的application-dev.yml中,将Feign调用schedule-service的URL硬编码为本地地址:
feign: client: config: default: connectTimeout: 5000 readTimeout: 5000 httpclient: enabled: true # 替换原service-url配置 schedule: service-url: http://localhost:8082 # 不走Nacos,直连对应Feign接口需用@RequestMapping指定完整路径:
@FeignClient(name = "schedule-service", url = "${schedule.service-url}") public interface ScheduleFeignClient { @GetMapping("/api/schedule/available/{doctorId}/{date}") Result<List<AvailableSlot>> queryAvailableSlots(@PathVariable Long doctorId, @PathVariable String date); }若仍报Connection refused,检查schedule-service是否已启动且/api/schedule/available/接口能被curl访问:
curl -X GET "http://localhost:8082/api/schedule/available/1001/2024-06-15" -H "Content-Type: application/json"返回{"code":200,"data":[...]}即通。
3.3 分布式事务的本地模拟方案
挂号下单需同时操作booking(生成订单)和schedule(锁定号源)。源码若用Seata,本地调试需启动Seata Server:
# 下载seata-server-2.0.0.tar.gz,解压后修改conf/application.yml store: mode: file # 开发环境用file模式,避免配DB然后启动:
sh seata-server.sh -p 8091 -h 127.0.0.1在booking-service的@GlobalTransactional方法中,添加日志验证事务传播:
@GlobalTransactional public Result<String> createBooking(BookingRequest request) { log.info("【事务开始】创建挂号单,患者ID:{}", request.getPatientId()); // 调用schedule-service锁号 scheduleFeignClient.lockSlot(request.getScheduleId()); // 保存挂号单 bookingMapper.insert(new Booking(...)); log.info("【事务提交】挂号单创建成功"); return Result.success("success"); }若日志中出现【事务开始】但无【事务提交】,且schedule-service日志显示锁号成功但booking-service数据库无记录,则Seata未生效——检查seata.conf中vgroupMapping.my_test_tx_group = "default"是否与代码中@GlobalTransactional(transactionName = "my_test_tx_group")匹配。
4. 号源余量实时计算的三个性能陷阱与优化实录
4.1 陷阱一:数据库COUNT(*)在高并发下的锁表风险
挂号页面每刷新一次就执行SELECT COUNT(*) FROM t_schedule WHERE doctor_id = ? AND date = ? AND status = 'AVAILABLE',在MySQL中会触发全表扫描+行锁,1000人同时刷页面,t_schedule表可能被锁数秒。
优化方案:用Redis缓存余号数
在schedule-service中,号源初始化时写入Redis:
// 初始化时执行(如定时任务) String key = "schedule:count:" + doctorId + ":" + date; redisTemplate.opsForValue().set(key, availableCount, Duration.ofHours(24));查询接口改为:
@GetMapping("/api/schedule/available/count/{doctorId}/{date}") public Result<Long> getAvailableCount(@PathVariable Long doctorId, @PathVariable String date) { String key = "schedule:count:" + doctorId + ":" + date; Long count = redisTemplate.opsForValue().get(key); if (count == null) { // 缓存穿透:查DB并回填 count = scheduleMapper.countAvailable(doctorId, date); redisTemplate.opsForValue().set(key, count, Duration.ofMinutes(5)); } return Result.success(count); }提示:
Duration.ofMinutes(5)是关键——余号变化频繁,缓存时间过长会导致用户看到“还有号”但实际已满,5分钟足够平衡一致性与性能。
4.2 陷阱二:号源释放时机错位导致“幽灵号”
患者退号时,若先更新schedule表状态为AVAILABLE,再发MQ通知booking-service更新订单状态,网络抖动可能导致booking-service未收到消息,号源被释放但订单仍显示“已预约”。
优化方案:状态机+本地消息表
在booking-service中建t_local_message表:
CREATE TABLE t_local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, business_type VARCHAR(32), -- 'REFUND' business_id BIGINT, -- 订单ID status TINYINT DEFAULT 0, -- 0待发送,1已发送,2已确认 content TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );退号逻辑:
@Transactional public void refundBooking(Long bookingId) { // 1. 更新订单状态为退款中 bookingMapper.updateStatus(bookingId, BookingStatus.REFUNDING); // 2. 写本地消息表(同一事务) localMessageMapper.insert(new LocalMessage("REFUND", bookingId, "退号请求")); // 3. 异步发MQ(由定时任务扫描local_message表) }定时任务每10秒扫描:
@Scheduled(fixedDelay = 10000) public void sendLocalMessages() { List<LocalMessage> messages = localMessageMapper.selectUnsent(); for (LocalMessage msg : messages) { rabbitTemplate.convertAndSend("refund.topic", "refund.order", msg.getContent()); localMessageMapper.markAsSent(msg.getId()); // 更新status=1 } }4.3 陷阱三:医生排班变更未触发号源重建
管理员修改张主任下周排班(如取消周三上午),但schedule-service未监听变更事件,导致周三上午的号源仍存在且可预约。
优化方案:用Nacos配置监听驱动号源重建
在schedule-service中:
@Component public class ScheduleConfigListener { @NacosInjected private ConfigService configService; @PostConstruct public void init() { try { configService.addListener("doctor-schedule-config", "DEFAULT_GROUP", new Listener() { @Override public void receiveConfigInfo(String configInfo) { // 解析JSON配置,重建对应医生号源 rebuildScheduleForDoctors(JSON.parseArray(configInfo, Long.class)); } @Override public Executor getExecutor() { return null; } }); } catch (NacosException e) { log.error("监听排班配置失败", e); } } }Nacos中配置项doctor-schedule-config内容示例:
[1001, 1002] // 医生ID列表,表示这些医生排班有变更rebuildScheduleForDoctors()方法内执行:删除旧号源+按新排班规则生成号源,全程加分布式锁(RedisLock)防重复重建。
5. 验证挂号链路完整性的三个终端命令与日志断点
5.1 用curl构造挂号全流程请求链
在确保所有服务已启动后,执行以下四步命令(按序执行,观察各服务日志):
# 1. 查询张主任今日可约时段(触发schedule-service余号查询) curl -X GET "http://localhost:8080/api/gateway/schedule/available/1001/2024-06-15" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \ -H "Content-Type: application/json" # 2. 锁定第一个时段(触发schedule-service锁号) curl -X POST "http://localhost:8080/api/gateway/booking/lock" \ -H "Authorization: Bearer ..." \ -d '{"scheduleId":10001,"patientId":20001}' # 3. 创建挂号单(触发booking-service分布式事务) curl -X POST "http://localhost:8080/api/gateway/booking/create" \ -H "Authorization: Bearer ..." \ -d '{"scheduleId":10001,"patientId":20001,"contactPhone":"138****1234"}' # 4. 查询挂号单状态(验证payment-service是否收到回调) curl -X GET "http://localhost:8080/api/gateway/booking/status/30001" \ -H "Authorization: Bearer ..."关键验证点:
schedule-service日志中出现LOCKED slot 10001 for patient 20001;booking-service日志中出现【事务提交】挂号单创建成功且数据库booking表有新记录;payment-service日志中出现Received payment callback for order 30001, status: SUCCESS。
5.2 日志断点定位高频失败环节
在IDEA中对以下方法设置断点,复现问题时直接命中:
| 服务 | 类名 | 方法 | 断点作用 |
|---|---|---|---|
booking-service | BookingController.java | createBooking() | 检查入参是否为空、JWT解析是否失败 |
schedule-service | ScheduleServiceImpl.java | lockSlot() | 查看Redis锁key是否冲突、DB更新行数是否为0 |
patient-service | PatientServiceImpl.java | verifyIdCard() | 验证身份证号格式、调用卫健委接口超时 |
gateway | AuthFilter.java | filter() | 检查JWT token是否过期、签名是否无效 |
断点触发后,重点关注Thread.currentThread().getStackTrace()输出,确认调用栈是否符合预期(如booking-service→schedule-service→patient-service,而非反向调用)。
5.3 数据库状态快照比对表
挂号成功后,立即执行以下SQL比对各服务数据一致性:
-- booking-service数据库 SELECT id, patient_id, schedule_id, status FROM t_booking WHERE id = 30001; -- 应返回 status = 'WAITING_PAYMENT' -- schedule-service数据库 SELECT id, doctor_id, date, status FROM t_schedule WHERE id = 10001; -- 应返回 status = 'LOCKED' -- payment-service数据库 SELECT order_id, amount, pay_status FROM t_payment WHERE order_id = 30001; -- 应返回 pay_status = 'UNPAID'(等待用户支付) -- patient-service数据库 SELECT id, real_name, id_card FROM t_patient WHERE id = 20001; -- 应返回脱敏后的身份证前6位+后4位若t_schedule状态为AVAILABLE而t_booking状态为WAITING_PAYMENT,说明锁号失败但未抛异常——检查schedule-service中lockSlot()方法是否漏写了@Transactional或Redis锁未释放。
本文还有配套的精品资源,点击获取