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

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

字数统计:约4200字

前言:一个搜索引发的"血案"

2019年双十一的那个凌晨,我正在公司值夜班,监控大屏上突然一片飘红------搜索服务响应时间从正常的50ms飙升到3秒以上,订单页面的搜索框彻底卡死。运营同事疯狂@我:"用户搜不了商品了!"

我手忙脚乱地登录服务器,发现问题比我想象的更严重:单节点ES集群的磁盘IO已经打满,查询队列堆积了上千个pending请求。更要命的是,那个只有500GB数据的索引,在双十一当晚硬是被塞进去了超过2TB的日志和数据,磁盘直接写爆。

那天晚上,我们临时紧急扩容、删历史数据、灰度切换搜索接口,一直折腾到凌晨5点才恢复正常。后来复盘,我发现问题的根源在于:我们把ES当成了"更快的数据库"来用,却完全忽略了它作为分布式搜索引擎的设计哲学。

从那以后,我花了整整三个月重新设计我们的搜索架构,从单节点ES扩展为三节点集群,又逐步演进到今天的17节点分布式集群。这篇文章,就是想把这段血泪史分享给大家,告诉你们如何正确地构建一个生产级的ElasticSearch搜索集群。

一、ElasticSearch不是数据库,而是搜索引擎

很多人(包括曾经的我)都会犯一个错误:把ElasticSearch当作数据库来用。确实,ES能存数据、能查数据,看起来和数据库差不多。但实际上,它和MySQL、MongoDB有着本质的不同。

1.1 为什么ElasticSearch查询快?

要理解ES为什么快,我们得先搞清楚它的底层数据结构。ElasticSearch的底层依赖于Lucene,而Lucene的核心就是倒排索引(Inverted Index)。

传统的正排索引是这样的:文档ID → 文档内容。比如:

  • 文档1:{id: 1, title: "如何使用Java"}
  • 文档2:{id: 2, title: "Python入门教程"}

而倒排索引是这样的:关键词 → 文档ID列表。比如:

  • "Java" → [1, 5, 9]
  • "Python" → [2, 7]
  • "教程" → [2, 8, 15]

当你搜索"Java教程"时,数据库可能需要逐行扫描(Full Table Scan),而倒排索引只需要两次简单的集合交集操作,速度完全不在一个量级。

这就是为什么ES能在毫秒级完成全文检索,而MySQL的LIKE查询可能需要几秒钟。

1.2 你必须接受的"反模式"

ES有它自己的脾气,不是所有场景都适合。以下是我总结的ES"反模式":

反模式一:把ES当主存储

ES不适合作为数据的唯一存储。它的事务能力极弱(没有ACID),数据可能丢失(虽然概率很低)。正确的做法是:MySQL存业务数据,ES存搜索索引,通过同步机制保持一致。

反模式二:不做分片规划

默认情况下,ES每个索引只有1个主分片。当数据量超过单节点容量时,你会面临痛苦的迁移。正确的做法是:根据数据量预估,提前规划分片数量(建议单个分片数据量不超过30GB)。

反模式三:忽略脑裂问题

ES的脑裂(Split-Brain)是指集群中出现多个Master节点,导致数据不一致。这通常发生在网络抖动或节点故障时。正确的做法是:合理配置minimum_master_nodes,通常是(master节点数/2)+1

1.3 集群架构设计原则

一个生产级的ES集群,应该遵循以下原则:

节点角色分离

  • Master节点:负责集群管理、索引创建、负载均衡
  • Data节点:负责数据存储和查询
  • Ingest节点:负责数据预处理(pipeline)
  • Coordinating节点:负责请求转发和聚合

最小集群配置

  • 生产环境至少3个Master节点
  • 数据节点建议3个以上(视数据量而定)
  • 使用SSD存储(机械硬盘会拖垮查询性能)

容灾设计

  • 跨机房/可用区部署
  • 开启自动备份(snapshot)
  • 定期进行恢复演练

二、集群配置实战:从零搭建高可用ES集群

这一节,我们来看看如何从头搭建一个生产级的ES集群。我会给出完整的配置文件和关键参数解释。

2.1 节点规划

假设我们有3台服务器,配置如下:

  • CPU: 16核
  • 内存: 64GB
  • 磁盘: 2TB SSD
  • 网络: 万兆网卡

规划如下:

  • node-1: Master + Data(同时承担Master和数据职责)
  • node-2: Master + Data
  • node-3: Master + Data + Ingest

2.2 核心配置文件

以下是node-1的elasticsearch.yml配置:

