资讯动态

实时搜索架构:从业务数据到ES检索的完整链路实践

发布时间:2026/10/22 3:32:14 来源:尧图企业网站定制
前一阵子接了个搜索相关的改造任务业务那边最早的需求就一句话商品列表页用户改完信息要马上能看到生效别老按查半天。听起来很简单但真正拆开以后才发现纠结的不是用哪个搜索引擎而是从一条数据在业务系统里发生变化到它真正能被检索到这条链路上的每一环都是延迟和故障的温床。最后落地的方案是经典的组合SpringCloud 做微服务底座Elasticsearch 承担全文索引和复杂检索Redis 撑起热点缓存和幂等控制Kafka 负责业务数据和索引之间的可靠传递。这套组合做实时搜索在中文分词、数据一致性、消费堆积、缓存击穿这些问题上的经验我觉得可以写出来分享一下。1. 先从实时这个词说起到底多少秒算实时1.1 业务方要的实时和搜索引擎工程师理解的实时不一样我接手这个需求的时候业务方在验收描述里写的是实时搜索。当时我以为要做到毫秒级后来冷静下来做了个延迟拆解事情完全不是那么回事。业务方说的实时一般是指数据在后台改了用户刷新一下列表就能看到几秒甚至 10 秒内他们都满意。而 Elasticsearch 自己给出的语义是近实时Near Real Time默认 refresh_interval 是一秒意思是一份文档写入索引以后最多等一秒左右就能被检索到但注意这个一秒只是 ES 段刷新层面的延迟远不是全链路的延迟。一条数据从业务库到搜索前端要经历业务库落库 - 业务事件发出 - Kafka 持久化 - 搜索服务消费 - 组装文档 - 调用 ES 写入 - ES 刷新段这么长一串。任何一个环节卡壳实时性都会退化成分钟甚至小时级。所以做设计方案之前我先把这条链路的每一段拿出去估时延逐个确认瓶颈在哪避免一上来就调整 ES 参数结果发现瓶颈根本不在 ES。1.2 一条链路拆开以后延迟大头其实在写进去而不是查出来下面这张表中的数字是我在模拟项目X里的实测估算值不同网络环境、机器配置下会有浮动但数量级可以参考环节典型耗时可控性业务系统落库5-20ms低属于业务系统内部业务发 Kafka 消息1-10ms中可做成批量异步发送Kafka 服务端持久化2-5ms中取决于副本数和 ack 配置搜索服务拉取并消费10-100ms高和消费线程数、批量大小强相关调用 ES Bulk 写入20-100ms高受批次大小、段刷新影响ES refresh 生效默认 1s 左右高可调整 refresh_interval从这张表能看出来正常情况下大头是 ES 默认的 refresh 一秒。如果你把 refresh_interval 调大比如改成 5 秒甚至 30 秒那么吞吐上去了但实时性会明显下降。我最后采用的平衡点是在搜索引擎场景保持 1 秒 refresh让它的近实时语义对业务透明而在一些统计列表场景会把 refresh 调到 10 秒换来更高的写入吞吐。想清楚 实时 的度量口径之后再谈架构才有可能不被实时这两个字绑架。2. 四个组件在链路里的角色以及我为什么敢这么分2.1 组件分工表谁负责接流量谁负责存数据谁负责搬数据先把结论放出来这张表是我在整体设计时画给自己看的分工边界也方便后面接手的同学理解为什么每个组件出现在这里组件在实时搜索里承担的职责常见误区SpringCloud服务注册发现、网关路由、配置管理、调用链治理误以为它是搜索的一部分其实它只负责让搜索服务稳定地暴露出来Kafka承接业务数据变更事件做削峰、解耦、故障隔离、消息重放误以为直接用业务服务同步调 ES 更快实际上同步调用会把写放大到上游Elasticsearch全文索引、分词、过滤、排序、聚合承载真实查询误以为可以替代数据库存全量数据会导致数据治理混乱Redis热点查询结果缓存、幂等键、分布式锁、降级兜底误以为可以缓存所有搜索结果做大病历式缓存会把内存打爆这个分工最关键的一点是搜索服务不要直接去业务库查数据。我在一开始的版本里图省事消费 Kafka 事件之后直接远程调业务服务的查询接口拿最新记录结果一次大促模拟压测时业务服务被搜索服务的批量回查请求直接打挂。后来改成事件里带足单据关键字段消费端优先使用事件里的快照数据必要时以 Redis 暂存兜底上游压力瞬间就下来了。2.2 一条数据从产生到被搜到的完整路径不画图了直接文字描述我平时的链路流转。假设现在要同步一个商品基础信息用户在某个后台编辑了商品标题和状态商品服务完成 MySQL 事务提交。商品服务在事务提交后发送一条事件到 Kafka 的 product-change-topic。搜索服务作为消费者监听这个 topic拿到事件后先做幂等判断。通过幂等校验后把商品文档组装成 ES 文档模型批量提交给 Elasticsearch。ES 写入完成后经过 refresh 周期文档进入可检索状态。用户在前端搜热销请求经过网关路由到搜索服务。搜索服务先查 Redis 缓存如果命中直接返回如果未命中查 ES写回缓存返回结果。这里有意思的是写入路径和检索路径是两条独立的链路它们只通过索引数据间接碰面。这也是为什么 Kafka 在这个架构里不只是可有可无的传话筒。它把写入动作从业务系统的主流程里剥离出去业务主链路只保证事件一定发出而不需要关心 ES 当前是否健康。ES 集群抖动不会反向拖垮商品服务这就是故障隔离的价值。2.3 为什么非要 Kafka 不可直接调用 ES 不行吗很多人一开始会问商品服务直接调用 ES 写文档不就没有中间环节了短时间内的确可以。但你要是做搜索架构需要考虑以下三个场景第一是削峰。运营后台做批量导入商品时一分钟可能产生几万条变更。如果这些变更全部同步调用 ESES 写线程池会瞬间被打满搜索接口也会跟着恶化。放到 Kafka 里商品服务只负责快速发消息ES 的消费速度由消费端自己控制天然就把斜坡填了。第二是重放。Kafka 的消息可以留存一段时间一旦搜索服务版本升级或者索引逻辑写 bug 导致数据错乱可以重新消费原始事件来修复数据而不需要找业务方再导一次数据。这个能力在索引重建场景里特别香。第三是事务边界。数据库事务已经提交但 ES 同步失败怎么办如果同步调用你得写一堆补偿逻辑。用 Kafka 之后消费端失败会触发重试重试多次仍失败的进死信补偿逻辑比较集中。当然Kafka 也引入了一些复杂度比如消费顺序问题、重复消费问题、消息体设计问题这些会在后面专门说。3. 写索引侧的实际落地从业务事件到 ES 的可靠管道3.1 索引映射怎么设计决定了搜索能不能准进入实际编码阶段之前第一件事不是写接口而是定索引映射。搜索质量的地基就在于 mapping后面想改成本很高的。我在模拟项目X里设计商品索引时核心字段大致如下{ mappings: { properties: { id: { type: long }, title: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, categoryName: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, status: { type: integer }, price: { type: double }, tags: { type: keyword }, updatedAt: { type: date } } } }这里关键的几个决策点标题和分类名用 text 中文分词器因为用户搜索的是自然语言状态、标签这类精确匹配字段用 keyword 或者 integer不允许分词否则精确过滤会出错价格用 double 或者 scaled_float 方便范围查询。还有一个后面踩坑踩出来的教训如果要按更新时间排序date 字段的格式必须统一成 millis 或者带时区的字符串我在测试时遇到过同一个索引里混了两种时间格式导致排序异常的问题。3.2 事务提交后发消息别在事务里发生产端这里的原则只有一条数据库事务提交之后再发 Kafka 消息。如果事务还没提交就发消息消费端去查库极有可能查到旧数据或者根本查不到从而产生脏索引。用 Spring 的事务同步器实现起来很干净Transactional public void saveProduct(ProductDO product) { productMapper.insert(product); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { ProductChangeEvent event new ProductChangeEvent(); event.setProductId(product.getId()); event.setEventId(UUID.randomUUID().toString()); kafkaTemplate.send(product-change-topic, String.valueOf(product.getId()), event); } }); }事件里面一定要带上 eventId这个后面幂等判断要用。消息 key 用商品 id这样同一个商品的变更会进同一个分区保证在分区内按顺序消费。说实话第一次做的时候我图省事没用 key结果同一个商品在重试场景里乱序消费索引里的价格一会儿新一会儿旧相当难查。3.3 消费端不是来一条写一条要用批量写一开始我总是每条消息处理完就调用一次 ES 的 index 接口吞吐量很一般QPS 一高 ES 线程池就开始堆积。后来改成攒批 Bulk 写效果立竿见影。做法也不复杂消费端把消息解析出来的文档放到内存队列里积累到一定条数或者每隔一定时间就触发一次 Bulk。我用 BulkProcessor 做这件事关键参数大概是每批攒到 500-1000 条或者攒到 5MB 左右触发写入每隔 1 秒强制刷新一次缓冲区允许并发请求数为 2。注意Bulk 消费端处理异常时要看 ES 响应里的逐条失败信息不要一整个批量全部重投。逐条失败可以记录下来进补偿表整体成功的话就提交 Kafka 的 offset。3.4 幂等消费Kafka 的 at-least-once 语义怎么破Kafka 默认是至少一次语义重复消费是常态。所以在写索引这里我把幂等判断做在了消费入口。最简单只用 Redis 做一个短时幂等即可KafkaListener(topics product-change-topic, groupId search-es-consumer) public void onMessage(ConsumerRecordString, String record) { ProductChangeEvent event JSON.parseObject(record.value(), ProductChangeEvent.class); String idempotentKey es:product:sync: event.getProductId() : event.getEventId(); Boolean first redisTemplate.opsForValue().setIfAbsent(idempotentKey, 1, Duration.ofMinutes(10)); if (first null || !first) { // 重复消息已经消费过直接返回 return; } try { // 查询最新状态组装文档Bulk 写入 ES } catch (Exception e) { // 处理失败要删除幂等键否则后续重试会被幂等挡掉 redisTemplate.delete(idempotentKey); throw e; } }这里面有个细节幂等键不能设计得过大也不能设永久有效。过期时间太短可能造成同一事件在过期后重复消费太长会让 Redis 里堆满无用的键。我用 10 分钟作为默认值覆盖 Kafka 重试窗口又不会过度膨胀。4. 搜索接口的落地查询 DSL、Redis 缓存和 SpringCloud 接入4.1 一个商品搜索接口DSL 该怎么组织检索侧的设计重点不在接口签名而在查询 DSL 怎么组织。一个典型商品搜索接口同时包含关键词匹配、状态过滤、价格区间、分页排序我一般会写成这种结构{ query: { bool: { must: [ { match: { title: 热销 } } ], filter: [ { term: { status: 1 } }, { range: { price: { gte: 10, lte: 100 } } } ] } }, from: 0, size: 20, sort: [ { updatedAt: { order: desc } } ] }这里有几个很实用的选择逻辑must里放 match影响相关性评分满足关键词全文检索的诉求。filter里放 term/range因为过滤条件不需要评分filter 子句能走 ES 的缓存性能更好。排序默认用业务字段不要把 ES 默认的_score排在第一位否则结果顺序不稳定用户会感觉老是在动。高亮可以直接在请求里加highlight片段返回里的highlight字段能直接拿来做红字显示这比自己在 Java 里截取关键词靠谱得多。分页也是一个重要的坑。用户翻到第 100 页的时候fromsize 会触发深分页ES 需要先取出 1000 条再丢弃前面 980 条CPU 白白浪费。我的做法是普通用户限制最大翻页深度比如最多 100 页如果要支持滑动加载改用search_after拿上一页最后一条的排序值作为下一页的偏移条件。4.2 Redis 缓存什么key 怎么设计击穿了怎么办很多人提到搜索缓存第一反应是把搜索结果整个塞进 Redis。这样做的隐患是缓存爆炸因为查询条件排列组合太多。我的原则是只缓存两类数据热词相关的列表结果和排序耗时的统计结果。key 的设计我会这么定search:product:query:{md5(关键词过滤条件分页排序)}:{version}value 直接存序列化后的 JSONTTL 设置 120 秒到 300 秒之间并故意加上随机抖动避免同一批热词同时过期造成缓存雪崩。另外我会对空结果也做缓存查不到的请求同样占资源空结果缓存 30 秒就能防住大量恶意空查询。缓存击穿这个问题我多说一句热点词过期瞬间如果涌进大量请求全部打到 ES 上ES 必挂。我用的是互斥锁兜底缓存未命中时先尝试获取 Redis 分布式锁抢到锁的线程去查 ES 并回填缓存抢不到的线程短暂等待后重读缓存。这样能确保同一个热点词只有一个请求真正打到 ES。4.3 搜索服务在 SpringCloud 里的接入姿态搜索服务本身做成一个无状态微服务注册到服务注册中心前端请求先经过网关再路由到搜索服务。好处是搜索服务可以水平扩多个实例网关层做负载均衡。Kafka 消费组也一样多个实例共同消费同一个 groupId分区会被自动分散到不同实例上。这里有一个容易忽略的点消费组里的实例数量和 Kafka 分区数是绑定的。如果分区只有 3 个而搜索服务开了 10 个实例那么只有 3 个实例在消费另外 7 个实例干瞪眼会造成一种我扩容了但没完全扩容的错觉。我在实际压测前就把模拟项目X的商品变更 topic 分区数规划成了 12后面扩容时才有弹性。调用 ES 时也要考虑降级。ES 抖动的时候搜索接口不能直接 500。我在网关和服务层面都配置了限流一旦 ES 错误率升高搜索服务会返回一条由 Redis 缓存的过期结果并在响应头里标注数据可能不是最新业务前端看到后做个小小的提示总比页面一把空白强。5. 一致性、补偿和三个真实的故障现场5.1 缓存和 ES 之间不一致怎么处理搜索场景的数据模型通常来自多个业务服务Redis 缓存和 ES 索引之间的不一致几乎无法完全避免。我的处理策略不是追求强一致而是追求最终一致 业务可接受。具体做法写操作发生时业务服务发出变更事件搜索服务在写 ES 成功后同步删除或更新对应搜索缓存。缓存 TTL 作为兜底不管主动更新成不成功最多滞后几分钟就能自愈。ES 同步失败的消息进本地补偿表由定时任务每 30 秒扫描一次重新投递最多重试 5 次再不行就发告警人工介入口。这套组合下来整体上不会出现半个小时以上数据不一致的局面而业务侧能接受的最大滞后恰恰在分钟级以内所以落地效果还可以。5.2 故障现场一消费线程阻塞导致堆积几百万条消息有一次模拟活动运营批量导入了几万条商品修改事件像洪水一样灌进 Kafka。结果我复盘时发现消费端每个线程都在调上游业务系统的详情接口而那个接口又因为瞬时 QPS 太高在做限流消费线程集体阻塞等待上游响应消息越积越多最后积压到了百万条。这个场景给我的教训非常直接消费链路里不能有任何强依赖远程同步调用的环节。后来我在事件消息里直接带上了商品全量快照字段消费端默认用快照组装文档拿不到快照的历史消息才去查 Redis 临时库兜底几乎把远程依赖砍干净了。积压恢复也快了很多因为消费速率不再被上游接口拖后腿。5.3 故障现场二重复消费导致同一条数据在索引里出现两次另一个经典故障是重复消费。我在测试环境停掉某个消费实例手动重投了一批消息结果索引里同一商品 id 出现了两条文档而且旧文档的 title 覆盖了新文档的高亮内容。原因就是幂等键当时没有包含 eventId只用了商品 id而消费者重试两次发的其实是不同事件。修复方法其实上面讲到过幂等键要把 productId 和 eventId 一起拼接。同时 ES 侧写入时不要用简单 index 请求而是把它改造成 upsert 语义加上doc_as_upsert或者使用带版本的乐观锁让重复写入在索引层也兜一道防线。5.4 故障现场三索引明明存在但一个词都搜不到这个故障当时排查得最久。索引 mapping 里 title 字段用的是 text 加 IK 分词器但是导入的数据是直接从 MySQL 全量同步过来的字段内容看起来都正常。偏偏线上用户搜热销能搜到搜保温杯就是空结果。后来用_analyze一看发现字段在索引阶段被 IK 分成了一堆词但另一部分历史数据在某次全量导入时实际写的不是字符串而是被当作 keyword 写入的根本没有分词。排查这一类问题的思路我后来固定成了四步第一步用_analyze看内容能不能拆出目标词第二步用_validate/query?explain校验查询 DSL 是不是把 match 写成了 term第三步看 mapping 里目标字段的实际类型确认是 text 还是 keyword第四步直接 GET 单条文档看存储的原值。这个现象本质是写入侧分词和查询侧分词不一致导致的也是搜索工程师最常踩的暗坑。6. 压测与参数调优哪些参数是我实测以后真正留了记录的6.1 先明确压测目标不要为了测而测压测不能只拿 QPS 一个数说话。我给自己定的观测口径是在 2000 QPS 下搜索接口平均延迟低于 300msTP99 低于 800msES 写入吞吐稳定在每秒 5000 文档以内Kafka 消费端不能出现无限增长的消费堆积。同时监控 ES 集群的堆内存使用率和 CPU任何一项超过 70% 就要暂停加压力否则测试结果会失真。压测工具我用的是常见的 Java/HTTP 压测工具并发线程从 50 慢慢涨到 500每档跑 15 分钟观察 Redis 命中率、ES 查询线程池、Kafka 消费 lag 三个核心指标。6.2 实测下来真正值得调的参数我把模拟项目X里最终保留的参数列成了一张表直接说结论参数最终取值调优理由ES refresh_interval商品索引1s保证实时搜索语义牺牲一点批量写吞吐ES refresh_interval日志统计索引30s日志搜索对近实时要求低换吞吐ES 副本数1测试环境保持 1 副本足够减少写放大Bulk 批量条数800 条超过 1000 条后单批次失败回退成本高Bulk 并发请求数2并发太高会压爆 ES 写线程池Kafka 消费线程数6topic 分区 12线程数不超过分区数Kafka 单次拉取最大条数500条数过大会导致消费处理时间过长Redis 缓存 TTL120-300s 随机固定 TTL 会造成热点缓存同步失效空结果缓存 TTL30s防止穿透又不至于让新鲜数据一直看不到这里有一个很多人忽略的点refresh_interval 不是越小越好。如果你把商品索引的 refresh 改成 200msES 会频繁触发段刷新磁盘 IO 飙升写入吞吐大降而用户的体感其实感知不到 800ms 的差异。默认 1 秒已经是搜索场景里一个很成熟的折中。6.3 最后整理一下踩坑后的固定配置流程我现在的习惯是启动任何实时搜索项目之前先写好一份配置基线然后压测再根据压测结果微调而不是上线后再救火。这份基线里必须包含索引 mapping 的版本号、refresh_interval、Kafka topic 分区数、消费组实例上限、Redis 内存上限和逐出策略、缓存 key 规则、补偿表扫描间隔。配置版本化也重要。索引 mapping 一旦上线生产环境改动是相当痛苦的需要重新索引。所以我不会直接在线上改 mapping而是新版本索引建好写入全部切到新索引然后通过对比查询迁移流量最后删除旧索引。配合 Kafka 消息的重放能力整个过程不需要业务方配合追加数据这是 Kafka 在这个架构里的又一个隐藏红利。最后再说一点我个人的实际体会。实时搜索这套架构ES 配置和 SpringCloud 调用其实都不难真正决定成败的是你愿不愿意把数据同步链路当成一个一等公民来对待。生产端事务提交后的消息发送、消费端的幂等与批量、失败时的补偿和告警、上线前的压测参数基线这些脏活累活统统不能跳过。我自己的经验是把 Kafka 消费端的稳定性做好把 ES 索引的生命周期管好把 Redis 缓存的 key 规则和过期时间设计好这套组合就真的能在一两年里做到足够省心。以后再做类似系统我大概会把这些沉淀直接挪过去复用但每一步还是要先压测验证一遍毕竟每一套业务的数据特点不一样。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