欢迎光临
我们一直在努力

【架构实战】ElasticSearch搜索集群:全文检索的艺术

【架构实战】ElasticSearch搜索集群:å

¨æ–‡æ£€ç´¢çš„艺术

倒排索引、分片副本、搜索优化、实战案例

一、从一个真实的æ•

事说起

2024年双十一,某电商平台搜索系统在流量洪峰到来的那一刻,突然"哑火"了。

用户在搜索框输å
¥"iPhone 15 Pro Max",等了整整8秒,页面才刷出结果。而更诡异的是,有些用户搜索"手机壳",å±
然搜出了"手机支架"——相å
³æ€§å®Œå
¨é”™ä¹±ã€‚运维团队紧急排查,发现是ElasticSearch集群的某个分片因为磁盘IO瓶颈导致查询è¶
时,而副本分片因为负载均衡策略问题,å
¨éƒ¨æ‰“在了同一台机器上。

"我们不是é
ç½®äº†å‰¯æœ¬å—?为什么还会这样?"开发同学一脸懵。

"副本是é
ç½®äº†ï¼Œä½†ä½ ä»¬æŠŠ5个副本分片å
¨éƒ¨åˆ†é
åˆ°äº†åŒä¸€å°é«˜é
æœºå™¨ä¸Šï¼Œä»¥ä¸ºè¿™æ ·èƒ½æé«˜æ€§èƒ½ã€‚结果那台机器的磁盘IO被打满,查询å
¨éƒ¨è¶
时。"运维同学无奈地解释。

这个æ•
事告诉我们:ElasticSearch不是开箱即用的搜索引擎,理解å
¶åº•层原理并正确é
ç½®ï¼Œæ‰èƒ½çœŸæ­£å‘挥它的威力。


二、核心概念:倒排索引——搜索的基石

2.1 什么是倒排索引?

传统的å
³ç³»åž‹æ•°æ®åº“使用正向索引:文档ID → 文档å†
容。比如:

文档ID: 1 → å†
容: "iPhone 15 Pro Max 256GB 深空黑"
文档ID: 2 → å†
容: "iPhone 15 Pro 手机壳 透明"
文档ID: 3 → å†
容: "iPhone 14 Pro Max 手机壳 黑色"

如果要搜索"iPhone",数据库需要扫描所有文档,逐个匹é
â€”—这就是å
¨è¡¨æ‰«æï¼Œæ•ˆçŽ‡æžä½Žã€‚

而ElasticSearch使用倒排索引:词条 → 文档ID列表。

词条 文档ID列表
─────────────────────────
iPhone → [1, 2, 3]
15 → [1, 2]
Pro → [1, 2, 3]
Max → [1, 3]
手机壳 → [2, 3]
256GB → [1]
深空黑 → [1]
透明 → [2]
黑色 → [3]

现在搜索"iPhone",只需要在倒排索引中找到"iPhone"这个词条,直接得到文档ID列表[1, 2, 3],无需扫描任何文档。这就是**O(1)级别**的查询效率。

2.2 倒排索引的结构

