为何放弃MySQL LIKE,选择ES 8.14构建百万级社区搜索

几个月前我接手一个日活80万左右的垂直社区项目,当时帖子表已经涨到370万条,评论表更是破了千万。原来的搜索逻辑直接嵌在MySQL 8.0里,用LIKE '%关键词%'做模糊匹配,还顺带LEFT JOIN了用户表和分类表。平时晚上请求少的时候还能忍,一到晚上9点流量高峰,搜索接口的平均响应直接飙到1200ms,数据库CPU常年卡在90%以上,DBA天天在群里点名让我优化。

我一开始也试过给MySQL加全文索引,用MATCH AGAINST替换LIKE,效果确实好了一点,但问题很快就暴露了。社区搜索不是单纯匹配标题,用户经常搜“2023年关于AI绘画的教程”,需要同时命中标题、正文、标签,还要按相关度排序,MySQL的全文索引在复杂组合查询下性能衰减得厉害,而且没法做拼音搜索、错别字容错,用户搜“AI会话”就找不到“AI绘画”的内容,体验很差。

后来我拉着架构师一起评估,决定迁移到Elasticsearch 8.14.0(2024年5月刚发布的稳定版,当时我们特意等了两周观察社区反馈才上)。选8.14版本不是追新,是因为它内置的向量检索能力已经比较成熟,我们后续打算接语义搜索,而且RESTful API的兼容性比7.x好太多,不用再处理一堆废弃接口的兼容问题。

迁移前我做了个压测对比:同样的370万帖子数据,搜“Python 爬虫 实战”,MySQL的MATCH AGAINST需要420ms,ES 8.14的默认分词查询只要38ms,差距接近11倍。更关键的是ES的分布式能力,我们当时单索引分了3个主分片,每个分片配1个副本,数据量涨到1000万的时候,查询耗时只涨了12ms,而MySQL如果单表破千万,全文索引的维护成本会直接拖垮写入性能。

我们当时建索引的Mapping不是照搬网上的模板,而是结合社区场景做了定制。比如正文内容用了ik_max_word分词,但标题用了ik_smart,因为标题短,细粒度分词反而会产生太多无意义的匹配。还有用户经常搜“2024最新”,我们给发布时间字段加了date类型,支持按时间范围过滤,不用再像MySQL那样对create_time做函数转换导致索引失效。

下面是当时我们创建帖子索引的实际代码,里面加了很多场景化的配置:

