拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java后端门诊服务聚合系统实战:Spring Boot模块化设计与订单状态管理

Java后端门诊服务聚合系统实战:Spring Boot模块化设计与订单状态管理

简介:基于Java语言开发的门诊服务聚合系统设计源码,面向医疗信息化开发者和有一定Java基础的后端学习者,可用于解决预约挂号、排队叫号、医疗记录管理等门诊服务场景中的流程聚合、数据管理与系统整合问题。整个资源包共包含51个文件,压缩后大小约220KB,其中以34个Java源文件为主,另有8个XML配置文件、1个YAML配置、1个properties属性文件及少量构建与部署文件;Java源文件承载各功能模块的业务逻辑,配置文件负责管理数据库连接、事务处理和安全控制等参数,整体结构清晰,便于按模块查看与复用。目前已有246人学习/下载,系统采用模块化、服务化开发思想,结合Spring Boot、Spring MVC和MyBatis完成后端业务处理,前端通过AJAX方式与后端交互,并引入Spring Security增强医疗数据访问安全。配套文档提供了项目介绍、接口说明和部署指引,读者结合源码可以梳理从功能模块划分到数据持久化的完整设计链路,也能借鉴其在用户管理、预约挂号、排队叫号、医疗记录管理等模块的代码组织方式,为同类医疗信息化项目提供有价值的参考。

1. 门诊服务聚合系统:为什么一个Java后端能顶掉三个窗口

患者就诊最烦的不是排队,而是为了“办好一件事”在挂号、缴费、报告窗口之间来回跑。门诊服务聚合系统的思路很直接:把挂号、候诊队列、缴费单、报告状态这些分散在不同科室、不同系统里的服务,用同一个Java后端统一聚合起来。前端只需要调一次接口,就能拿回患者当前的全部就诊进度。它适合课程设计、毕业设计,也适合小型诊所自建门诊系统。很多人拿到这类源码项目第一反应是拆微服务,结果还没开始写就被注册中心、配置中心拖住。门诊场景并发没那么极端,用Spring Boot做模块化聚合,先把订单状态串起来,才是性价比最高的做法。

2. 门诊聚合系统的模块拆分与数据库设计:把挂号、缴费、报告做成可联调的表结构

门诊聚合系统的核心不在代码,而在数据模型。我见过不少java课程设计案例源码,所有业务逻辑堆在一个Controller里,数据库只有三张表,一联调就露馅。聚合系统要聚合的是“一次完整的就诊数据”,如果表结构里连统一订单号都没有,后面所有接口都只能东拼西凑。这一章先讲清楚模块怎么分,再给出一套可以直接建表的SQL。

2.1 聚合系统的核心模块划分:患者端、医生端、统一网关层

聚合服务不一定要拆成微服务。我一般把工程拆成四个Maven模块就够用:common模块放统一返回体、业务异常、订单号生成器、状态枚举;aggregation模块是真正对外提供聚合接口的BFF层,负责把其他模块返回的碎片数据组装成前端需要的视图模型;patient模块负责预约挂号、支付、报告查询;doctor模块负责排班、叫号、医生工作台。每个模块按包名隔离,最终打成一个可执行jar包。这样既保留了模块边界,又不需要部署多个进程,课程设计的答辩现场也不会因为起不来服务而翻车。

为什么这样拆?因为聚合系统最忌讳把聚合逻辑和基础业务写在一起。聚合层只做编排,不碰业务细节;业务模块只关心自己的领域,不需要知道前端要什么字段。典型例子是患者首页要同时显示“待缴费金额”和“报告已出”两个信息,聚合层拿到patient模块的数据后直接组装,不再查一次数据库。如果让patient模块直接返回视图对象,下次前端要加一个字段,就得改patient模块并重新测试,聚合层形同虚设。

2.2 核心表结构与订单状态串起全流程

下面是一套我在类似项目中用的核心建表SQL,基于MySQL 8.0。第一张是“就诊聚合订单主表”,整个系统的所有流程都围绕它转。

