写点什么

【Redis 实用技巧#0】在 Redis 中,如何优雅地找出 10 万个指定前缀的 key?

作者:艾体宝IT
  • 2025-12-05
    江苏
  • 本文字数:2984 字

    阅读完需:约 10 分钟

在维护大型 Redis 集群的日常工作中,我们常常会遇到这样的需求:从数亿个 key 中筛选出特定前缀的一批数据。在此我们也给大家分享一个技巧,起源于不久前我们一位客户的监控系统就触发了一个告警,业务线需要紧急导出所有 user:profile: 前缀的 key 进行分析,数量大约在 10 万左右。


面对这样的需求,很多人的第一反应可能是直接使用 KEYS 命令,一条简单的 KEYS user:profile:* 似乎就能搞定。在开发环境中这个命令响应极快,能在毫秒级返回结果。


但要提醒的是,如果在生产环境执行这条命令,后果可能是灾难性的:轻则服务响应缓慢,重则直接导致整个 Redis 实例被监控判定为宕机,随即触发重启。

KEYS 命令为何是生产环境的"核弹"?

Redis 的核心架构是单线程模型,所有命令排队串行执行。


KEYS 命令的执行逻辑是遍历整个 key 空间,​不管你的 key 是 100 万个还是 1 亿个,它都会从头扫到尾​。


更致命的是,这个过程会完全阻塞主线程,期间所有的读写请求、key 过期操作都会被强制暂停。这直接导致了连锁反应:CPU 使用率瞬间飙升,客户端响应时间从毫秒级暴涨至秒级甚至超时。在极端情况下,这种长达数秒的停顿足以让健康检查失败,触发故障转移或实例重启。因此,KEYS 命令在开发环境虽方便,但在生产环境使用无异于引火自焚。


  • 所有请求(读、写、过期)全部卡死

  • CPU 使用率瞬间飙高

  • 响应时间激增

  • 严重时甚至会被监控系统判定为宕机,导致系统重启

Redis 官方的安全替代方案:SCAN 命令

从 2.8 版本开始,Redis 提供了 SCAN 命令作为安全替代。它的核心设计理念是**增量遍历**,即将一次性的全量扫描拆分为多次小规模操作。


基本用法很直观:


SCAN 0 MATCH user:profile:* COUNT 1000
复制代码


这条命令会返回两部分内容:一部分是当前批次匹配的 key,另一部分是一个游标值,用于下一次继续扫描。当游标返回 0 时,意味着整个遍历过程结束。我们可以通过编写一个简单的循环,不断调用 SCAN 直到游标归零,就能安全地获取所有目标 key。


优点


SCAN 命令的优势显而易见。首先它是非阻塞的,每次只扫描一小部分数据就释放主线程,不影响正常业务。其次支持模式匹配和批次大小控制,让我们能灵活调节扫描速率。最重要的是,它允许边扫描边处理结果,不需要等到全部遍历完成。


⚠️ 缺点


当然,SCAN 也有其局限性。由于采用近似随机的哈希槽遍历方式,它无法保证 key 的返回顺序,而且在 Redis 实例写入负载很高时,同一 key 可能会被重复返回,需要**业务层做好去重**。此外,面对超大规模数据集,完整遍历依然需要较长时间,只是不会阻塞业务而已。

实战代码:安全扫描的正确姿势

理论不如实践,下面是一个开箱即用的 Java 示例 👇


import redis.clients.jedis.Jedis;import redis.clients.jedis.ScanParams;import redis.clients.jedis.ScanResult;import java.util.HashSet;import java.util.Set;
public class RedisScanExample { public static void main(String[] args) { // 建立Redis连接 Jedis jedis = new Jedis("127.0.0.1", 6379); String cursor = "0"; Set<String> matchedKeys = new HashSet<>(); // 利用Set自动去重 ScanParams scanParams = new ScanParams().match("user:profile:*").count(10000); do { // 执行单次扫描,每批次上限10000条 ScanResult<String> scanResult = jedis.scan(cursor, scanParams); cursor = scanResult.getCursor(); // 累积扫描结果 matchedKeys.addAll(scanResult.getResult()); // 打印当前进度 System.out.println("已扫描批次,累计找到: " + matchedKeys.size() + " 个key"); // 如需防止CPU过载,可添加休眠控制速率 // try { // Thread.sleep(10); // 休眠10ms // } catch (InterruptedException e) { // Thread.currentThread().interrupt(); // break; // } } while (!cursor.equals("0")); // 游标为"0"时结束 System.out.println("\n✅ 扫描完成,共找到 " + matchedKeys.size() + " 个匹配key"); jedis.close(); }}
复制代码


这段代码的关键在于三个细节:


