接手一个新的电商平台技术架构时我们遇到了一个非常典型的问题商品数据量一上来搜索就卡搜出来的结果又跟用户想买的完全对不上推荐位来回就那么几个爆款转化率一直上不去。痛定思痛之后我们用 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它本质上是一个计算关键词和文档相关性的统计模型。默认参数k11.2、b0.75在多数电商场景下表现一般因为商品标题短、关键词分布集中默认参数会把匹配在标题里的权重压得过低。我们反复调参后落在k11.8、b0.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 的设计和消息格式的约定是最需要提前想清楚的部分。我们最终设计了三组核心 topicproduct-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。结果缓存重建时正好赶上流量高峰大量请求同时穿透到 ESES 扛不住把搜索服务也拖垮了。这个案例特别典型。缓存重建的惊群效应在电商场景里杀伤力巨大。我们后来做了两个改进一是在缓存失效后加分布式锁只有拿到锁的请求可以查询 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 cacheES 的读写性能非常依赖于文件系统缓存。6. 上线后的调优经验与长期迭代思考系统的核心技术栈稳定之后后续的长期迭代集中在搜索词意图识别和个性化排序模型升级两个方向上。搜索里的同义词扩展、错别字纠错、拼音搜索这些能力会逐步加入查询预处理链路。只要底层的 SpringCloud 微服务框架、Kafka 事件管道和 ES 索引结构没有大的变化这些新功能都只是在原有查询链路上做增强。有几次大促前做容量评估我特别建议大家提前做预案搜索和推荐链路的关键节点都要有降级开关。一旦 ES 响应时间恶化线上开关一键把搜索模式降级为 Redis 热门榜兜底推荐接口超时则直接走默认热门推荐列表。降级方案不需要多复杂但每个开关都要演练过真出问题时不至于手忙脚乱。最后说一个我从这套系统里最深的收获架构上的技术选型固然重要但真正影响业务结果的往往是细节——ES 分词词库里多了一个品牌别名、function_score 里一个权重值调大零点几、缓存穿透的一个锁等待时间这些平时不起眼的小改动最终组合起来才是搜索推荐系统整体转化率提升的原因。这也是做这类系统的行业从业者最有成就感的时刻看着技术参数转化为肉眼可见的业务指标所有的折腾都值得了。
阅读完成 · 觉得有帮助?