-- 就诊聚合订单主表,将挂号、缴费、取药、报告串联 CREATE TABLE `t_treatment_order` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '物理主键', `order_no` varchar(32) NOT NULL COMMENT '业务订单号,全局唯一', `patient_id` bigint NOT NULL COMMENT '患者ID', `doctor_id` bigint NOT NULL COMMENT '医生ID', `dept_id` bigint NOT NULL COMMENT '科室ID', `schedule_id` bigint NOT NULL COMMENT '排班ID', `status` tinyint NOT NULL DEFAULT '0' COMMENT '状态:0待支付,1已支付待就诊,2候诊中,3就诊中,4待取药,5已完成,6已取消', `pay_type` tinyint DEFAULT NULL COMMENT '支付方式:1微信,2支付宝,3医保', `total_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '订单总额', `paid_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '实付金额', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_patient_status` (`patient_id`,`status`), KEY `idx_doctor_status` (`doctor_id`,`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='门诊聚合订单主表';

订单主表的status字段是整套系统的状态枢纽。0是待支付,1是已支付待就诊,2开始进入候诊,3就诊中,4待取药,5完成,6取消。注意状态0不是“已创建”,因为门诊挂号的号源是稀缺资源,如果允许创建订单后一直不支付,号源会被无限制占用,所以我一般会加上“30秒内未支付自动取消”的定时任务。对外暴露用order_no而不是自增id,既防止被遍历,也方便在多个模块间传递。

接下来是支付流水子表和候诊排队子表。支付流水必须拆出来,因为同一个订单在多科室就诊时可能发生多笔支付,比如先微信付挂号费,再在诊室补缴检查费。候诊排队子表单独存叫号状态,方便护士站刷新队列。

CREATE TABLE `t_payment` ( `id` bigint NOT NULL AUTO_INCREMENT, `payment_no` varchar(32) NOT NULL COMMENT '支付流水号', `order_no` varchar(32) NOT NULL, `pay_channel` tinyint NOT NULL COMMENT '1微信,2支付宝,3医保', `pay_status` tinyint NOT NULL DEFAULT '0' COMMENT '0待支付,1支付中,2成功,3失败,4已退款', `trade_no` varchar(64) DEFAULT NULL COMMENT '渠道交易号', `amount` decimal(10,2) NOT NULL, `callback_time` datetime DEFAULT NULL COMMENT '渠道回调时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_no` (`payment_no`), KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB COMMENT='支付流水子表'; CREATE TABLE `t_queue` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `doctor_id` bigint NOT NULL, `queue_no` int NOT NULL COMMENT '当天叫号序号', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0等待,1呼叫中,2过号,3已就诊', `called_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_doctor` (`order_no`,`doctor_id`), KEY `idx_doctor_status` (`doctor_id`,`status`) ) ENGINE=InnoDB COMMENT='候诊排队子表';

支付流水表里pay_status和订单表status是两个维度的状态。支付流水只关心这笔钱有没有付成功,订单状态关心整个就诊流程走到哪一步。如果混在一起,取消订单、退款、部分支付这些场景会非常难写。候诊队列的queue_no是当天从1开始累加的序号,不是全局自增,这个字段在叫号时直接展示,所以不需要全局唯一。

2.3 报告状态同步表:LIS/PACS不是你的系统

门诊报告(检验、检查)往往不由聚合系统生成,而是由LIS或PACS系统负责。聚合服务要拿到报告状态,常见做法是“同步状态,不同步文件”。在本地建一张报告状态同步表,通过定时任务或回调更新。

CREATE TABLE `t_report_status` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(32) NOT NULL, `report_type` tinyint NOT NULL COMMENT '1检验,2检查', `report_name` varchar(128) NOT NULL, `status` tinyint NOT NULL DEFAULT '0' COMMENT '0未出,1已出,2已领取', `result_url` varchar(256) DEFAULT NULL COMMENT '报告文件地址', PRIMARY KEY (`id`), KEY `idx_order_no_status` (`order_no`,`status`) ) ENGINE=InnoDB COMMENT='报告状态同步表';

本地只存报告元数据,患者查询时先读本地状态,status=1且result_url存在,再跳转到文件服务。如果直接去LIS系统查询,每次患者刷新首页都会把压力打到检验科的系统,这是典型的“聚合系统拖垮被聚合系统”的翻车现场。

