Java后端门诊服务聚合系统实战:Spring Boot模块化设计与订单状态管理
发布时间:2026/9/29 17:47:30来源:尧图网络
简介基于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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB COMMENT报告状态同步表;本地只存报告元数据患者查询时先读本地状态status1且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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies !-- Web入口 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 数据库访问 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency !-- 缓存用于排班与门诊状态查询 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/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 ApiResultTreatmentFlowVO getTodayTreatment(RequestParam Long patientId) { // 1. 先查订单主表拿到患者今天的主订单 TreatmentOrder order orderService.findTodayOrder(patientId); if (order null) { return ApiResult.success(TreatmentFlowVO.empty()); } // 2. 并行查询支付、候诊、报告状态互不阻塞 CompletableFuturePaymentVO paymentFuture CompletableFuture.supplyAsync(() - paymentService.getLatest(order.getOrderNo()), pool); CompletableFutureQueueVO queueFuture CompletableFuture.supplyAsync(() - queueService.getCurrent(order.getOrderNo()), pool); CompletableFutureListReportVO 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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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()); }幂等处理分两步第一步进来先查当前支付流水状态如果已经是成功payStatus2就直接返回不重复改订单。这个判断防的是延迟重复通知。第二步真正的高并发冲突要用数据库乐观锁兜底。把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 MapInteger, SetInteger 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) { SetInteger 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与N1现象聚合接口响应时间从200毫秒涨到5秒压测时线程池被打满连登录接口也跟着变慢。原因查询报告列表时在循环里逐条查报告明细典型的N1问题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: GMT8所有LocalDateTime和Date字段会按统一格式输出。同时确认jdbc连接参数里有serverTimezoneAsia/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-zone08:00 redis: image: redis:7-alpine container_name: clinic-redis ports: - 6379:6379注意command里加了--default-time-zone08: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%排查后发现是候诊队列子查询偶尔抛超时异常被全局异常处理器吞成了空队列返回前端显示“正在候诊”但其实队列里没人。我在这套系统里吃过最大的亏就是只测主流程、没测支付回调重复通知上线第二天被对账数据打脸。后来我强制自己把每个对外接口都当成“会被重复调用”来设计幂等键先行再到状态机里检查非法流转这个习惯帮我避开了很多类似的坑。聚合系统的价值不在用了多新的框架而在把状态边界和失败分支理顺。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网