☰
Elasticsearch+Redis+Kafka+SpringCloud构建实时搜索系统实战
2026/10/21 14:11:02 网站建设 项目流程

直接构建一个可落地的实时搜索系统,这套组合在电商、内容社区、企业应用中都很常见。先说清楚这套东西解决什么问题:业务数据不断写入MySQL,但用户的搜索请求要毫秒级响应,直接查数据库在数据量大时撑不住,而Elasticsearch(以下简称ES)擅长全文检索和聚合,Redis负责扛住高并发读流量,Kafka则让数据从业务库到ES的流转变成异步、削峰、可重放的消息流,SpringCloud把整套服务拆成可独立部署的微服务。适合正在做搜索中台、想要把ES用得更稳的技术团队参考。

1. 整体架构设计与技术选型解析

1.1 为什么是这一套组合而不是单体搜索

先说一个我踩过的坑:早期做搜索功能,在业务服务里直接用ES客户端写入数据,搜索接口也直接查ES。看起来简单,但业务表一变,搜索服务就要跟着发版,ES的索引Mapping改动很可能影响线上查询,而且大量同步写入和查询混合在一起,ES的写入线程池和查询线程池互相抢资源,大促期间搜索毛刺特别明显。

这套组合的本质是把“数据生产”和“数据消费”彻底拆开。业务服务只负责把变更事件丢给Kafka,搜索同步服务作为独立消费者写ES,搜索服务则只查询ES和Redis。SpringCloud在这里承担服务注册发现、配置中心、网关路由这些微服务治理能力,让三个搜索相关服务(数据同步、搜索查询、热词统计)能够独立伸缩。比如大促时搜索流量翻倍,我只把搜索查询服务扩容到10个实例,数据同步服务维持2个实例就够,这是单体架构做不到的。

技术选型的核心逻辑是:Kafka解决“数据怎么稳定流过来”的问题,ES解决“数据怎么被高效查到”的问题,Redis解决“热点数据怎么扛住超高并发”的问题,SpringCloud解决“这些分布式组件怎么被有序管理”的问题。每一层都有自己的职责,互相不越界。

1.2 核心服务划分与职责边界

推荐至少拆成四个服务:

  • search-gateway:统一入口,做鉴权、限流、请求转发,基于SpringCloud Gateway实现,路由到下游搜索服务。
  • search-sync-consumer:消费Kafka中的业务变更消息,解析、清洗、写入ES索引。这是数据管道的关键环节,消费性能直接决定数据从MySQL变更到ES可见的延迟。
  • search-query-service:处理搜索请求,先查Redis缓存,未命中再查ES,并异步回填缓存。同时负责热词统计、搜索建议的下发。
  • search-admin-service:索引管理后台接口,创建索引、调整Mapping、手动触发全量同步。这个服务在生产中非常重要,ES的索引结构一定不能靠手工curl去改,要留一个可重复执行的入口。

服务之间通过OpenFeign调用,或者直接通过Kafka事件驱动,不搞强依赖的同步RPC链。比如搜索热词统计,查询服务请求进来后,在Redis里做ZINCRBY,再定时把热词榜单快照写入ES的一个聚合索引,供运营后台查看。如果做成同步RPC调用运营服务,链路太长且没必要。

2. 基础设施搭建与核心配置

2.1 Elasticsearch集群部署的关键参数

ES版本建议选择7.x以上的稳定版本,我用的是7.17系列,注意不要用8.0刚出的版本上生产,等几个patch再上不迟。集群至少三节点,Master专用节点和Data节点的角色要分开:

# elasticsearch.yml 关键配置 cluster.name: search-cluster node.name: es-master-1 node.roles: [master] network.host: 0.0.0.0 discovery.seed_hosts: ["es-master-2", "es-master-3"] cluster.initial_master_nodes: ["es-master-1", "es-master-2", "es-master-3"]

堆内存设置是重点,JVM堆大小设为物理内存的一半,但不超过31GB。比如机器是64GB内存,ES堆设32GB会触发压缩指针失效,导致内存浪费,实测设30GB是安全阈值。很多团队在这里翻过车,默认的1GB堆内存跑点数据就OOM,要修改jvm.options:

# jvm.options -Xms30g -Xmx30g

