聚合系統(tǒng)實戰(zhàn):Spring Boot模塊化設(shè)計與訂單狀態(tài)管理)
簡介基于Java語言開發(fā)的門診服務(wù)聚合系統(tǒng)設(shè)計源碼面向醫(yī)療信息化開發(fā)者和有一定Java基礎(chǔ)的后端學(xué)習(xí)者可用于解決預(yù)約掛號、排隊叫號、醫(yī)療記錄管理等門診服務(wù)場景中的流程聚合、數(shù)據(jù)管理與系統(tǒng)整合問題。整個資源包共包含51個文件壓縮后大小約220KB其中以34個Java源文件為主另有8個XML配置文件、1個YAML配置、1個properties屬性文件及少量構(gòu)建與部署文件Java源文件承載各功能模塊的業(yè)務(wù)邏輯配置文件負(fù)責(zé)管理數(shù)據(jù)庫連接、事務(wù)處理和安全控制等參數(shù)整體結(jié)構(gòu)清晰便于按模塊查看與復(fù)用。目前已有246人學(xué)習(xí)/下載系統(tǒng)采用模塊化、服務(wù)化開發(fā)思想結(jié)合Spring Boot、Spring MVC和MyBatis完成后端業(yè)務(wù)處理前端通過AJAX方式與后端交互并引入Spring Security增強(qiáng)醫(yī)療數(shù)據(jù)訪問安全。配套文檔提供了項目介紹、接口說明和部署指引讀者結(jié)合源碼可以梳理從功能模塊劃分到數(shù)據(jù)持久化的完整設(shè)計鏈路也能借鑒其在用戶管理、預(yù)約掛號、排隊叫號、醫(yī)療記錄管理等模塊的代碼組織方式為同類醫(yī)療信息化項目提供有價值的參考。1. 門診服務(wù)聚合系統(tǒng)為什么一個Java后端能頂?shù)羧齻€窗口患者就診最煩的不是排隊而是為了“辦好一件事”在掛號、繳費(fèi)、報告窗口之間來回跑。門診服務(wù)聚合系統(tǒng)的思路很直接把掛號、候診隊列、繳費(fèi)單、報告狀態(tài)這些分散在不同科室、不同系統(tǒng)里的服務(wù)用同一個Java后端統(tǒng)一聚合起來。前端只需要調(diào)一次接口就能拿回患者當(dāng)前的全部就診進(jìn)度。它適合課程設(shè)計、畢業(yè)設(shè)計也適合小型診所自建門診系統(tǒng)。很多人拿到這類源碼項目第一反應(yīng)是拆微服務(wù)結(jié)果還沒開始寫就被注冊中心、配置中心拖住。門診場景并發(fā)沒那么極端用Spring Boot做模塊化聚合先把訂單狀態(tài)串起來才是性價比最高的做法。2. 門診聚合系統(tǒng)的模塊拆分與數(shù)據(jù)庫設(shè)計把掛號、繳費(fèi)、報告做成可聯(lián)調(diào)的表結(jié)構(gòu)門診聚合系統(tǒng)的核心不在代碼而在數(shù)據(jù)模型。我見過不少java課程設(shè)計案例源碼所有業(yè)務(wù)邏輯堆在一個Controller里數(shù)據(jù)庫只有三張表一聯(lián)調(diào)就露餡。聚合系統(tǒng)要聚合的是“一次完整的就診數(shù)據(jù)”如果表結(jié)構(gòu)里連統(tǒng)一訂單號都沒有后面所有接口都只能東拼西湊。這一章先講清楚模塊怎么分再給出一套可以直接建表的SQL。2.1 聚合系統(tǒng)的核心模塊劃分患者端、醫(yī)生端、統(tǒng)一網(wǎng)關(guān)層聚合服務(wù)不一定要拆成微服務(wù)。我一般把工程拆成四個Maven模塊就夠用common模塊放統(tǒng)一返回體、業(yè)務(wù)異常、訂單號生成器、狀態(tài)枚舉aggregation模塊是真正對外提供聚合接口的BFF層負(fù)責(zé)把其他模塊返回的碎片數(shù)據(jù)組裝成前端需要的視圖模型patient模塊負(fù)責(zé)預(yù)約掛號、支付、報告查詢doctor模塊負(fù)責(zé)排班、叫號、醫(yī)生工作臺。每個模塊按包名隔離最終打成一個可執(zhí)行jar包。這樣既保留了模塊邊界又不需要部署多個進(jìn)程課程設(shè)計的答辯現(xiàn)場也不會因為起不來服務(wù)而翻車。為什么這樣拆因為聚合系統(tǒng)最忌諱把聚合邏輯和基礎(chǔ)業(yè)務(wù)寫在一起。聚合層只做編排不碰業(yè)務(wù)細(xì)節(jié)業(yè)務(wù)模塊只關(guān)心自己的領(lǐng)域不需要知道前端要什么字段。典型例子是患者首頁要同時顯示“待繳費(fèi)金額”和“報告已出”兩個信息聚合層拿到patient模塊的數(shù)據(jù)后直接組裝不再查一次數(shù)據(jù)庫。如果讓patient模塊直接返回視圖對象下次前端要加一個字段就得改patient模塊并重新測試聚合層形同虛設(shè)。2.2 核心表結(jié)構(gòu)與訂單狀態(tài)串起全流程下面是一套我在類似項目中用的核心建表SQL基于MySQL 8.0。第一張是“就診聚合訂單主表”整個系統(tǒng)的所有流程都圍繞它轉(zhuǎn)。-- 就診聚合訂單主表將掛號、繳費(fèi)、取藥、報告串聯(lián) CREATE TABLE t_treatment_order ( id bigint NOT NULL AUTO_INCREMENT COMMENT 物理主鍵, order_no varchar(32) NOT NULL COMMENT 業(yè)務(wù)訂單號全局唯一, patient_id bigint NOT NULL COMMENT 患者ID, doctor_id bigint NOT NULL COMMENT 醫(yī)生ID, dept_id bigint NOT NULL COMMENT 科室ID, schedule_id bigint NOT NULL COMMENT 排班ID, status tinyint NOT NULL DEFAULT 0 COMMENT 狀態(tài)0待支付,1已支付待就診,2候診中,3就診中,4待取藥,5已完成,6已取消, pay_type tinyint DEFAULT NULL COMMENT 支付方式1微信,2支付寶,3醫(yī)保, 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字段是整套系統(tǒng)的狀態(tài)樞紐。0是待支付1是已支付待就診2開始進(jìn)入候診3就診中4待取藥5完成6取消。注意狀態(tài)0不是“已創(chuàng)建”因為門診掛號的號源是稀缺資源如果允許創(chuàng)建訂單后一直不支付號源會被無限制占用所以我一般會加上“30秒內(nèi)未支付自動取消”的定時任務(wù)。對外暴露用order_no而不是自增id既防止被遍歷也方便在多個模塊間傳遞。接下來是支付流水子表和候診排隊子表。支付流水必須拆出來因為同一個訂單在多科室就診時可能發(fā)生多筆支付比如先微信付掛號費(fèi)再在診室補(bǔ)繳檢查費(fèi)。候診排隊子表單獨(dú)存叫號狀態(tài)方便護(hù)士站刷新隊列。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醫(yī)保, 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 渠道回調(diào)時間, 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 當(dāng)天叫號序號, 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是兩個維度的狀態(tài)。支付流水只關(guān)心這筆錢有沒有付成功訂單狀態(tài)關(guān)心整個就診流程走到哪一步。如果混在一起取消訂單、退款、部分支付這些場景會非常難寫。候診隊列的queue_no是當(dāng)天從1開始累加的序號不是全局自增這個字段在叫號時直接展示所以不需要全局唯一。2.3 報告狀態(tài)同步表LIS/PACS不是你的系統(tǒng)門診報告檢驗、檢查往往不由聚合系統(tǒng)生成而是由LIS或PACS系統(tǒng)負(fù)責(zé)。聚合服務(wù)要拿到報告狀態(tài)常見做法是“同步狀態(tài)不同步文件”。在本地建一張報告狀態(tài)同步表通過定時任務(wù)或回調(diào)更新。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已領(lǐng)取, result_url varchar(256) DEFAULT NULL COMMENT 報告文件地址, PRIMARY KEY (id), KEY idx_order_no_status (order_no,status) ) ENGINEInnoDB COMMENT報告狀態(tài)同步表;本地只存報告元數(shù)據(jù)患者查詢時先讀本地狀態(tài)status1且result_url存在再跳轉(zhuǎn)到文件服務(wù)。如果直接去LIS系統(tǒng)查詢每次患者刷新首頁都會把壓力打到檢驗科的系統(tǒng)這是典型的“聚合系統(tǒng)拖垮被聚合系統(tǒng)”的翻車現(xiàn)場。聚合查詢時還有一個容易踩的坑不要試圖用一條大SQL把訂單、支付、隊列、報告全部LEFT JOIN出來。如果同一訂單有多筆支付結(jié)果集會從一行變多行Java組裝時還得去重。我在第3章里說的聚合層“分多次查詢用Java組裝”就是因為這個。數(shù)據(jù)庫不擅長做視圖拼接Java配合CompletableFuture反而又清晰又快。3. 用Spring Boot搭建門診聚合服務(wù)最小可運(yùn)行項目的核心代碼這一章進(jìn)入動手環(huán)節(jié)。我會把項目骨架、聚合接口、關(guān)鍵配置拆開講給出一套能直接跑起來的最小核心代碼。讀者只要照著建表再把下面代碼填進(jìn)自己工程就能把聚合接口跑通。3.1 項目骨架與依賴版本先用一套穩(wěn)妥的Java技術(shù)棧門診聚合系統(tǒng)的選型不能太激進(jìn)。我一般用Spring Boot 2.7.x配Java 8因為很多課程設(shè)計環(huán)境、學(xué)校機(jī)房、舊服務(wù)器都還是Java 8。如果直接上Spring Boot 3.x要求Java 17不少人會在環(huán)境變量配置或IDE編譯那一步卡住最后還沒跑起來就放棄了。如果你已經(jīng)有java基礎(chǔ)想嘗鮮也可以升到3.x但下面代碼基于2.7更穩(wěn)。核心依賴如下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 !-- 數(shù)據(jù)庫訪問 -- 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 !-- 緩存用于排班與門診狀態(tài)查詢 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency !-- 參數(shù)校驗 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies參數(shù)說明MyBatis Plus 3.5.3.2這個版本跟Spring Boot 2.7配合很穩(wěn)定不會有兼容性報錯。mysql-connector-j 8.0.33是MySQL 8.x的官方驅(qū)動注意驅(qū)動類名是com.mysql.cj.jdbc.Driver老項目里寫的com.mysql.jdbc.Driver已經(jīng)廢棄。Redis在這套方案里不是強(qiáng)依賴如果只想把源碼跑通可以先把Redis依賴注釋掉但后面第4章要講分布式鎖建議還是配上。3.2 一個聚合Controller把掛號詳情、候診隊列、繳費(fèi)單、報告狀態(tài)一次返回聚合層的關(guān)鍵是“并行查詢、統(tǒng)一超時、失敗降級”。下面這個Controller是整套系統(tǒng)最核心的對外接口患者進(jìn)入門診首頁時前端只調(diào)它一次就能拿回今天的全部就診信息。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. 并行查詢支付、候診、報告狀態(tài)互不阻塞 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毫秒任何一個子任務(wù)卡住都直接失敗 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把三個獨(dú)立查詢并行化這里有一個新手高頻翻車點(diǎn)默認(rèn)的ForkJoinPool適合CPU密集任務(wù)不適合IO密集的數(shù)據(jù)庫查詢必須自定義線程池否則并發(fā)一上來會餓死應(yīng)用里的其他并行流。第三步用allOf().get(1200ms)統(tǒng)一超時意味著任何一個子查詢超過1.2秒整個接口直接失敗而不是無限等。第四步用getNow取默認(rèn)值即使某個子任務(wù)拋了異常也能把其余正常數(shù)據(jù)返回給前端做到局部降級。超時參數(shù)1200毫秒不是隨便定的。自助機(jī)、手機(jī)端患者操作的感知閾值通常在1秒左右如果后端占滿1200毫秒前端只剩800毫秒渲染勉強(qiáng)及格。如果在內(nèi)網(wǎng)環(huán)境可以提到1500毫秒超過這個值說明有慢SQL該去查執(zhí)行計劃而不是調(diào)超時。3.3 關(guān)鍵配置參數(shù)線程池、連接池、Redis緩存超時聚合接口的穩(wěn)定性不在代碼在配置。下面是一份我驗證過的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參數(shù)說明HikariCP的maximum-pool-size設(shè)成20對單體門診系統(tǒng)足夠。connection-timeout設(shè)為3000毫秒意味著拿連接超過3秒直接報錯避免線程全部卡死在等連接上。Redis的timeout是1000ms如果Redis抖動寧可讓緩存查詢失敗走數(shù)據(jù)庫也不拖垮聚合接口。app.aggregation是自定義參數(shù)對應(yīng)線程池構(gòu)造核心4個線程、最大8個、隊列容量50。這里不要開太大因為聚合接口一次會同時占用三個線程線程數(shù)開成8極限只能支撐約2.6個并發(fā)聚合請求但好處是每個請求都有獨(dú)立線程不會因為等待線程池排隊而超時。想要支持更高并發(fā)應(yīng)該縮短子查詢耗時而不是盲目加大線程池。補(bǔ)充一個關(guān)于緩存的建議聚合查詢不要緩存訂單主表。支付回調(diào)改庫后緩存不失效患者端會一直看到舊狀態(tài)。我通常只緩存報告狀態(tài)列表和醫(yī)生排班TTL設(shè)5分鐘后臺修改排班后主動刪除對應(yīng)緩存鍵。這樣既減少了數(shù)據(jù)庫壓力又不用寫一套復(fù)雜緩存一致性邏輯。4. 門診聚合系統(tǒng)的核心業(yè)務(wù)實現(xiàn)預(yù)約掛號與聚合支付對賬這一章講兩個最容易出問題的業(yè)務(wù)預(yù)約掛號和支付對賬。業(yè)務(wù)實現(xiàn)不好前面的聚合接口再漂亮也是空殼?!癹ava怎么保證數(shù)據(jù)一致性”是面試??嫉母哳l題也是這套系統(tǒng)里真正的難點(diǎn)。4.1 預(yù)約掛號的分布式鎖與重復(fù)下單防護(hù)預(yù)約掛號是門診聚合系統(tǒng)里并發(fā)壓力最大的接口。真實場景是8點(diǎn)放號同一秒內(nèi)幾百人搶一個專家號如果沒有防護(hù)訂單表會出現(xiàn)同一患者同一排班的多條重復(fù)訂單。解決方案分三層數(shù)據(jù)庫唯一索引做兜底、Redis鎖做前置攔截、業(yè)務(wù)層再次校驗。下面這個RedisLock工具類是可運(yùn)行的版本Component public class RedisLock { Resource private StringRedisTemplate stringRedisTemplate; /** * 嘗試加鎖過期時間默認(rèn)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); } } }在掛號服務(wù)里的用法String lockKey appointment:lock: scheduleId : patientId; String requestId UUID.randomUUID().toString(); boolean locked redisLock.tryLock(lockKey, requestId, 10); if (!locked) { throw new BusinessException(請不要重復(fù)提交掛號請求); } try { // 再次查數(shù)據(jù)庫防止鎖處理期間已經(jīng)有訂單 int count treatmentOrderMapper.countByScheduleAndPatient(scheduleId, patientId); if (count 0) { throw new BusinessException(您已掛過該號源請勿重復(fù)掛號); } treatmentOrderService.createOrder(...); } finally { redisLock.unlock(lockKey, requestId); }邏輯說明lockKey由排班ID和患者ID組成鎖粒度精確到“某個患者掛某個號”不是整個排班一把鎖。requestId是隨機(jī)UUID解鎖時校驗是不是自己加的鎖防止因為鎖過期把別人的鎖誤刪。過期時間10秒正常創(chuàng)建訂單不到1秒10秒足夠但如果數(shù)據(jù)庫卡了10秒以上鎖自動釋放后續(xù)線程能拿到鎖就可能再次產(chǎn)生重復(fù)訂單。所以數(shù)據(jù)庫唯一索引仍然是最后一道防線Redis鎖只是降低沖突概率。這里還有一個隱藏細(xì)節(jié)tryLock里的setIfAbsent和expire必須是原子操作不能先setnx再單獨(dú)expire否則setnx成功后進(jìn)程突然退出鎖永不釋放整個號源會被鎖死。上面代碼用的帶過期時間的setIfAbsent重載方法就是Redis官方推薦的原子寫法。4.2 聚合支付回調(diào)與對賬java怎么保證數(shù)據(jù)一致性支付是聚合系統(tǒng)最需要摳細(xì)節(jié)的地方?;颊呖赡芟扔梦⑿鸥稈焯栙M(fèi)再在診室補(bǔ)繳藥費(fèi)兩筆支付掛在同一個訂單號下。微信和支付寶的回調(diào)是異步的不保證只通知一次所以回調(diào)接口必須先做冪等再寫庫。下面這段代碼是核心處理邏輯Transactional(rollbackFor Exception.class) public void handlePayCallback(PayCallbackRequest req) { // 1. 用支付流水號作為冪等鍵 String paymentNo req.getPaymentNo(); Integer currentStatus paymentMapper.selectStatusByPaymentNo(paymentNo); if (currentStatus ! null currentStatus 2) { // 該流水已支付成功直接返回不重復(fù)處理 return; } // 2. 校驗渠道簽名與金額 boolean signOk payChannelService.verifySign(req); if (!signOk) { throw new BusinessException(支付回調(diào)簽名校驗失敗); } PaymentEntity payment paymentMapper.selectByPaymentNo(paymentNo); if (payment null) { log.warn(回調(diào)的支付流水不存在: {}, paymentNo); return; } // 3. 更新支付流水狀態(tài) payment.setPayStatus(2); payment.setTradeNo(req.getTradeNo()); payment.setCallbackTime(new Date()); paymentMapper.updateById(payment); // 4. 嘗試推進(jìn)訂單狀態(tài) treatmentOrderService.tryAdvanceOrder(payment.getOrderNo()); }冪等處理分兩步第一步進(jìn)來先查當(dāng)前支付流水狀態(tài)如果已經(jīng)是成功payStatus2就直接返回不重復(fù)改訂單。這個判斷防的是延遲重復(fù)通知。第二步真正的高并發(fā)沖突要用數(shù)據(jù)庫樂觀鎖兜底。把updateById改成帶條件的更新UPDATE t_payment SET pay_status 2, trade_no #{tradeNo}, callback_time #{now} WHERE payment_no #{paymentNo} AND pay_status ! 2影響行數(shù)為0時說明已經(jīng)被其他線程處理過直接返回。這樣即使兩個回調(diào)線程并發(fā)進(jìn)入也只有一條SQL能更新成功。tryAdvanceOrder做的是狀態(tài)機(jī)推進(jìn)只有當(dāng)一個訂單所有支付流水掛號費(fèi)、檢查費(fèi)、藥費(fèi)全部成功才能把訂單狀態(tài)從“待支付”變成“已支付待就診”。這里不能只更新支付流水就改訂單狀態(tài)否則可能患者只付了掛號費(fèi)系統(tǒng)就認(rèn)為整個訂單已經(jīng)支付完成候診隊列會提前放行。如果只依賴回調(diào)一旦回調(diào)丟失患者明明付了錢訂單還停在待支付就需要補(bǔ)償對賬。常見做法是每天凌晨跑一個定時任務(wù)調(diào)用支付渠道提供的賬單下載接口逐筆比對本地payment表和渠道賬單把本地仍然待支付但渠道已扣款的單子找出來主動刷新狀態(tài)。這個對賬任務(wù)在源碼里至少留一個接口入口面試時說到“數(shù)據(jù)一致性”可以按“回調(diào)冪等樂觀鎖定時對賬”三層去講比背八股文有意思得多。4.3 狀態(tài)機(jī)設(shè)計從待支付到已完成的流轉(zhuǎn)門診訂單狀態(tài)不能靠代碼里到處setStatus亂跳。我一般把狀態(tài)機(jī)寫成枚舉再提供一個統(tǒng)一流轉(zhuǎn)方法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(非法狀態(tài)流轉(zhuǎn): order.getStatus() - target.getCode()); } order.setStatus(target.getCode()); } }狀態(tài)機(jī)的好處是避免“已取消還能變成候診中”這種邏輯漏洞。注意狀態(tài)值從0到6和第2章表結(jié)構(gòu)一致。為什么支付成功后不能直接跳到就診中因為中間還需要生成候診隊列記錄叫號系統(tǒng)只有在“已支付待就診”狀態(tài)才能進(jìn)入隊列。如果直接跳到候診中排班和隊列的數(shù)據(jù)對不上護(hù)士站會看到患者不在隊列里。5. 門診服務(wù)聚合系統(tǒng)排查5個高頻踩坑與解決記錄這一章不是網(wǎng)上復(fù)制的java八股文是我實際調(diào)門診聚合系統(tǒng)時踩過的坑。每條按“現(xiàn)象、原因、解決”寫讀者可以直接按圖索驥排查。5.1 掛號成功后查詢不到聚合記錄事務(wù)邊界不一致現(xiàn)象患者在掛號接口返回成功后前端立刻調(diào)用聚合查詢首頁接口結(jié)果提示“今日無就診記錄”隔幾秒再查又有記錄。原因掛號接口里開啟事務(wù)后先插入主訂單再更新排班余票事務(wù)還沒提交就對外返回成功聚合查詢在另一個數(shù)據(jù)庫連接里讀不到未提交的數(shù)據(jù)。如果系統(tǒng)用了主從分離還可能是主從延遲。解決單體項目里Transactional的事務(wù)提交發(fā)生在方法返回前所以問題大多不在應(yīng)用事務(wù)而在主從延遲。聚合查詢關(guān)鍵數(shù)據(jù)強(qiáng)制走主庫最簡單的方式是在聚合Service里使用一個獨(dú)立DataSource路由到主庫。同時可以在訂單創(chuàng)建后刪除該患者的訂單緩存聚合接口先查Redis查不到再走主庫能大幅降低延遲影響。5.2 支付回調(diào)重復(fù)通知導(dǎo)致狀態(tài)錯亂沒有做冪等現(xiàn)象患者微信支付成功后訂單狀態(tài)被反復(fù)更新最后一次更新被覆蓋回“待支付”導(dǎo)致患者已付款卻被叫號系統(tǒng)拒絕。原因微信回調(diào)會多次通知高并發(fā)下兩個線程同時讀到支付流水是待支付都執(zhí)行了狀態(tài)更新。如果回調(diào)接口沒有冪等判斷也沒有樂觀鎖后來的線程會把已成功狀態(tài)覆蓋回去。解決回調(diào)入口先按payment_no加分布式鎖鎖內(nèi)再查狀態(tài)更新語句必須帶條件“WHERE pay_status ! 2”訂單狀態(tài)推進(jìn)放在支付流水更新之后用事務(wù)包裹。這樣即使渠道重復(fù)通知十次也只有第一筆能真正生效。5.3 聚合查詢超時拖垮整個接口慢SQL與N1現(xiàn)象聚合接口響應(yīng)時間從200毫秒漲到5秒壓測時線程池被打滿連登錄接口也跟著變慢。原因查詢報告列表時在循環(huán)里逐條查報告明細(xì)典型的N1問題t_payment表數(shù)據(jù)量上來后order_no字段沒有索引LEFT JOIN變成全表掃描。解決報告列表用一次in查詢替代循環(huán)給t_payment的order_no加普通索引聚合線程池單次任務(wù)超時控制在800毫秒內(nèi)超過直接返回空。壓測后我保留一個習(xí)慣所有聚合子查詢SQL單獨(dú)打印執(zhí)行計劃先看rows是不是幾十萬再談加緩存。5.4 科室排班數(shù)據(jù)緩存穿透空值緩存與布隆過濾器現(xiàn)象醫(yī)生突然停診前端查排班緩存為空大量請求直接打到數(shù)據(jù)庫數(shù)據(jù)庫CPU報警。原因緩存里沒有對應(yīng)排班鍵時請求會穿過緩存直擊數(shù)據(jù)庫。搶號高峰時一個不存在的scheduleId可能被刷幾千次數(shù)據(jù)庫撐不住。解決排班查詢加空值緩存把null值緩存起來并設(shè)置TTL 60秒更嚴(yán)的場景在接口層用布隆過濾器把有效scheduleId集合放進(jìn)去能濾掉絕大多數(shù)無效請求。布隆過濾器適合排班ID這種整體變化頻率低的場景刪除排班時要重建過濾器否則新排班會查不到。5.5 部署后接口報日期格式錯誤Jackson時間序列化配置現(xiàn)象前端看到的createTime是“2023-12-01T10:00:00”部分老瀏覽器解析失敗就診日期顯示NaN。原因Java 8時間類型默認(rèn)序列化不格式化后端返回ISO格式前端期望“yyyy-MM-dd HH:mm:ss”。解決在application.yml里統(tǒng)一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8所有LocalDateTime和Date字段會按統(tǒng)一格式輸出。同時確認(rèn)jdbc連接參數(shù)里有serverTimezoneAsia/Shanghai否則存儲和讀取會各差8小時。這個坑通常在本地Windows環(huán)境不出現(xiàn)部署到Linux服務(wù)器后突然冒出來因為本機(jī)時區(qū)和服務(wù)器時區(qū)不一致。6. 門診服務(wù)聚合系統(tǒng)的進(jìn)階驗證用Docker一鍵起全套環(huán)境并統(tǒng)計聚合成功率項目跑通后真正要問自己的是聚合接口到底成功了多少次失敗分支占多少第2章到第5章解決了“能不能用”這一章解決“用了之后怎么驗證它真的好用”。6.1 用docker-compose快速拉起MySQL與Redis聯(lián)調(diào)最怕環(huán)境不一致。我習(xí)慣在項目里放一個docker-compose.yml讓評審和同學(xué)一條命令起全套基礎(chǔ)環(huán)境。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這是為了規(guī)避容器默認(rèn)UTC時區(qū)和服務(wù)器本地時間不一致的問題。很多人本地跑MySQL好好的部署到服務(wù)器后所有時間字段差8小時就是因為沒設(shè)置時區(qū)。6.2 在聚合網(wǎng)關(guān)埋點(diǎn)統(tǒng)計聚合成功率驗證聚合接口是否健康可以寫一個簡單的AOP切面統(tǒng)計所有聚合Controller的成功和失敗次數(shù)。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。這樣壓測的時候能看到失敗率也能在聯(lián)調(diào)時快速發(fā)現(xiàn)某個子服務(wù)掛了導(dǎo)致聚合接口整體失敗。統(tǒng)計出來的數(shù)據(jù)比我自己的感覺可靠得多——有一次我覺得系統(tǒng)很穩(wěn)結(jié)果失敗率1.2%排查后發(fā)現(xiàn)是候診隊列子查詢偶爾拋超時異常被全局異常處理器吞成了空隊列返回前端顯示“正在候診”但其實隊列里沒人。我在這套系統(tǒng)里吃過最大的虧就是只測主流程、沒測支付回調(diào)重復(fù)通知上線第二天被對賬數(shù)據(jù)打臉。后來我強(qiáng)制自己把每個對外接口都當(dāng)成“會被重復(fù)調(diào)用”來設(shè)計冪等鍵先行再到狀態(tài)機(jī)里檢查非法流轉(zhuǎn)這個習(xí)慣幫我避開了很多類似的坑。聚合系統(tǒng)的價值不在用了多新的框架而在把狀態(tài)邊界和失敗分支理順。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取