設(shè)計(jì):并發(fā)控制與實(shí)時(shí)出價(jià)核心方案)
站在學(xué)生的角度我一直覺(jué)得“基于Spring Boot的在線拍賣系統(tǒng)”屬于畢業(yè)設(shè)計(jì)里最典型的“老六”選題——名字聽(tīng)起來(lái)平平無(wú)奇但實(shí)際做起來(lái)全是坑。先不說(shuō)實(shí)時(shí)出價(jià)的并發(fā)問(wèn)題光是“拍賣倒計(jì)時(shí)與訂單超時(shí)關(guān)閉”這兩件事就能讓很多人在中期檢查前一周急得撓頭。我自己帶過(guò)的項(xiàng)目里做過(guò)不下五個(gè)不同版本的在線拍賣系統(tǒng)從最基礎(chǔ)的SSM版到Spring Boot Redis WebSocket的進(jìn)階版都有。這次借這個(gè)“源碼文檔”的題把我實(shí)際做這套系統(tǒng)時(shí)踩過(guò)的坑、沉淀下來(lái)的寫法、以及文檔要怎么組織才不會(huì)被老師挑刺一次性說(shuō)清楚。這個(gè)內(nèi)容適合正在做畢業(yè)設(shè)計(jì)/課程設(shè)計(jì)的同學(xué)也適合剛學(xué)完Spring Boot想找個(gè)完整項(xiàng)目練手的朋友參考價(jià)值應(yīng)該比網(wǎng)上一堆殘缺版代碼高不少。1. 在線拍賣系統(tǒng)的核心設(shè)計(jì)拆解1.1 為什么選Spring Boot而不是SSH/SSM很多同學(xué)糾結(jié)技術(shù)選型其實(shí)現(xiàn)在真沒(méi)什么好糾結(jié)的。Spring Boot的自動(dòng)配置和起步依賴能把以前SSM時(shí)代一大坨XML配置直接干掉這對(duì)課設(shè)/畢設(shè)來(lái)說(shuō)意味著什么意味著你省下來(lái)的是真正寫業(yè)務(wù)邏輯的時(shí)間而不是在那里調(diào)applicationContext.xml配數(shù)據(jù)源、配事務(wù)管理器、配MyBatis映射路徑。另外從答辯角度來(lái)說(shuō)Spring Boot本身就是當(dāng)前企業(yè)級(jí)Java開發(fā)的主流底座你在項(xiàng)目里用Spring Boot MyBatis Plus Redis Vue這套組合老師大概率不會(huì)質(zhì)疑技術(shù)棧的合理性。我見(jiàn)過(guò)有人還在用SSHStruts2 Spring Hibernate做畢設(shè)說(shuō)實(shí)話那種項(xiàng)目拿到現(xiàn)在既不好調(diào)試也不好找參考資料純純給自己上難度。再說(shuō)插件生態(tài)。Spring Boot的起步依賴Starter把Web、數(shù)據(jù)校驗(yàn)、模板引擎、緩存、消息隊(duì)列這些能力全都包裝好了你在pom.xml里加依賴就完事版本沖突也比SSM時(shí)代少很多。對(duì)我來(lái)說(shuō)選Spring Boot還有一個(gè)隱性的好處代碼可讀性好模塊結(jié)構(gòu)清晰后續(xù)寫文檔、畫架構(gòu)圖的時(shí)候腦子里能直接對(duì)應(yīng)到代碼的包結(jié)構(gòu)不容易出現(xiàn)“文檔畫得天花亂墜、代碼里啥也沒(méi)有”的尷尬局面。1.2 拍賣系統(tǒng)的核心業(yè)務(wù)角色與流程在線拍賣系統(tǒng)本質(zhì)上就是淘寶的拍賣頻道或者說(shuō)“閑魚拍賣”的簡(jiǎn)化版。核心角色就三類買家、賣家發(fā)布者、管理員。買家瀏覽在拍商品、出價(jià)競(jìng)拍、查看我的競(jìng)拍記錄、支付成交訂單。賣家發(fā)布拍賣商品、設(shè)置起拍價(jià)/加價(jià)幅度/拍賣截止時(shí)間、查看成交結(jié)果。管理員審核商品上下架、管理用戶、處理違規(guī)數(shù)據(jù)、查看全站拍賣流水。主流程一條線拉到底賣家發(fā)布商品并設(shè)置拍賣參數(shù) - 管理員審核通過(guò) - 商品進(jìn)入“拍賣中”狀態(tài) - 買家在拍賣截止前出價(jià)系統(tǒng)校驗(yàn)出價(jià)是否高于當(dāng)前價(jià) - 截止時(shí)間到系統(tǒng)判定最后出價(jià)人為中標(biāo)者 - 生成拍賣訂單 - 中標(biāo)者支付 - 交易完成。這里有個(gè)容易被忽略的設(shè)計(jì)點(diǎn)拍賣雖然是“實(shí)時(shí)”的但系統(tǒng)的狀態(tài)流轉(zhuǎn)必須有一個(gè)清晰的狀態(tài)機(jī)。我見(jiàn)過(guò)不少同學(xué)把商品狀態(tài)和訂單狀態(tài)混在一個(gè)字段里管理結(jié)果后期寫判斷邏輯時(shí)if套if改一個(gè)地方崩三個(gè)地方。后面我會(huì)專門講狀態(tài)機(jī)設(shè)計(jì)這塊建議你認(rèn)真看。1.3 需求拆解與功能模塊劃分按照我做過(guò)幾版項(xiàng)目的經(jīng)驗(yàn)一個(gè)標(biāo)準(zhǔn)的在線拍賣系統(tǒng)功能模塊可以這樣劃分用戶模塊注冊(cè)、登錄、個(gè)人信息維護(hù)、密碼加密存儲(chǔ)BCrypt。商品模塊商品發(fā)布、商品列表分頁(yè)、商品詳情、圖片上傳、商品審核。拍賣模塊出價(jià)、加價(jià)校驗(yàn)、拍賣倒計(jì)時(shí)、實(shí)時(shí)價(jià)格展示、出價(jià)記錄。訂單模塊成交訂單生成、訂單支付模擬支付/支付寶沙箱、訂單超時(shí)關(guān)閉。管理模塊后臺(tái)數(shù)據(jù)看板、用戶管理、商品審核、拍賣流水統(tǒng)計(jì)。有些同學(xué)想做成C2C平臺(tái)型類似ebay那還要加上“保證金”“信用分”這類機(jī)制。但說(shuō)實(shí)話如果是課設(shè)/畢設(shè)別加太多花活把上面五個(gè)模塊做好、做深已經(jīng)完全能體現(xiàn)工作量了。我見(jiàn)過(guò)盲目追求功能多、結(jié)果每個(gè)功能都是半成品答辯時(shí)被老師一問(wèn)就卡殼的例子那才是真正的翻車。2. 核心技術(shù)點(diǎn)解析與方案選型這一節(jié)我認(rèn)為是整套系統(tǒng)的靈魂。你在答辯的時(shí)候老師問(wèn)得最多的就是“你這里怎么處理并發(fā)”“如果很多人同時(shí)出價(jià)怎么辦”“拍賣結(jié)束瞬間沒(méi)有訂單怎么辦”。這幾個(gè)問(wèn)題回答不好代碼寫得再花哨也白搭。2.1 實(shí)時(shí)出價(jià)與并發(fā)控制出價(jià)是拍賣系統(tǒng)最核心的高頻操作。一個(gè)熱門商品在最后幾分鐘可能同時(shí)有十幾個(gè)買家在搶著出價(jià)。如果直接UPDATE auction_record SET price?不做任何保護(hù)那么兩個(gè)并發(fā)請(qǐng)求同時(shí)讀到當(dāng)前價(jià)100元都以為自己出了110元最后一個(gè)寫入的覆蓋了前一個(gè)就會(huì)造成“出價(jià)覆蓋丟失”。我在實(shí)際項(xiàng)目里處理這個(gè)問(wèn)題用過(guò)兩種方案方案一數(shù)據(jù)庫(kù)樂(lè)觀鎖推薦畢設(shè)使用這個(gè)在拍賣記錄表或商品表里維護(hù)一個(gè)version字段更新時(shí)帶上版本號(hào)UPDATE auction_item SET current_price #{newPrice}, version version 1 WHERE id #{itemId} AND version #{oldVersion}如果更新影響行數(shù)為0說(shuō)明有人搶先出價(jià)當(dāng)前的出價(jià)請(qǐng)求就提示“價(jià)格已變動(dòng)請(qǐng)重新出價(jià)”。這個(gè)方案簡(jiǎn)單、可靠不需要引入額外中間件面試/答辯時(shí)也好講清楚原理。方案二Redis分布式鎖進(jìn)階加分項(xiàng)如果項(xiàng)目里已經(jīng)集成了Redis可以用SETNX實(shí)現(xiàn)一個(gè)輕量級(jí)鎖保證同一時(shí)間只有一個(gè)出價(jià)請(qǐng)求在處理// 偽代碼 Boolean locked redisTemplate.opsForValue().setIfAbsent(lock:auction: itemId, 1, Duration.ofSeconds(3)); if (Boolean.TRUE.equals(locked)) { try { // 執(zhí)行出價(jià)邏輯 } finally { redisTemplate.delete(lock:auction: itemId); } }說(shuō)實(shí)話課設(shè)級(jí)項(xiàng)目用樂(lè)觀鎖已經(jīng)足夠Redis鎖可以作為擴(kuò)展點(diǎn)寫在文檔的“系統(tǒng)優(yōu)化”章節(jié)反而顯得你有思考深度。別在核心流程里強(qiáng)行上分布式鎖萬(wàn)一Redis忘開了整個(gè)項(xiàng)目直接起不來(lái)答辯現(xiàn)場(chǎng)會(huì)很尷尬。2.2 拍賣狀態(tài)機(jī)與訂單超時(shí)關(guān)閉拍賣商品的狀態(tài)流轉(zhuǎn)我比較推薦用一張獨(dú)立的狀態(tài)表或者枚舉類來(lái)管理不要把判斷邏輯散落在各個(gè)Service里。我通常這樣定義狀態(tài)public enum AuctionStatus { PENDING(待審核, 0), // 賣家提交待管理員審核 ONGOING(拍賣中, 1), // 審核通過(guò)買家可出價(jià) SUCCESS(已成交, 2), // 拍賣結(jié)束產(chǎn)生了中標(biāo)者 FAILED(已流拍, 3), // 拍賣結(jié)束無(wú)人出價(jià) CLOSED(已關(guān)閉, 4); // 管理員強(qiáng)制關(guān)閉或商品違規(guī)下架 }為什么要用枚舉而不是直接用數(shù)字、字符串散落在代碼里因?yàn)槊杜e把可讀性和安全性都賺到了。你在代碼里寫item.getStatus() AuctionStatus.ONGOING.getCode()別人一眼就知道你在判斷“拍賣中”不會(huì)出現(xiàn)魔法數(shù)字滿天飛的情況。訂單超時(shí)關(guān)閉這塊算是拍賣系統(tǒng)一個(gè)不大不小的坑。買家中標(biāo)后如果一直不付款訂單不能一直占著。我看過(guò)好幾種實(shí)現(xiàn)最樸素的方案是定時(shí)任務(wù)掃描Scheduled(fixedRate 60000) // 每分鐘掃描一次 public void closeExpiredOrders() { ListAuctionOrder expiredOrders orderMapper.selectExpiredUnpaidOrders(); for (AuctionOrder order : expiredOrders) { order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); // 釋放商品狀態(tài)讓商品可以重新上架 } }每分鐘掃一次表數(shù)據(jù)量小的情況下性能完全可以接受。如果你項(xiàng)目里已經(jīng)引入了RabbitMQ可以優(yōu)化為“延遲隊(duì)列”方案但這不是必選項(xiàng)??偠灾劝讯〞r(shí)任務(wù)方案跑通再談延遲隊(duì)列優(yōu)化。寫文檔時(shí)把兩種方案都寫上去但明確說(shuō)“系統(tǒng)當(dāng)前采用定時(shí)任務(wù)方案延遲隊(duì)列作為后續(xù)優(yōu)化方向”這個(gè)表述會(huì)讓答辯老師覺(jué)得你有全局視野。2.3 支付回調(diào)與冪等性設(shè)計(jì)絕大多數(shù)課設(shè)/畢設(shè)不會(huì)真的對(duì)接支付寶微信支付都是做一個(gè)“模擬支付”頁(yè)面點(diǎn)一下按鈕就把訂單狀態(tài)從“待支付”改成“已支付”。但如果你想讓項(xiàng)目有點(diǎn)含金量可以在文檔里把“支付回調(diào)的冪等性處理”這個(gè)設(shè)計(jì)思想寫清楚——即便系統(tǒng)只做了模擬支付也要把這一層抽象做好。什么叫做冪等性就是同一個(gè)支付回調(diào)請(qǐng)求你處理一次和處理一百次最終結(jié)果是一樣的。怎么實(shí)現(xiàn)最經(jīng)典的做法就是根據(jù)業(yè)務(wù)訂單號(hào)進(jìn)行去重判斷public void handlePaymentCallback(String orderNo, String payStatus) { // 先查訂單如果已經(jīng)是已支付狀態(tài)直接返回不再重復(fù)處理 AuctionOrder order orderMapper.selectByOrderNo(orderNo); if (order null || OrderStatus.PAID.equals(order.getStatus())) { return; } // 校驗(yàn)金額、更新訂單狀態(tài)、記錄支付流水 ... }這段邏輯看起來(lái)簡(jiǎn)單但就是能避免“支付成功但頁(yè)面一直轉(zhuǎn)圈重試結(jié)果訂單被重復(fù)更新”的問(wèn)題。我在實(shí)際項(xiàng)目里就遇到過(guò)因?yàn)榛卣{(diào)接口沒(méi)做冪等JMeter壓測(cè)時(shí)并發(fā)點(diǎn)了三次支付結(jié)果生成了三筆流水記錄數(shù)據(jù)直接對(duì)不上。別覺(jué)得這是小事答辯時(shí)這就是潛在扣分點(diǎn)。2.4 即時(shí)反饋與消息推送拍賣和普通商城最大的區(qū)別就是“實(shí)時(shí)性”——買家出價(jià)后其他在線買家希望立刻看到最新價(jià)格而不是手動(dòng)刷新頁(yè)面。這塊我當(dāng)時(shí)用的是WebSocket STOMP協(xié)議的方案Spring Boot原生支持很好。原理是用戶出價(jià)成功后后端通過(guò)WebSocket把一個(gè)PriceChangeMessage推送給所有關(guān)注該商品的在線用戶前端收到消息后更新當(dāng)前價(jià)格和出價(jià)記錄不需要刷新頁(yè)面。這里要注意一點(diǎn)WebSocket推送的是“價(jià)格變化通知”但真正的數(shù)據(jù)源仍然是數(shù)據(jù)庫(kù)。頁(yè)面刷新后從后端拉取的價(jià)格才是最終依據(jù)。WebSocket只是提升體驗(yàn)的手段不能替代持久化。很多同學(xué)分不清這一點(diǎn)把實(shí)時(shí)數(shù)據(jù)和持久化數(shù)據(jù)混為一談導(dǎo)致刷新頁(yè)面后價(jià)格和剛才看到的不一致這就是典型的“推送數(shù)據(jù)沒(méi)落庫(kù)”。如果你不想引入WebSocket主要是感覺(jué)配置麻煩用前端輪詢也能湊合每3秒請(qǐng)求一次商品詳情接口拿到最新價(jià)格刷新頁(yè)面。但說(shuō)實(shí)話既然前面選了Spring BootWebSocket集成真的不復(fù)雜十來(lái)行配置就能跑起來(lái)在文檔里寫出來(lái)面子也好看。后面我會(huì)給出核心配置代碼。2.5 技術(shù)棧選型清單與版本建議關(guān)于版本選擇我直接給出一套我自己驗(yàn)證過(guò)、不會(huì)因?yàn)榘姹締?wèn)題翻車的組合組件推薦版本說(shuō)明JDK1.8 或 11別用JDK 17部分老教程的依賴不支持Spring Boot2.7.x2.x系列資料最豐富別追3.xMyBatis Plus3.5.x比原生MyBatis少寫80%的CRUDMySQL5.7 或 8.0都行注意驅(qū)動(dòng)配置區(qū)別Redis5.x不是必選項(xiàng)但加分Vue / Element UIVue2 ElementUI后端為主的話這樣最省心Maven3.8常規(guī)即可這里多說(shuō)一句Spring Boot 3.x雖然已經(jīng)出很久了但網(wǎng)上大部分針對(duì)性資料、踩坑分享都是基于2.x的包括一些額外面試問(wèn)題也是基于2.x課設(shè)/畢設(shè)沒(méi)必要追趕新版本。等我把這套系統(tǒng)跑成熟了再考慮遷移到3.x那時(shí)候找資料容易得多。用2.7.x不是落后是求穩(wěn)。3. 數(shù)據(jù)庫(kù)設(shè)計(jì)與核心代碼實(shí)現(xiàn)3.1 表結(jié)構(gòu)設(shè)計(jì)思路數(shù)據(jù)庫(kù)設(shè)計(jì)是答辯老師喜歡深挖的地方我的建議是你不僅要把表建出來(lái)還要能說(shuō)清楚為什么要這樣設(shè)計(jì)。我這個(gè)項(xiàng)目數(shù)據(jù)庫(kù)一共設(shè)計(jì)了6張核心表用戶表user關(guān)鍵字段id、username、passwordBCrypt加密、phone、avatar、create_time商品拍賣表auction_item關(guān)鍵字段id、item_name、description、cover_image、start_price起拍價(jià)、current_price當(dāng)前價(jià)、min_increment最小加價(jià)幅度、start_time、end_time、seller_id、status、version這里重點(diǎn)解釋一下version字段這是樂(lè)觀鎖的實(shí)現(xiàn)基礎(chǔ)。同時(shí)把current_price和start_price分開存是為了支持“起拍價(jià)不等于首次出價(jià)”的業(yè)務(wù)場(chǎng)景——有的賣家允許0元起拍。拍賣記錄表auction_record關(guān)鍵字段id、item_id、user_id、bid_price、create_time每個(gè)買家每次出價(jià)都記錄一條數(shù)據(jù)方便后期查“誰(shuí)在什么時(shí)候出了什么價(jià)”的完整流水。這里不要只記錄“當(dāng)前最高價(jià)是誰(shuí)”因?yàn)橐坏┲槐A糇罡邇r(jià)你就丟失了歷史出價(jià)記錄后續(xù)做數(shù)據(jù)分析或者處理糾紛時(shí)會(huì)非常被動(dòng)。訂單表auction_order關(guān)鍵字段id、order_no唯一、item_id、buyer_id、seller_id、deal_price、status、pay_time、create_time、expire_timeorder_no必須唯一這是冪等設(shè)計(jì)的關(guān)鍵。支付流水表payment_record關(guān)鍵字段id、order_no、user_id、amount、pay_status、callback_time這算一張附加表但在文檔里寫“訂單與支付流水字段分離”老師會(huì)認(rèn)為你考慮到了財(cái)務(wù)數(shù)據(jù)的安全性和審計(jì)需求這點(diǎn)印象分值得拿。系統(tǒng)管理員表admin_user關(guān)鍵字段id、username、password、role3.2 關(guān)鍵實(shí)體與Mapper層實(shí)現(xiàn)以MyBatis Plus為例實(shí)體類我一般不寫復(fù)雜的自定義SQL單表CRUD全部交給MyBatis Plus內(nèi)置方法。但涉及到多表聯(lián)查的報(bào)表如“拍賣成交報(bào)表”我會(huì)寫自定義Select注解。比如查詢商品詳情連帶出價(jià)記錄的SQLSelect(SELECT r.id, r.bid_price, r.create_time, u.username FROM auction_record r LEFT JOIN user u ON r.user_id u.id WHERE r.item_id #{itemId} ORDER BY r.bid_price DESC, r.create_time ASC) ListBidRecordVO selectBidRecordsByItemId(Long itemId);這里有一個(gè)細(xì)節(jié)值得注意出價(jià)相同的情況下按時(shí)間正序排列越早到達(dá)的出價(jià)人拍得商品。這不僅僅是排序問(wèn)題更是拍賣業(yè)務(wù)中“同價(jià)先到先得”規(guī)則的體現(xiàn)。把這個(gè)規(guī)則在代碼里實(shí)現(xiàn)出來(lái)答辯時(shí)解釋起來(lái)也順理成章。3.3 出價(jià)核心邏輯事務(wù)與并發(fā)保護(hù)出價(jià)Service是整個(gè)系統(tǒng)的核心。我當(dāng)時(shí)寫的第一版出價(jià)邏輯沒(méi)有加事務(wù)測(cè)試時(shí)發(fā)現(xiàn)問(wèn)題很隱蔽——價(jià)格更新了但出價(jià)記錄沒(méi)有插入或者反過(guò)來(lái)。后來(lái)我把整個(gè)出價(jià)過(guò)程收斂到一個(gè)Transactional方法里Transactional(rollbackFor Exception.class) public BidResult placeBid(BidRequest request) { AuctionItem item auctionItemMapper.selectById(request.getItemId()); // 1. 校驗(yàn)商品狀態(tài) if (item null || item.getStatus() ! AuctionStatus.ONGOING.getCode()) { return BidResult.fail(商品不存在或不在拍賣中); } // 2. 校驗(yàn)拍賣時(shí)間 if (item.getEndTime().before(new Date())) { return BidResult.fail(拍賣已結(jié)束); } // 3. 校驗(yàn)出價(jià)是否高于當(dāng)前價(jià) if (request.getBidPrice().compareTo(item.getCurrentPrice() item.getMinIncrement()) 0) { return BidResult.fail(出價(jià)不能低于當(dāng)前價(jià) 最小加價(jià)幅度); } // 4. 樂(lè)觀鎖更新當(dāng)前價(jià) int updated auctionItemMapper.updatePriceByVersion( item.getId(), request.getBidPrice(), item.getVersion()); if (updated 0) { return BidResult.fail(價(jià)格已變動(dòng)請(qǐng)重新出價(jià)); } // 5. 記錄出價(jià)流水這里作為平級(jí)信息記錄 AuctionRecord record new AuctionRecord(); record.setItemId(item.getId()); record.setUserId(request.getUserId()); record.setBidPrice(request.getBidPrice()); auctionRecordMapper.insert(record); // 6. 推送實(shí)時(shí)價(jià)格給前端 webSocketService.pushPriceChange(item.getId(), request.getBidPrice()); return BidResult.success(); }這段代碼里有個(gè)非常容易被忽略的點(diǎn)校驗(yàn)“出價(jià)高于當(dāng)前價(jià)”時(shí)輸入價(jià)格與當(dāng)前價(jià)格大小比較要使用BigDecimal的compareTo而不是equals。因?yàn)閚ew BigDecimal(100.0).equals(new BigDecimal(100.00))會(huì)返回false但compareTo則會(huì)正確返回0按label理解就是數(shù)學(xué)上的相等。這個(gè)問(wèn)題真的很細(xì)但踩過(guò)坑的人一定懂。3.4 WebSocket實(shí)時(shí)推送配置與代碼WebSocket在Spring Boot里的集成核心就三塊配置類、處理器/服務(wù)類、前端JS。配置文件application.ymlserver: port: 8080 spring: application: name: auction-system datasource: url: jdbc:mysql://localhost:3306/auction_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 redis: host: localhost port: 6379WebSocket配置類Configuration EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { Override public void configureMessageBroker(MessageBrokerRegistry registry) { // 客戶端訂閱前綴/topic/price/{itemId} registry.enableSimpleBroker(/topic); // 客戶端發(fā)送前綴/app/auction registry.setApplicationDestinationPrefixes(/app); } Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint(/ws-auction).withSockJS(); } }前端核心JS片段var socket new SockJS(/ws-auction); var stompClient Stomp.over(socket); stompClient.connect({}, function(frame) { stompClient.subscribe(/topic/price/ itemId, function(response) { var data JSON.parse(response.body); $(#currentPrice).text( data.currentPrice); }); });這里我想特別提一個(gè)現(xiàn)象很多人的WebSocket本地跑得好好的部署到云服務(wù)器之后連不上排查半天發(fā)現(xiàn)是云服務(wù)器安全組沒(méi)放行端口或者Nginx沒(méi)配置WebSocket代理升級(jí)。這個(gè)其實(shí)不是代碼問(wèn)題但如果你部署演示它就會(huì)變成拖延你進(jìn)度的真問(wèn)題。我的經(jīng)驗(yàn)是課設(shè)/畢設(shè)階段優(yōu)先本地演示別急著上服務(wù)器省得給自己多找一堆麻煩。3.5 定時(shí)任務(wù)與在線狀態(tài)檢查關(guān)單定時(shí)任務(wù)的核心不只是把訂單狀態(tài)改成已關(guān)閉還要聯(lián)動(dòng)把商品狀態(tài)改回可上架的狀態(tài)否則買家一直不付款商品就永久鎖死在那里了。我在第一版代碼里就漏了這一步確認(rèn)買家超時(shí)未付款后商品還一直顯示“已成交”導(dǎo)致賣家完全沒(méi)法重新上架商品。修正后的邏輯Scheduled(cron 0 * * * * ?) // 每分鐘觸發(fā)一次 public void processExpiredOrders() { ListAuctionOrder expiredOrders orderMapper.selectExpiredUnpaidOrders(); expiredOrders.forEach(order - { // 1. 訂單關(guān)閉 order.setStatus(OrderStatus.CLOSED); orderMapper.updateById(order); // 2. 商品狀態(tài)恢復(fù)從“已成交”改為“拍賣中”或“待審核” AuctionItem item auctionItemMapper.selectById(order.getItemId()); if (item ! null) { item.setStatus(AuctionStatus.ONGOING.getCode()); // 同時(shí)重置起拍價(jià)還是沿用上次成交價(jià)這個(gè)需要業(yè)務(wù)決策 auctionItemMapper.updateById(item); } }); }這個(gè)聯(lián)動(dòng)邏輯反映了一個(gè)真實(shí)的業(yè)務(wù)模型拍賣訂單和商品狀態(tài)是強(qiáng)綁定關(guān)系絕不能只關(guān)一個(gè)。課程設(shè)計(jì)雖然不用真實(shí)現(xiàn)什么支付退款/保證金退還這類復(fù)雜邏輯但狀態(tài)聯(lián)動(dòng)做不好演示數(shù)據(jù)一多就會(huì)露出破綻。我建議你在文檔的“業(yè)務(wù)規(guī)則說(shuō)明”里專門寫一段講清楚“超時(shí)關(guān)單后商品狀態(tài)如何流轉(zhuǎn)”這個(gè)小細(xì)節(jié)很容易成為答辯的加分項(xiàng)。4. 業(yè)務(wù)擴(kuò)展點(diǎn)與性能優(yōu)化思路4.1 拍賣漏斗中的時(shí)間輪思想與延期機(jī)制在線拍賣行業(yè)有個(gè)很常見(jiàn)的騷操作最后幾秒大家瘋狂出價(jià)等交易真正塵埃落定可能已經(jīng)比原定截止時(shí)間晚了十幾分鐘。對(duì)應(yīng)到真實(shí)系統(tǒng)在商品到達(dá)截止時(shí)間時(shí)會(huì)判斷如果最后1分鐘內(nèi)有出價(jià)記錄拍賣自動(dòng)延期5分鐘給其他買家留出繼續(xù)競(jìng)價(jià)的空間。這個(gè)機(jī)制學(xué)名叫“自動(dòng)延時(shí)”閑魚拍賣就是這么干的。實(shí)現(xiàn)思路也不復(fù)雜在出價(jià)成功后的代碼里加一段判斷// 如果當(dāng)前時(shí)間距離截止時(shí)間小于1分鐘自動(dòng)延長(zhǎng)5分鐘 if (item.getEndTime().getTime() - System.currentTimeMillis() 60_000) { item.setEndTime(new Date(item.getEndTime().getTime() 5 * 60_000)); auctionItemMapper.updateById(item); }這個(gè)機(jī)制不光是業(yè)務(wù)加分項(xiàng)也是文檔里一個(gè)“你比別人多想了一步”的證明。從底層原理上說(shuō)它跟“時(shí)間輪算法”的延遲任務(wù)調(diào)度是一路的只是當(dāng)前實(shí)現(xiàn)簡(jiǎn)易化足以支撐單機(jī)場(chǎng)景。4.2 定時(shí)任務(wù)與延遲消息的取舍前面提過(guò)延遲隊(duì)列RabbitMQ可以作為定時(shí)掃描的優(yōu)化替代。兩者差別在哪里定時(shí)掃描是“輪詢”思路不管有沒(méi)有到期的訂單到點(diǎn)了就全表掃一遍發(fā)現(xiàn)過(guò)期再處理。適合低頻海量任務(wù)但對(duì)數(shù)據(jù)庫(kù)壓力稍大。延遲隊(duì)列是“事件驅(qū)動(dòng)”思路訂單創(chuàng)建時(shí)就設(shè)置一個(gè)延遲消息到達(dá)設(shè)定時(shí)間后MQ自動(dòng)把消息推給消費(fèi)者處理精準(zhǔn)、實(shí)時(shí)但需要引入中間件。對(duì)于學(xué)生項(xiàng)目我仍然建議用定時(shí)任務(wù)。但你在面試或答辯時(shí)可以說(shuō)“當(dāng)前的實(shí)現(xiàn)是每分鐘掃描一次能滿足業(yè)務(wù)規(guī)模如果未來(lái)用戶量上來(lái)了可以替換為RabbitMQ延遲隊(duì)列將訂單關(guān)閉的精準(zhǔn)度提升到秒級(jí)。”這句話不用實(shí)現(xiàn)但體現(xiàn)了你對(duì)方案演進(jìn)路徑的認(rèn)知老師會(huì)認(rèn)可的。4.3 緩存策略什么該緩存什么不該緩存很多同學(xué)學(xué)了Redis就喜歡什么數(shù)據(jù)都往里面放把商品列表、用戶信息、出價(jià)記錄全塞進(jìn)去結(jié)果緩存一致性搞不定數(shù)據(jù)飄忽不定老師一查就覺(jué)得你基礎(chǔ)不扎實(shí)。拍賣系統(tǒng)里適合緩存的數(shù)據(jù)其實(shí)很有限核心就是兩類熱門商品詳情短時(shí)間內(nèi)容價(jià)格變化頻繁但讀多寫少相對(duì)而言緩存可以減少數(shù)據(jù)庫(kù)壓力。拍賣倒計(jì)時(shí)剩余時(shí)間精度要求不高可以緩存幾秒。不推薦緩存的數(shù)據(jù)出價(jià)記錄流水、訂單數(shù)據(jù)、支付信息。這些數(shù)據(jù)強(qiáng)一致要求高一秒鐘都不能錯(cuò)。拍賣系統(tǒng)里準(zhǔn)確遠(yuǎn)比快重要——你緩存了訂單數(shù)據(jù)支付回調(diào)一來(lái)緩存和數(shù)據(jù)庫(kù)狀態(tài)不一致那就是事故。緩存更新策略用“刪除緩存”而不是“更新緩存”這點(diǎn)值得寫進(jìn)文檔。更新緩存在并發(fā)場(chǎng)景下容易產(chǎn)生中間態(tài)數(shù)據(jù)刪除緩存則可以讓下一次讀取自然回源簡(jiǎn)單可靠。4.4 前后端交互普通接口與WebSocket的邊界有些同學(xué)把自己繞暈了既然有了WebSocket那商品詳情、出價(jià)記錄這些接口是不是都可以用WebSocket推其實(shí)不然。WebSocket適合的是“服務(wù)器主動(dòng)推送”的場(chǎng)景——有人出價(jià)了、拍賣結(jié)束了、你還剩30秒了這類消息不推給用戶用戶根本不知道。而普通HTTP接口適合“用戶主動(dòng)請(qǐng)求”的場(chǎng)景——查看商品列表、查看我的歷史出價(jià)記錄、后臺(tái)數(shù)據(jù)看板用戶沒(méi)主動(dòng)觸發(fā)服務(wù)器沒(méi)必要推。邊界清楚了代碼結(jié)構(gòu)也就自然清晰了。我項(xiàng)目里的做法是HTTP接口管數(shù)據(jù)CRUDWebSocket只管兩件事價(jià)格變動(dòng)推送、拍賣結(jié)束推送。這樣分工明確出了問(wèn)題也容易排查。5. 源碼與文檔如何配合最容易被低估的工作量5.1 獲取源碼后如何快速跑通整個(gè)項(xiàng)目如果讀者是拿這套“源碼文檔”來(lái)學(xué)習(xí)的拿到手第一件事別急著看代碼先按文檔里的環(huán)境要求把JDK、Maven、MySQL、Redis如果用到裝齊然后把數(shù)據(jù)庫(kù)腳本導(dǎo)入。啟動(dòng)順序有講究先啟動(dòng)Redis和MySQL再啟動(dòng)Spring Boot應(yīng)用最后啟動(dòng)前端如果是前后端分離。我見(jiàn)過(guò)太多次“明明代碼沒(méi)問(wèn)題就是起不來(lái)”的情況90%是端口被占用或者數(shù)據(jù)庫(kù)連接串沒(méi)改。切記application.yml里的數(shù)據(jù)庫(kù)密碼、端口號(hào)一定是先改成本地環(huán)境的不要直接拿著壓縮包里的配置去跑。建議跑通的路徑是登錄/注冊(cè) - 發(fā)布一件商品 - 管理員審核通過(guò) - 用另一個(gè)賬號(hào)出價(jià) - 看到價(jià)格實(shí)時(shí)刷新 - 等拍賣結(jié)束生成訂單。這條主鏈路完整走通項(xiàng)目至少成功了七成。5.2 文檔結(jié)構(gòu)如何組織才像模像樣一套能拿到高分的文檔我推薦目錄結(jié)構(gòu)是緒論研究背景、國(guó)內(nèi)外現(xiàn)狀、研究意義需求分析用例圖、系統(tǒng)功能需求、非功能需求系統(tǒng)設(shè)計(jì)架構(gòu)圖、功能模塊設(shè)計(jì)、數(shù)據(jù)庫(kù)設(shè)計(jì)系統(tǒng)實(shí)現(xiàn)核心功能界面截圖、核心代碼講解系統(tǒng)測(cè)試功能測(cè)試用例表、性能測(cè)試結(jié)果總結(jié)與展望不足與改進(jìn)方向這里有個(gè)經(jīng)驗(yàn)心得圖和表比字重要。老師翻你的文檔第一眼看的絕對(duì)不是正文而是ER圖、流程圖、用例圖和表格。圖多、圖清晰印象分至少漲一檔。盡量別用手畫的草稿圖我也知道畫圖費(fèi)時(shí)間但哪怕用ProcessOn畫個(gè)簡(jiǎn)單的架構(gòu)圖也比純文字好一百倍。5.3 答辯時(shí)的常見(jiàn)提問(wèn)與回答策略答辯老師最常問(wèn)的幾個(gè)點(diǎn)我提前幫你梳理了“為什么用樂(lè)觀鎖”回答思路出價(jià)是高頻操作樂(lè)觀鎖不加鎖不阻塞只在更新時(shí)檢驗(yàn)版本號(hào)沖突時(shí)讓用戶重試適合讀多寫多的場(chǎng)景?!芭馁u結(jié)束的瞬間你在哪里判斷的”回答思路出價(jià)方法里校驗(yàn)當(dāng)前時(shí)間超過(guò)endTime則拒絕同時(shí)定時(shí)任務(wù)掃描已到期商品進(jìn)行結(jié)算。“如果兩個(gè)買家同時(shí)出相同價(jià)格怎么辦”回答思路先到先得時(shí)間戳一致時(shí)按ID順序。“你的系統(tǒng)有什么可以改進(jìn)的地方”回答思路把定時(shí)任務(wù)掃描替換為延遲隊(duì)列、引入消息隊(duì)列削峰、服務(wù)端增加緩存別只說(shuō)“沒(méi)有”也別說(shuō)得太虛。如果這些問(wèn)題你都能在項(xiàng)目里找到對(duì)應(yīng)代碼位置當(dāng)場(chǎng)打開IDE指給老師看那答辯基本就穩(wěn)了。6. 常見(jiàn)問(wèn)題排查與避坑清單6.1 啟動(dòng)階段的坑我把自己和身邊人在這套系統(tǒng)上踩過(guò)的最典型問(wèn)題整理成一張速查表報(bào)錯(cuò)/問(wèn)題現(xiàn)象可能原因解決辦法Port 8080 was already in use端口被占用換端口或殺掉占用進(jìn)程Access denied for user rootlocalhost數(shù)據(jù)庫(kù)密碼不對(duì)核對(duì)application.yml配置Unknown database auction_db沒(méi)建庫(kù)或庫(kù)名不一致先執(zhí)行建庫(kù)SQL確保大小寫一致Field xxx doesnt have a default value數(shù)據(jù)庫(kù)字段非空限制檢查INSERT語(yǔ)句缺失的字段前端頁(yè)面樣式加載不出靜態(tài)資源路徑問(wèn)題檢查項(xiàng)目里static資源目錄位置Redis連接失敗Redis沒(méi)啟動(dòng)或密碼不對(duì)先啟動(dòng)Redis確認(rèn)配置的密碼為空或正確啟動(dòng)階段還有一個(gè)很典型的坑Maven依賴下載緩慢甚至失敗。國(guó)內(nèi)用戶建議把Maven鏡像換成阿里云鏡像倉(cāng)庫(kù)在settings.xml里配置mirror節(jié)點(diǎn)即可。這一步不做你的第一次構(gòu)建可能要卡半小時(shí)。6.2 業(yè)務(wù)邏輯層的隱蔽問(wèn)題除了啟動(dòng)業(yè)務(wù)邏輯里的坑更加隱蔽。例如出價(jià)成功后WebSocket推送的NPE空指針問(wèn)題——出價(jià)成功但WebSocket推送拋異常導(dǎo)致事務(wù)回滾明明價(jià)格已更新卻提示出價(jià)失敗。我在代碼里對(duì)推送操作做了try-catch但讓“推送異常不能影響業(yè)務(wù)主流程”這個(gè)原則更清晰的做法是把推送邏輯放到事務(wù)之外或者用事件監(jiān)聽(tīng)器異步處理。否則你無(wú)法向老師解釋清楚一個(gè)通知功能憑什么會(huì)讓出價(jià)失敗。再比如金額計(jì)算數(shù)據(jù)庫(kù)里金額字段用DECIMAL(10,2)代碼里對(duì)應(yīng)使用BigDecimal這個(gè)必須養(yǎng)成習(xí)慣。用double去算價(jià)格0.10.2不等于0.3這類問(wèn)題不用我多說(shuō)在拍賣系統(tǒng)這個(gè)對(duì)金額敏感的場(chǎng)景里屬于致命傷。6.3 性能與測(cè)試層面的坑寫測(cè)試用例時(shí)有同學(xué)用JMeter模擬100個(gè)并發(fā)請(qǐng)求結(jié)果發(fā)現(xiàn)很多出價(jià)失敗就懷疑樂(lè)觀鎖有問(wèn)題。其實(shí)這不是代碼bug反而是樂(lè)觀鎖在正常工作——同一秒內(nèi)100個(gè)請(qǐng)求爭(zhēng)搶同一版本號(hào)99個(gè)失敗是預(yù)期行為。測(cè)試時(shí)的正確做法是在請(qǐng)求中加一點(diǎn)隨機(jī)延遲模擬真實(shí)用戶的思考時(shí)間這樣測(cè)出來(lái)的才是真實(shí)表現(xiàn)。如果你想讓并發(fā)測(cè)試結(jié)果好看一點(diǎn)可以把min_increment設(shè)大一些減少無(wú)意義的纏斗或者在測(cè)試前把商品初始價(jià)調(diào)高這樣大家出價(jià)的意愿不至于那么密集。測(cè)試本來(lái)就是為了驗(yàn)證邏輯不是為了制造焦慮。6.4 代碼倉(cāng)庫(kù)與提交規(guī)范最后提一個(gè)看起來(lái)無(wú)關(guān)緊要但實(shí)際很影響體驗(yàn)的點(diǎn)如果你用Git管理代碼提交信息別寫“111”“aaa”“更新”這樣的內(nèi)容養(yǎng)成寫清楚“feat: 增加出價(jià)功能”“fix: 修復(fù)超時(shí)關(guān)單邏輯”的習(xí)慣。這不僅方便隊(duì)友協(xié)作更重要的是查歷史時(shí)能快速定位改動(dòng)。我見(jiàn)過(guò)把提交信息寫成“啊”的同學(xué)后來(lái)他自己都不知道哪條提交包含了關(guān)鍵修復(fù)。7. 這套項(xiàng)目的完整思考與我的個(gè)人建議說(shuō)實(shí)話在線拍賣系統(tǒng)在畢設(shè)里算是“安全牌”——技術(shù)棧主流、業(yè)務(wù)邏輯有深度、可擴(kuò)展空間大答辯時(shí)甭管老師懂不懂WebSocket你都能拿實(shí)際運(yùn)行效果說(shuō)話。我在實(shí)際帶項(xiàng)目過(guò)程中最大的體會(huì)是別把“拍賣”理解成一個(gè)普通商城。拍賣的核心在于“時(shí)間窗”和“競(jìng)爭(zhēng)出價(jià)”普通商城是死數(shù)據(jù)拍賣系統(tǒng)是動(dòng)態(tài)數(shù)據(jù)兩者的系統(tǒng)設(shè)計(jì)重點(diǎn)完全不同。很多同學(xué)把拍賣系統(tǒng)做成“帶出價(jià)功能的商城”本質(zhì)上還是CRUD那你就沒(méi)體現(xiàn)出拍賣系統(tǒng)的靈魂。如果你決定做這個(gè)題目我的建議是分三步走第一周把用戶和商品模塊跑通第二周死磕出價(jià)與訂單狀態(tài)機(jī)第三周補(bǔ)WebSocket實(shí)時(shí)推送和定時(shí)任務(wù)收尾。代碼量不用貪多核心邏輯能跑通、能自圓其說(shuō)文檔配合圖給足就已經(jīng)是一套拿得出手的項(xiàng)目了。最后分享一個(gè)很多人忽略的小技巧開發(fā)過(guò)程中遇到難以復(fù)現(xiàn)的bug別急著改代碼先錄屏記錄操作步驟再打開瀏覽器控制臺(tái)看報(bào)錯(cuò)。前端的問(wèn)題80%能從控制臺(tái)找到線索后端的問(wèn)題80%能從日志文件找到線索。養(yǎng)成這個(gè)排查習(xí)慣你會(huì)發(fā)現(xiàn)“莫名其妙”的bug其實(shí)都有跡可循。這套拍賣系統(tǒng)我前前后后改過(guò)三輪每一步深挖都靠的是這套方法。祝大家開發(fā)順利答辯順利。