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