ARTICLE DETAIL

资讯详情

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

Springboot 中 RedisTemplate 使用 scan 获取所有 key 时,底层到底做了哪些事情?TaoToken 视角下的源码拆解

Springboot 中 RedisTemplate 使用 scan 获取所有 key 时,底层到底做了哪些事情?TaoToken 视角下的源码拆解 1. 从一次线上卡顿说起RedisTemplate.scan 到底在干什么先说结论RedisTemplate的scan不是一次性把整个库的 key 拉回来而是游标驱动的分批拉取 迭代器代理。你写的那段connection.scan(...)返回的Cursorbyte[]本质是org.springframework.data.redis.core.ScanCursor它把「一次网络请求拿一批」和「Iterator 语义」缝合在了一起。为什么很多人会误以为它一次性返回全部因为 API 长得太像keys *了scan.forEachRemaining(...)一调用代码里看不到循环感觉数据是「凭空出现」的。但只要你把count(2)写进去就会发现一个反直觉的现象——明明库里有一万个 keycount(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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencySpring Boot 2.x 默认用 Lettuce3.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/apiKey 在控制台创建Model ID 按你选的模型填。三件套缺一不可Base URLhttps://taotoken.net/apiAPI 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 RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, 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 RedisTemplateString, Object redisTemplate; public ListString scanAllKeys(String pattern) { ListString keys new ArrayList(); redisTemplate.execute((RedisCallbackVoid) connection - { ScanOptions options ScanOptions.scanOptions() .match(pattern) .count(2) .build(); try (Cursorbyte[] 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 下它是LettuceConnectionJedis 下是JedisConnection。这个连接是线程绑定的同一个线程内多次execute会复用除非你手动释放。关键点scan命令是发在这个连接上的所以游标迭代期间连接不能关。ScanCursor的close()只改状态不直接关连接连接由RedisConnectionUtils.releaseConnection在execute结束时处理。3.2 命令封装doScan 如何拼出 SCAN 命令connection.scan(options)返回DefaultCursor的子类。以 Lettuce 为例最终走到LettuceConnection.scanpublic Cursorbyte[] scan(ScanOptions options) { return new LettuceScanCursor(options, this); }LettuceScanCursor继承ScanCursorbyte[]实现doScanprotected ScanIterationbyte[] doScan(long cursorId, ScanOptions options) { ScanArgs args ...; // 把 match/count 转成 Lettuce 的 ScanArgs KeyScanCursorbyte[] 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返回的是Cursorbyte[]因为 Redis 协议层就是字节。RedisTemplate的execute不会自动帮你反序列化 key你需要自己new String(bytes)或用template.getKeySerializer().deserialize(bytes)。如果你用redisTemplate.scan(options)Spring Data Redis 2.1 提供它会返回CursorK内部用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包含所有 keyscan只匹配 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前缀避免全库遍历。
返回列表