坑點(diǎn)講透淘金幣源碼,搞定高頻面試題)
3個(gè)坑點(diǎn)講透淘金幣源碼,搞定高頻面試題
看了一堆教程還是不會(huì)寫(xiě)項(xiàng)目?別慌,這通常是把“業(yè)務(wù)邏輯”和“底層實(shí)現(xiàn)”割裂了。在掘金技術(shù)社區(qū)翻遍關(guān)于積分系統(tǒng)的討論,你會(huì)發(fā)現(xiàn)大多數(shù)后端在面試中被問(wèn)懵,不是因?yàn)椴欢惴?,而是沒(méi)摸透像淘金幣這種高并發(fā)場(chǎng)景下的核心源碼。今天咱們不背八股文,直接拆解淘金幣兌換模塊的底層邏輯,把幾個(gè)高頻面試題的考點(diǎn)揉進(jìn)源碼里,讓你下次遇到類(lèi)似問(wèn)題,能直接掏出設(shè)計(jì)思路應(yīng)對(duì)。
入口定位:從用戶點(diǎn)擊到服務(wù)層的鏈路
很多人寫(xiě)項(xiàng)目時(shí),習(xí)慣直接調(diào)數(shù)據(jù)庫(kù),這在低并發(fā)下沒(méi)問(wèn)題,但一旦放到淘金幣這種量級(jí),系統(tǒng)瞬間就崩了。我們得先看入口。在典型的電商中臺(tái)架構(gòu)中,用戶點(diǎn)擊“使用淘金幣”按鈕后,請(qǐng)求并不會(huì)直接落在業(yè)務(wù)庫(kù)上,而是經(jīng)過(guò)網(wǎng)關(guān)層鑒權(quán),再進(jìn)入具體的業(yè)務(wù)服務(wù)。
這里有一個(gè)容易被忽視的細(xì)節(jié):冪等性控制。在源碼入口處,通常會(huì)先通過(guò) Redis 做一層攔截。為什么?因?yàn)榫W(wǎng)絡(luò)抖動(dòng)可能導(dǎo)致用戶連續(xù)點(diǎn)擊,如果每次都生成訂單,后續(xù)對(duì)賬就是個(gè)災(zāi)難。
// 偽代碼:淘金幣兌換入口攔截器
public class CoinExchangeInterceptor implements HandlerInterceptor {@Autowiredprivate StringRedisTemplate redisTemplate;@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 1. 獲取用戶ID和訂單唯一標(biāo)識(shí)(通常由前端生成或網(wǎng)關(guān)生成)String userId = (String) request.getAttribute(userId);String orderId = request.getParameter(orderId);// 2. 構(gòu)建Redis Key,利用setnx保證原子性String key = coin:lock: + userId + : + orderId;// 3. 嘗試加鎖,超時(shí)時(shí)間設(shè)為30秒,防止死鎖Boolean isLock = redisTemplate.opsForValue().setIfAbsent(key, 1, 30, TimeUnit.SECONDS);if (!isLock) {// 4. 如果加鎖失敗,說(shuō)明是重復(fù)請(qǐng)求,直接返回throw new BusinessException(請(qǐng)勿重復(fù)提交);}return true;}
}這段代碼看似簡(jiǎn)單,卻是解決高頻面試題中“如何防止重復(fù)支付”的關(guān)鍵。很多初學(xué)者會(huì)忽略 TimeUnit 的設(shè)置,導(dǎo)致一旦程序異常退出,鎖永遠(yuǎn)不釋放,直接造成線上事故。
核心片段:事務(wù)一致性與余額扣減
進(jìn)入業(yè)務(wù)核心,最頭疼的就是“扣金幣”和“發(fā)權(quán)益”這兩件事如何保證一致性。如果扣了金幣但權(quán)益沒(méi)發(fā)出去,用戶會(huì)投訴;如果權(quán)益發(fā)了但金幣沒(méi)扣,平臺(tái)就虧了。
在淘金幣的源碼實(shí)現(xiàn)中,并沒(méi)有簡(jiǎn)單地使用數(shù)據(jù)庫(kù)事務(wù)包裹整個(gè)流程,因?yàn)榘l(fā)權(quán)益可能涉及外部服務(wù)(如物流、優(yōu)惠券中心),網(wǎng)絡(luò)延遲不可控。這里采用了一種最終一致性的方案。
// 偽代碼:淘金幣扣減核心邏輯
@Service
public class CoinService {@Autowiredprivate CoinMapper coinMapper;@Autowiredprivate MessageTemplate messageTemplate;@Transactional(rollbackFor = Exception.class)public void deductCoin(String userId, Integer amount, String orderId) {// 1. 樂(lè)觀鎖更新余額// 注意:WHERE 條件中包含了 version 字段,防止并發(fā)覆蓋int rows = coinMapper.updateBalance(userId, amount, userId + : + orderId);if (rows == 0) {// 2. 更新失敗,可能是余額不足或版本沖突,拋出異?;貪Lthrow new BusinessException(扣減失敗,請(qǐng)刷新重試);}// 3. 事務(wù)提交前,發(fā)送延遲消息// 這里利用 RocketMQ 的定時(shí)消息特性,設(shè)置延遲 10 秒Message msg = new Message(coin_topic, deduct_success, orderId, userId + amount);messageTemplate.sendDelay(msg, 10);}
}這里有兩個(gè)高頻面試題的考點(diǎn):樂(lè)觀鎖 vs 悲觀鎖:源碼中使用了 version 字段,這是典型的樂(lè)觀鎖策略。在高并發(fā)讀多寫(xiě)少的場(chǎng)景下,樂(lè)觀鎖性能遠(yuǎn)優(yōu)于 SELECT FOR UPDATE 的悲觀鎖。
事務(wù)邊界與消息發(fā)送:注意,消息發(fā)送是在事務(wù)內(nèi)部。如果事務(wù)回滾,消息也會(huì)丟失嗎?這里其實(shí)有一個(gè)隱患,標(biāo)準(zhǔn)做法是使用本地消息表或者事務(wù)消息(Transaction Message)。上述代碼是簡(jiǎn)化版,生產(chǎn)環(huán)境中,messageTemplate.sendDelay 應(yīng)該被替換為 RocketMQ 的事務(wù)消息發(fā)送邏輯,確保“扣減成功”與“消息發(fā)出”的原子性。設(shè)計(jì)思想:為什么不用分布式鎖?
很多開(kāi)發(fā)者看到并發(fā)問(wèn)題,第一反應(yīng)是上 Redisson 分布式鎖。但在淘金幣這種秒殺級(jí)場(chǎng)景中,分布式鎖的性能瓶頸非常明顯。
源碼的設(shè)計(jì)思想核心在于:把并發(fā)壓力前置到緩存層,后端只做單條記錄的原子更新。緩存層預(yù)減:在 Redis 中維護(hù)一個(gè)金幣余額緩存,先執(zhí)行 DECRBY。如果 Redis 返回負(fù)數(shù),直接拒絕,根本不進(jìn)數(shù)據(jù)庫(kù)。
數(shù)據(jù)庫(kù)兜底:只有 Redis 扣減成功的請(qǐng)求,才會(huì)進(jìn)入數(shù)據(jù)庫(kù)執(zhí)行上述的樂(lè)觀鎖更新。
異步補(bǔ)償:通過(guò)消息隊(duì)列監(jiān)聽(tīng)“扣減成功”事件,異步處理后續(xù)的權(quán)益發(fā)放、日志記錄等非核心鏈路。這種架構(gòu)將數(shù)據(jù)庫(kù)的 QPS 壓力降低了 90% 以上。在面試中,如果你能講清楚“為什么選樂(lè)觀鎖而不是分布式鎖”,以及“緩存與數(shù)據(jù)庫(kù)不一致如何處理”,基本就能拿下這輪技術(shù)面。
手寫(xiě)簡(jiǎn)化版:本地模擬高并發(fā)場(chǎng)景
為了驗(yàn)證上述邏輯,我們可以寫(xiě)一個(gè)極簡(jiǎn)的本地模擬版本。假設(shè)我們有一個(gè)內(nèi)存版的“Redis”和一個(gè)模擬的“數(shù)據(jù)庫(kù)”。
// 偽代碼:本地模擬高并發(fā)扣減
public class LocalCoinSimulator {// 模擬 Redis 緩存private static final ConcurrentHashMapString, Integer redisCache = new ConcurrentHashMap();// 模擬數(shù)據(jù)庫(kù)private static final ConcurrentHashMapString, Integer dbBalance = new ConcurrentHashMap();public static void main(String[] args) {// 初始化:用戶 1001,余額 100redisCache.put(1001, 100);dbBalance.put(1001, 100);// 模擬 1000 個(gè)并發(fā)請(qǐng)求,每個(gè)請(qǐng)求扣 1 個(gè)金幣ExecutorService executor = Executors.newFixedThreadPool(20);CountDownLatch latch = new CountDownLatch(1000);for (int i = 0; i 1000; i++) {executor.submit(() - {try {exchangeCoin(1001, 1);} catch (Exception e) {// 忽略異常} finally {latch.countDown();}});}latch.await();System.out.println(Redis: + redisCache.get(1001));System.out.println(DB: + dbBalance.get(1001));}public static void exchangeCoin(String userId, int amount) {// 1. 原子扣減 Redis (模擬 Lua 腳本保證原子性)Integer result = redisCache.computeIfPresent(userId, (k, v) - {if (v = amount) {return v - amount;} else {// 余額不足,拋異常throw new RuntimeException(Insufficient balance);}});// 2. 只有 Redis 成功,才操作 DB// 這里簡(jiǎn)化為直接更新,實(shí)際應(yīng)使用樂(lè)觀鎖dbBalance.computeIfPresent(userId, (k, v) - v - amount);}
}運(yùn)行這段代碼,你會(huì)發(fā)現(xiàn) Redis 和 DB 的最終狀態(tài)是一致的(都是 0,因?yàn)?1000 個(gè)請(qǐng)求只夠扣 100 個(gè),剩下的會(huì)拋異常)。但在真實(shí)場(chǎng)景中,第 2 步的 DB 操作如果失敗,就需要依靠對(duì)賬系統(tǒng)來(lái)修復(fù) Redis 與 DB 的差異。這就是“最終一致性”的代價(jià)。
應(yīng)用場(chǎng)景與面試避坑指南
理解了淘金幣的源碼邏輯,我們可以將其遷移到很多實(shí)際項(xiàng)目中:電商優(yōu)惠券核銷(xiāo):邏輯與金幣扣減幾乎一致,區(qū)別在于優(yōu)惠券有有效期和庫(kù)存限制,需要在 Redis 中額外維護(hù)庫(kù)存計(jì)數(shù)。
游戲道具購(gòu)買(mǎi):高并發(fā)下,道具的發(fā)放可能涉及多個(gè)服務(wù)(背包、郵件、特效),必須使用消息隊(duì)列解耦。
銀行轉(zhuǎn)賬:這是強(qiáng)一致性場(chǎng)景,不能用上述的最終一致性方案,必須使用 TCC 或 Saga 模式。在準(zhǔn)備高頻面試題時(shí),務(wù)必注意以下避坑點(diǎn):不要盲目吹噓分布式鎖:面試官會(huì)追問(wèn)“鎖的粒度”、“鎖過(guò)期怎么辦”、“Redis 宕機(jī)了怎么辦”。如果答不上來(lái),不如老實(shí)說(shuō)“在可控范圍內(nèi),優(yōu)先用樂(lè)觀鎖”。
強(qiáng)調(diào)對(duì)賬機(jī)制:任何最終一致性方案,都必須提到“對(duì)賬”。如果沒(méi)有對(duì)賬,就是耍流氓。
區(qū)分緩存穿透與擊穿:在金幣余額查詢(xún)場(chǎng)景中,如果用戶余額為 0,每次請(qǐng)求都會(huì)打到數(shù)據(jù)庫(kù),這就是穿透。解決方案是緩存空對(duì)象,設(shè)置較短的過(guò)期時(shí)間。淘金幣的源碼實(shí)現(xiàn),本質(zhì)上是CAP 定理在工程實(shí)踐中的妥協(xié)。它犧牲了一致性(短暫不一致),換取了可用性(高并發(fā)不崩潰)和分區(qū)容錯(cuò)性(網(wǎng)絡(luò)抖動(dòng)可恢復(fù))。
這個(gè)知識(shí)點(diǎn)你面試被問(wèn)過(guò)嗎?留言說(shuō)說(shuō)