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