
1. 生产环境里 keys 到底踩了什么坑RedisTemplate里有个方法叫keys写起来特别顺手一行redisTemplate.keys(user_info:*)就能把想要的 key 全捞出来。但如果你真在生产环境这么干运维同学大概率会顺着网线来找你。原因不复杂Redis 处理命令是单线程的keys命令会一次性遍历整个键空间把所有匹配的 key 攒齐了再返回。数据量小的时候你感觉不到一旦 key 数量到了几十万、上百万这条命令就会把主线程占住期间其他请求全部排队等着表现出来就是接口大面积超时、CPU 飙高严重的时候直接触发故障。我见过不少团队在运维规范里直接把keys和flushdb列在一起属于高危命令能禁就禁。那问题来了业务上确实有「按前缀找一批 key」的需求比如清理某个用户的缓存、统计某类业务数据、做数据迁移总不能不让用吧。这时候正确的替代方案就是scan。scan的核心思路是「渐进式遍历」它不一次性返回所有结果而是每次返回一小批 key 和一个游标cursor你拿着这个游标继续下一次查询直到游标回到 0 表示遍历结束。这样每次操作只占用很短的时间片不会长时间阻塞 Redis 主线程。代价是遍历期间如果有 key 被增删结果可能不精确但对于绝大多数「找一批 key 做处理」的场景这个代价完全可以接受。这篇文章聚焦的就是在RedisTemplate里怎么把scan用对、用好。我会先讲清楚keys的阻塞风险然后给出可复制的ScanOptions配置模板和完整的 Helper 实现接着用redis-cli验证游标遍历的完整性最后把常见的报错和坑一个个排掉。整个过程中我会结合 TaoToken 统一 Key/API 通道的接入场景说明在真实项目里怎么把配置和调用串起来。适合正在用 Spring Data Redis、又想把keys换掉的 Java 后端同学。2. TaoToken 前置把 Key 和 API 通道先理顺在动手改代码之前先把「通道」这件事说清楚。很多同学写 Redis 相关代码时Key 是散落在各个业务类里的字符串拼接API 调用地址也是硬编码改一个环境要翻半天。TaoToken 在这里扮演的角色是帮你把 Key 管理和 API 访问通道统一起来让配置集中、可切换、可审计。先说 Key 的规范。Redis 的 key 最好用「业务前缀 分隔符 标识」的结构比如user_info:1001、order_cache:202405。前缀用枚举类或者常量类定义别在业务代码里手写字符串。这样做的好处是当你用scan匹配的时候pattern 直接从前缀常量拼出来不会因为手滑写错一个字符导致扫不到数据。比如定义一个RedisKeyPrefix枚举public enum RedisKeyPrefix { USER_INFO(user_info:), ORDER_CACHE(order_cache:), SESSION_TOKEN(session_token:); private final String prefix; RedisKeyPrefix(String prefix) { this.prefix prefix; } public String getPrefix() { return prefix; } public String build(String suffix) { return this.prefix suffix; } }这样scan的 pattern 就是RedisKeyPrefix.USER_INFO.getPrefix() *清晰又不容易出错。再说 API 通道。TaoToken 提供统一的 API 入口地址是https://taotoken.net/api你可以在控制台里管理访问凭证、查看调用情况。对于需要接入模型对话、编码计划或者 Agent 能力的场景建议先把 API Key 在控制台里生成好再在项目配置里引用。控制台地址是https://taotoken.net/consoleAPI Key 管理在https://taotoken.net/api-keys。如果你用的是 Claude Code 这类工具做辅助开发可以参考https://taotoken.net/claude-code-anthropic的接入说明需要长期跑编码任务或者 Agent 流程的可以看https://taotoken.net/coding-plan。注意把 Key 和 API 通道集中管理不是为了好看而是为了在排查问题时能快速定位是「Key 拼错了」还是「通道配置不对」。后面第五节排错时你会体会到这一点。配置层面Spring Boot 项目里 Redis 的连接信息建议放在application.yml通过环境变量注入敏感值。TaoToken 的 API 凭证同理不要硬编码在代码里。下面是一个配置片段示例路径和字段名按你项目实际情况调整spring: redis: host: ${REDIS_HOST:127.0.0.1} port: ${REDIS_PORT:6379} password: ${REDIS_PASSWORD:} database: 0 timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 taotoken: api-base: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY:}把taotoken.api-key通过环境变量注入本地开发用测试 Key线上用生产 Key切换环境只改环境变量不动代码。这一步做完后面写scan的时候Key 前缀和通道配置都是现成的专注在遍历逻辑本身就行。3. 可复制的 ScanOptions 配置与 Helper 实现这一节是核心直接给可复制的代码。先说ScanOptions的几个关键参数很多人用错就错在这里。ScanOptions.scanOptions()提供三个链式方法match(pattern)设置匹配模式count(n)设置每次返回的提示数量type(type)按数据类型过滤。重点说count。网上有些示例写count(Long.MAX_VALUE)这是典型的误用。count不是「总共返回多少」而是「每次遍历建议返回多少」它只是一个提示值Redis 不保证精确返回这么多。你把它设成Long.MAX_VALUE等于告诉 Redis 一次尽量多返回那就退化成类似keys的行为了失去了渐进式遍历的意义。合理的count值一般在 100 到 1000 之间。太小会导致往返次数多、网络开销大太大又可能单次阻塞偏久。我一般用 500 起步根据实际 key 数量和网络情况微调。下面是一个参数对照表参数作用推荐值说明match匹配 key 的模式user_info:*必须带*否则精确匹配count每次遍历的提示数量100~1000不是总数别设成极大值type按数据类型过滤按需如string、hash可减少无效遍历然后是完整的 Helper 实现。相比网上流传的版本我做了两点改进一是把count设成合理值二是把游标遍历封装成可中断、可回调的形式避免一次性把所有 key 塞进内存。import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; Component public class RedisScanHelper { private static final int DEFAULT_COUNT 500; Autowired private StringRedisTemplate stringRedisTemplate; /** * 渐进式遍历对每个 key 执行 consumer 回调 * 适合 key 数量大、不想一次性加载到内存的场景 */ public void scan(String pattern, ConsumerString consumer) { scan(pattern, DEFAULT_COUNT, consumer); } public void scan(String pattern, int count, ConsumerString consumer) { stringRedisTemplate.execute((RedisConnection connection) - { ScanOptions options ScanOptions.scanOptions() .match(pattern) .count(count) .build(); try (Cursorbyte[] cursor connection.scan(options)) { while (cursor.hasNext()) { String key new String(cursor.next(), StandardCharsets.UTF_8); consumer.accept(key); } } catch (Exception e) { throw new RuntimeException(scan 遍历失败, pattern pattern, e); } return null; }); } /** * 收集符合条件的 key 到 List * 注意key 数量极大时慎用可能占用较多内存 */ public ListString scanToList(String pattern) { ListString keys new ArrayList(); scan(pattern, keys::add); return keys; } }调用的时候pattern 一定要带*。比如你要找user_info:开头的所有 keyListString keys redisScanHelper.scanToList( RedisKeyPrefix.USER_INFO.getPrefix() *);如果你只是想对每个 key 做处理不想攒成 List直接用回调版本redisScanHelper.scan(RedisKeyPrefix.SESSION_TOKEN.getPrefix() *, key - { // 比如逐个删除、逐个统计 stringRedisTemplate.delete(key); });这里有个细节connection.scan返回的Cursor实现了Closeable必须放在 try-with-resources 里否则连接不会及时释放跑几次就可能把连接池耗光。网上那个count(Long.MAX_VALUE)的示例还有个隐患就是它把cursor.forEachRemaining和IOException捕获混在一起异常处理不够清晰。上面这个版本把异常包成运行时异常并带上 pattern排查时一眼能看出是哪个前缀出的问题。提示scan的游标遍历是「弱一致」的。遍历过程中如果 key 被删除可能扫不到如果新增 key可能扫到也可能扫不到。如果你的业务要求强一致需要在应用层加锁或者换用其他方案。4. 用 redis-cli 验证游标遍历完整性代码写完了怎么确认scan真的把该扫的 key 都扫到了最直接的办法是用redis-cli手动跑一遍游标遍历和你的 Java 结果对照。这一节给你完整的操作步骤。先准备测试数据。用redis-cli连上实例批量写入一批带前缀的 keyredis-cli -h 127.0.0.1 -p 6379进入交互后用循环写入 1000 个 keyfor i in $(seq 1 1000); do redis-cli set user_info:$i value_$i; done如果你在交互模式里也可以直接执行eval for i1,1000 do redis.call(set, user_info:..i, v..i) end 0数据准备好后用scan手动遍历。注意scan的语法是SCAN cursor [MATCH pattern] [COUNT count]第一次游标传 0SCAN 0 MATCH user_info:* COUNT 100返回结果类似1) 128 2) 1) user_info:1 2) user_info:2 ...第一个值是下一次要传的游标第二个值是这批返回的 key。你拿着128继续SCAN 128 MATCH user_info:* COUNT 100如此往复直到返回的游标变成0表示遍历结束。为了验证完整性可以把所有返回的 key 收集起来去重后计数看是不是 1000。手动做太累可以用一段 shell 脚本自动跑#!/bin/bash cursor0 total0 while true; do result$(redis-cli SCAN $cursor MATCH user_info:* COUNT 100) cursor$(echo $result | head -1) count$(echo $result | tail -n 2 | wc -l) total$((total count)) if [ $cursor 0 ]; then break fi done echo 总共扫描到 $total 个 key跑完输出应该是 1000 左右。为什么说「左右」因为scan在遍历期间如果有数据变动计数可能略有出入但静态数据下应该精确等于 1000。如果差得很多说明你的count或者 pattern 有问题。再对照 Java 侧的结果。在你的scanToList调用后打印keys.size()和 shell 脚本的结果比对。两边一致说明游标遍历逻辑正确。这一步做完你就有底气把keys从代码里彻底删掉了。注意redis-cli的SCAN命令和RedisTemplate的connection.scan底层是同一套游标机制所以用redis-cli验证是可靠的。但要注意redis-cli默认可能连的是 db0确认你的 Java 配置里 database 也是 0否则扫的不是同一个库。5. 本篇常见错排查401、local proxy failed 与游标异常改代码的过程中报错是少不了的。这一节把几个高频错误拎出来对照真实报错信息给排查思路。错误一NOAUTH Authentication required或 401 类鉴权失败。这个通常不是scan本身的问题而是 Redis 连接没带密码或者 TaoToken 的 API Key 没配对。先检查application.yml里的spring.redis.password是否通过环境变量正确注入。如果是 TaoToken 通道侧的 401去控制台确认 API Key 是否有效、是否过期地址是https://taotoken.net/api-keys。排查顺序是先确认 Redis 密码再确认通道 Key两者都对了还报 401就看是不是 Key 里带了多余空格。错误二local proxy failed或连接超时。这个报错一般出现在网络层说明客户端连不上目标地址。检查spring.redis.host和port是否正确本地能不能telnet通。如果是通过 TaoToken 通道访问 API 时出现确认taotoken.api-base填的是https://taotoken.net/api别多写或少写路径。另外注意连接池配置max-active太小、并发一高就会排队超时适当调大。错误三java.io.IOException: Unexpected end of stream或游标读取中断。这个多半是Cursor没正确关闭或者遍历过程中连接被回收了。确认你的scan代码用了 try-with-resources并且没有在遍历中途手动关闭连接。还有一种情况是count设得太大单次返回数据过多导致读取超时把count降到 500 以下试试。错误四reading choices相关报错。如果你在接入模型对话能力时看到类似error reading choices的提示这通常和 Redis 无关而是 API 响应解析问题。检查请求体格式是否符合文档要求参考https://taotoken.net/doc里的接口说明。模型对话的调试可以在https://taotoken.net/model-chat里先手动验证一遍确认通道通了再写进代码。错误五OAuth相关报错。如果你用的是 Claude Code 之类的工具接入时可能遇到 OAuth 流程问题。参考https://taotoken.net/claude-code-anthropic的说明确认回调地址和凭证配置正确。这类问题一般和 Redis 的scan无关但如果你在同一个项目里既用 Redis 又接模型通道排查时要分清是哪个环节报的错。错误六scan扫不到数据。最常见的原因是 pattern 没带*。scan的match是模式匹配user_info:只能精确匹配这个 keyuser_info:*才能匹配前缀。另外确认 database 选对了db0 和 db1 的数据是隔离的。还有一种情况是 key 真的不存在先用EXISTS user_info:1确认一下。把上面这些对照着排一遍基本能覆盖 90% 的接入问题。剩下的边角情况去https://taotoken.net/doc翻文档或者到控制台看调用日志通常能找到线索。6. 把 scan 用稳从配置到验证走一遍回到最开始的问题keys为什么危险scan为什么是正解。核心就一句话keys一次性遍历整个键空间阻塞主线程scan分批返回、游标推进把大操作拆成小操作。理解了这一点你就知道为什么count不能设成Long.MAX_VALUE为什么Cursor必须关闭为什么遍历结果是弱一致的。实际落地的时候我建议你按这个顺序走一遍先把 Key 前缀用枚举类管起来pattern 从常量拼再把ScanOptions的count设成 500 左右别贪大然后用redis-cli手动跑一遍游标遍历确认能扫全最后把 Java 侧的结果和 shell 脚本的结果对一下两边一致再上线。这套流程走下来keys就可以从你的代码库里彻底消失了。还有个小技巧如果你的scan是为了批量删除别在遍历过程中直接删因为删除会改变键空间可能影响游标推进。稳妥的做法是先扫出来存到 List再分批删。如果 key 实在太多List 也放不下那就用回调版本逐个处理但要注意处理逻辑本身别太慢否则连接占用时间会拉长。TaoToken 在这里的价值是让你的 Key 管理和 API 通道有统一的入口。控制台看调用、API Keys 管凭证、文档查接口出问题时排查路径清晰。需要长期跑编码或 Agent 任务的可以了解下 Coding Plan想先验证模型能力的去模型对话页面手动试一把。把这些通道理顺了Redis 的scan只是你工具箱里的一件趁手工具用对场景、配好参数它就能稳稳地替你扛住那些「找一批 key」的需求。