你在课程平台里输入“Redis 缓存”,真正希望发生的事情很简单:系统立刻找出相关内容,把更值得看的排在前面,而且课程更新后,旧关键词别再阴魂不散地出现在结果里。
麻烦在于,这几个要求背后其实是四件不同的事:召回负责找候选,过滤负责剔除不合格项,排序负责决定先后,索引维护负责让搜索结果和原始数据保持一致。把它们揉成一段遍历代码,数据少时能跑,数据一多就会变成一团不好查也不好修的线。
Redis 提供了两条路线。第一条路线使用原生 SET 和 ZSET,由我们自己维护倒排索引、集合逻辑和业务评分;第二条路线使用 Redis Search,让引擎管理全文索引、中文分词、字段过滤和相关性。它们不是“低配版”和“高配版”的关系,而是适合不同复杂度的工具。

一套能长期运行的搜索应用,必须把建索引、查询、排序、更新、清理和选型连成闭环。
读完这一章,你应该能回答这些具体问题:
我们先从一个小场景开始。课程平台有四篇内容:
如果每次搜索都把四篇正文重新读一遍,当然也能得到答案。可一旦变成四十万篇,代价就不是多写一个循环那么简单了。倒排索引做的事,是把查找方向反过来:不再让每篇文档回答“我有哪些词”,而是让每个词直接记录“哪些文档有我”。

词条映射到文档集合后,组合查询就变成了集合运算。
对于一个租户或一门课程,我们可以使用下面几类键:
search:{course-42}:docs # SET:当前可搜索的全部文档 ID
search:{course-42}:term:redis # SET:包含 redis 的文档 ID
search:{course-42}:term:缓存 # SET:包含“缓存”的文档 ID
search:{course-42}:doc:101 # HASH:文档摘要、版本和时间
search:{course-42}:doc:101:terms # SET:文档 101 上一次写入索引的词
search:{course-42}:rank:quality # ZSET:文档质量分
search:{course-42}:rank:freshness # ZSET:文档新鲜度分
search:{course-42}:tmp:query:<随机标识> # SET/ZSET:有 TTL 的临时查询结果花括号里的 {course-42} 是 Redis Cluster 的哈希标签。同一条 SINTERSTORE、ZINTERSTORE、事务或脚本涉及的键必须位于同一个哈希槽,统一标签能满足这个条件。不过它也会把这门课程的索引集中到一个槽里。若单个租户特别大,这种设计可能造成热点,需要按业务边界继续拆分,或者改用能跨分片协调查询的搜索方案。
原生 Redis 并不会自动替你维护这些二级索引。键名、分词规则、更新顺序、临时键回收和故障修复都属于应用代码的一部分。这个自由度很有用,维护成本也是真实存在的。
集合命令与搜索语义几乎可以一一对应:
SINTER search:{course-42}:term:redis search:{course-42}:term:运维
SUNION search:{course-42}:term:缓存 search:{course-42}:term:持久化
SDIFF search:{course-42}:term:缓存 search:{course-42}:term:击穿这里有一个容易说过头的地方:建立倒排索引并不意味着查询成本“与文档数无关”。集合越大、参与运算的集合越多、结果越多,CPU、内存和网络开销都会增加。SINTERSTORE 的工作量仍受集合基数影响,只是它避免了重新扫描和解析每篇原文。
如果一个词只需要回答“文档是否包含它”,用 SET 最直接。如果还想保存词频、字段命中权重或其他数值,可以把该词的倒排列表建成 ZSET。代价是内存更多,维护逻辑也更复杂。
一个稳妥的起点是:
SET,例如标签、权限、城市、必需技能;ZSET,例如质量分、发布时间、新鲜度;HASH;你可以在下面的实验里直接改变词条和逻辑,观察集合结果怎样变化。
英文句子通常可以先按空格和标点切开,中文却没有天然空格。“Redis缓存击穿排查指南”到底应该得到“Redis、缓存、击穿、排查、指南”,还是“Redis、缓存击穿、排查指南”,取决于词典、算法和业务语境。
旧式教程里经常出现这样的代码:
re.findall(r"[\u4e00-\u9fff]+", text)它找出的不是中文词,而是连续汉字片段。对“缓存击穿排查指南”来说,结果很可能只是一个长字符串。随后代码却展示“缓存”“击穿”“排查”等词,等于输出和算法根本对不上。