聚合查询时还有一个容易踩的坑:不要试图用一条大SQL把订单、支付、队列、报告全部LEFT JOIN出来。如果同一订单有多笔支付,结果集会从一行变多行,Java组装时还得去重。我在第3章里说的聚合层“分多次查询,用Java组装”,就是因为这个。数据库不擅长做视图拼接,Java配合CompletableFuture反而又清晰又快。

3. 用Spring Boot搭建门诊聚合服务:最小可运行项目的核心代码

这一章进入动手环节。我会把项目骨架、聚合接口、关键配置拆开讲,给出一套能直接跑起来的最小核心代码。读者只要照着建表,再把下面代码填进自己工程,就能把聚合接口跑通。

3.1 项目骨架与依赖版本:先用一套稳妥的Java技术栈

门诊聚合系统的选型不能太激进。我一般用Spring Boot 2.7.x配Java 8,因为很多课程设计环境、学校机房、旧服务器都还是Java 8。如果直接上Spring Boot 3.x要求Java 17,不少人会在环境变量配置或IDE编译那一步卡住,最后还没跑起来就放弃了。如果你已经有java基础想尝鲜,也可以升到3.x,但下面代码基于2.7更稳。核心依赖如下:

<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <!-- Web入口 --> <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.2</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- 缓存,用于排班与门诊状态查询 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- 参数校验 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

参数说明:MyBatis Plus 3.5.3.2这个版本跟Spring Boot 2.7配合很稳定,不会有兼容性报错。mysql-connector-j 8.0.33是MySQL 8.x的官方驱动,注意驱动类名是com.mysql.cj.jdbc.Driver,老项目里写的com.mysql.jdbc.Driver已经废弃。Redis在这套方案里不是强依赖,如果只想把源码跑通,可以先把Redis依赖注释掉;但后面第4章要讲分布式锁,建议还是配上。

3.2 一个聚合Controller,把挂号详情、候诊队列、缴费单、报告状态一次返回

聚合层的关键是“并行查询、统一超时、失败降级”。下面这个Controller是整套系统最核心的对外接口:患者进入门诊首页时,前端只调它一次,就能拿回今天的全部就诊信息。

