微服務實戰(zhàn):SpringCloud拆解員工密碼重置與訂單狀態(tài)機)
1. 項目概述與需求拆解1.1 外賣點餐配送系統(tǒng)到底做了什么外賣點餐配送系統(tǒng)說白了就是把用戶下單、商家做餐、騎手配送這條鏈路搬到線上并且用一套后臺把所有角色串起來。這個標題里有個容易被忽略的點——員工與忘記密碼。也就是說這套系統(tǒng)不只是給C端用戶點外賣用的它還有完整的運營后臺和員工賬號體系。系統(tǒng)里的角色至少包括C端用戶瀏覽菜品、下單、支付、查看訂單狀態(tài)、申請退款。商家端菜品管理、訂單接單/拒單、出餐通知。騎手端搶單/接單、取餐、配送、標記送達。平臺運營員工審核商家、處理糾紛、運營配置。系統(tǒng)管理員賬號權限管理、數(shù)據(jù)看板、系統(tǒng)配置。在這套系統(tǒng)里員工端忘記密碼就是一個非常典型的業(yè)務需求——不是只有用戶需要找回密碼后臺運營人員同樣需要。而且員工密碼重置涉及權限安全不能馬虎。我見過很多類似的畢設或實訓項目往往把精力全放在C端點餐頁面上結果一到員工管理、密碼找回這種不起眼的功能就草草了事。實際上這種功能恰恰是面試官和評審老師最愛追問的點你怎么保證重置密碼的安全性Token有效期怎么設計驗證碼怎么防刷這些細節(jié)才是項目的含金量所在。1.2 為什么這套系統(tǒng)必須上微服務分布式先說結論一個外賣系統(tǒng)如果只在單機SpringBoot里做代碼也能跑通但一旦考慮真實場景——高并發(fā)下單、騎手搶單、多端同時在線、持續(xù)迭代上線——單體應用就會變成瓶頸。外賣系統(tǒng)天然適合微服務拆分原因是它的業(yè)務域足夠清晰業(yè)務域典型職責拆分收益用戶服務C端注冊登錄、地址管理獨立擴展應對大促峰值商家服務店鋪信息、菜品管理與用戶流量隔離訂單服務下單、訂單狀態(tài)流轉核心鏈路重點保障配送服務騎手管理、派單、軌跡獨立伸縮搶單場景并發(fā)高支付服務支付回調(diào)、對賬第三方交互隔離故障員工/認證服務后臺賬號、權限、密碼管理安全邊界獨立審計這不是為了炫技。分布式帶來的核心價值是故障隔離和獨立擴展。比如中午高峰期訂單服務壓力大但商家服務可能很閑微服務架構下你可以只給訂單服務加副本而不是把整個系統(tǒng)垂直擴容一遍。當然微服務也意味著復雜度轉移——服務怎么發(fā)現(xiàn)、配置怎么管理、請求怎么路由、鏈路怎么追蹤、事務怎么保證這些都是單體應用壓根不用操心的事。標題里寫了SpringCloud就是要把這一整套分布式基礎設施落地到項目里這也是這個項目真正的學習價值。1.3 適合誰讀、讀完能落地什么這篇文章適合三類人用SpringBootVue做過單體項目想上一個微服務分布式項目的人。你會發(fā)現(xiàn)從單體到微服務核心不是代碼量變大而是思維模式變了——你要開始考慮服務邊界、網(wǎng)絡調(diào)用、數(shù)據(jù)一致性。正在做外賣/電商類畢設或實訓項目的人。我會把業(yè)務模塊拆解、表結構設計、接口契約、狀態(tài)機設計都講清楚可以直接參考。準備面試的人。外賣系統(tǒng)是面試中極其常見的業(yè)務場景訂單狀態(tài)的流轉、分布式事務的處理、并發(fā)場景的鎖設計都是高頻考點。這篇文章里的踩坑經(jīng)驗就是最好的面試素材。讀完這篇文章你能收獲的不只是怎么搭一個SpringCloud項目而是完整的外賣業(yè)務建模思路、微服務拆分方法論、分布式場景的實操解法以及一套可以直接復用的員工密碼找回安全方案。2. 整體架構設計與技術選型2.1 模塊拆分六個微服務怎么劃邊界微服務拆分有一個核心原則高內(nèi)聚、低耦合按業(yè)務能力劃分而不是按代碼層劃分。很多人第一次做微服務容易犯的錯是拆得太碎——把菜品管理和店鋪管理都拆成獨立服務結果一個下單接口要調(diào)用五六個服務鏈路長到無法排查。外賣系統(tǒng)的合理拆分方式是上面表格里的六邊形用戶服務、商家服務、訂單服務、配送服務、支付服務、員工/認證服務。每個服務獨立數(shù)據(jù)庫服務之間只通過API通信禁止直接操作對方的表。這里有一個很重要的設計決策為什么用戶服務和員工服務要分開因為C端用戶和后臺員工是兩套完全不同的賬號體系。C端用戶用手機號驗證碼登錄后臺員工用用戶名密碼登錄權限模型也不同。用戶服務面對的是海量C端流量員工服務面對的是少量后臺操作兩者的安全級別和擴展策略完全不一樣。強行合并會讓安全審計變得非常困難。訂單服務和配送服務為什么要拆想象一個場景用戶下單后訂單服務要創(chuàng)建訂單配送服務要生成配送任務。如果兩個服務不拆騎手搶單的并發(fā)流量會直接影響下單接口的穩(wěn)定性。拆開之后訂單服務和配送服務各自獨立伸縮訂單寫庫慢也不會牽連騎手端刷單。數(shù)據(jù)庫層面每個服務獨立庫。為了讓文章有落地感下面給訂單庫的核心表舉個例子-- 訂單主表 CREATE TABLE order_main ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 訂單號, user_id bigint(20) NOT NULL COMMENT 用戶ID, shop_id bigint(20) NOT NULL COMMENT 店鋪ID, total_amount decimal(10,2) NOT NULL COMMENT 訂單總金額, status tinyint(4) NOT NULL COMMENT 訂單狀態(tài) 0待支付 1已支付 2商家接單 3配送中 4已完成 5已取消, address_detail varchar(255) NOT NULL COMMENT 配送地址, create_time datetime NOT NULL, update_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_shop_id (shop_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT訂單主表;訂單狀態(tài)用tinyint而不是直接存字符串這是個常見的取舍——存數(shù)字節(jié)省空間、查詢高效但可讀性差所以代碼里一定要有對應的枚舉類。我在實際項目里見過有人把狀態(tài)直接映射成中文存庫當時看著方便后來統(tǒng)計報表時恨不得全都重來。2.2 技術選型SpringBoot SpringCloud Vue的組合邏輯這套技術棧組合是非常成熟的前后端分離 微服務標準方案選它的邏輯很實在。SpringBoot負責微服務的基礎開發(fā)框架。它解決了Spring配置地獄的問題讓一個獨立的微服務可以快速啟動和部署。每個微服務內(nèi)部還是傳統(tǒng)的三層架構Controller - Service - Mapper開發(fā)成本低Java程序員幾乎沒有學習成本。SpringCloud提供微服務治理全家桶。我用的核心組件包括Spring Cloud Gateway統(tǒng)一入口網(wǎng)關處理鑒權、路由轉發(fā)、限流。選Gateway而不是Zuul是因為Gateway基于WebFlux性能和吞吐量明顯更好而且Spring官方主推。Nacos服務注冊與發(fā)現(xiàn) 配置中心。比Eureka更強Eureka只管注冊發(fā)現(xiàn)配置管理還得再搭Config ServerNacos一站式搞定。OpenFeign服務間聲明式HTTP調(diào)用。寫一個接口加幾個注解就能完成服務間通信比手動寫RestTemplate省太多代碼。Sentinel流量控制和熔斷降級。外賣系統(tǒng)高峰期的流量是突發(fā)的必須有熔斷限流保護核心服務。Sleuth Zipkin鏈路追蹤。微服務排障的必備工具否則一個請求串了五六個服務出了問題你都不知道在哪一環(huán)。Vue負責前端展示層。我用了Vue3 Element Plus Vite的組合Vue3的組合式API寫業(yè)務邏輯更清爽Element Plus做后臺管理界面特別快。前端工程按角色拆成三個獨立應用用戶端H5、商家端Web、騎手端App或H5、運營后臺Web分項目開發(fā)部署。前端和后端的交互全部通過Gateway走HTTP/JSON開發(fā)期配置代理解決跨域上線后由Nginx統(tǒng)一轉發(fā)。這里要點一句很多人初學微服務的誤區(qū)不要為了微服務而微服務。如果你的項目就兩個模塊、預估QPS不超過100單體分布式緩存完全夠用硬上微服務只會給自己增加運維負擔。這套系統(tǒng)上微服務是因為它的業(yè)務邊界清晰、多端并發(fā)場景真實、需要獨立擴展這些條件必須滿足才值得拆。2.3 分布式組件清單與關鍵參數(shù)我花了大量時間在組件的環(huán)境搭建上這里直接給一份可用的選型清單組件版本選型用途關鍵配置說明Nacos2.2.x注冊中心 配置中心啟動時設置standalone模式生產(chǎn)環(huán)境至少3節(jié)點集群Spring Cloud Gateway2021.x統(tǒng)一入口配置路由斷言、過濾器鏈、限流策略OpenFeign內(nèi)置服務間調(diào)用設置連接超時和讀取超時避免默認1秒超時導致調(diào)用失敗Sentinel1.8.x熔斷限流降級核心接口設置QPS閾值和熔斷策略Zipkin2.x鏈路追蹤配合Sleuth收集調(diào)用鏈數(shù)據(jù)Redis6.x緩存 分布式鎖用于菜品緩存、驗證碼存儲、訂單防重復提交RabbitMQ3.x消息隊列訂單超時未支付取消、派單消息通知MySQL8.x業(yè)務數(shù)據(jù)存儲每個微服務獨立庫建議開啟binlog這里要強調(diào)一個經(jīng)驗Nacos和Spring Cloud的版本兼容性是個大坑。網(wǎng)上很多教程直接復制官方文檔結果啟動報各種莫名其妙的錯最后排查半天發(fā)現(xiàn)是版本不匹配。我的建議是直接在Spring Cloud Alibaba的官方版本說明里查對應關系Spring Boot 2.6.x就配合Spring Cloud 2021.0.x和Spring Cloud Alibaba 2021.0.4.0鎖死版本再開發(fā)。3. 核心業(yè)務模塊設計與實現(xiàn)3.1 點餐下單全流程的服務鏈路用戶點餐下單這個流程表面上看就是前端提交一個訂單但微服務架構下的完整鏈路是這樣的用戶點擊下單前端請求Gateway網(wǎng)關解析JWT Token確認用戶身份。Gateway根據(jù)路由規(guī)則把請求轉發(fā)到訂單服務。訂單服務先做冪等校驗基于用戶ID店鋪ID最近30秒內(nèi)的訂單查重防止用戶重復點提交導致重復下單。訂單服務調(diào)用商家服務遠程獲取菜品列表和最新價格校驗菜品是否下架、庫存是否充足。校驗通過后訂單服務創(chuàng)建訂單數(shù)據(jù)狀態(tài)為待支付。訂單服務發(fā)送MQ消息觸發(fā)支付服務生成支付單同時啟動延遲隊列超過15分鐘未支付自動取消訂單。用戶支付成功后支付服務回調(diào)訂單服務訂單狀態(tài)流轉為已支付。這個過程里最有技術含量的兩個點遠程調(diào)用如何保證數(shù)據(jù)一致以及訂單創(chuàng)建如何做冪等。先講冪等。用戶網(wǎng)絡不好時往往會瘋狂點提交訂單如果后端不做冪等控制一次下單就變成三四單。我的實現(xiàn)方案是在訂單服務里加一個Redis Key鍵是order:submit:{userId}:{shopId}值為訂單號過期時間設30秒。提交時先嘗試寫入如果Key已存在則直接返回已存在的訂單號。這樣既實現(xiàn)了冪等又緩存了用戶最近訂單前端可以直接跳轉支付頁。再看遠程調(diào)用的一致性。訂單服務調(diào)商家服務校驗菜品時如果商家服務超時了怎么辦直接報錯讓用戶重新下單還是保存草稿我的選擇是核心校驗不通過就快速失敗讓用戶重試。因為菜品價格和庫存是強約束條件不能為了用戶體驗用舊數(shù)據(jù)下單寧可讓用戶重新選一次也不能產(chǎn)生一筆錯誤訂單。3.2 訂單狀態(tài)機與配送派單邏輯訂單狀態(tài)是外賣系統(tǒng)的骨架我用了狀態(tài)機來管理而不是在Service層隨手改狀態(tài)。先定義一個枚舉public enum OrderStatusEnum { WAIT_PAY(0, 待支付), PAID(1, 已支付), SHOP_ACCEPT(2, 商家已接單), DELIVERING(3, 配送中), COMPLETED(4, 已完成), CANCELLED(5, 已取消); private Integer code; private String desc; // 狀態(tài)流轉合法性校驗 public boolean canTransferTo(OrderStatusEnum target) { switch (this) { case WAIT_PAY: return target PAID || target CANCELLED; case PAID: return target SHOP_ACCEPT || target CANCELLED; case SHOP_ACCEPT: return target DELIVERING || target CANCELLED; case DELIVERING: return target COMPLETED; default: return false; } } }為什么用狀態(tài)機因為訂單狀態(tài)流轉有嚴格的順序如果不做任何限制一個bug就可能讓已完成的訂單退回待支付。狀態(tài)機把合法的流轉路徑集中定義在一個地方所有修改訂單狀態(tài)的操作都必須經(jīng)過狀態(tài)機校驗。測試時只需要針對狀態(tài)機寫單元測試不需要把所有業(yè)務流程跑一遍省太多事了。配送派單的邏輯我單獨說因為這是外賣系統(tǒng)區(qū)別于普通電商的核心場景。騎手搶單是一個典型的并發(fā)場景。同一個配送任務同時推送給多個騎手誰先搶到就歸誰。這個功能如果直接在數(shù)據(jù)庫層面做比如UPDATE delivery_task SET rider_id ? WHERE id ? AND rider_id IS NULL在MySQL默認隔離級別下會出現(xiàn)超賣——兩個騎手同時讀到rider_id為NULL同時更新成功數(shù)據(jù)就被覆蓋了。我當時先用數(shù)據(jù)庫樂觀鎖做了一版結果高并發(fā)測試下丟單率很高。后來換成了Redis分布式鎖 任務狀態(tài)雙重校驗的方案public boolean grabOrder(Long taskId, Long riderId) { String lockKey delivery:grab: taskId; String lockValue UUID.randomUUID().toString(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS); if (!locked) { return false; // 已經(jīng)有騎手在搶 } try { // 二次檢查任務狀態(tài) DeliveryTask task deliveryTaskMapper.selectById(taskId); if (task.getRiderId() ! null || task.getStatus() ! DeliveryStatusEnum.WAIT_GRAB.getCode()) { return false; } int updateRows deliveryTaskMapper.grabTask(taskId, riderId); return updateRows 0; } finally { // Lua腳本釋放鎖防止誤刪別人的鎖 String script if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), Arrays.asList(lockKey), lockValue); } }這個方案里兩個關鍵細節(jié)鎖的過期時間一定不能太短否則任務還沒處理完鎖就自動釋放了另一個騎手就能趁虛而入釋放鎖時不能直接DEL要先判斷值是不是自己設置的避免把別人的鎖刪掉。這兩個點面試時都屬于一問一個準的細節(jié)。3.3 員工模塊與忘記密碼的完整方案標題里特別寫了員工 忘記密碼我猜有不少同學在這個功能上翻過車。員工忘記密碼看起來簡單——不就是發(fā)個驗證碼改個密碼嗎但放到微服務架構和真實業(yè)務里至少要回答這幾個問題第一員工密碼存在哪里員工屬于員工/認證服務密碼不能明文存。我用的是BCrypt加密而不是MD5。BCrypt自帶隨機鹽同樣的密碼每次加密結果都不同而且計算復雜度可調(diào)暴力破解成本遠高于MD5。另外員工密碼不能存在用戶服務的數(shù)據(jù)庫里否則后臺賬號和C端用戶混在一起權限邊界就崩了。第二怎么驗證忘記密碼的人確實是本人我提供兩種方式產(chǎn)品上可以配置手機號驗證碼員工賬號綁定手機號發(fā)送驗證碼校驗身份。驗證碼用Redis存有效期5分鐘每個手機號60秒內(nèi)只能發(fā)一次。防刷策略必須做——否則接口泄露后會被短信轟炸驗證碼成本蹭蹭漲。管理員重置如果員工手機號也換綁了就由管理員在后臺發(fā)起重置生成一個一次性重置鏈接發(fā)給員工。這個鏈接帶Token有效期2小時用一次就失效。第三重置密碼的Token怎么設計這是最容易出安全問題的地方。我的設計如下public class PasswordResetToken { // 業(yè)務IDemployeeId private Long targetId; // 隨機Token至少32字節(jié)用SecureRandom生成 private String token; // 過期時間 private LocalDateTime expireTime; }Token生成不能用UUID簡單糊弄UUID雖然隨機但包含時間戳信息安全性不夠強。要用SecureRandom生成128位隨機數(shù)再轉Base64字符串。Token只存Hash值入庫萬一數(shù)據(jù)庫泄露攻擊者也拿不到有效Token。第四忘記密碼的完整流程長什么樣我把后端接口的時序理一下1. POST /auth/forgot-password 提交員工賬號或手機號 2. 員工服務校驗賬號存在生成6位數(shù)字驗證碼 3. 驗證碼存入Rediskeyforgot:code:{phone}過期5分鐘 4. 調(diào)用短信服務發(fā)送驗證碼 5. POST /auth/verify-code 校驗驗證碼 6. 校驗通過后生成PasswordResetToken返回給前端 7. 前端跳轉重置密碼頁提交新密碼 Token 8. 員工服務校驗Token有效性和過期時間 9. 更新密碼BCrypt加密Token立即作廢 10. 記錄安全日志誰在什么時間從什么IP重置了密碼第五安全審計怎么做這一點很多項目都會漏掉。密碼重置屬于敏感操作必須記錄審計日志。我單獨建了一張employee_security_log表記錄員工ID、操作類型登錄成功、登錄失敗、修改密碼、重置密碼、IP地址、User-Agent、操作時間。這張表的價值在于一旦發(fā)生賬號被盜或內(nèi)部信息泄露可以通過日志還原操作鏈路。這里必須加粗提醒不要把驗證碼放在前端代碼里寫死。我在檢查別人項目時真的見過前端寫死驗證碼123456的騷操作雖然調(diào)試方便但上線后就是巨大的安全漏洞。驗證碼必須由服務端生成、存Redis、校驗也在服務端前端只負責把用戶輸入的驗證碼傳給后端。4. 分布式與微服務落地中的硬核問題4.1 服務間調(diào)用與鑒權統(tǒng)一微服務架構里服務間調(diào)用是常態(tài)——訂單服務要調(diào)商家服務、配送服務要調(diào)用戶服務。但這個調(diào)來調(diào)去會帶來一個麻煩每個服務都要做鑒權嗎我的答案是在網(wǎng)關統(tǒng)一鑒權服務間調(diào)用通過內(nèi)部Token識別。用戶帶著JWT Token請求進來這個Token只在網(wǎng)關被解析驗證一次驗證通過后網(wǎng)關把解析出來的用戶信息userId、角色等放在請求頭里轉發(fā)給下游服務。下游服務信任網(wǎng)關不重復解析JWT只處理業(yè)務。這樣做的收益很直接鑒權邏輯只維護一份服務開發(fā)專注業(yè)務即可不用每寫一個接口都拿一套JWT工具類。但這里有一個坑網(wǎng)關到下游是內(nèi)部調(diào)用如果網(wǎng)關被繞過怎么辦比如運維把某個服務的端口直接暴露了。我的方案是所有微服務只綁定內(nèi)網(wǎng)IP通過防火墻和Nacos注冊地址雙重限制外部無法直接訪問。服務間所有請求必須攜帶內(nèi)部Token內(nèi)部服務統(tǒng)一配置的調(diào)用憑證下游服務用一個全局過濾器校驗這個Token是否存在不存在直接拒絕。實際開發(fā)里還有一個高頻率出現(xiàn)的問題OpenFeign調(diào)用超時。Feign默認連接超時1秒、讀取超時1秒這在本地跑可能夠用線上一次數(shù)據(jù)庫慢查詢就可能超時。我當時被坑得很慘下單鏈路偶爾超時失敗排查半天才發(fā)現(xiàn)是Feign超時太短。全局配置如下feign: client: config: default: connectTimeout: 3000 readTimeout: 5000同時一定要配置Feign的熔斷降級。否則下游服務掛了上游服務會一直等待超時然后把線程池耗盡引發(fā)服務雪崩。我配了Sentinel的Feign降級下游不可用時返回一個友好的錯誤提示而不是讓用戶看到一串超時異常堆棧。4.2 分布式事務下單、扣庫存、支付、派單怎么保持一致單體應用里一次下單的事務直接包在Transactional里就完事了。但微服務架構下訂單服務、庫存服務、支付服務各自有獨立數(shù)據(jù)庫本地事務根本無法跨庫保證一致性。這就是分布式事務問題。我先說結論實際項目里放棄強一致追求最終一致。因為訂單系統(tǒng)的高并發(fā)場景下強一致方案比如2PC的吞吐量太拉胯而且實現(xiàn)復雜度極高可維護性差。我采用的方案是Seata的AT模式配合本地消息表處理非核心鏈路。Seata AT模式的好處是對業(yè)務代碼侵入極小——它就是通過攔截SQL記錄數(shù)據(jù)快照在全局事務提交或回滾時自動補償。你不用像TCC那樣得手寫Confirm和Cancel方法。但是Seata AT模式有一個必須要知道的前提它要求全局事務內(nèi)的所有服務都必須接入同一個Seata Server。如果某個服務是第三方的比如支付服務沒法接入Seata怎么辦這種情況我換成了事務消息方案支付回調(diào)成功的消息發(fā)送到RabbitMQ訂單服務監(jiān)聽消息消費成功就更新訂單狀態(tài)消費失敗就重試。消息中間件充當了不同服務之間的數(shù)據(jù)一致性協(xié)調(diào)者。舉一個下單扣庫存的典型流程1. 訂單服務本地開啟事務創(chuàng)建訂單 寫一條鎖定庫存消息到本地消息表 2. 本地事務提交成功后異步任務把消息發(fā)送到MQ 3. 庫存服務消費MQ消息執(zhí)行扣減庫存操作 4. 扣減成功回執(zhí)消息扣減失敗重試或進死信隊列人工處理 5. 訂單服務查看到庫存扣減成功更新訂單為可支付狀態(tài)這一步里最關鍵的設計是下單和寫消息必須在一個本地事務里。如果先發(fā)消息再寫訂單消息發(fā)出去了訂單還沒建庫存扣了卻沒訂單歸屬數(shù)據(jù)就亂了。本地消息表是整個方案可靠性的兜底。我還踩過一個分布式事務的坑回滾時數(shù)據(jù)不一致。有一次用戶下單后支付超時訂單服務要回滾但庫存服務已經(jīng)扣減成功了。排查發(fā)現(xiàn)是Seata的全局事務超時時間設置太短支付回調(diào)比全局事務超時晚到了一步。最后把全局事務超時從30秒調(diào)到2分鐘并且支付回調(diào)增加了重試機制才徹底解決。4.3 分布式鎖搶單和庫存扣減的并發(fā)控制上一節(jié)提到騎手搶單用了Redis分布式鎖這里展開講一下分布式鎖在庫存扣減場景的通用做法。外賣系統(tǒng)的庫存場景和電商不太一樣電商是商品庫存被很多人搶外賣是菜品當日限量比如招牌菜每天只出30份。點餐高峰期多個用戶同時下單同一道限量菜必須保證不超賣。我用的是Redis分布式鎖 數(shù)據(jù)庫樂觀鎖的雙保險方案public boolean deductStock(Long dishId, Integer quantity) { String lockKey stock:deduct: dishId; String lockValue UUID.randomUUID().toString(); boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 3, TimeUnit.SECONDS); if (!locked) { // 獲取鎖失敗說明有并發(fā)在扣減同一菜品庫存直接失敗讓用戶重試 return false; } try { int rows dishStockMapper.deductStockIfEnough(dishId, quantity); return rows 0; } finally { // Lua腳本釋放鎖 releaseLock(lockKey, lockValue); } }對應的SQL是UPDATE dish_stock SET stock stock - #{quantity} WHERE dish_id #{dishId} AND stock #{quantity}這里有三道防線Redis分布式鎖確保同一時刻只有一個線程執(zhí)行檢查庫存-扣減庫存的操作。SQL條件更新stock #{quantity}保證庫存不足時更新失敗即使鎖被極端情況繞過也不會超賣。數(shù)據(jù)庫行鎖InnoDB的更新操作自動鎖行多個實例并發(fā)更新同一行時由數(shù)據(jù)庫層面保證順序。很多人會問既然SQL已經(jīng)用條件更新保證了不超賣為什么還用分布式鎖答案是性能。如果沒有鎖多個請求同時執(zhí)行UPDATEInnoDB的行鎖會讓后面的請求排隊等待庫存扣減在高并發(fā)下變成串行。加了Redis鎖大部分請求在Redis這一層就被攔下了落到數(shù)據(jù)庫的實際并發(fā)量大幅降低數(shù)據(jù)庫壓力小很多。還有一個高頻問題Redisson的分布式鎖和手動用RedisTemplate寫鎖有什么區(qū)別Redisson的RLock是開箱即用的成熟方案自帶看門狗自動續(xù)期不用自己處理鎖超時和誤刪問題。如果項目里已經(jīng)引了Redisson直接用它就好省去自己造輪子。我上面的例子用手動實現(xiàn)是為了讓讀者理解原理生產(chǎn)環(huán)境我會優(yōu)先選Redisson。5. 常見問題與排查經(jīng)驗實錄5.1 網(wǎng)關超時與全局異常處理微服務項目上線后第一個高頻事故就是網(wǎng)關超時。前端等不到響應、用戶反復刷新、后端日志里全是超時異常這種場景我在項目里處理過好幾次。網(wǎng)關超時根源是Spring Cloud Gateway默認的響應超時時間非常短一個請求經(jīng)過網(wǎng)關到下游服務下游處理超過幾秒網(wǎng)關就主動斷開。而外賣下單鏈路里訂單服務要調(diào)用商家服務和支付服務第一次調(diào)用往往涉及初始化連接耗時就上去了。我的解決方案是分層設置超時網(wǎng)關到下游spring.cloud.gateway.httpclient.response-timeout設置成10秒。Feign服務間調(diào)用connectTimeout 3秒、readTimeout 5秒。數(shù)據(jù)庫超時MySQL連接池中maxLifetime和connectionTimeout合理配置避免線程池堆積。前端Axios超時設置30秒并做超時重試提示。與超時配套的是全局異常處理。微服務每個服務都自己寫try-catch會導致大量重復代碼我統(tǒng)一寫了一個全局異常處理器捕獲業(yè)務異常、參數(shù)校驗異常、兜底異常統(tǒng)一返回約定的JSON格式{ code: 500, message: 系統(tǒng)繁忙請稍后重試, traceId: a3f0c9d2e1b845f6 }traceId特別重要它是鏈路追蹤的入口標識。前端報錯時用戶截圖里只要帶上這個ID我就能用Zipkin定位到具體是哪個服務、哪個環(huán)節(jié)出了問題排查效率提升一個量級。5.2 服務之間數(shù)據(jù)不一致的排查思路微服務架構里最讓人頭疼的就是數(shù)據(jù)不一致——訂單顯示待支付但用戶已經(jīng)付款配送任務顯示配送中但訂單還是商家接單狀態(tài)。這種問題往往沒有明顯的報錯日志只有用戶投訴或對賬時才能發(fā)現(xiàn)。我總結出一套排查思路第一步看鏈路追蹤。用Zipkin找到這筆異常訂單對應的一次完整調(diào)用鏈路看每一步的耗時和狀態(tài)碼。如果哪一步出現(xiàn)異?;虺瑫r問題基本就鎖定在這。第二步看MQ消息消費。很多數(shù)據(jù)不一致是因為消息丟失或重復消費。檢查RabbitMQ的死信隊列看看有沒有消費失敗的消息堆積。我遇到過支付回調(diào)消息因JSON格式問題消費失敗重試3次后進入死信隊列導致訂單一直是待支付狀態(tài)——這種問題不看MQ日志根本發(fā)現(xiàn)不了。第三步看本地消息表。如果用了本地消息表方案檢查這張表的message_status字段看有沒有一直停在待發(fā)送或發(fā)送失敗狀態(tài)的記錄。我踩過一次坑本地消息表的定時任務被運維誤殺了消息一個都沒發(fā)出去所有訂單卡在待支付狀態(tài)排查了一上午才定位到。第四步對賬兜底。最終手段是每天凌晨跑一個對賬任務比對訂單服務和支付服務的交易記錄發(fā)現(xiàn)不一致就自動告警人工介入修復。這個對賬任務不能省它是數(shù)據(jù)一致性的最后一道防線。這里有一個重要的認知微服務架構里不可能消滅數(shù)據(jù)不一致只能縮短不一致的持續(xù)時間。設計目標是把不一致的時間窗口控制在秒級甚至毫秒級而不是追求永遠一致。5.3 Vue前端與微服務后端的聯(lián)調(diào)心得前端和后端聯(lián)調(diào)是項目開發(fā)里最磨人的環(huán)節(jié)微服務架構下這個問題被放大了——前端要對接的不止一個后端服務而是通過網(wǎng)關統(tǒng)一暴露的多個API。我的聯(lián)調(diào)經(jīng)驗可以濃縮成三條第一接口契約先行。開發(fā)前先定義好每個接口的URL、請求參數(shù)、響應結構并維護一份Swagger文檔。我在項目里要求所有服務必須開啟springdoc接口文檔前端根據(jù)文檔開發(fā)后端根據(jù)文檔測試。沒有契約約束前端等接口、后端改接口兩邊互相猜工期無限拉長。第二前端只認網(wǎng)關地址。開發(fā)環(huán)境下前端環(huán)境變量里配置VITE_API_BASE_URL/api通過Vite的代理轉發(fā)到本地網(wǎng)關。這樣前端代碼里不會出現(xiàn)任何后端服務地址上線后只需改代理配置或Nginx轉發(fā)前端代碼一行不用動。Vite開發(fā)代理配置大致是這樣// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, // 網(wǎng)關地址 changeOrigin: true, pathRewrite: { ^/api: } } } } })第三不要在前端處理任何業(yè)務狀態(tài)流轉。我見過有前端代碼里寫了如果訂單狀態(tài)是2就顯示商家已接單這種邏輯后來后端狀態(tài)加了新枚舉前端忘了同步頁面顯示錯亂。正確做法是后端返回狀態(tài)枚舉的code和desc前端只展示desc不做業(yè)務判斷。狀態(tài)機邏輯只屬于后端前端只管展示。聯(lián)調(diào)中還有一個讓我記憶深刻的坑本地跨域問題明明配置了還是報錯。后來發(fā)現(xiàn)是Cookie跨域——JWT放在請求頭里沒問題但為了存用戶狀態(tài)我在Token里塞了用戶信息Cookie的SameSite屬性導致跨域攜帶不了Cookie。最后把登錄態(tài)方案改成了前端存儲Token、請求頭攜帶Token的模式徹底繞開Cookie跨域問題。寫在最后的一些體會這個項目從搭建框架到跑通全流程前后花了大幾周時間?;仡^看我個人最大的感觸是微服務真正的難點不在于框架怎么搭而在于業(yè)務怎么拆、數(shù)據(jù)怎么保持一致、問題怎么快速定位。很多人一上來就照抄官方Demo搭了個注冊中心和網(wǎng)關以為微服務就入門了結果一寫真實業(yè)務就卡殼——訂單要跨服務查數(shù)據(jù)、狀態(tài)要跨服務流轉、并發(fā)要跨服務控制每個問題都比單體時代復雜一個量級。外賣點餐配送系統(tǒng)作為微服務實踐項目確實值得做業(yè)務場景足夠豐富每一個模塊都能挖出有價值的技術點而且技術棧通用性強做完這一套電商、本地生活、即時配送類系統(tǒng)的核心套路基本都能復用。也希望這篇文章里關于員工密碼找回、訂單狀態(tài)機、分布式鎖的細節(jié)能幫你少踩幾個坑。