100 萬的併發,如何設計一個商品搜索系統?
前言
大家好,我是田螺。
今天我們來看一道比較有深度的面試題:百萬併發下,商品搜索系統,你如何設計呢?
假設場景:某電商平臺大促期間,需支撐每秒 100 萬次的商品搜索請求,要求響應時間≤200ms,同時應對商品數據量超 10 億條。假設給你來做系統設計,怎麼做呢?
如果是我來回答面試官這道題的話,我會按照這些思路來跟面試官闡述:
-
爲什麼不能用 MySQL 的 llike?
-
總體架構設計
-
核心關鍵設計
-
爲什麼不能用 mysql 的 like?
我們每次提到關鍵詞搜索,大家很容易就想到數據庫的 like,比如:
SELECT * FROM products WHERE title LIKE '%智能手機%' LIMIT 10;
但是,顯然商品數據量超 10 億條,搜索不能用 like。
-
LIKE '%keyword%'
無法利用索引,觸發全表掃描。10 億數據時單次查詢可能耗時數秒。 -
不支持分詞搜索(如 "手機殼" 無法拆分爲 "手機" 和 "殼" 單獨匹配)
-
分庫分表後跨庫 LIKE 查詢複雜度指數級上升
可以使用 Elasticsearch ,但是我們是做系統設計,肯定不能直接回答面試官,說,用 Elasticsearch 呀,而是按照系統設計的思想(高可用 + 可擴展),說一整個鏈路。
- 總體架構設計
-
用戶層(前端 + CDN)
-
接入層(Nginx )
-
服務層(Search Gateway )
-
檢索層(搜索引擎,如 Elasticsearch)
-
數據層(商品數據服務 / DB / 緩存)
2.1 用戶層(前端 + CDN)
-
請求分發:通過 CDN 加速靜態資源(圖片 / JS/CSS)
-
瀏覽器緩存:利用 LocalStorage 緩存高頻搜索關鍵詞
-
請求合併:合併相似搜索請求(如防抖機制)
// 前端防抖示例(減少無效請求)
let searchTimer;
function handleSearch(keyword) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
}, 300); // 300ms防抖
}
2.2 接入層(Nginx)
-
流量管控:限流、熔斷、鑑權
-
負載均衡:輪詢 / 一致性哈希分發請求
-
協議轉換:HTTP/2 → HTTP/1.1 內部通信
Nginx 關鍵配置
# 限流配置(每秒1000請求/ip)
limit_req_zone $binary_remote_addr zone=search_limit:10m rate=1000r/s;
location /api/search {
limit_req zone=search_limit burst=200;
proxy_pass http://search_cluster;
# 緩存熱門請求結果(5秒)
proxy_cache search_cache;
proxy_cache_valid 200 5s;
}
2.3 服務層(Search Gateway)
-
業務邏輯:請求參數校驗、結果格式化
-
多級緩存:本地緩存 → Redis → Elasticsearch
-
降級策略:超時返回兜底數據
// 搜索網關僞代碼
public class SearchGateway {
@Cacheable(value = "localCache", key = "#keyword")
public List<Product> search(String keyword) {
// 1. 檢查Redis緩存
String redisKey = "search:" + keyword.hashCode();
List<Product> cached = redis.get(redisKey);
if (cached != null) return cached;
// 2. 查詢Elasticsearch
List<Product> result = elasticsearch.search(buildQuery(keyword));
// 3. 異步寫入緩存
executor.submit(() -> {
redis.setex(redisKey, 30, result); // 緩存30秒
});
return result;
}
}
2.4 檢索層(Elasticsearch)
-
索引構建:商品標題 / 類目 / 屬性倒排索引
-
分佈式查詢:分片並行計算
-
相關性排序:BM25 算法優化
索引設計 demo:
PUT /products
{
"settings": {
"number_of_shards": 40,
"number_of_replicas": 1,
"refresh_interval": "30s" // 降低寫入實時性要求
},
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "ik_max_word" },
"price": { "type": "double" },
"sales": { "type": "integer" }
}
}
}
2.5 數據層(DB + 緩存)
-
持久化存儲:MySQL 分庫分表
-
數據同步:Binlog → Canal → Elasticsearch
-
冷熱分離:Redis 緩存熱數據,HBase 存歷史數據
分庫分表 demo
-- 按商品ID分64個庫,每個庫分256表
CREATE TABLE products_%02d.t_product_%03d (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
price DECIMAL(10,2)
) ENGINE=InnoDB;
-- 分片路由算法:hash(product_id) % 64 → 分庫
-- hash(product_id) / 64 % 256 → 分表
- 核心關鍵設計
3.1 分片與容量設計(水平擴展)
- 使用 Elasticsearch,每個索引進行合理分片(Sharding)
每個分片大小控制在 20-50 GB,避免 OOM 和延遲增加
按業務維度(如商品類目、國家)分索引或路由分片
- 分片副本(Replica)數設置,提升可用性
3.2 深度分頁性能優化
我們在使用 mysql 做查詢的時候,會遇到深分頁的問題,比如回表十萬次
我們可以用標籤記錄法,來解決深分頁問題。
其實 Elasticsearch 也存在深分頁的問題,當用戶翻頁到幾百頁時,ES 會做全量掃描,性能陡降。
-
避免傳統 from + size 深分頁
-
Search After(推薦):基於上一頁最後一個 sort_value 做遊標分頁。(類似標籤記錄法思想)
-
Scroll API:適用於數據導出,不推薦用戶查詢
-
或業務限制分頁範圍(如最多展示 100 頁)
3.3 避免緩存穿透設計
跟大家一起復習一下,什麼是緩存穿透?
指查詢一個一定不存在的數據,由於緩存是不命中時需要從數據庫查詢,查不到數據則不寫入緩存,這將導致這個不存在的數據每次請求都要到數據庫去查詢,進而給數據庫帶來壓力。
在百萬併發商品搜索系統時,我們要避免這個問題,可以用布隆過濾器,簡單流程圖如下:
核心業務邏輯(代碼簡單 demo):
@Service
public class SearchService {
// 布隆過濾器(存儲所有有效關鍵詞)
@Autowired
private BloomFilter<String> searchBloomFilter;
// Redis緩存操作
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public SearchResult search(String keyword) {
// Step 1: 布隆過濾器校驗
if (!searchBloomFilter.mightContain(normalizeKeyword(keyword))) {
return SearchResult.EMPTY;
}
// Step 2: 查詢緩存
String cacheKey = "search:" + keyword.hashCode();
SearchResult cached = (SearchResult) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) return cached;
// Step 3: 查詢Elasticsearch
SearchResult result = elasticsearchClient.search(buildQuery(keyword));
// Step 4: 更新緩存
if (result.isEmpty()) {
redisTemplate.opsForValue().set(cacheKey, SearchResult.EMPTY, 30, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(cacheKey, result, 5, TimeUnit.MINUTES);
}
return result;
}
// 關鍵詞標準化處理(如去空格、轉小寫)
private String normalizeKeyword(String keyword) {
return keyword.trim().toLowerCase();
}
}
這裏有個點可能要注意一下哈,布隆過濾器需要初始化一下:
// 初始化所有有效關鍵詞到布隆過濾器
@PostConstruct
public void initBloomFilter() {
List<String> allKeywords = productDao.getAllSearchKeywords(); // 獲取所有商品標題/標籤
allKeywords.stream()
.map(this::normalizeKeyword)
.forEach(searchBloomFilter::put);
}
// 動態更新(新增商品時)
public void addProduct(Product product) {
// ... 其他業務邏輯
searchBloomFilter.put(normalizeKeyword(product.getTitle()));
product.getTags().forEach(tag ->
searchBloomFilter.put(normalizeKeyword(tag))
);
}
3.4 GC 調優
既然是百萬併發的系統設計,少不了 GC 調優。
儘量降低 Full GC 頻率:
使用 G1 或 ZGC
調大堆內存(視機器資源)
避免頻繁創建臨時對象,使用對象池(如 Query 對象)
在上線前,我們要進行壓力測試,然後調出最優最優 JVM 參數。JVM 最優參數配置不是一成不變的,根據實際壓測,得到的。
壓力測試可以用 loadrunner 或者 jemeter,進行高併發模擬測試。
JVM 參數配置 demo:
# elasticsearch/jvm.options
# 基礎配置
-Xms31g
-Xmx31g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# G1調優參數
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1ReservePercent=25
-XX:G1HeapRegionSize=4m
# 內存鎖防止Swap
-XX:+AlwaysPreTouch
-XX:+DisableExplicitGC
3.5 災備與高可用設計
高併發系統,少不了容災和高可用的設計要點,可以採取以下幾種方式:
-
多 AZ 部署(分佈在不同機房 / 可用區)
-
Elasticsearch 設置跨機房副本(replica + shard allocation awareness)
-
服務註冊中心(如 Nacos)與服務熔斷、降級策略配合
-
主從切換、故障自動轉移(Failover)
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/7GDktNjc7kX1ePTFZGa9kQ