
做电商后台商品搜索这一块迟早要面对。很多团队一提到“搜索”下意识就想去部署一套重型全文检索引擎结果机器资源、索引维护、集群调优全压上来一个小模块搞得比订单系统还重。我在之前的项目里用 Redis 从零搭了一套商品搜索十万级商品量单实例扛住了线上日常流量查询平均在 10 毫秒以内索引构建和更新也完全在可控范围内。这套方案的本质是把 Redis 的有序集合当成倒排索引来用配合哈希、集合做过滤和详情存储再用缓存、布隆过滤器、分布式锁把并发和穿透问题兜住。这篇就把整套架构拆开讲适合商品量在百万级以内、搜索需求以“关键词加筛选排序”为主、又不想为新模块引入重系统的团队参考。1. 轻量级索引方案的整体设计与取舍1.1 为什么是 Redis 而不是重型检索组件先聊选型逻辑。商品搜索的诉求无非几个按关键词召回、按类目品牌价格过滤、按销量时间排序、分页返回。重型检索引擎确实能做得很好分词、相关性排序、分布式扩展都专业但代价是集群节点变多、索引分片要规划、内存占用翻倍还得有人专人维护。对一个“搜索功能只是辅助转化入口”的业务来说这套成本经常是不划算的。Redis 方案的思路很直接用有序集合ZSet存储每个词对应的商品 ID 列表用分数score表达排序权重再用哈希Hash存商品详情字段用集合Set或有序集合存类目、品牌这些过滤维度。查询时做集合运算取交集最后按分数倒序取一页数据。整个过程都在内存里完成单次查询没有跨网络调用组件延迟自然低。这个方案适用边界也很清晰商品量百万级以内、搜索逻辑是“关键词 结构化筛选”、允许商品上架后几秒内被搜到、团队没有专职搜索引擎运维人员。如果业务有同义词挖掘、纠错、语义召回这类强检索需求那还是老老实实上专业组件Redis 在这块做不到同等效果。做技术选型先认清楚边界比选一个“看起来厉害”的方案重要得多。1.2 三类核心数据结构的搭配方案Redis 里有五种基础数据类型真正承担搜索任务的实际上是 ZSet、Hash、Set 这三类String 在缓存和布隆过滤器里打辅助。ZSet 是这套索引的核心。一个搜索词对应一个 key比如idx:word:连衣裙member 是商品 IDscore 是排序权重。查询“连衣裙”就是ZREVRANGE idx:word:连衣裙 0 19直接按权重倒序取前 20 个商品 ID。这个词条覆盖的商品越多这个 key 就越大所以后面会讲到对大词做截断。Hash 负责存商品详情key 形如goods:info:{id}字段包括标题、类目、品牌、价格、状态、销量、上架时间等。查询结果拿到商品 ID 后用HMGET批量取详情组装返回。这里有一个实际经验尽量不要把整个商品详情序列化成 JSON 塞进一个 String字段更新时要整体覆盖内存利用率差且无法对单字段做更新。拆成 Hash 的多个 field既省内存又能单独更新销量、价格这种高频变化字段。Set 用于维护过滤维度的成员关系比如valid:ids保存所有上架商品 IDbrand:{品牌ID}保存某个品牌下的所有商品 ID。过滤时用SINTER对候选结果做交集。如果过滤维度本身要参与排序就把它也建成 ZSetscore 统一置 0查询交集时用AGGREGATE MAX这样最终 score 还能保留原排序值。这个细节很多人会忽略后面在故障复盘里还会提。1.3 整体数据流与适用范围整套链路是这样的。商品上架或变更时服务从数据库拿到商品信息走分词、权重计算然后通过 Redis pipeline 一次性写入词条倒排索引、商品详情 Hash、类目和品牌索引。搜索请求进来后先对 query 分词分词结果作为关键词去查倒排索引多个词之间取交集接着依次做类目、品牌、上架状态过滤再根据排序模式取对应 ZSet 的一段数据最后批量查 Hash 组装结果返回。缓存层放在查询和索引之间。搜索结果按“关键词 过滤条件 排序 页码”维度做短时间缓存热点词首屏也可以做进程内缓存。同时用布隆过滤器拦截“根本没索引过的词”避免无效查询把流量打到底层。这套结构如果用一个词概括就是“索引外置、查询直取、缓存兜底”。Redis 在这里面充当的既是一个存储引擎也是一个计算引擎很多集合运算直接在服务端完成减少了应用层的数据搬运。对于十万级商品整个索引内存占用大概在 500MB 到 1GB 之间单机完全能扛。2. 商品索引的构建、权重与内存治理2.1 分词策略词库切分与二元切分混用商品搜索的召回效果一半以上取决于分词质量。Redis 只是个存储和计算的壳词切得烂后面索引和查询都白搭。我在项目中采用的是“词库切分 二元切分”混合策略。先基于一个通用中文词库把商品标题按词库匹配切出标准词比如“男士纯棉白色短袖T恤”切成“男士 / 纯棉 / 白色 / 短袖 / T恤”。同时保留二元切分结果作为补充把相邻两个字符组成一个词比如“纯棉”如果词库里没有也能通过 bigram 切成“纯棉”作为索引词。这种做法牺牲了一部分索引精度但能显著提升词库外的召回率代码也简单不需要维护巨大的自定义词典就能覆盖绝大多数商品标题。类目词和品牌词要单独处理。类目 ID 直接作为过滤条件不参与分词品牌名要单独切成品牌词做品牌搜索时命中率更高。比如“Apple iPhone 15 Pro 手机壳”品牌词“Apple”和通用词“手机壳”分开索引。还有一个容易被忽略的点英文词要统一转小写数字和单位要规范化避免“T恤”和“t恤”、“5G”和“5 g”这种同义不同词的索引分裂。做完规范化再去构建索引能少踩很多召回不全的坑。2.2 索引写入与删除的关键步骤索引写入最忌讳逐条命令执行几十个词条就是几十次网络往返性能完全没法看。正确做法是使用 pipeline 批量提交。核心流程代码如下import redis r redis.Redis(host10.0.0.5, port6379, db0, decode_responsesTrue) def calc_score(goods): # sales_norm 最近7天销量归一化到 0~100 # recency_bonus 时间衰减因子越新权重越高 return sales_norm * 0.7 recency_bonus * 0.3 def index_goods(goods): words segment(goods[title]) [goods[brand]] words dedup_words(words) pipe r.pipeline(transactionFalse) for w in words: key fidx:word:{w} pipe.zadd(key, {goods[id]: calc_score(goods)}) # 这里限制单个词条最大长度防止大词膨胀 if r.zcard(key) 5000: pass pipe.hset(fgoods:info:{goods[id]}, mapping{ title: goods[title], price: goods[price], brand: goods[brand], category_id: goods[category_id], status: goods[status], sales: goods[sales], on_time: goods[on_time], }) pipe.zadd(ffilter:category:{goods[category_id]}, {goods[id]: 0}) pipe.zadd(ffilter:brand:{goods[brand]}, {goods[id]: 0}) pipe.sadd(valid:ids, goods[id]) pipe.execute()删除商品时必须先拿到该商品当前标题分词得到的全部旧词再逐个从倒排索引里移除否则会残留旧索引搜索还能搜到下架商品。这也是很多团队上线后遇到“商品明明下架了还能搜到”的直接原因。删除流程同样走 pipeline最后删除商品详情 Hash 和过滤索引中的成员。这里多提一句上线索引变更不要直接操作线上的 key。我的习惯是先构建一套带版本号的索引 key比如idx:word:v2:连衣裙全部构建完成后再通过配置中心切换查询路由版本号实现平滑过渡。商品量不大时直接低峰期重建后切换也问题不大但一定要留回滚方案。2.3 排序权重设计ZSet 的 score 决定了商品在搜索结果里的位置这个值的设计要结合业务不是简单拿销量一放了事。我用的公式是score 近7天销量归一化值 × 0.7 上架时间衰减分 × 0.3销量归一化可以采用sqrt(销量)或者对数缩放避免头部爆款把长尾商品全部压到后面。时间衰减分可以设计成类似30 × exp(-上架天数 / 7)这样新品有初始加权越老权重衰减越快。这样综合下来一个刚上架但有基础销量的商品能排到前面老商品也不会彻底被埋没。需要注意一个技术细节Redis 的 score 是 IEEE 754 双精度浮点数超出 2^53 会丢失精度所以不要把毫秒时间戳直接塞进 score 做时间排序。正确做法是先归一化成相对分数或者单独维护一个“按上架时间排序”的 ZSetscore 使用秒级时间戳。排序维度多时我建议直接为每个排序维度建独立的 ZSet比如sort:price:asc、sort:new:desc避免在 score 里做复杂的位运算。权重公式一旦定了尽量不要频繁调整否则要遍历所有词条重算 score成本很高。2.4 内存估算与容量规划内存规划决定方案能扛多大商品量。按我实际项目里的统计单商品索引的内存开销大致这样分布倒排索引一个商品平均参与 15 个词条索引每个词条成员包含商品 ID、score 和跳表指针大约 100 到 150 字节合计 1500 到 2200 字节。商品详情 Hash20 个字段以内大约 300 字节。过滤索引类目、品牌、有效集合各一个成员关系合计约 200 字节。一台承载 10 万商品搜索索引的 Redis 实例内存消耗在 500MB 到 1GB 区间100 万商品大概需要 5GB 到 10GB。单实例控制在 10GB 以内比较稳妥超过这个量级就要考虑分片或者换方案了。内存治理上还有两个硬性措施。第一对高频大词做截断像“装”“女”“新款”这种热门词倒排列表可能覆盖几十万商品单个 key 过大会成为 bigkey而且查询时取交集成本极高。我的做法是每个词条最多保留排序前 5000 个商品 ID既控制内存也保证查询时效代价是深度长尾结果可能召回不全业务完全可接受。第二设置合理的 maxmemory-policy搜索专用实例建议直接noeviction避免 Redis 自动淘汰索引数据导致查询结果不完整这一点很多人会踩坑。3. 搜索查询链路的工程实现3.1 单关键词与多关键词检索单关键词查询最简单分词后拿到唯一词直接查idx:word:{词}的 ZSetZREVRANGE按 score 倒序取一段即可。比如用户搜“连衣裙”分词结果就是“连衣裙”一条命令搞定。多关键词就要做交集。用户搜“男士 黑色 连衣裙”分词得到三个词倒排索引分别对应三个 ZSet。交集有两种做法一种是客户端聚合。用ZRANGEBYSCORE把每个词的前 N 个商品 ID 取回应用层再用哈希集合求交集。这个方案适合单词条商品数较少的情况比如 5000 以内控制取回数量后速度很快且不占用 Redis 额外内存。另一种是服务端聚合使用ZINTERSTORE命令直接对多个 ZSet 求交集写入临时 key再读结果。这个方案的优点是原子缺点是会生成临时 key并发高时会带来内存抖动且因为 Redis 单线程执行大集合求交会阻塞其他命令。所以服务端聚合只建议用在词少、单词商品量可控的轻量场景。实际工程里我一般这么处理单词商品数小于 2000 用客户端聚合大于 2000 或词数较多时用服务端聚合加超时保护。生死原则是控制单个词条规模也就是前面说的截断策略这会让交集成本始终处于可控范围。3.2 类目、品牌、价格过滤的实现顺序过滤条件决定了最终返回给用户的商品范围。常见的过滤维度包括类目、品牌、价格区间、库存状态。这里的核心问题是过滤在哪个环节做、用什么数据结构做。类目和品牌过滤我用 ZSet 做key 分别是filter:category:{id}和filter:brand:{id}member 是商品 IDscore 统一为 0。和搜索结果 ZSet 求交集时用ZINTERSTORE并指定AGGREGATE MAX这样最终得分保留的是搜索词条里的排序 score不会因为两个 ZSet 的 score 相加而乱序。价格区间过滤有两种处理方式。如果候选结果集不大比如已过滤到几百个商品 ID直接在应用层批量查 Hash按价格字段过滤简单可靠。候选结果集很大时维护一个sort:price:ascZSetscore 就是商品价格先用ZRANGEBYSCORE取出价格区间内的商品 ID再和候选结果求交集。这里的关键是过滤顺序优先做能把候选集大幅缩小的过滤比如热门类目先过滤类目冷门词先过滤词条把最后需要处理的商品数量压到最小。上架状态过滤我单独用一个valid:idsSet 维护所有可售商品 ID。最后一步用SINTER和候选结果求交避免把下架商品返回给用户。这个集合更新要跟商品上架下架消息保持同步这里同步失败会导致搜索结果异常所以要放到和索引更新同一个异步任务里处理。3.3 分页、排序与前缀联想分页直接基于排序后的 ZSet 做ZREVRANGE key offset offsetpage_size-1取出当前页的商品 ID。但深分页是这套方案最明显的短板offset 深了以后Redis 需要跳过大量元素耗时随 offset 线性上涨。我的处理是限制 offset 最大 10000超过直接提示用户“请缩小筛选范围”既保护 Redis 也保护用户体验。排序这块默认综合排序直接用搜索词条的 score价格升序降序用一个独立的sort:price:ascZSet新品排序用sort:new:descZSetscore 是上架时间的秒级时间戳。查询时根据用户选择的排序模式选择对应的 ZSet 取数据。这里我会在返回结果前做一个去重操作因为多词搜索时同一个商品可能命中多个词条但只保留一次。前缀联想是搜索框体验的一部分。Redis 的 ZSet 有个特性当所有 member 的 score 相同时可以用ZRANGEBYLEX按字典序做范围查询。我维护一个prefix_poolZSet把所有搜索词存进去score 统一 0。用户输入“男”的时候执行ZRANGEBYLEX prefix_pool [男 [男\xff就能拿到所有以“男”开头的历史搜索词和商品词再按词频排序取前 10 个返回。这个方案的代价是词量大了以后每次 lex 范围扫描有一定开销所以只对前 1 到 3 个字符做前缀提示太长的前缀直接停止联想性能足够支撑日常搜索框的请求量。3.4 查询性能优化手段查询链路里最容易耗时的环节在“拿到商品 ID 后组装详情”这一段。如果每个商品 ID 单独查一次 Hash20 个商品就要 20 次 RTT网络开销直接拖慢整个请求。这里必须用 pipeline 或批量命令pipe r.pipeline(transactionFalse) for gid in goods_ids: pipe.hmget(fgoods:info:{gid}, title, price, brand, category_id, status) infos pipe.execute()一次网络往返拿到所有商品详情性能提升非常明显。如果对耗时更敏感可以把整个“分词 交集 过滤 取 ID”的逻辑写进 Lua 脚本一次请求提交给 Redis 执行避免多次网络往返。不过 Lua 脚本里面不要做太复杂的聚合控制在几十毫秒内完成否则同样会阻塞 Redis 单线程。另外对热点搜索词的首屏结果我会在应用进程内做一层短缓存比如 5 到 10 秒的过期时间命中就直接返回连 Redis 都不需要打。这一层缓存对高并发尖峰流量的削峰效果显著代价是极小概率的数据短时不一致商品搜索这种场景完全能接受。4. 缓存治理与 Redis 实例的高可用保障4.1 结果缓存与缓存穿透处理搜索结果的缓存 key 设计为search:cache:{query_hash}value 是当前页商品 ID 列表和总数TTL 设置在 30 到 120 秒之间。这个缓存能扛住绝大多数重复搜索请求但要注意两个问题。第一个问题是分页缓存一致性。如果只缓存第一页用户翻到第三页时第一页缓存过期可能导致上下页商品重复。我的做法是把缓存粒度定在“整个搜索条件和排序方式”上TTL 统一较短前几页一起缓存翻页时整体刷新。这样虽然缓存命中率略降但用户体验更稳。第二个问题是缓存穿透。用户搜索一个从来没有商品关联的词比如“哈哈哈哈”索引查不到任何结果。如果不做处理每次请求都穿透缓存打到索引高并发下 Redis 也会被问出压力。处理手段是两个一是把空结果也缓存起来TTL 60 秒避免同一个无效词反复打索引二是在查询前加一个布隆过滤器判断该词是否可能存在于索引中不存在直接返回空。布隆过滤器用 String 位图自己实现就行把词做三次哈希映射到位图误判率控制在 1% 以内内存消耗很小。4.2 缓存击穿与分布式锁缓存击穿发生在缓存过期瞬间一个关键词的请求全部涌到索引层瞬间产生大量重复查询。这时候需要一个分布式锁让只有一个请求去重建缓存其他请求等待或直接读旧数据。锁的实现很简单用 Redis 的SET key value NX EX命令lock_key flock:search:{query_hash} ok r.set(lock_key, token, nxTrue, ex3) if ok: try: data search_from_index(query) r.setex(cache_key, 60, data) finally: # 校验 token 后删除避免误删别人的锁 lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end r.eval(lua, 1, lock_key, token) else: time.sleep(0.05) data r.get(cache_key) if not data: # 锁竞争失败且缓存还没重建可以先返回默认搜索结果 pass锁的 TTL 不能太短否则在高延迟场景下锁提前释放多个请求同时重建也不能太长否则持有锁的请求崩溃后锁要等超时才能释放。我一般设置 3 到 5 秒索引构建逻辑控制在 1 秒内完成。释放锁时用 Lua 脚本校验 token防止误删其他请求的锁这个细节在并发量大的时候非常关键。4.3 主从、持久化与独立实例规划既然索引数据可以重算为什么还要重视持久化和高可用因为重建一套完整索引需要从数据库拉全量商品可能耗时几十分钟这段时间内搜索功能是不可用的。所以 RDB 和 AOF 该开还是要开目标是“快速恢复而不是零丢失”。我建议 RDB 每 5 分钟生成一次AOF 配置 everysec恢复时优先用 RDB 缩短启动时间AOF 做增量补齐。主从结构要搭但要注意读写分离的代价。从库可以分担一部分缓存读取和搜索结果读取但主从复制存在延迟刚上架的商品可能在从库上搜不到。我的处理是搜索索引的读取尽量走主库因为索引本身体量不大主库单机足以支撑从库只承担备份和容灾切换职责。如果业务对秒级延迟完全不敏感可以放一部分读流量到从库减轻主库压力。最容易被忽视的一点不要把搜索索引、业务缓存、分布式锁全部塞进同一个 Redis 实例。缓存实例的淘汰策略通常是 LRU 或 LFU一旦内存紧张索引数据可能被当成冷数据淘汰掉搜索功能直接异常。我维护这套系统时单独给搜索索引分了一个实例maxmemory 设成索引预估值的 1.5 倍淘汰策略用 noeviction锁和缓存放到另一个实例上各司其职互不干扰。4.4 数据一致性与索引重建商品信息变更到索引生效本质上是一个异步过程。我推荐的做法是商品变更写入数据库后发送一条变更消息到队列消费者拿到商品 ID 后重新计算所有分词和权重先删旧词条索引再写新词条索引最后更新 Hash 详情。消息失败要支持重试重试超过三次进入死信队列人工处理。整个链路的延迟目标控制在秒级用户在编辑后台改完商品标题两三秒后前端可搜到新词这个体验是可以接受的。全量索引重建适合用双版本方案。比如要重建全量索引先构建一批idx:word:v2:*的新 key全部构建完成后在配置中心把当前版本号从 v1 切到 v2查询层读配置决定访问带哪个版本号的 key。这样避免直接覆盖生产索引时造成的长时间不可用也保留了回滚能力。Redis 的 RENAME 命令虽然可以原子替换单个 key但一个词条一个 key搜索词涉及多个 key没法靠一个命令完成整体切换所以版本号才是稳妥做法。5. 线上踩坑记录与优化实录5.1 大词交集阻塞问题上线后第一次压测发现一个热门词查询时 Redis 整体延迟飙到 300 毫秒其他命令全部排队。排查下来是这个词在倒排索引里有 8 万多个商品和另一个大词用ZINTERSTORE做服务端聚合单条命令执行耗时超过 200 毫秒期间 Redis 单线程被占住其他请求全部卡住。修复方案有两个层次。第一层是把热门词条截断到 5000 个商品以内从源头控制集合大小第二层是超过截断阈值的词直接改用客户端聚合方式把词条按ZRANGEBYSCORE分批取回应用层用哈希集合求交集。这个调整之后最坏情况下的单次查询耗时也控制在 50 毫秒以内。5.2 过滤导致排序错乱另一个引擎级问题加了类目过滤后返回结果排序完全乱了。排查发现ZINTERSTORE默认的聚合方式是 SUM两个 ZSet 的 score 相加后原来排序值 90 的商品加上过滤 ZSet 的 score 0变成了一个奇怪的数。虽然看起来还是倒序但和纯搜索排序结果不一致用户明显感知到“这个商品为什么排前面”。修复方法就是前面提到的过滤维度的 ZSet score 全部置 0ZINTERSTORE时指定AGGREGATE MAX这样最终 score 完全来自搜索词条的排序值。这个坑非常隐蔽如果只看命令手册不注意聚合行为很难想到排序会被过滤操作改掉。5.3 旧词残留与数据漂移有段时间运营反馈某个商品标题已经改成“夏季冰丝短裤”但搜索“春季长裤”还是能搜到它。查了索引数据发现这个商品有两个旧词“春季”和“长裤”的倒排条目残留。原因是商品变更流程里只处理了新词写入没有先取旧词做删除。后来我把变更流程改成先根据数据库里旧标题分词得到旧词列表并删除再基于新标题分词写入索引。同时加了一个离线巡检脚本定期抽样商品重新分词比对索引自动清理异常条目。5.4 缓存穿透风暴处置有一回凌晨流量异常某无良脚本疯狂请求大量不存在的搜索词Redis 的查询量暴涨。由于当时没有布隆过滤器所有无效请求都打到了索引层虽然索引查询本身不算慢但整体负载翻了好几倍。处置分三步第一步把空结果缓存 TTL 调大到 5 分钟立刻把重复无效请求挡掉第二步紧急上线了基于 String 位图的布隆过滤器查询前先判断词是否存在第三步在网关层对单 IP 的搜索 QPS 做了限流。这套组合拳打完Redis 负载回到正常水平。布隆过滤器误判会导致少量无效词穿透到索引层但配合空结果缓存已经足够兜住大部分异常流量。5.5 注意事项速查表场景推荐方案禁忌分词召回不全词库切分 二元切分 英文小写规范化只依赖通用词库不做类目词扩充大词索引膨胀每个词条截断保留前 5000 个商品不设上限导致 bigkey 和阻塞多关键词交集小集合客户端聚合大集合服务端聚合加截断对大集合大规模使用 ZINTERSTORE过滤排序过滤 ZSet score 置 0AGGREGATE MAX使用默认 SUM 导致排序错乱缓存穿透布隆过滤器 空结果缓存不做防护让无效词直接打索引缓存击穿Redis 分布式锁 Lua 原子释放锁不设 TTL 或释放时不校验 token实例规划搜索索引独立实例noeviction和业务缓存混用被 LRU 淘汰索引重建版本号双写切换直接覆盖生产 key 导致长时不可用深分页限制 offset 上限放任深 offset 拖垮性能商品变更异步队列 先删旧词再写新词只写新词不删旧词导致数据漂移我在实际维护这套搜索模块的过程中最大的体会是Redis 搜索方案拼的不是 Redis 本身而是对商品数据特征的把握。分词、权重、过滤维度这些业务细节拆得越清楚索引结构就越简单性能自然也就稳了。最后再分享一个小技巧排查搜索排序问题时直接在客户端里对某个词执行ZREVRANGE idx:word:{词} 0 20 WITHSCORES看 score 的分布就能判断权重公式有没有生效。如果某个类目的商品 score 出现明显断层调整归一化区间通常比改索引结构更省力。这套方案如果后续商品量真涨到了千万级再做分片或者迁移到专业检索引擎索引结构也能平滑过度不会白做。