現(xiàn)國(guó)際版掃碼點(diǎn)餐系統(tǒng):架構(gòu)設(shè)計(jì)與踩坑總結(jié))
做了這么多年餐飲SaaS說實(shí)話掃碼點(diǎn)餐這個(gè)需求我之前一直沒覺得有什么技術(shù)含量直到接了那個(gè)海外華人餐廳的單子才發(fā)現(xiàn)國(guó)際版三個(gè)字背后藏著一整座冰山。很多人以為國(guó)際版掃碼點(diǎn)餐就是把按鈕翻譯成英文、菜單改成雙語真正動(dòng)手做起來才知道從訂單狀態(tài)機(jī)的設(shè)計(jì)、菜品的多語言與多規(guī)格模型到多幣種金額處理、多時(shí)區(qū)的時(shí)間歸因再到跨境支付回調(diào)的冪等防重每一層都是坑。這篇文章把我用Java從零實(shí)現(xiàn)國(guó)際版掃碼點(diǎn)餐系統(tǒng)的完整過程、技術(shù)選型邏輯、核心模塊設(shè)計(jì)以及踩過的坑一次說透適合正在做餐飲系統(tǒng)、或準(zhǔn)備把手里的本地點(diǎn)餐項(xiàng)目推向海外市場(chǎng)的Java開發(fā)者參考。1. 掃碼點(diǎn)餐不是簡(jiǎn)單掃碼下單先理清國(guó)際版的真實(shí)需求1.1 從用戶動(dòng)線拆解核心流程我接這個(gè)項(xiàng)目時(shí)需求文檔上寫的是做一套和國(guó)內(nèi)版差不多的掃碼點(diǎn)餐支持英文和中文就行。拿到這種需求千萬別急著寫代碼。我習(xí)慣第一步把用戶動(dòng)線完整畫出來顧客進(jìn)店坐下看到桌角的二維碼掏出手機(jī)掃碼進(jìn)入餐廳的菜單頁選菜加購(gòu)提交訂單后廚出餐服務(wù)員上菜顧客吃完后買單。這七步看著簡(jiǎn)單每一步放到國(guó)際版場(chǎng)景里都有變數(shù)。掃碼之后手機(jī)瀏覽器打開的是H5不是小程序因?yàn)楹M庥脩舨谎b微信菜單加載要考慮CDN分發(fā)和多語言切換提交訂單要處理不同國(guó)家的支付網(wǎng)關(guān)后廚出餐要對(duì)接KDS廚房顯示系統(tǒng)或者熱敏打印機(jī)買單環(huán)節(jié)有的國(guó)家習(xí)慣桌邊刷卡有的習(xí)慣掃碼支付有的直接去收銀臺(tái)。所以做國(guó)際版掃碼點(diǎn)餐核心是先把業(yè)務(wù)動(dòng)作抽象成一套與國(guó)家/地區(qū)解耦的流程引擎再把語言、貨幣、支付、打印格式做成可插拔的配置。1.2 國(guó)際版和國(guó)內(nèi)版的本質(zhì)差異這里我得說一個(gè)很多人忽略的點(diǎn)國(guó)際版不是國(guó)內(nèi)版的翻譯版而是另一套產(chǎn)品邏輯。國(guó)內(nèi)版的掃碼點(diǎn)餐微信支付和支付寶基本覆蓋了90%的場(chǎng)景支付回調(diào)、退款、對(duì)賬都圍繞這兩家來做。海外不同國(guó)家的支付方式極度碎片化北美流行信用卡和Apple Pay歐洲很多國(guó)家用iDEAL、Klarna東南亞用GrabPay、當(dāng)?shù)仉娮渝X包拉美則大量依賴現(xiàn)金和Pix巴西。支付只是差異之一。數(shù)據(jù)庫里的時(shí)間國(guó)內(nèi)一個(gè)時(shí)區(qū)搞定國(guó)際版要面對(duì)全球幾十個(gè)時(shí)區(qū)貨幣單位不同日元的金額是0位小數(shù)科威特第納爾是3位小數(shù)如果數(shù)據(jù)庫里金額字段用double存早晚出事還有菜單的多語言英文描述通常比中文長(zhǎng)一倍UI空間和打印機(jī)的小票格式全要跟著調(diào)再往深處說有的國(guó)家對(duì)食品過敏原有強(qiáng)制標(biāo)注要求有的國(guó)家對(duì)數(shù)字隱私有特定法規(guī)這些都要在系統(tǒng)里預(yù)留字段和邏輯。1.3 我的技術(shù)傾向與項(xiàng)目邊界我最終采用的方案是Spring Boot 3.x單體架構(gòu)起步按模塊劃分邊界預(yù)留接口拆分微服務(wù)。理由后面詳細(xì)說。項(xiàng)目整體分四個(gè)端顧客掃碼后的H5點(diǎn)餐端、服務(wù)員用的管理端、后廚展示/打印端、商家后臺(tái)運(yùn)營(yíng)端外加一個(gè)定時(shí)任務(wù)模塊處理訂單超時(shí)、庫存釋放、對(duì)賬單生成。技術(shù)棧以Java 17為主Spring MVC做REST APIMyBatis-Plus做數(shù)據(jù)持久化Redis做緩存和分布式鎖RabbitMQ做訂單事件異步通知MySQL 8.0存核心業(yè)務(wù)數(shù)據(jù)MongoDB存菜單的多語言副本和操作日志。這個(gè)組合不算新潮但勝在穩(wěn)定、社區(qū)資料多、出問題好排查餐飲系統(tǒng)不是炫技的地方。2. Java技術(shù)棧選型從單體到微服務(wù)的取舍2.1 為什么主力語言還是Java這個(gè)項(xiàng)目不是從零選語言但我也認(rèn)真對(duì)比過Go、Node.js和Java的優(yōu)劣勢(shì)。餐飲點(diǎn)餐系統(tǒng)的特點(diǎn)是業(yè)務(wù)邏輯復(fù)雜訂單狀態(tài)機(jī)、庫存、優(yōu)惠、支付對(duì)賬、并發(fā)有峰值但整體可控午晚餐高峰、團(tuán)隊(duì)后續(xù)要長(zhǎng)期維護(hù)迭代。Java在這三個(gè)維度上依然是綜合分最高的。它的生態(tài)里有成熟的狀態(tài)機(jī)框架、分布式事務(wù)方案、豐富的支付SDK對(duì)接案例而且Java開發(fā)者在全球范圍內(nèi)都好招。Go在處理高并發(fā)連接上有優(yōu)勢(shì)但業(yè)務(wù)模型復(fù)雜之后開發(fā)效率反而不如Java加上一套好用的框架。具體的方案上我沒有引入Spring Cloud那套全家桶而是先把所有功能做在一個(gè)可水平擴(kuò)展的應(yīng)用里。原因很直接這個(gè)項(xiàng)目的首版核心目標(biāo)是快速跑通流程、驗(yàn)證需求單體架構(gòu)在業(yè)務(wù)迭代初期的部署和調(diào)試成本遠(yuǎn)低于微服務(wù)。等訂單量上來、團(tuán)隊(duì)擴(kuò)充到多組并行開發(fā)時(shí)再按點(diǎn)餐服務(wù)、訂單服務(wù)、支付服務(wù)、基礎(chǔ)數(shù)據(jù)服務(wù)四個(gè)域拆也不遲模塊邊界我已經(jīng)在代碼層面用Package分好了。2.2 核心框架的版本與搭配細(xì)節(jié)Spring Boot 3.x要求Java 17起步這正好讓我避開了老項(xiàng)目中一堆基于javax包的兼容問題。這里要說個(gè)實(shí)操經(jīng)驗(yàn)很多老程序員拿到Spring Boot 3.x項(xiàng)目把別人的配置一拷結(jié)果啟動(dòng)報(bào)錯(cuò)十有八九是依賴包引入了舊版javax.servlet、javax.annotation。我在項(xiàng)目里用了一個(gè)笨辦法保證干凈統(tǒng)一用Maven BOM管理依賴版本pom.xml里顯式排除所有javax開頭的傳遞依賴核心依賴列表如下。組件版本用途說明Spring Boot3.2.xWeb、Validation、ActuatorMyBatis-Plus3.5.xORM與分頁插件Redis客戶端Lettuce緩存與分布式鎖RabbitMQ3.12.x訂單事件異步解耦ZXing3.5.x二維碼生成Quartz2.3.x超時(shí)訂單掃描與對(duì)賬定時(shí)任務(wù)數(shù)據(jù)庫連接池我用了HikariCPSpring Boot默認(rèn)集成配置時(shí)注意maximum-pool-size不要貪大我以前見過有人設(shè)成100結(jié)果數(shù)據(jù)庫連接數(shù)被打滿整個(gè)系統(tǒng)雪崩。按部署實(shí)例數(shù)平均每個(gè)實(shí)例20個(gè)連接在小規(guī)模餐飲項(xiàng)目里完全夠用。2.3 從單體出發(fā)哪些模塊必須提前預(yù)留接口雖然我說了用單體架構(gòu)但有兩處接口必須提前抽象。第一是支付網(wǎng)關(guān)我定義了一個(gè)PaymentGateway接口下面先實(shí)現(xiàn)Stripe和PayPal兩個(gè)適配器后續(xù)要接Adyen、Klarna只需要擴(kuò)展適配器下單流程和回調(diào)處理完全不動(dòng)。第二是消息推送推送到H5端我用了WebSocket Spring Messaging推送到后廚大屏用的是SSEServer-Sent Events這兩條通道在接口層統(tǒng)一封裝為OrderEventPublisher業(yè)務(wù)代碼只發(fā)一個(gè)OrderEvent具體走WebSocket還是SSE由配置決定。這里加密說一句單體架構(gòu)下代碼粒度不是越大越好模塊之間的依賴方向要在Package結(jié)構(gòu)上卡死。我按接口層→應(yīng)用服務(wù)層→領(lǐng)域服務(wù)層→基礎(chǔ)設(shè)施層的依賴規(guī)則去做架構(gòu)評(píng)審誰違反誰重構(gòu)這樣后續(xù)拆微服務(wù)時(shí)只需要把不同Package拎出來部署并補(bǔ)上Feign調(diào)用即可業(yè)務(wù)代碼改動(dòng)量很小。3. 多語言、多幣種、多時(shí)區(qū)國(guó)際化三大硬骨頭的落地解法3.1 多語言不等同于資源文件Spring的MessageSource做多語言簡(jiǎn)單但放到點(diǎn)餐場(chǎng)景里就復(fù)雜了一個(gè)菜品的名稱、描述、過敏原提示、口味說明都是多語言內(nèi)容而且品類不同語言的字符長(zhǎng)度差異很大。英文的Grilled Salmon with Lemon Butter Sauce放在中文香煎三文魚配檸檬黃油汁的位置肯定要換行甚至撐破布局。所以我的方案是菜單基礎(chǔ)表只存菜品ID和全局唯一編碼菜品名稱、描述、過敏原、自定義標(biāo)簽存到獨(dú)立的多語言表結(jié)構(gòu)大致這樣。CREATE TABLE dish_i18n ( id BIGINT AUTO_INCREMENT PRIMARY KEY, dish_id BIGINT NOT NULL, locale VARCHAR(16) NOT NULL, name VARCHAR(200) NOT NULL, description VARCHAR(1000), allergen_info VARCHAR(500), UNIQUE KEY uk_dish_locale (dish_id, locale) );使用方通過一個(gè)I18nMenuService取菜品時(shí)按當(dāng)前請(qǐng)求頭里的Accept-Language或者顧客選購(gòu)時(shí)選擇的語言偏好去查對(duì)應(yīng)語言字段。這里有一個(gè)細(xì)節(jié)內(nèi)容不能只按語言存還要按國(guó)家/地區(qū)存因?yàn)橥瑯邮怯⒄Z英國(guó)、美國(guó)、澳洲的菜品描述和拼寫習(xí)慣是有差異的比如chips和fries所以locale我存的是en-US、en-GB、zh-CN這種完整格式fallback鏈?zhǔn)莈n-US → en → 默認(rèn)語言。3.2 金額處理BigDecimal是底線國(guó)際版涉及多幣種最數(shù)據(jù)安全的點(diǎn)是金額精度。數(shù)據(jù)庫凡是涉及金額的字段全部用DECIMAL(12, 2)或DECIMAL(12, 4)Java代碼里一律用BigDecimal嚴(yán)禁double和float。一套菜品在不同國(guó)家定價(jià)涉及到匯率換算我單獨(dú)建了currency_rate表每天定時(shí)任務(wù)從匯率服務(wù)拉取最新匯率換算時(shí)用中間貨幣USD做錨點(diǎn)避免A國(guó)貨幣直接換算B國(guó)貨幣時(shí)的交叉匯率誤差。國(guó)際版還有一個(gè)容易被忽視的點(diǎn)不同貨幣的小數(shù)位數(shù)不同。JPY是0位小數(shù)USD和EUR是2位BHD和KWD是3位。我的Money對(duì)象里封裝了currencyCode和BigDecimal amount格式化顯示時(shí)通過Currency.getDefaultFractionDigits(currencyCode)動(dòng)態(tài)取小數(shù)位前端也通過接口拿到小數(shù)位配置避免顯示¥100.00這種在日本場(chǎng)景里奇怪的精度。小票打印的金額格式也一樣不能寫死。3.3 時(shí)區(qū)與時(shí)間歸因數(shù)據(jù)庫統(tǒng)一存UTC一臺(tái)部署在新加坡的服務(wù)器服務(wù)的顧客可能來自全球各地。訂單的創(chuàng)建時(shí)間、支付時(shí)間、對(duì)賬時(shí)間在數(shù)據(jù)庫里必須統(tǒng)一存UTC時(shí)間戳展示層再按餐廳所在時(shí)區(qū)或者顧客所在時(shí)區(qū)去轉(zhuǎn)換。我封裝了一個(gè)TimeZoneResolver組件從餐廳表里取該店鋪的默認(rèn)時(shí)區(qū)配合H5前端通過JS獲取用戶本地時(shí)區(qū)兩相結(jié)合決定每個(gè)請(qǐng)求里的時(shí)間上下文。這里還有一個(gè)業(yè)務(wù)歸因問題報(bào)表統(tǒng)計(jì)需要按餐廳本地日期分組而不能按服務(wù)器日期直接按天聚合。比如一個(gè)美國(guó)洛杉磯的餐廳UTC時(shí)間凌晨2點(diǎn)對(duì)應(yīng)的還是當(dāng)?shù)厍耙惶焱砩?點(diǎn)。我的做法是在生成日?qǐng)?bào)時(shí)先從UTC時(shí)間按餐廳時(shí)區(qū)偏移到本地時(shí)間再按本地y-m-d分組否則每天的單量和營(yíng)業(yè)額會(huì)算錯(cuò)邊界。類似的坑做跨時(shí)區(qū)項(xiàng)目的一定要提前設(shè)計(jì)。4. 掃碼點(diǎn)餐核心鏈路的設(shè)計(jì)與實(shí)現(xiàn)從桌臺(tái)碼到訂單狀態(tài)機(jī)4.1 桌臺(tái)碼的生成與綁定每張桌子的二維碼我存的核心信息只有三個(gè)餐廳ID、桌臺(tái)ID、一個(gè)隨機(jī)校驗(yàn)簽名。掃碼后前端拿到這些參數(shù)請(qǐng)求后端換取一個(gè)綁定桌臺(tái)的短期Token后續(xù)的所有操作都帶著這個(gè)Token。二維碼內(nèi)容我經(jīng)過ZXing庫生成編碼格式用的是帶校驗(yàn)的Payload內(nèi)容設(shè)計(jì)如下。public String generateTableCode(Long restaurantId, Long tableId) { String raw restaurant restaurantId table tableId expire (System.currentTimeMillis() 30 * 24 * 3600 * 1000L); String sign HmacUtils.hmacSha256Hex(SECRET_KEY, raw); return Base64.getUrlEncoder().encodeToString((raw sign sign).getBytes(StandardCharsets.UTF_8)); }這里有人會(huì)問為什么不直接用桌臺(tái)ID做碼因?yàn)橐纻卧臁H绻腥舜鄹亩S碼里的桌位參數(shù)就可能占別人的桌臺(tái)或者給后廚下惡作劇訂單。簽名校驗(yàn)?zāi)軌虮WC二維碼是系統(tǒng)簽發(fā)的同時(shí)過期時(shí)間避免二維碼被長(zhǎng)期濫用。掃碼落地后桌臺(tái)在Redis里生成一個(gè)狀態(tài)Key標(biāo)記有人用餐等結(jié)賬后再清除。4.2 購(gòu)物車與菜品多規(guī)格的處理顧客掃碼后的點(diǎn)餐過程購(gòu)物車在服務(wù)端Session和本地存儲(chǔ)之間我選擇了本地存儲(chǔ)服務(wù)端校驗(yàn)的混合模式。購(gòu)物車明細(xì)保存在H5前端的localStorage里減少后端壓力提交訂單時(shí)后端一次性校驗(yàn)菜品庫存、價(jià)格有效性、規(guī)格組合是否合法。為什么不把購(gòu)物車放Redis因?yàn)辄c(diǎn)餐場(chǎng)景下用戶加購(gòu)、改購(gòu)的請(qǐng)求頻率高每個(gè)操作都走Redis網(wǎng)絡(luò)I/O在弱網(wǎng)環(huán)境下體驗(yàn)會(huì)很差本地存儲(chǔ)則零延遲。價(jià)格和庫存校驗(yàn)收口在服務(wù)端完成就不會(huì)有客戶端篡改價(jià)格的風(fēng)險(xiǎn)。多規(guī)格比如飲品選糖度、小料牛排選熟度我建了一張sku表簡(jiǎn)單的規(guī)格組合用JSON字段存復(fù)雜的組合按SKU維度拆庫存。關(guān)鍵點(diǎn)在于規(guī)格的顯示文本也是多語言的糖度的少糖半糖正常在不同的語言環(huán)境里要有對(duì)應(yīng)翻譯漲價(jià)和庫存扣減只能針對(duì)SKU不能對(duì)著菜品級(jí)別操作否則會(huì)出現(xiàn)不同規(guī)格共享庫存導(dǎo)致超賣。4.3 訂單狀態(tài)機(jī)的驅(qū)動(dòng)力事件驅(qū)動(dòng) 狀態(tài)流轉(zhuǎn)掃碼點(diǎn)餐的訂單狀態(tài)比電商訂單復(fù)雜因?yàn)樗幸严聠巍呀訂巍谱髦小焉喜恕淹瓿蛇@條線下鏈路中間還可能插入催單退菜超時(shí)未支付自動(dòng)取消等異常操作。我用一張訂單狀態(tài)表記錄狀態(tài)狀態(tài)流轉(zhuǎn)全部通過OrderStatusEvent事件驅(qū)動(dòng)用RabbitMQ異步執(zhí)行后續(xù)動(dòng)作核心狀態(tài)機(jī)模型如下。當(dāng)前狀態(tài)觸發(fā)事件目標(biāo)狀態(tài)執(zhí)行動(dòng)作PENDING_PAYMENTPAY_SUCCESSRECEIVED后廚推送、打印訂單PENDING_PAYMENTPAY_TIMEOUTCANCELLED釋放桌臺(tái)、釋放庫存RECEIVEDKITCHEN_STARTPREPARING更新預(yù)計(jì)出餐時(shí)間PREPARINGDISH_READYDELIVERED叫號(hào)通知顧客DELIVEREDSETTLE_ACCOUNTCOMPLETED生成對(duì)賬單狀態(tài)流轉(zhuǎn)不能散落在業(yè)務(wù)代碼里我統(tǒng)一收口在一個(gè)OrderStateMachine類中每個(gè)合法轉(zhuǎn)移都做前置校驗(yàn)防止臟數(shù)據(jù)把訂單卡在一個(gè)進(jìn)退兩難的節(jié)點(diǎn)。這里要特別提醒支付成功的回調(diào)處理必須保證冪等即同一筆訂單的支付成功事件重復(fù)消費(fèi)不會(huì)導(dǎo)致狀態(tài)重復(fù)流轉(zhuǎn)。我在事件里引入了eventId去重表消費(fèi)前先查一條一樣eventId的記錄有沒有處理過處理過就直接ACK不做任何狀態(tài)變更。4.4 超時(shí)未支付的訂單處理延時(shí)消息與定時(shí)掃單國(guó)際版的顧客支付習(xí)慣不同有些國(guó)家的顧客下單后習(xí)慣慢慢選支付方式支付超時(shí)時(shí)間不能寫死。我設(shè)置了一個(gè)可配置的支付超時(shí)時(shí)間默認(rèn)15分鐘。實(shí)現(xiàn)上用了三級(jí)保障第一級(jí)RabbitMQ的延遲消息插件在訂單創(chuàng)建后發(fā)送一條延時(shí)消息到點(diǎn)檢查是否已支付第二級(jí)Quartz每3分鐘掃描一次處于待支付、時(shí)間超過閾值的訂單做兜底取消第三級(jí)顧客主動(dòng)取消接口直接釋放資源。三級(jí)保障確保了即使某個(gè)環(huán)節(jié)出問題也不至于把桌臺(tái)和庫存扣住不放。關(guān)于RabbitMQ延遲消息有一點(diǎn)踩坑經(jīng)驗(yàn)rabbitmq_delayed_message_exchange插件是把消息存在MnesiaErlang的分布式數(shù)據(jù)庫里插入和讀取性能遠(yuǎn)不如普通隊(duì)列如果訂單量很大會(huì)讓Broker壓力劇增。所以我只在OrderCreated事件上用延遲消息其他業(yè)務(wù)事件還是走普通交換機(jī)。后來測(cè)試高峰期訂單創(chuàng)建幾千筆時(shí)延遲插件所在Broker的內(nèi)存和消息堆積明顯偏高后來我調(diào)整了策略延遲消息只用于訂單超時(shí)提醒5分鐘以上的超時(shí)場(chǎng)景改用定時(shí)掃單兜底插件壓力就降下來了。spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3 initial-interval: 1000訂單事件消費(fèi)者我開啟了重試機(jī)制。這里有個(gè)學(xué)習(xí)要點(diǎn)如果消費(fèi)者處理消息時(shí)拋出異常不能簡(jiǎn)單無限重試否則會(huì)造成消息循環(huán)堆積甚至死信。配置如上最多重試3次仍失敗進(jìn)入死信隊(duì)列由定時(shí)任務(wù)查庫糾正狀態(tài)。消息和數(shù)據(jù)庫狀態(tài)是兩個(gè)獨(dú)立系統(tǒng)最終要對(duì)賬一致性絕對(duì)不能只靠消息驅(qū)動(dòng)。5. 國(guó)際版高并發(fā)場(chǎng)景下的性能優(yōu)化實(shí)踐5.1 菜單緩存與熱點(diǎn)數(shù)據(jù)隔離掃碼點(diǎn)餐在午晚餐高峰期的特點(diǎn)非常明顯短時(shí)間內(nèi)大量顧客同時(shí)打開菜單菜品信息和圖片請(qǐng)求量瞬間拉升。菜單數(shù)據(jù)是典型的讀多寫少我用Redis做了兩級(jí)緩存第一級(jí)是本地Caffeine緩存過期時(shí)間60秒等于每個(gè)服務(wù)實(shí)例在本地留一份熱點(diǎn)菜單第二級(jí)是Redis緩存過期時(shí)間5分鐘負(fù)責(zé)多實(shí)例間的數(shù)據(jù)同步和穿透兜底。請(qǐng)求進(jìn)來先查Caffeine不中再查Redis再回源查MySQL。這套機(jī)制有一個(gè)細(xì)節(jié)要注意菜單更新比如菜品下架、臨時(shí)沽清需要主動(dòng)淘汰緩存否則顧客看到的菜單和實(shí)際庫存不一致。我在菜單更新接口里寫了一個(gè)cacheService.evictMenu(restaurantId)方法先刪除Redis里的菜單緩存再把本地Caffeine實(shí)例刪除。單個(gè)實(shí)例刪除本地緩存比較容易但有多個(gè)實(shí)例時(shí)得通過Redis Pub/Sub廣播一個(gè)緩存的失效事件讓其他實(shí)例也刪。不然有的用戶看到的是新菜單有的還是舊菜單在時(shí)差問題下體驗(yàn)很糟糕。5.2 高并發(fā)扣庫存Redis原子操作 數(shù)據(jù)庫兜底點(diǎn)餐系統(tǒng)和電商系統(tǒng)一樣存在超賣風(fēng)險(xiǎn)但餐飲的庫存模型更輕主要針對(duì)沽清每日限量菜品如例湯、特色甜點(diǎn)。我用Redis的Lua腳本做庫存預(yù)扣原子性由Redis保證腳本邏輯是判斷當(dāng)前庫存大于0就減一否則返回失敗。同時(shí)落數(shù)據(jù)庫用樂觀鎖版本號(hào)做兜底防止Redis數(shù)據(jù)丟失后重啟恢復(fù)不一致。if redis.call(exists, KEYS[1]) 1 then local stock tonumber(redis.call(get, KEYS[1])) if stock 0 then redis.call(decr, KEYS[1]) return 1 end return 0 end return -1分布式環(huán)境下我可以更進(jìn)一步做訂單維度的防重同一個(gè)桌臺(tái)、同一顧客、同一種菜品在30秒內(nèi)的重復(fù)下單用Redis的SETNX加一個(gè)鎖來攔截避免顧客手滑多點(diǎn)了幾份。這個(gè)防重用后來看非常有效至少擋掉了很大比例的重復(fù)請(qǐng)求。5.3 訂單通知的推送通道WebSocket與SSE分工國(guó)際版點(diǎn)餐前端是H5頁面自然優(yōu)先考慮WebSocket維持長(zhǎng)連接但WebSocket在弱網(wǎng)環(huán)境里的自動(dòng)重連、斷線恢復(fù)處理比較麻煩。我的做法是按消息類型拆分面向點(diǎn)餐顧客的狀態(tài)變化通知比如訂單制作完成、可以取餐了用SSE單向通道服務(wù)端主動(dòng)推給顧客代碼簡(jiǎn)潔斷線自動(dòng)重連由瀏覽器原生能力處理面向顧客掃碼后的實(shí)時(shí)聊天如果有才用WebSocket雙向通道。這里有一個(gè)隱藏性能點(diǎn)SSE和WebSocket連接都屬于長(zhǎng)連接每個(gè)實(shí)例的連接數(shù)上限受Nginx和Tomcat配置影響。我在壓測(cè)時(shí)發(fā)現(xiàn)默認(rèn)Nginx沒有配置超時(shí)時(shí)間時(shí)空閑的SSE連接會(huì)一直掛著限制并發(fā)能力。后來我把proxy_read_timeout設(shè)為60秒配合前端SSE的心跳重連邏輯連接數(shù)和穩(wěn)定性都比較理想。線程模型上Tomcat的默認(rèn)最大線程數(shù)是200每個(gè)SSE連接會(huì)占一個(gè)線程所以當(dāng)在線顧客多的時(shí)候不能盲目開太多SSE連接我按餐廳ID做連接復(fù)用一個(gè)餐廳連接一個(gè)推送通道消息里帶桌臺(tái)標(biāo)識(shí)進(jìn)行廣播。6. 部署與運(yùn)維階段踩過的坑多區(qū)域、支付回調(diào)與日志監(jiān)控6.1 多區(qū)域部署與數(shù)據(jù)同步國(guó)際版自然要考慮多區(qū)域部署。我最初天真地以為部署在一個(gè)區(qū)域、全球訪問就行結(jié)果東南亞的顧客打開歐洲節(jié)點(diǎn)的頁面時(shí)菜單圖片加載慢得讓人抓狂。后來前端靜態(tài)資源掛CDN核心API部署到兩個(gè)區(qū)域一個(gè)主區(qū)域一個(gè)災(zāi)備區(qū)域MySQL做主從復(fù)制Redis跨區(qū)域只讀副本。這里有一個(gè)一致性取舍跨區(qū)域同步數(shù)據(jù)有延遲所以我把訂單創(chuàng)建強(qiáng)一致留在主區(qū)域菜單、餐廳信息這類低頻變更數(shù)據(jù)通過MQ異步同步到其他區(qū)域的只讀副本。有一個(gè)小的經(jīng)驗(yàn)不同區(qū)域之間的數(shù)據(jù)庫時(shí)區(qū)設(shè)置要保持一致統(tǒng)一UTC表結(jié)構(gòu)變更要走Online DDL工具比如pt-online-schema-change否則在復(fù)制環(huán)境下添加字段可能鎖表。我踩過一次直接ALTER TABLE加索引大表在從庫同步時(shí)延遲飆升線上點(diǎn)餐查詢慢得不行從那以后全部走gh-ost或者pt-osc。6.2 支付回調(diào)的冪等與防重Stripe、PayPal、Adyen這些支付網(wǎng)關(guān)的回調(diào)機(jī)制和國(guó)內(nèi)支付平臺(tái)大同小異但有兩個(gè)額外痛點(diǎn)回調(diào)來源IP不確定、回調(diào)可能重復(fù)送達(dá)且不保證順序。我在所有支付回調(diào)入口做了三件事驗(yàn)簽用各自的Webhook簽名機(jī)制、冪等eventId去重表、狀態(tài)機(jī)校驗(yàn)只有待支付訂單才能流轉(zhuǎn)到已支付。另外支付回調(diào)接口必須返回200響應(yīng)給支付網(wǎng)關(guān)如果業(yè)務(wù)處理成功前就直接返回200支付網(wǎng)關(guān)會(huì)認(rèn)為投遞成功丟失訂單如果業(yè)務(wù)處理失敗得要返回4xx來觸發(fā)網(wǎng)關(guān)重試但要小心重試風(fēng)暴所以重試次數(shù)和間隔要在網(wǎng)關(guān)后臺(tái)配置好。我在開發(fā)時(shí)遇到過一個(gè)問題Stripe回調(diào)順序不是嚴(yán)格按時(shí)間排序的比如payment_intent.succeeded事件先到charge.refunded事件后到兩個(gè)事件處理同一個(gè)訂單處理邏輯必須都是冪等的否則就會(huì)把已支付的訂單又標(biāo)記成已退款中間還經(jīng)歷了狀態(tài)機(jī)攔截。后來單獨(dú)寫了一個(gè)callback_audit表把每個(gè)事件的原始Payload、處理結(jié)果、耗時(shí)都記錄下來方便對(duì)賬和排障。6.3 日志與監(jiān)控的國(guó)際化陷阱多語言環(huán)境下日志里不能出現(xiàn)亂碼這是基本要求。我所有應(yīng)用日志文件統(tǒng)一UTF-8編碼數(shù)據(jù)庫連接串加了characterEncodingutf8RabbitMQ消息體強(qiáng)制JSON字符串UTF-8編碼。以前我遇到過一個(gè)問題中文菜單名寫入消息隊(duì)列后消費(fèi)者拿到的字符串末尾多了一個(gè)問號(hào)排查半天發(fā)現(xiàn)是生產(chǎn)者用的字符集是平臺(tái)默認(rèn)字符集在Linux下是UTF-8在Windows下是GBK后來強(qiáng)制在消息發(fā)送時(shí)指定StandardCharsets.UTF_8再?zèng)]出現(xiàn)過問題。監(jiān)控方面我用了Spring Boot Actuator暴露指標(biāo)健康、線程、內(nèi)存、緩存命中率、數(shù)據(jù)庫連接池水位配合Prometheus抓取、Grafana展示。關(guān)鍵報(bào)警圍繞三點(diǎn)訂單支付成功率突降、支付回調(diào)積壓數(shù)超過閾值、訂單表數(shù)據(jù)量接近分表閾值。餐飲高峰期一過凌晨定時(shí)對(duì)賬如果發(fā)現(xiàn)異常觸發(fā)告警到值班群。這幾種監(jiān)控就夠用了沒必要一開始就上全鏈路追蹤先把核心鏈路盯住。6.4 索引設(shè)計(jì)與慢查詢治理一段實(shí)際優(yōu)化實(shí)錄系統(tǒng)上線運(yùn)營(yíng)兩個(gè)月后發(fā)現(xiàn)顧客查看歷史訂單的接口越來越慢慢查詢?nèi)罩撅@示一條SQL掃了60多萬行。這條SQL的問題是一個(gè)多月前埋下的WHERE restaurant_id #{restaurantId} ORDER BY created_at DESC LIMIT 20當(dāng)時(shí)沒建聯(lián)合索引結(jié)果隨著訂單表數(shù)據(jù)量增長(zhǎng)排序和過濾都全表掃描了。我加了一個(gè)聯(lián)合索引(restaurant_id, created_at DESC)以后接口耗時(shí)從1.8秒降到40毫秒。這個(gè)案例想說的是索引設(shè)計(jì)一定要配合實(shí)際查詢模式來列表頁的篩選字段、排序字段都應(yīng)該通過聯(lián)合索引覆蓋不要等到慢查詢報(bào)警才去補(bǔ)救。再補(bǔ)充一條分頁的坑如果訂單量特別大用LIMIT 10000, 20這種深分頁性能很差可以用游標(biāo)分頁代替。也就是上一頁最后一條訂單的時(shí)間戳作為下一頁的查詢條件避免掃大量無關(guān)行。這個(gè)優(yōu)化對(duì)移動(dòng)端下拉加載歷史訂單特別有效。最終這套系統(tǒng)上線穩(wěn)定運(yùn)行了大半年。從一臺(tái)最小配置的云主機(jī)起步到高峰期扛過了一天幾萬的訂單量。整體看下來國(guó)際版掃碼點(diǎn)餐的難點(diǎn)不在單點(diǎn)技術(shù)而在業(yè)務(wù)理解和抽象能力。我在實(shí)際開發(fā)中的最大體會(huì)是做國(guó)際化項(xiàng)目第一個(gè)版本就要把語言、時(shí)區(qū)、幣種當(dāng)成一等公民來設(shè)計(jì)千萬不要在中后期打補(bǔ)丁。剛開始多花一周時(shí)間把基礎(chǔ)模型做扎實(shí)后面整體能省出一個(gè)月的返工時(shí)間。還有一點(diǎn)如果你也是自己做整個(gè)系統(tǒng)一定要留出足夠的聯(lián)調(diào)時(shí)間國(guó)際版的三方對(duì)接支付網(wǎng)關(guān)、CDN、消息推送、匯率API比國(guó)內(nèi)版本多得多聯(lián)調(diào)周期至少按國(guó)內(nèi)版的1.5倍估。掃碼點(diǎn)餐的腳手架代碼不難找但每一家餐廳的菜單結(jié)構(gòu)、后廚動(dòng)線和支付習(xí)慣都不同真正值錢的不是那幾行Java代碼而是對(duì)業(yè)務(wù)的深入理解。希望這篇文章能幫你少踩幾個(gè)坑把項(xiàng)目順利做上線。