鏈系統(tǒng)實(shí)戰(zhàn):從數(shù)據(jù)庫(kù)建模到庫(kù)存扣減)
接手這個(gè)供應(yīng)鏈管理系統(tǒng)的時(shí)候我心里其實(shí)有點(diǎn)打鼓。供應(yīng)鏈三個(gè)字聽(tīng)起來(lái)就是一張巨大的業(yè)務(wù)網(wǎng)絡(luò)供應(yīng)商、采購(gòu)、庫(kù)存、物流、訂單、結(jié)算任何一個(gè)環(huán)節(jié)都能單獨(dú)拉出來(lái)做成一套專業(yè)軟件。而我要做的是用SpringBoot把這些環(huán)節(jié)全部貫通起來(lái)交付一個(gè)能真實(shí)跑通的單體系統(tǒng)同時(shí)還要補(bǔ)齊完整的源碼、文檔和部署說(shuō)明任何一個(gè)人拿到這套東西都能在兩天內(nèi)跑起來(lái)。這個(gè)項(xiàng)目的核心價(jià)值不在于把某個(gè)單點(diǎn)功能做得多么花哨而在于把整條數(shù)據(jù)鏈路打通。從供應(yīng)商建檔、采購(gòu)申請(qǐng)、訂單審核、到貨入庫(kù)、庫(kù)存變動(dòng)、銷售出庫(kù)、物流履約一直到財(cái)務(wù)對(duì)賬每一筆數(shù)據(jù)都要有跡可循。我選SpringBoot作為底座就是因?yàn)樗銐騽?wù)實(shí)配置簡(jiǎn)化、生態(tài)成熟、社區(qū)活躍可以讓我把精力集中在業(yè)務(wù)邏輯而不是框架細(xì)節(jié)上。適合誰(shuí)參考如果你正在做系統(tǒng)設(shè)計(jì)或者想了解一個(gè)完整的供應(yīng)鏈平臺(tái)如何從零落地又或者只是想看一套標(biāo)準(zhǔn)的SpringBoot項(xiàng)目該怎么分層、怎么寫(xiě)核心業(yè)務(wù)代碼這套東西對(duì)你都很有參考價(jià)值。1. 系統(tǒng)設(shè)計(jì)思路從業(yè)務(wù)痛點(diǎn)倒推技術(shù)選型1.1 供應(yīng)鏈系統(tǒng)到底在管什么很多人一提到供應(yīng)鏈下意識(shí)想到的是物流車、倉(cāng)庫(kù)和掃碼槍這是最直觀的物理畫(huà)面。但從系統(tǒng)的角度看供應(yīng)鏈管理核心管的是三樣?xùn)|西物資的流向、資金的流向、信息的流向。物資流向就是實(shí)物的生命周期從供應(yīng)商發(fā)貨到倉(cāng)庫(kù)收貨、上架、存儲(chǔ)再到銷售出庫(kù)最后送到客戶手里。資金流向則跟著物資走采購(gòu)要給供應(yīng)商付錢客戶要給公司付錢中間還有退貨、折扣、對(duì)賬這些動(dòng)作。信息流向最抽象但也最重要它要讓每一個(gè)角色在任何時(shí)間點(diǎn)都知道“這批貨在哪、狀態(tài)是什么、接下來(lái)該干什么”。我的系統(tǒng)設(shè)計(jì)以信息流為主線把物資流和資金流作為信息流的兩個(gè)落地結(jié)果。你可以理解為數(shù)據(jù)庫(kù)里存的每一條采購(gòu)單、出入庫(kù)單和結(jié)算單都不是一張靜態(tài)的記錄表而是一條動(dòng)態(tài)狀態(tài)鏈條上的節(jié)點(diǎn)。設(shè)計(jì)的第一原則就是任何一條數(shù)據(jù)都必須有明確的狀態(tài)字段任何一次操作都要驅(qū)動(dòng)狀態(tài)的流轉(zhuǎn)而不是直接修改歷史數(shù)據(jù)。1.2 技術(shù)選型SpringBoot為什么是塊壓艙石技術(shù)選型這件事最怕的不是選錯(cuò)而是選了一堆看似高級(jí)、實(shí)際拖累交付進(jìn)度的東西。最初我其實(shí)糾結(jié)過(guò)兩套方案一套是Spring Boot加MyBatis-Plus加MySQL加Redis另一套是Spring Cloud微服務(wù)全套。后來(lái)果斷放棄第二種理由很簡(jiǎn)單這個(gè)系統(tǒng)雖然業(yè)務(wù)鏈路長(zhǎng)但實(shí)際用戶量和數(shù)據(jù)規(guī)模在單體架構(gòu)下完全能扛住。微服務(wù)的注冊(cè)中心、配置中心、網(wǎng)關(guān)、鏈路追蹤一旦引進(jìn)來(lái)成本會(huì)迅速超過(guò)收益尤其對(duì)于一個(gè)需要快速交付、穩(wěn)定運(yùn)行的場(chǎng)景來(lái)說(shuō)完全沒(méi)必要為了“架構(gòu)先進(jìn)”自找麻煩。SpringBoot成為壓艙石的原因一是自動(dòng)裝配機(jī)制讓項(xiàng)目啟動(dòng)成本極低二是它和Spring MVC、MyBatis、Redis這些老搭檔的整合非常順暢三是有大量生產(chǎn)級(jí)實(shí)踐可以參考。更重要的是SpringBoot的約定優(yōu)于配置能顯著降低新成員的接手成本。后來(lái)代碼寫(xiě)多了你會(huì)發(fā)現(xiàn)真正影響系統(tǒng)質(zhì)量的不是框架本身而是你對(duì)業(yè)務(wù)的理解和對(duì)邊界情況的處理SpringBoot能做的就是把這些噪音降到最低讓你專心寫(xiě)業(yè)務(wù)。1.3 從單體到微服務(wù)邊界劃分的思考我最終堅(jiān)持單體的另一個(gè)原因是供應(yīng)鏈系統(tǒng)里的各個(gè)模塊比如采購(gòu)、庫(kù)存、銷售根本做不到完全獨(dú)立的服務(wù)邊界。一次采購(gòu)入庫(kù)操作既要更新采購(gòu)單狀態(tài)又要生成入庫(kù)單還要增加商品庫(kù)存甚至要觸發(fā)應(yīng)付賬款的記錄。如果把采購(gòu)服務(wù)、庫(kù)存服務(wù)、結(jié)算服務(wù)拆成三個(gè)微服務(wù)就需要分布式事務(wù)來(lái)保證一致性引入消息隊(duì)列做最終一致性復(fù)雜度會(huì)瞬間上升一個(gè)量級(jí)。這是一個(gè)很常見(jiàn)的設(shè)計(jì)誤區(qū)認(rèn)為用了微服務(wù)就代表系統(tǒng)先進(jìn)。實(shí)際上面向業(yè)務(wù)復(fù)雜、數(shù)據(jù)強(qiáng)一致要求高的系統(tǒng)單體加上合理的事務(wù)控制是最穩(wěn)的選擇。這個(gè)項(xiàng)目里我把邊界劃分在業(yè)務(wù)模塊上而不是物理服務(wù)上通過(guò)代碼分包、接口隔離、事務(wù)邊界三個(gè)維度來(lái)控制復(fù)雜度效果遠(yuǎn)好于強(qiáng)行做服務(wù)化拆分。2. 核心業(yè)務(wù)模塊設(shè)計(jì)與數(shù)據(jù)庫(kù)建模2.1 供應(yīng)商與采購(gòu)模塊數(shù)據(jù)的源頭整個(gè)系統(tǒng)有兩類源頭數(shù)據(jù)一類是靜態(tài)的基礎(chǔ)檔案一類是動(dòng)態(tài)的業(yè)務(wù)單據(jù)。供應(yīng)商檔案是靜態(tài)數(shù)據(jù)里最關(guān)鍵的部分它不僅僅是記錄一個(gè)公司名稱和聯(lián)系方式更承擔(dān)著后續(xù)所有采購(gòu)、財(cái)務(wù)、評(píng)價(jià)的基礎(chǔ)。我在這張表上設(shè)計(jì)了供應(yīng)商編碼、等級(jí)、默認(rèn)賬期、結(jié)算方式等字段這些字段會(huì)在后續(xù)的采購(gòu)訂單、應(yīng)付賬單中被頻繁引用。供應(yīng)商編碼我建議用字符型而不是自增數(shù)字比如GYS0001這樣外系統(tǒng)對(duì)接的時(shí)候不會(huì)有歧義同時(shí)編碼規(guī)則里可以隱含分類信息。賬期字段非常重要它決定了采購(gòu)訂單生成應(yīng)付記錄時(shí)的預(yù)計(jì)付款日期直接影響到財(cái)務(wù)模塊的資金計(jì)劃。供應(yīng)商等級(jí)則用來(lái)控制采購(gòu)詢價(jià)時(shí)的優(yōu)先順序這些業(yè)務(wù)規(guī)則如果不在基礎(chǔ)數(shù)據(jù)階段做好設(shè)計(jì)后期全靠業(yè)務(wù)代碼判斷會(huì)非常痛苦。采購(gòu)模塊的核心單據(jù)是采購(gòu)訂單它上面關(guān)聯(lián)供應(yīng)商、采購(gòu)員、期望到貨日期下面掛采購(gòu)明細(xì)。我在這塊特別強(qiáng)調(diào)了一個(gè)設(shè)計(jì)細(xì)節(jié)采購(gòu)訂單和采購(gòu)明細(xì)分表存儲(chǔ)而不是把多條商品塞在一行記錄里。原因很簡(jiǎn)單一次采購(gòu)?fù)鄠€(gè)品類每個(gè)品類的數(shù)量、單價(jià)、稅率可能各不相同如果主從不分后續(xù)的對(duì)賬、收貨、退貨都會(huì)變得難以追蹤。2.2 庫(kù)存、銷售與履約一條鏈路三個(gè)狀態(tài)機(jī)供應(yīng)鏈系統(tǒng)的樂(lè)趣在于業(yè)務(wù)狀態(tài)的變化。采購(gòu)單有狀態(tài)草稿、待審、已審、部分到貨、已完成、已關(guān)閉。入庫(kù)單有狀態(tài)待收、已收、已上架。銷售訂單同樣有狀態(tài)待支付、已支付、待發(fā)貨、部分發(fā)貨、已發(fā)貨、已完成、已取消。這些狀態(tài)不是隨意定義的每一條都對(duì)應(yīng)一個(gè)業(yè)務(wù)階段每一條都規(guī)定了當(dāng)前階段能夠執(zhí)行哪些操作。舉一個(gè)典型的跨模塊流程銷售部門創(chuàng)建銷售訂單系統(tǒng)檢查庫(kù)存可用量如果不足自動(dòng)生成采購(gòu)建議單。采購(gòu)部門拿到建議單后手動(dòng)創(chuàng)建采購(gòu)訂單供應(yīng)商發(fā)貨后倉(cāng)庫(kù)掃碼收貨生成入庫(kù)單庫(kù)存量增加。這時(shí)候銷售訂單再次被激活安排發(fā)貨生成出庫(kù)單和物流記錄。整個(gè)過(guò)程里庫(kù)存變化始終是核心錨點(diǎn)每一次入庫(kù)、出庫(kù)操作都會(huì)觸發(fā)庫(kù)存表的更新同時(shí)寫(xiě)入庫(kù)存流水表確保每一件商品的每一次變動(dòng)都有據(jù)可查。用狀態(tài)機(jī)思路來(lái)設(shè)計(jì)業(yè)務(wù)流最大的好處是代碼邏輯清晰每個(gè)狀態(tài)節(jié)點(diǎn)就是一組可執(zhí)行動(dòng)作的集合不會(huì)出現(xiàn)業(yè)務(wù)操作出現(xiàn)二義性。比如一個(gè)已關(guān)閉的采購(gòu)單就不可能再執(zhí)行到貨操作這是狀態(tài)機(jī)天然給的約束而不是靠程序員寫(xiě)if判斷硬撐。2.3 訂單主表和明細(xì)表一個(gè)容易忽視卻致命的建模很多初學(xué)者在設(shè)計(jì)銷售訂單表的時(shí)候會(huì)圖省事把商品名稱、數(shù)量、單價(jià)直接塞進(jìn)訂單表里這是后續(xù)所有麻煩的根源。正確的做法一定是訂單主表和訂單明細(xì)表分離。訂單主表存訂單編號(hào)、客戶信息、訂單總金額、支付狀態(tài)訂單明細(xì)表存具體商品、數(shù)量、成交單價(jià)、優(yōu)惠金額。兩張表通過(guò)訂單編號(hào)關(guān)聯(lián)。為什么這么做最直接的原因是業(yè)務(wù)數(shù)據(jù)的不可變性。訂單一旦確認(rèn)客戶買的商品名、單價(jià)、數(shù)量就成了交易憑證哪怕商品以后改了名、調(diào)了價(jià)歷史訂單也必須保留交易當(dāng)時(shí)的信息。如果把訂單內(nèi)容做成關(guān)聯(lián)實(shí)時(shí)商品表商品一改歷史訂單跟著變財(cái)務(wù)對(duì)賬就會(huì)對(duì)不上。我在這套系統(tǒng)里把商品名稱、規(guī)格、成交單價(jià)都冗余存儲(chǔ)在訂單明細(xì)表里而不是查詢時(shí)臨時(shí)關(guān)聯(lián)商品表。數(shù)據(jù)冗余看著不夠高級(jí)但在交易類系統(tǒng)里反而是最穩(wěn)妥的做法。2.4 數(shù)據(jù)庫(kù)建模的關(guān)鍵字段解析列表這張表實(shí)際來(lái)源于我做這套系統(tǒng)時(shí)最常被問(wèn)到的問(wèn)題供應(yīng)商表和客戶表能不能合并庫(kù)存表要不要冗余商品的分類名稱我的回答是基礎(chǔ)實(shí)體表盡量純凈業(yè)務(wù)單據(jù)表按需冗余。允許適度冗余但冗余字段必須有明確的業(yè)務(wù)消費(fèi)方否則就是臟數(shù)據(jù)。我舉幾個(gè)實(shí)際建表時(shí)的關(guān)鍵點(diǎn)。第一所有業(yè)務(wù)表都必須包含create_time、update_time、deleted字段MyBatis-Plus的邏輯刪除功能依賴它這也是后續(xù)恢復(fù)誤刪數(shù)據(jù)的一條退路。第二所有金額字段用decimal(12,2)而不是double學(xué)過(guò)浮點(diǎn)數(shù)存儲(chǔ)的人都明白為什么。第三單據(jù)編號(hào)字段設(shè)置唯一索引同時(shí)預(yù)留商戶維度這樣未來(lái)做多租戶改造時(shí)不會(huì)因?yàn)榫幪?hào)沖突推倒重來(lái)。第四每個(gè)表盡量有一個(gè)業(yè)務(wù)主鍵如訂單編號(hào)、入庫(kù)單號(hào)和數(shù)據(jù)庫(kù)自增主鍵分離這樣對(duì)接外部系統(tǒng)、做數(shù)據(jù)遷移、打印單據(jù)時(shí)都非常方便。字段設(shè)計(jì)要點(diǎn)說(shuō)明常見(jiàn)誤區(qū)金額字段decimal(12,2)避免浮點(diǎn)誤差用double或float存儲(chǔ)金額業(yè)務(wù)主鍵單據(jù)編號(hào)字符串唯一與自增主鍵分離直接用自增id對(duì)客展示冗余字段單據(jù)表冗余商品名、單價(jià)等快照信息全部關(guān)聯(lián)實(shí)時(shí)查詢時(shí)間字段統(tǒng)一datetime加更新時(shí)間自動(dòng)填充不同庫(kù)混用varchar存時(shí)間邏輯刪除統(tǒng)一deleted標(biāo)記保留數(shù)據(jù)痕跡物理刪除無(wú)法追溯3. 后端工程結(jié)構(gòu)與核心代碼實(shí)踐3.1 工程目錄與分層約定優(yōu)于配置SpringBoot項(xiàng)目最忌諱的就是把代碼寫(xiě)成一鍋粥。我建立一個(gè)清晰的包結(jié)構(gòu)按業(yè)務(wù)模塊劃分而不是按技術(shù)層劃分。也就是說(shuō)不是先建controller包、service包、mapper包這樣平鋪而是先有采購(gòu)、庫(kù)存、銷售、供應(yīng)商這些業(yè)務(wù)包的頂層每個(gè)業(yè)務(wù)包下面再放controller、service、mapper、entity等子包。這樣做的好處是當(dāng)后端代碼量達(dá)到幾十個(gè)文件時(shí)你能快速定位一個(gè)功能所在的位置而不是在茫茫controller大海里搜索。另外模塊化包結(jié)構(gòu)天然給了代碼邊界采購(gòu)包的service不會(huì)直接調(diào)用銷售包mapper如果確實(shí)需要跨模塊數(shù)據(jù)就通過(guò)各自的service接口交互。這種約束能杜絕大量為了省事而直接穿透dao層的行為。com.supplychain ├── common # 通用工具、常量、異常處理 ├── config # 配置類redis、mybatis、安全攔截 ├── security # 登錄認(rèn)證、權(quán)限校驗(yàn) ├── modules │ ├── supplier # 供應(yīng)商模塊 │ ├── purchase # 采購(gòu)模塊 │ ├── inventory # 庫(kù)存模塊 │ ├── sales # 銷售模塊 │ ├── order # 訂單模塊 │ ├── logistics # 物流履約模塊 │ └── finance # 財(cái)務(wù)結(jié)算模塊 └── framework # 基礎(chǔ)框架、切面日志3.2 從采購(gòu)審核到入庫(kù)Service層的核心代碼這套系統(tǒng)里最有代表性的一個(gè)場(chǎng)景是從采購(gòu)審核通過(guò)到入庫(kù)完成。我直接放一段核心代碼來(lái)說(shuō)明設(shè)計(jì)思路。采購(gòu)入庫(kù)這個(gè)方法涉及三步更新采購(gòu)單狀態(tài)、生成入庫(kù)單、增加庫(kù)存并寫(xiě)庫(kù)存流水。這三個(gè)操作必須在一個(gè)事務(wù)里否則一旦中途失敗庫(kù)存會(huì)增加但采購(gòu)單還沒(méi)更新數(shù)據(jù)就錯(cuò)亂了。Transactional(rollbackFor Exception.class) public void completePurchaseInbound(Long purchaseOrderId, ListInboundItem inboundItems) { // 1. 校驗(yàn)采購(gòu)單當(dāng)前狀態(tài)必須是待入庫(kù) PurchaseOrder order purchaseOrderMapper.selectById(purchaseOrderId); if (order null || !OrderStatus.PENDING_INBOUND.equals(order.getStatus())) { throw new BusinessException(采購(gòu)單狀態(tài)異常無(wú)法入庫(kù)); } // 2. 生成入庫(kù)單主表 InboundOrder inboundOrder new InboundOrder(); inboundOrder.setOrderNo(generateOrderNo(IN)); inboundOrder.setPurchaseOrderId(order.getId()); inboundOrder.setSupplierId(order.getSupplierId()); inboundOrder.setStatus(InboundStatus.CONFIRMED); inboundOrderMapper.insert(inboundOrder); // 3. 逐條校驗(yàn)并更新庫(kù)存 for (InboundItem item : inboundItems) { InventoryStock stock inventoryStockMapper.selectBySkuIdForUpdate(item.getSkuId()); if (stock null) { throw new BusinessException(商品SKU不存在: item.getSkuId()); } stock.setAvailableQty(stock.getAvailableQty() item.getInboundQty()); stock.setTotalQty(stock.getTotalQty() item.getInboundQty()); inventoryStockMapper.updateById(stock); // 寫(xiě)庫(kù)存流水 InventoryFlow flow new InventoryFlow(); flow.setSkuId(item.getSkuId()); flow.setChangeType(INBOUND); flow.setChangeQty(item.getInboundQty()); flow.setRefOrderNo(inboundOrder.getOrderNo()); inventoryFlowMapper.insert(flow); } // 4. 更新采購(gòu)單為已完成 order.setStatus(OrderStatus.COMPLETED); purchaseOrderMapper.updateById(order); }這里有幾個(gè)點(diǎn)需要重點(diǎn)說(shuō)明。第一selectBySkuIdForUpdate里的for update是行級(jí)鎖為了處理并發(fā)情況下多個(gè)倉(cāng)庫(kù)管理員同時(shí)入庫(kù)同一商品導(dǎo)致的數(shù)量不準(zhǔn)。第二先插入入庫(kù)單還是先更新庫(kù)存我選擇先插入入庫(kù)單這樣一旦后續(xù)失敗至少能通過(guò)入庫(kù)單追蹤操作痕跡。第三整個(gè)方法必須用Transactional否則任何一個(gè)異常退出數(shù)據(jù)庫(kù)的數(shù)據(jù)都會(huì)殘缺。3.3 并發(fā)場(chǎng)景下的庫(kù)存扣減Redis 數(shù)據(jù)庫(kù)鎖雙保險(xiǎn)庫(kù)存扣減是供應(yīng)鏈系統(tǒng)最容易出事的地方。用戶下單后扣庫(kù)存如果并發(fā)量上來(lái)直接用數(shù)據(jù)庫(kù)更新很容易造成超賣。我在系統(tǒng)里做了雙層防線。第一層是用Redis做庫(kù)存預(yù)熱和秒殺級(jí)別的扣減。庫(kù)存數(shù)據(jù)提前加載到Redis用戶下單時(shí)先扣Redis庫(kù)存扣成功后再異步落庫(kù)。但考慮到這套系統(tǒng)更多面對(duì)的是一般采購(gòu)和銷售場(chǎng)景峰值并發(fā)沒(méi)有那么夸張所以更關(guān)鍵的第二層是請(qǐng)數(shù)據(jù)庫(kù)的行鎖來(lái)兜底。核心邏輯很簡(jiǎn)單Transactional public boolean deductStock(Long skuId, Integer qty) { // 這里利用數(shù)據(jù)庫(kù)的行級(jí)鎖避免并發(fā)讀到相同的庫(kù)存值 InventoryStock stock inventoryStockMapper.selectBySkuIdForUpdate(skuId); if (stock.getAvailableQty() qty) { throw new BusinessException(庫(kù)存不足); } stock.setAvailableQty(stock.getAvailableQty() - qty); inventoryStockMapper.updateById(stock); // 記錄流水略 return true; }這個(gè)方案的邏輯是多個(gè)請(qǐng)求同時(shí)進(jìn)來(lái)時(shí)第一個(gè)請(qǐng)求拿到行鎖扣減庫(kù)存后提交事務(wù)第二個(gè)請(qǐng)求才拿到鎖此時(shí)讀到的是扣減后的值。如果庫(kù)存只剩10件兩個(gè)請(qǐng)求各要8件第二個(gè)請(qǐng)求就會(huì)讀到庫(kù)存不足從而拒絕下單。這套機(jī)制在多數(shù)內(nèi)部管理系統(tǒng)里已經(jīng)足夠。如果未來(lái)訪問(wèn)量漲到需要秒殺級(jí)別再引入Redis預(yù)扣減和異步對(duì)賬。但一開(kāi)始就上這套復(fù)雜度反而容易引入緩存和數(shù)據(jù)庫(kù)一致性的新問(wèn)題。項(xiàng)目里有完善的注釋和文檔接手的人可以在理解現(xiàn)狀之后再?zèng)Q定要不要升級(jí)。4. 常見(jiàn)問(wèn)題與部署運(yùn)維實(shí)錄4.1 高頻問(wèn)題速查表以下是整個(gè)開(kāi)發(fā)過(guò)程中我以及團(tuán)隊(duì)成員最容易踩的坑我按問(wèn)題現(xiàn)象、根本原因、解決方案三個(gè)維度整理了一份速查表。這個(gè)表基本涵蓋了一個(gè)SpringBoot供應(yīng)鏈系統(tǒng)從開(kāi)發(fā)到上線的高頻問(wèn)題。問(wèn)題現(xiàn)象根本原因解決方案列表頁(yè)數(shù)據(jù)加載非常慢N1查詢導(dǎo)致大量SQL使用MyBatis-Plus批量查詢關(guān)聯(lián)數(shù)據(jù)循環(huán)外提庫(kù)存扣成負(fù)數(shù)沒(méi)有使用行級(jí)鎖或樂(lè)觀鎖select...for update或版本號(hào)控制單據(jù)編號(hào)重復(fù)多線程下用時(shí)間戳生成編號(hào)用Redis incr生成自增序列修改供應(yīng)商后歷史訂單單價(jià)變了商品價(jià)格字段直接關(guān)聯(lián)實(shí)時(shí)數(shù)據(jù)訂單明細(xì)冗余商品名、單價(jià)快照部署后訪問(wèn)空白或404前端路由history模式未配置后端配置接口轉(zhuǎn)發(fā)靜態(tài)資源一次業(yè)務(wù)操作部分成功部分失敗缺少事務(wù)控制Service層加Transactional事務(wù)注解不同業(yè)務(wù)包之間的數(shù)據(jù)依賴混亂跨模塊直接操作mapper統(tǒng)一走各自service接口4.2 庫(kù)存對(duì)不上賬一次印象深刻的排查實(shí)錄項(xiàng)目測(cè)試階段我遇到過(guò)一個(gè)非常典型的問(wèn)題系統(tǒng)庫(kù)存數(shù)據(jù)準(zhǔn)時(shí)對(duì)但實(shí)物庫(kù)存總是對(duì)不上。后來(lái)發(fā)現(xiàn)是部分入庫(kù)操作在異常場(chǎng)景下沒(méi)有寫(xiě)庫(kù)存流水。原因在于有些入庫(kù)的歷史方法直接更新庫(kù)存表沒(méi)有插入流水表導(dǎo)致對(duì)賬時(shí)無(wú)法追蹤是誰(shuí)動(dòng)了庫(kù)存。排查完這個(gè)坑我給系統(tǒng)的所有庫(kù)存變更操作定了一個(gè)鐵規(guī)任何一條變更都必須同時(shí)記錄流水哪怕是一次庫(kù)存盤(pán)點(diǎn)調(diào)整。庫(kù)存流水表本質(zhì)上是一本只追加、不修改的賬本每次庫(kù)存變化上游單據(jù)號(hào)、變更類型、變更前后數(shù)量、操作人都要記錄完整。后續(xù)做審計(jì)、對(duì)賬、異常排查全依賴這本賬。4.3 并發(fā)扣減導(dǎo)致超賣為什么不能用先查后更這個(gè)坑是并發(fā)場(chǎng)景下的經(jīng)典問(wèn)題。在做銷售訂單創(chuàng)建的時(shí)候最初我用的是先查庫(kù)存是否足夠再用update語(yǔ)句扣減。單線程測(cè)試時(shí)一切正常壓測(cè)時(shí)發(fā)現(xiàn)庫(kù)存從100瞬間變成了負(fù)數(shù)。原因是兩個(gè)請(qǐng)求同時(shí)讀到庫(kù)存100都認(rèn)為足夠然后依次扣減最終庫(kù)存變成98但實(shí)際可能已經(jīng)賣出去了102件。解決方式就是我上文代碼里的selectBySkuIdForUpdate讓并發(fā)請(qǐng)求排隊(duì)讀取。這里要強(qiáng)調(diào)的是不僅僅要用行鎖還要把讀和扣減放在同一個(gè)事務(wù)里否則鎖釋放在事務(wù)提交之前依然會(huì)有問(wèn)題。4.4 部署階段的兩個(gè)小坑部署這套系統(tǒng)時(shí)最容易踩的坑并不在代碼里而在環(huán)境上。第一個(gè)是JDK版本問(wèn)題項(xiàng)目基于JDK8還是JDK11開(kāi)發(fā)部署環(huán)境的版本必須匹配否則會(huì)出現(xiàn)莫名其妙的依賴報(bào)錯(cuò)。第二個(gè)是MySQL連接配置里的時(shí)區(qū)問(wèn)題serverTimezoneAsia/Shanghai如果沒(méi)有設(shè)置數(shù)據(jù)庫(kù)的時(shí)間會(huì)和本地時(shí)間相差8小時(shí)所有的時(shí)間記錄看上去就像穿越了一樣。我傾向于把部署文檔寫(xiě)得盡量細(xì)因?yàn)橄到y(tǒng)本身的價(jià)值最終要靠能否順利跑起來(lái)來(lái)體現(xiàn)。從環(huán)境準(zhǔn)備、數(shù)據(jù)庫(kù)初始化、配置修改到打包、啟動(dòng)、訪問(wèn)每一步都附上驗(yàn)證方式。如果啟動(dòng)失敗日志中看什么關(guān)鍵詞這個(gè)排查指引非常關(guān)鍵。比如端口被占用、Redis連接失敗、MySQL字符集不匹配這些都是常見(jiàn)的初裝問(wèn)題文檔里直接寫(xiě)明白排查路徑比臨時(shí)翻博客高效得多。最后分享一個(gè)我個(gè)人的實(shí)操經(jīng)驗(yàn)這個(gè)項(xiàng)目做下來(lái)我最深刻的體會(huì)是一套系統(tǒng)最容易出問(wèn)題的往往不是技術(shù)難點(diǎn)而是業(yè)務(wù)狀態(tài)和數(shù)據(jù)一致性的管理。代碼本身是誠(chéng)實(shí)的你花在設(shè)計(jì)上的心思最后都會(huì)用穩(wěn)定性和可維護(hù)性回報(bào)回來(lái)。我強(qiáng)烈建議每一個(gè)做供應(yīng)鏈系統(tǒng)的人先在草稿紙上畫(huà)出完整的業(yè)務(wù)狀態(tài)流轉(zhuǎn)圖再開(kāi)始建表寫(xiě)代碼。狀態(tài)流轉(zhuǎn)圖是所有功能的骨骼而SpringBoot、MyBatis這些技術(shù)只是填充其間的血肉。最后再分享一個(gè)小技巧運(yùn)維階段給所有核心業(yè)務(wù)表都加一個(gè)操作日志表用切面記錄增刪改前后的數(shù)據(jù)快照。供應(yīng)鏈系統(tǒng)的每一次變更都需要有機(jī)會(huì)回溯、追責(zé)、審計(jì)這一點(diǎn)在正式環(huán)境里價(jià)值極高。我的這套系統(tǒng)已經(jīng)在多個(gè)模擬業(yè)務(wù)場(chǎng)景里跑得很穩(wěn)如果你在設(shè)計(jì)類似項(xiàng)目時(shí)遇到什么困擾按照我上面的思路重構(gòu)一遍你會(huì)少踩很多坑。