:超時處理、來單提醒與催單機制的落地)
1. 項目概述與整體設計思路1.1 這個模塊到底要解決什么問題蒼穹外賣的訂單模塊光是把“用戶下單→商家接單→配送完成”這條主鏈路跑通其實只是完成了最基本的功能。真正讓系統(tǒng)“好用”的反而是那些不起眼的旁路邏輯訂單超時了沒人處理怎么辦、用戶下單后商家沒聽見怎么辦、用戶等急了怎么催單。這三件事看起來簡單但在真實業(yè)務里每一件都會直接影響用戶體驗和商家口碑。我最初接手這個模塊時第一反應是“這不就是三個定時任務加幾個接口嘛”。真做起來才發(fā)現訂單狀態(tài)定時處理、來單提醒、客戶催單這三塊每一塊背后都牽扯到狀態(tài)機設計、任務調度策略、實時推送通道、并發(fā)控制等等一系列問題。如果只盯著“把功能實現出來”那代碼可能很快就能跑通但一旦遇到高并發(fā)、服務器重啟、訂單狀態(tài)錯亂這些場景就會連環(huán)踩坑。這個模塊適合誰參考一種是正在做蒼穹外賣項目、想把訂單模塊做得更扎實的同學另一種是工作中要處理類似“超時關單實時提醒用戶主動觸發(fā)”這類業(yè)務場景的開發(fā)者。我會把三塊功能拆開講每塊都會給出可落地的方案和我在實踐中踩過的坑。1.2 三塊功能的技術選型概覽在設計這個模塊時我首先梳理了三塊功能各自的本質訂單狀態(tài)定時處理本質是一個“到了某個時間點批量掃描并更新狀態(tài)”的定時任務。核心難點在于任務執(zhí)行時機、掃描效率、狀態(tài)流轉的冪等性。來單提醒本質是一個“訂單創(chuàng)建后把消息實時推送給商家”的異步通知。核心難點在于推送通道的選擇、消息不丟失、商家在線/離線的差異化處理。客戶催單本質是一個“用戶主動觸發(fā)系統(tǒng)檢查訂單狀態(tài)并再次提醒商家”的交互接口。核心難點在于催單的頻率控制、狀態(tài)校驗、與定時任務的配合。方案選型上我的思路是這樣的定時任務先用Spring自帶的Scheduled把功能跑通后續(xù)再考慮引入分布式任務調度平臺提醒推送優(yōu)先走WebSocket同時保留瀏覽器通知作為降級方案催單接口則基于訂單狀態(tài)機做嚴格校驗避免用戶亂催、重復催。下面逐個展開。2. 來單提醒的推送鏈路與信息觸達設計2.1 為什么來單提醒要“先抄后推”來單提醒看起來就是“商家端收到一個新訂單”但如果直接在前端輪詢訂單列表又費流量又不實時。輪詢間隔短了數據庫壓力大間隔長了用戶下單后商家要等好幾秒才知道體驗很差。更好的做法是“先抄后推”訂單創(chuàng)建成功后先把數據落庫確保訂單一定不會丟然后再通過WebSocket把“有新訂單”這個事件推給商家端。也就是訂單主流程不依賴推送結果推送成功與否都不影響訂單正常生成。我剛開始做的時候差點把推送寫成同步調用后來想明白一個道理如果推送服務掛了難道用戶連單都下不了嗎肯定不行。這種設計的好處有三個第一訂單主鏈路簡單可靠第二推送是異步的不拖慢下單響應時間第三即使推送失敗商家端仍然可以通過訂單列表主動刷新看到新訂單只是“提醒”這個增強體驗丟了而已。所以我把推送邏輯放在訂單創(chuàng)建成功之后的事件發(fā)布環(huán)節(jié)通過Spring的ApplicationEventPublisher發(fā)一個事件再由監(jiān)聽器異步處理推送實現了主鏈路和解耦。2.2 多端觸達瀏覽器通知、WebSocket、短信的取舍來單提醒不能只依賴一條通道因為商家端可能處于不同的狀態(tài)網頁開著但不一定盯著看、瀏覽器標簽頁被切走了、甚至商家根本不在電腦前。我在實際開發(fā)中把觸達渠道按優(yōu)先級分成了三層WebSocket實時推送商家端在線時最優(yōu)先使用。連接建立后服務端一旦檢測到新訂單立即推送“來單提醒”消息商家端彈窗提示音。這是整個提醒功能的主力通道。瀏覽器通知Notification API當商家端的WebSocket連接存在但頁面處于后臺標簽頁時單純靠頁面內彈窗是看不見的。這時用瀏覽器原生通知即使不在當前標簽頁也能看到系統(tǒng)級彈窗。短信/電話提醒這屬于重型觸達一般只在商家長時間不接單、訂單即將超時或被客戶催單時才會觸發(fā)。因為短信有成本而且容易造成騷擾所以不能當作默認通道。我的建議是先把WebSocket和瀏覽器通知做好短信通道做成一個可擴展的接口后續(xù)接第三方短信平臺時只需要實現同一個接口即可。這樣既不會在初期被短信成本拖累又保留了后續(xù)迭代的空間。2.3 提醒內容模板與防騷擾策略來單提醒的內容設計也有講究。最初我只推了一個“您有新的訂單”商家點進去還要再查一遍訂單詳情多了一步操作。后來我把推送內容直接拼裝成包含關鍵信息的模板商家在彈窗里就能看到主要內容例如訂單號、用戶手機號后四位、訂單金額、預計送達時間、備注信息等。{ type: NEW_ORDER, data: { orderId: 1024, orderNumber: 2025011512345678, amount: 68.5, expectDeliveryTime: 2025-01-15 12:30, remark: 不要辣多放蔥, timestamp: 1736901234000 } }防騷擾策略這塊很多人會忽略。我遇到過一個問題商家端WebSocket重連時服務端會把連接期間積累的訂單全部補推一遍結果商家收到十幾個提醒彈窗體驗極差。后來我加了一個規(guī)則同一個商家端連接收到重復推送時前端做去重服務端則只推送“當前時間點之后的新訂單”歷史訂單通過列表接口加載不通過推送補發(fā)。另外為了避免同一訂單被反復提醒我在訂單表里加了remind_status字段標記該訂單是否已經推送過推送成功則置為已推送防止重復提醒。3. 客戶催單的實現與超時訂單的自動化閉環(huán)3.1 催單按鈕的前后端聯動客戶催單的業(yè)務背景很好理解用戶下單后如果商家遲遲不接單用戶等得著急于是發(fā)起催單系統(tǒng)需要把這個訴求轉達給商家并且要給出反饋。用戶在C端“催單”按鈕點擊后前端調用后端接口后端需要做三件事校驗訂單是否可以催單、記錄催單事件、觸發(fā)對商家的再次提醒。后端接口的核心邏輯我設計成下面這樣PostMapping(/remind/{orderId}) ApiOperation(客戶催單) public ResultString remind(PathVariable Long orderId) { // 1. 查詢訂單 Order order orderMapper.getById(orderId); if (order null) { throw new OrderBusinessException(MessageConstant.ORDER_NOT_FOUND); } // 2. 校驗訂單狀態(tài)只有待接單狀態(tài)才允許催單 if (!OrderStatus.PENDING_PAYMENT.equals(order.getStatus())) { throw new OrderBusinessException(MessageConstant.ORDER_STATUS_ERROR); } // 3. 記錄催單事件同一訂單短時間內不能重復催 if (remindService.isFrequent(orderId, LocalDateTime.now())) { throw new OrderBusinessException(MessageConstant.REMIND_TOO_FREQUENT); } remindService.save(orderId, userId, LocalDateTime.now()); // 4. 觸發(fā)商家提醒 orderRemindService.sendRemindAgain(order); return Result.success(); }前端收到成功響應后會彈一個“已提醒商家盡快接單”的提示同時按鈕進入冷卻狀態(tài)比如3分鐘內不可再次點擊。這個冷卻時間其實是我在實操中根據用戶反饋調整的最開始設的1分鐘結果有用戶連續(xù)點了好幾次商家端被反復打擾后來統(tǒng)一調整為3分鐘體驗才正常。3.2 狀態(tài)機校驗催單不是“想催就能催”催單接口最容易漏掉的一點是狀態(tài)校驗。如果不加狀態(tài)判斷用戶對已完成的訂單也能發(fā)起催單這顯然不合理。我維護了一套訂單狀態(tài)機定義了訂單從“待支付→待接單→已接單→派送中→已完成→已取消”的流轉規(guī)則然后把催單接口死死地限制在“待接單”和“派送中”兩個狀態(tài)。為什么不給“待支付”狀態(tài)開放催單因為用戶還沒付錢壓根不存在“商家不處理”的問題。為什么不給“已接單”開放催單商家已經在做了再催就是打擾干活。真正需要催的是商家遲遲不接單待接單或配送太久派送中這兩種場景。狀態(tài)機的價值就在這里它不是限制功能而是讓功能出現在真正需要它的場景里。3.3 超時未處理的自動提醒鏈路只靠用戶手動催單是不夠的很多用戶不會主動點催單或者壓根沒注意到訂單卡住了。所以系統(tǒng)還需要一條“自動催”的鏈路訂單進入待接單狀態(tài)后超過一定時間商家仍未接單系統(tǒng)就自動執(zhí)行一次提醒甚至同步觸發(fā)短信通知商家。這條鏈路本質上依賴定時任務。我在蒼穹外賣里做的是每分鐘掃描一次訂單表篩選出“狀態(tài)為待接單、且下單時間超過5分鐘”的訂單對這些訂單發(fā)起一次催單提醒。同時為了不讓商家被無限騷擾同一訂單的自動提醒最多觸發(fā)3次超過次數后會轉入異常訂單列表后續(xù)由人工介入處理。這種設計把“用戶主動催”和“系統(tǒng)自動催”結合了起來覆蓋了大多數超時場景。做的時候我特意留意了掃描SQL的寫法一定要在status和create_time上建聯合索引否則訂單量一旦上來每分鐘一次的全表掃描會把數據庫拖垮。4. 定時任務框架選型從Spring Schedule到分布式方案4.1 Spring Schedule的適用邊界蒼穹外賣項目里定時任務我用的Spring自帶的Scheduled原因很直接項目規(guī)??煽亍⒉恍枰獜碗s的任務編排、部署方式還是單機部署殺雞不用牛刀。Spring Schedule用起來也確實方便一個注解加一個方法就能搞定。Component Slf4j public class OrderTask { Scheduled(cron 0 * * * * ?) // 每分鐘執(zhí)行一次 public void processTimeoutOrder() { log.info(定時處理超時訂單開始); // 處理待支付超時訂單、待接單超時訂單等 orderService.processTimeoutOrder(); log.info(定時處理超時訂單結束); } }但Spring Schedule的短板也很明確單機執(zhí)行沒有任務分片能力沒有失敗重試機制沒有任務執(zhí)行記錄的可視化界面。一旦服務部署多個實例同一個任務會在每個實例上都執(zhí)行一遍如果沒有做好冪等控制就可能出現重復處理。所以我對Spring Schedule的定位是“單機場景夠用分布場景慎用”。4.2 分布式定時任務的演進路線如果蒼穹外賣后續(xù)要做成微服務架構、多實例部署那定時任務必須遷移到分布式方案。目前主流的思路有兩條引入XXL-JOB一個輕量級的分布式任務調度平臺支持任務分片廣播、失敗重試、動態(tài)調整cron表達式、執(zhí)行日志可視化。部署時通過調度中心統(tǒng)一觸發(fā)任務執(zhí)行器節(jié)點只負責干活天然解決了多實例重復執(zhí)行的問題?;赟pring Cloud Redis分布式鎖不引入額外中間件通過Redis的SETNX實現一個分布式鎖多實例搶到鎖的節(jié)點才執(zhí)行定時任務。優(yōu)點是輕量缺點是任務沒有分片能力同一時刻只有一個節(jié)點在跑并發(fā)吞吐受限。這兩條路我都實地調研過。對于蒼穹外賣這種體量的項目我傾向于先上Redis分布式鎖因為成本低、改造快足以支撐日常流量等業(yè)務量明顯增長、需要任務拆分并行執(zhí)行時再平滑遷移到XXL-JOB。定時任務這件事最忌諱一上來就上重武器先把簡單的方案用好比盲目堆架構更實在。4.3 蒼穹外賣采用的方案與取舍回到蒼穹外賣這個具體項目。我的最終落地組合是Spring Schedule做基礎定時調度 Redis分布式鎖做多實例互斥 狀態(tài)字段做業(yè)務冪等。也就是說即使以后部署了多個實例同一個任務同一時刻只有一個實例在跑即使任務執(zhí)行過程中出現異常中斷重跑時也不會把已處理過的訂單再處理一遍。這里有個細節(jié)值得多說一句冪等控制不能只靠分布式鎖因為鎖只能管住“同一時刻”管不住“下次執(zhí)行”。如果處理邏輯本身就存在臟數據問題即使鎖完全沒問題也可能重復更新狀態(tài)。所以我在更新訂單狀態(tài)的SQL里永遠帶上狀態(tài)條件例如只有當前狀態(tài)是“待接單”才能更新為“已接單”這樣即使任務重跑也不會影響已經流轉到下一狀態(tài)的訂單。5. 實操中的坑與排查實錄5.1 定時任務“丟跑”與重復執(zhí)行的根因定時任務最常見的問題就是“丟了”或者“重復跑”。我在測試階段遇到過這么一件事定時任務設置為每分鐘執(zhí)行一次某次手動觸發(fā)后日志里出現了兩條“開始處理”記錄。排查后發(fā)現是因為測試環(huán)境的訂單服務部署了兩個實例兩個實例的Scheduled同時觸發(fā)導致同一批訂單被掃描了兩次。雖然在SQL層面做了狀態(tài)條件拼接數據沒出大問題但日志里看起來非常瘆人。解決辦法就是我前面提到的Redis分布式鎖。我在任務入口處加了一個SETNX鎖鎖的key用任務名稱過期時間設為55秒這樣即使兩個實例同時觸發(fā)也只有搶到鎖的那個實例能繼續(xù)執(zhí)行另一個直接返回。這里還有一個小坑鎖的過期時間如果小于任務實際執(zhí)行時間任務還沒跑完鎖就自動釋放了另一個實例會再次搶到鎖導致重復執(zhí)行。所以過期時間一定要大于任務的最大執(zhí)行時長最好留出余量并在任務結束后主動釋放鎖。5.2 數據庫時間與服務器時區(qū)不一致這個坑非常隱蔽也很致命。某個訂單的超時時間明明設置的是12:00但定時任務執(zhí)行后發(fā)現訂單在13:00才被處理。排查了半天最終發(fā)現是服務器時區(qū)和數據庫時區(qū)不一致數據庫連接串里設置的serverTimezoneAsia/Shanghai但服務器系統(tǒng)時區(qū)是UTC導致NOW()函數取到的時間和Java本地時間差了8個小時。從那以后我定了個規(guī)矩所有時間字段一律以數據庫時間為準Java側不依賴系統(tǒng)默認時區(qū)連接串顯式指定serverTimezOne并且在項目啟動時統(tǒng)一設置TimeZone.setDefault(TimeZone.getTimeZone(Asia/Shanghai))。定時任務涉及時間判斷的SQL能不用應用層時間就不用直接寫數據庫函數從根源上避免時區(qū)偏差。5.3 WebSocket連接斷開與重連來單提醒的WebSocket連接在實際運營中經常會出現“悄悄斷開”的情況。服務端推送時報錯才發(fā)現商家端連接早就斷了但前端還不知道。我做了一個簡單的?;顧C制前端每30秒發(fā)送一個心跳ping服務端收到后回一個pong如果連續(xù)三次心跳都沒收到服務端回復前端就主動斷開并重新建立連接服務端也在注冊一個空閑檢測超過一定時間沒收到心跳就主動關閉連接。另外服務端推送消息時需要捕獲連接斷開異常不能因為某個商家端連接異常就把整個推送線程搞崩。我封裝了一個sendToUser方法推送時先檢查session.isOpen()再發(fā)送發(fā)送失敗就記錄日志并移除這個連接。不要小看這個細節(jié)在真實環(huán)境中商家的電腦可能隨時休眠、網絡可能隨時切換WebSocket連接穩(wěn)定性是來單提醒功能能否真正落地的關鍵。6. 幾點經驗總結6.1 狀態(tài)機是訂單系統(tǒng)的靈魂做了這個模塊之后我最大的一個體會是訂單系統(tǒng)的核心不是接口寫得多花哨而是狀態(tài)機設計得夠不夠嚴謹。定時處理、來單提醒、客戶催單這三塊功能最終都是在跟訂單狀態(tài)打交道。狀態(tài)流轉定義清楚了代碼自然好寫狀態(tài)流轉模糊再簡單的功能也會改出各種Bug。我建議大家在動手寫代碼之前先花半小時把訂單的完整狀態(tài)圖畫出來把“誰觸發(fā)了流轉”“流轉前需要滿足什么條件”“流轉后要做哪些后續(xù)動作”都列出來。這張圖就是整個訂單模塊的設計藍圖后面所有功能的開發(fā)都圍繞它展開能省下很多返工時間。6.2 定時任務的監(jiān)控與告警定時任務跑得久了最容易出現的問題就是“靜默失敗”任務啟動時報了個異常日志被打到某個不起眼的文件里然后整個訂單模塊的超時處理就癱瘓了而沒有任何人發(fā)現。所以我給大家一個很實用的建議定時任務必須有監(jiān)控。最簡單的方式是在任務開始和結束時各打一條日志關鍵任務還要往數據庫寫一張task_execution_log表記錄每次執(zhí)行的起始時間、結束時間、處理數量、執(zhí)行結果。日常巡檢只看這張表就能知道任務有沒有準時跑、跑了多少單、有沒有報錯。更進一步可以接一下企業(yè)微信/釘釘/飛書的webhook任務執(zhí)行異常時自動發(fā)消息到工作群這樣不用人工盯日志就能第一時間發(fā)現問題。6.3 后續(xù)擴展方向如果這個項目要繼續(xù)往下做我覺得有幾個方向可以延伸一是引入消息隊列如RocketMQ替代WebSocket直推讓推送能力與業(yè)務服務徹底解耦推送失敗時還能靠消息重試兜底二是把催單記錄沉淀成數據報表分析哪些時段商家接單最慢、哪些區(qū)域催單率最高反向指導商家改善出餐流程三是把訂單超時處理從“定時掃描”升級為“延遲消息”利用消息隊列的延遲隊列實現更精準、更及時的超時觸發(fā)減少掃描帶來的無效計算。這些擴展不一定要馬上做但心里得有這個圖譜等業(yè)務量上來的時候才知道該往哪個方向走。我到現在還記得第一次把這三塊功能完整跑通、商家在客戶端收到來單彈窗的時刻那種“系統(tǒng)真的活起來了”的感受是做技術的人最享受的瞬間。