)
秒殺系統幾乎是所有頭部互聯網公司 Java 后端面試里的壓軸題也是我這些年面試候選人時最愛聊的話題之一。你簡歷上只要寫了高并發(fā)、微服務這幾個詞十有八九會被問“設計一個秒殺系統你會怎么做”或者更直接“Redis 和數據庫庫存不一致怎么辦”“MQ 消息積壓了怎么處理”。這套“Spring Boot Kafka Redis”的組合在電商秒殺場景里已經成了事實標配背后的原理和應用細節(jié)卻往往被一筆帶過。這篇文章我把這套體系拆開揉碎講一遍包括業(yè)務層面的設計思路、技術層面的原理剖析還有我在模擬項目里一點點調出來的代碼實現和踩坑記錄。無論你是正要準備面試還是已經在負責秒殺相關系統的開發(fā)這份整理都能直接用上。1. 秒殺系統的核心挑戰(zhàn)與技術選型思路1.1 電商秒殺到底難在哪里先說業(yè)務本身。秒殺的定義就是在極短時間內、有限庫存下大量用戶同時搶購同一件商品。比如某手機品牌限量發(fā)售 200 臺開搶瞬間可能有 20 萬人同時發(fā)起請求。這樣量級的流量直接打到單機 MySQL 上數據庫 10 秒內必掛。我把秒殺系統的難點歸納成三個維度超高并發(fā)流量瞬時 QPS 可能達到 10 萬以上系統需要在上萬并發(fā)連接下保持可用。超賣防控100 個庫存賣出 120 單就是重大生產事故。超賣不僅涉及資損還會引發(fā)輿論風險屬于絕對不能踩的紅線。數據一致性庫存扣減、訂單生成、支付回調這些環(huán)節(jié)不能因為高并發(fā)就亂了套。傳統做法是把所有請求都直接打到數據庫上用 MySQL 的行鎖去控制庫存但數據庫每秒能承受的事務量級有限一般單庫也就幾千根本無法承接十萬級并發(fā)。這里面最核心的問題是——為什么要讓所有流量都打到最脆弱的環(huán)節(jié)架構設計的本質任務就是讓大部分流量在到達數據庫之前就被攔截或消化掉。1.2 為什么偏偏是這三個組件先給出一套我自己用得很順的分層模型接入層負責限流、風控、用戶身份校驗。緩存層用 Redis 承擔庫存預減和熱點數據讀取。異步緩沖層用 Kafka 承接削峰后的下單請求。存儲層MySQL 只處理真正需要落庫的數據。這套分層里每個組件各司其職Spring Boot提供了一套成熟的開發(fā)框架把 HTTP 接口、依賴注入、自動配置都做好了讓我能集中精力寫核心業(yè)務代碼而不是花時間搭骨架。Redis被放在數據庫前面是因為它的讀寫性能極高同時它支持 Lua 腳本原子執(zhí)行。庫存預減操作在 Redis 里完成可以扛住絕大多數并發(fā)流量。Kafka在這里扮演泄洪閘的角色。Redis 預減庫存成功后不直接寫數據庫而是先把消息發(fā)到 Kafka由消費者異步創(chuàng)建訂單、扣減 DB 庫存。峰值流量被削平了數據庫的寫壓力就變得可控。這套架構有一個核心假設最終一致性是被接受的。秒殺場景里用戶關心的是“我搶沒搶到”只要在幾秒內給出結果不需要像金融轉賬那樣強同步。這套組件的另一個好處是故障隔離。Redis 掛了只影響秒殺入口Kafka 堆積了訂單也不會把數據庫打崩。每個環(huán)節(jié)都有一定的緩沖余量這在真實生產環(huán)境中非常重要。2. 核心技術細節(jié)與原理剖析2.1 Redis 預減庫存把壓力擋在最前面預減庫存的核心思想是先在一個高速的存儲里把“誰能買”這件事定了再讓被選中的人去完成真正的訂單創(chuàng)建。Redis 因為兩個特點成為這個場景的完美人選一是基于內存單線程模型下讀寫延遲極低二是支持 Lua 腳本原子性執(zhí)行徹底避免了并發(fā)扣減時的競爭問題。我一般用一段 Lua 腳本來做預減。腳本先把商品的剩余庫存讀出來如果小于購買數量直接返回失敗如果充足就對庫存做扣減并返回成功。因為 Redis 是單線程執(zhí)行腳本的一段腳本在執(zhí)行期間不會被其他命令打斷所以多個請求同時來時它們是天然串行的不會出現“超賣”的問題。實際生產環(huán)境里還需要給庫存 key 設置合理的過期時間防止 Redis 內存長期堆積無用數據。我一般設置為活動結束后 1 小時這樣既能覆蓋退款、補償等后續(xù)流程又不至于長期占用內存。注意Redis 預減庫存是“預扣”數據庫的扣減才是“最終憑證”。在秒殺成功后如果用戶不支付庫存還需要回補。這里可以有兩種策略——超時自動取消訂單并回補 Redis 庫存或者在支付環(huán)節(jié)再去扣一次 DB 庫存。我在項目里采用的是前者邏輯更閉環(huán)。2.2 Kafka 異步削峰把瞬時洪峰變成平緩溪流如果 Redis 預減完成后直接把訂單寫入 MySQL數據庫依然會被瞬時打爆。因為 Redis QPS 能做到十萬MySQL 卻只有幾千這中間必須有一個緩沖地帶。Kafka 的定位就是這個緩沖。Kafka 之所以在大數據量下表現優(yōu)異核心在于它的設計理念順序寫磁盤而不是隨機寫磁盤順序寫的速度遠超隨機寫。通過分區(qū)實現并行消費一個 Topic 分成多個 Partition消費者可以并行處理不同分區(qū)的數據吞吐量隨分區(qū)數擴容。消息持久化寫入 Kafka 后消息不會丟失消費者宕機后恢復還能繼續(xù)消費為異步流程提供了可靠性保障。我把下單消息發(fā)送到 Kafka 后接口層就可以直接返回“搶購成功請等待支付”。用戶的感知是流暢的后端則按自己的節(jié)奏慢慢處理訂單創(chuàng)建。2.3 數據庫最終一致性避免超賣的最后防線Redis 預減庫存和 DB 庫存扣減是兩套數據它們天然存在不一致的窗口。但只要流程設計正確是可以做到最終一致的。我的設計是Redis 預減成功只能說明“用戶獲得了購買資格”真正的庫存扣減發(fā)生在 Kafka 消費者創(chuàng)建訂單時。DB 層用一句帶條件的 SQL 來扣減庫存——UPDATE product SET stock stock - 1 WHERE id ? AND stock 0。這句 SQL 利用數據庫行鎖保證同一商品在同一時刻只有一個事務能修改庫存從物理層面避免了超賣。在消息異步處理過程中如果消費者處理失敗可以重試。如果反復重試仍然失敗需要把消息轉入死信 Topic再通過定時任務補償。這些都在代碼層面做了兜底保證 DBA 層面的數據最終不會多扣或少扣。這里有一個容易犯的錯誤認為只要 Redis 扣減正確DB 扣不扣都無所謂。實際上Redis 只是分布式緩存數據可能因宕機丟失。DB 才是最終數據的權威來源。兩個系統的數據必須對上賬依賴對賬腳本定期掃描差異。從“強一致性”到“最終一致性”的思維轉變是秒殺系統架構設計的核心也是面試官最想從你嘴里聽到的關鍵詞之一。3. 實操過程與代碼實現下面這套代碼是一個完整的秒殺核心流程我從一個模擬項目里抽取出來的已經去掉業(yè)務細節(jié)保留了最核心的主鏈路。3.1 整體項目結構seckill-demo/ ├── pom.xml ├── src/main/java/com/demo/seckill/ │ ├── controller/SeckillController.java │ ├── service/SeckillService.java │ ├── service/OrderService.java │ ├── mq/SeckillProducer.java │ ├── mq/OrderConsumer.java │ ├── config/RedisConfig.java │ └── mapper/ProductMapper.java └── src/main/resources/ ├── application.yml └── lua/stock.lua3.2 秒殺接口前置校驗與限流第一個入口是 Controller。這里做的三件事是校驗用戶登錄態(tài)、準備 Redis key、調用預減庫存邏輯。RestController RequestMapping(/api/seckill) public class SeckillController { Autowired private SeckillService seckillService; PostMapping(/{skuId}) public SeckillResult doSeckill(PathVariable Long skuId, RequestParam Long userId) { // 前置校驗用戶是否登錄、是否在黑名單中 if (userId null) { return SeckillResult.fail(請先登錄); } // 限流每個用戶每秒最多請求3次 boolean allowed seckillService.rateLimit(userId); if (!allowed) { return SeckillResult.fail(請求過于頻繁); } return seckillService.startSeckill(skuId, userId); } }限流是一個極其重要的前置能力。我見過不少系統連最基本的按用戶限流都沒有做一個用戶用腳本幾十毫秒發(fā)一次請求庫存瞬間被腳本黨搶光。限流最輕量的實現是用 Redis 計數器固定窗口 1 秒內限制 N 次。更復雜的滑動窗口、令牌桶算法適合放到網關層這里 Redis 版足夠。3.3 Redis 預減庫存的 Lua 腳本實現這是整套系統的核心我把 Lua 腳本單獨放在 resources 目錄。腳本接收三個參數商品庫存 key、購買數量、用戶標識。腳本內部完成庫存檢查與扣減。-- KEYS[1]: 庫存 key -- ARGV[1]: 購買數量 -- ARGV[2]: 用戶唯一標識 local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) tonumber(ARGV[1]) then return 0 end redis.call(DECRBY, KEYS[1], ARGV[1]) -- 記錄已搶購用戶用于冪等判斷 redis.call(SADD, seckill_users_ .. KEYS[1], ARGV[2]) return 1Java 側調用Service public class SeckillService { Autowired private StringRedisTemplate redisTemplate; Autowired private SeckillProducer seckillProducer; private static final DefaultRedisScriptLong SECKILL_SCRIPT; static { SECKILL_SCRIPT new DefaultRedisScript(); SECKILL_SCRIPT.setLocation(new ClassPathResource(lua/stock.lua)); SECKILL_SCRIPT.setResultType(Long.class); } public SeckillResult startSeckill(Long skuId, Long userId) { String stockKey seckill:stock: skuId; String userKey seckill:user: skuId; // 冪等校驗同一用戶不能重復搶 Boolean isMember redisTemplate.opsForSet().isMember(userKey, userId.toString()); if (Boolean.TRUE.equals(isMember)) { return SeckillResult.fail(該商品已搶購請勿重復操作); } // 執(zhí)行 Lua 預減庫存 Long result redisTemplate.execute(SECKILL_SCRIPT, Collections.singletonList(stockKey), 1, userId.toString()); if (result null || result -1) { return SeckillResult.fail(活動不存在或已結束); } if (result 0) { return SeckillResult.fail(商品已售罄); } // 預扣成功發(fā)送 MQ 消息異步創(chuàng)建訂單 seckillProducer.sendSeckillMessage(skuId, userId); return SeckillResult.success(搶購成功請盡快支付); } }這段代碼的關鍵在于 Lua 腳本把“檢查庫存”和“扣減庫存”合成了一個原子操作。如果不這樣做檢查完發(fā)現庫存還有下一秒另一個請求把庫存扣光了當前請求還會繼續(xù)執(zhí)行——這就是典型的超賣 bug。我建議把冪等校驗放在 Lua 腳本內部用 SADD 命令保證一個用戶只能搶一次避免 Redis 網絡開銷之外的多余事務。上面代碼里我用 Set 做了第一層校驗Lua 內再做二次校驗雙保險。3.4 Kafka 生產者與消費者的落地細節(jié)生產者負責把預減成功的數據丟給 Kafka。這里要注意選對 key保證同一個商品的消息進入同一個分區(qū)才能做到局部順序消費。Component public class SeckillProducer { Autowired private KafkaTemplateString, String kafkaTemplate; public void sendSeckillMessage(Long skuId, Long userId) { String message skuId : userId; // 以 skuId 為 key同一商品的訂單進入同一分區(qū)保持順序 kafkaTemplate.send(seckill_orders, skuId.toString(), message); } }消費者一端創(chuàng)建訂單的流程包含幾個關鍵步驟接收消息、冪等校驗、扣減 DB 庫存、創(chuàng)建訂單。Component public class OrderConsumer { Autowired private OrderService orderService; KafkaListener(topics seckill_orders, groupId seckill_order_group) public void onMessage(ConsumerRecordString, String record) { String[] parts record.value().split(:); Long skuId Long.parseLong(parts[0]); Long userId Long.parseLong(parts[1]); // 1. 前置冪等校驗同一個訂單不能創(chuàng)建兩次 if (orderService.existsOrder(skuId, userId)) { return; } // 2. 扣減 DB 庫存條件更新防止超賣 int rows orderService.deductStock(skuId); if (rows 0) { // 更新失敗庫存不足發(fā)送告警并補償 Redis 庫存 orderService.compensateStock(skuId); return; } // 3. 創(chuàng)建訂單 orderService.createOrder(skuId, userId); } }DB 扣減庫存的 Mapper用了條件更新來兜底超賣Mapper public interface ProductMapper { Update(UPDATE product SET stock stock - 1 WHERE id #{skuId} AND stock 0) int deductStock(Param(skuId) Long skuId); }注意這里返回的rows如果為 0說明數據庫判斷庫存已經不足降級方案是把 Redis 里的庫存回補回去。這套回補邏輯我測試了很多次核心難點在于可能多個消費者同時失敗回補時又要保證 Redis 庫存與 DB 庫存的一致性這里我額外加了分布式鎖控制回補并發(fā)度。3.5 消息手動提交與失敗重試在實際生產環(huán)境中Kafka 消費者不能使用默認的自動提交 offset否則會出現“消息被提交但業(yè)務處理失敗”的場景。我使用手動確認模式只有訂單創(chuàng)建成功后才提交 offset。KafkaListener(topics seckill_orders, groupId seckill_order_group) public void onMessage(ConsumerRecordString, String record, Acknowledgment ack) { try { //... 業(yè)務邏輯 ack.acknowledge(); } catch (Exception e) { // 記錄日志不提交 offset等待消息消費超時后自動重平衡 log.error(訂單創(chuàng)建失敗: skuId{}, userId{}, skuId, userId, e); } }這個細節(jié)很容易被忽略但它直接影響數據的可靠性和最終一致性。3.6 Spring Boot 整合配置application.yml 里最關鍵的幾項配置spring: redis: host: localhost port: 6379 kafka: bootstrap-servers: localhost:9092 producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.apache.kafka.common.serialization.StringSerializer acks: all retries: 3 consumer: group-id: seckill_order_group enable-auto-commit: false auto-offset-reset: latest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.apache.kafka.common.serialization.StringDeserializeracks: all表示消息被所有副本接收才算成功retries: 3提高了發(fā)送可靠性。消費者開啟enable-auto-commit: false并且配合手動 ack才能真正做到“不丟消息”。4. 常見問題與排查技巧實錄4.1 超賣問題排查超賣是最嚴重的生產事故我把它放在第一位說。模擬超賣時可以使用 JMeter 或 Gatling 并發(fā)請求 1000 個用戶搶 100 個庫存。如果壓測后發(fā)現訂單數量超過了庫存數量排查方向按以下順序進行檢查 Redis Lua 腳本是否正確執(zhí)行扣減時是否先判斷庫存不足再返回失敗。檢查 DB 扣庫存 SQL 是否帶條件WHERE stock 0必不可少。檢查消費者是否重復消費沒有冪等設計時同樣的消息被 Kafka 集群重平衡后可能消費兩次。排查超賣的最佳方式是在數據庫訂單表增加唯一鍵約束比如(sku_id, user_id)唯一索引作為數據層面的最后防線。4.2 緩存穿透、擊穿與雪崩這三個問題是 Redis 場景的經典配套問題面試必考生產必踩。穿透用戶不斷請求數據庫中不存在的商品 ID導致 Redis 查不到每次都要打到 DBDB 壓力巨大。解決方案是布隆過濾器攔截非法 ID或者將空結果緩存幾秒。擊穿某個熱點 key 在過期瞬間大量請求全部打到 DB。解決方案是熱點 key 不設置過期時間或者使用互斥鎖只允許一個請求去加載數據。雪崩大量 key 在同一時間過期導致 DB 請求量瞬間飆高。解決方案是設置過期時間時加入隨機值避免集中淘汰。我在秒殺場景里重點關注擊穿問題。秒殺商品是天然的熱點 key數量有限且訪問集中。處理方案很簡單秒殺商品的活動信息在活動期間永不過期庫存數據由 Lua 腳本和 DB 回寫聯動更新。4.3 Kafka 消息積壓與重復消費消費者處理不過來了Topic 里的 Lag 持續(xù)增長這是最讓人頭疼的問題。排查和解決我總結成四步先看消費者的處理耗時是否因為 DB 鎖等待、外部接口超時導致消費變慢。增加分區(qū)數和消費者實例數Kafka 的分區(qū)數是并發(fā)消費的上限分區(qū)不夠加再多的消費者也沒用。批量消費優(yōu)化把單條處理改成批量處理減少網絡 IO 和 DB 連接的建立次數。臨時擴容緊急情況可以新建一個臨時 Topic用多倍的消費者實例去消費原 Topic 里積壓的消息處理完成后再切回來。重復消費的解決辦法是冪等設計。最輕量的是用數據庫唯一索引插入重復訂單時直接失敗然后 catch 后做“已存在”處理。我在訂單表里加了(sku_id, user_id)唯一索引重復消費時插入會報錯但邏輯上可以把它當成一次正常的重復提交忽略掉。4.4 問題速查表問題現象可能原因排查命令/思路解決方案訂單數超過庫存數Lua 腳本未原子執(zhí)行 / DB 扣減無條件檢查 Lua 腳本和 UPDATE SQL預減腳本原子化 DB 條件更新Redis 庫存變成負數回補庫存邏輯錯誤 / 扣減前未判斷MGET seckill:stock:*查看腳本內先判斷再扣減回補加鎖Kafka Lag 持續(xù)變大消費者數量不足 / 處理邏輯慢kafka-consumer-groups.sh --describe增加分區(qū)與消費者批量消費用戶重復搶購成功冪等設計缺失檢查 Set 和唯一索引Redis Set 記錄 數據庫唯一索引消息丟失生產者 acks0 / 消費者自動提交查看日志中是否有發(fā)送失敗acksall 手動 ack5. 面試官追問方向與應答要點5.1 高頻基礎問題把 Spring Boot Kafka Redis 這套方案講完面試官通常不會就此打住而是順著往下挖原理。我在多次面試交流里發(fā)現問得最密集的有這幾個Redis 是單線程的為什么還這么快關鍵回答點是基于內存、核心數據結構高效、IO 多路復用、單線程避免上下文切換和鎖競爭。注意不要回答“Redis 所有版本都是單線程”從 6.0 開始網絡模型也支持多線程了但是命令執(zhí)行還是單線程這個概念要講清楚。Kafka 為什么吞吐量遠高于傳統 MQ關鍵回答點是順序寫磁盤、零拷貝、分區(qū)并行、批量發(fā)送??梢詫Ρ?RabbitMQ 基于 AMQP 協議要處理更多復雜語義吞吐量天花板自然更低。為什么不用 Redis 直接把訂單存了關鍵回答點是Redis 是緩存可靠性不夠數據重啟可能丟失。訂單是核心業(yè)務數據必須有持久化保障。剛好回答這個問題能順勢引出 Kafka 的持久化機制。有一個容易踩的坑面試官問“Redis 和數據庫一致性怎么保證”你的回答里千萬不要說“用 Redis 存數據數據庫只做備份”這會讓面試官覺得你缺少分布式系統的基本素養(yǎng)。正確思路是強調 Redis 預扣、DB 落庫、對賬補償的最終一致性鏈路。5.2 進階場景推導面試官接下來的問題往往是“如果流量再漲十倍怎么辦”。核心思路是逐層加厚接入層引入負載均衡器集群水平擴容無狀態(tài)服務節(jié)點。緩存層用 Redis 集群或 Redis 分片擴容把熱點 key 分散到多節(jié)點。MQ 層增加 Topic 分區(qū)數掛更多消費者實例。DB 層引入分庫分表把訂單表按用戶維度分片降低單庫壓力。這整套擴容思路本質上就是從“單點系統”進化成“分布式系統”的過程。每一層都無狀態(tài)化每一層都能獨立擴展這個架構演進思路是面試時最有區(qū)分度的部分。5.3 設計題應答框架如果面試官給一個全新的秒殺場景讓你現場設計不要上來就寫代碼按這個順序講分析瓶頸流量峰值、庫存量、數據一致性要求。分層設計每一層負責什么。核心機制預減庫存、異步削峰、冪等去重、超時回補。數據一致性最終一致性如何保證兜底方案是什么。容量規(guī)劃預估 QPS計算 Redis、Kafka、MySQL 的量級。實操下來我能明顯感覺到這套設計邏輯比堆砌技術名詞更有說服力。不要一上來就喊 Redis 緩存、Kafka 消峰先把每一步的輸入輸出和邊界條件講清楚面試官才會覺得你真懂系統。6. 從面試到實戰(zhàn)的一點心里話我面試過不少候選人能把技術名詞背得很溜的很多但真正能答好秒殺系統的人都有幾個共同點知道每個組件在系統里承擔什么角色知道為什么用這個組件而不是另一個更知道數據不一致了怎么兜底。秒殺系統不是技術的堆砌而是一個對一致性、可靠性、高性能要求極高的綜合工程。Redis 解決了性能問題Kafka 解決了流量緩沖問題Spring Boot 解決了工程化的問題而真正讓系統跑穩(wěn)的是你對每一步異常情況都做好了預案。把上面這套設計思路理解透、代碼調過、問題踩過再遇到這類問題不管是面試還是實戰(zhàn)你都心里有底。