端主觀題復盤:秒殺、冪等與分布式鎖的坑)
1. 主觀題背后的能力模型小米在篩選什么樣的“穩(wěn)定器”剛看到“小米2018秋招服務(wù)端工程師主觀題合集”這個題目的時候我第一反應(yīng)是“老古董了”。但真當我靜下心把這幾道題過了一遍之后反而覺得后背有點發(fā)涼——這哪是什么面試題這分明是每個服務(wù)端工程師在線上踩過無數(shù)坑之后才會沉淀出來的“肌肉記憶”。那會兒互聯(lián)網(wǎng)大廠的秋招主觀題特別喜歡考“場景設(shè)計”。不給你標準答案就給你一個特別容易出問題的業(yè)務(wù)場景然后看你怎么接。比如讓你設(shè)計一個秒殺系統(tǒng)或者讓你聊聊客戶端與服務(wù)端的數(shù)據(jù)一致性怎么保證。當時很多應(yīng)試者覺得這是在刁難人現(xiàn)在回頭看小米的主觀題其實是在幫候選人畫像他們要的并不是一個只會寫CRUD、只會調(diào)接口的工具人而是一個能對系統(tǒng)穩(wěn)定性負責、能在極端流量下保持腦子清醒的“穩(wěn)定器”。為什么這么說因為服務(wù)端工程師和前端、客戶端工程師最大的區(qū)別在于客戶端出了問題用戶罵的是App卡服務(wù)端出了問題用戶罵的是整個公司。你永遠在幕后但又永遠在事故的第一現(xiàn)場。主觀題里那些看起來“不痛不癢”的小問號比如“接口超時了怎么辦”“MQ重復消費了怎么處理”“客戶端拿到了過期的路由配置怎么兜底”其實都是線上真實事故的高發(fā)點。小米把這些東西拿到考場上就是想看看你有沒有那根“弦”。1.1 拆解主觀題常見考察維度2018年那套題如果我沒記錯大體分三個維度可用性、一致性、擴展性??捎眯詥柕氖恰澳阍趺幢WC服務(wù)不掛”一致性問的是“數(shù)據(jù)沒錯亂”擴展性問的是“流量翻倍了你怎么辦”。這三個維度正好對應(yīng)服務(wù)端工程師的三個成長階段初級保證能跑中級保證不崩高級保證優(yōu)雅。所謂“優(yōu)雅”就是你不僅要把功能做出來還得把異常路徑、邊界條件、降級方案都想清楚。我見過很多簡歷上寫著“熟悉高并發(fā)”的候選人一聊到具體方案就露餡。比如一提秒殺就說“用Redis”但你再追問一句“Redis掛了怎么辦”他就開始支支吾吾。主觀題最大的價值就在這里——它不是考你背沒背過八股文而是考你在那種“信息不全、時間緊張、非黑即白”的狀態(tài)下能不能給出一個邏輯自洽、可落地驗證的解決方案。1.2 服務(wù)端認證的“反直覺”邏輯另一點很有意思小米主觀題里經(jīng)常穿插一些“反直覺”的設(shè)計題。比如服務(wù)端接口測試很多候選人以為就是把接口調(diào)通、返回200就完事了。真正寫過服務(wù)端測試的人會知道服務(wù)端最難測的不是“正常流程”而是“異常流程”??蛻舳藬嗑W(wǎng)重連了怎么辦數(shù)據(jù)庫超時了怎么辦下游服務(wù)返回了一個超大的JSON導致內(nèi)存溢出怎么辦這背后的邏輯是服務(wù)端工程師的核心價值不在于你讓正確的事情發(fā)生而在于你讓錯誤的事情不發(fā)生。一個接口能跑通那是基本功一個接口在極端情況下不拖垮整個系統(tǒng)那才是功力。主觀題想篩選的就是那些能在腦海中預演“各種死法”的工程師。2. 真題復盤一秒殺減庫存為什么你的分布式鎖會超賣2018年小米秋招主觀題里有一道題我印象特別深刻后來也經(jīng)常拿來給團隊新人做培訓大意是“一個商品庫存只有10件但有1萬人同時搶購你怎么設(shè)計服務(wù)端接口保證不超賣”很多候選人一看這題就樂了這不簡單嗎加鎖啊于是脫口而出“用synchronized”。但這是單機鎖放到集群環(huán)境里根本不生效。接著又有人反應(yīng)過來說“用分布式鎖用Redis的setnx”。這時候我會追問一句“然后呢”然后很多人就卡住了。其實這道題背后隱藏著一個巨大的深坑Redis分布式鎖本身并不能保證絕對安全。2.1 場景復現(xiàn)與常規(guī)解法我們先看最基本的實現(xiàn)。庫存扣減很多人會寫成這樣// 偽代碼不推薦的生產(chǎn)寫法 public Boolean deductStock(Long skuId, Integer num) { String lockKey lock:stock: skuId; Boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1); if (!locked) { return false; // 沒拿到鎖直接返回失敗 } try { int stock stockMapper.selectStock(skuId); if (stock num) { return false; } stockMapper.deductStock(skuId, num); return true; } finally { redisTemplate.delete(lockKey); // 釋放鎖 } }看著似乎沒什么問題實際上至少有三個坑。第一個坑沒有設(shè)置過期時間如果服務(wù)在try塊里拋異常宕機了鎖永遠不會釋放后續(xù)所有請求全部失敗這就是死鎖。第二個坑即使加了過期時間比如設(shè)置10秒過期但業(yè)務(wù)執(zhí)行超過了10秒鎖自動過期了另一個線程又獲取到了鎖兩個線程同時執(zhí)行扣減依然會超賣這就是鎖失效。第三個坑線程A刪鎖的時候可能把線程B的鎖給刪了。因為線程A執(zhí)行超時鎖已過期線程B拿到了鎖然后線程A finally里執(zhí)行delete把線程B的鎖刪掉了。2.2 鎖失效與超賣問題RedLock的門道針對上面第三點最簡單的處理辦法是在設(shè)置value的時候塞入一個唯一標識比如UUID刪除的時候先判斷這個標識是不是自己的是才刪。這就是很多公司內(nèi)部Redis鎖工具的雛形。但還有一個更致命的問題無法回避——鎖過期。Redis的setnx鎖如果設(shè)置了過期時間業(yè)務(wù)執(zhí)行一旦超時鎖就自動釋放了。這時候另一個線程拿著新鎖進來了兩個線程同時寫庫存超賣依然會發(fā)生。有人會提RedLock也就是Redis官方推薦的分布式鎖紅鎖方案。簡單說就是多節(jié)點Redis實例奇數(shù)個客戶端向半數(shù)以上節(jié)點同時申請鎖如果成功數(shù)量過半才算拿到鎖。但RedLock也不是萬能的它依賴了“時鐘漂移”這個非常不靠譜的假設(shè)還依賴GC暫停時長不能超過鎖過期時間這在生產(chǎn)環(huán)境里很難100%保證。而且RedLock的運維成本很高很多中小團隊根本不會為了一個秒殺場景去部署5個Redis節(jié)點。我在實際項目中更推薦“鎖數(shù)據(jù)庫樂觀鎖兜底”的雙保險策略。也就是先用分布式鎖做一道攔截攔截掉大多數(shù)請求數(shù)據(jù)庫層再做一個“CAS式扣減”用更新行數(shù)作為扣減成功的判定條件UPDATE stock SET remaining remaining - #{num} WHERE sku_id #{skuId} AND remaining #{num}這條SQL利用數(shù)據(jù)庫的行鎖和原子性在最后一道關(guān)口保證不會扣成負數(shù)。如果更新影響行數(shù)為0說明庫存不足或并發(fā)沖突直接返回失敗。這樣的好處是即使Redis鎖失效了數(shù)據(jù)庫也能兜住底。至于Redis鎖就當它是一個“流量攔截器”減輕數(shù)據(jù)庫壓力的。2.3 深入答好“如果Redis掛了”的追問面試官很喜歡在你去掉一個Bug之后馬上給你制造一個新的災(zāi)難“Redis掛了怎么辦”這個問題其實沒有標準答案但考察的是你有沒有降級意識。最穩(wěn)妥的降級方案是做一層多級緩存。比如在本地內(nèi)存Caffeine里緩存一個“秒殺開關(guān)”一旦Redis不可用本地緩存直接拉起“熔斷開關(guān)”所有秒殺請求直接返回“活動太火爆”防止流量穿透到數(shù)據(jù)庫。等Redis恢復后再通過配置中心下發(fā)“關(guān)閉熔斷”的指令。另外秒殺場景還應(yīng)該做請求削峰。不是說用戶點了一下按鈕就必須立刻同步調(diào)用扣減接口。服務(wù)端完全可以把“請求接收”和“請求處理”分離開用戶秒殺請求進來后先返回“排隊中”把請求體丟進MQ后端異步消費、依次扣減。這樣即使Redis掛了MQ兜底消息在隊列里不會丟也能保證最終一致性。3. 真題復盤二客戶端回調(diào)重試服務(wù)端如何守住冪等底線第二類高頻主觀題是關(guān)于接口冪等設(shè)計的。我記得有一道題的大意是“訂單支付成功后支付平臺會回調(diào)商戶服務(wù)端但回調(diào)可能會重復發(fā)送多次服務(wù)端如何保證訂單狀態(tài)不被重復修改請設(shè)計一個可靠的方案?!边@題其實比秒殺還貼近日常。做過支付系統(tǒng)的同學都知道支付回調(diào)這種外部依賴根本不可能保證“絕對只通知一次”。支付平臺的SLA再高也可能出現(xiàn)網(wǎng)絡(luò)抖動、回調(diào)超時、服務(wù)端重啟然后觸發(fā)它的重試機制。所以服務(wù)端必須默認凡是外部回調(diào)都當“無限重試”來處理。3.1 支付回調(diào)重復通知的冪等陷阱有些人會想“這還不簡單我收到回調(diào)后先查一下訂單狀態(tài)如果是已支付就直接返回成功。”這個思路方向是對的但代碼落地時很容易寫歪。比如// 偽代碼存在并發(fā)問題的寫法 public void handlePayCallback(PayNotify notify) { Order order orderMapper.selectByOrderId(notify.getOrderId()); if (PAID.equals(order.getStatus())) { return; // 已處理過直接返回 } order.setStatus(PAID); order.setPayTime(notify.getPayTime()); orderMapper.updateById(order); }這段代碼在“單線程順序處理”下沒問題但如果在并發(fā)場景下兩個線程同時查到訂單狀態(tài)都是“UNPAID”然后都執(zhí)行了update狀態(tài)就被重復修改了。雖然結(jié)果可能一樣但如果回調(diào)里除了改狀態(tài)還有加積分、發(fā)優(yōu)惠券、通知WMS發(fā)貨等一堆操作就會造成“重復發(fā)貨”“積分重復到賬”等嚴重事故。3.2 狀態(tài)機服務(wù)端治理數(shù)據(jù)一致性的利器在很多實際的項目里服務(wù)端最怕的不是外部調(diào)用不可用而是調(diào)用方“不老實”你說好了回調(diào)一次他偏給你回調(diào)十次你說好了先下單再支付他偏要支付完了再取消下單。這種混亂的調(diào)用邏輯單靠接口文檔去約束根本不現(xiàn)實。所以服務(wù)端必須把自己的核心數(shù)據(jù)設(shè)計成狀態(tài)機驅(qū)動的模型。什么叫“狀態(tài)機”就是給訂單定義一個狀態(tài)流轉(zhuǎn)的路徑地圖——哪些狀態(tài)能到哪些狀態(tài)不能到哪些狀態(tài)由服務(wù)端統(tǒng)一校驗而不是任由客戶端隨意修改。比如一個標準訂單的狀態(tài)流轉(zhuǎn)是待支付 - 已支付 - 已發(fā)貨 - 已完成 待支付 - 已取消 已支付 - 退款中 - 已退款在這個模型里“已支付”只能從“待支付”流轉(zhuǎn)過來。如果回調(diào)到達時訂單已經(jīng)處于“已支付”狀態(tài)那這個回調(diào)就是一個重復通知直接忽略掉。如果訂單已經(jīng)到了“已完成”或者“已取消”那就更不用說了直接返回成功給支付平臺。落實到代碼上可以這樣實現(xiàn)public void handlePayCallback(PayNotify notify) { // 用條件更新代替“先查后改”從源頭避免并發(fā)交錯 int rows orderMapper.updateStatusIfAllowed( notify.getOrderId(), WAIT_PAY, // 期望的舊狀態(tài) PAID, // 要更新成的新狀態(tài) notify.getPayTime() ); if (rows 1) { // 更新成功說明這是首次支付回調(diào) doPostPayActions(notify.getOrderId()); } else { // 更新失敗說明訂單狀態(tài)已被其他請求修改過 log.warn(重復支付回調(diào)或狀態(tài)非法, orderId: {}, notify.getOrderId()); } }對應(yīng)的SQL就是UPDATE order SET status #{newStatus}, pay_time #{payTime} WHERE order_id #{orderId} AND status #{oldStatus}這種“樂觀鎖狀態(tài)機”的組合是服務(wù)端應(yīng)對無效重復請求最有效的武器。它把“判斷”和“執(zhí)行”合并成一條原子操作根本不給競賽條件留機會。3.3 數(shù)據(jù)庫唯一鍵兜底與SQL示例除了狀態(tài)機還有一種更“硬核”的冪等方案就是利用數(shù)據(jù)庫唯一鍵來兜底。典型的場景是別外部訂單號了比如支付回調(diào)里帶了一個“transactionId”我們可以建一張獨立表CREATE TABLE pay_callback_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, transaction_id VARCHAR(64) NOT NULL, order_id BIGINT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_transaction_id (transaction_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;收到回調(diào)后先嘗試插入這條記錄。如果插入成功說明是第一次回調(diào)可以繼續(xù)執(zhí)行后續(xù)流程如果插入時拋出了“Duplicate entry”異常說明之前已經(jīng)處理過這個transactionId了直接返回成功。這個方案的好處是它不依賴“先查后改”而是利用數(shù)據(jù)庫的最底層約束來保證冪等。就算你的應(yīng)用層出現(xiàn)了并發(fā)問題、重復消費問題數(shù)據(jù)庫唯一鍵也會攔住第二條有效數(shù)據(jù)。壞處是多了一張表多了一次寫入會增加一點延遲但對支付這種對賬準確性要求極高的場景這點延遲完全值得。4. 擴展題拆解路由菜單下發(fā)與集群配置分發(fā)要的是集群思維除了純業(yè)務(wù)場景小米主觀題里還有一類很考驗“集群思維”的擴展題。比如有一道題問的是“一個中后臺系統(tǒng)登錄后需要根據(jù)用戶的角色從服務(wù)端動態(tài)獲取路由菜單服務(wù)端應(yīng)該怎么設(shè)計接口和數(shù)據(jù)結(jié)構(gòu)如果菜單在用戶使用過程中發(fā)生變化客戶端如何感知到最新的路由配置”這道題如果只是站在客戶端角度做做接口就算了但題意明顯是在問服務(wù)端要怎么管理這些配置、如何推送到各個節(jié)點。4.1 網(wǎng)關(guān)路由頻繁變更如何在線生效剛看到“從服務(wù)端獲取路由菜單”這個話題你可能會覺得很簡單——這不就是一個查詢接口嗎用戶在登錄后下拉菜單權(quán)限列表服務(wù)端返回給他不就行了但實際上把這個功能放到“集群架構(gòu)”里看水就深了。一個公司往往有多個微服務(wù)。“動態(tài)路由”不只是給前端的菜單用的它還會用在服務(wù)端內(nèi)部的網(wǎng)關(guān)層。比如你有一個營銷活動服務(wù)雙十一的時候活動A上線了需要一個新路由來承載活動結(jié)束下架路由也要同步下線。如果這個路由信息是各個服務(wù)節(jié)點的本地配置文件那就意味著每次路由變化你都要一臺一臺地去改配置、重啟服務(wù)。在集群節(jié)點很多的時候這種方式會耗費大量時間而且極易出現(xiàn)“改了A沒改B”的問題。正確的做法是把路由配置從本地剝離統(tǒng)一收口到一個配置中心。服務(wù)端各節(jié)點啟動時從配置中心拉取全量路由建立本地緩存。配置中心發(fā)布新版本路由后會通過長輪詢或WebSocket推動變更通知。各服務(wù)節(jié)點收到通知后拉取最新路由并熱加載到本地內(nèi)存整個過程不需要重啟服務(wù)。這里可以參考很多開源實現(xiàn)比如Nacos、Apollo、Consul等。這些配置中心本質(zhì)上干的就是同一件事動態(tài)配置的管理和推送。面試時能提到這層就已經(jīng)比只說接口要加分很多。4.2 對比幾類注冊中心的選型邏輯順著配置中心往下聊難免會聊到注冊中心。注冊中心和配置中心看上去有點像但本質(zhì)完全不一樣。注冊中心解決的是“服務(wù)在哪里”的問題配置中心解決的是“配置怎么變”的問題。在小米那種體量的技術(shù)體系里注冊中心和配置中心往往綁定在一起形成一套完整的微服務(wù)基礎(chǔ)設(shè)施。面試時如果能順手對比一下幾類常見注冊中心的優(yōu)劣是很加分的。比如組件一致性協(xié)議優(yōu)勢劣勢適用場景ZooKeeperZAB類似Paxos數(shù)據(jù)強一致、社區(qū)成熟、節(jié)點角色清晰需要自己維護會話臨時節(jié)點有羊群效應(yīng)分布式協(xié)調(diào)、分布式鎖、元數(shù)據(jù)存儲NacosRaftAP和CP模式可切換、內(nèi)置配置中心、支持HTTP/gRPC性能和大規(guī)模場景需壓測驗證服務(wù)發(fā)現(xiàn)與配置管理一體化場景ConsulRaft多數(shù)據(jù)中心、自帶健康檢查、DNS接口運維成本稍高依賴Agent多數(shù)據(jù)中心的微服務(wù)架構(gòu)etcdRaft性能好、Watch機制強大、云原生生態(tài)好需要搭配其他組件實現(xiàn)服務(wù)發(fā)現(xiàn)完整邏輯云原生Kubernetes基礎(chǔ)設(shè)施這里要提醒一句選型一定要結(jié)合自己團隊的規(guī)模和運維能力。不要因為Nacos支持AP/CP切換就無腦上也不要因為ZooKeeper“老派”就嫌棄。真實的生產(chǎn)環(huán)境里穩(wěn)定、易用、團隊熟悉比技術(shù)本身的新舊更重要。4.3 服務(wù)端接口測試的輔助與驗證閉環(huán)回到“路由菜單下發(fā)”這道題還有一處容易漏掉的考點就是接口的測試閉環(huán)。很多候選人答完接口設(shè)計就停了完全沒提“你怎么驗證這個動態(tài)路由是正確且完整地落在每一臺服務(wù)節(jié)點上的”。這其實是一個很要命的遺漏。服務(wù)端是集群架構(gòu)接口返回的數(shù)據(jù)在每一臺節(jié)點上可能都有緩存。如果只有其中一臺節(jié)點緩存了舊數(shù)據(jù)用戶請求打到那臺節(jié)點上時就會看到異常頁面。正確的做法是在動態(tài)路由下發(fā)后服務(wù)端要做一次“全量節(jié)點一致性校驗”。最簡單的方式是節(jié)點更新完本地路由后向配置中心上報一個版本號配置中心對比所有節(jié)點的上報版本號如果發(fā)現(xiàn)某個節(jié)點版本落后就向它重新推送一次。這個過程可以用定時任務(wù)來做也可以做成事件驅(qū)動。除了服務(wù)端自檢客戶端側(cè)也要做一層兜底每次拿到路由后記錄一個版本號前端菜單的接口里帶上這個版本號一旦發(fā)現(xiàn)版本不一致就重新拉取全量路由。雙端都做好校驗才能形成一個完整的驗證閉環(huán)。5. 答題避坑從“能用”到“優(yōu)雅”主觀題的高分動作聊到這里相信你已經(jīng)看出來了小米主觀題說到底考的就一件事你有沒有一套完整的、體系化的服務(wù)端思維框架。你給出的方案不一定要多炫酷但一定得業(yè)務(wù)可落地、狀態(tài)可感知、故障可降級、數(shù)據(jù)可恢復。5.1 “答非所問”的典型死法我在面試別人的時候最常見的死法就是“答非所問”。面試官問“Redis分布式鎖在秒殺場景下怎么保證不超賣”這是一個開放性問題但核心考點很明確是“并發(fā)一致性”。很多人上來講了一堆Redis的數(shù)據(jù)結(jié)構(gòu)甚至開始介紹Redis的持久化機制講得頭頭是道但就是不往“鎖失效”“CAS扣減”“消息隊列削峰”這些關(guān)鍵點上靠。這種回答技術(shù)深度可能夠了但方向完全跑偏。主觀題的評審官要的不是你背了多少技術(shù)選型而是你在面對一個具體業(yè)務(wù)問題的時候能不能精準定位到“這里需要什么能力”。秒殺需要的是“高并發(fā)讀”和“極端寫”的保護支付回調(diào)需要的是“冪等”和“最終一致性”路由下發(fā)需要的是“配置管理和全鏈路驗證”。先定位核心考點再展開技術(shù)方案這是答主觀題的第一步。5.2 如何從“能做”描述到“做好”很多候選人做題的時候都有個通病只給結(jié)論不給過程和代價。比如“我可以用Redis做分布式鎖”這句話如果只說這么一句在面試官眼里等于沒說。一個合格的方案至少應(yīng)該包含三塊內(nèi)容為什么選它選Redis做鎖是因為它性能高、實現(xiàn)簡單能滿足大多數(shù)場景的互斥需求。有哪些副作用Redis鎖存在鎖失效和主從切換丟鎖的風險不能作為唯一的安全防線。怎么規(guī)避副作用數(shù)據(jù)庫樂觀鎖兜底本地降級開關(guān)MQ異步削峰。把這三塊都答全了才是從“能做”升級到了“做好”。換句話說面試官要的不是一個孤立的答案而是一個完整的決策樹。5.3 寫在最后的一道防線最后再分享一個我個人的經(jīng)驗。答主觀題的時候一定要給自己留一條“保命后路”。什么叫保命后路就是當你的方案在極端情況下確實出問題的時候你有沒有Plan B。比如你設(shè)計了Redis分布式鎖那Redis掛了怎么辦比如你設(shè)計了MQ異步削峰那MQ本身堆積了怎么辦比如你設(shè)計了配置中心熱加載路由那配置中心掛了怎么辦這些問題不一定要你全部完美解決但你至少要給出一層兜底。告訴面試官我知道這里會掛我掛了之后會有監(jiān)控報警報警之后我會用降級開關(guān)把流量切走或者通過管理后臺手動干預。這層“對故障的敬畏之心”往往才是主觀題真正的加分項。說到底2018年的小米主觀題雖然已經(jīng)過去了好幾年但里面涉及到的秒殺、冪等、配置分發(fā)、集群容災(zāi)到今天依然是服務(wù)端工程師最核心的日常。哪怕你現(xiàn)在不去面試只是把這些題目當成自我練習對著空氣講一遍自己的方案也能發(fā)現(xiàn)很多自己平時沒想明白的地方。服務(wù)端這門手藝就是在這種一次次的“自問自答”里磨出來的。