尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

电商商品中心亿级搜索与SKU管理架构实践

电商商品中心亿级搜索与SKU管理架构实践 1. 先盘盘商品中心在整个电商体系里到底是干嘛的1.1 为什么无数系统都绕不开商品中心做电商这行有个很朴素的判断标准你问一个技术团队“你们的核心系统有哪些”十个里有九个会把商品中心排在订单、库存、支付前面。原因很简单商品是电商的“原材料”所有交易行为都围绕它发生。用户搜索一个关键词看到的是商品点进详情页看的是商品加购物车、下单、支付最终落地的还是具体的商品SKU。我在带团队做这套系统的时候接到过很多次类似的需求几百个商家同时上架商品、运营要批量改价格、搜索要支持多条件筛选、订单要锁定具体规格……这些表面需求拆到底层全部指向同一件事——商品中心的建模能力和检索能力。如果商品中心的模型不扎实后续的订单、促销、库存、搜索全都会跟着返工如果搜索性能跟不上用户点一下要等2秒大促场景下基本宣告放弃。所以这篇文章我把商品中心拆成两个核心战场亿级商品搜索和SKU管理。这俩实际是同一枚硬币的两面——搜索是商品中心的“入口”SKU是商品中心的“底座”。底座不稳入口再漂亮也是白搭入口不顺畅底座再扎实用户也感知不到。1.2 “亿级商品”到底是个什么概念很多同学对“亿级”没概念我先算一笔账。假设平台有5000万SPU标准商品单元每个SPU平均有3个SKU比如三个颜色那SKU总量就是1.5亿。如果每个SKU在搜索引擎里对应一条文档1.5亿条索引是常态。再叠加多字段标题、品牌、类目、属性、价格、库存状态搜索请求的QPS在平时可能是几千大促预热期会冲到几万甚至更高。这个规模下很多“常规做法”直接失效。比如MySQL单表存SKU超过千万级查询就会明显变慢比如用数据库LIKE查商品名亿级数据下全表扫描根本扛不住比如做搜索不用搜索引擎非要用关系型数据库硬扛那基本是自爆。亿级的本质不是“数据多”而是“多数据下依然要快、要准、要稳定”。这也是商品中心最锻炼架构能力的地方。2. 商品模型设计SKU管理的核心地基2.1 先分清SPU和SKU不然后面全是坑做商品中心第一关不是写代码而是搞清楚模型边界。SPUStandard Product Unit是“标准化产品单元”比如“iPhone 15 Pro Max”是一个SPUSKUStock Keeping Unit是“库存量单位”是用户真正能下单购买的最小粒度比如“iPhone 15 Pro Max 原色钛金属 256GB”是一个SKU。为什么这个区分很重要因为搜索和列表页展示的通常是SPU而订单和库存管的是SKU。用户搜“苹果手机”看到的是一个SPU列表点击进去选“颜色版本内存”选中的是一个SKU。如果模型上SPU和SKU混成一团搜索聚合没法做库存联动也会乱套。我在实际建模时用的方案是三层结构商品层SPU描述“这是什么”承载标题、品牌、类目、主图、详情、关键属性。销售层SKU描述“能买什么”承载销售属性颜色、尺码、版本、价格、库存、条形码、状态。扩展层属性描述“详细参数”承载规格参数、功能特性、包装清单等非销售属性。画表格更直观层级实体核心字段与交易的关系商品层SPU商品ID、标题、品牌、类目、默认图搜索/列表展示销售层SKUSKU_ID、SPU_ID、销售属性、价格、库存、状态购物车/订单/支付扩展层属性属性名ID、属性值ID、属性组筛选/详情参数2.2 SKU属性建模——为什么不能直接用冗余字段有人会问SKU不就有颜色尺码几个字段嘛我直接在表里加三列不就行了这个想法在小规模下没问题商品池一旦大了就爆炸。因为不同类目的销售属性完全不一样——手机有“内存版本”服装有“尺码”食品有“净含量”家居有“规格”。如果都用冗余字段表会变成几百列的“大宽表”维护成本难以想象。我的做法是属性拆成“属性名属性值”两张字典表SKU只存“属性名ID:属性值ID”的JSON或关联子表。举个例子iPhone那个SKU存的就是[ {attr_name_id: 101, attr_value_id: 5001}, {attr_name_id: 102, attr_value_id: 6003} ]其中101是“颜色”102是“内存版本”对应关系由字典维护。这样设计有几个好处。第一新增类目不用改表结构第二销售属性天然支持筛选聚合搜索引擎里做terms聚合很方便第三属性字典可以复用到搜索的facet筛选。代价是查询时多一次属性解析但这个开销在缓存层就能解决。2.3 库存和状态管理——SKU的“可用”比“存在”更重要SKU管理里最容易出问题的是状态。一个SKU不是“上架了就能卖”它还受制于库存、审核、渠道、时间等维度。我在项目里给SKU设计了几个核心状态机草稿商家录入中只是占位数据前端不可见。待审核提交审核平台校验类目、资质、图片、价格合规性。已上架可售最基本状态允许被搜索到并下单。已下架人为下架或违规下架前端不可见但数据保留。售罄库存为0前端可展示但不可购买搜索中需要特殊处理排序降低或直接聚合时过滤。这里面最关键的逻辑是搜索侧只索引“可售状态”的SKU/SPU。很多团队忽略这一步导致搜索结果里出现“已下架”的商品用户体验直接崩坏。库存维度我建议独立管理不要直接写死在SKU表里。因为库存涉及预占、扣减、回滚是高频写操作如果每次下单都更新SKU主表数据库压力太大。一般做法是SKU表存“库存版本号展示库存”实际可售库存放在Redis或独立库存服务里。搜索引用的库存状态通过订阅库存变更事件来更新而不是每次查询都实时去库存服务刷新。3. 亿级商品搜索架构选型与索引方案3.1 为什么我选了Elasticsearch而不是MySQL或自研但凡做过亿级商品搜索的人大概率绕不开Elasticsearch。我也想过自研一个简单的倒排索引但评估再三还是决定站在Elasticsearch的肩膀上。原因很务实倒排索引开箱即用Elasticsearch分词、倒排、TF-IDF/BM25排序都是现成的自研这些底层能力至少得半年。分布式扩展简单亿级数据必然要切分到多节点Elasticsearch的shard机制和路由能力天然适配。聚合能力对商品搜索是刚需搜索结果页的“品牌筛选”“类目筛选”“价格区间”本质是facet聚合。Elasticsearch的terms聚合和range聚合效率非常高。社区和运维生态成熟遇到问题能搜到大量方案招聘也相对简单。选型期间我也对比过Solr但考虑到Elasticsearch在实时性、聚合性能、社区活跃度上的综合表现最终定它。另外说一句阿里云上也有OpenSearch这类商业化产品如果团队人力不够直接用托管也是明智选择——但要心里有数商业化产品的成本会随数据量和QPS增长。3.2 索引模型商品搜索文档怎么设计索引设计是整个搜索系统的灵魂。很多人直接“把数据库表导成索引”结果搜索体验稀烂。我推荐宽表加嵌套的设计思路。核心思想是Search索引面向“查询展示”建模而不是面向“存储”建模。每个商品SPU在Elasticsearch里是一条文档文档里冗余聚合了SKU信息和销售属性。文档结构大致长这样{ spu_id: 100001, title: Apple iPhone 15 Pro Max 原色钛金属 256GB 5G手机, title_keyword: 苹果手机 iPhone 15 pro max 5G, brand_id: 300, brand_name: Apple, category_id: 2001, category_path: [手机数码, 手机通讯, 智能手机], price_min: 9999, price_max: 13999, sale_count: 12500, score: 4.8, status: 1, attrs: [ {attr_id: 101, attr_value_id: 5001, attr_value: 原色钛金属}, {attr_id: 102, attr_value_id: 6003, attr_value: 256GB} ], skus: [ {sku_id: 888101, color: 原色钛金属, version: 256GB, price: 9999, stock_status: 1}, {sku_id: 888102, color: 蓝色钛金属, version: 256GB, price: 9999, stock_status: 1} ], saleable: true, create_time: 1700000000000 }这里核心细节在于title_keyword是单独加工出来的搜索字段它比原title多了同义词和品牌词扩展比如“苹果手机”映射到“iPhone”“pro max”对应“Pro Max”。在查询时用multi_match同时匹配title和title_keyword能把召回质量提一个档次。3.3 全量与增量同步——索引和数据库怎么保持一致索引和源数据保持一致是商品搜索工程里最琐碎也最容易崩的部分。我的方案是**“全量增量”双轨同步**。全量同步每天凌晨跑一次从MySQL的SPU/SKU表批量读取全量商品数据写入Elasticsearch。这个任务必须幂等支持失败重跑因为线上数据量大会跑很久我们1.5亿文档全量重建约40分钟。增量同步实时订阅MySQL的binlog解析出商品变更事件发送到MQ我们用RocketMQ。消费者拿到事件后从业务库查询最新详情组装成Search文档写入Elasticsearch。增量链路里有几个经典坑要注意。第一binlog消费顺序同一个SPU的更新事件可能乱序需要在消费者里按更新时间字段做去重兜底方案是每次更新时带上update_time消费时对比当前文档的时间戳旧事件直接丢弃。第二更新还是要删除商品下架是更新状态不是删除文档因为订单历史数据可能还要引用来展示快照。第三失败重试MQ消费失败后要有重试队列和告警不然一个环节卡住搜索结果就和库存/状态不一致了。3.4 让搜索“准”起来分词、同义词与业务排序召回准不准分词和排序各占一半。Elasticsearch默认的分词器对中文不友好只按字切分效果很差。我在生产环境用IK分词并自定义了电商领域词库持续收录品牌词“优衣库”“戴森”、类目词“连衣裙”“跑步机”、属性词“无糖”“加绒”。同时配置了同义词过滤器把“苹果手机”和“iPhone”、“老公”和“丈夫”在一些礼品搜索场景里真有这种需求等同义映射。排序这块我的经验是**“业务分基础分”混合排序**。基础分用BM25相关性保证关键词匹配度业务分让运营配置权重比如综合排序公式可以简化为final_score bm25_score * 0.4 log(sale_count 1) * 0.3 (score / 5) * 0.2 freshness_bonus * 0.1其中freshness_bonus根据商品创建时间衰减新品加权。这个公式看起来简陋但实际效果比纯BM25好很多也足够解释给运营听。更重要的一点排序公式一定要线上A/B验证不要让技术拍脑袋定权值。4. 搜索与SKU联动从查询到下单的完整链路4.1 搜索结果为什么返回SPU而不是SKU商品搜索有个产品基本要求搜“iPhone 15”应该展示商品卡片用户点进去再选具体颜色版本而不是在搜索结果页看到几百个SKU平铺。这就决定了搜索查询返回的是SPU粒度但SPU的“可购买性”依赖其下SKU的集合状态。实现上我建议在Search文档里嵌套SKU数组就像上文的skus字段查询时用inner_hits或直接返回整个SKU数组。前端展示搜索卡片时默认显示第一个可售SKU的主图和价格区间price_minprice_max点击后再请求商品详情接口获取完整SKU列表。这里有个细节搜索接口必须做“可售状态过滤”。也就是说如果一个SPU下的所有SKU都售罄了正常情况下不应该出现在搜索列表里除非运营配置了“允许展示售罄商品”。我见过不少搜索不准的问题最后排查下来都是没过滤售罄状态。4.2 库存字段搜索里该放“实时库存”还是“库存状态”这是很多刚做搜索的人纠结的点。如果把精确库存数放进搜索索引大促时每个商品库存都是高频变化的索引更新压力会非常大。但如果没有库存信息搜索结果页无法展示“仅剩3件”这类刺激转化的标签。我的方案是索引里存库存状态如0售罄1有货2紧张不存精确数量。精确数量在商品详情接口里从Redis读取实时值。库存状态通过MQ异步更新延迟控制在1秒内。这个设计一方面保证了搜索性能另一方面也避免了搜索和库存服务的强耦合。实现“紧张”状态还需要一个阈值判断比如库存小于10判定为“紧张”这个阈值在配置中心动态调整。搜索结果页通过skus.stock_status字段就能轻松实现“仅看有货”这个筛选项。4.3 详情页SKU选择的实时库存扣减逻辑用户进详情页、选中SKU、提交订单这段链路是SKU管理的高频动作。重点说两个环节SKU实时库存读取用户选中某个SKU前端发起查询后端先从Redis读库存Redis没有再从MySQL读并回填。Redis的key我用sku_stock:{sku_id}value是当前可售库存和冻结库存。读库存不需要强一致所以读多级缓存没问题。下单扣减库存用户提交订单订单服务先做库存预占再写订单。我比较推荐“先预占、后支付确认”的模式下单时在Redis里用DECR预占库存支付成功后再写正式库存流水支付超时则释放预占。这个模式下Redis的原子操作天然防超卖同时数据库的库存流水也留了审计依据。扣库存的伪代码大致如下// 预占库存返回是否成功 public boolean preoccupyStock(Long skuId, int count) { String key sku_stock: skuId; Long remain redisTemplate.opsForValue().decrement(key, count); if (remain 0) { // 回滚预占 redisTemplate.opsForValue().increment(key, count); return false; } return true; }这个逻辑看着简单但有几个边界要注意库存为负要回滚、并发扣减依赖Redis原子操作、预占后要写日志供对账。线上踩过最狠的一个坑是参数校验没做有人用一个送人的低价SKU刷了上万个库存虽然没造成实际损失但也逼着我们把下单接口的库存校验做成了强制策略。5. 实操中反复踩过的坑排查思路和避坑指南5.1 搜索“搜不到”了先分召回问题还是排序问题商品搜索上线后最常见的排查需求就是“这商品为什么搜不到”。我总结了一套排查顺序按这个顺序查基本半小时内定位查索引文档用GET /product_index/_doc/{spu_id}确认商品文档存在、状态字段是1可售。查状态筛选确认搜索DSL里是否带上了status1和saleabletrue的过滤条件这是新手最容易漏的。查分词用_analyze接口拆解搜索词看关键词是否被正确分词。比如搜“苹果手机”如果被分词成“苹果”、“手机”那匹配逻辑基本没问题如果“苹果手机”被当成一个整体查不到那就是词库或同义词没覆盖。查索引更新延迟如果商品刚上架先查MQ消费进度确认增量同步有没有延迟。排查工具方面Elasticsearch的_explain接口是排序问题排查的神器能精确告诉你某个文档为什么排前面或排后面。我每次处理“排错位”的case都会先跑explain看得分拆解。5.2 SKU数据不一致状态错乱和价格异常SKU管理另一个高频问题是“数据不一致”。印象最深的是一次促销活动运营改了一个SPU下部分SKU的价格但同步任务只处理了改动的SKU导致这个SPU在搜索里的price_min还是旧值搜索结果页出现了“展示价”和“详情页价”不一致的投诉。这类问题的根源在于SPU的汇总字段价格区间、可售数量、SKU数量必须由子SKU的数据聚合而来而聚合逻辑一旦漏了SKU级别的事件就会产生脏数据。我的解决方案是双保险增量任务里收到“SKU变更事件”时不只要更新该SKU文档还要联动更新其SPU的汇总字段。增加对账任务定期扫描SPU下所有SKU重新计算price_min、price_max、saleable与索引对比不一致就触发重建。5.3 性能瓶颈大促期间搜索RT突然飙高大促前我们做过一轮压测发现搜索RT在QPS过万时开始明显上涨。排查发现两个核心问题。第一个是Elasticsearch分片热点。我们的索引初始只建了10个分片但数据量已经1.5亿部分大卖家商品的文档集中在几个分片上造成单个分片负载过高。优化方案是重建索引时按路由字段比如brand_id做路由把同一品牌的商品尽量分布到不同分片使热点分散。第二个是缓存命中率。搜索接口起初没有加缓存每个请求都打到Elasticsearch。后来在Search服务前加了一层Redis缓存缓存粒度是“关键词筛选条件页码”的MD5TTL设为60秒。实测缓存命中率能做到70%以上大促期间效果极其明显。5.4 给新手的几条避坑清单零零散散写了这么多最后把踩坑经验浓缩成清单当你在类似项目里遇到问题先对照自查坑点现象应对方案状态没过滤搜出已下架商品索引文档增加status字段查询强制过滤分词不统一同样商品搜不同词结果不一致自定义领域词库配置同义词定期维护SKU和SPU未联动价格区间错误、可售状态错误用事件驱动更新SPU汇总字段并跑对账任务索引延迟新商品搜不到、改价后搜索未变监控MQ消费延迟设置告警阈值库存扣减无预占超卖Redis原子扣减预占支付确认缓存击穿冷门词搜索打到ES导致RT飙高布隆过滤器空值缓存互斥锁这六条里面最后一条“缓存击穿”我多说一句。有一次上线后某个生僻词突然被刷流量因为这个词在索引里根本没有结果Redis缓存也没法预热结果每个请求都直接打到Elasticsearch。后来加了一个“空结果也缓存”的策略TTL设短一点30秒这种现象才彻底压下来。6. 最后留一点个人经验做商品中心这几年我最大的感受是技术方案永远在变但模型思维和排查方法论是沉淀下来最值钱的东西。SKU模型设计得合理后续接订单、促销、搜索、推荐都会顺搜索架构选型得当、索引和缓存配合到位亿级规模也并不可怕。真正难的是“变”——业务类目在扩、用户搜索习惯在变、大促流量在涨今天最优的分片策略明天可能就成了热点瓶颈所以一定不要把系统做成“改一次要两周”的定式留好扩展位比某个时刻做到极致更重要。这篇算是我做电商商品中心的一个阶段总结里面每一项都经过了线上流量的检验。如果你正在做类似系统希望这些经验能帮你少踩几个坑。后续有空我再把“商品中心如何支撑促销满减”“搜索排序中的个性化实践”这两块单独拆开写那个话题水更深值得聊的东西更多。
返回列表