儲(chǔ)管理系統(tǒng):從架構(gòu)設(shè)計(jì)到庫(kù)存并發(fā)控制實(shí)戰(zhàn))
1. 項(xiàng)目背景與需求定位1.1 這個(gè)項(xiàng)目到底解決什么問(wèn)題做這個(gè)零售與倉(cāng)儲(chǔ)管理系統(tǒng)之前我最直觀的感受是很多小型零售企業(yè)還在用Excel管庫(kù)存甚至靠手寫(xiě)賬本。倉(cāng)庫(kù)里進(jìn)了什么貨、賣了什么、還剩多少全依賴人工登記。一旦商品種類超過(guò)幾百個(gè)或者門店和倉(cāng)庫(kù)不在同一個(gè)地方這種模式的混亂程度會(huì)直線上升。庫(kù)存對(duì)不上賬、補(bǔ)貨不及時(shí)、月底盤點(diǎn)費(fèi)時(shí)費(fèi)力這些都是常態(tài)。所以我當(dāng)時(shí)做這個(gè)項(xiàng)目的核心目標(biāo)很明確用一套系統(tǒng)打通零售前端和倉(cāng)儲(chǔ)后端讓進(jìn)貨、入庫(kù)、出庫(kù)、銷售、盤點(diǎn)這幾條核心鏈路在一個(gè)系統(tǒng)里運(yùn)轉(zhuǎn)起來(lái)。Spring Boot作為項(xiàng)目底座不僅僅是圖它開(kāi)發(fā)效率高更重要的是它的生態(tài)足夠成熟后續(xù)擴(kuò)展權(quán)限管理、對(duì)接支付、生成報(bào)表等能力都很順手。1.2 適合誰(shuí)參考這套設(shè)計(jì)如果你是剛學(xué)完Java基礎(chǔ)想找一個(gè)完整的項(xiàng)目練手這個(gè)系統(tǒng)的復(fù)雜度恰到好處。它不像那種純CRUD的圖書(shū)管理系統(tǒng)那么簡(jiǎn)單也不像分布式電商那么復(fù)雜模塊劃分、權(quán)限控制、庫(kù)存邏輯都有足夠的深度可以挖掘。如果你是在小公司做內(nèi)部管理系統(tǒng)這套方案的可復(fù)制度很高。零售加倉(cāng)儲(chǔ)的組合非常常見(jiàn)核心模型拎出來(lái)改改可以直接遷移到其他行業(yè)的管理系統(tǒng)上。我做這套系統(tǒng)時(shí)有一個(gè)很深的體會(huì)項(xiàng)目的價(jià)值不在于用了多新的技術(shù)而在于業(yè)務(wù)邏輯是否梳理得清晰數(shù)據(jù)是否準(zhǔn)確可靠。這一點(diǎn)這個(gè)項(xiàng)目體現(xiàn)得很充分。2. 整體架構(gòu)與技術(shù)選型思路2.1 為什么選Spring Boot而不是其他框架技術(shù)選型這一步我先說(shuō)說(shuō)自己的考量。之前用SSHStrutsSpringHibernate做過(guò)類似項(xiàng)目配置文件多到讓人崩潰每加一個(gè)類都要去XML里注冊(cè)。Spring Boot最大的優(yōu)勢(shì)就是自動(dòng)配置和約定優(yōu)于配置省掉了大量繁瑣的裝配工作。開(kāi)發(fā)階段用內(nèi)嵌的Tomcat跑起來(lái)打包成可執(zhí)行Jar丟到服務(wù)器上就能運(yùn)行部署成本非常低。Java這個(gè)語(yǔ)言選得也比較穩(wěn)。零售倉(cāng)儲(chǔ)系統(tǒng)涉及資金和貨物穩(wěn)定性和事務(wù)能力是第一位的。Java在這方面的積累很深JDBC、JPA、MyBatis這些數(shù)據(jù)訪問(wèn)層方案都非常成熟遇到復(fù)雜查詢或者事務(wù)問(wèn)題社區(qū)里能搜到大量現(xiàn)成的解決方案。2.2 核心依賴與分層結(jié)構(gòu)我用的技術(shù)棧是Spring Boot 2.x MyBatis Plus MySQL Redis。Spring Boot負(fù)責(zé)整體框架和請(qǐng)求分發(fā)MyBatis Plus負(fù)責(zé)數(shù)據(jù)訪問(wèn)MySQL存業(yè)務(wù)數(shù)據(jù)Redis存登錄會(huì)話和緩存熱數(shù)據(jù)。分層結(jié)構(gòu)上我保持了經(jīng)典的四層架構(gòu)Controller層接收前端請(qǐng)求Service層寫(xiě)核心業(yè)務(wù)邏輯Mapper層做數(shù)據(jù)庫(kù)交互實(shí)體類映射表結(jié)構(gòu)。這里要強(qiáng)調(diào)一點(diǎn)很多初學(xué)者容易把業(yè)務(wù)邏輯寫(xiě)在Controller里圖省事。但一旦業(yè)務(wù)復(fù)雜起來(lái)Controller膨脹得飛快后期幾乎沒(méi)法維護(hù)。我的習(xí)慣是Controller只做參數(shù)接收和結(jié)果包裝所有判斷、計(jì)算、異常處理都下沉到Service層。2.3 為什么用RBAC模型做權(quán)限管理權(quán)限管理我采用了基于角色的訪問(wèn)控制模型RBAC就是給用戶分配角色給角色分配權(quán)限。超級(jí)管理員、倉(cāng)庫(kù)管理員、銷售員、店長(zhǎng)這幾種角色覆蓋了大部分業(yè)務(wù)場(chǎng)景。每個(gè)用戶登錄后系統(tǒng)根據(jù)其角色返回可見(jiàn)菜單和可操作的接口。這個(gè)設(shè)計(jì)看起來(lái)簡(jiǎn)單實(shí)際操作時(shí)有兩個(gè)很關(guān)鍵的點(diǎn)。第一個(gè)是權(quán)限粒度我控制到了按鈕級(jí)別哪些角色能看到“刪除商品”按鈕哪些角色只能看“編輯”都在前端根據(jù)權(quán)限標(biāo)識(shí)進(jìn)行控制。第二個(gè)是后端接口必須再做一次校驗(yàn)不能光靠前端隱藏按鈕。我曾見(jiàn)過(guò)一些項(xiàng)目前端把按鈕藏得很嚴(yán)實(shí)結(jié)果有人直接調(diào)接口就繞過(guò)了限制這個(gè)問(wèn)題務(wù)必避免。3. 數(shù)據(jù)庫(kù)設(shè)計(jì)與核心功能模塊切分3.1 數(shù)據(jù)表規(guī)劃的核心邏輯數(shù)據(jù)庫(kù)設(shè)計(jì)是整個(gè)系統(tǒng)最重要的一環(huán)。我總結(jié)一個(gè)原則寧可多拆幾張表不要把所有字段塞進(jìn)一張大表。合理的表結(jié)構(gòu)能讓后續(xù)的統(tǒng)計(jì)和擴(kuò)展變得很順暢。我設(shè)計(jì)的主要數(shù)據(jù)表包括用戶表、角色表、權(quán)限表、商品分類表、商品信息表、供應(yīng)商表、倉(cāng)庫(kù)表、庫(kù)存表、入庫(kù)單表、出庫(kù)單表、銷售訂單表、盤點(diǎn)記錄表。每個(gè)表的主鍵用自增ID所有金額字段統(tǒng)一用Decimal類型避免浮點(diǎn)數(shù)精度問(wèn)題。以商品表為例字段不止包括商品名稱、條碼、規(guī)格、單位這些基礎(chǔ)信息還專門加了預(yù)警閾值字段。這個(gè)預(yù)警閾值是庫(kù)存管理的關(guān)鍵當(dāng)庫(kù)存數(shù)量低于這個(gè)值時(shí)系統(tǒng)自動(dòng)在首頁(yè)生成補(bǔ)貨提醒。有些系統(tǒng)把預(yù)警寫(xiě)死在代碼里不同商品用一個(gè)閾值這其實(shí)不太符合實(shí)際情況。比如礦泉水可能剩100瓶還很充裕但一臺(tái)昂貴的設(shè)備剩1臺(tái)就得補(bǔ)貨。閾值放在商品表里按商品單獨(dú)配置這個(gè)設(shè)計(jì)我建議直接抄走。3.2 庫(kù)存字段到底放哪張表庫(kù)存的設(shè)計(jì)上我踩過(guò)一個(gè)小坑。最開(kāi)始我把庫(kù)存數(shù)量直接放在商品表里查詢是方便了但每次出入庫(kù)都要更新商品表而且沒(méi)法追溯歷史庫(kù)存變化。后來(lái)我改成了單獨(dú)的庫(kù)存表按倉(cāng)庫(kù)加商品維度存數(shù)量即每條記錄對(duì)應(yīng)“某商品在某倉(cāng)庫(kù)的庫(kù)存量”。這樣設(shè)計(jì)的好處有三個(gè)。一是多倉(cāng)庫(kù)場(chǎng)景下庫(kù)存數(shù)據(jù)天然隔離每個(gè)倉(cāng)庫(kù)各算各的二是盤點(diǎn)時(shí)只需要鎖定庫(kù)存表不需要?jiǎng)由唐分鲾?shù)據(jù)三是后續(xù)如果要按倉(cāng)庫(kù)維度統(tǒng)計(jì)庫(kù)存金額直接查庫(kù)存表關(guān)聯(lián)商品單價(jià)即可。入庫(kù)明細(xì)表和出庫(kù)明細(xì)表則把每一次操作的數(shù)量、單價(jià)、經(jīng)辦人、時(shí)間都記錄下來(lái)形成完整的溯源鏈路。3.3 功能模塊的邊界劃分整個(gè)系統(tǒng)我拆成了六個(gè)核心模塊商品管理、供應(yīng)商管理、倉(cāng)庫(kù)管理、入庫(kù)管理、出庫(kù)管理、銷售管理。外加系統(tǒng)管理模塊負(fù)責(zé)用戶和權(quán)限配置統(tǒng)計(jì)報(bào)表模塊負(fù)責(zé)數(shù)據(jù)匯總。商品管理管的是商品檔案包括新增商品、編輯信息、上下架、條碼管理。供應(yīng)商管理維護(hù)供貨商檔案和合作記錄。倉(cāng)庫(kù)管理管的是倉(cāng)庫(kù)基本信息包括庫(kù)位編碼。入庫(kù)管理覆蓋采購(gòu)入庫(kù)、退貨入庫(kù)、其它入庫(kù)這幾類場(chǎng)景。出庫(kù)管理覆蓋銷售出庫(kù)、報(bào)損出庫(kù)、調(diào)撥出庫(kù)等。這里我特別想說(shuō)的是銷售管理和出庫(kù)管理的關(guān)系。有些系統(tǒng)把銷售訂單和出庫(kù)流程完全分開(kāi)導(dǎo)致數(shù)據(jù)不一致。我的做法是銷售訂單創(chuàng)建后自動(dòng)生成一條對(duì)應(yīng)的銷售出庫(kù)單庫(kù)存扣減以出庫(kù)單為準(zhǔn)確保銷售數(shù)據(jù)和庫(kù)存數(shù)據(jù)的聯(lián)動(dòng)。這一塊邏輯看著簡(jiǎn)單實(shí)際編碼時(shí)要處理并發(fā)問(wèn)題后面我會(huì)詳細(xì)講。4. 核心業(yè)務(wù)流程與關(guān)鍵實(shí)現(xiàn)4.1 入庫(kù)流程的狀態(tài)流轉(zhuǎn)入庫(kù)流程的完整鏈路是創(chuàng)建入庫(kù)單 → 審核入庫(kù)單 → 更新庫(kù)存 → 記錄入庫(kù)明細(xì)。第一步倉(cāng)庫(kù)人員根據(jù)采購(gòu)單或到貨通知?jiǎng)?chuàng)建入庫(kù)單填寫(xiě)供應(yīng)商、入庫(kù)倉(cāng)庫(kù)、商品明細(xì)和數(shù)量。這里有一個(gè)容易忽略的地方入庫(kù)單的編號(hào)一定要系統(tǒng)自動(dòng)生成采用日期加流水號(hào)的格式。比如RK20250615001可讀性強(qiáng)后期對(duì)賬、客服查詢都會(huì)方便很多。第二步是審核。我設(shè)置了一個(gè)原則創(chuàng)建人和審核人不能是同一人。入庫(kù)單保存后處于待審核狀態(tài)審核通過(guò)后才會(huì)真正影響庫(kù)存。這個(gè)設(shè)計(jì)是為了防止操作失誤被直接寫(xiě)入庫(kù)存給復(fù)核留出空間。第三步和第四步是同步執(zhí)行的。審核通過(guò)的事務(wù)里先按“倉(cāng)庫(kù)加商品”維度去庫(kù)存表查是否存在記錄。存在則增加數(shù)量不存在則先新增一條庫(kù)存記錄再累加。同時(shí)把每一件商品的入庫(kù)明細(xì)寫(xiě)入入庫(kù)明細(xì)表。4.2 庫(kù)存扣減的并發(fā)控制方案庫(kù)存超賣是倉(cāng)儲(chǔ)系統(tǒng)最敏感的問(wèn)題。假設(shè)某商品庫(kù)存只剩10件同時(shí)來(lái)了兩個(gè)銷售訂單一單要8件一單要5件如果不做控制最終可能賣出去13件庫(kù)存變成負(fù)數(shù)。我用的方案是數(shù)據(jù)庫(kù)悲觀鎖配合事務(wù)隔離。執(zhí)行庫(kù)存扣減的SQL語(yǔ)句時(shí)用SELECT * FROM stock WHERE product_id ? AND warehouse_id ? FOR UPDATE先把這一行鎖住然后再檢查庫(kù)存是否充足執(zhí)行扣減。這樣同一時(shí)刻只有一個(gè)事務(wù)能操作這條庫(kù)存記錄徹底避免超賣。這里分享一個(gè)性能層面的小經(jīng)驗(yàn)。悲觀鎖在并發(fā)量不是極大的場(chǎng)景下完全夠用零售倉(cāng)儲(chǔ)系統(tǒng)的并發(fā)量一般不會(huì)像秒殺那么夸張。如果后續(xù)量大了可以改用Redis的分布式鎖或者樂(lè)觀鎖版本號(hào)機(jī)制。但雙十一那種級(jí)別的并發(fā)這個(gè)架構(gòu)本身就不合適需要引入消息隊(duì)列做異步削峰那就是另一套設(shè)計(jì)了。4.3 銷售與出庫(kù)的聯(lián)動(dòng)實(shí)現(xiàn)用戶在前臺(tái)下單后系統(tǒng)會(huì)做這幾步操作校驗(yàn)庫(kù)存是否充足、生成銷售訂單、生成出庫(kù)單、扣減庫(kù)存。這四個(gè)步驟我放在同一個(gè)事務(wù)里。一旦扣減庫(kù)存時(shí)發(fā)現(xiàn)異常整個(gè)事務(wù)回滾銷售訂單也不會(huì)生效。有一個(gè)實(shí)際問(wèn)題需要防范用戶下單后沒(méi)有付款訂單處于待付款狀態(tài)這個(gè)時(shí)候就扣庫(kù)存用戶又不買了庫(kù)存就被白白鎖死。我需要區(qū)分普通電商和零售門店場(chǎng)景。如果是門店收銀基本上是現(xiàn)場(chǎng)付款直接扣庫(kù)存問(wèn)題不大。但如果系統(tǒng)將來(lái)要支持線上預(yù)訂建議把扣減時(shí)間延后到支付成功之后或者引入預(yù)占庫(kù)存和釋放庫(kù)存的機(jī)制。4.4 盤點(diǎn)模塊的盈虧處理盤點(diǎn)這塊我覺(jué)得最能體現(xiàn)一個(gè)系統(tǒng)是否真正貼合業(yè)務(wù)場(chǎng)景。實(shí)際盤點(diǎn)時(shí)業(yè)務(wù)人員先錄入賬面數(shù)量再錄入實(shí)盤數(shù)量。系統(tǒng)自動(dòng)計(jì)算盈虧數(shù)量。如果實(shí)盤數(shù)大于賬面數(shù)說(shuō)明盤盈需要做盤盈入庫(kù)。如果實(shí)盤數(shù)小于賬面數(shù)說(shuō)明盤虧需要做盤虧出庫(kù)。這一進(jìn)一出都要生成對(duì)應(yīng)的出入庫(kù)單據(jù)并且記錄盈虧原因。我在系統(tǒng)里預(yù)設(shè)了幾種常見(jiàn)原因掃碼遺漏、貨物損壞、供應(yīng)商少發(fā)、錄入錯(cuò)誤。這樣財(cái)務(wù)復(fù)核時(shí)能直接看到原因歸類省去很多溝通成本。盤點(diǎn)單還有一個(gè)狀態(tài)進(jìn)行中、已提交、已審核。已經(jīng)審核的盤點(diǎn)單不可修改確實(shí)是復(fù)盤后確認(rèn)的數(shù)據(jù)。這個(gè)狀態(tài)機(jī)的控制保證了盤點(diǎn)流程的嚴(yán)肅性。5. 實(shí)操記錄與關(guān)鍵配置明細(xì)5.1 環(huán)境準(zhǔn)備與項(xiàng)目初始化項(xiàng)目開(kāi)發(fā)環(huán)境我用的JDK 1.8Spring Boot 2.5版本。雖然JDK 17和Spring Boot 3.0都已經(jīng)很成熟但考慮到很多生產(chǎn)環(huán)境的服務(wù)器還在用JDK 8而且相關(guān)生態(tài)的兼容性問(wèn)題少我最終選了相對(duì)保守的版本組合。用Spring Initializr初始化項(xiàng)目時(shí)我勾選了Web、MySQL Driver、MyBatis、Redis這幾個(gè)依賴。數(shù)據(jù)庫(kù)方面本地先用MySQL 5.7后續(xù)生產(chǎn)切換到MySQL 8.0連接驅(qū)動(dòng)升級(jí)一下即可代碼改動(dòng)很小。字符集一定要設(shè)utf8mb4否則存emoji或者特殊符號(hào)容易報(bào)錯(cuò)。5.2 核心配置文件示例application.yml里的幾個(gè)關(guān)鍵配置如下server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/retail_storage?useUnicodetruecharacterEncodingutf8mb4serverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0這里有幾個(gè)細(xì)節(jié)值得展開(kāi)說(shuō)。map-underscore-to-camel-case設(shè)為true后數(shù)據(jù)庫(kù)的create_time字段能自動(dòng)映射到實(shí)體的createTime屬性省去手動(dòng)寫(xiě)映射規(guī)則的麻煩。邏輯刪除配置也很有用我沒(méi)用物理刪除所有業(yè)務(wù)表都加了deleted字段。用戶誤刪商品檔案時(shí)實(shí)際上數(shù)據(jù)還在只是查詢時(shí)被過(guò)濾掉了隨時(shí)可以恢復(fù)。5.3 統(tǒng)一返回結(jié)果與全局異常處理這個(gè)部分強(qiáng)烈建議所有Spring Boot項(xiàng)目都做。我定義了一個(gè)通用的Result類包含code、message、data三個(gè)字段。所有Controller接口都返回這個(gè)類前端可以根據(jù)code判斷請(qǐng)求是否成功。狀態(tài)碼我用的是20000表示成功50000表示業(yè)務(wù)異常40100表示未登錄或登錄過(guò)期。Data public class ResultT { private Integer code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(20000); result.setMessage(操作成功); result.setData(data); return result; } public static T ResultT error(String message) { ResultT result new Result(); result.setCode(50000); result.setMessage(message); return result; } }全局異常處理用RestControllerAdvice注解實(shí)現(xiàn)。業(yè)務(wù)異常、參數(shù)校驗(yàn)異常、數(shù)據(jù)庫(kù)異常分別處理統(tǒng)一包裝成Result對(duì)象返回。這樣還有一個(gè)深層好處前端拿到統(tǒng)一格式的響應(yīng)體后可以封裝一套通用的請(qǐng)求攔截邏輯遇到40100自動(dòng)跳登錄頁(yè)遇到50000統(tǒng)一彈出提示。不用每個(gè)頁(yè)面各寫(xiě)一套錯(cuò)誤處理邏輯。5.4 登錄驗(yàn)證與JWT令牌用戶登錄后我用JWTJSON Web Token生成登錄令牌存到Redis里同時(shí)設(shè)置過(guò)期時(shí)間。每次請(qǐng)求經(jīng)過(guò)攔截器時(shí)從請(qǐng)求頭取出令牌并校驗(yàn)。使用JWT加Redis的組合既保留了JWT無(wú)狀態(tài)的特點(diǎn)又能通過(guò)Redis手動(dòng)踢人下線。令牌的有效期設(shè)置要結(jié)合業(yè)務(wù)場(chǎng)景來(lái)考慮。零售門店的人員流動(dòng)性大如果令牌一個(gè)月不過(guò)期員工離職后賬號(hào)仍然有效會(huì)帶來(lái)安全隱患。我設(shè)置的是8小時(shí)過(guò)期配合Redis里存的會(huì)話記錄超時(shí)后自動(dòng)重新登錄。門店收銀員一天登錄一次體驗(yàn)上也沒(méi)有問(wèn)題。5.5 前端頁(yè)面的技術(shù)選型前端部分用了Thymeleaf加Bootstrap沒(méi)做前后端分離。為什么這么選因?yàn)檫@套系統(tǒng)的使用人群是門店店員和倉(cāng)庫(kù)管理員他們需要的質(zhì)檢場(chǎng)景是登錄后用瀏覽器操作頁(yè)面不追求炫酷效果只追求簡(jiǎn)單好用加載速度快。如果要做前后端分離我建議后端提供純JSON接口配合Vue或React做管理后臺(tái)。但這會(huì)引入跨域處理、Token存儲(chǔ)、構(gòu)建部署等一系列額外復(fù)雜度。對(duì)于這個(gè)項(xiàng)目規(guī)模服務(wù)端渲染的方案在開(kāi)發(fā)效率和維護(hù)便利性上反而更有優(yōu)勢(shì)。6. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄6.1 數(shù)據(jù)庫(kù)連接池連接超時(shí)系統(tǒng)運(yùn)行一段時(shí)間后偶爾會(huì)報(bào)Communications link failure錯(cuò)誤。排查后發(fā)現(xiàn)是數(shù)據(jù)庫(kù)連接的閑置時(shí)間超過(guò)了MySQL的wait_timeout。解決辦法是在配置文件中設(shè)置連接池參數(shù)讓連接池定時(shí)檢查并回收失效連接。spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 20 idle-timeout: 30000 max-lifetime: 1800000 connection-test-query: SELECT 1這里max-lifetime設(shè)置成30分鐘要比MySQL的wait_timeout短一些確保連接被提前回收不至于被數(shù)據(jù)庫(kù)服務(wù)端斷開(kāi)后繼續(xù)使用。6.2 精度問(wèn)題導(dǎo)致金額對(duì)不上有同事反饋某個(gè)訂單的總金額差了一分錢。最后定位到是商品單價(jià)用了double類型計(jì)算產(chǎn)生浮點(diǎn)數(shù)誤差。這個(gè)問(wèn)題的教訓(xùn)很直接涉及金額的計(jì)算絕不能用double或float。后來(lái)我把所有金額字段統(tǒng)一改成Decimal代碼里用BigDecimal計(jì)算再也沒(méi)出現(xiàn)過(guò)這種問(wèn)題。BigDecimal計(jì)算時(shí)要注意構(gòu)造方法盡量用字符串參數(shù)比如new BigDecimal(19.90)不要直接用new BigDecimal(19.90)。后者由于二進(jìn)制浮點(diǎn)數(shù)表示問(wèn)題得到的值往往不是精確的19.9。初學(xué)的時(shí)候容易在這里踩坑。6.3 MyBatis Plus的字段填充失效使用MyBatis Plus的自動(dòng)填充功能時(shí)我在實(shí)體類的createTime字段上加了TableField(fill FieldFill.INSERT)注解但新增數(shù)據(jù)時(shí)字段值還是null。排查發(fā)現(xiàn)是MetaObjectHandler的實(shí)現(xiàn)類沒(méi)有交給Spring管理缺了Component注解。這個(gè)實(shí)現(xiàn)類里我重寫(xiě)了insertFill和updateFill兩個(gè)方法分別填充創(chuàng)建時(shí)間和更新時(shí)間。這類問(wèn)題通常在配置層面代碼編譯不報(bào)錯(cuò)運(yùn)行時(shí)才暴露排查起來(lái)需要細(xì)心對(duì)照官方文檔。6.4 并發(fā)測(cè)試中出現(xiàn)的庫(kù)存負(fù)數(shù)系統(tǒng)上線前做壓測(cè)并發(fā)開(kāi)30個(gè)線程同時(shí)購(gòu)買同一件商品日志里出現(xiàn)了庫(kù)存負(fù)數(shù)的情況。這說(shuō)明單純?cè)赟ervice層判斷庫(kù)存是否充足是擋不住并發(fā)的。多個(gè)線程同時(shí)讀到庫(kù)存為10同時(shí)判斷10大于購(gòu)買數(shù)量全部通過(guò)然后一起扣減庫(kù)存就變成負(fù)數(shù)了。我改用數(shù)據(jù)庫(kù)行級(jí)鎖之后同一商品同一倉(cāng)庫(kù)的庫(kù)存行同一時(shí)刻只有一個(gè)事務(wù)能操作后面的線程必須等待前一個(gè)事務(wù)提交后才能讀到最新庫(kù)存。再加上扣減的SQL語(yǔ)句里帶上條件stock quantity雙重保障徹底解決了這個(gè)問(wèn)題。6.5 高頻查詢的Redis緩存策略商品列表、分類信息這類數(shù)據(jù)變化頻率低、讀取頻率高非常適合放到Redis里緩存。我的做法是添加或修改商品時(shí)主動(dòng)刪除相關(guān)緩存查詢時(shí)先查緩存緩存不存在時(shí)再查數(shù)據(jù)庫(kù)并回填緩存。這里要重點(diǎn)提醒一句緩存一定要設(shè)置過(guò)期時(shí)間不能是永久key。不設(shè)置過(guò)期時(shí)間的緩存一旦忘記主動(dòng)清理數(shù)據(jù)不一致的問(wèn)題會(huì)長(zhǎng)期存在。時(shí)間設(shè)短了效果差設(shè)長(zhǎng)了數(shù)據(jù)舊一般我會(huì)根據(jù)業(yè)務(wù)對(duì)實(shí)時(shí)性的要求調(diào)整商品列表設(shè)30分鐘登錄會(huì)話設(shè)8小時(shí)是比較常規(guī)的配置。6.6 常見(jiàn)問(wèn)題速查表問(wèn)題現(xiàn)象可能原因解決方案項(xiàng)目啟動(dòng)失敗端口被占用8080端口被其它進(jìn)程占用使用netstat命令查找進(jìn)程并停止或修改配置端口點(diǎn)擊登錄無(wú)反應(yīng)登錄接口報(bào)500SQL語(yǔ)句有誤查看后端日志定位具體報(bào)錯(cuò)行導(dǎo)入商品Excel亂碼文件編碼不是UTF-8讀取時(shí)指定字符集前端上傳時(shí)做編碼處理庫(kù)存列表查詢慢商品表數(shù)據(jù)量大未加索引在商品條碼、分類ID字段上建立索引報(bào)表數(shù)據(jù)與明細(xì)對(duì)不上統(tǒng)計(jì)SQL存在重復(fù)關(guān)聯(lián)先用子查詢?nèi)ブ卦訇P(guān)聯(lián)匯總表7. 部署上線與數(shù)據(jù)備份經(jīng)驗(yàn)系統(tǒng)部署我用了最直接的方式Maven打包出Jar文件扔到服務(wù)器上運(yùn)行。生產(chǎn)環(huán)境我用的是CentOS系統(tǒng)執(zhí)行nohup java -jar retail-storage.jar app.log 21 日志輸出到文件方便排查問(wèn)題。關(guān)于日志級(jí)別我建議生產(chǎn)環(huán)境用info級(jí)別開(kāi)發(fā)環(huán)境用debug級(jí)別。debug日志記錄的信息量很大如果生產(chǎn)環(huán)境開(kāi)啟磁盤很容易被刷爆。同時(shí)要用logback的按天滾動(dòng)策略保留最近30天的日志避免日志文件無(wú)限增長(zhǎng)。數(shù)據(jù)庫(kù)備份這件事我吃過(guò)虧。項(xiàng)目上線初期某天誤操作批量修改了商品數(shù)據(jù)由于沒(méi)有備份最后只能手工逐條恢復(fù)花了好幾個(gè)小時(shí)。后來(lái)我寫(xiě)了一個(gè)shell腳本每天凌晨?jī)牲c(diǎn)用mysqldump全量備份備份文件保留7天再配合Binlog做增量恢復(fù)。每隔兩周手動(dòng)演練一次恢復(fù)流程確保備份真的能恢復(fù)而不是備份了個(gè)寂寞。8. 項(xiàng)目復(fù)盤與個(gè)人實(shí)操心得整個(gè)系統(tǒng)從設(shè)計(jì)到開(kāi)發(fā)再到上線加起來(lái)用了大約六周時(shí)間。現(xiàn)在回頭復(fù)盤這個(gè)項(xiàng)目的價(jià)值主要有三層。第一層是業(yè)務(wù)建模。零售倉(cāng)儲(chǔ)雖然沒(méi)有特別深?yuàn)W的算法但業(yè)務(wù)流程的梳理很考驗(yàn)邏輯能力。入庫(kù)、出庫(kù)、盤點(diǎn)、銷售之間環(huán)環(huán)相扣的狀態(tài)流轉(zhuǎn)一開(kāi)始我想得簡(jiǎn)單畫(huà)完時(shí)序圖才發(fā)現(xiàn)各種邊界情況都要處理比如審核駁回后庫(kù)存如何處理、盤點(diǎn)期間能不能繼續(xù)出入庫(kù)等等。第二層是數(shù)據(jù)一致性的把握。庫(kù)存、金額、訂單這三類數(shù)據(jù)必須保證絕對(duì)準(zhǔn)確任何一個(gè)環(huán)節(jié)出錯(cuò)后續(xù)的對(duì)賬和財(cái)務(wù)都會(huì)很痛苦。事務(wù)邊界、并發(fā)控制、唯一約束這些基本功在這個(gè)項(xiàng)目里體現(xiàn)得淋漓盡致。第三層是對(duì)用戶體驗(yàn)的認(rèn)識(shí)。這個(gè)系統(tǒng)的使用者年齡跨度大很多員工對(duì)計(jì)算機(jī)操作并不熟練。所以我做頁(yè)面時(shí)盡量遵循了幾個(gè)原則一個(gè)頁(yè)面只做一件事操作按鈕文字直白比如“新增商品”“確認(rèn)出庫(kù)”不要用“執(zhí)行”這種模糊詞匯。重要的操作有二次確認(rèn)彈窗比如刪除商品、盤點(diǎn)審核。每一步操作后有明確的成功或失敗提示。這些看似瑣碎的設(shè)計(jì)反而讓系統(tǒng)在實(shí)際使用中推廣得很順利。最后再分享一個(gè)小技巧多崗位角色協(xié)同使用系統(tǒng)時(shí)操作日志的作用遠(yuǎn)比想象中重要。我在所有關(guān)鍵業(yè)務(wù)表上都加了創(chuàng)建人、創(chuàng)建時(shí)間、更新人、更新時(shí)間字段并且單獨(dú)建了一張操作日志表記錄誰(shuí)在什么時(shí)間做了什么操作。上線后有一次盤點(diǎn)差異排查就是靠操作日志發(fā)現(xiàn)有人在非工作時(shí)段做過(guò)一次入庫(kù)單修改才找到數(shù)據(jù)對(duì)不上的原因。這個(gè)設(shè)計(jì)不需要太高深的技術(shù)但對(duì)運(yùn)維和審計(jì)非常有價(jià)值。如果你準(zhǔn)備復(fù)刻這個(gè)項(xiàng)目強(qiáng)烈建議把這個(gè)細(xì)節(jié)加進(jìn)去。