yaml 复制代码
# 集群名称,所有节点必须一致
cluster.name: production-es-cluster

# 节点名称,每个节点唯一
node.name: node-1
node.master: true
node.data: true
node.ingest: false

# 绑定地址(生产环境应绑定内网IP)
network.host: 10.0.1.101
http.port: 9200
transport.tcp.port: 9300

# discovery配置 - Zen Discovery
discovery.seed_hosts:
  - 10.0.1.101:9300
  - 10.0.1.102:9300
  - 10.0.1.103:9300

# 关键配置:防止脑裂
# 至少需要2个master节点参与选举
discovery.zen.minimum_master_nodes: 2

# 集群恢复配置
cluster.routing.allocation.node_initial_primaries_recoveries: 4
cluster.routing.allocation.node_concurrent_recoveries: 2
indices.recovery.max_bytes_per_sec: 100mb

# 内存配置 - 建议留一半给系统
# ES默认使用一半物理内存作为JVM堆
# 但如果机器内存很大,可以调小这个比例
# 官方建议:不超过32GB,最好保持在26GB以下
# 因为JVM使用compressed oops的阈值就是约26GB
bootstrap.memory_lock: true
ES_JAVA_OPTS: "-Xms30g -Xmx30g -XX:+UseG1GC"

# 跨机房部署时的分区感知
# 假设我们有3个机架:rack1, rack2, rack3
cluster.routing.allocation.awareness.attributes: rack_id
node.attr.rack_id: rack1

# 索引默认配置
index.number_of_shards: 5
index.number_of_replicas: 1

# 搜索性能优化
indices.queries.cache.size: 15%
indices.fielddata.cache.size: 30%

2.3 JVM参数调优

ES 7.x版本的JVM推荐配置(针对64GB内存机器):

bash 复制代码
# /etc/elasticsearch/jvm.options

# 堆内存设置 - 建议留一半给OS
-Xms31g
-Xmx31g

# G1垃圾回收器 - ES官方推荐
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=30
-XX:G1NewSizePercent=25

# 禁用JMX远程监控(生产环境可开启并配置密码)
-Dcom.sun.management.jmxremote=false

# 启用G1的并行GC线程
-XX:ParallelGCThreads=16
-XX:ConcGCThreads=4

2.4 系统参数优化

Linux系统层面需要调整以下参数:

bash 复制代码
# /etc/sysctl.conf

# 增加文件描述符限制
fs.file-max = 655360

# 增加内存映射限制
vm.max_map_count = 262144

# 增加线程数限制
kernel.threads-max = 655360

# TCP参数优化
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535

# 禁用swappiness(ES需要内存,不适合swap)
vm.swappiness = 1
bash 复制代码
# /etc/security/limits.conf

# 增加ES用户限制
elasticsearch soft nofile 655360
elasticsearch hard nofile 655360
elasticsearch soft nproc 655360
elasticsearch hard nproc 655360
elasticsearch soft memlock unlimited
elasticsearch hard memlock unlimited

三、实战案例:电商搜索平台架构演进

光有配置不够,这一节我来分享一个真实的电商搜索平台案例,看看我们是如何从单节点演进到17节点集群的。

3.1 业务背景

我们的电商平台有以下搜索需求:

  • 商品搜索:SKU数量超过5000万,日均搜索请求3000万+
  • 订单搜索:历史订单5亿+条
  • 日志分析:每日新增日志数据500GB+
  • 实时分析:需要秒级的数据可见性

3.2 架构演进历程

第一阶段:单节点探索(2018.01-2018.06)

最初我们只是用单节点ES来做商品搜索的试点。配置很简陋:

  • 单台16核32GB机器
  • 单索引,无分片
  • 默认副本配置

业务刚起步时,数据量小(10万SKU),勉强能用。但随着业务增长,问题逐渐暴露:

  • 查询开始变慢,TP99从50ms涨到500ms
  • 索引写入阻塞,写入延迟高达10秒
  • 节点宕机导致服务不可用

第二阶段:集群化改造(2018.07-2019.03)

痛定思痛,我们进行了第一次架构升级:

复制代码
┌─────────────────────────────────────────────────────────────┐
│                      Load Balancer                          │
└─────────────────────────────────────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌───────────────┐    ┌───────────────┐    ┌───────────────┐
│  Coordinating │    │  Coordinating │    │  Coordinating │
│    Node 1     │    │    Node 2     │    │    Node 3     │
└───────────────┘    └───────────────┘    └───────────────┘
        │                     │                     │
        └─────────────────────┼─────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌───────────────┐    ┌───────────────┐    ┌───────────────┐
