
业务量上来之后,MySQL 的复杂查询慢日志开始刷屏,P99 延迟从几十毫秒慢慢爬到两三秒。这时候引入 Elasticsearch 不是赶技术潮流,而是架构演进到一定阶段必须跨过的坎。很多团队一开始把 ES 当数据库用,结果踩了一堆数据不一致、内存溢出、分片倾斜的坑。这篇内容不堆砌官方文档,只聊线上真实场景里怎么搭同步链路、怎么写 Mapping、怎么封查询以及怎么兜底。
一、 MySQL 撑不住的时候,才是引入 ES 的时机
关系型数据库在处理点查和简单事务时很稳,但一旦检索维度变多,B+Tree 的局限性就会暴露出来。比如模糊匹配 LIKE '%keyword%' 直接走全表扫描;当 WHERE 条件堆到五六个字段,联合索引命中率断崖式下跌,filesort 和临时表频繁出现;更致命的是,MySQL 本身没有基于 BM25 或 TF-IDF 的相关性打分能力,搜出来的结果顺序全靠运气;碰上 GROUP BY 带 COUNT/SUM 的聚合看板,千万级数据一跑,内存直接吃满。
什么时候该切 ES?看三个硬指标:
- 单表数据量摸到 500w 往上,复杂查询延迟稳定破 2s,且加索引效果已经边际递减。
- 业务侧开始提全文检索、同义词扩展、地理位置圈选、多标签交叉过滤、实时聚合下钻这类需求。
- 读写比例严重倾斜,搜索场景基本是 9:1 甚至更高。ES 的倒排索引架构天生就是吃这种读多写少的负载。
记住一个底线:ES 是检索和聚合引擎,不是事务型数据库。引入它的第一天就要接受最终一致性的设定,用延迟换吞吐,用补偿机制换强一致。别指望它既能扛高并发写入又能保 ACID。
二、 数据同步:别在双写和 Canal 之间纠结了
数据怎么从 MySQL 进 ES,是落地第一道坎。线上常见的路子就三种,选哪种全看团队运维能力和业务容忍度。
应用双写最省事,代码里同时调 MySQL 和 ES 接口。延迟最低,但业务耦合极重,一旦 ES 抖动或网络超时,主流程跟着挂,只适合早期验证期。
Logstash JDBC 轮询 靠定时 SELECT 捞增量数据。配置简单,但分钟级延迟根本扛不住实时搜索,通常只用来做历史数据迁移或者离线报表。
Canal + Kafka + 消费者才是生产环境的主流。Canal 伪装成 MySQL Slave 解析 Binlog,把变更事件推给 Kafka,业务方起 Spring Boot 消费。这套链路业务代码零侵入,支持断点续传,全量初始化和增量同步能拆成不同消费组。Kafka 扛住峰值写入,ES 按自己的节奏慢慢消化。
实际部署时,几个细节决定成败:
- Canal 务必开
GTID模式,主从切换时才不会丢偏移量。 - Kafka 消费者关掉自动提交,处理完再手动 ACK。别追求 Kafka 层面的 Exactly-Once,那是理论值,生产上靠的是手动 ACK + ES 按主键幂等覆盖,只要业务逻辑兜住,最终一致性就稳了。
- 全量同步和增量同步的消费者一定要拆开。全量跑的时候别跟增量混在一个 Group,不然位点提交逻辑会互相打架。
#mermaid-svg-t6wWMrgU4zYc4Whg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-t6wWMrgU4zYc4Whg .error-icon{fill:#552222;}#mermaid-svg-t6wWMrgU4zYc4Whg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-t6wWMrgU4zYc4Whg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-t6wWMrgU4zYc4Whg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-t6wWMrgU4zYc4Whg .marker.cross{stroke:#333333;}#mermaid-svg-t6wWMrgU4zYc4Whg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-t6wWMrgU4zYc4Whg p{margin:0;}#mermaid-svg-t6wWMrgU4zYc4Whg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster-label text{fill:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster-label span{color:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster-label span p{background-color:transparent;}#mermaid-svg-t6wWMrgU4zYc4Whg .label text,#mermaid-svg-t6wWMrgU4zYc4Whg span{fill:#333;color:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg .node rect,#mermaid-svg-t6wWMrgU4zYc4Whg .node circle,#mermaid-svg-t6wWMrgU4zYc4Whg .node ellipse,#mermaid-svg-t6wWMrgU4zYc4Whg .node polygon,#mermaid-svg-t6wWMrgU4zYc4Whg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-t6wWMrgU4zYc4Whg .rough-node .label text,#mermaid-svg-t6wWMrgU4zYc4Whg .node .label text,#mermaid-svg-t6wWMrgU4zYc4Whg .image-shape .label,#mermaid-svg-t6wWMrgU4zYc4Whg .icon-shape .label{text-anchor:middle;}#mermaid-svg-t6wWMrgU4zYc4Whg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-t6wWMrgU4zYc4Whg .rough-node .label,#mermaid-svg-t6wWMrgU4zYc4Whg .node .label,#mermaid-svg-t6wWMrgU4zYc4Whg .image-shape .label,#mermaid-svg-t6wWMrgU4zYc4Whg .icon-shape .label{text-align:center;}#mermaid-svg-t6wWMrgU4zYc4Whg .node.clickable{cursor:pointer;}#mermaid-svg-t6wWMrgU4zYc4Whg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-t6wWMrgU4zYc4Whg .arrowheadPath{fill:#333333;}#mermaid-svg-t6wWMrgU4zYc4Whg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-t6wWMrgU4zYc4Whg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-t6wWMrgU4zYc4Whg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t6wWMrgU4zYc4Whg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-t6wWMrgU4zYc4Whg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t6wWMrgU4zYc4Whg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster text{fill:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg .cluster span{color:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-t6wWMrgU4zYc4Whg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-t6wWMrgU4zYc4Whg rect.text{fill:none;stroke-width:0;}#mermaid-svg-t6wWMrgU4zYc4Whg .icon-shape,#mermaid-svg-t6wWMrgU4zYc4Whg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-t6wWMrgU4zYc4Whg .icon-shape p,#mermaid-svg-t6wWMrgU4zYc4Whg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-t6wWMrgU4zYc4Whg .icon-shape .label rect,#mermaid-svg-t6wWMrgU4zYc4Whg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-t6wWMrgU4zYc4Whg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-t6wWMrgU4zYc4Whg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-t6wWMrgU4zYc4Whg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Binlog
JSON Event
MySQL
Canal Server
Kafka Topic
消费者A: 历史全量灌数
消费者B: 实时增量同步
Spring Boot ES Service
三、 Mapping 写不对,后期改索引比重写业务还痛苦
ES 的性能大头都在索引设计上。Mapping 定死了,后期想改分词策略或者加字段,基本只能走 Reindex 重建,数据量大的时候非常折腾。
分词器别用默认的。 中文场景直接上 ik 插件。生产一般配两套策略:ik_max_word 做细粒度召回,ik_smart 做粗粒度展示。同义词和停用词通过 filter 链挂上去,synonyms.txt 扔进配置目录,通过 _reload_search_analyzers 接口热刷,不用停机重建索引。把"的、了、在"这类停用词过滤掉,倒排表体积能缩掉一大截,查询响应也快不少。
字段类型别乱填。 需要分词的用 text,精确匹配、排序、聚合的必须配 keyword。复杂字段直接上 multi_fields 双写。纯展示用的字段(比如富文本正文详情)把 index 设成 false,倒排索引直接省了。doc_values 默认开着支撑排序和聚合,如果某个字段既不排序也不聚合,显式关掉它能省出 30% 左右的堆内存。嵌套对象是个坑,object 类型底层是扁平化的,交叉条件会串数据;真要保层级关系用 nested,但注意每条嵌套记录都会独立生成倒排,写入放大很明显,别滥用。
分片和刷新间隔看数据量说话。 ES 7+ 以后,单分片容量压在 20GB~50GB 比较舒服。500GB 数据大概分 10~25 个主分片就行。别设成 1 个分片扛所有写入,也别开几十个分片让协调节点路由到崩溃。refresh_interval 默认 1s 对非实时业务太激进,调到 30s 能大幅降低 Segment 合并压力,写入吞吐肉眼可见地上涨。磁盘压缩用 "codec": "best_compression"(底层是 DEFLATE 算法),读多写少的场景能省 40% 空间,CPU 损耗完全在可接受范围内。
四、 Spring Data ES 查询封装:别再去拼 JSON 字符串了
Spring Data ES 4.x 之后 TransportClient 彻底退役,全线切到 ElasticsearchRestTemplate 和 ElasticsearchOperations。线上写查询一定要用强类型 Builder,拼接字符串不仅难维护,还容易漏掉转义。
java
@Service
@RequiredArgsConstructor
public class SearchFacade {
private final ElasticsearchOperations esOps;
public PageResult<Article> search(ArticleSearchReq req) {
BoolQueryBuilder bool = QueryBuilders.boolQuery();
// 1. 全文匹配走打分逻辑
if (StringUtils.isNotBlank(req.getKeyword())) {
bool.must(QueryBuilders.matchQuery("title", req.getKeyword())
.operator(Operator.AND));
}
// 2. 硬过滤条件走 filter 上下文,不进打分还能缓存
bool.filter(QueryBuilders.termsQuery("status", List.of(1, 2)));
if (req.getCategory() != null) {
bool.filter(QueryBuilders.termQuery("category", req.getCategory()));
}
if (req.getStartTime() != null) {
bool.filter(QueryBuilders.rangeQuery("publish_time").gte(req.getStartTime()));
}
NativeQuery query = NativeQuery.builder()
.withQuery(bool)
.withPageable(PageRequest.of(req.getPage(), req.getSize()))
.withHighlight(highlight -> highlight
.fields(hf -> hf.field("title").preTags("<em>").postTags("</em>")))
.build();
SearchHits<Article> hits = esOps.search(query, Article.class);
return convertToPageResult(hits);
}
}
几个实战习惯:
must和filter别混用。状态、类目、时间范围这类确定性条件一律塞filter,ES 会把它放进 Filter Cache,不参与相关性打分,速度极快。只有真正需要文本匹配的才走must。- 高亮别全局开,只给
text字段配。前端直接拿<em>标签渲染,比后端拼字符串干净得多。 - 聚合下钻时,
terms聚合默认只返回 Top 10。业务要全量分布的,把size拉大或者结合composite聚合做游标分页,否则数据一多直接截断,业务侧以为统计错了。
五、 深度分页与性能调优:线上血泪总结
from + size 超过 10000 的时候,ES 会直接拒接。别硬调 max_result_window 跟它对着干,换个姿势。
后台导数据、跑离线批处理,直接用 Scroll API。它相当于给当前结果集拍了个快照,服务端维持游标上下文。用完记得调 clear_scroll 释放资源。别拿这个给 C 端做列表分页,快照期间数据变更不可见,延迟还高。
线上 App 列表、无限下拉流,老老实实用 Search After。无状态游标,靠上一页最后一条文档的排序值定位下一页。注意排序字段必须带唯一性,不然翻页会跳数据或重复。通常组合 publish_time 倒序 + _id 倒序就够用了。
java
Sort sort = Sort.by("publish_time").descending().and(Sort.by("_id").descending());
Object[] searchAfter = new Object[]{lastTime, lastId};
NativeQuery nextQuery = NativeQuery.builder()
.withQuery(bool)
.withSorts(sort)
.withSearchAfter(searchAfter)
.withPageable(PageRequest.ofSize(req.getSize()))
.build();
性能调优就抓几个关键点:
- 路由预过滤 :多租户系统如果按
tenant_id隔离数据,写入和查询时显式传routing=tenant_id。查询直接打到对应分片,少扫 90% 的数据,延迟断崖式下降。 - 避开 Script 查询:Painless 脚本跑在堆内存里,极易触发长 GC。能提前算的字段,在同步链路里固化好再落盘。
- 盯死线程池 :重点看
thread_pool.search.active和queue长度。队列要是频繁打满,说明分片计算扛不住了。这时候加机器没用,优先查是不是有超大聚合或者没走 Filter 的全表 Term 查询,优化完再决定要不要扩 Data 节点。
六、 异步同步的一致性:靠兜底,不靠运气
Binlog 异步同步到 ES,窗口期丢事件、网络抖动导致失败是常态。线上能稳跑的系统,从来不赌运气,全是靠机制兜底。
对账补偿是最后一道防线。 每天低峰期起个定时任务,捞 MySQL 近 7 天有变更的主键,批量去 ES 查版本号或字段 Hash。比对不上的记录,重新扔进 Kafka 补发一次,全程打审计日志。别觉得麻烦,这套机制能兜住 99% 的同步异常。
死信队列(DLQ)加指数退避。 消费者解析 Binlog 或者调 ES 写接口失败,别直接吞掉。异常事件落盘到独立的 DLQ Topic 或者数据库重试表。重试间隔按 1s → 5s → 30s → 5min 拉长,失败 5 次以上直接转人工工单。消费者必须保证幂等,ES 原生按 _id 覆盖写入就是天然幂等,但并发场景下要按 MySQL 的 update_time 判断先后,避免旧版本数据覆盖新数据。
版本冲突别硬扛。 ES 8.x 早就废了 _version,改用 seq_no + primary_term 做乐观并发控制。同步时带上这两个值:
java
IndexRequest request = new IndexRequest("articles");
request.id(article.getId().toString())
.source(json)
.setIfSeqNo(currentDoc.seqNo())
.setIfPrimaryTerm(currentDoc.primaryTerm());
抛出 409 Conflict 说明有并发写入。策略很简单:以 MySQL 为准,忽略冲突,拉取最新 seq_no 重试。应用层捕获 VersionConflictEngineException,把新旧字段做 Merge 再写,比直接覆盖安全得多。
七、 生产避坑与演进路线
线上踩过的坑,整理成几条硬规则:
集群脑裂在 7+ 版本基本绝迹了,底层改用 Voting Config Exclusions。保证 discovery.seed_hosts 配齐所有 Master 节点 IP 就行,别留单点。
频繁 Full GC 和停顿,90% 是因为堆内存给太大。ES 的 JVM 堆别超 30GB,超过之后压缩指针失效,反而拖慢内存分配。聚合查询如果桶太多,强制用 composite aggregation 游标慢慢拉,别一次性全量返回。开启 G1GC,配合 -Xms 和 -Xmx 等值设置,停顿时间能压进 100ms 内。
慢查询日志刷屏,多半是 Filter 没用对,或者正则表达式滥用。把慢查询阈值 index.search.slowlog.threshold.query.warn 调到 500ms,定期用 _profile API 看各 Stage 耗时。复杂正则能不用就不用,提前预处理成 wildcard 或者独立倒排字段。
写入 OOM 通常是 Bulk 请求体塞太满。单次 Bulk 控制在 5MB~15MB 或者 1000~5000 条。非实时同步的业务,把 refresh_interval 调大,别跟 ES 的 Segment 合并较劲。
架构演进别一步到位。
起步阶段 1 Master + 2 Data 跑通全量增量同步,满足基础 MVP 搜索就行。数据量上来后,用 ILM(索引生命周期管理)把日志、历史订单这类冷数据自动踢到 Warm Node(便宜 HDD),Hot Node 只留近 30 天 SSD,硬件成本能压掉大半。跨机房高可用上 CCR(跨集群复制)做异地只读副本,或者直接用 ECK 走云原生编排。等大模型业务进来,预留 dense_vector 字段,接 knn 算法做语义召回,后面慢慢往"关键词 + 向量混合检索"的统一网关迁。
写在最后
ES 不是银弹,它吃性能也吃设计。Mapping 写错一个字段,后期重建索引能折腾几天;同步链路不兜底,数据对不齐业务方天天来催;查询习惯不好,集群扩容再多也扛不住长尾延迟。
落地这套引擎,核心不在调几个 API,而是把同步链路、查询规范、监控告警(Prometheus 抓 _nodes/stats 和慢日志)、对账机制串成一套闭环。团队初期最好把 Mapping 变更走 Code Review,搜索接口独立成中台服务,别跟业务代码绑死。把可观测性和补偿机制建好,ES 才能真正从"慢查询的替身"变成"业务增长的底座"。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
