ARTICLE DETAIL

资讯详情

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

Redis的Lettuce客户端SCAN异常排查:集群模式下的游标与连接配置

Redis的Lettuce客户端SCAN异常排查:集群模式下的游标与连接配置 1. 集群里 SCAN 游标为什么会断从一次线上排查说起Redis 集群模式下用 Lettuce 做 SCAN最容易踩的坑不是命令写错而是游标在分片之间无法传递。你如果直接拿StatefulRedisClusterConnection的sync().scan(cursor)去循环跑着跑着就会抛出这样一句A scan in Redis Cluster mode requires to reuse the resulting cursor from the previous scan invocation这句话翻译成人话就是集群模式下SCAN 的游标必须由上一次调用返回的那个游标继续往下走不能自己造一个字符串0反复传进去。单机 Redis 里游标是服务端维护的你传0开始、拿返回值继续没问题但集群里每个分片各自维护自己的游标空间StatefulRedisClusterConnection本身不帮你保存「当前扫到哪个分片、哪个游标」于是它检测到你复用了错误的游标直接报错。我遇到这个问题的场景很典型一个做缓存预热和过期键清理的定时任务单机环境跑得好好的迁到三主三从的集群后扫描任务每隔几分钟就中断一次日志里全是上面那句报错。更麻烦的是任务中断后游标丢失下次又从0开始导致部分 key 永远扫不到部分 key 被重复处理。这里要先厘清一个概念SCAN 在集群模式下不是「一个」扫描而是「每个分片各扫一遍」。集群有 N 个 master 分片你真正需要的是对每个分片独立执行 SCAN各自维护游标直到该分片游标归零再换下一个分片。Lettuce 提供了两种思路一种是走RedisClusterCommands拿到节点级别的连接手动遍历每个分片另一种是干脆用StatefulRedisConnection单连接配合ScanCursor对象让 Lettuce 帮你管理游标状态。excerpt 里给的解法是后者——用ScanCursor.INITIAL初始化每次用返回的scanResult.getCursor()更新循环条件用!cursor.isFinished()。这个写法本身没错但它默认你连的是单个节点如果你把它套在集群连接上依然会出问题。所以排查这类异常核心就三件事确认你连的是集群连接还是单节点连接、确认游标是不是复用了上一次的返回值、确认ScanArgs的 match/count 参数有没有真正生效。下面我会把这三件事拆开讲并给出可直接复制的配置片段。顺便说一句多集群环境下凭证管理也是个隐形坑我后面会结合统一 Key/API 通道的方式讲怎么把多套集群的访问凭证收敛到一处避免配置散落导致连错集群。2. 前置准备用 TaoToken 统一管理多集群访问凭证与模型通道在动手改代码之前先把「连哪个集群、用什么凭证」这件事理清楚。很多 SCAN 异常表面看是游标问题根因其实是连错了集群或者凭证串了——比如测试环境的 key 连到了生产集群扫出来的 key 完全对不上预期你还以为是游标丢了。我现在的做法是把多套 Redis 集群的访问信息以及配套的模型调用通道统一收敛到 TaoToken 这一层来管理。它的官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 基址是 https://taotoken.net/api 这个不加 UTM。你可以在控制台里为不同集群、不同环境分别建 Key每个 Key 绑定不同的权限和配额代码里只认一个 Base URL 一个 Key切换环境时改配置而不是改代码。具体操作路径我列一下都是控制台里能点到的模型对话入口https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 用来验证通道是否通、模型是否可用Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 长期跑编码类 Agent 任务时用控制台https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 管理 Key、看调用量API Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 新建/吊销 Key 都在这接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里面有各语言的接入示例。为什么要在这里提这个因为排查 SCAN 异常时你经常需要让 AI 帮你分析日志、生成扫描脚本、对比不同集群的 key 分布。如果每次都要手动切 Key、改 Base URL排查效率极低。把凭证统一到 TaoToken 后你的排查脚本、日志分析工具、代码生成助手都走同一个通道环境切换只改一个变量。注意TaoToken 是统一凭证与模型通道不是 Redis 代理也不替代你的 Redis 客户端。Redis 连接本身还是走 Lettuce 直连你的集群TaoToken 管的是「你调模型/调工具时用的 Key 和 Base URL」。前置准备清单项目说明示例值Redis 集群地址各分片 master 节点10.0.0.1:6379,10.0.0.2:6379集群密码如有your-redis-passwordTaoToken Base URL统一通道https://taotoken.net/apiTaoToken Key控制台生成sk-xxxx示例勿照抄模型 ID用于日志分析按控制台可用列表选把这些准备好后面写配置和排查脚本时就不会手忙脚乱。3. 可复制的 Lettuce 集群 SCAN 配置ScanCursor 与连接参数这一节是重点直接给可复制的配置。先说结论集群模式下做 SCAN要么用节点级连接逐分片扫要么用ScanCursor对象配合单连接绝不要拿集群连接的scan(String)硬循环。先看连接配置。Lettuce 连集群的推荐写法import io.lettuce.core.RedisURI; import io.lettuce.core.cluster.RedisClusterClient; import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; import io.lettuce.core.cluster.ClusterClientOptions; import io.lettuce.core.cluster.ClusterTopologyRefreshOptions; import io.lettuce.core.ClientOptions; import java.time.Duration; import java.util.Arrays; import java.util.List; public class ClusterConnectionFactory { public static StatefulRedisClusterConnectionString, String create() { ListRedisURI nodes Arrays.asList( RedisURI.create(redis://10.0.0.1:6379), RedisURI.create(redis://10.0.0.2:6379), RedisURI.create(redis://10.0.0.3:6379) ); // 如有密码 nodes.forEach(uri - uri.setPassword(your-redis-password)); RedisClusterClient client RedisClusterClient.create(nodes); // 拓扑刷新集群扩容/主从切换后自动更新分片视图 ClusterTopologyRefreshOptions topologyRefreshOptions ClusterTopologyRefreshOptions.builder() .enablePeriodicRefresh(Duration.ofSeconds(30)) .enableAllAdaptiveRefreshTriggers() .build(); ClusterClientOptions clientOptions ClusterClientOptions.builder() .topologyRefreshOptions(topologyRefreshOptions) .autoReconnect(true) .build(); client.setOptions(clientOptions); return client.connect(); } }这段配置的关键点enablePeriodicRefresh让客户端定期刷新分片拓扑enableAllAdaptiveRefreshTriggers在遇到 MOVED/ASK 重定向时主动刷新。SCAN 异常里有一类就是拓扑过期导致的——客户端还以为某个分片在旧节点上扫到一半连接被重定向游标就断了。接下来是 SCAN 的核心。正确姿势是逐分片扫描用ScanCursor管理每个分片的游标import io.lettuce.core.ScanArgs; import io.lettuce.core.ScanCursor; import io.lettuce.core.cluster.api.StatefulRedisClusterConnection; import io.lettuce.core.cluster.api.sync.RedisAdvancedClusterCommands; import io.lettuce.core.cluster.api.sync.RedisClusterCommands; import io.lettuce.core.KeyScanCursor; import io.lettuce.core.cluster.models.partitions.RedisClusterNode; import io.lettuce.core.cluster.models.partitions.Partitions; public class ClusterKeyScanner { public static void scanAll(StatefulRedisClusterConnectionString, String connection) { Partitions partitions connection.getPartitions(); ScanArgs scanArgs ScanArgs.Builder.matches(prefix:*).limit(500); for (RedisClusterNode node : partitions) { if (!node.is(RedisClusterNode.NodeFlag.UPSTREAM)) { continue; // 只扫 master 分片 } // 拿到该分片的节点级连接 StatefulRedisClusterConnectionString, String nodeConn connection.getConnection(node.getNodeId()); RedisAdvancedClusterCommandsString, String commands nodeConn.sync(); ScanCursor cursor ScanCursor.INITIAL; do { KeyScanCursorString scanResult commands.scan(cursor, scanArgs); for (String key : scanResult.getKeys()) { System.out.println(node node.getUri() key key); } cursor scanResult.getCursor(); } while (!cursor.isFinished()); } } }这里有几个必须说清楚的点第一connection.getConnection(nodeId)拿到的是指向单个分片的连接在这个连接上执行 SCAN游标语义和单机一致ScanCursor.INITIAL起步、scanResult.getCursor()续传、isFinished()判断结束完全成立。第二ScanArgs.Builder.matches(prefix:*).limit(500)里的limit对应 Redis 的 COUNT 参数。很多人反馈「自定义传参不起作用」其实是把ScanArgs传给了集群连接的scan(String)重载那个重载根本不接受ScanArgs参数自然被忽略。用节点级连接 scan(ScanCursor, ScanArgs)才能让 match/count 生效。第三如果你非要用StatefulRedisConnection单连接那套写法务必确认你连的是单个节点而不是集群入口。excerpt 里的RedisClient.create().connect()默认连 localhost:6379在集群环境下这个连接只能看到它连上的那个分片扫不全。配置片段给完了下一节讲怎么验证游标真的在续传、参数真的生效。4. 验证请求与成功结果游标续传、参数生效、分片覆盖写完代码不验证等于没写。SCAN 这类问题最怕「看起来跑通了其实漏扫了一半 key」。我一般用三步验证法。第一步验证游标续传。在循环里打印每次的游标值和本批 key 数量观察游标是否在变化、是否最终归零do { KeyScanCursorString scanResult commands.scan(cursor, scanArgs); System.out.printf(cursor%s batchSize%d finished%s%n, scanResult.getCursor(), scanResult.getKeys().size(), scanResult.isFinished()); cursor scanResult.getCursor(); } while (!cursor.isFinished());正常输出长这样cursor0 batchSize500 finishedfalse cursor17408 batchSize500 finishedfalse cursor34816 batchSize312 finishedfalse cursor0 batchSize0 finishedtrue如果游标一直是0且finishedfalse说明你复用了错误的游标对象如果第一批就finishedtrue但 key 数量远小于预期说明 COUNT 太小或者 match 写错了。第二步验证 ScanArgs 生效。故意用一个不存在的前缀比如matches(nonexistent:*)如果还能扫出 key说明 match 参数没生效你八成用错了重载。反过来用matches(user:*)扫出来的 key 应该全部以user:开头抽查几个确认。第三步验证分片覆盖。集群有 N 个 master你的扫描日志里应该出现 N 个不同的节点地址。如果只有一个节点地址反复出现说明你只扫了一个分片。可以用CLUSTER SLOTS或CLUSTER NODES对照分片数量redis-cli -h 10.0.0.1 -p 6379 -a your-redis-password cluster nodes | grep master输出里 master 的行数就是你的分片数扫描日志里的节点地址数量应该和它一致。成功结果长这样三主集群每个分片各扫一轮node10.0.0.1:6379 cursor0 batchSize500 finishedfalse node10.0.0.1:6379 cursor17408 batchSize500 finishedfalse node10.0.0.1:6379 cursor0 batchSize0 finishedtrue node10.0.0.2:6379 cursor0 batchSize500 finishedfalse node10.0.0.2:6379 cursor0 batchSize0 finishedtrue node10.0.0.3:6379 cursor0 batchSize500 finishedfalse node10.0.0.3:6379 cursor0 batchSize0 finishedtrue三个节点各出现一次每个节点的游标独立归零这才叫扫全了。如果你在验证过程中需要让 AI 帮你比对日志、生成校验脚本可以走模型对话入口 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 快速验证通道是否可用把日志贴进去让它帮你找异常模式。长期跑这类校验任务的话Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 更合适配额和稳定性都更可控。5. 常见报错排查401、local proxy failed、reading choices、OAuth排查 SCAN 异常时你可能会在日志里看到一些看起来不相关的报错。我把踩过的坑按报错原文列出来对照着查。报错一A scan in Redis Cluster mode requires to reuse the resulting cursor from the previous scan invocation这是本篇的主线报错。根因在集群连接上用了scan(String cursor)重载或者手动传了一个不是上次返回值的游标。解法改用节点级连接 ScanCursor对象或者逐分片扫描。检查你的代码里有没有clusterCommands.scan(0)这种写法有就改掉。报错二401 Unauthorized这个通常出现在你调模型通道或管理接口时不是 Redis 本身的错。检查 TaoToken Key 是否填对、是否过期、Base URL 是否写成了带路径的错误形式。正确基址是 https://taotoken.net/api Key 在 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 管理。如果 Redis 侧报NOAUTH那是 Redis 密码没设和 401 是两码事别混。报错三local proxy failed/ 连接被拒排查脚本里如果配了本地转发容易出现这个。先确认你的 Redis 集群地址是直连可达的telnet 10.0.0.1 6379能通再说。TaoToken 通道的连通性单独验证别把 Redis 连接问题和通道问题搅在一起。报错四reading choices相关解析失败这是模型返回体解析报错一般出现在你用脚本调模型分析日志时。检查请求体是不是标准 JSON、model字段是否填了控制台里真实存在的 ID、messages结构是否正确。对照接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里的示例改。报错五OAuth / 认证跳转异常如果你用的是 Claude Code 这类工具接入认证方式要配对。Claude Code 走 Anthropic 兼容通道时Base URL、Key、Model ID 三件套必须齐全且一致{ base_url: https://taotoken.net/api, api_key: sk-你的Key, model: 控制台可用的模型ID }三件套缺一个都会认证失败。同理如果你用 Cline MCP 或 Codex 的auth.json也要把这三项写全{ baseURL: https://taotoken.net/api, apiKey: sk-你的Key, model: 控制台可用的模型ID }排查顺序建议先确认 Redis 连接本身通不通redis-cli ping再确认 SCAN 逻辑对不对游标是否续传最后才看模型通道相关的报错。别一上来就怀疑通道大部分 SCAN 异常都是客户端用法问题。6. 把凭证和扫描逻辑都收敛到一处回到最初的问题集群模式下 SCAN 游标中断本质是「集群连接不维护游标状态」和「分片各自为政」这两件事叠加。解法不复杂——逐分片扫描每个分片用ScanCursor独立管理游标配合拓扑自动刷新基本能覆盖绝大多数场景。我现在的习惯是把 Redis 集群连接配置、扫描参数、以及模型通道的 Base URL/Key 全部收敛到一份配置里环境切换只改一个 profile。这样排查问题时不会出现「代码里连的是 A 集群凭证却是 B 集群的」这种低级错误。TaoToken 在这里的作用就是把多套凭证统一管起来控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 里能一眼看到哪些 Key 在用、配额还剩多少。最后留一个实用技巧扫描大集群时把limitCOUNT设成 500 到 1000 之间太小会导致往返次数暴增太大单次响应变慢还可能触发超时。我实测下来 500 是个比较稳的值你可以根据自己的 key 总量和网络延迟微调。扫描任务本身建议加个断点续传的日志把每个分片扫到哪个游标记下来任务中断后能从断点继续而不是从头再来。
返回列表