│    Data 1     │    │    Data 2     │    │    Data 3     │
│  (Primary)    │    │  (Replica)    │    │  (Replica)    │
└───────────────┘    └───────────────┘    └───────────────┘

关键配置变更:

  • 3个Data节点,每个64GB内存
  • 主分片数:5(按5000万数据 / 30GB = 约170个分片,后调整为5个主分片 + 2副本)
  • 副本数:2
  • 引入Coordinating节点分离读写压力

这次升级效果显著:

  • 查询TP99稳定在80ms以内
  • 写入吞吐量提升了5倍
  • 实现了基础的容灾能力(任意一个节点宕机不影响服务)

第三阶段:多集群架构(2019.04-2020.12)

随着业务进一步增长,单集群开始显现瓶颈:

  • 数据量突破5000万后,单集群查询开始变慢
  • 不同业务线互相影响(搜索拖垮了日志分析)
  • 跨机房容灾需求

我们采用了多集群架构:

复制代码
                    ┌──────────────────┐
                    │   Search Gateway │  (统一入口,智能路由)
                    └──────────────────┘
                              │
         ┌────────────────────┼────────────────────┐
         ▼                    ▼                    ▼
┌────────────────┐   ┌────────────────┐   ┌────────────────┐
│  商品搜索集群   │   │  订单搜索集群   │   │  日志分析集群  │
│ (8节点, 热数据) │   │ (6节点, 温数据) │   │ (3节点, 冷数据)│
└────────────────┘   └────────────────┘   └────────────────┘
  • 商品搜索集群:8节点,SSD存储,保留最近90天数据
  • 订单搜索集群:6节点,SSD+HDD混部,保留最近2年数据
  • 日志分析集群:3节点,HDD存储,保留最近30天热数据,冷数据归档到S3

当前架构:云原生时代(2021-至今)

现在我们迁移到了Kubernetes上的ES Operator管理:

yaml 复制代码
# Elasticsearch CR配置示例
apiVersion: elasticsearch.k8s.elastic.co/v1
kind: Elasticsearch
metadata:
  name: production-es
  namespace: es-prod
spec:
  version: 8.11.0
  nodeSets:
    # Hot节点 - 处理写入和热门查询
    - name: hot-nodes
      count: 5
      config:
        node.attr.temp: hot
      volumeClaimTemplates:
        - metadata:
            name: elasticsearch
          spec:
            resources:
              requests:
                storage: 500Gi
            storageClassName: ssd
  
    # Warm节点 - 处理历史数据查询
    - name: warm-nodes
      count: 8
      config:
        node.attr.temp: warm
      volumeClaimTemplates:
        - metadata:
            name: elasticsearch
          spec:
            resources:
              requests:
                storage: 1Ti
            storageClassName: hdd
  
    # Master节点
    - name: master-nodes
      count: 3
      config:
        node.master: true
        node.data: false
        node.ingest: false

3.3 数据同步方案

主数据存储在MySQL中,我们采用以下方案同步到ES:

方案一:Canal + Kafka(推荐)

复制代码
MySQL → Canal → Kafka → 消费者 → ES

优点:解耦、可靠、支持重试

缺点:延迟稍高(通常1-3秒)

方案二:Logstash JDBC插件

复制代码
MySQL → Logstash(jdbc) → ES

优点:配置简单

缺点:不适合实时场景,资源消耗大

方案三:应用层双写

复制代码
应用 → MySQL + ES (同步双写)

优点:延迟最低

缺点:需要处理分布式事务一致性问题

我们最终采用的是方案一,Canal监听MySQL的binlog,通过Kafka解耦,下游用自定义消费者处理:

java 复制代码
// Canal消费者示例
@Component
public class EsSyncConsumer {
    
    @KafkaListener(topics = "mysql-binlog", groupId = "es-sync")
    public void consume(BinLogMessage message) {
        String tableName = message.getTableName();
        OperationType opType = message.getType();
        
        if ("product".equals(tableName)) {
            switch (opType) {
                case INSERT:
                case UPDATE:
                    esService.indexDocument(message.getAfter());
                    break;
                case DELETE:
                    esService.deleteDocument(message.getId());
                    break;
            }
        }
    }
}

四、踩坑实录:那些年我们踩过的ES"地雷"

这一节来分享我在ES运维中踩过的那些坑,希望你能绕过这些"地雷"。

4.1 坑一:mapping爆炸

问题现象

