ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Elasticsearch性能优化实战:10个关键技巧与调优经验

Elasticsearch性能优化实战:10个关键技巧与调优经验 接手Elasticsearch集群的第一个月我几乎都在救火搜索从200毫秒涨到3秒、写入高峰期bulk请求一片红色rejected、隔三差五集群变黄。查到最后发现绝大多数性能问题跟CPU核数没关系罪魁祸首是映射设计、分片策略和一些被忽略的系统参数。很多文章喜欢把ES性能优化讲成玄学动不动就列几十个参数调优清单但生产环境里真正有用的往往是最朴素的那几条。这篇内容我把自己这几年在搜索性能上验证过的10个关键技巧摊开讲适合正在用ES做日志检索、订单查询、用户标签聚合的读者也适合刚接手ES集群、还没踩过坑的运维新手。每一条我都会说清楚为什么这么改、改的时候要注意什么以及我自己踩过的坑。1. 映射设计性能在数据进来之前就已经定了1.1 技巧1字段类型手动设置keyword和text是两回事搜索性能问题里我见过最多的一类是“动态映射惯坏了所有人”。开启动态映射后往索引里丢一个字符串ES会自作主张生成一个text字段顺带给你加一个keyword子字段。表面省事实际上坑特别多。一个很典型的场景状态字段status被自动映射成text保存的是“SUCCESS”查询时写match或term都会出问题。text字段默认走standard分词器会把“SUCCESS”切成小写term你拿一个大写字符串去做term查询大概率匹配不上即便匹配上了还会触发算分开销。更麻烦的是text字段默认不能用来排序和聚合等业务方找上门要按状态分组统计时改映射已经晚了。我的建议很直接所有索引的mapping都要手工维护不要依赖动态映射。类型选择上记住一个原则——需要精确匹配、过滤、排序、聚合的字段用keyword需要全文检索、按语义拆词的字段才用text并显式指定分析器。数字别乱用long状态、枚举、编号、IP这些用keyword更合适金额类字段用scaled_float既能保证精度又省空间。日期字段要指定format别让ES猜。一个订单索引的映射示例PUT /orders { mappings: { properties: { order_no: { type: keyword }, status: { type: keyword }, remark: { type: text, analyzer: ik_max_word }, amount: { type: scaled_float, scaling_factor: 100 }, created_at: { type: date, format: epoch_millis } } } }映射一旦创建大部分属性是不能改的。所以新接入一个业务索引时我会要求开发把每个字段的用途写清楚是只展示、只过滤、还是需要聚合排序。这个评审流程比事后调优值钱得多。1.2 技巧2关掉没用的doc_values、norms和index_options第二个技巧跟前一个相关但很多人不知道不是所有字段都需要倒排索引也不是所有字段都需要doc_values。先理清两个机制的区别。倒排索引负责查“文档里有没有这个词”doc_values负责读“这个字段在这个文档里存的值”主要服务于排序和聚合。一个字段如果只用来展示、不参与任何过滤搜索理论上可以不开倒排索引节省空间。一个字段如果只做过滤、不做聚合排序doc_values就可以关掉。默认情况下keyword字段的doc_values是开的text字段没有doc_values却有norms。norms是啥它是做相关度打分时需要读取的归一化因子。如果一个字段永远在filter上下文中用压根不参与评分norms可以关掉。另一个选项是index_options默认是positions会存分词后的位置信息用于短语查询。如果该字段永远不做短语/位置查询设置成docs就够了同样能省下一截存储和加载开销。打个比方访问日志索引里有个client_ip字段既需要过滤又需要分组统计那倒排索引和doc_values都保留。而response_time如果只在结果列表里展示不聚合、不排序就可以把doc_values关掉。操作方式是在映射里加response_time: { type: integer, doc_values: false, index: false }这里有个注意点关掉index不代表字段不能查了而是不做倒排索引直接扫描doc_values或原文实际上index:false之后普通term查询是查不了的。所以生产上一定要想清楚使用场景再动别为了省那点空间把过滤能力也省没了。1.3 技巧3fielddata慎开wildcard搜索要有底线text字段本身没有doc_values想做聚合和排序时ES会让你开启fielddata把字段值全部加载进堆内存。有一次生产事故就是这么来的开发同学给一个高基数的tag字段开了fielddata做聚合集群堆内存从40%一路涨到95%眼看着就要OOM。fielddata的代价是吃掉大量JVM堆而且无法被操作系统换页堆一满整个集群搜索全部卡死。正确的做法是用multi-fields给text字段挂一个keyword子字段做聚合或者用runtime fields按需做计算字段。千万别为了图方便直接开fielddata。再提一个很容易中招的搜索写法wildcard通配符。*开头的匹配在底层要遍历倒排索引里的几乎全部term高基数keyword字段上做一次CPU就冒烟。低频小索引还能忍生产环境里碰到那种“用户输入一个前缀星号就去搜”的需求必须提前预警。替代方案有这么几个使用wildcard字段类型它用免索引skip index的方式降低通配开销或者对搜索词做edge ngram分词用前缀索引来支撑模糊匹配再或者在产品功能上限制通配符只能从中间某段开始。总之别让wildcard裸奔在核心链路上。2. 分片规划与索引形态结构不对调优白费2.1 技巧4分片大小和数量要控制在合理区间分片是ES数据分布和并行查询的基本单位但很多新手对分片的态度是“多多益善”巴不得一个索引拆成100个分片。实际效果是分片太少单分片数据量过大重启恢复和rebalance都慢分片太多每个查询都要广播到大量分片再把结果合并合并开销和segment内存开销反而拖垮性能。业内的经验值是单个分片控制在30到50GB左右。这不是官方铁律而是大量生产集群验证出来的甜点区。以每天200GB的日志量为例按天建索引每个索引分3个主分片每个分片大概66GB略高如果分5个主分片每个约40GB就舒服很多。你可以在创建索引时根据预估日增量反推主分片数 预估单索引数据量 / 40GB向上取整。主分片数在索引创建之后就不能改了只能通过split或reindex重建。所以新索引上线前一定要根据数据量增长趋势做估算别拍脑袋定个2或者30。还有一个容易被忽略的点副本也是分片ES集群里所有分片主副本的总数会直接决定索引结构在不同节点间的分布。一个节点上堆几百个分片哪怕每个都不大仅分片元数据和segment元数据的开销就能吃掉不少堆内存。经验上每GB堆内存承载的分片个数控制在20~25个以内比较稳妥。2.2 技巧5用滚动索引和ILM把生命周期理清楚日志、事件、监控这类时序数据我强烈建议按天建索引而不是一个月一个大索引。原因有三个按天建索引可以只对某一天的数据做force merge、关闭或删除可以通过索引别名对外暴露统一的读写入口还可以配合ILM索引生命周期管理让旧索引自动降级和清理。滚动索引的写法是这样的在别名上写入当满足大小或时间阈值时自动切换到新索引。POST /logs-write/_rollover { conditions: { max_size: 50gb, max_age: 1d } }配上ILM policy之后hot阶段保持SSD上的实时写入warm阶段自动把索引置为只读并做force mergecold阶段可以缩分片或冻结delete阶段定期清理。这套流程跑起来后运维工作量能下降一大截。这里重点说明force merge。ES的Lucene底层是LSM风格不断产生新的segment查询时要跨多个segment读取segment数量越多性能越差。对不再写入的只读索引执行一次强制合并POST /logs-2024-06-01/_forcemerge?max_num_segments1把几十个小segment合并成一个大segment查询速度立竿见影。但有两个坑一是千万不能对正在写入的热索引频繁force merge合并操作本身要读所有旧段、写新段IO压力巨大会踩死写入链路二是force merge之后的索引如果后续还有大量更新或删除段还是会被重新拆分所以只在“只读”阶段做。2.3 副本数量读能力与写压力的平衡副本是ES提升读吞吐最直接的手段同一份数据多放几份多个节点可以并行承担查询。但副本不是越多越好每增加一个副本主分片的每次写入都要多同步一份写放大效应非常明显。在写入密集的时段副本数量过高会让bulk耗时明显上升。我的生产习惯是分场景对待。正常在线业务副本数保持1份如果是纯历史数据且读多写少可以适度提高到2份大批量导入历史数据时直接先把副本设为0导入完成后再调回1同时配合关闭refresh导入速度能快好几倍。恢复副本时要留意节点间的数据拷贝流量别一不小心把网络打满。3. 查询提速每个请求都能省则省3.1 技巧6filter和query分清楚能进filter就不进mustES查询分两种上下文query context和filter context。query context要计算相关度分数会做词频、逆文档频率统计性能开销高filter context只回答“匹配还是不匹配”不记分而且结果可以被节点级的bitset缓存。换句话说同样的查询条件放在filter里比放在must里快得多。很多线上慢查询就是开发把所有条件一股脑塞进must导致的。比如一个订单搜索用户输入关键词算分没问题但“状态是已完成”、“创建时间在一小时内”这种条件压根不需要打分就该放filter里。推荐写法GET /orders/_search { query: { bool: { filter: [ { term: { status: SUCCESS } }, { range: { created_at: { gte: 2024-06-01T00:00:00Z } } } ], must: [ { match: { remark: 加急订单 } } ] } } }顺序上我习惯把filter条件放在must前面。虽然ES会自动做条件重排但这样写语义更清晰。更重要的是filter命中的候选集直接变成一个bitset后续must阶段只需要在这个小集合上算分候选集越小性能越好。3.2 技巧7深分页别再用fromsize改用search_afterES默认限制fromsize最多查10000条超过直接报错。即便你把index.max_result_window调大深翻页的性能也是硬伤每次分页ES都要把所有分片里符合条件的前N条结果全部取出来排序然后丢掉前面的再返回你要的那一页。翻到第10000条代价是扫描所有匹配结果的前10000条页面越深越慢。生产环境里用户逐页浏览、无限滚动翻页这类场景应该用search_after。它和fromsize的区别是不指定偏移量而是拿上一页最后一条记录的排序值作为起点继续往后取。比如按时间倒序翻页GET /orders/_search { size: 100, sort: [ { created_at: desc }, { _id: asc } ], search_after: [1719552000000, order_012345] }这里有个非常容易踩的坑排序字段必须唯一否则在时间戳相同的记录上翻页会漏数据或重复。所以我永远会在sort条件里加一个_id作为tie-breaker保证排序值全局唯一。至于scroll它适合做全量导出因为会在内存里维持一个查询上下文快照。但它不适合线上交互翻页上下文占资源而且不反映新的数据变化。新版本里更好的选择是PITpoint in time加search_after组合既稳定又支持后续新增数据的搜索。总之深分页这件事选对方案比硬扛更重要。3.3 技巧8想尽办法让shard request cache多命中ES的分片级请求缓存shard request cache对size:0的请求生效也就是count和聚合这类只需要统计结果、不需要返回明细的查询。命中缓存后聚合响应基本是毫秒级返回。这个特性默认是开启的但我发现不少用户的缓存命中率低得可怜原因是DSL写得不够“稳定”。最常见的罪魁祸首是DSL里用了动态时间比如gte: now-1h。每次请求生成的查询字符串都不同缓存压根没法命中。解决方法是在业务侧把时间窗口计算成固定的绝对时间戳再拼进查询里。比如前端传“最近一小时”后端代码先算出2024-06-05T14:00:00Z到2024-06-05T15:00:00Z再拿去查ES。另外要明白request cache和filter cache的区别。filter cache缓存的是某个查询条件在每个segment上的bitset只要数据没变重复使用条件就能命中request cache缓存的是整个请求的最终聚合结果要求请求本身完全一致。高频dashboard的count、group by查询特别适合打开request cache而那些带了随机排序、用户私有参数的高基数聚合缓存价值不大反而占用内存。查看缓存效果可以这样GET /logs/_stats/request_cache?pretty重点关注count、evictions和memory_size_in_bytes。如果evictions特别大说明缓存被频繁淘汰考虑是不是堆内存太紧或者请求多样性太高。4. 写入性能批量、刷盘与磁盘瓶颈判断4.1 技巧9批量写入与refresh_interval是绝配先纠正一个最常见的坏习惯程序里for循环一条一条insert然后每天都抱怨ES写入慢。ES的bulk API就是为批量而生的单条请求既有网络往返开销又有重复的语法解析和提交事务开销。批量提交才是正路。批次大小没有绝对标准我常用的经验值是单批数据量在5到15MB之间或者2000到5000条数据。太小批次过多浪费吞吐太大会让单次请求内存压力变大反而触发熔断。再就是refresh_interval。ES默认每秒refresh一次把内存中的缓冲区变成可见的segment所以叫“近实时搜索”。但每秒refresh意味着每秒都可能产生一个小segmentsegment一多后台merge压力就大写入链路会被拖慢。如果业务允许查询延迟30到60秒把refresh_interval从默认的1秒调成30秒或60秒写入吞吐的提升非常明显。PUT /logs/_settings { index.refresh_interval: 30s }translog也值得调。默认durability: request模式下每个写请求都要强制fsync到磁盘才返回安全但慢。高吞吐场景可以改成async由ES按sync_interval默认5秒批量刷盘性能提升明显代价是节点异常宕机时可能丢失几秒数据。这个取舍必须让业务方确认不能自己拍板。大批量导历史数据时我还会把副本临时设为0、refresh设为-1彻底关闭导完再恢复。实践中这样一套组合拳写入吞吐能提升两到三倍。4.2 写入变慢到底怨谁指标排查与磁盘识别“ES写入变慢了怎么判断是磁盘问题还是集群问题”这是我被问得最多的一个问题。排查思路应该由内到外按顺序看四类指标。第一步看写入线程池。命令是GET /_cat/thread_pool/write?vhnode_name,name,active,queue,rejected如果queue持续增长rejected数量不断增加说明写入请求已经堆积到线程池处理不过来了。第二步看段落合并。写入速度快不等于一切正常如果后台merge线程在疯狂合并大段同样会占用大量IO和CPU把写入拖慢。GET /_nodes/stats/indices/merges?filter_path**.*.merges.current,**.*.merges.total_time_in_millis第三步看JVM GC。老年代GC频繁、GC耗时长堆可能已经不够用或者有fielddata把堆吃掉了。第四步才看系统层磁盘。用iostat和vmstat看IO状态。写入变慢的定位表现象大致原因排查命令write队列暴涨、rejected持续增加写入线程池打满或下游merge拥堵_cat/thread_pool/writeiowait高、w_await高、util接近100%磁盘IO饱和多半是机械盘iostat -x 1、vmstat 1GC次数多、GC耗时高、heap居高不下JVM堆过小或堆内有大量缓存数据_nodes/stats/jvmmerges.current长期非零、total_time增长快段合并抢占资源_nodes/stats/indices/merges单分片segment数上千refresh太快或长期没force merge_cat/segments判断磁盘问题时重点看iostat -x 1的await和util。util接近100%说明磁盘一直在忙w_await明显偏高说明写请求排队时间长。但要注意util在新型SSD上参考意义不如吞吐量直观最好结合iostat的wkB/s和磁盘额定写带宽对比判断是不是到了天花板。分享一个真实案例。我们有一个日志集群某天写入延迟从50毫秒涨到500毫秒查了thread_pool发现rejected还不算严重GC也正常但所有节点的iowait稳定在80%以上。用iostat一看写吞吐已经打到机械盘的极限了。这个集群的hot节点用的还是SATA机械盘多路merge一起压上去就直接趴窝。后来hot节点全部换成SSD同样的写入量延迟回落到了正常区间。4.3 磁盘选型hot层别省钱SSD是底线Lucene的写入模型是顺序写新segment但合并时要读多个旧段、再写新段大量随机IO。translog还需要频繁fsync。机械盘在这种工作负载下延迟波动非常厉害bulk一上来就原形毕露。所以我的结论很明确承载实时写入和近期查询的hot层必须用SSD盘再大也不能拿机械盘顶包。warm/cold层放只读历史数据用机械盘或对象存储都行毕竟查询频率低、写入已经停了。还有一个忠告不要用NFS这类网络存储作为ES的data目录。官方不支持锁和网络延迟都可能让分片状态异常。本地盘是最稳的选择如果机房条件受限用云盘至少要在写入性能指标上做一个完整的压测验证。5. 内存与线程池Java堆只是ES内存的一半5.1 技巧10堆该给多大文件系统缓存又是怎么回事很多人以为ES内存调优就是把JVM堆调大机器64GB就给堆32GB甚至更多。这是个大误区。ES的搜索性能其实不只是靠JVM堆Lucene的倒排索引、doc_values、segment文件本身大量依靠操作系统的文件系统缓存来加速读取。堆负责的是索引结构信息、聚合中间结果和查询时的相关数据结构而真正读磁盘上的doc values和倒排文件时走的往往是OS page cache。堆太大的后果是GC停顿变长。堆达到30GB以上时一次Full GC就要停顿好几秒搜索在这几秒内集体卡死。官方和社区的经验是堆给到物理内存的一半左右并且尽量别超过30.5GB因为一旦超过32GB边界JVM无法使用压缩对象指针内存占用和GC开销都会明显上升。拿64GB机器来说ES_JAVA_OPTS设置-Xms30g -Xmx30g其余内存留给文件系统缓存搜索性能反而更好。还有一个容易忽略的参数bootstrap.memory_lock: true。它的作用是锁定ES的堆内存防止操作系统把它swap到磁盘。一但发生swap查询延迟会剧烈抖动这种问题不看监控基本发现不了。开启前要注意系统是否有权限lock内存否则ES会启动失败。5.2 线程池和hot_threads卡在哪一环节一看便知排查线上偶发慢查询时hot_threads接口是我首先会用的GET /_nodes/hot_threads它会抓取各节点CPU占用最高的线程栈直接告诉你瓶颈在哪个环节——是GC线程在悄悄做Full GC还是merge线程在疯狂刷段或是某个搜索线程卡在了一个慢查询上。有一次我就靠这个接口发现某节点出现大量线程卡在Lucene的TermsEnum遍历上顺藤摸瓜找到了一条未加前缀限制的wildcard查询。关于线程池调优我的态度是“少动参数多动流量”。search线程池的大小默认跟CPU核数相关queue_size默认1000。把queue_size调大看起来能抗住更多请求实际上是把请求的延迟从“快速失败”变成“排队等待”最终用户体验更差。生产上更合理的做法是做好容量规划、在入口限流、用费控手段保护核心查询真扛不住就加节点。同样indices.breaker内存熔断器保护着堆不被超大聚合撑爆。有些团队为了跑一个大聚合把熔断阈值调高结果就是集群OOM。正确做法是保留默认阈值然后优化聚合本身、拆分请求或者改预聚合。6. 上线前的压测与日常巡检把隐患挡在故障之前6.1 开启慢查询日志把问题晒在明面上性能优化最怕的是“感觉慢”没有数据支撑。生产环境我建议把ES的慢查询日志打开把超过阈值的query和fetch都记录下来PUT /logs/_settings { index.search.slowlog.threshold.query.warn: 2s, index.search.slowlog.threshold.query.info: 1s, index.search.slowlog.threshold.fetch.warn: 1s }有了慢查询日志再结合业务访问量就能知道哪些接口的DSL写得有问题。线上压测也别拿ab随便打建议用esrally或者拿线上真实查询做脱敏回放。压测期间重点盯四个指标p99延迟、堆内存使用曲线、线程池rejected数量、GC次数和耗时。这四个指标任何一个出现异常曲线都说明系统在临界点附近。6.2 每周一次巡检几个命令就够了性能是养出来的不是调一次就完事。我习惯每周给集群做一次“轻量体检”基本就是下面几条命令curl -s localhost:9200/_cluster/health?pretty curl -s localhost:9200/_cat/nodes?vhip,heap.percent,cpu,load_1m curl -s localhost:9200/_cat/indices?vhindex,docs.count,store.size,pri.store.size curl -s localhost:9200/_cat/thread_pool/write,search?vhnode_name,name,active,queue,rejected curl -s localhost:9200/_cat/segments?vhindex,shard,count巡检时重点看几个信号有没有red或yellow索引、有没有unassigned分片、堆水位是不是长期超过85%、磁盘水位是否逼近阈值。ES默认磁盘水位规则是超过85%不再分配新分片、超过90%开始把分片迁走、超过95%直接让索引只读。有一回我们某个节点磁盘悄悄到了93%ES自动把索引设成了只读业务写不进数据排查了半天才发现是磁盘水位触发了保护。从那以后磁盘水位成了我每次巡检第一眼看的东西。最后说句实在话。如果非要从这10个技巧里挑一个最先落地我会先改映射。因为映射一旦建错后面所有调优都像是拿放大镜去修一座歪掉的地基分片规划、查询缓存、force merge这些更多是在地基正确的基础上救火。我自己习惯把这10条整理成一份上线checklist每接入一个新业务索引就完整过一遍比出了问题再翻监控踏实得多。ES的性能是一点点设计出来、一点点养出来的希望这些经验能帮你少交一点学费。
返回列表