倒排索引由三个核心结构组成:

  • Term Dictionary(词条字å
    ¸ï¼‰ï¼šæ‰€æœ‰ä¸é‡å¤çš„词条,按字å
    ¸åºæŽ’序。ElasticSearch使用FST(Finite State Transducer)压缩存储,å†
    存占用极小。

  • Term Index(词条索引):Term Dictionary的索引,用于快速定位词条在磁盘上的位置。通常每128个词条建立一个索引项。

  • Posting List(倒排表):每个词条对应的文档ID列表。ElasticSearch使用Frame of Reference编码和Roaring Bitmaps压缩,大å¹
    减少存储空间。

  • Term Index (å†
    存) → Term Dictionary (磁盘) → Posting List (磁盘)
    ↓ ↓ ↓
    快速定位 词条详æƒ
    文档ID列表

    2.3 实战:查看倒排索引

    我们可以通过ElasticSearch的_termvectorsAPI查看某个文档的倒排索引信息:

    GET /products/_termvectors/1?fields=name&term_statistics=true

    {
    "term_vectors": {
    "name": {
    "terms": {
    "iphone": {
    "term_freq": 1,
    "doc_freq": 3,
    "ttf": 3
    },
    "15": {
    "term_freq": 1,
    "doc_freq": 2,
    "ttf": 2
    }
    }
    }
    }
    }

    å
    ¶ä¸­ï¼š

    • term_freq:该词条在当前文档中出现的次数
    • doc_freq:åŒ
      含该词条的文档数量(文档频率)
    • ttf:该词条在所有文档中出现的总次数

    三、分片与副本:分布式的艺术

    3.1 分片(Shard):数据的水平切分

    ElasticSearch将一个索引的数据分散到多个分片中,每个分片是一个独立的Lucene索引。

    为什么需要分片?

  • 水平扩展:单台机器存不下海量数据,分片让数据分散到多台机器
  • 并行查询:查询可以并行执行在多个分片上,提高性能
  • 分片数量如何确定?

    官方建议:每个分片大小在10GB-50GB之间,分片数量 = 数据总量 / 30GB。

    // 创建索引时指定分片数
    PUT /products
    {
    "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1
    }
    }

    3.2 副本(Replica):高可用的保障

    副本是分片的拷贝,用于:

  • 容灾:主分片æ•
    障时,副本自动升级为主分片
  • **负载均衡**:查询请求可以分发到副本上,提高查询吞吐量
  • 副本数量如何确定?

    • 开发环境:0个副本(节省资源)
    • 生产环境:至少1个副本
    • 高可用场景:2个副本(å
      è®¸ä»»æ„2台机器同时æ•
      障)

    3.3 分片分é

    ç­–ç•¥

    ElasticSearch通过分片分é
    å™¨ï¼ˆShard Allocator)决定分片放在哪些节点上。核心原则:

  • 均衡原则:尽量让每个节点的分片数量相近
  • 感知原则:避å
    ä¸»åˆ†ç‰‡å’Œå‰¯æœ¬åˆ†ç‰‡åˆ†é
    åˆ°åŒä¸€å°æœºå™¨
  • 属性感知:可以根据机架、可用区等属性分é
    ï¼Œé¿å
    å•点æ•
    障
  • # elasticsearch.yml é
    ç½®æœºæž¶æ„ŸçŸ¥
    cluster.routing.allocation.awareness.attributes: rack_id

    node.attr.rack_id: rack1 # 节点1
    node.attr.rack_id: rack2 # 节点2

    这样é
    ç½®åŽï¼ŒElasticSearch会尽量将主分片和副本分片分é
    åˆ°ä¸åŒçš„æœºæž¶ä¸Šã€‚

    3.4 实战案例:分片分é

    å¤±è´¥æŽ’查

    **问题现象**:集群状态为Yellow,提示"分片分é
    å¤±è´¥"。

    排查步骤:

    // 1. 查看集群健康状态
    GET /_cluster/health

    {
    "status": "yellow",
    "unassigned_shards": 2
    }

    // 2. 查看分片分é
    è§£é‡Š
    GET /_cluster/allocation/explain

    {
    "index": "products",
    "shard": 1,
    "primary": false,
    "current_state": "unassigned",
    "unassigned_info": {
    "reason": "NODE_LEFT",
    "at": "2024-11-11T10:00:00.000Z"
    },
    "can_allocate": "no",
    "allocate_explanation": "cannot allocate because the node left the cluster"
    }

    解决方案:

    如果是节点临时æ•
    障,等å¾
    节点恢复即可。如果节点永ä¹
    下线,需要调整分片分é
    ç­–略:

    // 重新分é
    å‰¯æœ¬
    POST /_cluster/reroute?retry_failed=true


    四、搜索优化:从å

    ¥é—¨åˆ°ç²¾é€š

    4.1 查询DSL:构建复杂查询

    ElasticSearch提供了强大的Query DSL(Domain Specific Language):

    // bool组合查询
    GET /products/_search
    {
    "query": {
    "bool": {
    "must": [
    { "match": { "name": "iPhone" }}
    ],
    "should": [
    { "match": { "brand": "Apple" }},
    { "range": { "price": { "lte": 10000 }}}
    ],
    "must_not": [
    { "match": { "status": "下架" }}
    ],
    "filter": [
    { "term": { "category": "手机" }}
    ]
    }
    }
    }

    å
    ¶ä¸­ï¼š

    • must:å¿
      须匹é
      ï¼Œå‚与评分
    • should:选择性匹é
      ï¼Œå‚与评分
    • must_not:å¿
      须不匹é
      ï¼Œä¸å‚与评分
    • filter:å¿
      须匹é
      ï¼Œä¸å‚与评分(性能更高)

    4.2 相å

    ³æ€§è¯„分:BM25算法

    ElasticSearch默认使用BM25算法计算文档相å
    ³æ€§è¯„分:

    score(D, Q) = Σ IDF(qi) * (f(qi, D) * (k1 + 1)) / (f(qi, D) + k1 * (1 – b + b * |D| / avgdl))

    å
    ¶ä¸­ï¼š

    • f(qi, D):词条qi在文档D中的出现频率
    • |D|:文档D的长度
    • avgdl:所有文档的平均长度
    • k1、b:调节参数,默认k1=1.2,b=0.75

    实战:调整BM25参数

    PUT /products
    {
    "settings": {
    "index": {
    "similarity": {
    "custom_bm25": {
    "type": "BM25",
    "k1": 1.5,
    "b": 0.8
    }
    }
    }
    },
    "mappings": {
    "properties": {
    "name": {
    "type": "text",
    "similarity": "custom_bm25"
    }
    }
    }
    }

    4.3 高亮显示:让结果更直观

    GET /products/_search
    {
    "query": {
    "match": { "name": "iPhone" }
    },
    "highlight": {
    "fields": {
    "name": {
    "pre_tags": ["<em>"],
    "post_tags": ["</em>"],
    "fragment_size": 150,
    "number_of_fragments": 3
    }
    }
    }
    }

    // 返回结果
    {
    "hits": {
    "hits": [
    {
    "_source": {
    "name": "iPhone 15 Pro Max 256GB 深空黑"
    },
    "highlight": {
    "name": ["<em>iPhone</em> 15 Pro Max 256GB 深空黑"]
    }
    }
    ]
    }
    }

    4.4 聚合分析:不只是搜索

    ElasticSearch的聚合功能可以实现复杂的数据分析:

    // æŒ‰å“ç‰Œåˆ†ç»„ï¼Œè®¡ç®—å¹³å‡ä»·æ ¼å’Œé”€é‡
    GET /products/_search
    {
    "size": 0,
    "aggs": {
    "brands": {
    "terms": { "field": "brand.keyword", "size": 10 },
    "aggs": {
    "avg_price": { "avg": { "field": "price" }},
    "total_sales": { "sum": { "field": "sales" }}
    }
    }
    }
    }


    五、实战案例:电商搜索系统架构

    5.1 系统架构设计

    用户请求
    ↓
    API网å
    ³ï¼ˆé™æµã€é‰´æƒï¼‰
    ↓
    搜索服务(查询重写、结果排序)
    ↓
    ElasticSearch集群(3主3从)
    ↓
    数据同步服务(MySQL → ES)

    5.2 数据同步方案

    方案一:双写模式

    应用层同时写å
    ¥MySQLå’ŒElasticSearch:

    @Transactional
    public void saveProduct(Product product) {
    // 1. 写å
    ¥MySQL
    productMapper.insert(product);

    // 2. 写å
    ¥ElasticSearch
    try {
    elasticsearchTemplate.save(product);
    } catch (Exception e) {
    // 写å
    ¥å¤±è´¥ï¼Œè®°å½•日志,异步补偿
    log.error("ES写å
    ¥å¤±è´¥", e);
    mqService.send("es_sync_topic", product);
    }
    }

    优点:实现简单,实时性高
    缺点:代码侵å
    ¥æ€§å¼ºï¼Œä¸€è‡´æ€§éš¾ä»¥ä¿è¯

    方案二:CDC(Change Data Capture)

    通过监听MySQL的binlog,实时同步到ElasticSearch:

    # Canalé
    ç½®
    canal.instance.master.address: 127.0.0.1:3306
    canal.instance.filter.regex: shop\\\\.products

    # 同步到ES
    canal.adapters:
    name: es
    hosts: 127.0.0.1:9200
    index: products
    mapping:
    id: _id
    name: name
    price: price

    优点:代码无侵å
    ¥ï¼Œä¸€è‡´æ€§æœ‰ä¿éšœ
    缺点:需要额外部署Canal,运维成本高

    5.3 搜索性能优化

    优化一:使用filter代替query

    // æ
    ¢ï¼šä½¿ç”¨query
    GET /products/_search
    {
    "query": {
    "bool": {
    "must": [
    { "term": { "category": "手机" }},
    { "term": { "brand": "Apple" }}
    ]
    }
    }
    }

    // 快:使用filter
    GET /products/_search
    {
    "query": {
    "bool": {
    "filter": [
    { "term": { "category": "手机" }},
    { "term": { "brand": "Apple" }}
    ]
    }
    }
    }

    filter不计算评分,且会被缓存,性能更高。

    优化二:预热缓存

    在业务低峰期预热查询缓存:

    @Scheduled(cron = "0 0 3 * * ?")
    public void warmUpCache() {
    List<String> hotKeywords = getHotKeywords();
    for (String keyword : hotKeywords) {
    elasticsearchTemplate.query(keyword);
    }
    }

    优化三:异步查询

    对于非核心查询(如推荐、广告),使用异步查询:

    CompletableFuture<List<Product>> recommendFuture = CompletableFuture.supplyAsync(() -> {
    return recommendService.query(userId);
    });

    CompletableFuture<List<Ad>> adFuture = CompletableFuture.supplyAsync(() -> {
    return adService.query(userId);
    });

    // ç­‰å¾
    所有查询完成
    CompletableFuture.allOf(recommendFuture, adFuture).join();


    å

    ­ã€è¸©å‘实录

    踩坑一:深度分页性能问题

    问题:查询第10000页,每页10条,耗时10秒。

    GET /products/_search
    {
    "from": 100000,
    "size": 10,
    "query": { "match_all": {} }
    }

    **原因**:ElasticSearch需要查询所有分片的from+size条数据,在协调节点排序后取[from, from+size]。from越大,排序的数据越多,性能越差。

    解决方案:使用search_after代替from/size:

    // 第一次查询
    GET /products/_search
    {
    "size": 10,
    "sort": [
    { "_id": "asc" }
    ]
    }

    // 后续查询
    GET /products/_search
    {
    "size": 10,
    "sort": [
    { "_id": "asc" }
    ],
    "search_after": ["AVd3d3d3d3d3d3d3"]
    }

    踩坑二:字段类型错误导致无法聚合

    问题:对price字段聚合时报错。

    GET /products/_search
    {
    "aggs": {
    "price_stats": { "stats": { "field": "price" }}
    }
    }

    // 报错
    {
    "error": {
    "type": "illegal_argument_exception",
    "reason": "Field [price] of type [text] is not supported for aggregation"
    }
    }

    **原因**:price字段被映射为text类型,text类型不支持聚合。

    解决方案:使用keyword类型或添加子字段:

    PUT /products/_mapping
    {
    "properties": {
    "price": {
    "type": "text",
    "fields": {
    "keyword": { "type": "keyword" },
    "double": { "type": "double" }
    }
    }
    }
    }

    // 使用子字段聚合
    GET /products/_search
    {
    "aggs": {
    "price_stats": { "stats": { "field": "price.double" }}
    }
    }

    踩坑三:集群脑裂问题

    问题:集群出现两个Master节点,数据不一致。

    **原因**:网络分区导致部分节点无法通信,各自选举出Master。

    解决方案:

    # elasticsearch.yml
    # 设置最小主节点数 = 节点数/2 + 1
    discovery.zen.minimum_master_nodes: 2

    # 或è€
    使用7.x+版本的自动é
    ç½®
    cluster.initial_master_nodes: ["node1", "node2", "node3"]

    踩坑四:JVMå †å†

    存设置不当

    问题:频繁Full GC,查询è¶
    时。

    **原因**:堆å†
    存设置过大,è¶
    过物理å†
    存的50%,导致大量å†
    存用于Page Cache,反而降低性能。

    解决方案:

    # jvm.options
    Xms16g
    Xmx16g

    # å †å†
    存不è¶
    过物理å†
    存的50%,且不è¶
    过32GB
    # Lucene利用操作系统的Page CacheåŠ é€ŸæŸ¥è¯¢ï¼Œå †å†
    存过大反而影响性能


    七、总结

    ElasticSearch作为分布式搜索引擎,å
    ¶æ ¸å¿ƒä¼˜åŠ¿åœ¨äºŽï¼š

  • 倒排索引:O(1)级别的查询效率
  • 分片副本:水平扩展和高可用保障
  • 丰富的查询DSL:支持复杂查询和聚合分析
  • 分布式架构:自动分片分é
    å’Œæ•
    障恢复
  • 但同时,ElasticSearch也有å
    ¶å¤æ‚性:

  • 分片规划:分片数量和大小需要合理规划
  • **数据一致性**:与MySQLç­‰å
    ³ç³»åž‹æ•°æ®åº“的同步方案需要谨æ
    Žé€‰æ‹©
  • 性能优化:深度分页、字段类型、JVMé
    ç½®ç­‰éƒ½éœ€è¦æ·±å
    ¥ç†è§£
  • 运维复杂度:集群监控、æ•
    障排查、容量规划都需要专业能力

  • å

    «ã€æ€è€ƒé¢˜

  • 如果你的业务需要支持"搜索推荐"(用户输å
    ¥æ—¶å®žæ—¶æŽ¨èæœç´¢è¯ï¼‰ï¼Œä½ ä¼šå¦‚何设计?需要考虑哪些技术点?

  • ElasticSearchå’ŒMySQL各有优劣,什么场景下应该选择ElasticSearch作为主存储?什么场景下应该保持MySQL为主存储,ElasticSearchä»
    作为搜索加速?

  • 在微服务架构下,如何保证ElasticSearch数据与各个微服务数据的一致性?你会选择哪种同步方案?


  • 九、个人观点

    在我参与过的多个项目中,ElasticSearch最常见的误区是:把它当作数据库来用。

    很多团队直接把业务数据存到ElasticSearch,不再使用MySQL。这在初期确实简单高效,但随着业务发展,问题逐渐暴露:

  • 事务支持弱:ElasticSearch没有完整的事务机制,复杂业务逻辑难以实现
  • 更新性能差:频繁更新会导致大量segment文件,查询性能下降
  • 数据一致性难保证:分布式环境下的数据一致性是个大问题
  • 我的建议是:ElasticSearch作为搜索引擎,MySQL作为数据存储,两è€
    各司å
    ¶èŒã€‚通过CDC或双写模式保持数据同步,既享受ElasticSearch的搜索能力,又保留MySQL的事务特性。

    另一个常见误区是:忽视集群运维。很多团队搭建完集群就不管了,直到出问题才临时抱佛脚。建议从项目初期就建立完善的监控体系(使用ElasticSearch Head、Kibana、Prometheus等),定期进行容量规划和æ•
    障演练。

    最后,ElasticSearch的学习曲线确实陡峭,但一旦掌握,你会发现它是一个强大而优é›
    的搜索引擎。希望这篇文章能帮助你少走弯路,在实践中真正发挥ElasticSearch的威力。


    作è€
    :架构实战系列 | 字数:约4500字

    赞(0)
    未经允许不得转载:171主机测评 » 【架构实战】ElasticSearch搜索集群:全文检索的艺术
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址