做过几年电商后端的人,应该都遇到过这种场景:大促前做压测,搜索接口在峰值流量下超时率一路飙升,ES节点CPU被打满,推荐位返回的内容却跟搜索结果高度重复,用户明明搜的是“手机”,前两屏却全是同一个店铺的爆款。问题很明显——搜索和推荐是两套系统,甚至两个团队在维护,搜索只管“词面匹配”,推荐只管“行为猜测”,两者互不感知,结果就是用户搜索时看到的排序结果并没有把他的实时行为、历史偏好和购买意愿用起来,转化率自然上不去。
后来在某电商项目里,我们把搜索和推荐真正打通,用SpringCloud搭微服务底座,ES做商品检索与排序,Redis承担热点缓存和用户近线特征存储,Kafka负责把点击、加购、下单这些行为数据实时回流到排序链路,最终成了一套能自我进化的搜索推荐系统。这篇文章把我当时的设计思路、落地细节和踩坑过程都整理出来,不聊虚的概念,只讲能直接复现的链路、代码段和配置,适合正在做电商后端、搜索推荐或者微服务架构的同学参考。
1. 先解决一个老问题:搜索和推荐各自为战,转化率上不去
很多团队对“搜索”和“推荐”的理解是分开的:搜索负责把用户输入的词跟商品文本匹配,按相关性排序;推荐负责根据用户历史行为猜他还想要什么。两个系统各自迭代,指标各看各的,最后会出现一个很怪的现象——用户搜索完某个商品后,推荐位还在推他上次浏览过但已经下单的东西,而搜索页里他加购过的一个小众品牌,反而排在最底下。
我自己调过一个项目的数据漏斗:搜索曝光到点击的转化率挺高,说明召回和相关性没问题;但点击到加购的转化率明显偏低。后来把某段时间的搜索日志拉出来看,发现用户搜某个品类词时,排序靠前的全是销量最高的爆款,里面很多其实用户已经在别的店铺买过了;而用户最近在看的、加购的相似商品,因为文本相关性没那么高,被排到后面去了。这其实就是典型的“搜索没有消费行为数据”。
所以核心思路不是把搜索和推荐做成一模一样的功能,而是让搜索链路在最合适的位置接入推荐特征。例如,当用户输入“夏季连衣裙”时,文本检索负责保证基本相关性,排序阶段参考他在近七天偏好哪个价格带、对哪个品牌点击最多、最近加购过的款式是什么。这样搜索结果的覆盖面和排序逻辑都更贴近真实购买意愿,转化率才谈得上优化。
顺带说一句,搜索和推荐各自为战还有个隐患:重复计算资源。搜索侧维护一套商品索引,推荐侧又要维护一套特征和候选集,数据同步还经常对不上。把它们放到同一套技术底座里,业务上各做各的,但数据链路、存储和排序参数是共享的,迭代效率会高很多。
2. 架构全貌:请求链路与行为数据流如何贯穿四个组件
整套系统的核心流程可以分成两条线:一条是用户发起搜索的同步请求链路,另一条是用户行为被采集后异步回流的消息链路。两条线的中心,就是SpringCloud微服务框架下的一组服务。
同步请求链路由以下环节组成:前端带上userId和query词,先走网关层做鉴权和限流,请求落到SearchService;SearchService不直接拿查出的结果列表返回,而是开启一个并行调用阶段——一边请求ES,做基本的文本召回和粗排,一边从Redis读取当前用户的近线特征,比如最近点击过的商品ID集合、近期高频类目、偏好价格区间,再从配置中心拿当前用户所属实验分组的排序策略参数;拿到这些素材之后,再进入重排服务,把用户特征和商品候选用一个加权公式算分,重新调整顺序,最后拼装返回。并行调用这一步特别重要,因为ES查询、Redis读取和配置拉取三者互不依赖,串行做一次增加几十毫秒,并行之后能控制在几十毫秒以内。
异步行为消息流则是这样运作的:前端埋点把曝光、点击、加购、下单、收藏这些行为上报到采集服务,采集服务把原始事件写入Kafka的原始行为Topic;下游消费者分别做几件事,一件是更新ES里的商品转化分和热度分,一件是更新Redis中该用户的特征Hash和偏好ZSet,还有一件是触发推荐候选池的增量刷新。这样下一轮该用户再来搜同一个词时,ES的排序分和用户的特征数据都已经是最新的了。
为什么用Kafka来承接这套行为流,而不是让采集服务直接写ES和Redis?原因很现实:行为数据的峰值速率远高于搜索请求量,如果每个行为都同步写ES和Redis,ES刷新频率和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存储,每个用户一个key,field是行为类型(click、cart、order),value是一个商品ID列表,这个列表维护最近N条;TTL按场景设置,一般用户不活跃超过7天就自然淘汰。第二类是用户偏好分,用ZSet存储,key是userId:pref:category或者userId:pref:brand,member是类目或品牌ID,score是累加后的行为权重分。第三类是搜索结果热点缓存,用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在短时间内只允许累加一次。
行为事件给排序贡献的权重,我们建议先按业务价值粗排,而不是一刀切。做过一版权重如下:
| 行为类型 | 权重分值 | 衰减周期 |
|---|---|---|
| 曝光 | 1 | 1天 |
| 点击 | 5 | 3天 |
| 收藏 | 8 | 5天 |
| 加购 | 20 | 7天 |
| 下单 | 50 | 15天 |
这套权重的含义是:下单行为对转化最有直接价值,权重最高且衰减最慢;曝光权重最低且衰减最快,避免用户只是不小心看到了商品就给排序加码。实际效果中,通过这个“行为回流+分数更新”机制,一个商品在被用户加购后,出现在相似搜索词前排的时延从分钟级降到了10秒以内。对用户感知来说,他刚加购的商品下次搜索同类词时排得更前,体验上明显更“懂我”。
6. SpringCloud负责的“稳定”:服务拆分、降级与实验开关
SpringCloud在整套系统里承担的不是炫技成分,而是最核心的稳定性工作。服务按职责拆成下面这些模块,彼此之间通过注册中心发现地址,通过配置中心统一管理参数。