  • 使用 Set 自动去重避免重复 key 干扰

  • 通过 count 参数灵活控制每批次扫描量

  • 在必要时添加 sleep 控制速率。


实际运行中,你会看到类似这样的输出:


Scanned  batch,  total found:  3200 keys  Scanned  batch,  total found:  7580 keys  ...  ✅  Scan  completed,  found  100000  matching  keys
复制代码


整个扫描过程平顺推进,Redis 实例的 CPU 和响应时间几乎不受影响。

理解 SCAN 的底层原理

要真正用好 SCAN,需要理解它的实现机制。Redis 内部使用哈希表存储所有 key,SCAN 命令本质上是通过游标分段遍历哈希槽。每次调用时,它只扫描哈希表的一小部分,返回该片段中匹配模式的 key。由于哈希表在持续写入时会发生 rehash,游标的视角可能会有轻微漂移,因此 SCAN 被称为"近似一致性遍历",而非某个时间点的完整快照。


这意味着 SCAN 的性能表现与多个因素相关。key 总量越大,遍历所需批次自然越多。count 参数并非精确控制,而是单次调用扫描槽位的上限值,设置过大会导致单次调用变慢。在写入密集型场景下,重复 key 的概率会略有增加。理解这些底层细节,才能在调优时做出正确决策。

从架构层面根治问题

如果你发现自己频繁需要按前缀搜索 key,这其实是数据模型设计需要优化的信号。问题不在于搜索方式,而在于为何需要搜索。以下两种架构方案能从根源上解决问题。


1️⃣ 为特定前缀维护索引集合


第一种方案是为高频前缀维护反向索引。在写入数据时,同步将 key 加入对应的索引集合。例如每次写入 user:profile:1001 时,执行 SADD user:profile:index 1001。后续查询只需执行 SMEMBERS user:profile:index 即可瞬间获取所有 key,复杂度从 O(N)降至 O(1)。这种方案查询性能极佳,天然支持分页(可用 ZSET 实现有序索引)。代价是增加了写入逻辑的复杂度,需要保证索引与主数据的一致性,同时索引本身可能产生脏数据,需要定期巡检校验。


写入数据时,同步维护一个索引集合:


SADD user:profile:index 1001SADD user:profile:index 1002
复制代码


这样以后只需要:


SMEMBERS user:profile:index
复制代码


就能立刻获取所有对应 key,无需全库遍历。


2️⃣ 数据库分片/Partitioning


第二种方案是数据库分片或分区。当某类 key 数量达到千万级以上时,可按业务前缀拆分存储。将不同前缀的 key 分散到独立 Redis 实例,或在集群模式下使用 hash tag(如 user:{profile}:id)确保同类 key 落在同一节点。这样每次扫描的范围被限制在单个分片,效率大幅提升。分片策略的选择需要权衡业务特性和运维成本,但对于超大规模数据集,这是必经之路。

写在最后

Redis"快"的特性人尽皆知,但在生产环境中,这种快是把双刃剑。其让你能轻松应对亿级数据读写,也能让一条不当命令拖垮整个系统。当下次下意识想敲下 KEYS * 时,请先确认自己是在开发环境还是生产环境。


真正的工程成熟度不在于写出最简短的命令,而在于设计出最稳定的系统。理解工具的原理与边界,才能在关键时刻做出正确选择。

发布于: 刚刚阅读数: 3
用户头像

艾体宝IT

关注

还未添加个人签名 2024-10-11 加入

还未添加个人简介

评论

发布
暂无评论
【Redis实用技巧#0】在Redis中,如何优雅地找出10万个指定前缀的key?_Reids_艾体宝IT_InfoQ写作社区