
1. 为什么 ZSet 一大就不能用 ZRANGE 全量拉线上有个签到积分榜ZSet 里塞了 800 多万成员。某天运营要导一份「积分大于 0 的全部用户」做对账我第一反应是ZRANGE leaderboard 0 -1 WITHSCORES结果命令刚发出去Redis 实例的延迟曲线直接竖起来客户端这边也卡在 socket 读上不动最后超时报错。原因很直白ZRANGE 是同步一次性返回Redis 主线程要把这 800 万成员序列化进输出缓冲区网络要一次性搬完客户端还要一次性反序列化成一个大 List。数据量小的时候没感觉量级一上来内存、带宽、主线程阻塞三件事同时爆。ZSCAN 就是为这种场景准备的。它把「一次拿全量」拆成「多次拿一小批」每次只返回有限个成员和分数客户端处理完再拿下一批。核心检索词先摆清楚ZSCAN 是 Redis 针对 ZSet 的增量迭代命令靠游标 cursor 记录进度适合大数据量下安全遍历有序集合避免 KEYS/ZRANGE 这类全量操作把实例拖垮。它适合谁适合要在生产环境扫大 ZSet 做导出、对账、清理、统计的开发和运维也适合写定时任务遍历排行榜、延时队列的同学。这篇不讲虚的直接给可复制的 redis-cli 命令、Java 客户端配置片段以及游标遍历完整性、内存占用的验证动作。中间会重点说 COUNT 参数怎么调、迭代期间数据被改了怎么办、连接怎么持有才不炸连接池。2. 前置准备TaoToken 接入与 Redis 环境确认要跑通后面的验证你需要一个能连的 Redis 实例以及一个稳定的模型/编码辅助入口来帮你生成和校对脚本。我这边习惯用 TaoToken 做统一接入官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 。它本身不是 Redis别搞混它的作用是当你在写扫描脚本、排查报错、生成压测代码时能有个顺手的模型对话和编码通道。先把 Key 拿到手进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 在 API Keys 页面创建一个密钥页面地址 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 。创建完复制出来后面配置客户端要用。如果你只是想先验证模型能不能正常回话可以直接开模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 试一句。Redis 侧的准备很简单确认版本和当前 ZSet 规模redis-cli -h 127.0.0.1 -p 6379 ping redis-cli -h 127.0.0.1 -p 6379 zcard leaderboard redis-cli -h 127.0.0.1 -p 6379 info memory | grep used_memory_humanzcard返回成员总数这个数字决定了你后面 COUNT 该给多大、要不要分片。info memory用来做前后对比验证扫描过程有没有把内存顶上去。注意别在业务高峰做全量扫描哪怕 ZSCAN 是增量的遍历一遍仍然会持续占用 CPU 和网络。3. 可复制配置redis-cli 与 Java 客户端片段3.1 redis-cli 手动验证游标先手工跑一遍建立对游标返回值的直觉。首次游标固定为 0redis-cli -h 127.0.0.1 -p 6379 zscan leaderboard 0 COUNT 100返回结构是两段第一个是下次要用的游标第二个是成员和分数交替出现的数组。类似1) 17408 2) 1) user:1001 2) 320 3) user:1002 4) 298把返回的游标17408填进下一次调用直到返回的游标变成0表示遍历结束redis-cli -h 127.0.0.1 -p 6379 zscan leaderboard 17408 COUNT 100带模式匹配时加 MATCH比如只扫user:前缀redis-cli -h 127.0.0.1 -p 6379 zscan leaderboard 0 MATCH user:* COUNT 100注意MATCH 是在服务端对每个候选元素做模式判断它不会减少扫描的总工作量只是过滤返回结果。所以别指望用 MATCH 来「加速」它省的是客户端处理量不是 Redis 的遍历量。3.2 Java 客户端每次迭代释放连接很多人写 ZSCAN 循环时把 Jedis 连接放在循环外面整个遍历期间一直占着一条连接。数据量小没事几百万成员扫几分钟连接池很快被这种任务吃光。正确做法是每批拿一次连接、用完立刻还import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import redis.clients.jedis.ScanParams; import redis.clients.jedis.ScanResult; import redis.clients.jedis.resps.Tuple; import java.util.List; import java.util.function.Consumer; public class ZSetBatchScanner { private final JedisPool jedisPool; public ZSetBatchScanner(JedisPool jedisPool) { this.jedisPool jedisPool; } public void scanInBatches(String key, int batchSize, ConsumerListTuple processor) { String cursor ScanParams.SCAN_POINTER_START; ScanParams params new ScanParams().count(batchSize); do { // 每批独立获取连接处理完立即归还 try (Jedis jedis jedisPool.getResource()) { ScanResultTuple result jedis.zscan(key, cursor, params); ListTuple batch result.getResult(); if (!batch.isEmpty()) { processor.accept(batch); } cursor result.getCursor(); } } while (!cursor.equals(ScanParams.SCAN_POINTER_START)); } }调用时把处理逻辑传进去比如统计总分ZSetBatchScanner scanner new ZSetBatchScanner(jedisPool); long[] total {0}; long[] count {0}; scanner.scanInBatches(leaderboard, 500, batch - { for (Tuple t : batch) { total[0] (long) t.getScore(); count[0]; } }); System.out.println(avg (count[0] 0 ? 0 : total[0] / count[0]));3.3 COUNT 参数怎么定COUNT 是「建议值」不是「精确值」Redis 底层哈希表按桶返回实际每批数量会在 COUNT 附近浮动。给几个实测参考COUNT 取值单批实际返回适用场景风险10默认约 10极小集合、调试网络往返次数多遍历慢100约 100常规业务扫描平衡点推荐起步值500约 400-600百万级导出单批内存可控1000约 800-1200离线批处理客户端需留足堆内存10000约 8000不推荐单批序列化压力大可能顶爆输出缓冲我的经验是在线任务从 100 起步离线导出用 500 到 1000。别一上来给 10000那等于把 ZRANGE 的问题换个名字又犯一遍。4. 验证请求与成功结果4.1 验证遍历完整性光看游标归零不够要确认「扫出来的成员数」和ZCARD对得上。写个脚本边扫边计数#!/bin/bash KEYleaderboard CURSOR0 TOTAL0 while :; do RESULT$(redis-cli -h 127.0.0.1 -p 6379 zscan $KEY $CURSOR COUNT 500) CURSOR$(echo $RESULT | head -1 | tr -d ) N$(echo $RESULT | tail -n 2 | grep -c ) TOTAL$((TOTAL N / 2)) if [ $CURSOR 0 ]; then break fi done echo scanned$TOTAL redis-cli -h 127.0.0.1 -p 6379 zcard $KEY如果集合在扫描期间没被修改两个数字应该一致。不一致通常意味着扫描期间有写入见第 5 节。4.2 验证内存占用扫描前后各采一次内存确认没有异常增长redis-cli -h 127.0.0.1 -p 6379 info memory | grep -E used_memory_human|mem_fragmentation_ratio正常情况扫描前后used_memory_human变化很小因为 ZSCAN 不复制整个集合。如果你看到内存明显上涨八成是客户端把每批结果全攒在 List 里没释放或者 COUNT 给太大导致输出缓冲堆积。4.3 用模型辅助核对脚本脚本逻辑拿不准时可以把上面的 Java 片段丢进模型对话页 https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentchatutm_campaignrewrite 让它帮你检查游标终止条件、连接释放位置。长期写这类扫描任务的话用 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 会更顺手接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。5. 本篇常见错排查5.1 游标没归零就退出漏数据典型错误是把cursor 0当成「本批为空」而不是「遍历结束」。ZSCAN 可能返回空批次但游标非 0这时必须继续扫。判断条件永远是「游标是否回到 0」不是「本批有没有数据」。// 错误本批为空就退出 if (batch.isEmpty()) break; // 正确只看游标 if (cursor.equals(ScanParams.SCAN_POINTER_START)) break;5.2 迭代期间数据被改出现重复或遗漏ZSCAN 是弱一致性遍历。扫描过程中如果有其他客户端增删成员可能出现某个成员被扫两次或者某个成员在扫到它之前被删掉从而漏掉。这不是 bug是增量迭代的固有特性。应对方式结果侧去重用 Set 或数据库唯一键兜底记录已处理游标支持断点续传全量对账类任务尽量放低峰期减少并发写。5.3 连接池被长时间占用前面强调过别把连接放在循环外。还有一种隐蔽写法是用了连接池但忘了归还比如手动getResource()后没 close。用 try-with-resources 最稳。5.4 COUNT 给太大导致单批超时COUNT 只是建议值但给到几万时单次 ZSCAN 返回的数据量足以让客户端反序列化卡住甚至触发 socket 读超时。排查方法把 COUNT 降到 100 再跑如果立刻正常就是单批过大。5.5 把 ZSCAN 当成强一致快照有人以为 ZSCAN 遍历期间看到的是「开始那一刻的集合」。不是。它反映的是遍历过程中集合的实时状态。需要快照语义的话得自己在应用层做版本控制或者先ZRANGE到临时 key 再扫——但那样又回到全量问题了所以通常还是接受弱一致 去重。6. 继续把扫描任务跑稳ZSCAN 的价值在于把「一次全量」拆成「多次小批」让内存和主线程压力可控。落地时记住三件事游标归零才算结束、每批独立释放连接、COUNT 从 100 到 500 起步别贪大。验证环节用 ZCARD 对账成员数、用 info memory 看内存曲线基本能覆盖大部分线上问题。如果你要把这套扫描逻辑接进现有的编码流程或者让模型帮你生成压测脚本、排查游标异常可以从 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 拿密钥接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 里有完整的调用说明。先把 redis-cli 那几条命令在测试实例上跑一遍确认游标行为符合预期再往生产脚本里搬。