100萬的并發(fā),如何設計一個商品搜索系統(tǒng)?
前言
今天我們來看一道比較有深度的面試題:百萬并發(fā)下,商品搜索系統(tǒng),你如何設計呢?
假設場景:某電商平臺大促期間,需支撐每秒100萬次的商品搜索請求,要求響應時間≤200ms,同時應對商品數(shù)據量超10億條。假設給你來做系統(tǒng)設計,怎么做呢?
如果是我來回答面試官這道題的話,我會按照這些思路來跟面試官闡述:
- 為什么不能用MySQL的llike?
- 總體架構設計
- 核心關鍵設計
1.為什么不能用mysql的like?
我們每次提到關鍵詞搜索,大家很容易就想到數(shù)據庫的like,比如:
SELECT * FROM products WHERE title LIKE '%智能手機%' LIMIT 10;
但是,顯然商品數(shù)據量超10億條,搜索不能用like。
LIKE '%keyword%'
無法利用索引,觸發(fā)全表掃描。10億數(shù)據時單次查詢可能耗時數(shù)秒。- 不支持分詞搜索(如"手機殼"無法拆分為"手機"和"殼"單獨匹配)
- 分庫分表后跨庫LIKE查詢復雜度指數(shù)級上升
可以使用Elasticsearch ,但是我們是做系統(tǒng)設計,肯定不能直接回答面試官,說,用Elasticsearch呀,而是按照系統(tǒng)設計的思想(高可用 + 可擴展),說一整個鏈路。
2. 總體架構設計
- 用戶層(前端 + CDN)
- 接入層(Nginx )
- 服務層(Search Gateway )
- 檢索層(搜索引擎,如 Elasticsearch)
- 數(shù)據層(商品數(shù)據服務 / DB / 緩存)
圖片
2.1 用戶層(前端 + CDN)
- 請求分發(fā):通過CDN加速靜態(tài)資源(圖片/JS/CSS)
- 瀏覽器緩存:利用LocalStorage緩存高頻搜索關鍵詞
- 請求合并:合并相似搜索請求(如防抖機制)
// 前端防抖示例(減少無效請求)
let searchTimer;
function handleSearch(keyword) {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
}, 300); // 300ms防抖
}
2.2 接入層(Nginx)
- 流量管控:限流、熔斷、鑒權
- 負載均衡:輪詢/一致性哈希分發(fā)請求
- 協(xié)議轉換:HTTP/2 → HTTP/1.1內部通信
Nginx 關鍵配置
# 限流配置(每秒1000請求/ip)
limit_req_zone $binary_remote_addr znotallow=search_limit:10m rate=1000r/s;
location /api/search {
limit_req znotallow=search_limit burst=200;
proxy_pass http://search_cluster;
# 緩存熱門請求結果(5秒)
proxy_cache search_cache;
proxy_cache_valid 200 5s;
}
2.3 服務層(Search Gateway)
- 業(yè)務邏輯:請求參數(shù)校驗、結果格式化
- 多級緩存:本地緩存 → Redis → Elasticsearch
- 降級策略:超時返回兜底數(shù)據
// 搜索網關偽代碼
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算法優(yōu)化
索引設計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 數(shù)據層(DB + 緩存)
- 持久化存儲:MySQL分庫分表
- 數(shù)據同步:Binlog → Canal → Elasticsearch
- 冷熱分離:Redis緩存熱數(shù)據,HBase存歷史數(shù)據
分庫分表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. 核心關鍵設計
3.1 分片與容量設計(水平擴展)
- 使用 Elasticsearch,每個索引進行合理分片(Sharding)
每個分片大小控制在 20-50 GB,避免 OOM 和延遲增加
按業(yè)務維度(如商品類目、國家)分索引或路由分片
- 分片副本(Replica)數(shù)設置,提升可用性
3.2 深度分頁性能優(yōu)化
我們在使用mysql做查詢的時候,會遇到深分頁的問題,比如回表十萬次
圖片
我們可以用標簽記錄法,來解決深分頁問題。
其實Elasticsearch 也存在深分頁的問題,當用戶翻頁到幾百頁時,ES 會做全量掃描,性能陡降。
- 避免傳統(tǒng) from + size 深分頁
- Search After(推薦):基于上一頁最后一個 sort_value 做游標分頁。(類似標簽記錄法思想)
- Scroll API:適用于數(shù)據導出,不推薦用戶查詢
- 或業(yè)務限制分頁范圍(如最多展示 100 頁)
3.3 避免緩存穿透設計
跟大家一起復習一下,什么是緩存穿透?
指查詢一個一定不存在的數(shù)據,由于緩存是不命中時需要從數(shù)據庫查詢,查不到數(shù)據則不寫入緩存,這將導致這個不存在的數(shù)據每次請求都要到數(shù)據庫去查詢,進而給數(shù)據庫帶來壓力。
在百萬并發(fā)商品搜索系統(tǒng)時,我們要避免這個問題,可以用布隆過濾器,簡單流程圖如下:
圖片
核心業(yè)務邏輯(代碼簡單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);
}
// 動態(tài)更新(新增商品時)
public void addProduct(Product product) {
// ... 其他業(yè)務邏輯
searchBloomFilter.put(normalizeKeyword(product.getTitle()));
product.getTags().forEach(tag ->
searchBloomFilter.put(normalizeKeyword(tag))
);
}
3.4 GC調優(yōu)
既然是百萬并發(fā)的系統(tǒng)設計,少不了GC調優(yōu)。
盡量降低 Full GC 頻率:
- 使用 G1 或 ZGC
- 調大堆內存(視機器資源)
- 避免頻繁創(chuàng)建臨時對象,使用對象池(如 Query 對象)
在上線前,我們要進行壓力測試,然后調出最優(yōu)最優(yōu)JVM參數(shù)。JVM 最優(yōu)參數(shù)配置不是一成不變的,根據實際壓測,得到的。
壓力測試可以用loadrunner或者jemeter,進行高并發(fā)模擬測試。
JVM 參數(shù)配置demo:
# elasticsearch/jvm.options
# 基礎配置
-Xms31g
-Xmx31g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# G1調優(yōu)參數(shù)
-XX:InitiatingHeapOccupancyPercent=35
-XX:G1ReservePercent=25
-XX:G1HeapReginotallow=4m
# 內存鎖防止Swap
-XX:+AlwaysPreTouch
-XX:+DisableExplicitGC
3.5 災備與高可用設計
高并發(fā)系統(tǒng),少不了容災和高可用的設計要點,可以采取以下幾種方式:
- 多 AZ 部署(分布在不同機房 / 可用區(qū))
- Elasticsearch 設置跨機房副本(replica + shard allocation awareness)
- 服務注冊中心(如 Nacos)與服務熔斷、降級策略配合
- 主從切換、故障自動轉移(Failover)
圖片