打开任何一个稍微像样的网站或 App,搜索框里总会出现“搜索建议”:刚输入red,下面已经弹出 redis、redis cluster、redisson 这些词。这就是 AutoSuggest,也就是自动补全。在“Redis 命令及数据类型”这个系列终于写到第二十七篇的时候,我打算把话题收束到一个非常经典也非常实际的需求上:怎么用 Redis 天然的数据类型和命令,不用引入任何额外组件,就实现一套能抗住真实流量的自动补全服务。
先给结论:如果只是做“前缀联想”,词表规模在几十万量级,那 Redis 的有序集合(ZSET)配合ZRANGEBYLEX这组字典序命令,是性价比最高的方案。这个方案不是新东西,官方文档里也有自动补全的示例,但很多文章只讲了半句话——大家知道ZRANGEBYLEX能按字典序查范围,却不知道为什么成员分值必须相同、\xff那个边界怎么写才不踩坑、遇到中文和大小写怎么办、线上内存会涨到什么程度。这篇文章就把这些坑一个一个填上,顺便给出一套可以直接抄的代码。
1. 从需求说起:AutoSuggest 到底难在哪
很多人觉得自动补全不就是“查前缀”吗?把词表丢数据库里,来一个LIKE 'prefix%'不就完了。实际做一次就会发现,这个需求远没有想象中那么简单。用户每敲一个字符,前端就会发起一次联想请求,也就是说查询频率是“按按键次数”算的。一个稍活跃的搜索框,高峰期一秒钟可能要抗住几百次联想查询,每次都去数据库全表扫一遍,基本不可能活下来。
1.1 用户在搜索框里按下第三个字母的那一刻
可以拆解一下自动补全的真实链路:用户在输入app的瞬间,后端要在一个可能包含几十万词的词表里,把以app开头的所有词捞出来,按某个热度或相关度排好序,再把 top 10 返回给前端。整个过程要求在几十毫秒内完成,不然用户的手指已经敲到下一个字母了,这次查询的结果就是废的。
这意味着三件事必须同时满足。第一,查询必须是索引级的,不能线性扫描整个词表;第二,返回结果要有序,至少得有个稳定的排序规则;第三,数据可以低成本更新,新词入库、旧词下线都不能停机。如果你拿这三点去套常见方案,就会发现普通关系型数据库的LIKE在数据量大时很难吃到前缀索引,全文检索引擎对这种需求又显得笨重,而 Redis 恰好在这三点上都很顺手。
1.2 一个合格的自动补全服务需要满足的条件
我习惯把自动补全的诉求归纳成一个列表,方便做选型判断:
- 前缀匹配:输入任意长度的前缀,能快速召回所有以该前缀开头的候选词。
- 结果排序:候选词不能只按字典序返回,真实场景需要按热度、点击量或人工权重排序。
- 动态更新:新词能即时入库,过期词能批量清理,不能重启服务。
- 低延迟:单次联想请求要在个位数毫秒到几十毫秒内返回,不能被 GC、网络抖动拖垮。
这四个条件听起来简单,放到具体技术选型里就很有意思。SQL 方案在“前缀匹配”和“排序”上需要建组合索引,更新成本高;搜索引擎方案在“低延迟”和“轻量部署”上输得太明显;而 Redis 的 ZSET 用两个键就能把“前缀匹配”和“热度排序”拆开解决,这也是后面几节要展开的内容。
2. 选型拆解:为何是 ZSET,而不是 Set、List 或普通 Hash
标题里写着“数据类型”,那这一节绕不开一个问题:Redis 五种基本数据结构里,为什么偏偏选有序集合来做自动补全?我用一张对比表把几种候选数据结构的天然能力摆出来,大家一眼就能看明白。
| 数据结构 | 前缀检索 | 有序返回 | 插入/更新复杂度 | 适合场景 |
|---|---|---|---|---|
| String | 不支持 | 不支持 | O(1) | 单个词精确查询、缓存 |
| List | 只能线性扫描 | 可维护但插入成本高 | O(N) | 小规模列表、消息队列 |
| Set | 不支持(只能精确命中) | 无序 | O(1) | 标签去重、白名单 |
| Hash | 字段无法按 key 做范围查询 | 不支持 | O(1) | 词到权重的映射 |
| ZSET | 支持字典序范围查询 | 天然按 score/字典序排序 | O(log N) | 排行榜、自动补全、限流窗口 |
这个表的结论很直白:Set 擅长判断“有没有”,却给不了“哪些以某前缀开头”;List 能给到顺序,但做前缀查找只能从头扫到尾;Hash 是一个优秀的“词典”,可它的字段名不支持范围查询。真正同时具备“有序”和“范围查找”能力的,只有 ZSET。
2.1 先看数据类型的天然差异:一张对比表
上面那张表里,最容易被忽略的是更新复杂度这一列。Redis 的 ZSET 底层是跳表加哈希表,插入、删除、查一个成员都是 O(log N) 级别。这个复杂度在数据量到十万、百万时依旧能保持很好的稳定性,不像 List 那样一插入就要做大量元素搬移。
换句话说,ZSET 的存储结构天生就是“为了范围查询和排序而生的”。自动补全需要的前缀范围查询,在 ZSET 里不是一个刻意为之的特例,而是它有序性带来的自然结果。这也是为什么我说自动补全和 ZSET 是绝配。
2.2 有序集合的“平行世界”:score 相同时的字典序
ZSET 的排序规则是“先按 score,再按 member”。这句话是理解整个自动补全方案的钥匙。正常用 ZSET 做排行榜时,每个 member 的分值各不相同,按 score 排序;但还有一种相对冷门的用法,叫字典序视图——当你把一批成员的 score 全部设置为同一个值,比如全部设为 0,那么排序的决胜规则就只剩 member 本身了,ZSET 会按照 member 的字典序进行排序。
更准确地说,Redis 里跳表节点比较元素时,先比 score,score 相同就比字符串本身的字节序。所以当整个集合的 score 都是 0 时,这个 ZSET 表面上是一个“得分都是 0 的排行榜”,实际上已经退化成了一棵高效的字典树。ZRANGEBYLEX这类命令,就是这个“字典序视图”的对外接口。
这也是后面所有设计的前提:一旦有某个成员的 score 变了,字典序就被破坏,ZRANGEBYLEX 的结果就不稳定了。所以严谨的工程做法是“索引与热度分离”,这一点第四节会重点讲。
3. Lex 命令实战:ZRANGEBYLEX、ZLEXCOUNT 与 ZREMRANGEBYLEX
既然打定主意用 ZSET 的字典序视图,那就要把这一组命令吃透。Redis 提供了一套 lex 家族命令,我实际用到的只有三个:ZRANGEBYLEX 负责查询、ZLEXCOUNT 负责统计、ZREMRANGEBYLEX 负责删除。
3.1 ZRANGEBYLEX:范围语法和第一组 redis-cli 演示
先看命令签名:
ZRANGEBYLEX key min max [LIMIT offset count]这里的min和max不是普通的字符串,而是带括号前缀的规则:[表示闭区间,包含边界本身;(表示开区间,不包含边界;-和+分别代表负无穷和正无穷。举个例子,往一个 ZSET 里塞一批英文词:
127.0.0.1:6379> ZADD word:idx 0 apple 0 apply 0 application 0 banana 0 band 0 bank 0 beacon 0 (integer) 8 127.0.0.1:6379> ZRANGEBYLEX word:idx "[app" "[app\xff" 1) "apple" 2) "application" 3) "apply"[app是闭区间下界,[app\xff是闭区间上界。\xff是一个字节层面的最大值(255),任何以app开头的字符串,在字节序上都不会超过app\xff,所以这一条命令的含义就是“取所有以 app 开头的成员”。再试一个前缀:
127.0.0.1:6379> ZRANGEBYLEX word:idx "[ban" "[ban\xff" 1) "banana" 2) "band" 3) "bank"中文场景同理,ZSET 的字典序比较是按照 UTF-8 编码字节来的,所以直接写中文前缀也没问题:
127.0.0.1:6379> ZADD city:idx 0 北京 0 南京 0 北海 0 上海 0 深圳 0 成都 0 (integer) 7 127.0.0.1:6379> ZRANGEBYLEX city:idx "[北" "[北\xff" 1) "北京" 2) "北海"Redis 6.2 之后推出了统一的 ZRANGE 命令,字典序查询可以写成ZRANGE city:idx BYLEX "[北" "[北\xff",语义完全一样。如果线上是 Redis 7.x,我更推荐这种写法,因为命令家族统一了,不用记那么多变体。
3.2 那个让人栽跟头的 \xff 边界技巧
\xff是整个方案里最容易出错的地方,必须单独拿出来说。Redis 内部对字符串的比较是“按字节”的,\xff是单个字节的最大值,任何“以某前缀开头但还没结束”的字符串,它的下一个字节都小于 255,所以用prefix + \xff作为上界一定是稳妥的。这个思路本身没错,错的是客户端编码。
举个最常见的例子:很多 Redis 客户端库默认开了decode_responses=True,也就是把所有返回结果按字符串解码。这时候你在 Python 里写"[app\xff",这个\xff已经被解释成 Unicode 字符 U+00FF 了,发到 Redis 之前再按 UTF-8 编码,U+00FF 被编成了 C3 BF 两个字节,而不是原来的单个 FF 字节。比较结果立刻就不对了,你以为取到的是“以 app 开头的所有词”,实际取到的可能是“以 app 开头且下一个字节小于 C3 BF 的词”——英文小写字母全都能匹配上,但一旦遇到中文或特殊字符,结果就残缺了。
解决办法有两个。第一个是关闭自动解码,所有成员和范围参数都用 bytes 类型传递,这最通用;第二个是偷懒技巧:如果词表已经统一规范化为小写 ASCII,那上界从\xff改成{就完全够用,因为z的 ASCII 码是 122,{是 123,任何全小写字符串都比prefix + {小。我常在内部工具里用后者,省心,但要能接受“只适用于纯 ASCII 词表”的约束。
3.3 ZLEXCOUNT 与 ZREMRANGEBYLEX:统计与清理
查询有了,统计和删除也不能缺。ZLEXCOUNT 用来统计某个字典序区间内的成员数量,比如我想知道词表里有多少个词以 app 开头:
127.0.0.1:6379> ZLEXCOUNT word:idx "[app" "[app\xff" (integer) 3这个命令很适合做搜索建议旁边的“共 N 条结果”提示,也适合写测试用例来验证索引确实更新了。ZREMRANGEBYLEX 则是批量删除利器,比如想让一批旧商品下线,按前缀一次性清掉:
127.0.0.1:6379> ZREMRANGEBYLEX word:idx "[old_" "[old_\xff" (integer) 34这条命令会比遍历删除高效得多,也避免了一个一个跑 ZREM 的网络往返。不过说实话,我平常很少直接用 ZREMRANGEBYLEX 做日常清理,因为词表里“按前缀删”的场景往往伴随业务逻辑校验,更多时候是在数据迁移或测试环境初始化时用。
4. 从命令行到服务:实现一个带热度排序的 AutoSuggest
命令行里把命令跑通只是第一步,真正要上线,还得解决一个非常现实的问题:自动补全不能只按字典序排,得按热度排。用户输入ap,返回结果里apple pie点击率最高,就不能让它在字典序上被application压着。
4.1 设计:两个 ZSET 各司其职
这里必须做一个关键的设计决策。前面提过,ZRANGEBYLEX 要求整个集合的 score 完全一致,排序结果才稳定;而热度排序又要求不同词有不同的 score。一个 ZSET 同时承担两个任务,逻辑上就是打架的。所以正确的做法是拆成两个键:
word:idx:词典索引,所有成员 score 固定为 0,只负责高效率的字典序范围查询。word:freq:热度表,score 表示这个词的热度或命中次数,用 ZINCRBY 实时更新。
查询的时候,先从word:idx里把候选词按前缀捞出来,再批量去word:freq查每个候选词的热度,最后在内存里按热度排序取 top N。这个流程看起来多了一步,但每一步都是 O(log N) 或纯内存计算,实际延迟照样能压到几毫秒。这也就是我常说的“索引与热度分离”,是这套方案里最重要的一条工程经验。
4.2 Python 代码:查询、重排、缓存一把梭
我直接用一段 Python 代码把完整流程写出来,大家照着改改就能用。这里刻意把decode_responses设为 False,原因就是前面说的编码问题。
import redis r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=False) IDX_KEY = "word:idx" FREQ_KEY = "word:freq" def add_term(term: str, weight: int = 1) -> None: key = term.encode("utf-8") # 索引只负责字典序,score 永远为 0 r.zadd(IDX_KEY, {key: 0}) # 热度单独记,不影响索引稳定性 r.zincrby(FREQ_KEY, weight, key) def suggest(prefix: str, limit: int = 8, max_candidates: int = 50) -> list[str]: if not prefix: return [] start = b"[" + prefix.encode("utf-8") end = b"[" + prefix.encode("utf-8") + b"\xff" # 先拉一批候选词。注意不能只拉 limit 个, # 因为字典序结果不等于热度结果,要在内存里重排。 candidates = r.zrangebylex( IDX_KEY, start, end, start=0, num=max_candidates ) if not candidates: return [] # 批量取热度,避免 N 次网络往返 with r.pipeline() as pipe: for word in candidates: pipe.zscore(FREQ_KEY, word) scores = pipe.execute() ranked = sorted( ((word, score if score is not None else 0.0) for word, score in zip(candidates, scores)), key=lambda x: x[1], reverse=True, ) return [word.decode("utf-8") for word, _ in ranked[:limit]]这里有个细节必须强调:max_candidates别设得太小。字典序排前 50 的词,热度排前 10 的也许一个都不在里面,所以通常先取 50 到 100 个候选,再按热度裁剪到最终要返回的 8 个或 10 个。这个参数和limit的差距,就是“字典序”和“热度序”之间需要缓冲的空间。
4.3 为什么还要加一层前缀缓存
很多人走到上面那一步就觉得完事了,但真实线上场景还差一层缓存。用户输入前缀是有明显热点的,同一时刻大家都在敲ap、bo、sh这种常见前缀,如果每次都全量走一遍“取候选词 + 批量查热度 + 内存重排”,虽然已经不慢,但重复计算还是浪费。
我会在建议服务前面加一个短 TTL 缓存,比如 60 秒。同一个前缀 60 秒内的结果直接复用,等于是把最高频的那部分查询变成了一次 O(1) 读取:
def suggest_cached(prefix: str, limit: int = 8, max_candidates: int = 50) -> list[str]: cache_key = f"suggest:cache:{prefix}".encode("utf-8") cached = r.get(cache_key) if cached is not None: return cached.decode("utf-8").split("|") words = suggest(prefix, limit, max_candidates) r.set(cache_key, "|".join(words).encode("utf-8"), ex=60) return words注意,这个缓存也会引入“热度排序更新不及时”的问题,所以 TTL 不能太长。我自己一般压到 30 到 60 秒:30 秒内词频变化可以接受,又足够把高并发查询挡住大半。
5. 生产环境要操心的几件事:编码、内存与清理
方案跑通了,代码也能返回结果了,但放进生产环境之前,还有几件不那么显眼却非常致命的事情要处理。我自己的几次线上问题,几乎全栽在这些细节上。
5.1 大小写规范化与多字节编码问题
Redis 的字典序比较是区分大小写的,Apple和apple会被当成两个完全不同的成员。如果词表里有大小写混写,用户输入app搜不到Apple,输入App又搜不到apple,体验就很糟糕。
常见的做法是写入和查询时统一规范化:全部转成小写或者全部转成首字母大写,词表在写入前先做一次 lower 和 trim,查询前缀时做同样的处理。但这里有个连带问题——如果索引里存的是小写词,展示给用户时也得显示原始写法。解决办法是在word:idx里存规范化后的 key,再用一个 Hash 做“key 到展示文案”的映射,查询 top N 之后去 Hash 里批量取展示名。
另外提醒一句:如果你要支持拼音首字母补全,那索引里就不能只存原始字符串了。中文按字符前缀查没问题,因为 UTF-8 编码下字节序和 Unicode 码点序一致,但拼音补全需要单独的拼音映射表,那就超出纯 lex 方案的范畴了。
5.2 内存估算:一个 ZSET 到底吃多少内存
自动补全是在拿内存换速度,所以内存估算一定要心里有数。基于我实际压测的经验,ZSET 里的每个短字符串成员,在不考虑 Redis 内存碎片的情况下,大约占用 70 到 120 字节。这里面包含字符串本身、跳表节点、哈希表条目等开销。
按这个量级换算:10 万条词,索引表大约 10 到 15 MB;100 万条词,大约 100 到 150 MB。如果你还维护了一个同样规模的word:freq热度表,那整体内存就要再乘以 2 左右。也就是说,200 万条词的完整自动补全方案,大概要吃掉 300 到 400 MB 内存。这个数字在 Redis 里不算夸张,但如果词表继续膨胀,超过单机内存容量,就得考虑分片或换更重的方案了。
5.3 热度衰减与新词入库
热度排序不能只靠累计点击数。一个月前很火的词,现在可能已经没人搜了,但它的累计热度仍然很高,结果就会一直霸占联想第一位。所以热度要做衰减。
最简单的做法是写一个定时任务,每隔一段时间把word:freq所有 score 乘一个小于 1 的系数。这个词表小的时候没问题,词表大了 O(N) 的扫描和写操作成本就不容忽视。更优雅的做法是“懒惰衰减”:每个词除了存热度分值,再存一个最后更新时间,查询时按时间差计算有效热度。这样既不需要定时全量刷新,也能让新数据实时参与排序。
新词入库也要特别注意:一个词如果只写了word:freq而忘了写word:idx,那它永远不会出现在联想结果里,因为前缀查询根本不经过热度表。我把这两步写进一个add_term函数里就是为了避免这种不一致。
5.4 什么时候该换更重的方案
前面讲了这么多,也得划清边界。ZSET 字典序方案最适合的场景:词表几十万量级、查询模式是“纯前缀开头”、排序依据简单(点击数或人工权重)、不要求拼写纠错、不要求同义词扩展。
一旦需求跨过这条线,比如需要模糊匹配、拼音转换、全文检索、多字段加权排序,纯 ZSET 就撑不住了。这时候可以看模块化的搜索功能,或者专门的全文本检索系统。但我的态度是:不要一开始就上重方案。80% 的搜索建议需求,用 Redis 两个 ZSET 就能解决,还顺手把缓存层、存储层都省了。等业务真的长大到需要更重的引擎时,再迁移也不迟。
最后分享一点我自己的体会:我最早接这类需求时,也是在命令行里照着文档敲 ZRANGEBYLEX,自认为跑通了就很高兴,真正上线才被decode_responses和\xff编码坑了一整晚。后来把“索引与热度分离”想明白,整个服务的结构一下子就清晰了。如果你正在给搜索框做补全,先用这套 ZSET 方案顶一阵子,你会发现省下的不止是一个组件的运维成本,还有大量排查编码和排序问题的精力。