Linux系统还要留意mmap count的限制,ES启动时如果报错“max virtual memory areas vm.max_map_count [65530] is too low”,需要执行:

sysctl -w vm.max_map_count=262144

这个不调,es节点启动后分片分配会不稳定。

索引分片数怎么算?一个经典经验公式是:分片数 = 节点数 × 每节点分片数(1-3个),并且总分片数不超过数据总量/单分片最大容量(建议单分片控制在30GB以内)。比如预计500GB数据,单分片按30GB算,约17个分片,三个数据节点分布下来每节点5-6个分片,是比较健康的分配。

2.2 Redis缓存层的高可用与数据结构设计

Redis在这个系统里不是简单的KV缓存,它承担三层职责:搜索结果页缓存、热词ZSet排行、布隆过滤器防穿透。部署形态上建议主从加哨兵,或者直接用云厂商的Redis集群版,别自己维护Cluster模式除非团队有专门运维。

搜索接口的缓存设计要区分两类:

  • 热词搜索结果:对“手机”、“连衣裙”这类高频词,用String结构缓存完整JSON,key设计为search:result:{keyword}:{page}:{size},过期时间设置在5-10分钟即可,被穿透打爆的代价远大于短暂数据不新鲜。
  • 搜索建议词:用ZSet结构,member是建议词,score是权重分,每次搜索命中后对关键词执行ZINCRBY search:suggestions:hot 1 {keyword}。下拉建议直接读取前10个member。

注意:Redis的过期时间要有随机抖动,避免大量key在同一秒过期造成的缓存雪崩。比如基础过期时间5分钟,附加0~60秒随机值。

2.3 Kafka Topic设计与分区参数选择

Topic设计成按业务域隔离:item-change(商品/文档变更)、search-click-log(用户点击日志)、search-sync-trigger(全量同步触发信号)。分区的数量不能拍脑袋,计算方式是:目标峰值吞吐量除以单分区可支撑的吞吐量。假设业务峰值每秒需要同步5000条变更消息,Kafka单分区写入吞吐实测约5MB/s(以每条消息1KB计算约5000条/s),那分区数设8个就能覆盖,留一倍冗余设16个更稳妥。

生产者端建议开启幂等:

spring.kafka.producer.properties.enable.idempotence: true spring.kafka.producer.acks: all spring.kafka.producer.retries: 5 spring.kafka.producer.max.in.flight.requests.per.connection: 5

关键点是enable.idempotence=true之后,Kafka自动保证同分区消息不重复,配合acks=all确保主副本都写入成功。很多开发者看到重复消息第一反应是改消费者的幂等逻辑,其实生产者端开启幂等能滤掉大部分因网络重试导致的重复。

消费者端这里有个必须注意的坑:同步ES的消费者如果是手动提交偏移量,一定要在写ES成功之后再提交。我见过一版代码先提交offset再去写ES,结果ES写入失败消息直接丢失,数据对不齐排查了大半天。

服务端还有两个参数值得调,一个log.retention.hours保留最近24小时消息即可,另一个log.segment.bytes调成1GB,减少分区的segment文件数量,对冷数据读取更友好。

3. 实时数据管道实现:从业务库到ES

3.1 双写策略选型:应用双写还是Binlog监听

数据进Kafka的方式,业界主流有三种:应用层代码双写、Binlog监听(如Canal)、定时任务扫描。我的建议是:如果业务代码你能做主,优先应用层双写。在业务事务内,业务数据操作成功后,发送一条变更事件到Kafka:

@Transactional public void updateItem(Item item) { itemMapper.updateById(item); kafkaTemplate.send("item-change", item.getId().toString(), JSON.toJSONString(new ItemChangeEvent(item.getId(), "UPDATE", item.getUpdateTime()))); }

很多人的顾虑是:Kafka发送失败怎么办?事务会回滚吗?答案是Kafka发送是网络IO,不应该放在MySQL事务中同步等待。正确做法是事务提交后,在TransactionSynchronizationManager.registerSynchronization的afterCommit回调里再发Kafka:

@Transactional public void updateItem(Item item) { itemMapper.updateById(item); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { kafkaTemplate.send("item-change", item.getId().toString(), eventJson); } }); }

