news 2026/9/26 10:51:52

Spring 中 Redis Scan 的几个关键点:从 ScanOptions 到 ScanCursor 配 TaoToken 的实战骨架

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring 中 Redis Scan 的几个关键点:从 ScanOptions 到 ScanCursor 配 TaoToken 的实战骨架

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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。scan.match和scan.count就是后面要重点讲的ScanOptions两个参数,先在这里占位,代码里读出来用。

注意:apiKey不要硬编码进业务代码提交到仓库,放配置文件或者环境变量里,settings.json记得加进.gitignore。

如果你只是想先验证模型通道通不通,可以直接在模型对话页试一条请求:https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。长期做编码和 Agent 场景的,可以看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_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为止。

正确的遍历写法:

Set<String> allKeys = new HashSet<>(); try (Cursor<String> 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 RedisTemplate<String, String> redisTemplate; private final ScanOptions scanOptions; public RedisScanService(RedisTemplate<String, String> redisTemplate, ScanOptions scanOptions) { this.redisTemplate = redisTemplate; this.scanOptions = scanOptions; } public Set<String> scanAllKeys() { Set<String> result = new HashSet<>(); try (Cursor<String> 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 Map<String, Object> scanKeys() { long start = System.currentTimeMillis(); Set<String> keys = scanService.scanAllKeys(); long cost = System.currentTimeMillis() - start; Map<String, 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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。Key 管理页:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。接入细节看文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。

如果你用的是 Claude Code 这类编码工具,想把通道配进去,参考:https://taotoken.net/claude-code-anthropic?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。长期跑编码和 Agent 任务的,Coding Plan 在 https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。

最后留一个我踩过的坑:scan 的 count 不要照搬别人的值,一定要在自己的数据量和实例负载下实测。我一开始抄了个 10000,结果在预发环境把 Redis 延迟拉高了,后来降到 1000 才稳。参数这东西,别人的经验只是起点,自己的监控数据才是依据。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 10:49:11

公众号登录无限回调接口源码:OAuth2动态分发与避坑指南

简介&#xff1a;这是2024年最新公众号无限回调登录接口源码&#xff0c;专门面向需要快速接入微信公众平台登录能力的开发者&#xff0c;尤其适合暂无备案域名或希望绕过繁琐审核流程的场景。资源共7个文件&#xff0c;整体仅7.77MB&#xff0c;包含PHP核心源码、JPG界面截图、…

作者头像 李华
网站建设 2026/9/26 10:48:33

2026实测10款降AIGC网站红黑榜!TaoToken统一Key接入与达标率硬核对标

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 10:47:50

AI编程革命:Codex一键生成高效脚本,TaoToken统一Key接入实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 10:47:02

智谱 GLM-5-Turbo 实测:在 OpenClaw 里配 TaoToken 跑通 Agent 任务

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华