接手一个新的电商平台技术架构时,我们遇到了一个非常典型的问题:商品数据量一上来,搜索就卡,搜出来的结果又跟用户想买的完全对不上,推荐位来回就那么几个爆款,转化率一直上不去。痛定思痛之后,我们用 SpringCloud 拆分了服务,用 Elasticsearch 承载了商品检索,用 Redis 处理了热点数据和缓存,用 Kafka 打通了数据同步和行为采集链路,最终把这套电商搜索推荐系统打磨到了一个能稳定支撑高并发、且搜索推荐质量明显可感知的水平。这篇文章不是架构理论堆砌,而是把我在真实业务场景里折腾这套系统的过程、踩过的坑、试出来的参数和解法完整记录下来。不管你是刚开始琢磨微服务改造,还是已经在用 ES、Redis、Kafka 但玩得不深,这套系统拆解都能给你一些可以直接抄作业的参考思路。1. 整体架构设计与核心思路拆解1.1 为什么是这四件套组合先回答一个很多人会纠结的问题:电商搜索推荐,为什么偏偏选 SpringCloud + ES + Redis + Kafka 这个组合,而不是别的东西?原因很简单,这四件套各自解决的痛点非常清晰,职责之间几乎不重叠。SpringCloud 负责的是微服务拆分和治理。商品、订单、库存、搜索、推荐这些模块如果堆在一个单体应用里,任何一个模块升级或出问题都会互相拖累。拆开之后,搜索服务、推荐服务可以独立横向扩容,支撑大促弹性的流量。更重要的是,SpringCloud 那套注册发现机制能让我们在服务扩容时不用改代码,新实例启动后自动注册,流量自动分流。Elasticsearch 解决的是“怎么搜得准、搜得快”的问题。MySQL 的 LIKE 查询在几百上千万条商品数据面前可以说是灾难性的,全表扫描不仅慢,而且不支持分词、同义词、相关性打分这些搜索引擎该有的能力。ES 天然就是个倒排索引引擎,分词、过滤、聚合、排序都内置好了,而且分片机制让它能够水平扩展,数据量大一点也不用慌。Redis 处理的是“高频访问的共享数据”。搜索推荐场景里,很多数据是读多写少的,比如分类导航、热门搜索词、用户实时浏览序列、推荐结果的预聚合缓存。这些如果每次都打到 ES 或者 MySQL,再好的机器也扛不住。Redis 最大的优势不只是快,而是它的数据结构足够灵活——字符串、哈希、列表、有序集合都能派上用场。Kafka 则是把整个系统的数据流盘活了。搜索推荐系统有一个绕不开的问题:ES 里的索引数据怎么从业务库里更新出来?用户的行为数据怎么流转到推荐引擎?答案就是用 Kafka 做异步消息管道,业务系统产生事件后直接投递,下游的索引同步服务、画像计算服务、推荐实时服务各自订阅自己关心的 topic。这样做的好处是削峰填谷,高峰期不会把下游系统打爆。1.2 服务拆分与数据流转链路设计整套系统在 SpringCloud 体系下拆成了这样几个核心服务:商品服务:商品基础信息的 CRUD,数据源是 MySQL搜索服务:对接 ES,提供商品检索接口推荐服务:对接 Redis 和 ES,提供个性化推荐接口用户行为服务:接收用户浏览、点击、加购、下单行为事件索引同步服务:消费 Kafka 中的商品变更消息,增量更新 ES链路的数据流转是这样的:商品服务在后台管理端发生变更后,把变更事件发给 Kafka 的product-changetopic;索引同步服务作为消费者拿到事件,去数据库查询最新的商品数据再写入 ES;同时搜索服务和推荐服务在处理用户请求时,会先把热点结果缓存到 Redis;用户在前端浏览商品产生的行为事件,则通过埋点接口进入 Kafka 的user-behaviortopic,推荐服务实时消费它来更新用户的兴趣模型。这套链路最核心的设计思想是解耦。商品服务和索引服务之间不直接调用,搜索服务的索引更新不依赖业务系统的可用性,推荐服务的用户画像更新不影响主流程的响应速度。一次大促期间某个服务挂了,最多是数据同步延迟,不会导致整个搜索推荐功能瘫痪。1.3 高转化的系统层面理解为什么这套系统的最终目标加上了“高转化”三个字?因为在电商场景里,搜索推荐系统的 KPI 不只是性能和可用性,更重要的是转化指标——用户搜了之后是不是真的下单买了。从系统架构的角度看,“高转化”意味着三件事:第一,搜索结果要能读懂用户意图。同样是搜“苹果”,有人想买手机,有人想买水果店里的苹果,还有人是想买苹果汁。这就需要 ES 的查询条件设计结合用户实时行为做上下文感知,而不是单纯看关键词。第二,排序模型要综合考虑多种因素。相关性和转化率并不是强强相关的关系,一个很相关的商品销量差、评价低,排在前面用户也不会买。我们在实际系统里用的是“相关性 × 热度因子 × 用户偏好因子 × 价格合理性”这样一个综合评分模型,后面会详细讲怎么落地。第三,推荐结果要跟上用户即时状态。上午刚搜过儿童自行车,下午打开 App 推荐的就是儿童安全座椅,这种实时性不是靠离线训练模型能做到的,必须靠 Kafka 实时消费行为事件、Redis 存储用户的短时兴趣加权状态来实现。2. 核心细节解析与实操要点2.1 商品索引 Mapping 设计的关键决策ES 的索引映射设计是整个搜索质量的根基,一开始没想清楚,后面改起来成本相当高。我强烈建议你在建索引之前,先把商品有哪些检索字段、排序字段、聚合字段列清楚。商品索引的 mapping 设计里有几个特别容易踩坑的地方:第一个坑是多个检索字段没有区分text和keyword。商品名称、品牌名、类目名这些字段需要分词检索,应该用text类型;而商品编码、SKUID、状态这些字段精确匹配,应该用keyword。如果全部用text,精确查询时就会发现明明条件都对,就是查不出来,因为text字段会被分词器拆成多个 token 再去倒排索引匹配。第二个坑是没设计好子字段。我们实践中比较顺手的方式是:商品名称title字段用ik_max_word做最细粒度分词建立索引,同时给它的子字段title.keyword保留完整原文用于精确排序。ik_max_word虽然索引体积更大、构建更慢,但召回更全,不会漏掉包含长尾词的商品。查询的时候用ik_smart做粗粒度分词,保证精确度。第三个坑是数值类型的精度问题。价格如果带小数,建议用scaled_float类型并设置scaling_factor为 100,比分体double省空间,查询性能也更好。如果你需要用价格范围过滤,这个字段必须有 doc values。2.2 中文分词与查询 DSL 的组合玩法中文分词是电商搜索里最有技术含量的环节之一。我们原本用标准分词器的时候,搜“连衣裙女夏季”会得到一堆匪夷所思的结果。后来接入了 IK 分词器,并且维护了一整套电商领域词典,收录了品牌名、爆款别名、材质词汇、风格词汇。这些自定义词必须放到 IK 的扩展词典里,而不是主词典——主词典升级时自定义词会被覆盖。决定了分词器之后,查询 DSL 的设计就成了搜索质量的主战场。我们用得最多的是bool查询嵌套模式:{ "query": { "bool": { "must": [ { "multi_match": { "query": "连衣裙女夏季", "fields": ["title^3", "category_name^2", "brand_name"], "type": "best_fields", "operator": "and" } } ], "filter": [ { "term": { "status": 1 } }, { "range": { "price": { "gte": 50, "lte": 1000 } } } ], "must_not": [ { "term": { "is_spam": true } } ] } } }这里有个值得讲清楚的设计:must里的operator用and还是or,直接决定搜索的精度。用and意味着分词后的每个词都必须匹配上,召回量小,但精度高;用or召回量高,但会出现很多只匹配了其中一个词的噪音。在实际电商场景里,我们默认用or扩大召回,然后在should子句中通过加权字段做排序,让同时命中多个关键字的商品排在前面。这个方案在召回和精度之间平衡得比较好。2.3 相关性打分与个性化加权实践ES 5.x 之后默认的打分算法是 BM25,它本质上是一个计算关键词和文档相关性的统计模型。默认参数k1=1.2、b=0.75在多数电商场景下表现一般,因为商品标题短、关键词分布集中,默认参数会把匹配在标题里的权重压得过低。我们反复调参后落在k1=1.8、b=0.3附近效果更符合直觉——更多的文章出现关键词时,相关性不会急剧衰减,毕竟电商用户搜“洗衣机”时,标题里出现三次和出现十次的商品,对这个用户来说不应该有多大差别。真正拉开转化差距的是业务层面的加权。同样的关键词“连衣裙”,销量十万的爆款和销量几十的长尾商品,默认打分可能只差一点,但用户大概率买爆款。所以在查询层我们叠加了function_score:{ "query": { "function_score": { "query": { "bool": { ... } }, "functions": [ { "filter": { "range": { "sales_count": { "gte": 1000 } } }, "weight": 2.0 }, { "filter": { "term": { "user_like_category": "女装" } }, "weight": 1.5 }, { "gauss": { "create_time": { "origin": "now", "scale": "30d" } }, "weight": 0.8 } ], "score_mode": "sum", "boost_mode": "multiply" } } }这个 DSL 里三层加权的逻辑是:热门商品加权、用户偏好的类目加权、新品时间衰减加权,三者加起来再乘相关性分。score_mode用sum避免三个函数值相乘后把结果拉到离谱的区间。2.4 Redis 缓存策略的组合应用ES 是能扛住高并发,但线上真实流量一大,每次都把完整请求打到 ES 也不是明智选择。Redis 在搜索推荐链路里要做的事,比单纯做缓存要复杂得多。第一层是导航分类和基础数据的缓存。商品类目树、品牌列表、热门搜索词这些数据几乎不变,直接用 Redis String 缓存 JSON,生命周期设为 24 小时,后台变更时主动淘汰对应 key。第二层是搜索结果缓存。同一个搜索词在短期内搜索结果是稳定的,比如“连衣裙”8 点搜和 9 点搜,商品列表基本一样。我们用搜索词 MD5 + 分页参数作为 key,缓存前 50 条结果,TTL 设为 5 分钟。大促期间配合Cache Aside模式,查询时先读 Redis,没有就查 ES 再回填,释放了不少 ES 的查询压力。第三层是用户实时行为状态。用 Redis 的 Hash 结构存储每个用户的类目点击次数,用 ZSet 存浏览历史时间线。这个在后面推荐实时召回时会具体展开。这里有个经验想特别分享:缓存一定要分粒度,不要一股脑把所有数据塞进同一个 key。大 key 在 Redis 集群模式下会导致单分片内存倾斜,读取性能也会劣化。我们把搜索结果按“热门词短时间缓存、长尾词不缓存”的策略处理,每个 key 的数据量控制在几十 KB 以内,性能稳定很多。3. 实操过程与核心环节实现3.1 Kafka 数据同步链路的落地细节整个 Kafka 链路的搭建过程中,topic 的设计和消息格式的约定是最需要提前想清楚的部分。我们最终设计了三组核心 topic:product-change:商品新增、更新、上下架消息user-behavior:用户点击、浏览、加购、下单行为消息recommend-log:推荐结果的曝光与点击日志product-change和user-behavior的分区数都设成了与下游消费者实例数对应的倍数关系,这样做的目的是保证同一个商品的变更事件和同一个用户的行为事件在消费时能保持顺序性。比如按商品 ID 做 key,同一个商品的消息永远进同一个分区,消费时就不会出现“先消费了更新后、又消费了更新前”这样的错乱。Kafka 的消息体我们用 JSON 格式,但加了一个event_type字段来区分事件类型。商品变更消息里只带商品 ID 和变更操作类型,不携带完整商品数据。因为如果携带完整数据,一旦商品服务改了字段结构,生产者消费者都要跟着改,耦合会越来越高。消费者拿到消息后,再去调商品服务的查询接口拉最新的数据。这样设计的好处是消息体积小、生产端可靠,坏处是如果商品服务此时不可用,消息只能等重试。// 这里补全代码示意{ "eventType": "PRODUCT_UP_SHELF", "productId": 123456, "timestamp": 1623456789000 }消费端的幂等处理也是必须做的。Kafka 是至少一次投递语义,重复消费是常态。我们在索引同步服务里维护了一张 Redis Set,存最近处理过的消息 ID,消费后把消息 ID 写入 Set 并设置过期时间。这样即使 Kafka 重复投递,也能通过去重过滤掉,不会把旧数据覆盖新数据。3.2 ES 索引与数据从零到上线的全过程索引建立之后的第一次全量数据灌入是很多人容易搞出问题的环节。几百万条商品数据一次性灌进 ES,如果直接用 bulk 批量插入,分片的分发会极不平衡,部分分片瞬间打满,部分分片闲置,索引构建慢到怀疑人生。我们的全量导入方案分三步走:第一步,关闭分片自动均衡。在创建索引时将cluster.routing.allocation.enable临时设为none,防止数据导入过程中分片在节点之间搬来搬去。第二步,创建索引时先设置零副本。number_of_replicas设为 0,主分片写入完成后,一次性把副本数调回去。这样写入时不需要复制数据,速度能提升一大截。第三步,用批量写入工具或自研程序以多线程方式按分片维度灌数据。每条 bulk 请求控制在 5MB 以内,超过这个体积写入反而变慢。完成全量同步之后,把副本数恢复为 1,重新开启分片均衡。最后用forcemerge做一次段合并,把索引中的小文件合并成大段,查询性能和稳定性都会有明显提升。3.3 搜索服务接口与查询链路实现搜索服务对外提供的接口不是简单把 ES 查询结果原样返回,而是包了一层比较重的聚合逻辑。一次搜索请求的处理链路是:先查 Redis 缓存,缓存命中直接返回。缓存未命中时拼装 ES 查询 DSL,执行查询拿到hits和aggregations,然后把商品 ID 列表传给商品服务——商品服务在 Redis 里缓存了商品的基础信息,直接从缓存拿,拿不到的再查 MySQL 补全——最后把完整商品信息与评分信息组装成响应结构。这里要注意一个细节:ES 查询出的_source里不要存完整的商品信息。只把商品 ID、标题、价格、销量、评分这几个排序展示必需的字段放进去,剩下的详细描述、图片地址这些大字段延迟到商品服务去取。ES 索引瘦身之后,内存占用下降很多,查询响应也会更快。3.4 推荐服务的实时行为与召回策略实现推荐系统这部分,我们走了“规则召回为主、实时加权为核心”的路线。没有一上来就上深度模型,因为冷启动阶段没有大量日志数据,模型根本练不出来,规则系统反而可以快速产生业务价值。推荐的第一个召回源是用户实时行为序列。用户在 Redis 里的浏览历史是一个 ZSet,成员是商品 ID,分数是浏览时间戳。当用户请求推荐接口时,取最近 24 小时浏览过的商品 ID 列表,把这些商品对应的类目聚合成一个用户偏好向量。偏好权重公式是:更新时间越近的浏览行为权重越高,点击和加购行为权重高于纯浏览。第二个召回源是相似商品的协同过滤。用户浏览了某件商品,这件商品需要被提前预计算好“看了又看”的相似商品列表。离线任务会每天跑一次,把商品按类目、标题分词、价格区间计算相似度,相似度高的商品 ID 列表写入 Redis。线上推荐时直接取列表。第三个召回源是热门商品兜底。用户没有实时行为数据时,用 Redis ZSet 存的全站近 7 天销量热榜做召回。这三个召回源的结果合并到同一个候选池之后,用前面 ES 排序类似的加权规则做融合排序。这里需要提醒的是,推荐系统的效果好不好,不能靠拍脑袋判断,一定要接入 AB 测试。我们当时上线推荐系统后,用三个流量桶分别测试了纯热门召回、行为召回、行为加相似召回三种策略,对比点击率和下单转化率,行为加相似召回在转化率上明显领先。3.5 A/B 测试与核心指标验证没有埋点的推荐系统等于盲人摸象。我们在推荐结果、搜索结果、商品详情页分别埋了曝光事件和点击事件,事件实时上报到 Kafka,然后通过 Flink 做实时统计,再把统计结果写回 Redis,运营后台就能实时看到指标。核心指标主要看三块:点击率 CTR = 点击人数 / 曝光人数下单转化率 CVR = 下单人数 / 点击人数搜索无结果率 = 搜索无结果请求数 / 总搜索请求数这三个指标能直接评估搜索推荐质量。我印象比较深刻的是,优化了 ES 查询 DSL 和排序加权后,无结果率从约 7% 降到了不到 1%,CTR 提升约 2 个百分点,CVR 是纯新增的系统能力,从无到有稳定在了一个可观的数值。数据不会骗人,指标一测就知道方向对不对。4. 常见问题与排查技巧实录4.1 ES 深分页引发的性能问题线上搜索服务上线后遇到的第一个大坑是“翻页翻到 100 页以后接口直接超时”。ES 的from + size方式在分页越深时性能越差,因为每个分片都要把前 N 条结果取出来,再在协调节点上做全局排序。翻到第 100 页时,单次查询的 CPU 消耗已经是前十页的上百倍。这个问题我们最终的解法是限制搜索的深度,只允许用户翻前 50 页,超过之后建议用更精确的关键词重新搜索。技术上对于需要深拉取的场景,切换成了search_after方案。search_after不依赖全局序号,而是通过上一页最后一条的排序值继续往后找,性能和稳定性都比from + size好很多。搜索链路里加这样的约束之后,最慢的请求响应时间也从 2 秒级降到了 500 毫秒以内。4.2 Redis 缓存击穿和雪崩的连锁反应某天晚高峰搜索接口突然大面积超时,排查之后发现是 Redis 缓存大面积失效引起的。当时运营准备大促,批量更新了热门促销商品的数据,为了方便清掉了相关缓存 key。结果缓存重建时正好赶上流量高峰,大量请求同时穿透到 ES,ES 扛不住,把搜索服务也拖垮了。这个案例特别典型。缓存重建的惊群效应在电商场景里杀伤力巨大。我们后来做了两个改进:一是在缓存失效后加分布式锁,只有拿到锁的请求可以查询 ES 并重建缓存,其他请求短暂等待后直接读取缓存;二是对热门商品数据做缓存永不过期策略,后台数据变更时主动更新缓存,而不是依靠 TTL 被动淘汰。这两个改动之后,Redis 层面的问题基本绝迹。4.3 Kafka 消费积压导致的数据延迟有次线上出现了一个诡异的问题:商品后台明明已经下架了,但搜索里还是能搜到。查了一圈,定位到product-changetopic 里的下架消息积压了。原因是索引同步服务的一个消费者实例因为内存溢出重启了,重启之后消费线程恢复得慢,积压了几十万条消息。商品下架消息排在最前面,但迟迟没有被消费,下游 ES 自然没有更新。排查的经验是:必须做好 Kafka 消费端的监控报警,特别是消费 offset 和生产者 offset 的差值。差值是持续上涨,那必定是消费者出问题了。我们后来给索引同步服务增加了消费线程数的动态调整机制,积压严重时可以手动拉升消费速度,同时优化了消费者的批量处理逻辑,从一次处理一条改成了poll一批后批量更新 ES,消费吞吐翻了数倍。4.4 搜索召回乱与推荐结果重复的排查思路搜索召回乱的典型案例是搜“苹果”出现一堆苹果水果和苹果手机夹杂在一起,用户一脸懵。这个问题要从两个方向修:一是必须在查询 DSL 里加入类目偏好作为 boost 因子,用户经常在数码类目下点击,搜“苹果”时数码类目商品会自动加权;二是在前端和推荐层增加类目去重或整理策略,同一叶子类目下的商品最多展示两个,避免结果被单一类目霸屏。推荐结果重复的问题则通常出在召回源之间的交集没处理好。三个召回源各产出一批商品 ID,合并时直接拼接就会有重复。我们的去重逻辑是在合并阶段用 Redis Set 做基于商品 ID 的去重,同时通过预算分配保证不同召回源的占比合理——用户的实时行为召回占 50%,相似商品占 30%,热门兜底占 20%,避免推荐结果单调。5. 搜索推荐场景下的性能压测与容量评估搜索推荐系统上线前,压测是不能省的一个环节。我们用压测工具模拟了双十一级别的流量模型,核心指标是 P99 延迟和错误率。ES 集群的容量评估给大家一个参考思路:单分片建议容量控制在 30GB 以内,超过了分片查询性能会下降明显。我们商品数据量在八百万左右,索引大小约 50GB,最终规划了 5 个主分片,每个节点部署一个副本,一共 10 个分片分布在 5 个节点上。压测结果 QPS 达到 3000 时 P99 响应时间稳定在 300 毫秒以内。Redis 的容量估算是另一套逻辑。用户近 24 小时的实时行为序列,每个用户平均存储 50 个商品 ID 和类目偏好,内存大概在几乎可以忽略的量级。真正的大头是搜索结果的缓存、热门榜单和商品基础信息缓存,这些加起来内存占用在 8GB 左右。按 Redis 集群的分片分配,三个主节点加三个副本就能轻松覆盖。压测中得到的配置经验是:ES 启动时 JVM 堆内存不要超过 31GB,超过这个值 JVM 的对象指针压缩会失效,内存利用率不升反降。然后一定要给操作系统留一半的物理内存做 page cache,ES 的读写性能非常依赖于文件系统缓存。6. 上线后的调优经验与长期迭代思考系统的核心技术栈稳定之后,后续的长期迭代集中在搜索词意图识别和个性化排序模型升级两个方向上。搜索里的同义词扩展、错别字纠错、拼音搜索这些能力会逐步加入查询预处理链路。只要底层的 SpringCloud 微服务框架、Kafka 事件管道和 ES 索引结构没有大的变化,这些新功能都只是在原有查询链路上做增强。有几次大促前做容量评估,我特别建议大家提前做预案:搜索和推荐链路的关键节点都要有降级开关。一旦 ES 响应时间恶化,线上开关一键把搜索模式降级为 Redis 热门榜兜底;推荐接口超时则直接走默认热门推荐列表。降级方案不需要多复杂,但每个开关都要演练过,真出问题时不至于手忙脚乱。最后说一个我从这套系统里最深的收获:架构上的技术选型固然重要,但真正影响业务结果的往往是细节——ES 分词词库里多了一个品牌别名、function_score 里一个权重值调大零点几、缓存穿透的一个锁等待时间,这些平时不起眼的小改动,最终组合起来才是搜索推荐系统整体转化率提升的原因。这也是做这类系统的行业从业者最有成就感的时刻,看着技术参数转化为肉眼可见的业务指标,所有的折腾都值得了。
接手一个新的电商平台技术架构时,我们遇到了一个非常典型的问题:商品数据量一上来,搜索就卡,搜出来的结果又跟用户想买的完全对不上,推荐位来回就那么几个爆款,转化率一直上不去。痛定思痛之后,我们用 SpringCloud 拆分了服务,用 Elasticsearch 承载了商品检索,用 Redis 处理了热点数据和缓存,用 Kafka 打通了数据同步和行为采集链路,最终把这套电商搜索推荐系统打磨到了一个能稳定支撑高并发、且搜索推荐质量明显可感知的水平。这篇文章不是架构理论堆砌,而是把我在真实业务场景里折腾这套系统的过程、踩过的坑、试出来的参数和解法完整记录下来。不管你是刚开始琢磨微服务改造,还是已经在用 ES、Redis、Kafka 但玩得不深,这套系统拆解都能给你一些可以直接抄作业的参考思路。1. 整体架构设计与核心思路拆解1.1 为什么是这四件套组合先回答一个很多人会纠结的问题:电商搜索推荐,为什么偏偏选 SpringCloud + ES + Redis + Kafka 这个组合,而不是别的东西?原因很简单,这四件套各自解决的痛点非常清晰,职责之间几乎不重叠。SpringCloud 负责的是微服务拆分和治理。商品、订单、库存、搜索、推荐这些模块如果堆在一个单体应用里,任何一个模块升级或出问题都会互相拖累。拆开之后,搜索服务、推荐服务可以独立横向扩容,支撑大促弹性的流量。更重要的是,SpringCloud 那套注册发现机制能让我们在服务扩容时不用改代码,新实例启动后自动注册,流量自动分流。Elasticsearch 解决的是“怎么搜得准、搜得快”的问题。MySQL 的 LIKE 查询在几百上千万条商品数据面前可以说是灾难性的,全表扫描不仅慢,而且不支持分词、同义词、相关性打分这些搜索引擎该有的能力。ES 天然就是个倒排索引引擎,分词、过滤、聚合、排序都内置好了,而且分片机制让它能够水平扩展,数据量大一点也不用慌。Redis 处理的是“高频访问的共享数据”。搜索推荐场景里,很多数据是读多写少的,比如分类导航、热门搜索词、用户实时浏览序列、推荐结果的预聚合缓存。这些如果每次都打到 ES 或者 MySQL,再好的机器也扛不住。Redis 最大的优势不只是快,而是它的数据结构足够灵活——字符串、哈希、列表、有序集合都能派上用场。Kafka 则是把整个系统的数据流盘活了。搜索推荐系统有一个绕不开的问题:ES 里的索引数据怎么从业务库里更新出来?用户的行为数据怎么流转到推荐引擎?答案就是用 Kafka 做异步消息管道,业务系统产生事件后直接投递,下游的索引同步服务、画像计算服务、推荐实时服务各自订阅自己关心的 topic。这样做的好处是削峰填谷,高峰期不会把下游系统打爆。1.2 服务拆分与数据流转链路设计整套系统在 SpringCloud 体系下拆成了这样几个核心服务:商品服务:商品基础信息的 CRUD,数据源是 MySQL搜索服务:对接 ES,提供商品检索接口推荐服务:对接 Redis 和 ES,提供个性化推荐接口用户行为服务:接收用户浏览、点击、加购、下单行为事件索引同步服务:消费 Kafka 中的商品变更消息,增量更新 ES链路的数据流转是这样的:商品服务在后台管理端发生变更后,把变更事件发给 Kafka 的product-changetopic;索引同步服务作为消费者拿到事件,去数据库查询最新的商品数据再写入 ES;同时搜索服务和推荐服务在处理用户请求时,会先把热点结果缓存到 Redis;用户在前端浏览商品产生的行为事件,则通过埋点接口进入 Kafka 的user-behaviortopic,推荐服务实时消费它来更新用户的兴趣模型。这套链路最核心的设计思想是解耦。商品服务和索引服务之间不直接调用,搜索服务的索引更新不依赖业务系统的可用性,推荐服务的用户画像更新不影响主流程的响应速度。一次大促期间某个服务挂了,最多是数据同步延迟,不会导致整个搜索推荐功能瘫痪。1.3 高转化的系统层面理解为什么这套系统的最终目标加上了“高转化”三个字?因为在电商场景里,搜索推荐系统的 KPI 不只是性能和可用性,更重要的是转化指标——用户搜了之后是不是真的下单买了。从系统架构的角度看,“高转化”意味着三件事:第一,搜索结果要能读懂用户意图。同样是搜“苹果”,有人想买手机,有人想买水果店里的苹果,还有人是想买苹果汁。这就需要 ES 的查询条件设计结合用户实时行为做上下文感知,而不是单纯看关键词。第二,排序模型要综合考虑多种因素。相关性和转化率并不是强强相关的关系,一个很相关的商品销量差、评价低,排在前面用户也不会买。我们在实际系统里用的是“相关性 × 热度因子 × 用户偏好因子 × 价格合理性”这样一个综合评分模型,后面会详细讲怎么落地。第三,推荐结果要跟上用户即时状态。上午刚搜过儿童自行车,下午打开 App 推荐的就是儿童安全座椅,这种实时性不是靠离线训练模型能做到的,必须靠 Kafka 实时消费行为事件、Redis 存储用户的短时兴趣加权状态来实现。2. 核心细节解析与实操要点2.1 商品索引 Mapping 设计的关键决策ES 的索引映射设计是整个搜索质量的根基,一开始没想清楚,后面改起来成本相当高。我强烈建议你在建索引之前,先把商品有哪些检索字段、排序字段、聚合字段列清楚。商品索引的 mapping 设计里有几个特别容易踩坑的地方:第一个坑是多个检索字段没有区分text和keyword。商品名称、品牌名、类目名这些字段需要分词检索,应该用text类型;而商品编码、SKUID、状态这些字段精确匹配,应该用keyword。如果全部用text,精确查询时就会发现明明条件都对,就是查不出来,因为text字段会被分词器拆成多个 token 再去倒排索引匹配。第二个坑是没设计好子字段。我们实践中比较顺手的方式是:商品名称title字段用ik_max_word做最细粒度分词建立索引,同时给它的子字段title.keyword保留完整原文用于精确排序。ik_max_word虽然索引体积更大、构建更慢,但召回更全,不会漏掉包含长尾词的商品。查询的时候用ik_smart做粗粒度分词,保证精确度。第三个坑是数值类型的精度问题。价格如果带小数,建议用scaled_float类型并设置scaling_factor为 100,比分体double省空间,查询性能也更好。如果你需要用价格范围过滤,这个字段必须有 doc values。2.2 中文分词与查询 DSL 的组合玩法中文分词是电商搜索里最有技术含量的环节之一。我们原本用标准分词器的时候,搜“连衣裙女夏季”会得到一堆匪夷所思的结果。后来接入了 IK 分词器,并且维护了一整套电商领域词典,收录了品牌名、爆款别名、材质词汇、风格词汇。这些自定义词必须放到 IK 的扩展词典里,而不是主词典——主词典升级时自定义词会被覆盖。决定了分词器之后,查询 DSL 的设计就成了搜索质量的主战场。我们用得最多的是bool查询嵌套模式:{ "query": { "bool": { "must": [ { "multi_match": { "query": "连衣裙女夏季", "fields": ["title^3", "category_name^2", "brand_name"], "type": "best_fields", "operator": "and" } } ], "filter": [ { "term": { "status": 1 } }, { "range": { "price": { "gte": 50, "lte": 1000 } } } ], "must_not": [ { "term": { "is_spam": true } } ] } } }这里有个值得讲清楚的设计:must里的operator用and还是or,直接决定搜索的精度。用and意味着分词后的每个词都必须匹配上,召回量小,但精度高;用or召回量高,但会出现很多只匹配了其中一个词的噪音。在实际电商场景里,我们默认用or扩大召回,然后在should子句中通过加权字段做排序,让同时命中多个关键字的商品排在前面。这个方案在召回和精度之间平衡得比较好。2.3 相关性打分与个性化加权实践ES 5.x 之后默认的打分算法是 BM25,它本质上是一个计算关键词和文档相关性的统计模型。默认参数k1=1.2、b=0.75在多数电商场景下表现一般,因为商品标题短、关键词分布集中,默认参数会把匹配在标题里的权重压得过低。我们反复调参后落在k1=1.8、b=0.3附近效果更符合直觉——更多的文章出现关键词时,相关性不会急剧衰减,毕竟电商用户搜“洗衣机”时,标题里出现三次和出现十次的商品,对这个用户来说不应该有多大差别。真正拉开转化差距的是业务层面的加权。同样的关键词“连衣裙”,销量十万的爆款和销量几十的长尾商品,默认打分可能只差一点,但用户大概率买爆款。所以在查询层我们叠加了function_score:{ "query": { "function_score": { "query": { "bool": { ... } }, "functions": [ { "filter": { "range": { "sales_count": { "gte": 1000 } } }, "weight": 2.0 }, { "filter": { "term": { "user_like_category": "女装" } }, "weight": 1.5 }, { "gauss": { "create_time": { "origin": "now", "scale": "30d" } }, "weight": 0.8 } ], "score_mode": "sum", "boost_mode": "multiply" } } }这个 DSL 里三层加权的逻辑是:热门商品加权、用户偏好的类目加权、新品时间衰减加权,三者加起来再乘相关性分。score_mode用sum避免三个函数值相乘后把结果拉到离谱的区间。2.4 Redis 缓存策略的组合应用ES 是能扛住高并发,但线上真实流量一大,每次都把完整请求打到 ES 也不是明智选择。Redis 在搜索推荐链路里要做的事,比单纯做缓存要复杂得多。第一层是导航分类和基础数据的缓存。商品类目树、品牌列表、热门搜索词这些数据几乎不变,直接用 Redis String 缓存 JSON,生命周期设为 24 小时,后台变更时主动淘汰对应 key。第二层是搜索结果缓存。同一个搜索词在短期内搜索结果是稳定的,比如“连衣裙”8 点搜和 9 点搜,商品列表基本一样。我们用搜索词 MD5 + 分页参数作为 key,缓存前 50 条结果,TTL 设为 5 分钟。大促期间配合Cache Aside模式,查询时先读 Redis,没有就查 ES 再回填,释放了不少 ES 的查询压力。第三层是用户实时行为状态。用 Redis 的 Hash 结构存储每个用户的类目点击次数,用 ZSet 存浏览历史时间线。这个在后面推荐实时召回时会具体展开。这里有个经验想特别分享:缓存一定要分粒度,不要一股脑把所有数据塞进同一个 key。大 key 在 Redis 集群模式下会导致单分片内存倾斜,读取性能也会劣化。我们把搜索结果按“热门词短时间缓存、长尾词不缓存”的策略处理,每个 key 的数据量控制在几十 KB 以内,性能稳定很多。3. 实操过程与核心环节实现3.1 Kafka 数据同步链路的落地细节整个 Kafka 链路的搭建过程中,topic 的设计和消息格式的约定是最需要提前想清楚的部分。我们最终设计了三组核心 topic:product-change:商品新增、更新、上下架消息user-behavior:用户点击、浏览、加购、下单行为消息recommend-log:推荐结果的曝光与点击日志product-change和user-behavior的分区数都设成了与下游消费者实例数对应的倍数关系,这样做的目的是保证同一个商品的变更事件和同一个用户的行为事件在消费时能保持顺序性。比如按商品 ID 做 key,同一个商品的消息永远进同一个分区,消费时就不会出现“先消费了更新后、又消费了更新前”这样的错乱。Kafka 的消息体我们用 JSON 格式,但加了一个event_type字段来区分事件类型。商品变更消息里只带商品 ID 和变更操作类型,不携带完整商品数据。因为如果携带完整数据,一旦商品服务改了字段结构,生产者消费者都要跟着改,耦合会越来越高。消费者拿到消息后,再去调商品服务的查询接口拉最新的数据。这样设计的好处是消息体积小、生产端可靠,坏处是如果商品服务此时不可用,消息只能等重试。// 这里补全代码示意{ "eventType": "PRODUCT_UP_SHELF", "productId": 123456, "timestamp": 1623456789000 }消费端的幂等处理也是必须做的。Kafka 是至少一次投递语义,重复消费是常态。我们在索引同步服务里维护了一张 Redis Set,存最近处理过的消息 ID,消费后把消息 ID 写入 Set 并设置过期时间。这样即使 Kafka 重复投递,也能通过去重过滤掉,不会把旧数据覆盖新数据。3.2 ES 索引与数据从零到上线的全过程索引建立之后的第一次全量数据灌入是很多人容易搞出问题的环节。几百万条商品数据一次性灌进 ES,如果直接用 bulk 批量插入,分片的分发会极不平衡,部分分片瞬间打满,部分分片闲置,索引构建慢到怀疑人生。我们的全量导入方案分三步走:第一步,关闭分片自动均衡。在创建索引时将cluster.routing.allocation.enable临时设为none,防止数据导入过程中分片在节点之间搬来搬去。第二步,创建索引时先设置零副本。number_of_replicas设为 0,主分片写入完成后,一次性把副本数调回去。这样写入时不需要复制数据,速度能提升一大截。第三步,用批量写入工具或自研程序以多线程方式按分片维度灌数据。每条 bulk 请求控制在 5MB 以内,超过这个体积写入反而变慢。完成全量同步之后,把副本数恢复为 1,重新开启分片均衡。最后用forcemerge做一次段合并,把索引中的小文件合并成大段,查询性能和稳定性都会有明显提升。3.3 搜索服务接口与查询链路实现搜索服务对外提供的接口不是简单把 ES 查询结果原样返回,而是包了一层比较重的聚合逻辑。一次搜索请求的处理链路是:先查 Redis 缓存,缓存命中直接返回。缓存未命中时拼装 ES 查询 DSL,执行查询拿到hits和aggregations,然后把商品 ID 列表传给商品服务——商品服务在 Redis 里缓存了商品的基础信息,直接从缓存拿,拿不到的再查 MySQL 补全——最后把完整商品信息与评分信息组装成响应结构。这里要注意一个细节:ES 查询出的_source里不要存完整的商品信息。只把商品 ID、标题、价格、销量、评分这几个排序展示必需的字段放进去,剩下的详细描述、图片地址这些大字段延迟到商品服务去取。ES 索引瘦身之后,内存占用下降很多,查询响应也会更快。3.4 推荐服务的实时行为与召回策略实现推荐系统这部分,我们走了“规则召回为主、实时加权为核心”的路线。没有一上来就上深度模型,因为冷启动阶段没有大量日志数据,模型根本练不出来,规则系统反而可以快速产生业务价值。推荐的第一个召回源是用户实时行为序列。用户在 Redis 里的浏览历史是一个 ZSet,成员是商品 ID,分数是浏览时间戳。当用户请求推荐接口时,取最近 24 小时浏览过的商品 ID 列表,把这些商品对应的类目聚合成一个用户偏好向量。偏好权重公式是:更新时间越近的浏览行为权重越高,点击和加购行为权重高于纯浏览。第二个召回源是相似商品的协同过滤。用户浏览了某件商品,这件商品需要被提前预计算好“看了又看”的相似商品列表。离线任务会每天跑一次,把商品按类目、标题分词、价格区间计算相似度,相似度高的商品 ID 列表写入 Redis。线上推荐时直接取列表。第三个召回源是热门商品兜底。用户没有实时行为数据时,用 Redis ZSet 存的全站近 7 天销量热榜做召回。这三个召回源的结果合并到同一个候选池之后,用前面 ES 排序类似的加权规则做融合排序。这里需要提醒的是,推荐系统的效果好不好,不能靠拍脑袋判断,一定要接入 AB 测试。我们当时上线推荐系统后,用三个流量桶分别测试了纯热门召回、行为召回、行为加相似召回三种策略,对比点击率和下单转化率,行为加相似召回在转化率上明显领先。3.5 A/B 测试与核心指标验证没有埋点的推荐系统等于盲人摸象。我们在推荐结果、搜索结果、商品详情页分别埋了曝光事件和点击事件,事件实时上报到 Kafka,然后通过 Flink 做实时统计,再把统计结果写回 Redis,运营后台就能实时看到指标。核心指标主要看三块:点击率 CTR = 点击人数 / 曝光人数下单转化率 CVR = 下单人数 / 点击人数搜索无结果率 = 搜索无结果请求数 / 总搜索请求数这三个指标能直接评估搜索推荐质量。我印象比较深刻的是,优化了 ES 查询 DSL 和排序加权后,无结果率从约 7% 降到了不到 1%,CTR 提升约 2 个百分点,CVR 是纯新增的系统能力,从无到有稳定在了一个可观的数值。数据不会骗人,指标一测就知道方向对不对。4. 常见问题与排查技巧实录4.1 ES 深分页引发的性能问题线上搜索服务上线后遇到的第一个大坑是“翻页翻到 100 页以后接口直接超时”。ES 的from + size方式在分页越深时性能越差,因为每个分片都要把前 N 条结果取出来,再在协调节点上做全局排序。翻到第 100 页时,单次查询的 CPU 消耗已经是前十页的上百倍。这个问题我们最终的解法是限制搜索的深度,只允许用户翻前 50 页,超过之后建议用更精确的关键词重新搜索。技术上对于需要深拉取的场景,切换成了search_after方案。search_after不依赖全局序号,而是通过上一页最后一条的排序值继续往后找,性能和稳定性都比from + size好很多。搜索链路里加这样的约束之后,最慢的请求响应时间也从 2 秒级降到了 500 毫秒以内。4.2 Redis 缓存击穿和雪崩的连锁反应某天晚高峰搜索接口突然大面积超时,排查之后发现是 Redis 缓存大面积失效引起的。当时运营准备大促,批量更新了热门促销商品的数据,为了方便清掉了相关缓存 key。结果缓存重建时正好赶上流量高峰,大量请求同时穿透到 ES,ES 扛不住,把搜索服务也拖垮了。这个案例特别典型。缓存重建的惊群效应在电商场景里杀伤力巨大。我们后来做了两个改进:一是在缓存失效后加分布式锁,只有拿到锁的请求可以查询 ES 并重建缓存,其他请求短暂等待后直接读取缓存;二是对热门商品数据做缓存永不过期策略,后台数据变更时主动更新缓存,而不是依靠 TTL 被动淘汰。这两个改动之后,Redis 层面的问题基本绝迹。4.3 Kafka 消费积压导致的数据延迟有次线上出现了一个诡异的问题:商品后台明明已经下架了,但搜索里还是能搜到。查了一圈,定位到product-changetopic 里的下架消息积压了。原因是索引同步服务的一个消费者实例因为内存溢出重启了,重启之后消费线程恢复得慢,积压了几十万条消息。商品下架消息排在最前面,但迟迟没有被消费,下游 ES 自然没有更新。排查的经验是:必须做好 Kafka 消费端的监控报警,特别是消费 offset 和生产者 offset 的差值。差值是持续上涨,那必定是消费者出问题了。我们后来给索引同步服务增加了消费线程数的动态调整机制,积压严重时可以手动拉升消费速度,同时优化了消费者的批量处理逻辑,从一次处理一条改成了poll一批后批量更新 ES,消费吞吐翻了数倍。4.4 搜索召回乱与推荐结果重复的排查思路搜索召回乱的典型案例是搜“苹果”出现一堆苹果水果和苹果手机夹杂在一起,用户一脸懵。这个问题要从两个方向修:一是必须在查询 DSL 里加入类目偏好作为 boost 因子,用户经常在数码类目下点击,搜“苹果”时数码类目商品会自动加权;二是在前端和推荐层增加类目去重或整理策略,同一叶子类目下的商品最多展示两个,避免结果被单一类目霸屏。推荐结果重复的问题则通常出在召回源之间的交集没处理好。三个召回源各产出一批商品 ID,合并时直接拼接就会有重复。我们的去重逻辑是在合并阶段用 Redis Set 做基于商品 ID 的去重,同时通过预算分配保证不同召回源的占比合理——用户的实时行为召回占 50%,相似商品占 30%,热门兜底占 20%,避免推荐结果单调。5. 搜索推荐场景下的性能压测与容量评估搜索推荐系统上线前,压测是不能省的一个环节。我们用压测工具模拟了双十一级别的流量模型,核心指标是 P99 延迟和错误率。ES 集群的容量评估给大家一个参考思路:单分片建议容量控制在 30GB 以内,超过了分片查询性能会下降明显。我们商品数据量在八百万左右,索引大小约 50GB,最终规划了 5 个主分片,每个节点部署一个副本,一共 10 个分片分布在 5 个节点上。压测结果 QPS 达到 3000 时 P99 响应时间稳定在 300 毫秒以内。Redis 的容量估算是另一套逻辑。用户近 24 小时的实时行为序列,每个用户平均存储 50 个商品 ID 和类目偏好,内存大概在几乎可以忽略的量级。真正的大头是搜索结果的缓存、热门榜单和商品基础信息缓存,这些加起来内存占用在 8GB 左右。按 Redis 集群的分片分配,三个主节点加三个副本就能轻松覆盖。压测中得到的配置经验是:ES 启动时 JVM 堆内存不要超过 31GB,超过这个值 JVM 的对象指针压缩会失效,内存利用率不升反降。然后一定要给操作系统留一半的物理内存做 page cache,ES 的读写性能非常依赖于文件系统缓存。6. 上线后的调优经验与长期迭代思考系统的核心技术栈稳定之后,后续的长期迭代集中在搜索词意图识别和个性化排序模型升级两个方向上。搜索里的同义词扩展、错别字纠错、拼音搜索这些能力会逐步加入查询预处理链路。只要底层的 SpringCloud 微服务框架、Kafka 事件管道和 ES 索引结构没有大的变化,这些新功能都只是在原有查询链路上做增强。有几次大促前做容量评估,我特别建议大家提前做预案:搜索和推荐链路的关键节点都要有降级开关。一旦 ES 响应时间恶化,线上开关一键把搜索模式降级为 Redis 热门榜兜底;推荐接口超时则直接走默认热门推荐列表。降级方案不需要多复杂,但每个开关都要演练过,真出问题时不至于手忙脚乱。最后说一个我从这套系统里最深的收获:架构上的技术选型固然重要,但真正影响业务结果的往往是细节——ES 分词词库里多了一个品牌别名、function_score 里一个权重值调大零点几、缓存穿透的一个锁等待时间,这些平时不起眼的小改动,最终组合起来才是搜索推荐系统整体转化率提升的原因。这也是做这类系统的行业从业者最有成就感的时刻,看着技术参数转化为肉眼可见的业务指标,所有的折腾都值得了。