做电商搜索,很多人第一反应是上 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 方案A:Set组合筛选,简单直接但能力有限
商品每有一个属性,就维护一个 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 方案B:ZSet做排序,Set做筛选,覆盖常见搜索
如果排序需求跑不掉,就得把 ZSet 用起来。围绕“价格”“销量”“上新时间”分别维护 ZSet,score 就是对应值:
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 方案C:Redis 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组合 | Set+ZSet | RediSearch |
|---|---|---|---|
| 文本检索 | 弱 | 弱 | 强 |
| 数值范围筛选 | 手动 | 手动 | 内置 |
| 排序 | 不支持 | 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 并发,搜索场景是“关键词 + 品牌筛选 + 价格区间 + 按销量倒序”:
| 场景 | QPS | P99延迟 | 内存增量 |
|---|---|---|---|
| 纯关键词搜索 | 约 12000 | 8ms | 约 120MB |
| 关键词+品牌+价格筛选 | 约 8000 | 12ms | 同上 |
| 关键词+筛选+排序 | 约 6500 | 15ms | 同上 |
对比原来 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 到 offset+limit 的候选都过一遍,offset 一页页往后翻,CPU 和内存开销是线性涨的。我们线上遇到过用户翻到 200 页之后接口耗时飙到 3 秒的 case。改成前端只允许“加载更多”模式,每次携带上次最后一条的排序值作为游标,问题立刻消失。
6.4 中文搜索的召回率需要业务侧配合
RediSearch 默认的分词对中文是 unigram,搜“华为手机”时“手”“机”这种单字也能作为词条,可能召回一堆“手机壳”而把“华为 Mate 60”排在后面。如果想改善,可以在索引里额外加一个“关键词别名”字段,运营或算法把“华为手机”“华为 Mate 60”这类短语提前维护成标签,搜索时优先匹配别名。这不是 Redis 的锅,而是任何轻量搜索引擎做中文都得面对的现实。
最后再分享一个小技巧:上线后我每天会扫一遍FT.SEARCH的慢查询和命中率,把超过 100ms 的查询模板单独拎出来优化,而不是等到用户投诉才去查。Redis 做搜索的本质是“拿内存换性能”,只要把数据量边界、字段设计、更新链路三个口子管好,它能比想象中更能扛。对中小体量的商品搜索来说,这套轻量级架构已经稳定运行了挺长时间,日常维护量很小。如果你的项目也卡在“不想上 ES 又想要搜索”的中间带,可以考虑按这套思路试一次。