
做返利App搜索系统最磨人的不是搜不到而是搜得不准。用户经常把另一边的商品标题直接复制过来长串的品牌词、型号、规格混在一起想在千万级商品库里快速找出最值得推荐的返利链接普通数据库模糊查询根本顶不住。我参与的这个项目就是给某电商返利App重新做一版搜索底层基于Elasticsearch搭建商品导购引擎覆盖索引建模、分词策略、排序调优、数据同步和稳定性治理。这篇文章适合正在做电商搜索、导购类App的开发者也适合刚接触ES但想搞懂搜索优化思路的同学。下面是把我们踩过的坑和最终方案整理出来的版本。1. 需求拆解返利App搜索到底难在哪1.1 导购场景和普通电商搜索的差异返利App的用户搜索行为有一个很典型的特点大多数用户不是来闲逛的是带着明确目标来的。他们可能刚在某个电商平台看中一件商品然后复制标题到这里查有没有返利、能返多少。这个行为决定了搜索Query又长又口语化类似“某品牌轻薄本 16G内存 512G硬盘 银色”这种字符串信息密度高但冗余词也多。用户真正想要的是快速识别出这是哪个商品、哪个型号、哪个配置而不是简单把包含这些字眼的商品全部列出来。如果搜索系统没有意图识别能力就会把不同型号、不同容量、不同颜色的商品混在一起展示用户点进去发现货不对跳出率瞬间拉满。另外一个差别在数据源。返利App的商品通常会接多个联盟渠道同一个商品在不同渠道的标题、价格、佣金率往往不一样甚至同一个商品ID在不同渠道会重复出现。普通电商搜索只需要对商家自己后台的商品做检索导购引擎却要在检索结果里再处理合并、去重、渠道选择。也就是说搜索系统不只是负责“把商品搜出来”还承担了“帮用户选一个最优返利入口”的职责。这个本质差别决定了整个架构不能照搬普通电商搜索那一套。1.2 核心需求清单与技术选型思考动手写代码之前我们先把需求写成了几条硬指标支持千万级商品数据搜索响应P99不超过200毫秒支持中文分词、拼音检索、错别字纠正排序能融合返利比例、销量、券后价等业务因子商品信息变更后索引延迟控制在分钟级大促流量下集群不能频繁拒绝请求。这几条列出来之后技术路线的选择基本就清晰了普通数据库的LIKE查询到千万级以后慢查询很致命也没法做相关度排序。全文检索引擎里对比过Solr和Elasticsearch最后选了Elasticsearch主要是因为它的中文搜索生态更好、分布式运维成本低、周边工具链丰富社区里能查到的资料也比较多。这里想多说一句技术选型的逻辑。ES和Solr底层都是Lucene但ES把Lucene封装成了近实时的分布式系统开箱即用对中小团队更友好。Solr虽然在某些场景下配置更灵活但需要我们自己去处理集群协调和运维细节。我们的团队当时没有专职的搜索引擎工程师所以可维护性比理论性能更重要。事实证明选ES之后整个系统的开发周期确实比预期快了不少。1.3 整体架构和分层思路整个导购引擎没有做成一个单体服务而是拆成了四层。数据接入层负责从各联盟平台拉取商品和佣金数据数据加工层负责字段清洗、数据去重、类目映射索引层统一走Elasticsearch写入搜索服务层对外提供查询接口包括Query理解、拼音纠错、排序、缓存和降级。这个分层最大的好处是把数据问题和搜索问题隔离开来。项目后期我们定位到的很多奇葩Bug最后查出来都是数据管道把脏数据写进了索引跟ES自己一点关系都没有。所以如果你也在做类似项目我的建议是不要一上来就调ES参数先把数据管道的字段映射、幂等逻辑、失败重试做扎实搜索服务质量至少稳定一半。后面每一层的关键细节我都会展开讲。2. 商品索引结构设计与映射配置2.1 字段规划与类型选择的关键点索引设计是ES项目里最不能省的一步。我们一开始为了图省事想把商品表的几十个字段全部塞进ES文档结果文档体积大、写入慢查询时还容易被垃圾字段干扰。后来把字段按照三组来管理。基础属性包括商品ID、标题、品牌、类目、主图、商品规格这些负责搜得到业务指标包括销量、价格、券后价、佣金率、返利金额、券面额这些负责排得准运营控制包括上下架状态、推广状态、更新时间这些是硬过滤条件。类型选择上也有几个容易踩的坑。商品ID要用keyword而不是long因为很多平台的ID带字母或前缀keyword做精确过滤也更稳。价格字段用scaled_float可以省点存储但精度要设对如果后续还要做范围聚合直接用double反而省心。上下架状态用byte就够了不需要integer。另外强烈建议把原始标题额外存一份keyword类型比如叫title.raw排查分词问题和做完全匹配加分时特别管用。2.2 分词器选型中文分词、拼音与同义词分词器决定了搜索召回的下限。ES默认的standard分词器对中文是按单字切查“保温杯”会被拆成“保”“温”“杯”三个term查询全是噪音。我们的方案是使用IK这种中文分词插件索引阶段用max_word模式切细词查询阶段用smart模式切粗词这样既能提高召回又能减少误召回。标题字段用max_word建立索引查询时用smart去匹配是中文搜索很常见的一种组合。拼音搜索也是返利App用户的高频行为用户经常输入全拼或首字母。我们用拼音转换插件给标题生成了一个pinyin子字段保存全拼和首字母组合查询的时候让拼音字段也参与匹配。这里有个特别提醒拼音字段不要给太高权重否则搜中文“手机”的时候拼音字段会把大量发音相似的无关结果带进来。我们在实践里把pinyin子字段放在should的最末端权重压得很低只负责兜底召回。同义词是导购搜索提升泛化能力非常有效的手段。像“保温杯”和“水杯”、“充电宝”和“移动电源”这类用户习惯互换的说法如果不同义词配置召回率会差很多。我们在索引端和查询端都配置了同义词过滤器并且把同义词词库做成了可以运营的配置每次大促前根据搜索词报表补充一批。说实话这个动作对搜索体验的提升比调评分公式还明显。2.3 分片数、副本数与刷新间隔分片数是ES项目里最容易改不动的参数所以一定要认真定。我们当时的商品数据在千万级单分片控制在30-40GB最终定了8个主分片、2个副本。为什么不是4个因为4个分片单分片会接近80GB查询和段合并性能都下降。为什么不是16个因为集群只有三台数据节点分片太碎导致每个小请求都要跨节点延迟反而更高。这个决策思路比具体数字更重要不同量级的项目应该有自己的计算逻辑。refresh_interval也值得细说。默认的1秒刷新是为了让新增数据尽快可见但也会导致频繁生成小段文件。全量导入时我们把它调到30秒甚至临时关闭导入完成后再改回1秒。增量场景如果业务允许秒级延迟保持默认就行。另外translog的durability在批量写入时可以设置成异步换更高的写入性能但要接受节点故障时可能丢失少量数据的风险。生产环境怎么取舍要看你的数据重要程度。3. 搜索链路设计与相关性排序优化3.1 Query DSL设计多字段加权与bool查询搜索接口最终发给ES的DSL不是简单的一个match。我们把它拆成了几个部分must部分放文本匹配主查询匹配标题、品牌、类目这些核心字段should部分放追加加分项比如品牌命中、热销命中、用户偏好类目命中filter部分放硬性条件比如价格区间、上下架状态、佣金是否大于0。must保证召回should调整排序filter守住业务规则。这三部分各司其职调试起来也好定位。多字段加权时boost值不能凭感觉。标题字段权重最高品牌次之类目再次。品牌匹配尤其需要小心如果品牌词被当成普通词出现在其他商品标题里很容易召回一堆不相关的结果。我们的做法是先把用户Query做一次简单的品牌识别识别出品牌词之后再把它单独放到should子句里做加权而不是写在must里直接强行匹配。这样既能提升品牌商品的排序又不会破坏召回。排查相关性一定要用explain。ES返回的explanation会展示每个词的得分贡献很多问题可以一眼看出来。比如我们遇到过查询词“优惠券”被分词成“优惠”和“券”而“券”字在语料里出现频率很低IDF很高导致很多无关商品因为这个字获得了很高得分。这种问题最后是靠调整停用词和字段权重解决的。3.2 融合返利和销量特征的相关性排序文本相关度解决不了业务排序问题。ES默认的BM25只考虑词频、逆文档频率和文档长度不知道哪个商品更值得推荐。返利App的“值得推荐”至少要结合佣金率、销量、券金额、转化率这些指标。我们用function_score把文本分和业务分做了融合。具体是先把佣金率、销量、返利金额做log1p变换再归一化到0到1区间然后线性加权。为什么用log1p因为销量分布非常长尾头部商品有百万销量尾部只有个位数直接用原始值会让头部商品永远霸榜log变换之后梯度更平滑头尾不会再拉开上百倍差距。权重配比是需要反复验的我们专门搭了一个小工具输入一个Query就能返回TOP50商品以及它们对应的文本分、业务分方便团队肉眼判断结果是否合理。这个过程很枯燥但确实是对排序质量提升最大的环节。不过业务分叠加要有底线。我们曾经为了提高高佣商品曝光把佣金率权重调得很高结果搜“手机”时出来的全是佣金率高但销量极低的冷门配件用户投诉点击进去的商品根本不是想要的。后来我们加了两条规则销量低于阈值的商品不参与佣金加权业务分只做有限幅度的调整不能超过文本相关度分太多。搜索系统的相关度是底线业务利益只能在不明显牺牲相关度的前提下追求。3.3 轻量个性化与同款折叠策略返利App的个性化不需要做成千人千面的推荐系统轻量偏好加权就够了。我们的做法是基于点击日志统计用户最近30天点击最多的三级类目生成一个userId到类目偏好的映射缓存到Redis。查询的时候把偏好类目作为一个should子句给命中偏好的商品适量加分。从效果看点击率有一点提升但不算巨大。这种低成本方案在业务初期比较合适等数据积累够了再考虑更加完整的个性化方案。同款折叠反而是我们投入精力较多的点。同一个商品可能出现在多个联盟平台如果不做归并搜索结果里会出现两个几乎相同的商品链接用户会觉得平台很山寨。我们通过“清洗后的标题哈希主图地址”生成商品系列的group_id索引里保存这个字段查询时用ES的collapse按group_id折叠折叠后保留佣金率最高的那条。这样既去掉了重复曝光又保证了最优返利入口被优先展示。4. 数据同步与性能调优实践4.1 Binlog增量同步与全量重建商品数据源头在MySQL我们的同步链路是MySQL开binlog用订阅组件读取变更事件解析后发到消息队列再由消费者写入ES。这套链路的好处是数据新鲜度高价格和上下架状态变化能在几秒到几分钟内反馈到搜索索引。同步逻辑里有几个细节不能忽略更新事件里可能包含旧值和新值不能简单整条覆盖同一商品ID短时间内更新多次消费端要合并ES写入失败要进重试队列重试次数太多就定位死信队列人工排查。全量重建在索引映射变更、数据恢复、测试环境初始化时都会遇到。我们写了一个全量任务分批从MySQL读取商品数据清洗后通过bulk批量写入ES。全量同步最怕和增量同步同时写同一批文档造成版本冲突。我们的处理方法是先建新索引全量导入完成后用别名机制切换流量再删旧索引。整个过程对业务无感知也是生产环境比较稳妥的发布方式。4.2 缓存、降级与熔断搜索服务的外层加了一层Redis缓存缓存key包含搜索词、排序方式、页码、用户分群和过滤条件TTL设为5分钟。这个设计主要解决热点查询的重复计算比如大促前大量用户搜同一个爆款词没有缓存时几千个请求会同时打到ES有缓存后绝大多数热点Query直接命中。注意缓存key不能漏掉过滤条件价格区间和“只看有券”这种筛选变化了不能继续用旧缓存。缓存击穿和雪崩是搜索服务一定会碰到的问题。热点缓存过期瞬间大量请求穿透到ES查询RT会突然飙高。我们对付击穿用互斥锁第一个请求查ES并回填缓存其他请求等待对付雪崩是在TTL上加了随机偏移不让大量key同时过期。再往上是熔断降级我们监控ES集群CPU和搜索线程池队列超过阈值自动切换成简化Query把耗时的聚合和facet去掉优先保证核心搜索能出结果哪怕排序质量暂时差一些。4.3 压测数据与参数调整记录压测是优化ES配置的前提。我们用线上流量回放工具压测了三个核心接口普通搜索、带价格过滤的搜索、带聚合统计的搜索。第一轮压测P99在600毫秒左右问题很明显。逐步排查发现几个原因搜索线程池队列堆积、部分查询带了不必要的大聚合、段合并没有处理好。我们先后调整了节点GC参数限制部分花费过高的查询并把历史商品按月归档到只读索引让热点索引体积减了一半。调整后P99降到120毫秒左右QPS可以支撑到2500以上。这里要强调一个经验压测结果不要只看平均值一定要看P99和P999。搜索是面向用户的少量慢请求带来的体感恶化远大于平均值体现的水平。上线前要给系统留足余量不能照着一台机器扛满负载去设计。5. 常见问题与排障实录5.1 搜索不到商品时先查分词项目里处理最多的“搜索不到”问题最后几乎都指向同一个原因查询词和文档字段的分词结果对不上。比如用户输入一个英文品牌名文档里是大写分析过程中如果没有统一小写term就无法匹配。排障的第一步是用ES的_analyze接口分别对查询词和文档标题做分词把产出的term列表放在一起对比差异一眼就能看出来。还有一个常见的坑是query_string和match的区别。query_string语法灵活但也要求输入符合Lucene语法用户随便输入一个冒号或括号就可能让整个查询报错。我们统一用match和multi_match从源头避免了一堆随机语法错误。所以排查搜索问题时先看查询用什么语法发送的很多时候问题不在索引而在查询层的拼写太随意。5.2 相关性被高佣金带偏这类问题在导购场景里太典型了。我们一开始把佣金率作为function_score的重要因子结果搜“笔记本”时排在前面的全是一些高佣金但销量很低的冷门品牌业务方看到佣金率很开心用户却完全不买账。同样的例子还有搜“手机”出现手机壳。后来我们做两个调整一是给业务分增加销量下限约束低于阈值的商品不参与佣金加权二是调低佣金率权重让文本相关度始终居于主导地位。这是一条必须守住的底线。5.3 写入拒绝和CPU飙高的排查商品数据更新集中在晚上大批写入和查询同时打进来ES开始报rejected_execution_exception。这个报错看起来像是机器性能不足其实是线程池队列满了。写入线程池的容量不是无限的大批量bulk提交时队列排满新请求就会被拒绝。我们做了三处优化bulk请求体控制在10MB以内批数量控制在1000到2000之间客户端增加指数退避重试避免重试风暴把refresh_interval从1秒调整到5秒减少刷新和段合并压力。调整之后写入拒绝基本消失CPU也稳定了。这个案例也提醒我ES的很多问题都不是硬件不够而是参数和业务流量不匹配。排查时要先看监控面板上的线程池队列、段数量、GC曲线不要一上来就加机器。6. 核心配置速查与最后的几点心得6.1 常用参数速查表整理一个当时项目里实际使用的参数表可直接作为参考起点但不能照抄因为数据量、查询模式和硬件规格不同最优值会有差异。配置项我们的取值备注主分片数8按单分片30-40GB预估副本数2提供高可用和读扩展refresh_interval增量1s批量导入30s或关闭全量导入后再恢复bulk批次大小1000-2000条请求体小于10MB过大易触发写入拒绝拼音字段权重0.1左右仅兜底召回权重过高干扰中文相关度佣金率业务权重0.2-0.3需要根据点击率持续调缓存TTL5分钟热点词单独设置随机偏移GC回收器G1搭配合理堆内存策略这些数值是我们在三台数据节点、千万级数据量下的经验值。你在自己的环境里一定要用压测数据来验证而不是直接复制。6.2 几点个人经验和收尾这个项目做下来我最大的体会是搜索优化是个持续迭代的过程没有“一劳永逸”的配置。分词词典、同义词、停用词、排序权重都需要根据用户的真实搜索行为反复调整。我们每两周就会拉一次搜索词报表看看哪些词没有结果、哪些词点击率特别低然后针对性地调整索引和排序。很多效果不是一次大改动带来的而是几十个小优化堆出来的。另外就是排障时的心态。ES吃掉了一个查询不代表ES是罪魁祸首有时数据管道写入了脏字段有时是查询DSL结构不合理有时是缓存策略没生效。遇到问题先沿着一条链路完整查一遍用户输入、查询解析、缓存、DSL、ES查询、返回结果每个环节留好日志比猜问题要高效得多。最后分享一个小习惯每次调整完索引或排序之后保留一份变更记录里面写明改了什么、为什么改、对哪个指标有影响。这个项目后期能快速收敛问题很大程度上靠的就是这份记录。希望这篇内容能帮你少踩几个坑如果你也有类似的导购搜索项目欢迎拿着具体问题来交流。