1. 从一次线上卡顿说起为什么海量 key 遍历不能直接用 keys线上有个需求要把 Redis 里某个业务前缀下的所有 key 捞出来做一次数据核对。第一版代码写得很直接redisTemplate.keys(order:detail:*)本地测试几十个 key 秒回结果预发环境一跑接口直接超时Redis 监控里那条命令耗时飙到几百毫秒把同实例上其他业务的请求也拖慢了。问题就出在keys这个命令上。它的时间复杂度是 O(n)n 是当前库里的 key 总数而且它是单线程阻塞执行的——Redis 处理命令是单线程模型keys在遍历期间会把整个实例卡住别的命令全得排队。key 少的时候看不出来key 一多就是灾难。scan就是为这个场景设计的。它把一次全量遍历拆成多次小批次扫描每次只返回一部分 key 和一个游标你拿着游标继续下一批直到游标归零表示扫完。这样单次命令的执行时间被压得很短不会长时间占用 Redis 主线程。但scan不是银弹它有几个必须搞清楚的点否则代码照样写错。这篇就围绕 Spring Data Redis 里的ScanOptions和ScanCursor把参数含义、游标推进逻辑、去重、以及和 pipeline 的冲突讲透最后给一份能直接跑的骨架代码顺带把 TaoToken 的 Key/API 通道配置也串进来方便你在本地一次验证跑通。适合谁看正在用 Spring 做 Redis 海量 key 遍历、被keys坑过、或者scan写出来结果不对的同学。读完你能自己判断 count 设多少、游标什么时候算结束、结果为什么会有重复。2. 前置准备TaoToken 统一 Key/API 通道配置在写扫描代码之前先把调用通道理顺。我习惯把模型调用和 Redis 这类基础设施的接入信息统一收口避免每个项目里散落一堆 Key。TaoToken 提供统一的 API 通道官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。这里要说明一下TaoToken 在这篇里的角色是「统一 Key/API 通道」也就是你项目里需要调外部能力时走同一个入口、同一套 Key 管理而不是每个服务各配各的。它不替代你的编辑器也不替代 Redis 本身只是把接入配置集中起来。配置放在settings.json里骨架长这样{ taotoken: { apiBase: https://taotoken.net/api, apiKey: sk-your-key-here, defaultModel: claude-sonnet, timeoutMs: 30000 }, redis: { host: 127.0.0.1, port: 6379, database: 0, scan: { match: order:detail:*, count: 1000 } } }几个字段说明一下。apiBase固定指向 API 入口不要带 UTM 参数那是给官网链接用的。apiKey换成你自己在控制台生成的 Key生成入口在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。scan.match和scan.count就是后面要重点讲的ScanOptions两个参数先在这里占位代码里读出来用。注意apiKey不要硬编码进业务代码提交到仓库放配置文件或者环境变量里settings.json记得加进.gitignore。如果你只是想先验证模型通道通不通可以直接在模型对话页试一条请求https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。长期做编码和 Agent 场景的可以看 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。3. ScanOptions 的 match 与 count参数到底怎么设ScanOptions是 Spring Data Redis 对scan命令参数的封装核心就两个match和count。match是匹配规则对应 Redis 的 glob 模式比如order:detail:*。它是在 Redis 服务端做过滤的不是把所有 key 拉回来再在客户端筛。这点很重要——如果你不加 match扫的就是整个库的 key数据量大时网络传输和客户端内存都会吃不消。count是最容易被误解的参数。很多人以为它是「一次扫描返回的结果数量」其实不是。count是每次向 Redis 请求时底层建议扫描的槽位数量返回的 key 数量可能比它多也可能比它少。Redis 默认值是 10这个值太小了意味着你要来回很多次才能扫完每次往返都有网络开销。我实测下来count 设太小比如默认的 10时扫 10 万个 key 可能要几千次往返光网络延迟就够呛设太大比如 10 万又失去了分批的意义单次命令执行时间变长可能重新引入阻塞风险。经验值大概在 1000 到 10000 之间具体看你的 key 总量和 Redis 负载。key 总量在百万级、实例比较空闲的可以往 5000 甚至 10000 靠实例负载高、对延迟敏感的往 1000 靠。代码里这样构造ScanOptions options ScanOptions.scanOptions() .match(order:detail:*) .count(1000) .build();这里有个坑要提醒count只是「建议值」Redis 不保证每次返回正好这么多。所以你的代码逻辑不能依赖「返回数量等于 count」这个假设必须靠游标来判断是否结束。另外scan命令本身不保证返回的 key 不重复。原因和 Redis 底层哈希表的扩容/缩容有关——在 rehash 过程中同一个 key 可能在不同的批次里被扫到两次。所以拿到结果后必须去重通常用Set收集。4. ScanCursor 游标推进hasNext 为什么不能只看一次ScanCursor是 Spring Data Redis 对 scan 返回结果的封装里面有两个关键东西cursorId和CursorState。cursorId是游标 ID每次 scan 返回一个新的。当它小于等于 0 时表示扫描结束。CursorState有两个状态OPEN和FINISHED。FINISHED表示整个扫描完成。最容易写错的地方在这里ScanCursor的hasNext()被重写过它不是简单判断当前批次还有没有元素。看编译后的源码逻辑public boolean hasNext() { this.assertCursorIsOpen(); while (!this.delegate.hasNext() !ScanCursor.CursorState.FINISHED.equals(this.state)) { this.scan(this.cursorId); } if (this.delegate.hasNext()) { return true; } else { return this.cursorId 0L; } }翻译成人话当当前批次的迭代器delegate没有下一个元素了但状态还不是FINISHED它会自动再发起一次 scan拿下一批结果更新cursorId、state和delegate。所以你在外层用while (cursor.hasNext())遍历时框架已经帮你处理了跨批次的推进你不需要手动调 scan。但这里有个认知误区当前批次遍历完不代表整个扫描结束。必须等CursorState变成FINISHED或者cursorId 0才算真正扫完。如果你自己手动管理游标一定要循环到cursorId 0为止。正确的遍历写法SetString allKeys new HashSet(); try (CursorString cursor redisTemplate.scan(options)) { while (cursor.hasNext()) { String key cursor.next(); allKeys.add(key); // 用 Set 去重 } }用 try-with-resources 包住Cursor确保连接释放。Set去重是必须的别省这一步。5. 可复制配置完整扫描骨架代码把前面的东西拼起来给一份能直接跑的骨架。假设你用 Spring Boot Spring Data Redis。先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency配置类里定义 RedisTemplate 和读取 settings.json 的配置Configuration public class RedisScanConfig { Value(${redis.scan.match:order:detail:*}) private String scanMatch; Value(${redis.scan.count:1000}) private long scanCount; Bean public ScanOptions scanOptions() { return ScanOptions.scanOptions() .match(scanMatch) .count(scanCount) .build(); } }扫描服务Service public class RedisScanService { private final RedisTemplateString, String redisTemplate; private final ScanOptions scanOptions; public RedisScanService(RedisTemplateString, String redisTemplate, ScanOptions scanOptions) { this.redisTemplate redisTemplate; this.scanOptions scanOptions; } public SetString scanAllKeys() { SetString result new HashSet(); try (CursorString cursor redisTemplate.scan(scanOptions)) { while (cursor.hasNext()) { result.add(cursor.next()); } } return result; } }调用入口RestController public class ScanController { private final RedisScanService scanService; public ScanController(RedisScanService scanService) { this.scanService scanService; } GetMapping(/scan/keys) public MapString, Object scanKeys() { long start System.currentTimeMillis(); SetString keys scanService.scanAllKeys(); long cost System.currentTimeMillis() - start; MapString, Object resp new HashMap(); resp.put(total, keys.size()); resp.put(costMs, cost); return resp; } }application.yml里对应配置redis: host: 127.0.0.1 port: 6379 scan: match: order:detail:* count: 1000这套代码的关键点ScanOptions通过 Bean 注入参数从配置读方便不同环境调整Cursor用 try-with-resources 自动关闭结果用Set去重。6. 验证请求与成功结果本地跑一次启动应用后先往 Redis 里灌点测试数据。用 redis-cli 批量写for i in $(seq 1 5000); do redis-cli set order:detail:$i value-$i /dev/null done然后请求扫描接口curl http://localhost:8080/scan/keys预期返回类似{ total: 5000, costMs: 320 }total应该等于你写入的 key 数量去重后。costMs是总耗时5000 个 key、count 设 1000 的情况下大概几百毫秒量级具体看机器和网络。如果你想验证 count 的影响把count改成 100 再跑一次会发现耗时变长因为往返次数变多了。改成 10000 再跑耗时可能略降但如果 key 总量不大提升不明显。这就是前面说的权衡。验证过程中可以同时开一个 redis-cli 执行monitor观察 scan 命令的调用频率和每次返回情况能直观看到分批效果。7. 本篇常见错排查报错一SCAN cannot be called in pipeline / transaction mode.这个报错很明确scan 不能在 pipeline 或事务模式下调用。如果你在SessionCallback里开了multi或者用了executePipelined里面又调 scan就会报这个。解决办法是把扫描逻辑和其他需要事务/pipeline 的处理分开扫描全部完成后再开启事务做后续操作。报错二扫描结果数量对不上少了或者多了少了通常是 match 模式写错比如order:detail:*写成了order:detail*或者 key 的实际前缀和你想的不一样。用redis-cli --scan --pattern order:detail:*先确认一下实际能扫出多少。多了则可能是没去重同一个 key 被扫到两次用Set收集就能解决。报错三Cursor没关闭导致连接泄漏redisTemplate.scan()返回的Cursor实现了Closeable必须关闭。用 try-with-resources 是最稳的写法。如果手动cursor.close()记得放在 finally 里。报错四count 设太大反而变慢前面提过count 是建议值设太大单次命令执行时间长可能阻塞其他请求。如果发现扫描期间其他 Redis 操作延迟升高把 count 调小。报错五游标一直不结束死循环如果你自己手动管理游标循环条件写成了while (cursorId 0)但忘了在每轮更新cursorId就会死循环。用框架的cursor.hasNext()一般不会出这个问题因为框架内部帮你更新了。手动管理时务必确认每轮都拿到新的 cursorId。8. 后续接入与通道收口扫描代码跑通之后如果你还要把扫描结果送到模型做进一步处理比如让模型分析 key 的分布规律、生成核对报告这时候统一通道就派上用场了。把settings.json里的taotoken.apiBase和apiKey读进你的客户端所有模型调用走同一个入口Key 管理集中在一处换环境只改配置不改代码。需要生成或轮换 Key 的去控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。Key 管理页https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。接入细节看文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。如果你用的是 Claude Code 这类编码工具想把通道配进去参考https://taotoken.net/claude-code-anthropic?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。长期跑编码和 Agent 任务的Coding Plan 在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 。最后留一个我踩过的坑scan 的 count 不要照搬别人的值一定要在自己的数据量和实例负载下实测。我一开始抄了个 10000结果在预发环境把 Redis 延迟拉高了后来降到 1000 才稳。参数这东西别人的经验只是起点自己的监控数据才是依据。