ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis模糊查询全解析:从KEYS阻塞到SCAN与索引设计实战

Redis模糊查询全解析:从KEYS阻塞到SCAN与索引设计实战 如果你在业务代码里写过KEYS user:*那你大概体会过那种“上线前好好的一压测 Redis 就报警”的酸爽。Redis 的模糊查询一直是个很矛盾的话题需求太常见官方又不推荐用KEYS直接扫。很多人被问到时第一反应是“用 KEYS 不就完了”但真正到线上环境跑过一次百万甚至千万级别的 Key 时才发现这个命令能把单线程 Redis 卡到客户端集体超时。这篇文章我把 Redis 模糊查询这件事完整过一遍从命令模型、KEYS和SCAN的原理差异到怎么用数据结构设计索引来实现字段级模糊搜索再到一个可以直接参考的用户信息搜索完整实例最后附上我在生产环境踩过的几个坑。无论你是刚接触 Redis 的新手还是已经在用但想系统梳理一遍的老手都应该能从里面找到能直接落地的东西。1. 先搞清楚 Redis 里的“模糊查询”到底是什么1.1 Redis 的命令模型决定了查询只能走 KeyRedis 本质上是一个 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和后面要讲的SCANtime 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 传 0SCAN 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:idSADD 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:1001Value 里才有name等字段这种情况用 SCAN 扫业务 Key 是查不到内容的。正确做法是额外建一个索引。比如维护一个 SetSADD 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 ListMapObject, 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(); ListLong ids new ArrayList(); Cursorbyte[] cursor stringRedisTemplate.execute( (RedisCallbackCursorbyte[]) 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))); } } // 候选集去重后做内存分页 ListLong pageIds ids.stream().distinct() .skip(offset) .limit(limit) .collect(Collectors.toList()); if (pageIds.isEmpty()) { return Collections.emptyList(); } // 批次取 Hash 详情用 pipeline 减少 RTT ListObject details stringRedisTemplate.executePipelined( (RedisCallbackObject) connection - { for (Long id : pageIds) { connection.hGetAll((user:info: id).getBytes(StandardCharsets.UTF_8)); } return null; }); return details.stream() .map(obj - obj instanceof Map?, ? ? (MapObject, 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 返回结果少于预期与重复 KeySCAN 有一个非常容易让人困惑的行为明明库里有 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 字典序这些工具才能各就各位。希望这篇分享能让你少走一趟我走过的弯路。
返回列表