这样事务没提交就不会发消息,事务回滚不会有脏数据流入搜索索引。如果对极端情况要求高,可以再加一张本地消息表,在事务内写入业务数据和消息状态表,由定时任务重试发送状态为pending的消息,这就是事务消息的最终一致性方案。个人经验:中小团队用afterCommit+Kafka重试机制够了,真出现小概率丢失,还有后面说的定时全量补偿兜底。

3.2 消费者同步服务与ES批量写入优化

搜索同步服务的核心逻辑是消费item-change消息,转换成ES文档写入索引。这里性能瓶颈在ES写入,Kafka消费本身很快,所以要把攒批和大批次写入做好:

@KafkaListener(topics = "item-change", containerFactory = "kafkaListenerContainerFactory") public void onMessage(List<ConsumerRecord<String, String>> records, Acknowledgment ack) { List<ItemDoc> docs = new ArrayList<>(records.size()); for (ConsumerRecord<String, String> record : records) { ItemChangeEvent event = JSON.parseObject(record.value(), ItemChangeEvent.class); // 根据业务ID组装文档对象 docs.add(buildDoc(event.getItemId())); } boolean success = esBulkWrite(docs); if (success) { ack.acknowledge(); // ES写入成功再提交offset } else { // 记录失败批次,发送到死信topic,或者放入失败重试队列 kafkaTemplate.send("item-change-dlq", JSON.toJSONString(docs)); } }

ES的bulk批量写入,批次大小不是越大越好,我实测8MB~15MB的bulk请求是性能和内存的最佳平衡点,再大会导致ES的HTTP请求超时、内存压力陡增。换算成条数,一条文档几百字节到1KB,那一批5000~10000条左右比较合适。消费者端的Batch监听设置:

@Bean public KafkaListenerContainerFactory<?> kafkaListenerContainerFactory() { ConcurrentKafkaListenerContainerFactory<String, String> factory = new ConcurrentKafkaListenerContainerFactory<>(); factory.setBatchListener(true); // 批量监听 factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE); factory.setConcurrency(4); // 消费线程数,不要超过分区数 return factory; }

注意setConcurrency不要大于Kafka分区数,不然多出来的线程空转没有意义。如果这个topic是16个分区,消费组开4个并发实例,每个实例起4个线程,正好全部覆盖。

ES索引写入前的Mapping和分词器设计,是搜索质量的分水岭。用中文搜索场景,IK分词器是标配,在索引创建时设置:

{ "settings": { "analysis": { "analyzer": { "ik_analyzer": { "type": "custom", "tokenizer": "ik_max_word" } } } }, "mappings": { "properties": { "itemName": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" }, "categoryId": { "type": "keyword" }, "price": { "type": "double" }, "status": { "type": "integer" }, "updateTime": { "type": "date", "format": "epoch_millis" } } } }

ik_max_word用于索引侧,尽可能多地切词,ik_smart用于查询侧,切词更精确,减少查询时的无效匹配。这个细节很多人不留意,全部用max_word会导致搜索“红色连衣裙”时,分词碎片过多产生大量不相关结果。

3.3 全量同步与增量同步的补偿机制

只靠增量同步不能覆盖极端情况:比如消费者逻辑升级后解析失败、Kafka消息被清理、消息乱序导致旧数据覆盖新数据。所以我坚持每个数据管道都要有全量补偿能力。

全量同步的设计我在admin服务里单独实现了一个接口:POST /admin/index/rebuild。这个接口分三步走:

  1. 从MySQL分页扫出所有业务ID列表,每批500个。
  2. 对每个ID组产生一条“全量同步批次”消息发到search-sync-triggertopic,消息体是ID集合。
  3. 消费者对这批ID重新查库、重新组装文档、bulk写ES(用指定版本号,比如用updateTime作为版本字段)。

注意全量同步时要小心ES的索引脑裂问题。如果默认采用重建索引方案,需要创建新索引item_v2,切换别名item指向新索引,全量写入完成后原子切换别名,而不是原地删了重建,否则全量同步期间搜索接口直接拿不到数据。

POST /_aliases { "actions": [ { "remove": { "index": "item_v1", "alias": "item" } }, { "add": { "index": "item_v2", "alias": "item" } } ] }

用别名机制还有个好处:零停服修改Mapping。ES是不支持改已经存在的字段类型的,但你可以新建一个索引、定义新的Mapping、全量灌数据、然后切别名,搜索服务无感知。

4. 搜索接口实现与三高并发场景应对

4.1 搜索接口的查询链路与缓存编排

搜索接口的完整链路是:请求进入search-query-service,先走缓存,对缓存未命中走ES,回填Redis,同时记录热词。核心实现:

public SearchResult search(String keyword, int page, int size) { // 第一步:组装缓存key String cacheKey = "search:result:" + keyword + ":" + page + ":" + size; // 第二步:查缓存 String cached = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cached)) { SearchHit hit = new SearchHitsService().incrKeywordScore(keyword); // 异步记录热词 asyncRecordHotKeyword(keyword); return JSON.parseObject(cached, SearchResult.class); } // 第三步:缓存未命中,查ES SearchResult result = esSearch(keyword, page, size); // 第四步:回填缓存,过期时间带随机抖动 int expireTime = 300 + new Random().nextInt(60); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(result), expireTime, TimeUnit.SECONDS); return result; }

ES侧查询条件一般包含四个部分:全文检索字段(itemName)、过滤条件(上下架状态、类目)、排序规则(综合排序、价格、销量)、分页。用bool查询把Must和Filter分开是性能关键:

BoolQueryBuilder boolQuery = QueryBuilders.boolQuery() // must 全场检索,影响相关度评分 .must(QueryBuilders.multiMatchQuery(keyword, "itemName", "subTitle", "brandName")) // filter 不参与评分,ES会自动缓存结果 .filter(QueryBuilders.termQuery("status", 1)) .filter(QueryBuilders.termQuery("categoryId", categoryId));

这里有个很实战的经验:过滤条件尽量用filter而不是must,因为filter查询结果会被ES的Query Cache缓存,而must查询因为参与打分,缓存的命中率低很多。同样是“过滤上架状态”,用filter比must快一个数量级不夸张。

深分页问题是搜索接口最常见的坑。ES的from + size默认最多查10000条,超过就报错,而用户翻到100页之后还给他继续加载没有意义。两种方案:

  • 搜索场景用search_after:去掉from,维护上一页最后一个文档的排序值,下一页用该值作为search_after起点。但这需要前端配合传递游标,而不是传统页码。
  • API场景用游标查询(Scroll),但Scroll是快照类型,不适合实时交互。

我实际的项目里,搜索的页码限制在50页以内,超出提示用户“搜索结果过多,请添加筛选条件”。同时用search_after处理“加载更多”场景,这样既不触碰ES深度分页上限,又能支持用户快速下钻到第几百条。

4.2 Redis三类缓存问题与布隆过滤器方案

缓存穿透、击穿、雪崩这三个问题,在搜索系统里逐一要应对:

  • 穿透:搜索的关键词完全不存在(比如用户搜了一个乱码),缓存和ES里都没有。恶意用户可以用大量不存在的关键词打爆ES。我采用布隆过滤器前置拦截,启动时把全量业务ID放进Redis的Bitmap或者内存布隆过滤器中,查询前先判断关键词是否存在(这里的布隆是针对关键词的Hash判断,对正常的搜索词不存在场景,则缓存空结果时设置一个短过期时间)。
  • 击穿:某个热词(“手机”)的缓存刚好过期,瞬间大量请求打到ES。解决方式业界做法是加锁回填,但我更推荐“逻辑过期”:缓存不设置物理过期时间,而是把过期时间放在value里,后台异步线程发现逻辑过期后主动重建缓存,请求进来永远能拿到旧缓存,不会打穿到ES。
  • 雪崩:大量热词在同一时间集中过期。解决办法前面提过,就是过期时间加随机抖动,让过期天然错开。

Redis这边还要注意内存控制。缓存一条搜索结果JSON可能2KB,100万条热词就是2GB,不能无限堆积。用maxmemory-policy allkeys-lru加容量上限,避免Redis内存吃满导致淘汰掉间隔时间长的正常缓存。

4.3 热词统计的实时窗口实现

运营后台需要“实时热门搜索词”榜单。这个功能用Redis的ZSet可以实现一个轻量方案,完全不必引入Flink之类的流计算框架:

每次用户搜索,执行ZINCRBY search:hot:keywords 1 "某个词"。后台拿热点时读取ZREVRANGE search:hot:keywords 0 19 WITHSCORES。

但要防止“榜单被瞬间刷爆”:同一用户短时间内多次搜索同一个词,应该去重。最简单可用的方案是维护一个用户维度的最近搜索Set,或者更粗粒度——在代码层同一个请求Session内对同一个词做合并,只计算一次。如果连Session维度都不做,大促时一个自动化脚本循环刷搜索,“手机”这个词就能被刷到榜一,榜单就失真了。

热词榜的数据粒度是当天的,所以每天凌晨需要把昨天的榜单Archive到ES索引,比如hot-search-daily索引,然后清理search:hot:keywords这个ZSet。这里用定时任务实现,注意清理时机要错过晚高峰,建议凌晨2点执行。

5. 常见问题与排查技巧实录

5.1 ES集群状态红色与分片未分配

ES集群状态变红的排查,在实时搜索系统上线初期非常常见。典型症状:GET _cluster/health返回status: red,某个索引的部分分片处于UNASSIGNED状态,搜索请求直接报错或者返回不全。

排查步骤:

# 看是哪个索引、哪个分片异常 GET /_cat/shards?v&h=index,shard,prirep,state,unassigned.reason # 查看未分配的原因 GET /_cluster/allocation/explain

最常见的三种原因和对应解法:

  • 磁盘水位线触发:节点磁盘使用率超过85%后,ES停止向该节点分配分片。解决是扩容磁盘或删除不需要的索引,然后执行POST /_cluster/reroute?retry_failed=true重新分配。
  • 分片数据损坏:unassigned.reason显示CORRUPTED,只能从副本恢复或重建索引,所以副本数务必设1以上。
  • 节点下线后分片未自动搬移:偶尔出现NODE_LEFT原因导致的分片未分配,一般等待ES自身重试即可,必要时调大unassigned.node_left.delayed_timeout,默认1分钟可能不够。

如果是高负载写入导致的红色,还要检查写队列是否有rejected任务:GET _nodes/stats/thread_pool看write线程池的rejected计数。rejected数量持续增长说明写入TPS已经逼近集群的写入池上限,要么加节点,要么降低bulk批次频率。

5.2 Kafka消费积压的定位思路

消费积压的常规排查方法是查看消费组Lag:

kafka-consumer-groups.sh --bootstrap-server localhost:9092 \ --describe --group search-sync-group

出现积压后不要急着加并发,先定位瓶颈在哪一环:

  • 看ES写入耗时:消费者日志里记录每次bulk的耗时,如果稳定在200ms以上,说明ES写入是瓶颈,加消费线程没有帮助,反而加剧ES压力。
  • 看消息处理逻辑:消费者里如果有同步RPC调用(比如查商品详情接口),这种IO等待会吊住消费线程,需要改成批量查MySQL组装数据,而不是逐条同步调用。
  • 看分区数与并发数匹配:消费者组总并发数=实例数×setConcurrency,如果小于分区数,一部分分区消息只能被单线程处理;如果大于分区数,多出来的线程在空转。

最常用的处理手段是“扩容消费者实例数到与分区数一致,同时检查ES写吞吐”。比如16个分区,把同步服务部署成4个实例,每个实例4个消费线程,总共16并发,同时把ES批量写的refresh_interval从默认1秒临时调成30秒(大流量同步期间,降低refresh频率能显著提升写入吞吐),同步完成后再调回1秒。

操作方式是在全量同步场景:

PUT /item_v2/_settings { "index.refresh_interval": "30s" }

这招在全量重建索引时非常管用,写入速度能提升两三倍。代价是这个时间段内新增的数据搜索到的延迟会增大,但反正是在重建索引,索引还没切别名,线上无感知。等全量写完了再改回1s,然后执行一次POST /item_v2/_refresh强制刷新。

5.3 数据不一致:Redis和ES返回结果对不上

这个问题的根源在于缓存更新链路和业务变更链路有延迟。比如用户修改了商品名字,Kafka消息流转到ES更新成功需要几秒,但Redis缓存还有旧的5分钟过期数据,搜索看到的还是旧名字。

解法我从两个维度处理:实时性要求高的字段,修改商品名这类信息时,在业务代码执行成功后就主动删除对应的搜索缓存key,而不是等它自然过期:

// 业务侧主动删除搜索缓存,通知搜索服务也生效 List<String> keys = redisTemplate.keys("search:result:" + itemNamePrefix + "*"); redisTemplate.delete(keys);

这个操作要注意批量删除的粒度,KEYS命令在生产环境是大忌,扫全库可能阻塞Redis单线程几百毫秒。改用SCAN游标扫描或者直接用SSCAN,也可以提前把热词key的索引存到Redis Set中,变更时直接遍历Set里的key来删除。

另一个层级的解决方案是:数据同步服务在ES写入成功后,给Redis发一个“版本号”标记。查询服务拿到缓存后,检查缓存中的版本号是否和ES中的全局版本号匹配,不匹配就强制回源ES并重建缓存。这个方案我用在商品名这类强一致字段上,成本可控,效果很好。

5.4 搜索质量差:分词和查询结果的经典调优

新手用ES做中文搜索最大的困惑是:搜“连衣裙”能搜到,搜“裙子长款”什么都搜不到。问题往往不在ES,而在查询语句把关键词的所有term都做must匹配了。中文分词后的term很多是碎片,全部must匹配会过于严格。

我的建议是把搜索条件拆成三层:

BoolQueryBuilder bool = QueryBuilders.boolQuery(); // 第一层:完整短语优先 bool.should(QueryBuilders.matchPhraseQuery("itemName", keyword).boost(5.0f)); // 第二层:全部词项匹配(默认) bool.should(QueryBuilders.multiMatchQuery(keyword, "itemName", "subTitle").boost(2.0f)); // 第三层:部分词项匹配 bool.should(QueryBuilders.multiMatchQuery(keyword, "itemName").operator(Operator.OR).minimumShouldMatch("70%"));

这句面前端视角的感受:能匹配到完整短语的结果排在最前面,匹配不上全部词,但能匹配部分词的结果也能出来,只是排序靠后。搜索的“召回率”和“精准率”要平衡,不能为了精准把召回砍死。

如果用了IK分词还不理想,就需要在IK自定义词典里维护专有词(比如某些产品型号、品牌代号)。IK热更新词典是另一个话题,但在系统上线前务必把自定义词典挂在ES的每个节点下,并确认IKAnalyzer.cfg.xml配置生效,不然你上线的词典根本不会加载。

6. 监控告警与事故复盘建议

6.1 三个服务必须看的核心指标

这套架构里,监控指标分成三层:

  • ES层:集群状态(green/yellow/red)、分片分配情况、写入QPS、查询QPS、查询P99延迟、bulk拒绝数、Segments数量、Merge耗时。Segments数量太多会导致查询变慢,需要保养任务POST /item/_forcemerge?max_num_segments=1定期执行。
  • Kafka层:消费组Lag、生产者发送失败率、broker的请求处理耗时、分区Leader分布是否均匀。Lag是核心告警项,我设定超过5000就发钉钉告警。
  • Redis层:命中率、内存使用率、慢查询数。Redis的慢查询日志,SLOWLOG GET 10要看,大量慢查询说明某个key的value过大(大key)或者使用了不合理的命令(比如KEYS)。命中率低于70%就要审视缓存设计是否有逻辑漏洞。

6.2 一个真实的故障复盘:ES写入瓶颈拖垮搜索

分享一个让我印象深刻的故障:大促备战流量压测时,搜索同步服务消费Kafka极其缓慢,积压最多达到几十万。当时以为Kafka消费线程不够,加了并发反而把ES打挂。事后分析线程池监控:ES的bulk线程池活跃度100%,rejected数持续增长,说明ES写入能力已经耗尽。而消费端还在拼命拉数据发bulk,导致ES拒绝写入,无限重试死循环。

真正的解法有两步:

  1. 在消费者端引入背压机制:每批bulk写入后,统计ES的bulk响应时间和rejected数量,如果响应时间超过阈值,消费线程主动sleep一段时间,降低发送节奏。就像水龙头进水太快水箱要溢出了,先把进水阀关小。
  2. 给ES写入预留缓冲:ES的bulk queue默认是200,通过thread_pool.bulk.queue_size可以调大,但不能无限调,调太大会导致请求排队时间过长、超时重试。
PUT /_cluster/settings { "persistent": { "thread_pool.bulk.queue_size": 800 } }

吃下这次教训后,我把消费者服务的健康检查里加了一个“ES写入延迟自检”,本地维护一个ES集群健康状态的缓存,当ES写入P99超过500ms或bulk rejected数量高时,暂停消费并告警,而不是傻乎乎地继续重试。这也解释了为什么前面反复强调kafka的offset提交要和ES写入结果绑定,一旦ES出问题,宁可积压也不要丢数据。

7. 部署与上线避坑清单

7.1 环境隔离与配置管理

SpringCloud的配置中心,我用Nacos管理所有服务配置。环境隔离是一个必踩的坑:开发环境、测试环境、生产环境必须分开的namespace,而不是靠配置文件的后缀去区分。有次同事把测试环境的Kafka地址配到生产,生产同步服务直接把测试库数据拉到线上索引,等发现的时候索引里混了几百条脏数据,清洗花了半天。

在Nacos里至少维护三个namespace:dev、test、prod,每个namespace里放独立的search-sync-service.yml、search-query-service.yml。索引别名的环境隔离也重要,item_dev、item_test、item(生产),按环境解析索引名,不要在生产代码里硬编码索引名。

7.2 ES索引的生命周期管理

索引不能上线后就不管了。日常维护至少三件事:

  1. 定期forcemerge:在业务低峰期(凌晨)对索引执行forcemerge,减少segment数量,提高查询性能。我的定时任务是每周一次,在凌晨3点执行。
POST /item/_forcemerge?max_num_segments=1
  1. 冷热数据分离:如果索引数据显示出明显的时效性(比如3个月以前的数据几乎无人搜索),可以按时间维度定义索引模板,每天生成一个日期后缀的索引,配合索引生命周期管理策略,超过30天的索引自动降为只读或迁移到冷节点。

  2. Mapping的兼容性演进:永远只在新增字段时更新Mapping,老字段类型不能改。真要改,就走前面的别名切换方案,重建索引、切别名。

7.3 安全加固的底线配置

内网部署也不代表可以裸奔。ES的9200端口,至少配置enable-security插件或者加IP白名单。Kafka的SASL认证建议开启,不然任何能访问这个网段的客户端都可以直接消费你的topic。Redis更要命,如果部署环境无法保障网络安全,至少设置requirepass,不然被攻破后Redis直接沦为挖矿工具,我见过不止一次这样的安全事故。

再有一条底线:所有密码不放在配置文件的明文里,用配置中心加解密插件或者环境变量注入。这是合规要求,也是职业道德底线,别图省事直接把密码写进Git仓库。

8. 搜索体验优化进阶方向

当基础的数据管道和搜索接口稳定之后,还可以做几件明显提升体验的事情。

搜索建议(下拉提示):输入端每敲一个字就触发联想,数据源来自每次搜索热词的ZSet。接口单独建立一个search-suggest-service,从Redis读取前10个热词,也可以结合ES的completion suggester,把商品标题的前缀查询放进去。两种方案可以结合:Redis管热词联想,ES管商品标题联想。

同义词扩展:用户搜“笔记本”,应该也能搜到“笔记本电脑”、“laptop”。通过ES的同义词filter把这个词族纳入查询,能明显提升召回。注意同义词词典的维护节奏,和业务词表一起定期更新,我建议用版本号管理同义词库文件,每次变更重建索引或者变更原词典(如果建有同义词token filter则必须全量reindex,这是一个代价要考虑清楚的点)。

个性化排序:到这一步需要引入用户行为数据。用户的点击日志通过search-click-logtopic进入Kafka,经过汇总统计后得到每个用户的偏好类目和品牌,在搜索排序时通过FunctionScoreQueryBuilder给该用户偏好类目加权。没有Flink也可以用Redis+定时任务对点击流做轻量聚合,先跑第一版验证效果。

这些延伸特性会持续反哺架构:搜索系统的复杂度在于它不仅是“查一下”,它是一套数据生产、加工、索引、查询、反馈的闭环。

我在多个项目里反复验证过这套SpringCloud+ES+Redis+Kafka的组合,数据的最终一致性、高并发查询、横向扩容能力都有完整的解法。希望这篇总结能帮你少走一些弯路,尤其是在数据管道和缓存细节上,这些都是常规文档里不会告诉你的实战经验。如果后面你在落地时遇到具体问题,欢迎带着你的监控数据来交流具体排查过程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询