實戰(zhàn):排期建模與并發(fā)搶場方案)
去年幫一位做球館運營的朋友落地了一套預約管理系統(tǒng)前端是微信小程序后端是Java整體跑通之后我才敢說這類看似很簡單的預約系統(tǒng)真正做起來全是細節(jié)。用戶選場地、選時間、下單支付、管理員核銷聽起來就是四個步驟但背后涉及場地與時段的多維度建模、并發(fā)訂場下的數(shù)據(jù)一致性、微信登錄與支付的接入、小程序端各種設(shè)備適配任何一個環(huán)節(jié)沒想清楚后面返工的成本都極高。這篇文章不是課程式教學而是把我從零搭建這套Java基于微信小程序的球館預約系統(tǒng)的完整思路、關(guān)鍵設(shè)計、踩坑記錄都寫出來配上源碼和文檔說明希望能給正在做同類項目的朋友省點時間。1. 球館預約系統(tǒng)的業(yè)務(wù)本質(zhì)與整體架構(gòu)1.1 先把場地時段的二維模型想清楚預約類系統(tǒng)的核心從來不是下單這個動作而是排期數(shù)據(jù)怎么組織。球館預約和普通電商下單最大的區(qū)別在于商品是某個場地在某個時間段的使用權(quán)這是一個典型的二維組合模型。舉個例子一個球館有羽毛球場地A、B、C三片每天營業(yè)時間是9點到22點以1小時為最小預約單位。那么一天可售的商品就有 3片場地 × 13個時段 39個。一周就是273個。如果最小預約單位改成半小時這個數(shù)字直接翻倍。我見過很多初版設(shè)計把時段寫死成早中晚三個字段結(jié)果用戶要定晚上8點半到9點半系統(tǒng)直接改不了——這就是模型設(shè)計不到位。正確的做法是拆兩張核心表一張是場地表記錄場地基礎(chǔ)信息一張是排期表記錄某場地某天的某時段是否可約??捎脮r段在后臺提前生成用戶端只負責查和選。這樣的模型好處很多可以單獨對某個場地的某個時段做停售比如場地A在周三下午做維護、可以動態(tài)調(diào)整價格黃金時段加價、可以輕松支持不同的預約粒度。我在文檔說明里畫的ER圖也是按這個思路來的建議所有做預約類系統(tǒng)的朋友先別急著寫代碼把這張二維組合模型想明白后面所有功能都是在這個骨架上長出來的。1.2 技術(shù)選型思路為什么是Spring Boot 原生微信小程序后端選型我用了Spring Boot MyBatis-Plus Redis MySQL前端用微信小程序原生語法。這套組合不算新潮但非常適合中小型球館預約項目。Spring Boot生態(tài)成熟招人容易社區(qū)資料多。MyBatis-Plus做單表CRUD效率極高尤其是分頁查詢、條件構(gòu)造器寫起來比手寫XML SQL痛快太多。Redis在這里有兩個職責一是緩存熱點數(shù)據(jù)比如未來兩天的場地余量二是做分布式鎖防止并發(fā)搶場。MySQL存核心業(yè)務(wù)數(shù)據(jù)排期表日增量不大壓力完全在可控范圍。小程序端我沒有選uni-app或者Taro直接用原生。原因是這個項目頁面量不大原生語法在微信開發(fā)者工具里的調(diào)試體驗最穩(wěn)定而且很多隱藏坑比如導航欄高度適配、分享參數(shù)傳遞原生處理起來最直接??缍丝蚣苓m合大型多端項目但對球館預約這種只服務(wù)微信用戶的場景原生是性價比最高的選擇。1.3 目錄結(jié)構(gòu)與數(shù)據(jù)表設(shè)計項目采用經(jīng)典的分層結(jié)構(gòu)controller、service、mapper、entity、config、common統(tǒng)一返回體和異常處理。我習慣在common里放一個Result類所有接口統(tǒng)一返回 code message data小程序端做統(tǒng)一攔截。這樣可以避免每個接口返回結(jié)構(gòu)不一致前端解析起來像拆盲盒。數(shù)據(jù)表方面核心是下面這幾張表名用途關(guān)鍵字段venue場館信息area_type、opening_time、closing_time、addresscourt場地信息venue_id、name、statuscourt_schedule排期表court_id、date、start_time、end_time、price、statususer用戶表openid、nickname、phoneorder訂單表order_no、user_id、schedule_id、amount、status、pay_timerefund_record退款記錄order_no、refund_amount、reason、status排期表一定要加聯(lián)合唯一索引比如 (court_id, date, start_time)這是防止重復生成排期的底層保障。訂單表的 status 建議用 TinyInt 存數(shù)字狀態(tài)碼不要用字符串省空間也方便比較。這里多說一句order_no 生成不要用自增ID用時間戳隨機數(shù)拼一個20位以內(nèi)的業(yè)務(wù)單號微信支付回調(diào)時要用它做冪等匹配格式穩(wěn)定很重要。2. Java后端最容易翻車的兩個點時段建檔與并發(fā)訂場2.1 周循環(huán)排期用任務(wù)自動生成一個周期內(nèi)的場地狀態(tài)排期數(shù)據(jù)不是人工一條條錄進去的而是需要一個生成機制。我的做法是系統(tǒng)定義一個定時任務(wù)每天凌晨自動生成未來一周的排期數(shù)據(jù)。比如今天是周一任務(wù)會把下周一之前的所有場地時段補充完整。如果某天場地需要維護后臺管理員可以單獨把那條排期標記為不可約不需要改生成邏輯。生成邏輯的核心是嵌套循環(huán)先遍歷場地再遍歷營業(yè)時段逐條插入排期表。插入前先檢查這條排期是否已存在存在就跳過。這個先查再插的操作有并發(fā)風險多個定時任務(wù)實例同時跑所以我在排期表上建了上文提到的聯(lián)合唯一索引真出現(xiàn)重復插入會被數(shù)據(jù)庫直接擋掉比在代碼里加鎖簡單可靠得多。還有一個小細節(jié)跨天問題。球館如果營業(yè)到凌晨2點那么23:00-00:00這個時段到底屬于哪一天我統(tǒng)一按開始時間所在日期歸屬時段跨天就把結(jié)束時間直接寫成第二天的日期時間排期表里的date字段只表示這是哪一天開放的場次。這個規(guī)則一定要在文檔說明里寫清楚不然前端展示周五晚場的時候很容易錯位。2.2 并發(fā)下黃金時段被搶的問題鎖不是越重越好球館預約最經(jīng)典的并發(fā)場景是晚上7點到9點的黃金時段同一片場地兩個人同時點擊預約。如果代碼是先查余量再扣減那在高并發(fā)下必然出現(xiàn)超賣——兩個人都查到了余量都下單成功。我當時設(shè)計了三種可選方案實際選的是第二種第一數(shù)據(jù)庫樂觀鎖在排期表加一個version字段更新時帶上version條件。好處是無鎖開銷壞處是沖突的時候用戶直接報錯體驗比較生硬。第二Redis分布式鎖 事務(wù)以 schedule_id 為鎖鍵用戶請求時先搶鎖搶到后走下單事務(wù)事務(wù)提交后再釋放鎖。這樣可以保證同一片場地同一個時段同時只有一個請求在操作其他請求等待或快速失敗。我選這個方案是因為它既保證了數(shù)據(jù)一致性又不會像樂觀鎖那樣讓大量請求直接失敗。第三數(shù)據(jù)庫悲觀鎖SELECT ... FOR UPDATE實現(xiàn)最簡單但對數(shù)據(jù)庫連接占用比較久并發(fā)高了容易拖垮連接池。這里有一個很多新手容易踩的坑Redis鎖釋放時機。不能剛執(zhí)行完扣庫存就釋放鎖事務(wù)還沒提交的話另一個請求拿到鎖讀到的還是舊數(shù)據(jù)。我當時把鎖的范圍包裹到整個事務(wù)方法外層用注解方式做了個簡單的鎖切面確保事務(wù)提交后才釋放鎖。代碼大致是下面這個結(jié)構(gòu)public OrderResult createOrder(Long scheduleId, Long userId) { String lockKey court:schedule:lock: scheduleId; boolean locked redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS); if (!locked) { return OrderResult.fail(手速太快了換個時段試試); } try { return orderService.createOrder(scheduleId, userId); } finally { redisLock.unlock(lockKey); } }鎖的超時時間要結(jié)合實際業(yè)務(wù)耗時設(shè)置我用5秒是因為下單事務(wù)里包含了微信支付預下單請求最慢也就兩三秒。設(shè)置太短會鎖失效太長會阻塞正常請求。這個參數(shù)沒有銀彈壓測之后再定最靠譜。2.3 微信登錄與會話保持code2Session那點事小程序端調(diào)用 wx.login() 拿到臨時 code后端拿這個 code 請求微信接口換取 openid 和 session_key。這里有幾個容易踩的坑我得說清楚。首先code 有效期只有5分鐘而且只能用一次。如果前端換了 code 后請求失敗重試就必須重新 wx.login()否則后端一查就報invalid code。其次session_key 不要自己存數(shù)據(jù)庫。它是微信側(cè)的會話密鑰有效期不固定而且官方建議敏感操作時才去解密。我們自己的系統(tǒng)要維護登錄態(tài)正確做法是拿到 openid 后查用戶表存在就當老用戶處理不存在就自動注冊然后自己生成一個業(yè)務(wù) token比如UUID或者JWT返回給小程序。小程序后續(xù)請求帶這個 token后端從 token 解析出 userId。token 我建議存 Redis 并設(shè)置過期時間比如7天。每次請求通過攔截器校驗 token 有效性順便用 Redis 的過期機制實現(xiàn)無感續(xù)期。如果 token 固定不過期用戶體驗是省事了但安全性差很多尤其是球館預約涉及支付訂單找回密碼這種功能雖然用不上但賬號被盜的后果還是得防。3. 小程序端的預約體驗從場館列表到支付成功3.1 場館列表的加載更多分頁要這么做才不卡小程序端首頁是場館列表數(shù)據(jù)量不大但如果直接用 wx.request 一次性拉全量數(shù)據(jù)網(wǎng)絡(luò)差的時候會白屏很久而且用戶滑動的時候沒有任何過渡。這里我用的是經(jīng)典的分頁加載 觸底加載更多模式。分頁參數(shù)我習慣用 pageNum 和 pageSize后端用 MyBatis-Plus 的 Page 插件一行代碼就能拿到分頁結(jié)果。小程序端維護三個核心變量pageNum、pageSize、hasMore。每次滾動到底部觸發(fā) onReachBottompageNum 加一把新數(shù)據(jù) concat 到舊數(shù)據(jù)后面。hasMore 的判斷標準是本次返回的數(shù)據(jù)條數(shù)是否等于 pageSize如果小于說明沒有更多了。加載更多還有一個體驗細節(jié)請求發(fā)出后要加一個 loading 狀態(tài)標記防止用戶快速滑動觸發(fā)多次重復請求。用 boolean 變量 isLoading 控制請求開始置 true請求結(jié)束后置 false只有 isLoading 為 false 時才允許發(fā)起下一次請求。onReachBottom() { if (this.data.isLoading || !this.data.hasMore) return; this.setData({ isLoading: true, pageNum: this.data.pageNum 1 }); this.fetchVenueList(); }另外微信小程序在 onPullDownRefresh 下拉刷新時要調(diào)用 wx.stopPullDownRefresh()不然刷新動畫會一直轉(zhuǎn)。這個 API 很容易被忽略我在文檔里也單獨標出來了。3.2 場地選擇與時段面板的狀態(tài)控制預約頁是整個小程序交互最復雜的部分用戶先選日期再選場地再選時段然后看到價格。這個頁面的核心是一場狀態(tài)聯(lián)動。我采用了三段式布局頂部日期橫滑條中間場地 Tab底部時段網(wǎng)格。選日期的時候場地 Tab 和時段網(wǎng)格全部重置選場地的時候時段網(wǎng)格重新請求該場地該日期的排期數(shù)據(jù)。這里要注意不要每次切換都發(fā)請求最好做一層前端緩存比如按 courtId date 作為 key把排期數(shù)據(jù)存在內(nèi)存變量里切換回來直接讀緩存。考慮到小程序 setData 的性能瓶頸對于頻繁切換的交互緩存策略能明顯降低卡頓感。時段網(wǎng)格的每個格子根據(jù)排期數(shù)據(jù)有三種狀態(tài)可約、已被約、停售。已被約的格子置灰并顯示已約停售的顯示維護中只有可約狀態(tài)可點擊。點擊后高亮當前選擇同時把價格顯示到頁面底部按鈕上。價格這里要由后端返回前端不要自己做計算否則后臺改價格規(guī)則后小程序端會顯示舊價格。日期橫滑條需要注意日期跨月和今天不能約過去的時段這兩個邊界。我的處理是日期列表由前端生成未來7天當天之前不可選如果選的是今天后端查詢排期時會把開始時間早于當前時間的時段直接標記為不可約。這個規(guī)則前后端都要做后端是必須的前端是為了體驗。3.3 支付前后的狀態(tài)機切換訂單狀態(tài)我用狀態(tài)機管理這一點強烈建議在文檔說明里畫一張狀態(tài)圖。整個狀態(tài)流轉(zhuǎn)是待支付 → 已支付 → 已核銷以及 待支付 → 已取消已支付 → 已退款。用戶在預約頁提交訂單后后端創(chuàng)建訂單并返回訂單號狀態(tài)是待支付同時調(diào)微信支付統(tǒng)一下單接口拿到支付參數(shù)返回給小程序。小程序收到后用 wx.requestPayment 拉起支付面板。這里有個非常容易出錯的地方支付成功后要等回調(diào)更新狀態(tài)而不是前端直接跳轉(zhuǎn)。微信支付的回調(diào)是異步的前端 wx.requestPayment 的 success 只代表用戶輸入密碼完成了支付但后端還沒收到支付結(jié)果通知。如果這時候用戶立刻關(guān)掉小程序回調(diào)可能還沒到數(shù)據(jù)庫里的訂單還是待支付。穩(wěn)妥的做法是前端支付成功后就跳轉(zhuǎn)到支付結(jié)果確認中頁面同時輪詢訂單狀態(tài)接口每1.5秒查一次直到訂單狀態(tài)變?yōu)橐阎Ц痘蛞淹丝畈盘厥醉摗]喸兊拇a要控制次數(shù)比如最多查10次超時后提示用戶支付結(jié)果確認中請稍后在訂單列表查看。這個交互雖然笨一點但能避免大量支付了但訂單沒更新的客訴。支付回調(diào)接口一定要做冪等處理。微信可能因為網(wǎng)絡(luò)原因重復推送回調(diào)后端要根據(jù)訂單號判斷如果已經(jīng)是已支付就立刻返回 success不再做任何修改。我之前見過一個項目沒做冪等重復回調(diào)把訂單金額累加了兩遍對賬的時候賬都平不上。4. 管理后臺與訂單流轉(zhuǎn)核銷、退款與數(shù)據(jù)統(tǒng)計4.1 訂單列表與核銷流程給前臺一套高效的驗票工具后臺管理端我用的是獨立的Web項目前端是Vue Element UI后端和預約小程序共用同一套Java服務(wù)。管理端的作用不只是看數(shù)據(jù)更重要的是處理線下場景。核銷是球館最常用的操作用戶到場后打開小程序出示核銷碼前臺在管理端輸入或者掃碼完成核銷。核銷碼我用了動態(tài)碼方案訂單支付成功后生成一個6位數(shù)字碼過期時間是訂單日期的次日凌晨。前臺也可以按手機號查訂單再手動核銷兩條路徑都做了兜底。核銷操作背后要校驗幾件事訂單狀態(tài)必須是已支付、核銷碼和訂單號匹配、訂單日期是今天。這三條缺一條都不能放行。核銷完成后訂單狀態(tài)變?yōu)橐押虽N不可逆。我做了一個二次確認彈窗前臺一旦誤操作可以少一點損失。這里實際踩過的坑是核銷碼里容易混入容易混淆的字符比如0和O、1和I生成時必須要剔除或者用純數(shù)字。管理端還支持手動退款操作。退款調(diào)用微信支付退款接口退完更新訂單狀態(tài)。因為涉及資金操作我加了一級管理員審批權(quán)限普通前臺只能發(fā)起退款申請老板在更高權(quán)限賬號上審批。審批通過后才真正調(diào)退款接口。這種設(shè)計不是為了復雜而是球館這種實體店退款糾紛特別容易發(fā)生在前臺操作不規(guī)范上權(quán)限拉開一層能省掉很多麻煩。4.2 運營看板場館管理者真正關(guān)心的是這幾個數(shù)管理后臺首頁我做了一個簡單的數(shù)據(jù)看板不要搞花里胡哨的圖表最核心的是幾個指標今日營收、今日訂單數(shù)、今日核銷數(shù)、未來7天預約量趨勢、各場地的預約熱度。未來7天預約量是我特意加的因為球館老板最關(guān)心的是今晚有沒有人訂。趨勢用簡單的柱狀圖展示預約熱度用場地的預約率排序這樣能直觀看到哪片場地最搶手為定價調(diào)整做參考。這里我剛開始犯過一個錯把看板做得很重接了一套大屏用的數(shù)據(jù)可視化組件結(jié)果球館前臺電腦配置不高加載圖表卡到不行。后來全部換成輕量級組件甚至有一個版本直接輸出純HTML表格。我的建議是內(nèi)部管理工具的性能比視覺效果重要100倍優(yōu)先保證可用性再談美觀。5. 部署上線前的配置清單與實測踩坑記錄5.1 域名、HTTPS與小程序后臺配置漏一步都上線不了微信小程序的網(wǎng)絡(luò)請求有嚴格限制必須是 HTTPS 域名而且域名必須在小程序后臺配置到 request 合法域名里。我第一次上線就漏了這一步真機調(diào)試時所有接口都報url not in domain list排查了好久才反應(yīng)過來。配置清單大概是這樣的首先準備一個備案過的域名解析到服務(wù)器。服務(wù)器上配好 Nginx 做反向代理同時給域名簽發(fā) HTTPS 證書我用的是免費證書申請和續(xù)期都有自動化工具。然后在小程序后臺把接口域名添加到 request 合法域名里。開發(fā)者工具里要關(guān)閉不校驗合法域名這個選項再去測因為開著這個選項真機上就會出現(xiàn)上線前一切正常上線后全掛的尷尬局面。如果是本地開發(fā)可以用開發(fā)者工具自帶的不校驗合法域名功能但記住這只是開發(fā)便利不是上線的依據(jù)。調(diào)試階段我想抓包看網(wǎng)絡(luò)請求細節(jié)用 Charles 代理看小程序和服務(wù)器之間的交互排查過幾個詭異的超時問題這個是看查問題的利器建議做小程序開發(fā)的朋友都提前熟悉。5.2 體驗版分發(fā)與真機調(diào)試讓別人試用沒那么簡單開發(fā)完要發(fā)給球館老板試用不是直接把代碼發(fā)給他就行的。正確流程是在微信公眾平臺把代碼上傳設(shè)為體驗版然后添加體驗成員。這里有個坑體驗成員必須是該小程序的開發(fā)者或者體驗者而且需要在小程序后臺手動添加。微信開發(fā)者工具里有個預覽功能可以生成二維碼但這個二維碼是臨時性的適合開發(fā)自測不適合長期給運營人員用。體驗版在真機上要用手機流量或者WiFi訪問一定確保手機和服務(wù)器網(wǎng)絡(luò)連通。我第一次給朋友發(fā)體驗版他打開一片空白最后發(fā)現(xiàn)是服務(wù)器只允許內(nèi)網(wǎng)IP訪問手機用的是4G流量根本連不上。這個問題的排查思路很簡單用手機瀏覽器訪問一下接口域名能打開看到 JSON 就說明網(wǎng)絡(luò)通打不開就是防火墻或白名單問題。真機調(diào)試還有一個常見的適配問題頂部導航欄高度。iPhone X 系列和普通機型的狀態(tài)欄高度不一樣如果自定義導航欄要在 onLoad 里獲取 wx.getSystemInfoSync() 的狀態(tài)欄高度來做適配。用系統(tǒng)默認導航欄就沒有這個問題但自定義體驗好一點。我當時的方案是頁面布局流一點導航欄直接用系統(tǒng)默認的省掉一批適配問題把精力花在業(yè)務(wù)上。5.3 我的實測踩坑清單從上線到現(xiàn)在改過的這些問題有一說一上線前的測試再充分真實運營起來還是會暴露問題。我在跑通這個項目前后最讓我印象深刻的幾個坑寫在這里給大家做個參考。第一個是時區(qū)問題。服務(wù)器是 UTC 時區(qū)數(shù)據(jù)庫存的是北京時間但定時任務(wù)生成排期時用的 LocalDate.now() 取的是服務(wù)器本地日期導致排期整體晚了一天。我后來統(tǒng)一在 Spring Boot 配置里指定了時區(qū)同時數(shù)據(jù)庫連接串里也加了 serverTimezoneAsia/Shanghai。這個問題如果不上線跑幾天根本發(fā)現(xiàn)不了但發(fā)現(xiàn)了就會很頭疼。第二個是支付回調(diào)的網(wǎng)絡(luò)抖動。微信支付回調(diào)偶爾會有幾秒延遲如果用戶在小程序里一直等不到結(jié)果就會反復點支付按鈕。我后來在后端做了一個兜底待支付訂單超過30分鐘自動關(guān)閉支付回調(diào)成功但訂單已關(guān)閉的情況自動重新開啟訂單并進入已支付狀態(tài)。這個邏輯起初沒想周全后來發(fā)現(xiàn)用戶等了太久不支付支付后訂單被關(guān)的情況后加上去的。第三個是小程序端 session 過期導致已登錄狀態(tài)集體失效。用戶在球館前臺用一臺手機核銷另一臺手機退出了登錄狀態(tài)業(yè)務(wù)數(shù)據(jù)倒沒亂但操作者會以為自己賬號丟了。我后來把 token 過期時間調(diào)長到14天同時在關(guān)鍵操作核銷、退款審批前用指紋或者密碼做二次身份確認這樣賬號安全性和體驗都照顧到了。第四個比較隱蔽批量接口的性能。訂單列表如果一次性查全量幾千條數(shù)據(jù)還能扛但跑到幾萬條就會明顯變慢。管理端列表我加了日期范圍篩選器和分頁同時給 order 表的 create_time 建了普通索引查詢從秒級降到毫秒級。這個優(yōu)化雖然基礎(chǔ)但對內(nèi)部工具來說提升非常直接。最后再分享一個實際運營中的小技巧做這種預約系統(tǒng)代碼是一方面運營策略也得跟上。我在上線之后發(fā)現(xiàn)球館最怕的是用戶預約了但沒來場地空著浪費。后來我在管理端加了一個簡單的爽約統(tǒng)計連續(xù)爽約3次的用戶會收到提醒。這個小功能代碼量不大但球館老板反饋很好。如果你也打算做類似系統(tǒng)建議一開始就在用戶表里預留一個 credit 或者 status 字段后期做運營功能會少改很多表結(jié)構(gòu)。從項目規(guī)劃到上線我最大的體會是預約類系統(tǒng)的核心是業(yè)務(wù)模型的準確性和數(shù)據(jù)一致性的把握這決定了系統(tǒng)能不能撐住真實場景而不是堆了多少花哨功能。編程技術(shù)和思維框架在項目演進中的作用不只是把代碼寫出來而是讓你理解系統(tǒng)每一個狀態(tài)變化背后的業(yè)務(wù)含義。希望我的這個過程和踩坑經(jīng)驗能幫你在自己的預約系統(tǒng)項目里少走一段彎路。