1. 从一次线上卡顿说起:RedisTemplate.scan 到底在干什么
先说结论:RedisTemplate的scan不是一次性把整个库的 key 拉回来,而是游标驱动的分批拉取 + 迭代器代理。你写的那段connection.scan(...)返回的Cursor<byte[]>,本质是org.springframework.data.redis.core.ScanCursor,它把「一次网络请求拿一批」和「Iterator 语义」缝合在了一起。
为什么很多人会误以为它一次性返回全部?因为 API 长得太像keys *了:scan.forEachRemaining(...)一调用,代码里看不到循环,感觉数据是「凭空出现」的。但只要你把count(2)写进去,就会发现一个反直觉的现象——明明库里有一万个 key,count(2)却不会只返回 2 个就结束。这说明count不是「总量」,而是「每次向 Redis 请求的建议条数」。
我试过在一个测试库里塞 5000 个user:session:*的 key,用count(2)去扫,日志里能看到SCAN命令被反复触发,每次返回的游标值都在变,直到游标回到0才停。这个过程就是ScanCursor在背后做「取一批、消费一批、再取下一批」的接力。
它适合谁?三类人最该看懂:一是写过keys *被 DBA 警告过的后端;二是做缓存清理、数据迁移、监控统计时需要遍历 key 的开发者;三是想学「远程分页数据如何优雅封装成迭代器」这种设计模式的人。ScanCursor的写法其实可以直接抄去封装分页 HTTP 接口。
下面我按「连接获取 → 命令封装 → 游标迭代 → 结果反序列化」这条链路,把源码一层层拆开,并给出可复制的配置和验证步骤。中间会用到 TaoToken 的模型对话能力来辅助读源码,但核心还是 Spring Data Redis 本身的机制。
2. 前置准备:环境、依赖与 TaoToken 接入配置
在拆源码之前,先把能跑起来的环境搭好。你需要一个 Spring Boot 项目、一个可用的 Redis 实例,以及能帮你快速解释源码片段的工具。这里我用 TaoToken 的模型对话来做源码问答,它的接入方式和 OpenAI 兼容,配置成本很低。
2.1 依赖与版本确认
ScanCursor位于spring-data-redis中,Spring Boot 2.x 和 3.x 都有,但包路径和底层客户端不同。先确认你的版本:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>Spring Boot 2.x 默认用 Lettuce,3.x 也是 Lettuce。ScanCursor是客户端无关的抽象,真正的doScan由LettuceScanCursor或JedisScanCursor实现。你可以通过mvn dependency:tree | grep spring-data-redis看具体版本。
2.2 TaoToken 接入:Base URL、Key、Model ID 三件套
如果你想让模型帮你逐行解释ScanCursor的hasNext(),可以走 TaoToken 的 API。它的 Base URL 是https://taotoken.net/api,Key 在控制台创建,Model ID 按你选的模型填。三件套缺一不可:
- Base URL:
https://taotoken.net/api - API Key:在 API Keys 页面 生成
- Model ID:例如
claude-sonnet-4-5这类对话模型
用 curl 验证一下连通性,避免后面读源码时工具掉链子:
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-5", "messages": [{"role": "user", "content": "用一句话解释 Redis SCAN 的游标机制"}] }'返回里有choices[0].message.content就说明通了。注意这里不要用任何网络代理工具,直连即可;如果公司网络有限制,走内网出口或让运维放行域名。
2.3 RedisTemplate 配置片段
默认的RedisTemplate用 JDK 序列化,key 会带一堆二进制前缀,scan出来的byte[]转字符串会很难看。建议显式配置 String 序列化:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这段配置决定了后面scan出来的 key 是否可读。如果你用RedisCallback直接拿byte[],序列化器不影响scan本身,但影响你后续对 key 的解析。
2.4 一个可复制的 scan 调用示例
把 excerpt 里的代码整理成可运行版本,注意Cursor用完要close(),否则连接资源不会释放:
@Autowired private RedisTemplate<String, Object> redisTemplate; public List<String> scanAllKeys(String pattern) { List<String> keys = new ArrayList<>(); redisTemplate.execute((RedisCallback<Void>) connection -> { ScanOptions options = ScanOptions.scanOptions() .match(pattern) .count(2) .build(); try (Cursor<byte[]> cursor = connection.scan(options)) { while (cursor.hasNext()) { keys.add(new String(cursor.next(), StandardCharsets.UTF_8)); } } return null; }); return keys; }try-with-resources会调用ScanCursor.close(),把状态置为CLOSED。如果你用forEachRemaining,同样建议包在 try 里。
3. 源码逐层拆解:从 connection.scan 到 ScanCursor.hasNext
这一节是全文核心。我们沿着调用栈往下走,每一步都对应一个真实的方法。
3.1 连接获取:RedisConnection 从哪来
redisTemplate.execute(RedisCallback)内部会调用RedisConnectionUtils.getConnection(factory),从连接工厂拿一个RedisConnection。Lettuce 下它是LettuceConnection,Jedis 下是JedisConnection。这个连接是线程绑定的,同一个线程内多次execute会复用,除非你手动释放。
关键点:scan命令是发在这个连接上的,所以游标迭代期间连接不能关。ScanCursor的close()只改状态,不直接关连接,连接由RedisConnectionUtils.releaseConnection在execute结束时处理。
3.2 命令封装:doScan 如何拼出 SCAN 命令
connection.scan(options)返回DefaultCursor的子类。以 Lettuce 为例,最终走到LettuceConnection.scan:
public Cursor<byte[]> scan(ScanOptions options) { return new LettuceScanCursor(options, this); }LettuceScanCursor继承ScanCursor<byte[]>,实现doScan:
protected ScanIteration<byte[]> doScan(long cursorId, ScanOptions options) { ScanArgs args = ...; // 把 match/count 转成 Lettuce 的 ScanArgs KeyScanCursor<byte[]> scanCursor = connection.scan(args); return new ScanIteration<>(scanCursor.getCursor(), scanCursor.getKeys()); }这里count(2)被翻译成ScanArgs.limit(2),match("*")翻译成ScanArgs.match("*")。Redis 服务端收到的是SCAN <cursor> MATCH * COUNT 2。注意COUNT是提示,Redis 可能返回多于或少于 2 条,源码里没有做数量校验。
3.3 游标迭代:hasNext 里的 while 循环
回到ScanCursor.hasNext(),这是整个机制的心脏:
public boolean hasNext() { assertCursorIsOpen(); while (!delegate.hasNext() && !CursorState.FINISHED.equals(state)) { scan(cursorId); } if (delegate.hasNext()) { return true; } return cursorId > 0; }delegate是当前这批结果的迭代器,初始是Collections.emptyIterator()。第一次调用hasNext()时,delegate.hasNext()为 false,进入scan(cursorId),cursorId初始为 0。scan调doScan发命令,拿到结果后processScanResult把delegate替换成新批次的迭代器。
如果新批次有数据,while条件里delegate.hasNext()变 true,跳出循环返回 true。如果新批次为空但游标没归零,继续scan下一批。直到游标为 0 且delegate也空了,state变FINISHED,返回cursorId > 0即 false。
3.4 结果反序列化:byte[] 到你的对象
connection.scan返回的是Cursor<byte[]>,因为 Redis 协议层就是字节。RedisTemplate的execute不会自动帮你反序列化 key,你需要自己new String(bytes)或用template.getKeySerializer().deserialize(bytes)。
如果你用redisTemplate.scan(options)(Spring Data Redis 2.1+ 提供),它会返回Cursor<K>,内部用keySerializer反序列化。但注意这个 API 在部分版本里对RedisTemplate的泛型有要求,容易踩坑。稳妥做法还是走RedisCallback拿byte[],自己控制解析。
3.5 用 TaoToken 辅助读源码
读ScanCursor时如果对CursorState的状态流转有疑问,可以把类贴给模型问。比如问「READY、OPEN、FINISHED、CLOSED 分别在什么时机切换」,模型会结合open()、close()、processScanResult给你梳理。这比自己翻注释快,但结论要回到源码验证。
4. 验证请求:日志、断点与成功结果
光看源码不够,得让程序跑起来,用日志和断点确认每一步。
4.1 打开 Lettuce 命令日志
在application.yml里加:
logging: level: io.lettuce.core: DEBUG org.springframework.data.redis: DEBUG启动后执行scanAllKeys("user:*"),控制台会打印类似:
SCAN 0 MATCH user:* COUNT 2 SCAN 12345 MATCH user:* COUNT 2 SCAN 67890 MATCH user:* COUNT 2每次SCAN的游标值不同,直到某次返回0。这直接证明了「分批拉取」而非「一次性返回」。
4.2 断点验证 delegate 替换
在ScanCursor.processScanResult的delegate = result.iterator();这行打断点,Debug 模式跑。每次命中时观察:
cursorId从 0 变成新值delegate从Collections$EmptyIterator变成ArrayList$Itrstate在游标归零时变成FINISHED
我实测下来,count(2)时断点会命中多次,每次result.getItems()的 size 不固定,有时 2 有时 3,印证了COUNT只是提示。
4.3 成功结果对照
跑完后打印 key 总数,和redis-cli dbsize对比(注意dbsize包含所有 key,scan只匹配 pattern)。如果 pattern 是*,两者应该一致。如果scan结果少了,检查是否在迭代期间有 key 过期或被删——SCAN不保证快照一致性。
4.4 用模型对话验证理解
把日志和断点观察到的现象整理成问题,发给 TaoToken 的模型对话,比如「为什么 COUNT 2 却返回 3 条」。模型会解释 Redis 的SCAN实现细节,你再回源码对照,理解会更牢。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节对照真实报错,给出排查路径。注意所有排查都不涉及任何网络代理工具。
5.1 401 Unauthorized
调用 TaoToken API 时返回 401,通常是 Key 没带或带错。检查:
echo $TAOTOKEN_API_KEY curl -H "Authorization: Bearer $TAOTOKEN_API_KEY" https://taotoken.net/api/v1/models如果 Key 为空,去 API Keys 重新生成。注意 Header 是Bearer加空格,少空格也会 401。
5.2 local proxy failed
这个报错通常出现在客户端配置了本地代理但代理没启动。TaoToken 直连即可,不需要代理。检查环境变量HTTP_PROXY、HTTPS_PROXY是否被设置,如果设置了但代理不可用,清掉:
unset HTTP_PROXY HTTPS_PROXY然后重试 curl。如果公司网络必须走网关,联系运维配置白名单,不要自己搭代理。
5.3 reading choices 报错
解析响应时choices字段读不到,多半是返回了错误结构。先看原始响应:
curl -s https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer $TAOTOKEN_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}' | jq .如果error字段有内容,按提示改。常见是 Model ID 写错,比如把claude-sonnet-4-5写成claude-sonnet-4.5。
5.4 OAuth 相关报错
如果你用 Claude Code 这类工具接入,OAuth 流程报错通常是回调地址或 token 过期。检查配置文件里的 Base URL 是否为https://taotoken.net/api,以及 token 是否还在有效期。重新走一遍授权流程即可。
5.5 Redis 侧报错
ScanCursor抛InvalidDataAccessApiUsageException: Cannot access closed cursor,说明你在close()之后又调了hasNext()。检查是否把Cursor传出了execute回调外使用。NoSuchElementException: No more elements available是next()在没数据时被调用,用hasNext()保护即可。
6. 把 ScanCursor 的设计用到你的项目里
ScanCursor最值得抄的不是scan本身,而是「远程分页 + 迭代器代理」这个模式。任何需要遍历远程数据集、又不想一次性加载的场景都能套:分页 HTTP 接口、数据库游标查询、消息队列批量拉取。
核心三要素:一个delegate持有当前批次、一个cursorId记录远程位置、一个hasNext()在delegate耗尽时自动拉下一批。你只需要实现doScan(cursorId, options),其余交给基类。
如果你在写 Agent 或长期编码任务,需要频繁调用模型做源码分析,可以看 Coding Plan,按量或包月都行。接入文档在 doc,配置细节以文档为准。
最后留一个实用技巧:生产环境扫大库时,count别设太小,否则网络往返次数爆炸;也别设太大,单批结果占内存。一般 100 到 1000 之间比较稳,具体看 key 的平均大小和网络延迟。扫的时候加match前缀,避免全库遍历。