如果你在业务代码里写过KEYS user:*,那你大概体会过那种“上线前好好的,一压测 Redis 就报警”的酸爽。Redis 的模糊查询一直是个很矛盾的话题:需求太常见,官方又不推荐用KEYS直接扫。很多人被问到时第一反应是“用 KEYS 不就完了?”,但真正到线上环境跑过一次百万甚至千万级别的 Key 时,才发现这个命令能把单线程 Redis 卡到客户端集体超时。这篇文章我把 Redis 模糊查询这件事完整过一遍:从命令模型、KEYS和SCAN的原理差异,到怎么用数据结构设计索引来实现字段级模糊搜索,再到一个可以直接参考的用户信息搜索完整实例,最后附上我在生产环境踩过的几个坑。无论你是刚接触 Redis 的新手,还是已经在用但想系统梳理一遍的老手,都应该能从里面找到能直接落地的东西。
1. 先搞清楚 Redis 里的“模糊查询”到底是什么
1.1 Redis 的命令模型决定了查询只能走 Key
Redis 本质上是一个 Key-Value 存储系统,它不像 MySQL 那样有表、有列、有 SQL 解析器。你访问任何数据的第一步都是先拿到 Key,然后通过 Key 去取 Value。这个特性决定了:在 Redis 语境里,模糊查询绝大多数时候指的是“对 Key 做模式匹配”,而不是“对存储内容做条件查询”。
举个例子。你把用户信息存成了user:info:1001这样的 Hash,里面有个字段叫name: "zhangsan"。如果你想“按用户名模糊查”,直接跑KEYS user:info:*是拿不到的——因为这个命令只会匹配 Key 本身,不会去看 Hash 里面某个字段的值。这跟 MySQL 里的WHERE name LIKE '%zhang%'完全是两回事。
很多刚接触 Redis 的人在这里会栽跟头,因为他们潜意识里还在用关系型数据库的思维。Redis 的模糊查询其实只有两种实现路径:
- 第一种,让 Key 本身携带可搜索的信息,这样
KEYS或SCAN就能匹配到。 - 第二种,额外维护一套索引结构(Set、ZSet、Hash 等),先模糊匹配索引,再通过索引拿到真实数据的 Key。
搞懂这个底层模型,后面的所有方案就都顺理成章了。
1.2 Glob 风格匹配符:*?[]的用法
Redis 的KEYS和SCAN系列命令,匹配规则用的是类 Glob 风格,不是正则表达式。常用的符号就这几个:
| 符号 | 含义 | 示例 | 匹配结果 |
|---|---|---|---|
* | 匹配任意多个字符(包括 0 个) | KEYS user:* | 匹配user:1001,也匹配user:name:zhang |
? | 匹配单个字符 | KEYS user:1?? | 匹配user:100、user:123,不匹配user:12 |
[abc] | 匹配方括号中的任意一个字符 | KEYS order:[1,2]* | 匹配order:1001、order:2001开头的 Key |
[a-z] | 匹配指定区间内的一个字符 | KEYS log:[a-d]* | 匹配log:a1、log:d2,不匹配log:e1 |
\ | 转义符 | KEYS user:name\* | 匹配字面量user:name* |
需要注意两个点。第一,匹配是区分大小写的,KEYS User:*不会匹配user:*。第二,*在最前面会导致效率很低,比如KEYS *zhang*,这一点在下一节讲阻塞问题时还会放大。
1.3 最常见的模糊查询场景
从实际业务来看,Redis 模糊查询主要集中在三类场景:
- 清理缓存:按某个固定的业务前缀批量删除 Key,比如
session:*、cache:product:*。 - 后台导出或排查:运营或开发需要根据某个关键词列出相关 Key,人工确认问题。
- 构造小型索引:比如把用户名拼进 Key 或 Set 成员里,实现简单的搜索能力。
无论场景是什么,心里要先有个分类:这个查询是“临时排查”还是“线上高频调用”?这两者的技术选型完全不同。临时排查用KEYS可能问题不大,但线上接口如果敢直接调KEYS,轻则超时重试,重则引发雪崩。下一节我就详细说这个事。
2. KEYS 命令:演示很方便,上生产前先想清楚
2.1 KEYS 的写法和返回结果
KEYS的语法极其简单:
KEYS pattern比如:
KEYS user:info:* KEYS order:2024:* KEYS user:name:zhang*执行后,Redis 会返回一个数组,包含所有匹配的 Key。如果匹配到的 Key 数量很多,返回结果会一次性全部输出,在客户端上可能还要再吃一波内存。在redis-cli里小数据量时很好用,看起来也直观,但它的问题不在语法上,而在执行机制上。
2.2 单线程阻塞:为什么几百万 Key 时它会卡死业务
Redis 的服务端命令执行是单线程的。所谓单线程,是指所有命令在核心执行路径上严格串行排队。KEYS的复杂度是 O(N),其中 N 是当前数据库里的 Key 总数,而不是匹配到的数量。
这个点非常关键。哪怕你的 pattern 是user:info:zhang*,实际能匹配上的 Key 只有 1 个,KEYS也需要把整个 Key 空间从头到尾过一遍才能确定“没有其他匹配项”。在一个有 1000 万个 Key 的实例上跑一次KEYS,意味着 Redis 在命令执行的这几秒内,没办法处理任何其他读写命令。所有请求都会排在这个KEYS后面,然后客户端大量超时,连接堆积,最终把 CPU、内存、网卡全部拖垮。
我见过最典型的线上事故是这样的链路:Redis 出现慢查询,其中一条是KEYS user:*,执行了大概 8 秒。这 8 秒里,正常业务的读写全部排队,Lettuce 客户端开始报RedisCommandTimeoutException,应用层触发重试,重试的请求又继续排队,最后 Redis 的 connected_clients 从几百涨到几千,整个集群进入恶性循环。
2.3 一个可以自己复现的压测脚本
如果你没亲眼看过KEYS的杀伤力,建议在自己的测试环境复现一次。用 Docker 起一个 Redis 实例:
docker run -d --name redis-demo -p 6379:6379 redis:7-alpine然后造一批 Key,比如 10 万个:
(for i in $(seq 1 100000); do printf "SET user:info:$i:user$i value\r\n"; done) | redis-cli --pipe接着用time命令对比一下KEYS和后面要讲的SCAN:
time redis-cli KEYS 'user:info:*:user12*'10 万 Key 时,KEYS可能还不到 100 毫秒,感受不明显。你可以把数量加到 50 万甚至 100 万,然后观察 Redis 的redis-cli --latency输出,你会发现执行KEYS的瞬间,延迟曲线直接拉平到秒级。这个复现成本很低,但带来的感官冲击很值得体验一次——尤其是当你意识到生产环境可能是千万级 Key 的时候。
2.4 什么样的场景才允许用 KEYS
我个人的红线是这样的:
- 本地开发环境或测试环境,数据量很小,用
KEYS没毛病。 - 线上环境,数量明确在几千以内的低频运维操作,可以接受,但要有心理准备。
- 线上环境,千万级 Key、高频接口、批量任务,绝对禁止用
KEYS扫 Master。
另外有人会想说“我在从库上跑KEYS行不行?不影响主库”。不行。从库虽然不直接服务写请求,但命令执行也是单线程的,KEYS同样会阻塞从库的复制和读请求,甚至可能拉大主从延迟,照样出问题。
3. SCAN 游标遍历:生产环境唯一推荐的通配查询方式
3.1 SCAN 的基本语法和游标机制
SCAN是KEYS的“增量遍历”替代方案。核心思路很简单:不一次性扫描全库,而是每次只遍历一部分,通过游标(cursor)记录进度,多次迭代直到遍历完成。
语法:
SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]第一次调用时,cursor 传 0:
SCAN 0 MATCH user:info:* COUNT 200返回结果有两部分:一个是下一次要用的游标值,一个是本次命中的 Key 列表。当返回的游标为 0 时,表示遍历结束。
游标不是页码。它内部对应的是 Redis 哈希表的一个槽位指针,每次调用会从当前位置往后扫若干槽位。所以你不能说“SCAN 到第 2 页”,游标只能从上次返回的值继续往下传。
3.2 MATCH、COUNT 这两个参数最常见的误解
先说 MATCH。SCAN 0 MATCH user:info:* COUNT 200里的 MATCH,并不是先在全局范围内筛选出匹配的 Key 再返回,而是在遍历槽位的过程中,对遇到的每个 Key 做模式匹配,匹配的就放进结果里。这意味着:即使整个库里匹配的 Key 很少,只要 Key 空间很大,你仍然需要遍历很多槽位才能完成一次完整扫描。
再说 COUNT。COUNT 默认值是 10,它代表的是“单次迭代要遍历的哈希桶数量”,不是“返回多少条结果”。很多人以为设置COUNT 1000就一定能返回最多 1000 条 Key,这是一个非常常见的误解。实际上单次返回多少条,取决于这 1000 个桶里有多少 Key 命中了 MATCH 条件。匹配率低的时候,COUNT 1000返回 0 条都很正常。
我整理了一个表,方便对照:
| 参数 | 常见误解 | 正确理解 |
|---|---|---|
| MATCH | 先缩小扫描范围 | 遍历过程中的过滤条件 |
| COUNT | 返回结果条数 | 单次迭代遍历的哈希桶数量 |
| 游标 | 页码 | 内部遍历位置标记,为 0 表示结束 |
| 返回结果 | 完整、无重复 | rehash 期间可能重复,客户端要去重 |
3.3 HSCAN / SSCAN / ZSCAN:对不同结构内成员做模糊匹配
除了对全库 Key 做遍历,Redis 还提供了针对某个具体结构内部的增量扫描命令:
HSCAN key cursor [MATCH pattern] [COUNT count]:遍历 Hash 的 field,返回 field-value 成对出现。SSCAN key cursor [MATCH pattern] [COUNT count]:遍历 Set 的成员。ZSCAN key cursor [MATCH pattern] [COUNT count]:遍历 ZSet 的成员,返回 member-score 成对出现。
举个例子。假设我把用户名索引维护在 Set 里,成员格式是name:id:
SADD user:name:index zhangsan:1001 lisi:1002 wangwu:1003现在想找所有以zhang开头的用户,可以这样:
SSCAN user:name:index 0 MATCH zhang* COUNT 100同样,cursor 返回 0 才算遍历完。这套命令组合在 4.2 节会派上大用场。使用它们的注意点和 SCAN 一样:如果这个 Hash / Set / ZSet 本身很大,比如单成员几百万,那么 HSCAN / SSCAN / ZSCAN 单次也可能比较耗时,但它依然是渐进式的,不会像 KEYS 那样一次性占死 CPU。
3.4 使用 SCAN 时的几个正确习惯
根据我的使用经验,写 SCAN 相关代码时最好固定养成这几个习惯:
- 用 Set 在客户端去重。SCAN 在哈希表 rehash 期间,可能出现重复返回同一个 Key 的情况。
- 循环遍历直到游标为 0,不要只调一次就以为拿全了。
- 遍历过程中 Key 可能被新增或删除,SCAN 不保证快照一致性,这是正常现象。
- 如果是对匹配结果做批量删除,优先用
UNLINK而不是DEL。UNLINK在 4.0+ 是异步删除,可以避免一次性删除大 Key 时让 Redis 卡顿。 - 集群模式下要逐个节点跑,然后合并结果,这个坑我在 6.4 节展开说。
4. 让数据结构为模糊查询服务:键名设计与索引方案
4.1 键名设计:把需要检索的字段拼进 Key
既然 Redis 的模糊查询默认匹配的是 Key 本身,那最简单的思路就是:设计 Key 的时候,把可能需要搜索的字段放进去。
常见的格式是用冒号分层,比如:
user:info:{id}:{name} order:info:{orderId}:{userId}拿第一个来说,如果要按名字模糊查,直接:
SCAN 0 MATCH user:info:*:zhang* COUNT 200这种做法的优点是实时性好,写入时不需要额外维护索引,查的时候直接按前缀过滤。缺点是 Key 会变长,而且如果业务里要改名字,就得同步处理 Key,否则旧 Key 和新 Key 会不一致。
我的建议是:不要把所有字段都往 Key 里塞。Redis 的 Key 越长,占用的内存越多,而且每个层级都用冒号分隔,会在大批量遍历时增加匹配开销。一般只把最高频查询的那一两个字段拼进去,其他字段还是老老实实存在 Hash 里。
4.2 Set/Hash 索引实现字段级模糊查询
如果用户数据已经存在 Hash 里,Key 本身是user:info:1001,Value 里才有name等字段,这种情况用 SCAN 扫业务 Key 是查不到内容的。正确做法是额外建一个索引。
比如维护一个 Set:
SADD user:name:index zhangsan:1001 lisi:1002 wangwu:1003成员格式统一是名字:ID。做模糊查询时:
SSCAN user:name:index 0 MATCH zhang* COUNT 500拿到zhangsan:1001后,解析出 ID 1001,再通过 Pipeline 批量HGETALL user:info:1001,就能拿到完整用户信息。
这套方案的优点是结构简单,写入时多做一次 SADD 而已。缺点是当这个 Set 膨胀到几十万、上百万成员时,SSCAN 虽然不会阻塞 Redis 太久,但整体遍历时间会变长,查询耗时不可控。遇到这种情况,就需要做分桶。
4.3 ZSET 字典序索引与 ZRANGEBYLEX 的前缀匹配
一个比较高级的优化方案是利用 ZSet 的字典序特性。ZSet 的成员如果分数全部相同,Redis 会自动按照成员字符串的字典序排列。在这个前提下,可以用ZRANGEBYLEX按字典序区间来取数据,速度比SSCAN的全量遍历快很多。
假设索引结构是:
ZADD user:name:zset 0 zhangsan:1001 0 lisi:1002 0 wangwu:1003查询所有以zhang开头的成员,可以这样:
ZRANGEBYLEX user:name:zset [zhang (zhang{ LIMIT 0 20这里的小技巧:[zhang代表闭区间起点,(zhang{代表开区间上界。因为(后面是{,而{的 ASCII 码是 123,比所有常规字母和数字的 ASCII 码都大,所以任何以zhang开头的常规字符串,字典序都小于zhang{。这样就能圈出一个准确的前缀区间。
需要提醒一句:这个技巧依赖 ASCII 边界,如果业务数据里包含{、|、}这类高位字符,上界要换成更保守的哨兵字符,或者干脆用 SSCAN 兜底。另外 ZRANGEBYLEX 要求所有成员的分数保持一致,否则字典序排列不成立,返回结果会变得不可预测。
4.4 什么时候应该换掉 Redis 另起炉灶
写到这里我必须泼一盆冷水:Redis 的模糊查询,本质上都是在“全量遍历”框架下的妥协方案。SCAN 只是把一次性的阻塞拆成了多次小步快跑,并没有把 O(N) 变成 O(logN) 或 O(1)。如果业务需要的是高频、复杂条件组合、结果集还要排序分页的“搜索引擎”,Redis 不是合适的载体。
我的选型经验是这样的:
- 简单前缀查询、缓存清理、小规模索引(万级到十万级):用 SCAN / SSCAN / ZRANGEBYLEX 这套。
- 中等规模、查询条件单一但量大(百万级):可以考虑分桶索引 + Redis,但设计和运维成本会明显上升。
- 复杂查询、全文搜索、多条件过滤、大数据量:直接上 MySQL 的 LIKE + 索引,或者 Elasticsearch,不要让 Redis 去干它不擅长的事。
在 Redis 里硬造一个功能残缺的搜索引擎,前期看着爽,后期维护起来会非常痛苦。
5. 一个完整的用户信息模糊搜索实例
5.1 需求与方案选型
这部分我给出一个可以直接抄作业的实例。假设我们有一个用户管理后台,需求是:输入一个关键词,对用户名做前缀模糊匹配,展示匹配用户的信息(ID、姓名、邮箱、年龄),支持分页。
数据结构我设计成三层:
- 用户详情:
user:info:{id},Hash 结构,字段包括name、email、age、createdAt。 - 用户名索引:
user:name:index:{首字母},Set 结构,成员格式是name:id,按用户名首字母分桶。 - 之所以按首字母分桶,是为了避免所有用户名都堆在一个 Set 里。比如
zhangsan:1001分到z桶,lisi:1002分到l桶,这样每个桶的规模大概是总用户数除以 26,遍历效率会高很多。
先说明为什么不直接在业务 Key 上做 SCAN:用户详情 Key 是user:info:1001,里面存的是 Hash 字段,SCAN 匹配不到 Hash 里的name。所以必须依赖单独的索引结构。为什么不直接用一个总的user:name:index:用户量到几十万以后,单个 Set 的 SSCAN 耗时还是偏长,分桶后单桶只有一两万成员,毫秒级就能遍历完。
5.2 写入流程与索引维护
注册一个新用户时,需要同时写入用户详情和索引。为了保证一致性,建议用 Lua 脚本原子执行。
-- KEYS[1] = user:info:{id} -- ARGV[1] = name -- ARGV[2] = email -- ARGV[3] = age -- ARGV[4] = id redis.call('HSET', KEYS[1], 'name', ARGV[1], 'email', ARGV[2], 'age', ARGV[3]) local first = string.lower(string.sub(ARGV[1], 1, 1)) redis.call('SADD', 'user:name:index:' .. first, ARGV[1] .. ':' .. ARGV[4])如果支持用户名修改,需要额外处理:先读出旧名字,从旧首字母桶里 REM 掉旧成员,再往新首字母桶里 SADD 新成员。如果直接用的 Jedis 或 Spring Data Redis,也可以在 Java 代码里用事务包裹这几个操作,但 Lua 更干净,也能避免客户端多次 RTT。
5.3 模糊查询核心代码
查询流程分三步:定位分桶 → 对桶内 Set 做 SSCAN 模糊匹配 → 拿到 ID 列表后批量取 Hash 详情。
我用 Spring Data Redis 写一个可直接参考的 Java 实现:
public List<Map<Object, Object>> searchByUserName(String keyword, int offset, int limit) { if (keyword == null || keyword.isEmpty()) { return Collections.emptyList(); } String first = String.valueOf(Character.toLowerCase(keyword.charAt(0))); String bucketKey = "user:name:index:" + first; ScanOptions options = ScanOptions.scanOptions() .match(keyword + "*") .count(500) .build(); List<Long> ids = new ArrayList<>(); Cursor<byte[]> cursor = stringRedisTemplate.execute( (RedisCallback<Cursor<byte[]>>) connection -> connection.sScan(bucketKey.getBytes(StandardCharsets.UTF_8), options)); while (cursor.hasNext()) { String member = new String(cursor.next(), StandardCharsets.UTF_8); // member 格式 name:id,按最后一个冒号切分拿到 id int idx = member.lastIndexOf(':'); if (idx > 0 && idx < member.length() - 1) { ids.add(Long.parseLong(member.substring(idx + 1))); } } // 候选集去重后做内存分页 List<Long> pageIds = ids.stream().distinct() .skip(offset) .limit(limit) .collect(Collectors.toList()); if (pageIds.isEmpty()) { return Collections.emptyList(); } // 批次取 Hash 详情,用 pipeline 减少 RTT List<Object> details = stringRedisTemplate.executePipelined( (RedisCallback<Object>) connection -> { for (Long id : pageIds) { connection.hGetAll(("user:info:" + id).getBytes(StandardCharsets.UTF_8)); } return null; }); return details.stream() .map(obj -> obj instanceof Map<?, ?> ? (Map<Object, Object>) obj : Map.of()) .collect(Collectors.toList()); }这里有几个细节需要特别解释。
第一,分页方式:SCAN / SSCAN 家族没有“偏移量”的概念,游标是遍历位置,不是过滤后的结果序号。所以最稳妥的办法是先把候选 ID 集合全部拿到内存,再用skip和limit做分页。当候选集不大时(比如前缀zhang*只有几十上百人),这个方案完全够用。如果关键字是a*这种能命中十几万的宽泛前缀,内存分页就会很吃力,这种场景需要考虑换搜索引擎,或者把索引升级成 ZSet 再用 ZRANGEBYLEX 做区间分页。
第二,批量读取:拿到分页 ID 之后千万不要逐条 HGETALL,那会产生大量 RTT。上面用executePipelined,一次网络往返把整页数据全部取回。如果存的是 String 结构,还可以直接用 MGET 一把梭。
5.4 性能分析、分页与批量读取的边界
这套方案在 50 万用户量级下,实测效果很不错。假设用户名均匀分布在 26 个桶里,每个桶约 2 万成员。一次SSCAN user:name:index:z 0 MATCH zhang* COUNT 500,通常几次迭代就能遍历完整个桶,耗时在毫秒级。拿到 ID 后流水线 HGETALL,一页 20 条用户详情,整体接口耗时一般不会超过 10 毫秒。
但边界也要心里有数:如果业务形态偏“后缀模糊”或“包含模糊”,比如用户输入ang要匹配zhangsan,这套方案直接失效,因为分桶的前提是“前缀首字母清晰”。后缀模糊、中间模糊,是 Redis 模糊查询的天敌。遇到这种需求,要么把常见后缀也建索引,要么换 Elasticsearch。
6. 我在生产环境踩过的模糊查询相关的坑
6.1 KEYS 阻塞引发命令超时雪崩
我第一次在线上看到“Redis command timed out”这个异常时,人还是懵的。那是一个运营后台的导出功能,代码里为了按条件捞出所有相关用户 Key,直接写了KEYS user:*。当时线上 Redis 大概 1000 万 Key,这条命令执行了 8 秒左右。8 秒内所有业务读写全部排队,Lettuce 客户端大量超时,服务端堆积的请求又继续压进 Redis,最终 CPU 打满、连接数暴涨。
排查链路是这样的:先看监控确认 Redis 的慢查询,通过SLOWLOG GET看到几条巨大的KEYS记录,然后对照服务端日志的时间点,发现超时异常和慢查询完全吻合。处理方案不复杂:把KEYS改成SCAN,导出任务改成异步分批执行;同时把运营后台的查询接口加了一层 Redis 命令白名单,凡是模糊查询统一走封装好的 Scan 工具类。从那以后我很确定一件事:工具类代码里最容易藏雷,后台功能也要按生产标准来写。
6.2 可视化工具扫 Key 把 Redis 扫卡了
有一次更冤。某个测试同学为了排查问题,用 Redis Desktop Manager 连上了生产环境的 Redis,点了一下“刷新 Key 列表”,瞬间 Redis CPU 飙到 90% 以上。原因就是老版本的可视化工具在刷新 Key 列表时,底层执行的是KEYS *,相当于把全库所有 Key 从头到尾扫了一遍。
现在的工具基本都支持 SCAN 遍历,比如 Another Redis Desktop Manager 在设置里可以选“Use SCAN”之类的选项。但生产环境的安全实践应该是这样的:不允许开发测试人员直接连生产 Redis;连上了也尽量不要用图形化工具去浏览全量 Key;如果确实需要排查,用命令行的 SCAN 模式,或者让 DBA 执行。工具是无辜的,关键是要让使用工具的人知道命令背后发生了什么。
6.3 SCAN 返回结果少于预期与重复 Key
SCAN 有一个非常容易让人困惑的行为:明明库里有 100 个匹配的 Key,你设置COUNT 500,结果第一次调用只返回了几个,甚至返回 0 个。
这不是 Bug,而是 COUNT 的语义问题。COUNT 代表的是扫描的哈希桶数量,不是返回条数。匹配率低时,哪怕翻了 500 个桶,也可能一个匹配项都没有。正确做法是循环迭代,直到游标返回 0 为止。
另外,SCAN 在哈希表 rehash 期间可能返回重复 Key。我见过有同事拿 SCAN 结果直接去删数据,同一个 Key 被删两次,因为第二次会报“no such key”,程序直接抛异常。正确做法是先放到 Set 里去重再处理。这两个问题看起来是大白话,但在开发中踩到的人真的不少。
6.4 集群环境下 SCAN 需要每个节点分别跑
最后说一个集群模式的坑。Redis Cluster 的 Key 分散在多个 slot 和多个节点上,单条SCAN命令只能遍历当前连接到的那个节点。用 Spring Data Redis 的connection.scan(options)在集群下直接调用,你会遇到两种情况:要么只返回了部分节点上的 Key,要么直接报跨 slot 错误。
正确做法是从 ClusterTopology 里拿到所有主节点的连接,对每个 master 分别执行 SCAN,最后在应用层合并结果。类似 JedisCluster 在新版本里提供了封装好的 scan 方法,但如果你用的是 Lettuce 或者 Spring Data Redis,还是老老实实自己遍历节点更可控。我有一个建议:把“集群全量扫描+去重”这段逻辑封装成一个独立的工具类,不要散落在业务代码各处。因为踩过一次坑之后,你会发现这类逻辑每个项目都会用到。
说实话,Redis 模糊查询这件事本身并不复杂,复杂的是你愿不愿意在设计阶段就想清楚:这个查询是给在线用户用的,还是给自己排查用的;是需要实时全量遍历,还是可以接受索引一致性维护成本。把这些边界想明白之后,SCAN、SSCAN、分桶索引、ZSet 字典序这些工具才能各就各位。希望这篇分享能让你少走一趟我走过的弯路。