用Redis做电商搜索:轻量级架构实战与踩坑总结
做电商搜索很多人第一反应是上 Elasticsearch。这没错大规模商品检索、中文分词、相关性排序它都很能打。但如果你手里的商品量只有十几万搜索场景主要是“按类目、品牌、价格区间筛选 标题关键词匹配”为了这个需求就要养一套 ES 集群从机器成本到运维负担都挺不划算的。今天这篇就是想聊聊我在这类“轻量级”需求下怎么用 Redis 把商品搜索扛起来以及整个过程里踩过的坑。先声明一下背景这不是要否定搜索引擎而是针对特定规模、特定复杂度做工程取舍。你会看到完整的选型逻辑、三条可落地的技术路线、数据同步方案、真实压测数据和一堆只能在生产环境里才能撞见的细节问题。适合想给中小体量电商、二手交易、本地生活类项目找个低成本搜索方案的朋友。1. 选型背景把“搜索”工作量拆开看Redis哪些能接哪些接不了1.1 需求盘点我们到底要搜什么我之前接手的某跨平台系统商品数据量在 15 万左右日订单量不算高但用户对搜索响应很敏感。运营提的需求拆开看其实非常朴素关键词搜商品标题和几个关键属性按类目、品牌、价格区间做二次筛选按销量、价格、上架时间排序分页返回。没有同义词、没有纠错、没有“千人千面”的个性化搜索排序更没有“猜你想搜”。这种需求如果交给 ES属于杀鸡用牛刀索引策略要调、分词器要配、排序权重要摸索团队还得有人长期守着集群。我们当时连专职 DBA 都没有更别提 ES 运维了。于是我把问题换了个问法能不能用 Redis 这种已经在用的基础设施接住 90% 的搜索需求这样机器不用新增团队技术栈也不用扩展运维复杂度几乎为零。这个想法听着诱人但真要把方案落地得先把 Redis 能干什么、不能干什么彻底盘清楚。1.2 从数据结构角度评估Redis的搜索能力Redis 能用来做搜索的底子主要有三类Hash存商品详情、属性字段可以按 key 取天然适合存放商品主数据。Set存拥有某个类目标签、品牌、属性值的商品 ID 集合交集/并集/差集操作就是天然的“与/或/非”筛选。ZSet给商品 ID 挂一个可排序的 score价格、销量、时间都可以映射成 score天然支持范围查询和排序。如果只用原生 K/V 能力Redis 能应付的是“多条件属性组合筛选 排序”这类需求但对标题文本搜索比较吃力——你总不能让 Redis 去 scan 全部 key 做字符串匹配那样性能和资源消耗都不可控。Redis Search 模块RediSearch补上了最后这块它内部自己维护倒排索引和分词器可以直接对 Hash 字段做全文检索并且还能把数值范围、Tag 筛选、打分排序这些能力统一在一条命令里。装上这个模块之后Redis 才真正变成一个“轻量搜索引擎”。1.3 什么时候不适合这套方案我见过不少人把 Redis 搜索方案吹上天然后拿去做社区帖子全文搜索结果中文分词效果拉胯召回一塌糊涂。这里要把话说明白如果你的搜索是“全文语义检索”要处理长文本、同义词、近义词、个性化权重别用这个方案规规矩矩用搜索引擎。如果你的数据量到了百万以上且索引字段多、更新频繁内存成本和索引构建开销会很难看需要仔细做容量评估。如果搜索本身就是你产品的核心卖点那就更不该用 Redis 凑合该上重型方案就上。说白了Redis 做搜索是“轻量架构在特定约束下的最优解”不是万能解。认清这个边界后面实施起来才不会变形。我在项目启动前就把这些判断写进了技术方案文档避免团队中途摇摆。2. 三条技术路线拆解Set组合筛选、Sorted Set排序、Redis Search模块选路线之前我习惯先想清楚团队愿意维护多少代码。三种方案从零到一的工作量差异很大我按从简到繁挨个拆。2.1 方案ASet组合筛选简单直接但能力有限商品每有一个属性就维护一个 Set比如category:手机、brand:华为、color:黑色。用户筛“华为、黑色、手机”就是三个 Set 的交集SINTERSTORE tmp:search:result category:手机 brand:华为 color:黑色之后再做分页。Redis 6.2 提供了SINTERCARD可以只返回交集数量用来先算总数很省事。但这个方案有两个很明显的问题文本检索没着落。搜“手机”得预先维护一个“关键词 商品 ID 集合”的映射关键词从哪里来要么靠运营维护词表要么每次入库时做简单的分词处理实现非常粗糙。分页十分尴尬。Set 本身无序要按价格排序还得再拉出商品详情在内存里排序数据量一大就崩。所以方案 A 只适合那种“属性筛选占大头、关键词搜索可以很弱”的场景。我后来更推荐用 Hash Set 组合Hash 存商品详情Set 维护属性索引至少取商品详情不用挨个 GET。2.2 方案BZSet做排序Set做筛选覆盖常见搜索如果排序需求跑不掉就得把 ZSet 用起来。围绕“价格”“销量”“上新时间”分别维护 ZSetscore 就是对应值ZADD price:index 2999 spu:1001 ZADD sales:index 1200 spu:1001 ZADD online:index 1689234567 spu:1001用户搜“手机2000到4000按销量排序”流程是先对属性条件做 Set 交集拿到候选商品集合再用ZINTERSTORE或者ZRANGEBYSCORE限定候选集合在销量索引里的 score 范围最后ZREVRANGE取一页 ID。这个方案能撑住“关键词不复杂、属性筛选 排序”的中等需求。但 ZSet 方案需要自己维护多套索引的一致性——商品改了价格price:index里的 score 要同步改容易漏而且候选集大了之后ZINTERSTORE会产生临时 key要记得清理。我们当时用了一个定时任务专门扫tmp:*前缀的过期 key。2.3 方案CRedis Search模块最接近“搜索引擎”的轻量解这是我在生产环境最终采用的方案。RediSearch 不依赖外部组件直接用模块机制跑在 Redis 进程里可以对 Hash 类型的数据建索引一条命令完成检索、筛选、排序、分页、高亮。建索引的核心命令长这样FT.CREATE idx:product ON HASH PREFIX 1 spu: \ SCHEMA \ name TEXT WEIGHT 5.0 \ category TAG \ brand TAG \ price NUMERIC SORTABLE \ sales NUMERIC SORTABLE \ on_time NUMERIC SORTABLE NOINDEX注意这里对字段做了区分name是 TEXT 类型参与全文检索category、brand是 TAG 类型做精确匹配price、sales是 NUMERIC做范围筛选和排序on_time用NOINDEX只存储不索引避免索引膨胀。用户搜“华为手机 黑色”实际查询是FT.SEARCH idx:product 手机 华为 \ FILTER category 手机 \ FILTER price 2000 4000 \ SORTBY sales DESC \ LIMIT 0 20写代码时也可以直接用语法字符串FT.SEARCH idx:product name:手机 price:[2000 4000] SORTBY sales DESC LIMIT 0 20中文分词方面RediSearch 默认对中文按 unigram 方式切分“华为手机”会被切成“华”“为”“手”“机”四个单字大部分场景下能工作但召回精度一般要做更细的中文分词可以自己挂外部分词器或者退一步在业务侧先把关键词拆好再通过 TAG 字段做精确匹配。我们最终选择的是后一种维护一个“关键词别名”的 TAG 字段把运营整理的“华为手机”“Mate 60”这类短语提前索引。2.4 三条路线的选型对比维度Set组合SetZSetRediSearch文本检索弱弱强数值范围筛选手动手动内置排序不支持ZSet排序内置分页手动手动内置内存开销低中高开发成本中高低配置为主适合规模万级十万级以下十万级~百万级边界我最终的结论很简单只要允许使用 RediSearch 模块优先选方案 C。方案 A 和 B 更适合那种“Redis 版本老旧、没法上模块”的存量环境。如果你要在新项目里从零搭直接看方案 C 就够了。3. 数据同步与一致性从MySQL到Redis的流水线设计搜索索引的数据不能只靠手动刷得有链路把 MySQL或者你自己的主库里的商品变更稳定搬进 Redis。这一步通常比搜索本身更容易出问题。3.1 双写为什么不可靠最直观的做法是在商品写接口里写完 MySQL 再写 Redis。但生产环境里“再写一次 Redis”很容易失败Redis 网络抖动、连接池耗尽、数据没序列化对都会导致两边状态不一致。而且双写逻辑散落在业务代码里改一个字段就要动一大片。我的建议是业务代码只管主库Redis 里的搜索索引统一交给独立的同步链路去维护不要在业务事务里掺和。这样即使同步链路出问题主库数据还是干净的随时可以重建索引。3.2 订阅Binlog的同步方案成熟一点的做法是订阅 MySQL 的 binlog把增删改事件解析出来再回放到 Redis 的 Hash 和索引上。有人会直接部署现成的订阅组件也有人自己写一个小程序监听 binlog 事件。流程大致是MySQL 主库开启 binlog格式设为 row同步程序把自己伪装成从库接收 binlog 事件解析出变更前、变更后的完整行数据组装成 Redis 命令HSET、DEL投递到 Redis。这个方案的好处是业务代码零侵入主从结构天然提供了顺序性和可靠性。缺点是依赖 binlog 配置、同步程序的可用性自己维护需要一点基础设施功底。如果你没有专门团队我建议增量同步先做成“轮询变更表 定时任务”后面有精力再演进到 binlog 方案。3.3 定时任务兜底每天全量重建光靠增量同步总会出现玄学的不一致。我不信任何“应该不会丢”的同步机制。实际做法是每天凌晨跑一次全量重建任务扫描 MySQL 全表重新灌入 Redis 索引再清理一次过期 key。这样即使白天增量同步漏了消息第二天也会被兜回来。全量重建要注意别把线上 Redis 打挂。我的做法是先灌入到临时 key 前缀比如spu_tmp:重建完成后再用 rename 把临时 key 原子替换成线上 key索引同理FT.DROPINDEX 再重新 FT.CREATE。这个“临时写入、原子切换”的思路极大减少了重建对线上查询的影响。因为 Rebuild 期间用户看到的还是旧数据切完瞬间生效不会有半截数据的情况。3.4 更新顺序和失败补偿同步链路上每个环节都可能失败我习惯在同步表里加一个“待同步”状态主库变更时在事务里写一个变更日志表变更前的数据 JSON 或者变更 ID同步程序轮询这个表把变更应用到 Redis如果 Redis 写入失败保留该记录重试队列继续投递超过重试次数的进入死信表人工或自动补偿。这种“业务表 轮询 重试”的做法虽然朴素但是非常可控比直接分布式事务简单得多。对一个商品搜索场景索引延迟几秒钟完全可接受不需要强一致。我甚至专门把搜索索引的更新延迟控制在“用户下一次刷新前生效”这个级别就够了。4. 搜索主链路与降级策略一次查询从接入到返回系统层面的东西理清了再看看一次真实的搜索请求在链路上怎么跑。4.1 请求进来之后的第一层处理用户的搜索词没法直接丢给 Redis先要做清洗去除首尾空格、统一英文大小写过滤掉常见无意义词比如“的”“了”“最新”“热卖”如果业务里要做品类纠偏可以在这里接一个小的映射表。清洗后的词条会生成一个查询结构包含关键词、筛选条件类目、品牌、价格区间、排序字段和分页参数。把这些翻译成 RediSearch 的 FT.SEARCH 语句时小心别让用户输入变成注入串——TAG 字段里的值要做转义避免{}、之类的字符干扰查询语法。我们曾遇到过用户搜“C 教程”带着两个加号直接进查询结果 TAG 匹配直接报语法错误后来在清洗层统一把特殊字符剥掉了。4.2 Redis执行与结果组装FT.SEARCH 有个很方便的特性索引建在 Hash 字段上时返回结果可以直接带上文档的所有字段不需要二次回表。比如我们给每个 spu 开头的 Hash 里存了标题、图片、价格、库存、状态搜索时RETURN 10 name price stock cover status直接返回这些值。但有一个细节值得提醒大字段别全返回。一开始图省事RETURN 不带字段列表结果高清图 URL、详情大文本全塞进响应流量直接翻了几倍。后来改成只返回列表页需要的字段详情页再单独接口查。这一步对带宽和响应体量的优化非常明显。分页方面RediSearch 支持LIMIT offset 20这种常规分页但 offset 越大性能越差这也是搜索引擎的通病。如果列表页要做“下一页”最好是记下上一页最后一个文档的 sort key用游标式翻页不要给用户随便翻到 500 页之后。4.3 可降级链路RediSearch挂了怎么办再稳的 Redis 也有出问题的时候。我们的降级策略分了三级第一级读 Redis Search 索引第二级索引查询超时或连接异常降级到 MySQL 的 LIKE 查询用name LIKE %关键词%配合条件查询对 15 万数据量来说走覆盖索引 限定类目后还能接受第三级MySQL 也扛不住直接返回运营配置的热搜商品列表。这套降级逻辑很朴素但胜在简单可靠。平时我会定期做一次故障演练主动把 RediSearch 进程停掉看看降级路径能不能在 1 秒内切换。第一次演练的时候发现降级开关写反了用户请求全部打到 MySQL慢查询直接飙红还好是演练环境。5. 性能测试与优化细节内存、慢查询、游标分页方案落地之后最关心的就是性能和资源消耗了。这里分享几组我在压测环境里记录的数据和一些调优想法。5.1 压测数据10万商品量级的表现我的压测环境是一台 4C8G 的虚拟机单机 Redis 开了 RediSearch商品量 10 万索引字段按前面说的配置。压测工具模拟 50 并发搜索场景是“关键词 品牌筛选 价格区间 按销量倒序”场景QPSP99延迟内存增量纯关键词搜索约 120008ms约 120MB关键词品牌价格筛选约 800012ms同上关键词筛选排序约 650015ms同上对比原来 MySQLLIKE方案的 P99大约 60ms 到 200ms 浮动提升是很明显的。内存方面10 万商品带全文索引大概多占 120MB对一台业务服务器来说压力不大。值得注意的是 CPU 占用压测时 RediSearch 的 CPU 峰值到了 70%如果线上这台机器还跑着其他业务需要留足余量。5.2 慢查询排查方法Redis 也有慢查询日志SLOWLOG GET能看到超过阈值的命令。调优时我常用这几个动作用FT.INFO idx:product看索引的字段统计、文档数、内存占用确认哪个字段让索引膨胀检查查询里有没有对 TEXT 字段做无谓的*前缀通配这类查询开销大避免一个条件都没有的“全表型”搜索。比如用户清空筛选只翻页那不如直接读一个商品列表缓存不要走索引全文检索。5.3 内存与容量规划容量估算有一个简单公式单个文档的 Hash 大小 × 1.8 左右再加索引字段的倒排表开销。更准确的方法是建索引后用FT.DEBUG和INFO看实际内存再按峰值流量 × 3 的冗余去规划实例规格。实际经验是10 万商品、10 个索引字段给 512MB 内存空间足够如果是 50 万商品建议直接上 2GB。如果内存实在紧张还可以把不参与搜索、只用于展示的大字段拆到另一个 Hash或者干脆不在 Redis 里存详情只在索引里保留商品 ID 和列表页必需字段详情页走缓存/主库。这个取舍在商品量大时特别有效。6. 工程踩坑总结版本、key设计、超大分页方案讲完最后聊聊真正让我在夜里爬起来修过的几个坑。这些细节平常文档里根本不会写但实际工程里能要命。6.1 模块版本与 Redis 版本要匹配RediSearch 是个模块和 Redis 版本、集群模式有兼容要求。我第一次上线就因为 Redis 是某个老版本而模块版本太新直接无法加载。后来规范成Redis 版本固定模块版本跟着 Redis 版本走升级前先在测试环境做全量回归不能随意 upgrade。这个坑尤其隐蔽因为模块加载失败时Redis 主进程可能还能启动只是搜索命令不可用线上会表现为“商品搜索突然全部 404”。6.2 key 设计不要和业务缓存混在一起我把商品搜索索引、商品详情缓存、业务临时数据分了三套 key 前缀。如果在同一个 key 空间里混着存哪天清理过期数据或者做内存淘汰时很容易误伤。比如 Redis 设置了 allkeys-lru 淘汰策略搜索索引被淘汰掉一批用户搜出来的结果就莫名其妙少了一截。建议给搜索索引相关 key 单独规划逻辑库或实例内存不够宁可加机器也不要让无关的缓存驱逐逻辑影响索引完整性。6.3 超大分页是隐形的内存炸弹RediSearch 的 LIMIT 虽然支持很大的 offset但内部会把从 0 到 offsetlimit 的候选都过一遍offset 一页页往后翻CPU 和内存开销是线性涨的。我们线上遇到过用户翻到 200 页之后接口耗时飙到 3 秒的 case。改成前端只允许“加载更多”模式每次携带上次最后一条的排序值作为游标问题立刻消失。6.4 中文搜索的召回率需要业务侧配合RediSearch 默认的分词对中文是 unigram搜“华为手机”时“手”“机”这种单字也能作为词条可能召回一堆“手机壳”而把“华为 Mate 60”排在后面。如果想改善可以在索引里额外加一个“关键词别名”字段运营或算法把“华为手机”“华为 Mate 60”这类短语提前维护成标签搜索时优先匹配别名。这不是 Redis 的锅而是任何轻量搜索引擎做中文都得面对的现实。最后再分享一个小技巧上线后我每天会扫一遍FT.SEARCH的慢查询和命中率把超过 100ms 的查询模板单独拎出来优化而不是等到用户投诉才去查。Redis 做搜索的本质是“拿内存换性能”只要把数据量边界、字段设计、更新链路三个口子管好它能比想象中更能扛。对中小体量的商品搜索来说这套轻量级架构已经稳定运行了挺长时间日常维护量很小。如果你的项目也卡在“不想上 ES 又想要搜索”的中间带可以考虑按这套思路试一次。