PUT /community_posts_v1 { "settings": { "number_of_shards": 3, "number_of_replicas": 1, "analysis": { "analyzer": { "title_analyzer": { "type": "ik_smart", "stopwords": ["的", "了", "呢"] }, "content_analyzer": { "type": "ik_max_word" } } } }, "mappings": { "properties": { "post_id": { "type": "integer" }, "title": { "type": "text", "analyzer": "title_analyzer", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "content": { "type": "text", "analyzer": "content_analyzer" }, "tags": { "type": "keyword" }, "author_id": { "type": "integer" }, "create_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }, "view_count": { "type": "integer" }, "comment_count": { "type": "integer" } } } }

这里有个细节我特意调整过:给title加了一个keyword子字段,因为后面要做按标题精确去重,还有排序的时候如果按标题字母序,用keyword比text快很多。当时如果直接用默认的text类型做排序,ES会报“Fielddata is disabled on text fields by default”的错误,我第一次踩这个坑的时候排查了半小时,后来才明白text类型默认不开启正排索引,要么用keyword要么手动开fielddata,但开fielddata会占大量内存,不划算。

现在回想,放弃MySQL LIKE不是因为ES更“高级”,而是业务场景已经超出了MySQL的舒适区。MySQL擅长的是事务型操作,强一致性读写,而搜索本质是“模糊匹配+相关度排序+高并发读”,这本来就是ES的主场。如果当时硬扛着用MySQL优化,要么得拆搜索表做读写分离,要么得加缓存层,成本比直接上ES高至少3倍,而且后续扩展语义搜索的时候还得重构,不如一步到位。

独家复盘:某日活百万社区搜索从500ms到50ms的优化全记录

去年10月我们上线ES搜索后,初期效果确实好,平均响应稳定在80ms左右。但到了12月大促,社区搞了个“年度内容评选”活动,搜索QPS从平时的120涨到了800,平均响应直接掉到520ms,最慢的时候甚至到了1.2s,用户投诉说搜索比翻页还慢。我当时盯着监控看了三天,把整个链路拆成了索引设计、查询语句、缓存、集群配置四个部分,一步步磨,最后把平均响应压到了47ms,峰值QPS 1200的时候也没超过100ms。

第一个要命的问题是索引设计不合理。我们一开始把所有帖子数据都塞到一个索引里,包括正文里的大段HTML代码,单条文档平均大小有12KB。后来我查ES的慢查询日志,发现80%的查询耗时都花在了文档解析上,因为用户搜的时候根本不需要返回完整正文,只需要标题、摘要、发布时间就够了。于是我把索引拆成了两个:一个是community_posts_basic,只存标题、摘要、标签、时间这些核心字段,单条文档只有2KB;另一个是community_posts_detail,存完整正文,只有用户点进帖子详情的时候才查。拆完之后,搜索查询的文档解析耗时直接降了60%。

然后是查询语句的问题。原来的搜索接口是前端传什么参数就拼什么查询,比如用户搜“Python 教程”,后端直接拼了一个multi_match查标题和内容,没有做任何过滤。我后来加了两个条件:一是如果用户选了时间范围,就加range过滤,而且把过滤条件放在must外面用filter,因为filter不计算相关度,结果会被ES缓存;二是加了post_filter过滤已删除的帖子,原来是在must里加term查询,现在移到post_filter之后,相关度计算的时候不会考虑删除状态,结果更准确,速度也快了20%。

下面是我们优化后的搜索接口核心代码,用Java High Level REST Client写的,当时特意处理了各种边界场景:

public List<PostBasicDTO> searchPosts(String keyword, List<String> tags, Long startTime, Long endTime, Integer page, Integer size) { SearchRequest searchRequest = new SearchRequest("community_posts_basic"); BoolQueryBuilder boolQuery = QueryBuilders.boolQuery(); // 关键词匹配,同时查标题和摘要,权重不同 if (StringUtils.isNotBlank(keyword)) { MultiMatchQueryBuilder matchQuery = QueryBuilders.multiMatchQuery(keyword) .field("title", 2.0f) // 标题权重是摘要的2倍 .field("summary", 1.0f) .fuzziness("AUTO"); // 允许错别字,比如搜“pyhton”也能匹配到“python” boolQuery.must(matchQuery); } // 标签过滤,用filter不计算相关度,结果可缓存 if (CollectionUtils.isNotEmpty(tags)) { boolQuery.filter(QueryBuilders.termsQuery("tags", tags)); } // 时间范围过滤 if (startTime != null && endTime != null) { boolQuery.filter(QueryBuilders.rangeQuery("create_time") .gte(startTime) .lte(endTime)); } // 只返回需要的字段,减少网络传输和文档解析耗时 String[] fetchFields = {"post_id", "title", "summary", "tags", "create_time", "view_count"}; SearchSourceBuilder sourceBuilder = new SearchSourceBuilder() .query(boolQuery) .fetchSource(fetchFields, null) .from((page - 1) * size) .size(size) .sort("view_count", SortOrder.DESC) // 按浏览量排序,热门内容靠前 .sort("_score", SortOrder.DESC); // 相关度次之 searchRequest.source(sourceBuilder); try { SearchResponse response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT); return Arrays.stream(response.getHits().getHits()) .map(hit -> { PostBasicDTO dto = new PostBasicDTO(); dto.setPostId((Integer) hit.getSourceAsMap().get("post_id")); dto.setTitle((String) hit.getSourceAsMap().get("title")); dto.setSummary((String) hit.getSourceAsMap().get("summary")); dto.setTags((List<String>) hit.getSourceAsMap().get("tags")); dto.setCreateTime((Long) hit.getSourceAsMap().get("create_time")); dto.setViewCount((Integer) hit.getSourceAsMap().get("view_count")); return dto; }) .collect(Collectors.toList()); } catch (IOException e) { log.error("ES搜索失败,keyword:{}", keyword, e); return Collections.emptyList(); } }

这里有个我踩过的坑:一开始我没加fetchSource指定返回字段,默认返回整个文档,后来监控发现单次查询的网络传输量有1.2MB,加了之后直接降到120KB,这也是响应时间降下来的重要原因。还有fuzziness("AUTO")这个配置,原来我没加,用户搜“人工智能”打成“人工智障”就匹配不到结果,加了之后自动容错1个编辑距离,搜索成功率直接提了15%。

第三个优化是加了查询缓存。ES本身有节点级别的查询缓存,但只对filter查询有效,我们业务层的缓存用的是Caffeine,缓存键是“keyword + tags + 时间范围 + 页码”,过期时间设30秒,因为社区内容更新频率不高,30秒内的新内容晚点看到也没关系。上线缓存后,缓存命中率稳定在65%左右,直接挡掉了65%的ES查询请求,集群负载直接降了一半。

最后是集群配置调优。我们原来用的3台4核8G的云服务器,JVM堆内存设了4G,后来发现ES的老年代经常满,导致频繁Full GC。我查了官方文档,8.14版本建议堆内存不超过物理内存的50%,而且不要超过32G(因为超过32G会禁用压缩指针,内存利用率下降),于是我们把服务器升级到8核16G,堆内存设到8G,剩下的内存留给操作系统做文件系统缓存,用来缓存热点索引数据。调完之后,Full GC从每分钟2次降到了每天1次,查询稳定性直接上了一个台阶。

现在这个搜索接口已经跑了半年多,平均响应47ms,峰值QPS 1500的时候也没出过问题。如果当时没做这些细化的优化,光靠加机器,至少得再加3台服务器,成本每个月多花大几千,而且响应时间也压不到50ms以内。

避坑指南:线上集群OOM与脑裂问题的排查与解决实录

今年3月我们ES集群出过一次大事故,早上10点突然所有节点都不可达,监控显示3个节点全部OOM(内存溢出),重启之后又出现脑裂,两个节点组成一个集群,另一个节点自己成主,数据写入冲突,差点把索引搞坏。我当时从早上10点排查到下午4点,把整个过程和解决思路理清楚,后来也成了我们团队的标准运维手册。

先说OOM的问题。当时我们集群是3个节点,版本是ES 8.14.0,每个节点16G内存,JVM堆设了8G。出问题前我们刚上线了一个新功能:把用户的搜索历史也存到ES里,用来做个性化推荐,搜索历史索引的文档量涨到了2000万,而且每个文档里存了用户最近30天的搜索关键词,单条文档平均有8KB。我查了节点的GC日志,发现ParNew收集器的年轻代每次回收都要1.2秒,老年代满了之后触发ConcurrentMarkSweep Full GC,每次要5秒以上,最后直接OOM,堆内存溢出日志里全是java.lang.OutOfMemoryError: Java heap space

一开始我以为是堆内存不够,想把堆调到12G,后来查了ES官方的最佳实践,8.x版本堆内存最好不要超过物理内存的50%,而且我们服务器是16G,12G堆的话留给文件系统缓存的只有4G,反而会影响索引查询性能。后来我查了索引的段(Segment)情况,用GET /_cat/segments?v命令看,发现搜索历史索引有120多个段,很多都是小于1MB的小段,ES合并段的线程一直在后台跑,占用了大量堆内存。还有我们当时开了_source字段的压缩,但用的是默认的LZ4压缩,压缩率不够,后来换成best_compression,单条文档大小直接降了30%。

解决OOM我做了三个操作:一是把搜索历史索引的分片数从默认的5个改成3个,因为2000万数据3个分片足够,每个分片的数据量在700万左右,不大不小;二是手动触发段合并,POST /search_history/_forcemerge?max_num_segments=1,把段数量降到1,合并完之后段数量从120降到3,堆内存占用直接降了40%;三是把搜索历史索引的_source压缩改成best_compression,同时关闭了不需要的字段索引,比如search_keyword字段原来既做分词又存keyword,后来只保留分词,用来搜索就够了,不需要精确匹配。

下面是当时我用来排查段情况的命令,还有强制合并的代码,都是实际跑过的:

# 查看所有索引的段信息,包括段大小、文档数、是否合并 curl -X GET "http://es-node1:9200/_cat/segments?v&h=index,shard,segment,size,size.memory,docs,committed,searchable" # 查看指定索引的分片状态,看有没有未分配的副本 curl -X GET "http://es-node1:9200/_cat/shards/search_history?v" # 手动强制合并索引段,max_num_segments=1表示合并到1个段,注意这个操作会占用大量IO,不要在大流量时跑 curl -X POST "http://es-node1:9200/search_history/_forcemerge?max_num_segments=1&flush=true"

这里要注意,_forcemerge操作会阻塞索引的写入吗?我一开始也担心,后来查了文档,8.14版本的_forcemerge是异步的,不会阻塞写入,但会占用大量磁盘IO,所以我当时是凌晨2点跑的,合并完之后索引大小从18GB降到了12GB,查询速度也快了20%。

然后是脑裂的问题。OOM重启之后,我发现集群状态是yellow,用GET /_cat/health?v看,有两个节点是master角色,另一个节点自己成了主,这就是典型的脑裂。后来查了ES的日志,发现原来我们3个节点的discovery.seed_hosts配置只写了两个节点的IP,第三个节点没加进去,而且minimum_master_nodes参数没设,默认是1,也就是说只要有一个节点存活就能选主。当时OOM的时候三个节点同时重启,有两个节点先起来,互相发现之后选了主,第三个节点后起来,没被前两个节点发现,自己就成了主,导致两个集群同时存在。

解决脑裂我改了两个配置,都是ES 8.14版本的要求:一是discovery.seed_hosts必须包含所有候选主节点的IP,我们原来只配了["192.168.1.10", "192.168.1.11"],后来加上了192.168.1.12,三个节点都互相能发现;二是cluster.initial_master_nodes在初始化集群的时候要设置,但我们是已经运行的集群,所以不用改这个,不过8.x版本已经废弃了minimum_master_nodes,改用discovery.zen.minimum_master_nodes的替代方案:cluster.fault_detection.leader_check.interval设成30s,还有discovery.zen.ping_timeout设成60s,避免网络波动导致误判节点下线。

下面是当时我们修改的elasticsearch.yml的核心配置,每个节点都改,只是node.name不一样:

cluster.name: community_es_cluster node.name: es-node-1 node.roles: [ master, data, ingest ] # 小集群不用分角色,三个节点都做主和数据节点 network.host: 192.168.1.10 http.port: 9200 transport.port: 9300 # 发现配置,包含所有候选主节点 discovery.seed_hosts: ["192.168.1.10:9300", "192.168.1.11:9300", "192.168.1.12:9300"] # 初始化集群的时候设置,已经运行的集群不用改 cluster.initial_master_nodes: ["es-node-1", "es-node-2", "es-node-3"] # 避免脑裂的配置 discovery.zen.ping_timeout: 60s cluster.fault_detection.leader_check.interval: 30s cluster.fault_detection.follower_check.interval: 30s # JVM堆内存设置,在jvm.options里,不是这里 # -Xms8g # -Xmx8g

改完配置之后,我把三个节点依次重启,先重启两个从节点,最后重启主节点,重启完之后用GET /_cat/master看,只有一个主节点,集群状态恢复green,数据也没有丢失。后来我特意做了一次故障演练,手动关掉一个主节点,另外两个节点在30秒内就选出了新主,写入只中断了2秒,完全符合我们的业务要求。

这次事故之后我总结了一个经验:ES集群的配置不要照搬网上的模板,尤其是discovery相关的配置,8.x版本和7.x差别很大,我们当时就是参考了7.x的配置,才导致脑裂。还有OOM的时候不要盲目加堆内存,先查段数量、索引设计、字段配置,很多时候是索引用得不对,不是内存不够。现在我们的集群已经稳定运行了4个月,没再出过OOM和脑裂的问题,每个月做一次段合并,监控堆内存使用率稳定在60%以下。

4. 不止全文检索:基于kNN向量搜索构建语义推荐系统

去年我接手了一个技术社区的内容推荐需求,当时产品同学提了一个看似简单的要求:用户搜“怎么优化MySQL慢查询”,除了返回标题里带这些关键词的文章,能不能把“数据库性能调优实战”这类语义相近的内容也推出来?当时我们用的还是 Elasticsearch 8.12 版本,单纯靠 match 查询做全文检索,只能匹配到字面关键词,很多优质内容因为措辞不同被漏掉,用户反馈搜索结果太“死板”。

那时候我翻 Elasticsearch 8.x 的更新日志,发现 8.0 之后已经内置了 kNN(k-Nearest Neighbors)向量检索能力,到 8.14.0(2024年5月发布)版本,向量检索的性能和稳定性已经优化得很成熟,还支持与传统的文本检索做混合查询。我算了一笔账:如果自己单独搭一个向量数据库再和 ES 做数据同步,光是数据一致性问题就要多花两周开发,而直接用 ES 的原生向量能力,只需要改索引结构和查询逻辑,成本要低得多。

我们先把社区的 12 万篇技术文章做了向量化处理。我选了 text-embedding-ada-002 模型,每篇文章取标题+正文前 500 字的拼接文本,生成 1536 维的向量。这里有个细节要注意:ES 8.14 对向量字段的类型定义做了优化,dense_vector 类型现在支持设置 index: true 直接开启向量索引,不需要额外配置插件。我当时给文章索引建的 mapping 是这样的:

PUT /tech_articles { "mappings": { "properties": { "article_id": { "type": "keyword" }, "title": { "type": "text", "analyzer": "ik_max_word" }, "content": { "type": "text", "analyzer": "ik_max_word" }, "tags": { "type": "keyword" }, "publish_time": { "type": "date" }, "title_vector": { "type": "dense_vector", "dims": 1536, "index": true, "similarity": "cosine" }, "content_vector": { "type": "dense_vector", "dims": 1536, "index": true, "similarity": "cosine" } } } }

这里我特意把标题和内容分别生成了向量,原因是用户搜索词通常较短,和标题向量的匹配度更高,而长文本内容的语义覆盖更全,分开存储可以后续给不同的权重。当时有个小问题:一开始我把向量维度写成了 768,和模型输出的维度对不上,写入数据的时候直接报了 mapper_parsing_exception,排查了半小时才发现是模型版本和维度参数没对齐,这个错误大家写的时候一定要核对清楚。

数据写入之后,我做了一次混合查询的测试。用户搜“MySQL慢查询优化”,我先把这个搜索词也用同一个模型生成 1536 维向量,然后同时做文本匹配和向量相似度查询,用 bool 查询把两者结合起来:

GET /tech_articles/_search { "query": { "bool": { "should": [ { "match": { "title": { "query": "MySQL慢查询优化", "boost": 2.0 } } }, { "match": { "content": { "query": "MySQL慢查询优化", "boost": 1.0 } } }, { "knn": { "field": "title_vector", "query_vector": [ 0.023, -0.012, 0.045, ... 省略剩余1533维 ], "k": 10, "boost": 1.5 } }, { "knn": { "field": "content_vector", "query_vector": [ 0.023, -0.012, 0.045, ... 省略剩余1533维 ], "k": 10, "boost": 1.0 } } ], "minimum_should_match": 1 } }, "size": 20 }

这里我给标题的文本匹配加了 2.0 的权重,标题向量检索加了 1.5 的权重,是因为我们统计过,用户点击标题匹配度高的文章概率比内容匹配的高 40%。上线之后我们做了 A/B 测试,实验组的搜索点击率从原来的 18% 涨到了 27%,平均搜索耗时从 220ms 涨到了 310ms,这个耗时增长在可接受范围内,毕竟多了向量计算的开销。

后来我们遇到了一次线上问题:某天晚上流量高峰,向量查询的耗时突然涨到了 1.2s,接口超时率到了 15%。我查了集群监控,发现当时向量查询的 QPS 到了 800,而我们的 dense_vector 字段没设置 index_options 里的 type 参数,默认用的是 hnsw 算法,但是 ef_construction 参数用的是默认值 100,索引构建的质量不够,查询的时候需要遍历更多的候选节点。我把 title_vectorindex_options 改成了 { "type": "hnsw", "ef_construction": 200, "m": 16 },重新刷了一遍数据,耗时又降回了 350ms 左右。这里要提醒大家,向量索引的参数不是默认值就够用,数据量超过 10 万条的时候,一定要根据自己的查询 QPS 和耗时要求调整 hnsw 的参数,不然流量一高就容易出问题。

现在 ES 8.14 之后还在优化向量检索的内存占用,我最近测试下来,1536 维的向量,12 万条数据占用的向量索引内存大概是 2.3G,比 8.12 版本同数据量少了 400M 左右,对于中小规模的语义推荐场景,完全不需要单独再维护一套向量数据库,直接用 ES 的原生能力就够。

5. 面试高频:深入倒排索引原理与分片副本设置策略

上个月我帮团队面试了 7 个后端开发,问到 Elasticsearch 的核心原理,有 5 个人只能说上来“倒排索引就是把词和文档对应起来”,再问分片副本怎么设置,要么说“默认就行”,要么说“分片越多越好”。我之前做电商商品搜索系统的时候,就因为一开始分片设置不合理,吃过大亏,这些不是背概念就能解决的问题,得结合实际场景说。

先讲倒排索引,我不用教科书里的定义,举个我们商品系统的真实例子。我们的商品索引里有 100 万条商品数据,每条数据有商品 ID、商品名、描述、类目这些字段。如果像 MySQL 那样做正向索引,就是“商品 ID 1001 对应商品名是‘2024新款纯棉短袖T恤’”,要搜“纯棉 T恤”,就得把 100 万条数据的商品名全部扫一遍,就算给商品名建了 B+ 树索引,也只能做前缀匹配,搜“T恤 纯棉”就匹配不到了。

倒排索引干的事刚好反过来:它先把所有商品的文本字段做分词,比如“2024新款纯棉短袖T恤”会被 ik 分词器拆成“2024”“新款”“纯棉”“短袖”“T恤”这些词项(Term),然后维护一张词项到文档的映射表。比如“纯棉”这个词项,后面会跟着 [1001, 1003, 1005…] 这些包含这个词的文档 ID,还同时记录了这个词在每个文档里出现的次数、出现的位置。我之前看 Lucene 的底层存储结构,这个映射表不是简单的列表,是按词项排序的有序结构,查找的时候用二分查找,速度比全表扫描快几个数量级。

我们当时遇到过一个查询慢的问题:用户搜“手机”,结果返回要 1.8s,按说“手机”是个高频词,倒排索引查词项很快啊。我查了查询日志,发现当时的查询是 match 查询,默认会计算 TF-IDF 评分,而“手机”在 100 万商品里出现了 30 万次,IDF 值很低,评分计算还要遍历所有包含“手机”的文档,光是评分计算就花了 1.2s。后来我把这个查询改成了 filter 查询,因为商品搜索场景里,类目筛选、关键词过滤不需要评分,用 filter 的话 ES 会跳过评分计算,还会把结果缓存起来,改完之后查询耗时直接降到了 120ms。这里要讲清楚:倒排索引快是快,但如果查询逻辑不合理,比如不需要评分的时候用了 match,还是会慢,不是有了倒排索引就万事大吉。

再讲分片和副本,这个是面试必问的,也是最容易设错的地方。分片(Shard)是 ES 把索引数据拆分成多个部分,每个分片是一个独立的 Lucene 索引,能分布在不同节点上,实现水平扩展。副本(Replica)是分片的拷贝,主要用来做高可用和负载均衡,主分片挂了副本能顶上去,查询的时候也能分担主分片的查询压力。

我之前做的那个电商商品系统,一开始我图省事,直接用了默认设置:每个索引 1 个主分片,1 个副本。结果上线之后,商品数据涨到 500 万条,单分片的大小到了 80G,查询耗时从 200ms 涨到了 800ms,大促的时候 QPS 到 1200 就扛不住了。后来我查了 ES 的官方建议,单个分片的大小最好控制在 10G-50G 之间,太大了 Lucene 段合并会很慢,查询的时候遍历的数据也多。我把主分片改成了 10 个,每个分片大概 8G 左右,副本还是 1 个,改完之后查询耗时降到了 280ms,QPS 能扛到 3000 以上。

这里有个我踩过的坑:当时我一开始把主分片设成了 20 个,想着以后数据涨了也不用改,结果反而更慢了。因为分片不是越多越好,每个分片都要占用内存和文件句柄,查询的时候如果有 20 个分片,协调节点要把 20 个分片的结果汇总,分片越多汇总开销越大。我们当时 20 个分片的时候,查询耗时要比 10 个分片高 30%,后来调整到 10 个才刚好。所以分片数量的设置原则是:先估算数据总量,按单个分片 10-50G 算主分片数,而且主分片的数量一旦设置就不能修改,除非重建索引,所以一开始就要留一定的冗余,比如预估一年数据量 300G,设 10 个主分片,每个分片 30G,留了足够的增长空间。

副本的设置也有讲究,我们之前有个日志索引,存的是应用的访问日志,每天 50G 数据,我一开始设了 2 个副本,结果集群磁盘用了 70%,经常报警。后来我想通了,日志数据对可用性要求没那么高,丢了几个小时的数据也没关系,就把副本改成了 0,磁盘占用直接降了 2/3。而商品搜索的索引,我设了 1 个副本,因为大促的时候查询量是平时的 5 倍,副本可以分担查询压力,而且万一主分片所在的节点宕机,副本能立刻切换成主分片,不会中断服务。

我还遇到过一次分片分配的问题:当时集群有个节点磁盘满了,ES 自动把这个节点上的分片迁移到了其他节点,结果迁移的时候大量占用网络带宽,导致正常查询都超时了。后来我设置了 cluster.routing.allocation.disk.watermark.low: 85%cluster.routing.allocation.disk.watermark.high: 90%,磁盘用到 85% 的时候就不再往这个节点分配分片,90% 的时候才触发迁移,还设置了迁移的并发数 cluster.routing.allocation.cluster_concurrent_rebalance: 2,避免迁移影响正常业务。这些设置不是通用的,得看你的集群规模和业务场景,比如日志集群可以设置更激进的迁移策略,而核心业务集群就要保守一点。

最后说下和 MySQL 全文检索的区别,我面试的时候经常拿这个对比。MySQL 的全文检索是基于倒排索引,但只支持英文分词(或者用 ngram 插件做中文分词),没有 ES 那么灵活的分词器,也不支持向量检索、聚合分析这些功能,而且 MySQL 全文检索的性能在数据量超过 100 万之后下降得很明显,我们之前试过在 MySQL 里存 200 万商品做全文检索,查询耗时到了 2s 以上,换成 ES 之后同样的查询只要 100ms 左右。所以如果是简单的、数据量小的全文检索需求,用 MySQL 没问题,但如果是千万级数据、需要复杂查询和扩展性的场景,ES 是更合适的选择。

站长实战手记

一次差点让我背锅的搜索重构

前年我接手了一个日活几十万的社区项目,当时搜索还是用 MySQL 的 LIKE '%keyword%'。那会儿数据量刚过百万,搜索接口平均响应已经到了 500ms,高峰期甚至能飙到 1.5 秒,DBA 天天在群里盯着慢查询日志骂人。

我拍板决定引入 Elasticsearch 8.x。部署完集群,把数据同步过去,满以为能交差了。结果上线第二天,凌晨两点我被报警电话炸醒——搜索服务挂了。

排查下来发现是集群 OOM。我当时照着网上教程,给 JVM 堆内存设了机器物理内存的一半,觉得这已经是“标准配置”了。但忽略了当时索引里有很多大字段(比如文章内容体),加上副本数设成了 1,导致内存吃紧。最后我不得不把那些不需要检索的字段放进 binary 类型,同时把堆内存回调到物理内存的 30% 左右,才把服务稳住。

后来随着数据量涨到千万级,我又遇到了脑裂问题。因为当时为了省钱,只有三个节点,且都在同一个机架上。一次交换机抖动,导致两个节点失联,集群就分裂了。那次之后我学乖了,哪怕是小集群,也尽量保证节点跨可用区部署,并且把 discovery.seed_hosts 配置得足够严谨。

我的真实看法

* 别为了用而用:如果你的数据量不到百万,或者搜索只是个简单的后台管理功能,千万别上 ES。MySQL 的索引配合 LIKE 完全够用,引入 ES 带来的运维成本和同步一致性问题,会让你头疼死。

* 慎用向量搜索:现在 kNN 很火,但我实测下来,如果数据量不大,传统的 BM25 算法其实已经非常精准了。向量搜索对硬件要求高,且调试 Embedding 模型非常耗时,别盲目跟风。

给新手的建议

别只看文档里的 CRUD,去亲手搭一个三节点的集群,然后试着把其中一个节点干掉。看看数据怎么重新分配,看看集群状态怎么变红再变绿。这种“破坏式”学习,比背概念管用得多。