ES集群突然不可用,所有查询都超时。登录查看发现cluster health是red,某个索引的unassigned shards高达数百个。

问题根因

我们有一个日志收集场景,用户上传的日志可能有任意字段。我们用了动态mapping(dynamic: true),导致字段数无限增长。ES的mapping中字段数有默认限制(默认1000),超过后就会报错。

解决过程

bash 复制代码
# 查看索引的字段数量
GET /your_index/_mapping

# 解决方案一:关闭动态mapping
PUT /your_index
{
  "mappings": {
    "dynamic": false,
    "properties": {
      "timestamp": {"type": "date"},
      "message": {"type": "text"}
    }
  }
}

# 解决方案二:设置字段数限制
PUT /your_index/_settings
{
  "index.mapping.total_fields.limit": 2000
}

# 解决方案三:使用动态模板
PUT /your_index
{
  "mappings": {
    "dynamic_templates": [
      {
        "strings": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "keyword"  # 用keyword避免text的分词开销
          }
        }
      }
    ]
  }
}

经验总结

  • 生产环境务必关闭动态mapping,或设置合理的字段数限制
  • 对日志类数据,使用动态模板统一设置字段类型
  • 定期检查索引字段数量,发现异常及时处理

4.2 坑二:深度分页

问题现象

运营提了一个需求:查看第10000页的商品列表。查询发出后,服务器CPU飙升,5秒后超时。

问题根因

ES的深度分页(from + size)是最常见的性能杀手。假设你查询第10000页,每页10条:

  1. ES需要在每个分片上取出前10010条数据
  2. 然后在Coordinating节点合并排序
  3. 最后返回第10000-10010条

当分片数多、数据量大时,这个操作会消耗大量内存和CPU。

解决过程

bash 复制代码
# 错误示范 - 深度分页
GET /products/_search
{
  "from": 10000,
  "size": 10,
  "query": {"match_all": {}}
}

# 解决方案一:限制最大from值
# 在elasticsearch.yml中配置
index.max_result_window: 10000

# 解决方案二:使用search_after(推荐)
# 第一次查询
GET /products/_search
{
  "size": 10,
  "query": {"match_all": {}},
  "sort": [{"_id": "asc"}]
}
# 返回最后一条的sort值:[ "product_9999" ]

# 第二次查询(使用search_after)
GET /products/_search
{
  "size": 10,
  "query": {"match_all": {}},
  "search_after": ["product_9999"],
  "sort": [{"_id": "asc"}]
}

# 解决方案三:使用scroll(适合离线导出)
GET /products/_search?scroll=5m
{
  "size": 1000,
  "query": {"match_all": {}}
}
# 返回scroll_id,后续用scroll_id获取后续数据

# 解决方案四:使用pit(point in time)- ES 7.10+
# 创建一个pit
POST /products/_pit?keep_alive=5m
# 返回pit_id

# 使用pit查询
GET /_search
{
  "pit": {"id": "pit_id", "keep_alive": "5m"},
  "size": 10,
  "query": {"match_all": {}},
  "sort": [{"_id": "asc"}]
}

经验总结

  • 永远不要用from+size做深度分页
  • 前端分页建议使用search_after或pit
  • 超过10000条数据,考虑用scroll做离线处理
  • 实际上,大多数业务场景用户不会翻到第10000页,可以用"没有更多了"来限制

4.3 坑三:聚合分页

问题现象

做一个按照品牌聚合的查询,需要展示前100个品牌及其商品数量。查询耗时3秒,无法接受。

问题根因

ES的聚合(aggregation)默认只返回前10个bucket。请求100个需要设置size参数,但这会导致所有数据先加载到内存,再排序返回,非常消耗资源。

解决过程

bash 复制代码
# 默认只返回10个聚合结果
GET /products/_search
{
  "size": 0,
  "aggs": {
    "brands": {
      "terms": {
        "field": "brand.keyword",
        "size": 10  # 默认10
      }
    }
  }
}

# 正确的分页聚合方式 - 使用composite aggregation
GET /products/_search
{
  "size": 0,
  "aggs": {
    "brands": {
      "composite": {
        "size": 20,
        "sources": [
          {
            "brand": {
              "terms": {
                "field": "brand.keyword"
              }
            }
          }
        ]
      },
      "aggs": {
        "top_products": {
          "top_hits": {
            "size": 5,
            "sort": [{"sales": "desc"}]
          }
        }
      }
    }
  }
}

4.4 坑四:脑裂问题

问题现象

