【架构实战】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条:
- ES需要在每个分片上取出前10010条数据
- 然后在Coordinating节点合并排序
- 最后返回第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 核心要点回顾
- ES是搜索引擎,不是数据库 - 理解倒排索引的原理,合理使用场景
- 集群规划要趁早 - 预估数据量,合理设置分片数
- 角色分离是必须的 - Master/Data/Coordinating节点各司其职
- 容灾不能少 - 多机房部署,自动备份,定期演练
- 监控要及时 - 关注cluster health、索引健康、查询延迟
5.2 思考题
- 如果你负责设计一个日均10亿搜索请求的架构,你会如何规划ES集群?
- 当ES集群出现性能问题时,你会优先排查哪些指标?
- 如何在保证搜索体验的同时,实现数据的实时性(秒级)?
5.3 个人观点
ElasticSearch确实是一个强大的搜索和日志分析平台,但它不是万能的。在我看来,ES最适合的场景是:
- 全文检索(搜索引擎)
- 日志分析(ELK Stack)
- 监控数据存储(APM)
而对于强事务需求、复杂关联查询、精确计数的场景,MySQL仍然是更好的选择。
最好的架构不是"一个系统解决所有问题",而是"让合适的系统做合适的事情"。ES和MySQL配合使用,才是真正的最优解。
本文作者:架构实战系列
原创不易,转载请注明出处