庫(kù)管理系統(tǒng)實(shí)戰(zhàn):庫(kù)存扣減與流水追溯設(shè)計(jì))
簡(jiǎn)介本資源為基于 Java 與 MySQL 實(shí)現(xiàn)的倉(cāng)庫(kù)管理系統(tǒng)完整源碼工程面向希望以真實(shí)項(xiàng)目練手的初學(xué)者與進(jìn)階開(kāi)發(fā)者可直接用作畢業(yè)設(shè)計(jì)、課程設(shè)計(jì)、大作業(yè)或工程實(shí)訓(xùn)選題。項(xiàng)目采用 SpringBoot、Shiro、MybatisPlus 搭建后臺(tái)前端使用 LayUI 與 DTree開(kāi)發(fā)環(huán)境為 IDEA、Navicat、Maven 3.5.2、Tomcat 8.5 與 MySQL系統(tǒng)劃分為系統(tǒng)模塊和業(yè)務(wù)模塊業(yè)務(wù)側(cè)包含客戶(hù)管理與供應(yīng)商管理支持列表分頁(yè)、模糊查詢(xún)及增刪改與批量刪除等操作。壓縮包共 367 個(gè)文件約 5.34MB以 106 個(gè) Java 源碼、49 個(gè) HTML 頁(yè)面、42 個(gè) JS 腳本、75 個(gè) GIF 與 23 個(gè) PNG 圖片資源為主另含 XML、JSON、CSS 及 SQL 建表腳本結(jié)構(gòu)完整便于二次開(kāi)發(fā)。目前已有 422 人學(xué)習(xí)適合對(duì)照源碼理解分層設(shè)計(jì)與權(quán)限控制思路。1. 倉(cāng)庫(kù)管理系統(tǒng)到底在管什么從一張入庫(kù)單說(shuō)起很多團(tuán)隊(duì)做倉(cāng)庫(kù)管理系統(tǒng)第一反應(yīng)是打開(kāi) IDE 建表寫(xiě) CRUD結(jié)果上線(xiàn)三個(gè)月后庫(kù)存對(duì)不上、盤(pán)點(diǎn)靠 Excel 補(bǔ)、采購(gòu)和倉(cāng)儲(chǔ)互相甩鍋。問(wèn)題不在代碼寫(xiě)得爛而在于一開(kāi)始沒(méi)想清楚「?jìng)}庫(kù)管理」到底在管什么。它管的不是商品列表而是每一次庫(kù)存變動(dòng)的來(lái)龍去脈誰(shuí)在什么時(shí)間、因?yàn)槟膹垎螕?jù)、把哪個(gè)批次的多少件貨、從哪個(gè)庫(kù)位挪到了哪個(gè)庫(kù)位。Java MySQL 這套組合之所以成為倉(cāng)庫(kù)管理系統(tǒng)的常見(jiàn)落地方式是因?yàn)?Java 的強(qiáng)類(lèi)型和成熟生態(tài)能把業(yè)務(wù)規(guī)則寫(xiě)死MySQL 的事務(wù)能力能保證庫(kù)存扣減不出負(fù)數(shù)。這套方案適合中小型倉(cāng)儲(chǔ)、電商后臺(tái)、制造業(yè)原料庫(kù)這類(lèi)場(chǎng)景日單量在幾千到幾萬(wàn)之間團(tuán)隊(duì)規(guī)模三到八人。如果你正被庫(kù)存不準(zhǔn)、單據(jù)追溯難、并發(fā)扣減超賣(mài)這些問(wèn)題困擾下面這套從建表到扣減的實(shí)現(xiàn)路徑可以直接參考。2. 表結(jié)構(gòu)怎么設(shè)計(jì)庫(kù)存、單據(jù)、流水三張核心表的關(guān)系2.1 為什么不能只建一張商品表加個(gè)庫(kù)存字段新手最容易踩的坑就是建一張product表里面放一個(gè)stock字段入庫(kù)加、出庫(kù)減。這種設(shè)計(jì)在單倉(cāng)庫(kù)、單批次、無(wú)并發(fā)時(shí)能跑一旦出現(xiàn)以下任一情況就會(huì)翻車(chē)同一個(gè) SKU 分布在多個(gè)庫(kù)位、同一批貨有不同生產(chǎn)日期、需要追溯某次盤(pán)虧是哪張單據(jù)造成的。庫(kù)存的本質(zhì)是「流水累加的結(jié)果」而不是一個(gè)可以隨意覆蓋的數(shù)字。所以核心表至少拆成三層商品基礎(chǔ)信息、庫(kù)存快照、庫(kù)存流水。商品表管「是什么」庫(kù)存表管「現(xiàn)在有多少」流水表管「怎么變成這么多的」。常見(jiàn)做法是再加一張單據(jù)主表和單據(jù)明細(xì)表形成「單據(jù) → 明細(xì) → 流水 → 庫(kù)存」的鏈路。這樣任何一次庫(kù)存變動(dòng)都能反查到源頭單據(jù)盤(pán)點(diǎn)差異也能定位到具體操作。2.2 建表 SQL 與字段說(shuō)明下面這套表結(jié)構(gòu)是我在多個(gè)中小型倉(cāng)庫(kù)項(xiàng)目里反復(fù)用過(guò)的精簡(jiǎn)版去掉了花哨的擴(kuò)展字段保留最核心的追溯能力。-- 商品表管是什么 CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT, sku_code VARCHAR(64) NOT NULL COMMENT SKU編碼業(yè)務(wù)唯一, product_name VARCHAR(128) NOT NULL, unit VARCHAR(16) DEFAULT 件, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_sku (sku_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品基礎(chǔ)表; -- 庫(kù)存表管現(xiàn)在有多少按商品庫(kù)位批次維度 CREATE TABLE inventory ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_code VARCHAR(32) NOT NULL COMMENT 倉(cāng)庫(kù)編碼, location_code VARCHAR(32) NOT NULL COMMENT 庫(kù)位編碼, batch_no VARCHAR(64) DEFAULT COMMENT 批次號(hào)無(wú)批次填空串, quantity INT NOT NULL DEFAULT 0 COMMENT 當(dāng)前庫(kù)存數(shù)量, version INT NOT NULL DEFAULT 0 COMMENT 樂(lè)觀鎖版本號(hào), updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_inv (product_id,warehouse_code,location_code,batch_no), KEY idx_product (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT庫(kù)存快照表; -- 庫(kù)存流水表管怎么變成這么多的只增不改 CREATE TABLE inventory_flow ( id BIGINT NOT NULL AUTO_INCREMENT, product_id BIGINT NOT NULL, warehouse_code VARCHAR(32) NOT NULL, location_code VARCHAR(32) NOT NULL, batch_no VARCHAR(64) DEFAULT , change_qty INT NOT NULL COMMENT 變動(dòng)數(shù)量入正出負(fù), biz_type VARCHAR(32) NOT NULL COMMENT 業(yè)務(wù)類(lèi)型INBOUND/OUTBOUND/ADJUST, biz_no VARCHAR(64) NOT NULL COMMENT 來(lái)源單據(jù)號(hào), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_biz (biz_no), KEY idx_product_time (product_id,created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT庫(kù)存流水表;inventory表上的唯一鍵uk_inv是關(guān)鍵它保證了同一商品在同一庫(kù)位同一批次只有一行記錄避免并發(fā)插入產(chǎn)生重復(fù)庫(kù)存行。version字段用于樂(lè)觀鎖后面扣減時(shí)會(huì)用到。inventory_flow表只增不改每次變動(dòng)插一條change_qty用正負(fù)號(hào)區(qū)分出入庫(kù)這樣對(duì)賬時(shí)直接SUM(change_qty)就能和inventory.quantity比對(duì)發(fā)現(xiàn)不一致立刻能定位。注意batch_no默認(rèn)值用空串而不是 NULL是因?yàn)?MySQL 唯一索引中多個(gè) NULL 不沖突會(huì)導(dǎo)致同一商品同一庫(kù)位出現(xiàn)多行「無(wú)批次」庫(kù)存這是血淚教訓(xùn)。2.3 單據(jù)表的最小字段集單據(jù)表不需要一開(kāi)始就設(shè)計(jì)得很復(fù)雜但biz_no、status、created_by這三個(gè)字段必須有。biz_no是業(yè)務(wù)單號(hào)和流水表關(guān)聯(lián)status控制單據(jù)狀態(tài)流轉(zhuǎn)防止已完成的單據(jù)被重復(fù)提交created_by用于責(zé)任追溯。明細(xì)表則記錄每個(gè)商品的應(yīng)入/應(yīng)出數(shù)量實(shí)際執(zhí)行時(shí)再和流水表比對(duì)形成「計(jì)劃 vs 實(shí)際」的閉環(huán)。3. 入庫(kù)出庫(kù)怎么落地Java 服務(wù)層的事務(wù)與扣減邏輯3.1 入庫(kù)先寫(xiě)流水還是先改庫(kù)存入庫(kù)邏輯相對(duì)簡(jiǎn)單但順序有講究。正確順序是校驗(yàn)單據(jù)狀態(tài) → 寫(xiě)流水 → 更新庫(kù)存 → 更新單據(jù)狀態(tài)全部放在一個(gè)Transactional方法里。先寫(xiě)流水的好處是即使后續(xù)更新庫(kù)存失敗回滾流水也會(huì)一起回滾不會(huì)出現(xiàn)「有流水沒(méi)庫(kù)存」的臟數(shù)據(jù)。如果反過(guò)來(lái)先改庫(kù)存再寫(xiě)流水一旦寫(xiě)流水失敗庫(kù)存已經(jīng)變了回滾雖然能救回來(lái)但邏輯上不夠清晰。Service public class InboundService { Autowired private InventoryMapper inventoryMapper; Autowired private InventoryFlowMapper flowMapper; Autowired private InboundOrderMapper orderMapper; Transactional(rollbackFor Exception.class) public void inbound(Long orderId) { // 1. 校驗(yàn)單據(jù)狀態(tài)防止重復(fù)入庫(kù) InboundOrder order orderMapper.selectByIdForUpdate(orderId); if (order null || !CREATED.equals(order.getStatus())) { throw new BizException(單據(jù)狀態(tài)不允許入庫(kù)); } // 2. 逐條明細(xì)處理 for (InboundDetail detail : order.getDetails()) { // 2.1 寫(xiě)流水change_qty 為正 InventoryFlow flow new InventoryFlow(); flow.setProductId(detail.getProductId()); flow.setWarehouseCode(order.getWarehouseCode()); flow.setLocationCode(detail.getLocationCode()); flow.setBatchNo(detail.getBatchNo()); flow.setChangeQty(detail.getQty()); flow.setBizType(INBOUND); flow.setBizNo(order.getBizNo()); flowMapper.insert(flow); // 2.2 更新庫(kù)存存在則累加不存在則插入 int updated inventoryMapper.increaseStock( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); if (updated 0) { inventoryMapper.insertInventory( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); } } // 3. 更新單據(jù)狀態(tài) orderMapper.updateStatus(orderId, FINISHED); } }selectByIdForUpdate用了行鎖防止同一單據(jù)被并發(fā)處理。increaseStock是一條UPDATE inventory SET quantity quantity #{qty}, version version 1 WHERE ...的語(yǔ)句利用數(shù)據(jù)庫(kù)原子性保證累加正確。如果返回 0 說(shuō)明該庫(kù)位該批次還沒(méi)有庫(kù)存行此時(shí)插入新行。這里有個(gè)細(xì)節(jié)插入時(shí)如果并發(fā)沖突唯一鍵會(huì)報(bào)錯(cuò)外層事務(wù)回滾調(diào)用方重試即可。3.2 出庫(kù)樂(lè)觀鎖扣減與超賣(mài)防護(hù)出庫(kù)比入庫(kù)復(fù)雜因?yàn)橐乐箍鄢韶?fù)數(shù)。常見(jiàn)做法有兩種悲觀鎖SELECT ... FOR UPDATE和樂(lè)觀鎖UPDATE ... WHERE quantity #{qty}。中小型系統(tǒng)我更傾向樂(lè)觀鎖因?yàn)殒i粒度小、吞吐高配合重試機(jī)制足夠用。Transactional(rollbackFor Exception.class) public void outbound(Long orderId) { OutboundOrder order orderMapper.selectById(orderId); if (order null || !CREATED.equals(order.getStatus())) { throw new BizException(單據(jù)狀態(tài)不允許出庫(kù)); } for (OutboundDetail detail : order.getDetails()) { // 樂(lè)觀鎖扣減quantity qty 才更新 int updated inventoryMapper.decreaseStock( detail.getProductId(), order.getWarehouseCode(), detail.getLocationCode(), detail.getBatchNo(), detail.getQty()); if (updated 0) { throw new BizException(庫(kù)存不足或并發(fā)沖突商品 detail.getProductId()); } // 扣減成功后再寫(xiě)流水change_qty 為負(fù) InventoryFlow flow new InventoryFlow(); flow.setProductId(detail.getProductId()); flow.setWarehouseCode(order.getWarehouseCode()); flow.setLocationCode(detail.getLocationCode()); flow.setBatchNo(detail.getBatchNo()); flow.setChangeQty(-detail.getQty()); flow.setBizType(OUTBOUND); flow.setBizNo(order.getBizNo()); flowMapper.insert(flow); } orderMapper.updateStatus(orderId, FINISHED); }decreaseStock對(duì)應(yīng)的 SQL 是UPDATE inventory SET quantity quantity - #{qty}, version version 1 WHERE product_id ... AND quantity #{qty}。quantity #{qty}這個(gè)條件就是防超賣(mài)的關(guān)鍵數(shù)據(jù)庫(kù)層面保證不會(huì)扣成負(fù)數(shù)。如果返回 0要么庫(kù)存真的不夠要么并發(fā)沖突導(dǎo)致版本變了兩種情況都拋異常讓上層處理。這里沒(méi)有用version字段做 CAS因?yàn)閝uantity qty本身已經(jīng)足夠version更多是留給后續(xù)擴(kuò)展用的。提示如果出庫(kù)單明細(xì)很多逐條扣減可能產(chǎn)生死鎖。常見(jiàn)做法是按product_id排序后再處理保證加鎖順序一致。3.3 盤(pán)點(diǎn)調(diào)整讓庫(kù)存和流水對(duì)得上盤(pán)點(diǎn)調(diào)整本質(zhì)上是「以實(shí)際盤(pán)點(diǎn)數(shù)為準(zhǔn)反向生成一條調(diào)整流水」。假設(shè)系統(tǒng)庫(kù)存 100實(shí)際盤(pán)點(diǎn) 95那就生成一條change_qty -5、biz_type ADJUST的流水同時(shí)把inventory.quantity直接設(shè)為 95。這里不要用累加因?yàn)楸P(pán)點(diǎn)就是強(qiáng)制校準(zhǔn)。調(diào)整完成后用SELECT SUM(change_qty) FROM inventory_flow WHERE product_id ? AND ...和inventory.quantity比對(duì)兩者必須相等否則說(shuō)明有流水漏寫(xiě)或庫(kù)存被繞過(guò)修改。4. 并發(fā)與一致性那些讓庫(kù)存對(duì)不上的坑4.1 避坑一事務(wù)里調(diào)用遠(yuǎn)程接口導(dǎo)致鎖持有過(guò)久現(xiàn)象出庫(kù)接口偶爾超時(shí)數(shù)據(jù)庫(kù)連接池被打滿(mǎn)庫(kù)存扣減變慢。原因在Transactional方法里調(diào)用了外部 HTTP 接口比如通知 WMS 或推送消息遠(yuǎn)程調(diào)用耗時(shí)幾百毫秒甚至超時(shí)導(dǎo)致數(shù)據(jù)庫(kù)行鎖一直不釋放。解決把遠(yuǎn)程調(diào)用移到事務(wù)提交之后用TransactionSynchronizationManager.registerSynchronization的afterCommit回調(diào)或者用本地消息表 異步任務(wù)。事務(wù)里只做數(shù)據(jù)庫(kù)操作這是鐵律。4.2 避坑二批量入庫(kù)時(shí)逐條提交導(dǎo)致部分成功現(xiàn)象一次入庫(kù) 100 個(gè) SKU前 50 個(gè)成功后 50 個(gè)失敗庫(kù)存只加了一半單據(jù)狀態(tài)卻是「處理中」。原因循環(huán)里每條明細(xì)單獨(dú)開(kāi)事務(wù)沒(méi)有整體回滾。解決整個(gè)單據(jù)的處理放在一個(gè)事務(wù)方法里任何一條明細(xì)失敗就拋異?;貪L全部。如果數(shù)據(jù)量確實(shí)大拆成「預(yù)占 確認(rèn)」兩階段但第一階段也要保證冪等。4.3 避坑三MySQL 隔離級(jí)別選錯(cuò)導(dǎo)致幻讀現(xiàn)象兩個(gè)線(xiàn)程同時(shí)入庫(kù)同一商品同一庫(kù)位都查不到庫(kù)存行都執(zhí)行插入結(jié)果唯一鍵沖突報(bào)錯(cuò)。原因RR 隔離級(jí)別下普通SELECT是快照讀看不到其他事務(wù)未提交的插入。解決插入庫(kù)存行時(shí)用INSERT ... ON DUPLICATE KEY UPDATE quantity quantity #{qty}把「查-插-改」合并成一條原子語(yǔ)句?;蛘哂肧ELECT ... FOR UPDATE加間隙鎖但性能差一些。4.4 避坑四流水表 change_qty 正負(fù)號(hào)寫(xiě)反現(xiàn)象對(duì)賬時(shí)發(fā)現(xiàn)流水累加和庫(kù)存對(duì)不上差值是庫(kù)存的兩倍。原因出庫(kù)時(shí)change_qty寫(xiě)成了正數(shù)導(dǎo)致流水越加越多。解決在InventoryFlow的 setter 里做約束或者用枚舉BizType統(tǒng)一控制符號(hào)。更穩(wěn)妥的做法是在數(shù)據(jù)庫(kù)層加CHECK約束MySQL 8.0.16 支持但很多團(tuán)隊(duì)用 5.7那就靠代碼規(guī)范和單元測(cè)試覆蓋。4.5 避坑五忘記處理 batch_no 為 NULL 的情況現(xiàn)象同一商品同一庫(kù)位有的庫(kù)存行batch_no是 NULL有的是空串唯一鍵沒(méi)攔住出現(xiàn)兩行庫(kù)存。原因Java 對(duì)象里String batchNo默認(rèn)是 null插入時(shí)沒(méi)轉(zhuǎn)成空串。解決在 MyBatis 的insert語(yǔ)句里用IFNULL(#{batchNo}, )或者在 Service 層統(tǒng)一batchNo batchNo null ? : batchNo。這個(gè)坑很隱蔽往往上線(xiàn)后盤(pán)點(diǎn)才發(fā)現(xiàn)。5. 從能跑到好用庫(kù)存對(duì)賬與性能驗(yàn)證的兩個(gè)技巧5.1 用一條 SQL 做每日庫(kù)存對(duì)賬系統(tǒng)跑起來(lái)之后最怕的是「賬實(shí)不符」。除了定期盤(pán)點(diǎn)我習(xí)慣每天凌晨跑一條對(duì)賬 SQL把流水累加和庫(kù)存快照比對(duì)差異超過(guò)閾值的記錄直接告警。SELECT i.product_id, i.warehouse_code, i.location_code, i.batch_no, i.quantity AS snapshot_qty, IFNULL(f.flow_sum, 0) AS flow_qty, i.quantity - IFNULL(f.flow_sum, 0) AS diff FROM inventory i LEFT JOIN ( SELECT product_id, warehouse_code, location_code, batch_no, SUM(change_qty) AS flow_sum FROM inventory_flow GROUP BY product_id, warehouse_code, location_code, batch_no ) f ON i.product_id f.product_id AND i.warehouse_code f.warehouse_code AND i.location_code f.location_code AND i.batch_no f.batch_no HAVING diff 0;這條 SQL 的HAVING diff 0會(huì)直接列出所有不一致的記錄。正常情況下結(jié)果為空一旦有數(shù)據(jù)就說(shuō)明某次操作繞過(guò)了流水直接改了庫(kù)存或者流水寫(xiě)漏了。我一般會(huì)把這個(gè)查詢(xún)配成定時(shí)任務(wù)結(jié)果推送到內(nèi)部告警群比人工盤(pán)點(diǎn)發(fā)現(xiàn)得早得多。5.2 壓測(cè)時(shí)重點(diǎn)看三個(gè)指標(biāo)庫(kù)存扣減的性能瓶頸往往不在 SQL 本身而在鎖競(jìng)爭(zhēng)。用 JMeter 或 wrk 壓測(cè)時(shí)我重點(diǎn)看三個(gè)指標(biāo)一是innodb_row_lock_waits如果持續(xù)增長(zhǎng)說(shuō)明行鎖競(jìng)爭(zhēng)激烈二是Threads_running超過(guò) CPU 核數(shù)兩倍就要警惕三是接口 P99 延遲如果 P99 是 P50 的十倍以上通常是鎖等待導(dǎo)致的。優(yōu)化方向一般是把單條扣減改成批量扣減、按商品 ID 分片、或者引入 Redis 做預(yù)扣減再異步落庫(kù)。但后者會(huì)引入一致性復(fù)雜度中小系統(tǒng)不到萬(wàn)不得已不建議上。5.3 一個(gè)我堅(jiān)持了多年的習(xí)慣每次改完庫(kù)存相關(guān)的代碼不管多小的改動(dòng)我都會(huì)在測(cè)試環(huán)境跑一遍「入庫(kù) 100 → 出庫(kù) 30 → 盤(pán)點(diǎn)調(diào)整為 60 → 再出庫(kù) 10」的完整鏈路然后執(zhí)行上面那條對(duì)賬 SQL確認(rèn) diff 為 0 才提交。這個(gè)習(xí)慣幫我攔住了至少三次符號(hào)寫(xiě)反、兩次事務(wù)漏加的問(wèn)題。庫(kù)存系統(tǒng)的 bug 不會(huì)立刻暴露往往在幾個(gè)月后盤(pán)點(diǎn)時(shí)才炸出來(lái)那時(shí)候追溯成本極高。寧可多花十分鐘跑一遍鏈路也別給自己留后悔藥。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取