100 萬的併發,如何設計一個商品搜索系統?

前言

大家好,我是田螺

今天我們來看一道比較有深度的面試題:百萬併發下,商品搜索系統,你如何設計呢?

假設場景:某電商平臺大促期間,需支撐每秒 100 萬次的商品搜索請求,要求響應時間≤200ms,同時應對商品數據量超 10 億條。假設給你來做系統設計,怎麼做呢?

如果是我來回答面試官這道題的話,我會按照這些思路來跟面試官闡述:

  1. 爲什麼不能用 MySQL 的 llike?

  2. 總體架構設計

  3. 核心關鍵設計

  4. 爲什麼不能用 mysql 的 like?


我們每次提到關鍵詞搜索,大家很容易就想到數據庫的 like,比如:

SELECT * FROM products WHERE title LIKE '%智能手機%' LIMIT 10;

但是,顯然商品數據量超 10 億條,搜索不能用 like。

可以使用 Elasticsearch ,但是我們是做系統設計,肯定不能直接回答面試官,說,用 Elasticsearch 呀,而是按照系統設計的思想(高可用 + 可擴展),說一整個鏈路。

  1. 總體架構設計

2.1 用戶層(前端 + CDN)

// 前端防抖示例(減少無效請求)
let searchTimer;
function handleSearch(keyword) {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
        fetch(`/api/search?q=${encodeURIComponent(keyword)}`);
    }, 300); // 300ms防抖
}

2.2 接入層(Nginx)

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)

// 搜索網關僞代碼
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)

索引設計 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 + 緩存)

分庫分表 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 → 分表
  1. 核心關鍵設計

3.1 分片與容量設計(水平擴展)

  • 每個分片大小控制在 20-50 GB,避免 OOM 和延遲增加

  • 按業務維度(如商品類目、國家)分索引或路由分片

3.2 深度分頁性能優化

我們在使用 mysql 做查詢的時候,會遇到深分頁的問題,比如回表十萬次

我們可以用標籤記錄法,來解決深分頁問題

其實 Elasticsearch 也存在深分頁的問題,當用戶翻頁到幾百頁時,ES 會做全量掃描,性能陡降。

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 災備與高可用設計

高併發系統,少不了容災和高可用的設計要點,可以採取以下幾種方式:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/7GDktNjc7kX1ePTFZGa9kQ