正则可以做字符清理,不能代替真正的中文分词器。
不管你选开源分词库、公司内部词法服务,还是 Redis Search 的中文分词,至少要固定下面这份协议:
下面的代码故意不绑定某个中文库,而是把分词器作为显式依赖传入。这样测试环境、离线重建和线上查询能共用同一份接口。
import re
import unicodedata
from collections.abc import Callable, Iterable
LATIN_TOKEN = re.compile(r"[a-z0-9][a-z0-9._+-]*")
def normalize_text(text: str) -> str:
"""统一全角半角、大小写和首尾空白。"""
return unicodedata.normalize("NFKC", text).lower().strip()
def tokenize(
text: str,
cut_chinese: Callable[[str
cut_chinese 可以接任何确定的分词实现。限制词长是为了防止异常输入制造巨大键名;生产环境还应限制正文大小和单篇文档的最大词数。若词本身可能含空格、控制字符或超长内容,可以对规范化后的词做摘要,再用单独的词典 HASH 保存“摘要 → 原词”的映射。
第一次写入一篇新文档,可以用事务把文档、词表和倒排集合一起提交。这里先展示新建逻辑,更新逻辑放到后面专门处理。
from redis import Redis
def term_key(scope: str, term: str) -> str:
return f"search:{{{scope}}}:term:{term}"
def index_new_document(
conn: Redis,
scope: str,
doc_id: str,
title: str,
terms: set[str],
这里把文档自己的词表也保存了。它看起来像重复数据,却是后续更新和删除的关键:没有旧词表,我们就不知道文档从哪些倒排集合中退出,只能重新分词旧正文,或者留下脏索引。
不要给 term:*、docs 或 doc:*:terms 这类长期索引随手设置 TTL。一个热门词集合过期后,原文仍在,搜索却突然召回不到它。TTL 应该属于可重算的临时结果,而不是事实索引。
真实搜索往往不是简单的“两个词求交集”。例如用户想找:
必须命中“缓存”;“击穿”或“穿透”至少命中一个;排除“广告”。
我们可以把它拆成三层:组内 OR、组间 AND、最后 NOT。
(缓存) AND (击穿 OR 穿透) NOT 广告只返回一次结果时,可以直接使用 SINTER、SUNION、SDIFF。如果还要排序、分页、重复读取,先把中间结果写进临时键更方便:
SUNIONSTORE search:{course-42}:tmp:group:a1 \
search:{course-42}:term:击穿 \
search:{course-42}:term:穿透
EXPIRE search:{course-42}:tmp:group:a1 30
SINTERSTORE search:{course-42}:tmp:positive:b2 \
search:{course-42}:term:缓存 \
search:{course-42}:tmp:group:a1
EXPIRE search:{course-42}:tmp:positive:b2 30
SDIFFSTORE search:{course-42}:tmp:query:c3 \
search:{course-42}:tmp:positive:b2 \
search:{course-42}:term:广告
EXPIRE search:{course-42}:tmp:query:c3 30TTL 的作用不是“让 Redis 看起来干净”,而是给失败路径兜底。客户端可能在创建临时集合后断线;如果没有过期时间,这些一次性结果会一直占内存。
很多示例函数同时接受“逻辑词”和“Redis 完整键”,然后在内部又加一遍 idx: 前缀,最后很容易得到 idx:idx:xxx。下面的实现规定:底层函数只接收完整键名,不猜调用者传了什么。
from dataclasses import dataclass
from uuid import uuid4
@dataclass(frozen=True)
class SearchPlan:
# 每个子列表内部是 OR;不同子列表之间是 AND
required_groups: list[list[str]]
excluded_terms: list[str]
def build_boolean_result(
conn,
scope: str,
plan: SearchPlan,
ttl_seconds: int = 30,
这段事务保证命令按顺序连续执行,并且每个临时结果都带 TTL。它不负责解析用户输入。查询语法解析器应该先把原始输入转换成 SearchPlan,并限制词数、组数、通配符和最大候选规模,不能让用户随意拼接 Redis 键名。
当客户端带着旧 result_key 翻页时,先检查它是否还存在。存在时可以按需续期,不存在就重建查询。不要把任意用户提供的键拿来 EXPIRE,应先验证键名前缀和所属用户。
def reuse_result(conn, result_key: str, expected_prefix: str, ttl: int = 30) -> bool:
if not result_key.startswith(expected_prefix):
return False
if not conn.exists(result_key):
return False
conn.expire(result_key, ttl)
return True用户离开结果页时,可以主动 UNLINK 临时键,TTL 只是兜底。UNLINK 把实际内存回收放到后台线程,适合较大的临时集合;是否使用它仍要结合实例版本和运维策略验证。
KEYS * 不是“大 Hash 上的慢命令”,它是扫描当前数据库键空间的命令。搜索接口不要用它找文档,也不要把用户的模糊查询翻译成全库 KEYS。需要渐进式运维扫描时用 SCAN;需要业务搜索时用明确的索引。
集合只回答“谁入围”,不回答“谁排第一”。如果我们把所有候选都交给应用层排序,每一页都要搬运大量 ID。ZSET 更适合保存数值评分和有序结果。

先统一量纲,再组合权重;同分规则也要成为排序协议的一部分。
假设一篇内容有三种信号:
如果相关度范围是 0~5,质量分范围是 0~10000,时间又是十位数时间戳,直接相加时,权重只是摆设。应先把每个信号变换到约定区间,再做加权。
这里的 、、 都是归一化后的分数。归一化规则和权重必须版本化。线上改权重前要用真实查询集离线评估,再逐步放量;本章不会给出一个看起来精确、实际没有数据支持的“万能权重”。
Redis 的 ZUNIONSTORE 可以把普通 SET 当作“每个成员分数都是 1”的输入。于是,三个查询词对应的集合做加和后,分数就是命中词数量:
ZUNIONSTORE search:{course-42}:tmp:q1:relevance 3 \
search:{course-42}:term:redis \
search:{course-42}:term:缓存 \
search:{course-42}:term:性能 \
WEIGHTS 1 1 1 AGGREGATE SUM
EXPIRE search:{course-42}:tmp:q1:relevance 60接着,让布尔候选集合参与交集,但把它的权重设为 0。它只负责过滤,不改变总分:
ZINTERSTORE search:{course-42}:tmp:q1:ranked 4 \
search:{course-42}:tmp:q1:candidates \
search:{course-42}:tmp:q1:relevance \
search:{course-42}:rank:quality \
search:{course-42}:rank:freshness \
WEIGHTS 0 0.5 0.3 0.2 AGGREGATE SUM
EXPIRE search:{course-42}:tmp:q1:ranked 60
ZRANGE search:{course-42}:tmp:q1:ranked 0 19 REV WITHSCORES这里假设三个分数已经处于可比较区间。ZRANGE ... REV 是现代 Redis 中统一的逆序范围写法,也可以继续看到旧代码中的 ZREVRANGE,但新代码没必要再为每种方向维护一套命令名。
SORT 可以按外部键或 HASH 字段给 SET/LIST 排序,适合小规模、单字段的明确场景。但它在查询时读取外部数据并完成排序,候选集合大时成本会跟着上升,也不方便表达多信号评分。
如果排序维度会频繁读取,预先维护 ZSET 通常更直接。如果需要全文相关性、字段权重、短语和排序字段,Redis Search 的索引模型更合适。工具能做,不等于它就是这条路径上最省心的选择。
对临时排名 ZSET 做浅分页很简单:
# 第 1 页,每页 20 条
ZRANGE search:{course-42}:tmp:q1:ranked 0 19 REV WITHSCORES
# 第 2 页
ZRANGE search:{course-42}:tmp:q1:ranked 20 39 REV WITHSCORES只要这个临时 ZSET 不再改动,它就是查询时刻的快照,翻页不会因新内容插入而漂移。代价也很清楚:每个活跃查询都可能复制一份候选排名,占用额外内存。因此 TTL、每用户并发上限和候选集上限必须一起设置。
如果直接对持续变化的公共 ZSET 做 offset 分页,用户翻到下一页前插入一条高分记录,旧记录的排名会整体后移,下一页就可能重复看到一条;删除或降分也可能造成遗漏。
一种原生 ZSET 的游标方案是把“业务分数 + 唯一顺序号”编码为一个可比较的整数,然后基于上次分数继续读。但 ZSET score 是双精度浮点数,只有 到 范围内的整数能精确表达。编码前必须证明分数范围、放大倍数和顺序号不会越界。证明不了,就不要靠小数尾巴偷偷塞 ID。
更常见的选择是:
(score, member),由应用处理同分边界;FT.AGGREGATE ... WITHCURSOR;下面的实验会在翻页中途插入新记录,让你直接观察 offset 和游标边界的区别。
稳定排序至少需要一个确定的平局规则。只按“质量分”排序,而不说明同分时按什么排列,分页结果就不是完整协议。不要依赖客户端碰巧返回的顺序。
新建文档只是不断 SADD。真正容易出事故的是修改:文档 101 原来包含“过期”,现在改成“淘汰”。如果只把新词加进去,“过期”的倒排集合仍会保留 101,用户会搜到一篇正文已经不含该词的内容。

更新不是覆盖一份正文,而是对旧索引做减法、对新索引做加法,再把版本一起推进。
设旧词集合是 ,新词集合是 :
中的词要执行 SREM, 中的词要执行 SADD,交集部分不需要动。这样写入量与“实际变化的词数”相关,而不是每次把所有倒排关系删掉重建。
如果两个编辑请求同时读取旧词表,再各自写入新词表,后提交的请求可能基于过期版本计算差异。下面用 WATCH 对词表做乐观锁;发生并发修改时,事务会失败并重试。
from redis.exceptions import WatchError
def replace_document_index(
conn,
scope: str,
doc_id: str,
title: str,
new_terms: set[str],
updated_at: int,
max_retries: int = 5,
) -> None:
base = f"search:{{{scope}}}"
Redis 事务保证命令连续执行,但不提供关系数据库式回滚。命令参数必须在进入事务前校验好,不能指望中间某条命令报错后自动恢复前面的写入。逻辑复杂、往返过多时,可以用 Redis Function 或参数化 Lua 脚本在服务端原子执行;脚本会阻塞其他命令,所以工作量必须有上限,不能把大规模重建塞进一个脚本。
删除文档时,同样先读取 doc:<id>:terms,从每个倒排集合中 SREM,再删除文档、词表、质量分和新鲜度分。可以继续复用上面的更新流程,把 new_terms 传为空集,然后在同一事务里执行清理。
生产系统还需要三层兜底:
如果原文在关系数据库、索引在 Redis,跨系统无法靠一个 Redis 事务保证原子性。常见做法是数据库事务内写 outbox 事件,再由索引消费者按版本幂等更新;或者接受短暂最终一致,并明确搜索结果允许多旧。
保存“文档 → 词”的正向词表并不多余。倒排索引负责查,正向词表负责改和删;两者合起来,索引才有完整生命周期。
搜索不一定是正文里找词。职位推荐、内容标签、权限筛选、本地服务匹配,本质上都在做“对象包含哪些离散属性”的查询。只要属性可枚举、语义明确,SET/ZSET 往往比全文索引更容易解释。
假设职位 job-7 要求 Python、Redis、Linux:
job:{hire}:skill:python -> {job-2, job-7, job-9}
job:{hire}:skill:redis -> {job-1, job-7}
job:{hire}:skill:linux -> {job-3, job-7, job-8}
job:{hire}:required-count -> job-7 的 score 是 3候选人会 Python 和 Redis。把两个技能 SET 做 ZUNIONSTORE ... AGGREGATE SUM,每个职位的分数就是已命中的必需技能数:
ZUNIONSTORE job:{hire}:tmp:u42:matched 2 \
job:{hire}:skill:python \
job:{hire}:skill:redis \
WEIGHTS 1 1 AGGREGATE SUM
EXPIRE job:{hire}:tmp:u42:matched 60然后用“要求数 − 命中数”得到技能缺口:
ZUNIONSTORE job:{hire}:tmp:u42:gap 2 \
job:{hire}:required-count \
job:{hire}:tmp:u42:matched \
WEIGHTS 1 -1 AGGREGATE SUM
EXPIRE job:{hire}:tmp:u42:gap 60
# 缺口为 0,说明全部必需技能都已满足
ZRANGE job:{hire}:tmp:u42:gap 0 0 BYSCORE这里有一个前提:技能倒排集合只记录“必需技能”。候选人会的额外技能不会凭空增加某个职位的命中数,所以缺口不会变成负数。加分技能应放在另一套索引里,进入排序信号,而不是混进资格判断。
同样命中 2 项技能,对“要求 3 项”的职位是三分之二,对“要求 8 项”的职位只有四分之一。匹配率应按职位自己的要求数计算:
原生 ZSET 的聚合擅长加权求和,不擅长为每个成员做动态除法。数据量不大时,可以批量取回 matched 和 required-count 的分数,在应用层计算比例;若需要在服务端做复杂分组、计算和过滤,Redis Search 的 FT.AGGREGATE 更自然。
位置、合同类型和权限等硬条件仍可先做 SET 交集。最后的排序再组合匹配率、加分技能、新鲜度和业务规则。顺序应当是“先满足资格,再谈得分”,否则一个技能不合格但热度很高的职位可能被错误推到前面。
你可以在下面选择候选人的技能和城市,观察“完全满足”和“部分匹配”两种模式怎样生成不同结果。
技能标签适合精确匹配,不等于能力判断。候选人没填写某个词,可能只是使用了同义表达;真正的人岗匹配还要处理同义词、技能层级、项目经历、年限和公平性,不能让一次集合运算替代完整招聘判断。
手写倒排索引很适合查询模式固定的小系统。可一旦需求变成“标题权重更高、正文中文分词、标签精确过滤、时间范围、短语、高亮、聚合、向量相似度”,继续堆 SET 键和解析器,实际上是在自己维护一个搜索引擎的子集。
Redis Search 能对 HASH 或 JSON 文档建立二级索引,并提供 TEXT、TAG、NUMERIC、GEO、VECTOR 等字段类型。文档命中索引前缀后,字段更新会触发索引维护,应用不再手动为每个词执行 SADD / SREM。
下面的 schema 刻意区分了全文字段、精确标签和数值字段:
FT.CREATE idx:course \
ON HASH \
PREFIX 1 course:doc: \
LANGUAGE chinese \
STOPWORDS 0 \
SCHEMA \
title TEXT WEIGHT 5.0 \
body TEXT \
tags TAG SEPARATOR | \
category TAG \
published_at NUMERIC SORTABLE \
quality NUMERIC SORTABLE这里使用 LANGUAGE chinese,让索引器按中文分词规则处理文本。如果仍按默认英文模式建立中文索引,连续中文会按空白和标点切分,召回表现往往与预期不符。STOPWORDS 0 表示这份索引不使用默认停用词;若业务确实需要中文停用词,应在建索引时显式给出并经过测试。
title 设置了更高权重,因为标题命中通常比正文偶然出现更能表达主题。tags 和 category 使用 TAG,因为它们需要精确匹配,不需要全文分词。只有真正用于排序的数值字段才标记 SORTABLE,因为可排序副本会额外占内存。
FT.SEARCH idx:course \
'(@title:(缓存 优化) | @body:(缓存 优化)) @tags:{redis}' \
WITHSCORES \
RETURN 4 title tags published_at quality \
LIMIT 0 20 \
DIALECT 2查询字符串包含特殊语法。不要把用户输入直接拼进去;应使用客户端提供的转义或查询构造能力,并限制前缀、模糊词和通配符的数量。参数只能放在语法允许的位置,字段名本身不能由用户随意替换。
如果按发布时间排序:
FT.SEARCH idx:course \
'缓存 @category:{数据库}' \
SORTBY published_at DESC \
RETURN 3 title published_at quality \
LIMIT 0 20 \
DIALECT 2LIMIT 没有稳定排序时,连续请求可能出现重复或遗漏。分页接口至少要使用稳定排序字段;大量聚合结果的深翻页可以改用 FT.AGGREGATE ... WITHCURSOR,读完后及时释放游标或让其按空闲超时回收。游标保存服务端状态,所以也要设置数量与空闲时间边界。
换成 Redis Search,不等于索引没有成本:
TEXT 倒排记录、词频、位置偏移都要占内存;SORTABLE 会保存用于低延迟排序的值;可以使用 FT.INFO idx:course 观察文档数、词条数、倒排索引大小、位置向量、可排序值和总索引内存;用 FT.PROFILE 检查真实查询计划。容量结论要来自与你的数据分布和查询组合相近的压测,不能拿别人的单机数字当承诺。
Redis Search 自动维护索引,解决的是“应用不用手写每个倒排成员”的问题。它不会替你决定 schema、中文词典、字段权重、分页协议、内存预算和数据一致性目标。
到这里,闭环还差最后两步:把临时资源收干净,以及承认某些需求已经超出原生结构的舒适区。
建议把每次搜索创建的临时键集中记录,便于主动回收:
search:{course-42}:tmp:<query-id>:group:0
search:{course-42}:tmp:<query-id>:group:1
search:{course-42}:tmp:<query-id>:positive
search:{course-42}:tmp:<query-id>:result
search:{course-42}:tmp:<query-id>:relevance
search:{course-42}:tmp:<query-id>:ranked每个键创建时立即设置 TTL;请求结束后可 UNLINK。还要监控:

先看查询形态、更新成本和内存预算,再决定工具。
原生结构的优势是简单、透明、可针对固定查询精确优化;缺点是索引维护、中文处理、相关性和复杂查询都由应用承担。Redis Search 把这些能力放回搜索引擎,但索引仍在 Redis 的资源模型里,需要严肃计算内存。专业搜索引擎适合更独立的检索集群与复杂分析,也带来额外同步链路和运维成本。
下面的选型台不会替你拍板,它会把需求推向不同方案,并列出上线前必须验证的项目。
先写出真实查询,而不是先挑命令。列清 AND、OR、NOT、排序、分页、权限和更新时效要求,确认哪些条件是硬过滤,哪些只是加分项。
用接近生产分布的数据建立索引。热门词、长尾词、大文档、高频更新和空结果都要覆盖,不能只测十几条平均样本。
分别测读取与写入。记录 P50、P95、P99、超时、候选基数、索引内存、临时键峰值和更新可见延迟,不用单个平均值概括一切。
故意制造失败。让消费者重复事件、乱序事件、连接中断和实例切换,验证版本控制、重试、TTL 和对账能否恢复一致。
一套可靠的 Redis 搜索方案,不是“命令越多越强”。它应该让每个索引都有来源、每个临时结果都会过期、每次更新都能重试、每种排序都能解释,并且知道自己什么时候该把工作交给真正的搜索引擎。
现在回看整个流程:文档先被规范化和分词,词条形成倒排集合;查询计划通过集合运算生成带 TTL 的候选;ZSET 负责可解释的排序和分页;更新时用旧词表、版本与原子提交维护一致;临时资源最终被回收;需求变复杂时,再把工作交给 Redis Search 或更专业的搜索系统。这个闭环比任何单条“神奇命令”都重要。
最后再决定边界。如果为了补齐全文能力已经写出复杂解析器、词典、评分器和分页器,切换到 Redis Search 或专业搜索引擎通常比继续补丁更诚实。