@RestController @RequestMapping("/api/aggregation") @RequiredArgsConstructor public class TreatmentAggregationController { private final TreatmentOrderService orderService; private final QueueService queueService; private final PaymentService paymentService; private final ReportService reportService; private final AggregationThreadPool pool; @GetMapping("/patient/today") public ApiResult<TreatmentFlowVO> getTodayTreatment(@RequestParam Long patientId) { // 1. 先查订单主表,拿到患者今天的主订单 TreatmentOrder order = orderService.findTodayOrder(patientId); if (order == null) { return ApiResult.success(TreatmentFlowVO.empty()); } // 2. 并行查询支付、候诊、报告状态,互不阻塞 CompletableFuture<PaymentVO> paymentFuture = CompletableFuture.supplyAsync(() -> paymentService.getLatest(order.getOrderNo()), pool); CompletableFuture<QueueVO> queueFuture = CompletableFuture.supplyAsync(() -> queueService.getCurrent(order.getOrderNo()), pool); CompletableFuture<List<ReportVO>> reportFuture = CompletableFuture.supplyAsync(() -> reportService.listByOrder(order.getOrderNo()), pool); // 3. 整体超时1200毫秒,任何一个子任务卡住都直接失败 try { CompletableFuture.allOf(paymentFuture, queueFuture, reportFuture).get(1200, TimeUnit.MILLISECONDS); } catch (Exception e) { throw new BusinessException("门诊信息查询超时,请稍后重试"); } // 4. 组装视图对象,如果某个子查询失败则返回空值 TreatmentFlowVO vo = new TreatmentFlowVO(); vo.setOrderNo(order.getOrderNo()); vo.setOrderStatus(order.getStatus()); vo.setPayment(paymentFuture.getNow(PaymentVO.empty())); vo.setQueue(queueFuture.getNow(QueueVO.empty())); vo.setReports(reportFuture.getNow(Collections.emptyList())); return ApiResult.success(vo); } }

逻辑说明:第一步查订单主表是串行的,因为后面所有查询都依赖orderNo。第二步用CompletableFuture把三个独立查询并行化,这里有一个新手高频翻车点:默认的ForkJoinPool适合CPU密集任务,不适合IO密集的数据库查询,必须自定义线程池,否则并发一上来会饿死应用里的其他并行流。第三步用allOf().get(1200ms)统一超时,意味着任何一个子查询超过1.2秒,整个接口直接失败,而不是无限等。第四步用getNow取默认值,即使某个子任务抛了异常,也能把其余正常数据返回给前端,做到局部降级。

超时参数1200毫秒不是随便定的。自助机、手机端患者操作的感知阈值通常在1秒左右,如果后端占满1200毫秒,前端只剩800毫秒渲染,勉强及格。如果在内网环境,可以提到1500毫秒;超过这个值说明有慢SQL,该去查执行计划而不是调超时。

3.3 关键配置参数:线程池、连接池、Redis缓存超时

聚合接口的稳定性不在代码,在配置。下面是一份我验证过的application.yml核心配置:

spring: datasource: url: jdbc:mysql://localhost:3306/clinic?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 redis: host: localhost port: 6379 timeout: 1000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 app: aggregation: core-pool-size: 4 max-pool-size: 8 queue-capacity: 50 query-timeout: 1200

参数说明:HikariCP的maximum-pool-size设成20,对单体门诊系统足够。connection-timeout设为3000毫秒,意味着拿连接超过3秒直接报错,避免线程全部卡死在等连接上。Redis的timeout是1000ms,如果Redis抖动,宁可让缓存查询失败走数据库,也不拖垮聚合接口。app.aggregation是自定义参数,对应线程池构造:核心4个线程、最大8个、队列容量50。这里不要开太大,因为聚合接口一次会同时占用三个线程,线程数开成8,极限只能支撑约2.6个并发聚合请求,但好处是每个请求都有独立线程,不会因为等待线程池排队而超时。想要支持更高并发,应该缩短子查询耗时,而不是盲目加大线程池。

补充一个关于缓存的建议:聚合查询不要缓存订单主表。支付回调改库后缓存不失效,患者端会一直看到旧状态。我通常只缓存报告状态列表和医生排班,TTL设5分钟,后台修改排班后主动删除对应缓存键。这样既减少了数据库压力,又不用写一套复杂缓存一致性逻辑。

4. 门诊聚合系统的核心业务实现:预约挂号与聚合支付对账

这一章讲两个最容易出问题的业务:预约挂号和支付对账。业务实现不好,前面的聚合接口再漂亮也是空壳。“java怎么保证数据一致性”是面试常考的高频题,也是这套系统里真正的难点。

4.1 预约挂号的分布式锁与重复下单防护

预约挂号是门诊聚合系统里并发压力最大的接口。真实场景是:8点放号,同一秒内几百人抢一个专家号,如果没有防护,订单表会出现同一患者同一排班的多条重复订单。解决方案分三层:数据库唯一索引做兜底、Redis锁做前置拦截、业务层再次校验。下面这个RedisLock工具类是可运行的版本:

@Component public class RedisLock { @Resource private StringRedisTemplate stringRedisTemplate; /** * 尝试加锁,过期时间默认10秒 */ public boolean tryLock(String key, String requestId, long expireSeconds) { // 使用setnx + 过期时间,保证原子性 return Boolean.TRUE.equals(stringRedisTemplate.opsForValue() .setIfAbsent(key, requestId, expireSeconds, TimeUnit.SECONDS)); } public void unlock(String key, String requestId) { String value = stringRedisTemplate.opsForValue().get(key); if (requestId.equals(value)) { // 只删除自己持有的锁 stringRedisTemplate.delete(key); } } }

在挂号服务里的用法:

String lockKey = "appointment:lock:" + scheduleId + ":" + patientId; String requestId = UUID.randomUUID().toString(); boolean locked = redisLock.tryLock(lockKey, requestId, 10); if (!locked) { throw new BusinessException("请不要重复提交挂号请求"); } try { // 再次查数据库,防止锁处理期间已经有订单 int count = treatmentOrderMapper.countByScheduleAndPatient(scheduleId, patientId); if (count > 0) { throw new BusinessException("您已挂过该号源,请勿重复挂号"); } treatmentOrderService.createOrder(...); } finally { redisLock.unlock(lockKey, requestId); }

逻辑说明:lockKey由排班ID和患者ID组成,锁粒度精确到“某个患者挂某个号”,不是整个排班一把锁。requestId是随机UUID,解锁时校验是不是自己加的锁,防止因为锁过期把别人的锁误删。过期时间10秒,正常创建订单不到1秒,10秒足够;但如果数据库卡了10秒以上,锁自动释放,后续线程能拿到锁,就可能再次产生重复订单。所以数据库唯一索引仍然是最后一道防线,Redis锁只是降低冲突概率。

这里还有一个隐藏细节:tryLock里的setIfAbsent和expire必须是原子操作,不能先setnx再单独expire,否则setnx成功后进程突然退出,锁永不释放,整个号源会被锁死。上面代码用的带过期时间的setIfAbsent重载方法,就是Redis官方推荐的原子写法。

4.2 聚合支付回调与对账:java怎么保证数据一致性

支付是聚合系统最需要抠细节的地方。患者可能先用微信付挂号费,再在诊室补缴药费,两笔支付挂在同一个订单号下。微信和支付宝的回调是异步的,不保证只通知一次,所以回调接口必须先做幂等再写库。下面这段代码是核心处理逻辑:

@Transactional(rollbackFor = Exception.class) public void handlePayCallback(PayCallbackRequest req) { // 1. 用支付流水号作为幂等键 String paymentNo = req.getPaymentNo(); Integer currentStatus = paymentMapper.selectStatusByPaymentNo(paymentNo); if (currentStatus != null && currentStatus == 2) { // 该流水已支付成功,直接返回,不重复处理 return; } // 2. 校验渠道签名与金额 boolean signOk = payChannelService.verifySign(req); if (!signOk) { throw new BusinessException("支付回调签名校验失败"); } PaymentEntity payment = paymentMapper.selectByPaymentNo(paymentNo); if (payment == null) { log.warn("回调的支付流水不存在: {}", paymentNo); return; } // 3. 更新支付流水状态 payment.setPayStatus(2); payment.setTradeNo(req.getTradeNo()); payment.setCallbackTime(new Date()); paymentMapper.updateById(payment); // 4. 尝试推进订单状态 treatmentOrderService.tryAdvanceOrder(payment.getOrderNo()); }

幂等处理分两步:第一步,进来先查当前支付流水状态,如果已经是成功(payStatus=2)就直接返回,不重复改订单。这个判断防的是延迟重复通知。第二步,真正的高并发冲突要用数据库乐观锁兜底。把updateById改成带条件的更新:

UPDATE t_payment SET pay_status = 2, trade_no = #{tradeNo}, callback_time = #{now} WHERE payment_no = #{paymentNo} AND pay_status != 2

影响行数为0时说明已经被其他线程处理过,直接返回。这样即使两个回调线程并发进入,也只有一条SQL能更新成功。

tryAdvanceOrder做的是状态机推进:只有当一个订单所有支付流水(挂号费、检查费、药费)全部成功,才能把订单状态从“待支付”变成“已支付待就诊”。这里不能只更新支付流水就改订单状态,否则可能患者只付了挂号费,系统就认为整个订单已经支付完成,候诊队列会提前放行。

如果只依赖回调,一旦回调丢失,患者明明付了钱订单还停在待支付,就需要补偿对账。常见做法是每天凌晨跑一个定时任务,调用支付渠道提供的账单下载接口,逐笔比对本地payment表和渠道账单,把本地仍然待支付但渠道已扣款的单子找出来,主动刷新状态。这个对账任务在源码里至少留一个接口入口,面试时说到“数据一致性”,可以按“回调幂等+乐观锁+定时对账”三层去讲,比背八股文有意思得多。

4.3 状态机设计:从待支付到已完成的流转

门诊订单状态不能靠代码里到处setStatus乱跳。我一般把状态机写成枚举,再提供一个统一流转方法:

public enum OrderStatus { PENDING_PAY(0, "待支付"), PAID(1, "已支付待就诊"), WAITING(2, "候诊中"), TREATING(3, "就诊中"), WAIT_MEDICINE(4, "待取药"), DONE(5, "已完成"), CANCELED(6, "已取消"); private final int code; private final String desc; } public class OrderStateMachine { private static final Map<Integer, Set<Integer>> ALLOW_TRANSITIONS = new HashMap<>(); static { ALLOW_TRANSITIONS.put(0, Set.of(1, 6)); // 待支付 -> 已支付/取消 ALLOW_TRANSITIONS.put(1, Set.of(2, 6)); // 已支付 -> 候诊中/取消 ALLOW_TRANSITIONS.put(2, Set.of(3)); // 候诊中 -> 就诊中 ALLOW_TRANSITIONS.put(3, Set.of(4, 5)); // 就诊中 -> 待取药/已完成 ALLOW_TRANSITIONS.put(4, Set.of(5)); // 待取药 -> 已完成 } public static void transition(OrderEntity order, OrderStatus target) { Set<Integer> allowed = ALLOW_TRANSITIONS.get(order.getStatus()); if (allowed == null || !allowed.contains(target.getCode())) { throw new BusinessException("非法状态流转: " + order.getStatus() + " -> " + target.getCode()); } order.setStatus(target.getCode()); } }

状态机的好处是避免“已取消还能变成候诊中”这种逻辑漏洞。注意状态值从0到6和第2章表结构一致。为什么支付成功后不能直接跳到就诊中?因为中间还需要生成候诊队列记录,叫号系统只有在“已支付待就诊”状态才能进入队列。如果直接跳到候诊中,排班和队列的数据对不上,护士站会看到患者不在队列里。

5. 门诊服务聚合系统排查:5个高频踩坑与解决记录

这一章不是网上复制的java八股文,是我实际调门诊聚合系统时踩过的坑。每条按“现象、原因、解决”写,读者可以直接按图索骥排查。

5.1 挂号成功后查询不到聚合记录:事务边界不一致

现象:患者在挂号接口返回成功后,前端立刻调用聚合查询首页接口,结果提示“今日无就诊记录”,隔几秒再查又有记录。

原因:挂号接口里开启事务后,先插入主订单再更新排班余票,事务还没提交就对外返回成功;聚合查询在另一个数据库连接里读不到未提交的数据。如果系统用了主从分离,还可能是主从延迟。

解决:单体项目里@Transactional的事务提交发生在方法返回前,所以问题大多不在应用事务,而在主从延迟。聚合查询关键数据强制走主库,最简单的方式是在聚合Service里使用一个独立DataSource路由到主库。同时可以在订单创建后删除该患者的订单缓存,聚合接口先查Redis,查不到再走主库,能大幅降低延迟影响。

5.2 支付回调重复通知导致状态错乱:没有做幂等

现象:患者微信支付成功后,订单状态被反复更新,最后一次更新被覆盖回“待支付”,导致患者已付款却被叫号系统拒绝。

原因:微信回调会多次通知,高并发下两个线程同时读到支付流水是待支付,都执行了状态更新。如果回调接口没有幂等判断,也没有乐观锁,后来的线程会把已成功状态覆盖回去。

解决:回调入口先按payment_no加分布式锁,锁内再查状态;更新语句必须带条件“WHERE pay_status != 2”;订单状态推进放在支付流水更新之后,用事务包裹。这样即使渠道重复通知十次,也只有第一笔能真正生效。

5.3 聚合查询超时拖垮整个接口:慢SQL与N+1

现象:聚合接口响应时间从200毫秒涨到5秒,压测时线程池被打满,连登录接口也跟着变慢。

原因:查询报告列表时,在循环里逐条查报告明细,典型的N+1问题;t_payment表数据量上来后,order_no字段没有索引,LEFT JOIN变成全表扫描。

解决:报告列表用一次in查询替代循环;给t_payment的order_no加普通索引;聚合线程池单次任务超时控制在800毫秒内,超过直接返回空。压测后我保留一个习惯:所有聚合子查询SQL单独打印执行计划,先看rows是不是几十万,再谈加缓存。

5.4 科室排班数据缓存穿透:空值缓存与布隆过滤器

现象:医生突然停诊,前端查排班缓存为空,大量请求直接打到数据库,数据库CPU报警。

原因:缓存里没有对应排班键时,请求会穿过缓存直击数据库。抢号高峰时,一个不存在的scheduleId可能被刷几千次,数据库撑不住。

解决:排班查询加空值缓存,把null值缓存起来并设置TTL 60秒;更严的场景在接口层用布隆过滤器,把有效scheduleId集合放进去,能滤掉绝大多数无效请求。布隆过滤器适合排班ID这种整体变化频率低的场景,删除排班时要重建过滤器,否则新排班会查不到。

5.5 部署后接口报日期格式错误:Jackson时间序列化配置

现象:前端看到的createTime是“2023-12-01T10:00:00”,部分老浏览器解析失败,就诊日期显示NaN。

原因:Java 8时间类型默认序列化不格式化,后端返回ISO格式,前端期望“yyyy-MM-dd HH:mm:ss”。

解决:在application.yml里统一配置:

spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8

所有LocalDateTime和Date字段会按统一格式输出。同时确认jdbc连接参数里有serverTimezone=Asia/Shanghai,否则存储和读取会各差8小时。这个坑通常在本地Windows环境不出现,部署到Linux服务器后突然冒出来,因为本机时区和服务器时区不一致。

6. 门诊服务聚合系统的进阶验证:用Docker一键起全套环境并统计聚合成功率

项目跑通后,真正要问自己的是:聚合接口到底成功了多少次?失败分支占多少?第2章到第5章解决了“能不能用”,这一章解决“用了之后怎么验证它真的好用”。

6.1 用docker-compose快速拉起MySQL与Redis

联调最怕环境不一致。我习惯在项目里放一个docker-compose.yml,让评审和同学一条命令起全套基础环境。

version: "3" services: mysql: image: mysql:8.0 container_name: clinic-mysql environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: clinic ports: - "3306:3306" command: --default-time-zone=+08:00 redis: image: redis:7-alpine container_name: clinic-redis ports: - "6379:6379"

注意command里加了--default-time-zone=+08:00,这是为了规避容器默认UTC时区和服务器本地时间不一致的问题。很多人本地跑MySQL好好的,部署到服务器后所有时间字段差8小时,就是因为没设置时区。

6.2 在聚合网关埋点统计聚合成功率

验证聚合接口是否健康,可以写一个简单的AOP切面,统计所有聚合Controller的成功和失败次数。

@Aspect @Component public class AggregationMetricAspect { private final AtomicLong successCount = new AtomicLong(); private final AtomicLong failCount = new AtomicLong(); @Around("execution(* com.clinic.gateway..*Controller.*(..))") public Object count(ProceedingJoinPoint pjp) throws Throwable { long start = System.currentTimeMillis(); try { Object result = pjp.proceed(); successCount.incrementAndGet(); log.info("aggregation success, cost={}ms", System.currentTimeMillis() - start); return result; } catch (Exception e) { failCount.incrementAndGet(); log.error("aggregation fail", e); throw e; } } }

再通过Spring Boot Actuator暴露一个自定义Endpoint,把successCount和failCount输出成JSON。这样压测的时候能看到失败率,也能在联调时快速发现某个子服务挂了导致聚合接口整体失败。统计出来的数据比我自己的感觉可靠得多——有一次我觉得系统很稳,结果失败率1.2%,排查后发现是候诊队列子查询偶尔抛超时异常,被全局异常处理器吞成了空队列返回,前端显示“正在候诊”但其实队列里没人。

我在这套系统里吃过最大的亏,就是只测主流程、没测支付回调重复通知,上线第二天被对账数据打脸。后来我强制自己把每个对外接口都当成“会被重复调用”来设计,幂等键先行,再到状态机里检查非法流转,这个习惯帮我避开了很多类似的坑。聚合系统的价值不在用了多新的框架,而在把状态边界和失败分支理顺。希望帮到你。

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

返回列表