资讯详情

SpringCloud+ES+Redis+Kafka打造电商搜索推荐融合系统

📅 2026/10/21 22:48:01 | 华诺云谱 👁 阅读
SpringCloud+ES+Redis+Kafka打造电商搜索推荐融合系统
做过几年电商后端的人应该都遇到过这种场景大促前做压测搜索接口在峰值流量下超时率一路飙升ES节点CPU被打满推荐位返回的内容却跟搜索结果高度重复用户明明搜的是“手机”,前两屏却全是同一个店铺的爆款。问题很明显——搜索和推荐是两套系统甚至两个团队在维护搜索只管“词面匹配”推荐只管“行为猜测”两者互不感知结果就是用户搜索时看到的排序结果并没有把他的实时行为、历史偏好和购买意愿用起来转化率自然上不去。后来在某电商项目里我们把搜索和推荐真正打通用SpringCloud搭微服务底座ES做商品检索与排序Redis承担热点缓存和用户近线特征存储Kafka负责把点击、加购、下单这些行为数据实时回流到排序链路最终成了一套能自我进化的搜索推荐系统。这篇文章把我当时的设计思路、落地细节和踩坑过程都整理出来不聊虚的概念只讲能直接复现的链路、代码段和配置适合正在做电商后端、搜索推荐或者微服务架构的同学参考。1. 先解决一个老问题搜索和推荐各自为战转化率上不去很多团队对“搜索”和“推荐”的理解是分开的搜索负责把用户输入的词跟商品文本匹配按相关性排序推荐负责根据用户历史行为猜他还想要什么。两个系统各自迭代指标各看各的最后会出现一个很怪的现象——用户搜索完某个商品后推荐位还在推他上次浏览过但已经下单的东西而搜索页里他加购过的一个小众品牌反而排在最底下。我自己调过一个项目的数据漏斗搜索曝光到点击的转化率挺高说明召回和相关性没问题但点击到加购的转化率明显偏低。后来把某段时间的搜索日志拉出来看发现用户搜某个品类词时排序靠前的全是销量最高的爆款里面很多其实用户已经在别的店铺买过了而用户最近在看的、加购的相似商品因为文本相关性没那么高被排到后面去了。这其实就是典型的“搜索没有消费行为数据”。所以核心思路不是把搜索和推荐做成一模一样的功能而是让搜索链路在最合适的位置接入推荐特征。例如当用户输入“夏季连衣裙”时文本检索负责保证基本相关性排序阶段参考他在近七天偏好哪个价格带、对哪个品牌点击最多、最近加购过的款式是什么。这样搜索结果的覆盖面和排序逻辑都更贴近真实购买意愿转化率才谈得上优化。顺带说一句搜索和推荐各自为战还有个隐患重复计算资源。搜索侧维护一套商品索引推荐侧又要维护一套特征和候选集数据同步还经常对不上。把它们放到同一套技术底座里业务上各做各的但数据链路、存储和排序参数是共享的迭代效率会高很多。2. 架构全貌请求链路与行为数据流如何贯穿四个组件整套系统的核心流程可以分成两条线一条是用户发起搜索的同步请求链路另一条是用户行为被采集后异步回流的消息链路。两条线的中心就是SpringCloud微服务框架下的一组服务。同步请求链路由以下环节组成前端带上userId和query词先走网关层做鉴权和限流请求落到SearchServiceSearchService不直接拿查出的结果列表返回而是开启一个并行调用阶段——一边请求ES做基本的文本召回和粗排一边从Redis读取当前用户的近线特征比如最近点击过的商品ID集合、近期高频类目、偏好价格区间再从配置中心拿当前用户所属实验分组的排序策略参数拿到这些素材之后再进入重排服务把用户特征和商品候选用一个加权公式算分重新调整顺序最后拼装返回。并行调用这一步特别重要因为ES查询、Redis读取和配置拉取三者互不依赖串行做一次增加几十毫秒并行之后能控制在几十毫秒以内。异步行为消息流则是这样运作的前端埋点把曝光、点击、加购、下单、收藏这些行为上报到采集服务采集服务把原始事件写入Kafka的原始行为Topic下游消费者分别做几件事一件是更新ES里的商品转化分和热度分一件是更新Redis中该用户的特征Hash和偏好ZSet还有一件是触发推荐候选池的增量刷新。这样下一轮该用户再来搜同一个词时ES的排序分和用户的特征数据都已经是最新的了。为什么用Kafka来承接这套行为流而不是让采集服务直接写ES和Redis原因很现实行为数据的峰值速率远高于搜索请求量如果每个行为都同步写ES和RedisES刷新频率和Redis带宽都会被打爆还会反过来影响主链路。让Kafka作为中间缓冲高峰期允许消费端有轻微积压只要在几十秒内追平对转化效果的影响基本可接受。SpringCloud在这里的身份不是算法参与者而是把这些服务和依赖关系管理起来的骨架注册发现、配置中心、网关路由、熔断限流都靠它统一处理。3. 搜索服务里最磨人的一环ES索引构建与电商排序思路ES在电商搜索里承担的是“召回初排”的角色很多人以为只要把商品数据灌进去、写个match查询就算完事真正上线才发现索引结构不合理、排序只看BM25分数出来的结果根本不适合做转化。先聊索引结构。我们用的商品索引mapping大概是这样的{ mappings: { properties: { productId: { type: keyword }, productName: { type: text, analyzer: ik_max_word, search_analyzer: ik_smart }, categoryPath: { type: keyword }, brandId: { type: keyword }, price: { type: float }, salesCount: { type: integer }, stock: { type: integer, index: false }, specs: { type: nested, properties: { specName: { type: keyword }, specValue: { type: keyword } } }, recScore: { type: float } } } }几个容易忽略的细节。第一商品规格字段我用了nested类型而不是普通object因为一个商品有多个规格维度颜色、尺寸、版本用object会被ES平铺成一个对象数组过滤时会发生属性错配比如明明搜了“红色 256G”结果匹配出“蓝色 256G”的商品。nested能保证每个规格组合独立匹配。第二库存字段我设了index:false因为库存是强实时数据检索时不需要对它建索引直接从商品服务查详情时带出来就行否则库存一变就要更新索引压力反而大。第三所有用于排序的热度字段销量、转化分、行为权重必须放在索引里不能用查询时临时脚本算临时脚本在大流量下是性能杀手。再聊排序。ES默认的BM25算的是“文本相关性”但电商搜索最终目标是让人下单所以相关性只是基础门槛排序阶段一定要叠加业务因子。我们用的排序策略是function_score{ query: { bool: { must: [ { multi_match: { query: 连衣裙, fields: [productName^3, categoryPath^2, brandId] }} ], filter: [ { range: { price: { gte: 50, lte: 500 } } }, { term: { stock: 1 } } ] } }, rescore: { window_size: 1000, query: { function_score: { query: { match_all: {} }, functions: [ { field_value_factor: { field: salesCount, factor: 0.0001, modifier: log1p } }, { field_value_factor: { field: recScore, factor: 0.001, modifier: log1p } }, { gauss: { price: { origin: 200, scale: 100 } } } ], score_mode: multiply, boost_mode: sum } } } }这个设计背后的逻辑是先用multi_match做文本召回保证用户搜的词跟商品有关再用filter把不合理的价格区间和无货商品过滤掉最后用rescore阶段对前1000个候选重新打分把销量、转化积分、价格匹配度这三个因子灌进去。field_value_factor用log1p是为了压制头部爆款分数的非线性增长避免销量最高的商品永远霸占第一屏给中小商品留出曝光机会。gauss函数的作用是把价格贴近用户历史购买均价的商品往前排防止一次连衣裙搜索全出3000块的轻奢款。这里要特别说一句recScore字段不是人工填的而是Kafka消费者根据用户行为实时更新的。某用户搜“连衣裙”之后如果对某个具体款式产生了加购行为这个款式的recScore会在几十秒内涨上去下次其他相似用户搜索时它的排序就会提前。这才是“搜索推荐融合”在ES层面的真正体现。4. Redis的真正角色热点缓存之外的“用户现场”存储很多文章提到电商里的Redis张口就是缓存商品详情、缓存搜索结果但在这套系统里Redis更关键的定位是“用户近线特征存储”。什么叫近线就是行为数据从Kafka消费出来后不写MySQL这种重型存储而是写进Redis以极低的读取延迟供搜索请求链路实时查询。我用Redis存三类核心数据。第一类是用户行为历史用Hash存储每个用户一个keyfield是行为类型click、cart、ordervalue是一个商品ID列表这个列表维护最近N条TTL按场景设置一般用户不活跃超过7天就自然淘汰。第二类是用户偏好分用ZSet存储key是userId:pref:category或者userId:pref:brandmember是类目或品牌IDscore是累加后的行为权重分。第三类是搜索结果热点缓存用String存储若干高频query词对应的商品结果列表设置较短TTL比如30到60秒避免大促时同一个搜索词反复打爆ES。用户偏好分的写入逻辑是Kafka消费者在消费行为消息时同步完成的比如点击一次某品牌加1分加购一次加5分下单一次加10分同时做时间衰减每过一天整体score乘以0.95。搜索请求到达SearchService后从Redis里取出这些ZSet的TopN连同行为历史列表一并传给重排阶段对应商品的排序权重就能动态调整。这里有一个很常见的坑如果把一个用户近7天的所有行为都塞进同一个Redis key比如用一个大Hash存了几万个field写入时产生大key读取时也容易阻塞Redis单线程。我们后来做了一个小改动把“最近点击商品”和“近期偏好类目”拆成不同的key每个key的数据量控制在几百条以内并把TTL错开性能立刻稳了。还有一个细节读取用户特征时一定要用mget或者pipeline批量操作不能用单条命令for循环否则请求一多Redis的往返延迟会把整个搜索接口拖垮。Redis在防缓存穿透上也起到了作用。搜索接口面对的长尾query非常多不可能每个词都缓存结果但某些异常流量会频繁搜一个根本不存在商品的词每次都打到ES上白白消耗CPU。我们给空结果也做了一个TTL很短的缓存比如5秒同时加了一层布隆过滤器拦截明显不合法的词ES的压力降了不少。5. Kafka接住行为数据实时反馈才能让排序越用越准行为数据是这套系统的燃料Kafka是运输燃料的管道。当时我们设计Topic时没有把所有事件都混在一个主题里而是按用途拆开避免下游消费互相影响。user-behavior-raw原始行为事件payload包含userId、itemId、behaviorType、query、scene、timestamp所有行为先进这里。product-score-update解析后要更新ES商品分数的消息consummer群组专门写ES。user-profile-update解析后要更新Redis用户特征的消息consummer群组专门写Redis。rec-candidate-trigger触发推荐候选池增量更新的消息下游由推荐服务消费。打开Kafka消费自动提交这行配置之后如果消费者处理业务逻辑的耗时大于max.poll.interval.ms就容易发生rebalance特别是在大促流量进来时。我们后来改成关闭自动提交业务处理成功后手动提交offset并且把poll的数据量调小每条消息处理前先做幂等校验。为什么要注意幂等因为用户点击同一件商品可能在一秒内连发两次事件如果消费者把两次都写入商品分数排名会虚高。我们的做法是在Redis里存一个行为去重标记相同userId、itemId、behaviorType在短时间内只允许累加一次。行为事件给排序贡献的权重我们建议先按业务价值粗排而不是一刀切。做过一版权重如下行为类型权重分值衰减周期曝光11天点击53天收藏85天加购207天下单5015天这套权重的含义是下单行为对转化最有直接价值权重最高且衰减最慢曝光权重最低且衰减最快避免用户只是不小心看到了商品就给排序加码。实际效果中通过这个“行为回流分数更新”机制一个商品在被用户加购后出现在相似搜索词前排的时延从分钟级降到了10秒以内。对用户感知来说他刚加购的商品下次搜索同类词时排得更前体验上明显更“懂我”。6. SpringCloud负责的“稳定”服务拆分、降级与实验开关SpringCloud在整套系统里承担的不是炫技成分而是最核心的稳定性工作。服务按职责拆成下面这些模块彼此之间通过注册中心发现地址通过配置中心统一管理参数。服务职责关键依赖gateway-service入口鉴权、限流、灰度路由SpringCloud Gatewaysearch-service接收搜索请求并行调用ES/Redis/特征服务返回结果ES、Redisrec-service推荐候选生成、重排负责给search-service提供策略Redis、Kafkauser-profile-service维护用户偏好特征提供近线查询接口Redis>
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