【架构实战】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节点负责数据预处理pipelineCoordinating节点负责请求转发和聚合最小集群配置生产环境至少3个Master节点数据节点建议3个以上视数据量而定使用SSD存储机械硬盘会拖垮查询性能容灾设计跨机房/可用区部署开启自动备份snapshot定期进行恢复演练二、集群配置实战从零搭建高可用ES集群这一节我们来看看如何从头搭建一个生产级的ES集群。我会给出完整的配置文件和关键参数解释。2.1 节点规划假设我们有3台服务器配置如下CPU: 16核内存: 64GB磁盘: 2TB SSD网络: 万兆网卡规划如下node-1: Master Data同时承担Master和数据职责node-2: Master Datanode-3: Master Data Ingest2.2 核心配置文件以下是node-1的elasticsearch.yml配置# 集群名称所有节点必须一致cluster.name:production-es-cluster# 节点名称每个节点唯一node.name:node-1node.master:truenode.data:truenode.ingest:false# 绑定地址生产环境应绑定内网IPnetwork.host:10.0.1.101http.port:9200transport.tcp.port:9300# discovery配置 - Zen Discoverydiscovery.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:4cluster.routing.allocation.node_concurrent_recoveries:2indices.recovery.max_bytes_per_sec:100mb# 内存配置 - 建议留一半给系统# ES默认使用一半物理内存作为JVM堆# 但如果机器内存很大可以调小这个比例# 官方建议不超过32GB最好保持在26GB以下# 因为JVM使用compressed oops的阈值就是约26GBbootstrap.memory_lock:trueES_JAVA_OPTS:-Xms30g -Xmx30g -XX:UseG1GC# 跨机房部署时的分区感知# 假设我们有3个机架rack1, rack2, rack3cluster.routing.allocation.awareness.attributes:rack_idnode.attr.rack_id:rack1# 索引默认配置index.number_of_shards:5index.number_of_replicas:1# 搜索性能优化indices.queries.cache.size:15%indices.fielddata.cache.size:30%2.3 JVM参数调优ES 7.x版本的JVM推荐配置针对64GB内存机器# /etc/elasticsearch/jvm.options# 堆内存设置 - 建议留一半给OS-Xms31g-Xmx31g# G1垃圾回收器 - ES官方推荐-XX:UseG1GC-XX:MaxGCPauseMillis200-XX:G1HeapRegionSize16m-XX:InitiatingHeapOccupancyPercent30-XX:G1NewSizePercent25# 禁用JMX远程监控生产环境可开启并配置密码-Dcom.sun.management.jmxremotefalse# 启用G1的并行GC线程-XX:ParallelGCThreads16-XX:ConcGCThreads42.4 系统参数优化Linux系统层面需要调整以下参数# /etc/sysctl.conf# 增加文件描述符限制fs.file-max655360# 增加内存映射限制vm.max_map_count262144# 增加线程数限制kernel.threads-max655360# TCP参数优化net.core.somaxconn65535net.ipv4.tcp_max_syn_backlog65535# 禁用swappinessES需要内存不适合swapvm.swappiness1# /etc/security/limits.conf# 增加ES用户限制elasticsearch soft nofile655360elasticsearch hard nofile655360elasticsearch soft nproc655360elasticsearch hard nproc655360elasticsearch 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节点SSDHDD混部保留最近2年数据日志分析集群3节点HDD存储保留最近30天热数据冷数据归档到S3当前架构云原生时代2021-至今现在我们迁移到了Kubernetes上的ES Operator管理# Elasticsearch CR配置示例apiVersion:elasticsearch.k8s.elastic.co/v1kind:Elasticsearchmetadata:name:production-esnamespace:es-prodspec:version:8.11.0nodeSets:# Hot节点 - 处理写入和热门查询-name:hot-nodescount:5config:node.attr.temp:hotvolumeClaimTemplates:-metadata:name:elasticsearchspec:resources:requests:storage:500GistorageClassName:ssd# Warm节点 - 处理历史数据查询-name:warm-nodescount:8config:node.attr.temp:warmvolumeClaimTemplates:-metadata:name:elasticsearchspec:resources:requests:storage:1TistorageClassName:hdd# Master节点-name:master-nodescount:3config:node.master:truenode.data:falsenode.ingest:false3.3 数据同步方案主数据存储在MySQL中我们采用以下方案同步到ES方案一Canal Kafka推荐MySQL → Canal → Kafka → 消费者 → ES优点解耦、可靠、支持重试缺点延迟稍高通常1-3秒方案二Logstash JDBC插件MySQL → Logstash(jdbc) → ES优点配置简单缺点不适合实时场景资源消耗大方案三应用层双写应用 → MySQL ES (同步双写)优点延迟最低缺点需要处理分布式事务一致性问题我们最终采用的是方案一Canal监听MySQL的binlog通过Kafka解耦下游用自定义消费者处理// Canal消费者示例ComponentpublicclassEsSyncConsumer{KafkaListener(topicsmysql-binlog,groupIdes-sync)publicvoidconsume(BinLogMessagemessage){StringtableNamemessage.getTableName();OperationTypeopTypemessage.getType();if(product.equals(tableName)){switch(opType){caseINSERT:caseUPDATE:esService.indexDocument(message.getAfter());break;caseDELETE:esService.deleteDocument(message.getId());break;}}}}四、踩坑实录那些年我们踩过的ES地雷这一节来分享我在ES运维中踩过的那些坑希望你能绕过这些地雷。4.1 坑一mapping爆炸问题现象ES集群突然不可用所有查询都超时。登录查看发现cluster health是red某个索引的unassigned shards高达数百个。问题根因我们有一个日志收集场景用户上传的日志可能有任意字段。我们用了动态mappingdynamic: true导致字段数无限增长。ES的mapping中字段数有默认限制默认1000超过后就会报错。解决过程# 查看索引的字段数量GET /your_index/_mapping# 解决方案一关闭动态mappingPUT /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。解决过程# 错误示范 - 深度分页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_afterGET /products/_search{size:10,query:{match_all:{}},search_after:[product_9999],sort:[{_id:asc}]}# 解决方案三使用scroll适合离线导出GET /products/_search?scroll5m{size:1000,query:{match_all:{}}}# 返回scroll_id后续用scroll_id获取后续数据# 解决方案四使用pitpoint in time- ES 7.10# 创建一个pitPOST /products/_pit?keep_alive5m# 返回pit_id# 使用pit查询GET /_search{pit:{id:pit_id,keep_alive:5m},size:10,query:{match_all:{}},sort:[{_id:asc}]}经验总结永远不要用fromsize做深度分页前端分页建议使用search_after或pit超过10000条数据考虑用scroll做离线处理实际上大多数业务场景用户不会翻到第10000页可以用没有更多了来限制4.3 坑三聚合分页问题现象做一个按照品牌聚合的查询需要展示前100个品牌及其商品数量。查询耗时3秒无法接受。问题根因ES的聚合aggregation默认只返回前10个bucket。请求100个需要设置size参数但这会导致所有数据先加载到内存再排序返回非常消耗资源。解决过程# 默认只返回10个聚合结果GET /products/_search{size:0,aggs:{brands:{terms:{field:brand.keyword,size:10# 默认10}}}}# 正确的分页聚合方式 - 使用composite aggregationGET /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。这就是经典的脑裂问题。解决过程# elasticsearch.yml# 关键配置最小master节点数# 公式(master节点数 / 2) 1# 3个master节点 - 最小2个discovery.zen.minimum_master_nodes:2# 建议生产环境至少3个master节点# 并且使用合理的选举超时时间discovery.zen.join_timeout:30sdiscovery.zen.publish_timeout:30s# 更好的方案使用dedicated master节点node.master:truenode.data:falsenode.ingest:false4.5 坑五内存溢出问题现象一个复杂的聚合查询导致ES节点OOMJVM进程被kill。问题根因复杂的聚合查询如嵌套聚合、大量terms会消耗大量内存。ES的fielddata默认是懒加载的首次聚合时会一次性加载全部数据到内存。解决过程# 查看当前fielddata使用情况GET /_nodes/stats/indices/fielddata?fields*# 限制fielddata内存使用PUT /your_index/_settings{indices.breaker.fielddata.limit:30%# 默认45%}# 使用doc_values代替fielddata# text字段默认不支持聚合需要改为keywordPUT /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配合使用才是真正的最优解。本文作者架构实战系列原创不易转载请注明出处