机房网络抖动后,集群分裂成两个小集群,两个都认为自己是主节点。数据写入出现冲突,部分数据丢失。

问题根因

我们只有2个Master节点的集群,网络抖动时两个节点都认为对方宕机,各自选举自己为Master。这就是经典的脑裂问题。

解决过程

yaml 复制代码
# elasticsearch.yml
# 关键配置:最小master节点数
# 公式:(master节点数 / 2) + 1
# 3个master节点 -> 最小2个
discovery.zen.minimum_master_nodes: 2

# 建议:生产环境至少3个master节点
# 并且使用合理的选举超时时间
discovery.zen.join_timeout: 30s
discovery.zen.publish_timeout: 30s

# 更好的方案:使用dedicated master节点
node.master: true
node.data: false
node.ingest: false

4.5 坑五:内存溢出

问题现象

一个复杂的聚合查询导致ES节点OOM,JVM进程被kill。

问题根因

复杂的聚合查询(如嵌套聚合、大量terms)会消耗大量内存。ES的fielddata默认是懒加载的,首次聚合时会一次性加载全部数据到内存。

解决过程

bash 复制代码
# 查看当前fielddata使用情况
GET /_nodes/stats/indices/fielddata?fields=*

# 限制fielddata内存使用
PUT /your_index/_settings
{
  "indices.breaker.fielddata.limit": "30%"  # 默认45%
}

# 使用doc_values代替fielddata
# text字段默认不支持聚合,需要改为keyword
PUT /your_index/_mapping
{
  "properties": {
    "category": {
      "type": "text",
      "fields": {
        "keyword": {
          "type": "keyword"
        }
      }
    }
  }
}

# 聚合时使用keyword子字段
GET /your_index/_search
{
  "aggs": {
    "category": {
      "terms": {
        "field": "category.keyword"
      }
    }
  }
}

# 对大数据量使用composite aggregation分批处理

五、总结与思考

5.1 核心要点回顾

  1. ES是搜索引擎,不是数据库 - 理解倒排索引的原理,合理使用场景
  2. 集群规划要趁早 - 预估数据量,合理设置分片数
  3. 角色分离是必须的 - Master/Data/Coordinating节点各司其职
  4. 容灾不能少 - 多机房部署,自动备份,定期演练
  5. 监控要及时 - 关注cluster health、索引健康、查询延迟

5.2 思考题

  1. 如果你负责设计一个日均10亿搜索请求的架构,你会如何规划ES集群?
  2. 当ES集群出现性能问题时,你会优先排查哪些指标?
  3. 如何在保证搜索体验的同时,实现数据的实时性(秒级)?

5.3 个人观点

ElasticSearch确实是一个强大的搜索和日志分析平台,但它不是万能的。在我看来,ES最适合的场景是:

  • 全文检索(搜索引擎)
  • 日志分析(ELK Stack)
  • 监控数据存储(APM)

而对于强事务需求、复杂关联查询、精确计数的场景,MySQL仍然是更好的选择。

最好的架构不是"一个系统解决所有问题",而是"让合适的系统做合适的事情"。ES和MySQL配合使用,才是真正的最优解。


本文作者:架构实战系列
原创不易,转载请注明出处

相关推荐
踩着两条虫4 小时前
可视化设计器组件系统:从交互核心到 AI 智能代理的落地实践
开发语言·前端·人工智能·低代码·设计模式·架构
Java识堂4 小时前
MongoDB架构详解
数据库·mongodb·架构
薛定猫AI4 小时前
【深度解析】Hermes Agent 0.14.0:本地代理、会话交接与自主工作流架构实践
架构
逸Y 仙X5 小时前
文章三十三:Elasticsearch 文本分词器深入实战
java·大数据·elasticsearch·搜索引擎·全文检索
Yeats_Liao5 小时前
BLE Mesh能承载AI推理吗?分布式边缘AI节点部署实战
服务器·人工智能·分布式·架构·边缘计算
TDengine (老段)16 小时前
TDengine RAFT共识协议 — 选举、日志复制、快照与仲裁
android·大数据·数据库·物联网·架构·时序数据库·tdengine
码云之上16 小时前
万星入坞:我们如何用三层插件体系干掉巨石应用
前端·架构·前端框架
kyriewen16 小时前
一口气讲清楚 Monorepo、Turborepo、pnpm、Changesets 到底是什么?
前端·架构·前端工程化
zzmgc418 小时前
纯静态 + Web Worker + 虚拟滚动:我是怎么让浏览器吃下 10MB JSON 不卡的
前端·架构