Java微服务挂号系统实战:业务域拆分与本地联调
2026/9/12 4:36:59 网站建设 项目流程

简介:这是一套基于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端口冲突)

微服务本地调试最常见失败点是端口抢占。该源码包默认端口通常为:

服务名端口关键启动参数
gateway8080--spring.profiles.active=dev
booking-service8081--server.port=8081 --spring.cloud.nacos.discovery.register-enabled=false
schedule-service8082--server.port=8082 --spring.cloud.nacos.discovery.register-enabled=false
patient-service8083--server.port=8083 --spring.cloud.nacos.discovery.register-enabled=false
payment-service8084--server.port=8084 --spring.cloud.nacos.discovery.register-enabled=false

注意:register-enabled=false是关键——它让服务不向Nacos注册,但保留Nacos配置拉取能力(config.enabled=true),避免配置丢失。

3.2 Feign客户端直连配置(跳过服务发现)

booking-serviceapplication-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.confvgroupMapping.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-serviceBookingController.javacreateBooking()检查入参是否为空、JWT解析是否失败
schedule-serviceScheduleServiceImpl.javalockSlot()查看Redis锁key是否冲突、DB更新行数是否为0
patient-servicePatientServiceImpl.javaverifyIdCard()验证身份证号格式、调用卫健委接口超时
gatewayAuthFilter.javafilter()检查JWT token是否过期、签名是否无效

断点触发后,重点关注Thread.currentThread().getStackTrace()输出,确认调用栈是否符合预期(如booking-serviceschedule-servicepatient-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状态为AVAILABLEt_booking状态为WAITING_PAYMENT,说明锁号失败但未抛异常——检查schedule-servicelockSlot()方法是否漏写了@Transactional或Redis锁未释放。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询