
1. 从一次线上卡顿说起为什么需要Scan很多人第一次接触Redis的Scan命令都是因为踩了KEYS *的坑。我印象特别深早年间维护一个存了几十万个键的实例某次为了排查一批前缀键顺手在生产环境敲了个KEYS user:*结果整个实例瞬间卡住监控上QPS直接掉底业务方电话立刻打过来。那次之后我才真正理解KEYS是O(N)全量遍历而且Redis单线程模型下它会阻塞所有后续命令键越多卡得越久。Scan就是为了解决这个问题而生的——它把一次全量遍历拆成多次渐进式的小遍历每次只返回一小批结果和一个游标你拿着游标继续下一次直到游标归零。整个过程不会长时间阻塞主线程对线上服务友好得多。但Scan并不是无脑替代KEYS那么简单。它有一堆反直觉的特性返回的结果可能重复、遍历期间新增或删除的键行为不确定、游标不是简单的递增数字而是一串看起来毫无规律的值、COUNT参数只是提示而非精确数量。这些特性如果理解不到位写出来的代码要么漏数据要么死循环要么性能还不如预期。这篇内容我就围绕Scan这套游标迭代器机制把它的设计原理、正确用法、常见误区和实战技巧一次讲透。不管你是刚接触Redis的新手还是已经用过Scan但总觉得用着不踏实的老手都能从中找到能直接落地的经验。需要先明确一点Scan家族其实是一组命令包括SCAN遍历当前库的键、HSCAN遍历哈希的字段、SSCAN遍历集合成员、ZSCAN遍历有序集合成员及其分值。它们的游标机制完全一致只是作用对象不同。理解了SCAN其余三个自然就通了。下面我会以SCAN为主线穿插讲其他三个的差异点。2. 游标到底是个什么东西Scan的底层机制拆解2.1 游标不是偏移量而是反向二进制迭代的产物大多数人第一次看到Scan返回的游标比如0、17、128、192会下意识以为它是第几个元素的偏移量。这是最大的误解。如果你按偏移量去理解就会觉得为什么我COUNT设10返回的游标却跳到几百实际上Redis的游标是基于反向二进制迭代算法reverse binary iteration算出来的。我用一个生活化的类比来解释。假设有一张哈希表桶的数量是82的3次方键被散列到0到7号桶里。普通遍历是从0号桶走到7号桶。但Redis的Scan不是这样它把桶的编号看成二进制然后按高位进位、低位翻转的顺序遍历。比如从0000开始下一个是4100再下一个是2010再下一个是6110……这种顺序的好处是当哈希表发生扩容或缩容rehash时桶的数量从8变成16或变成4已经遍历过的位置和新表的位置能对应上从而保证在rehash过程中遍历不会遗漏元素。这就是为什么游标看起来跳来跳去却依然能保证完整性的原因。理解这一点非常关键因为它直接决定了你该怎么用Scan游标是一个不透明的状态标记你唯一能做的就是把它原样传给下一次调用绝对不要自己去解析、计算或假设它的含义。我见过有人试图对游标做加减运算来跳过某些数据结果就是漏掉大量键或者陷入死循环。2.2 COUNT只是提示不是承诺SCAN cursor COUNT 100里的COUNT很多人以为每次返回100个元素。错。COUNT的真实含义是每次调用时Redis大致扫描多少个桶底层哈希槽而不是返回多少个元素。因为每个桶里可能挂着多个键哈希冲突用链表解决也可能一个键都没有所以实际返回的元素数量是波动的。实测下来如果你设COUNT为100单次返回可能是几十个也可能是两三百个极端情况下甚至可能返回0个元素但游标不为0。这不是bug是设计使然。所以你的代码逻辑必须是只要游标不为0就继续循环而不能是每次拿到固定数量就处理。那COUNT设多少合适这取决于你的场景。COUNT越大单次扫描的桶越多总往返次数越少但单次阻塞时间越长COUNT越小单次越快但总往返次数越多网络开销越大。默认值是10对于大多数场景偏小。我的经验是如果客户端和服务端网络延迟低比如同机房COUNT可以设到500到1000如果跨机房COUNT设100到200比较均衡。但要注意COUNT再大也不会让单次调用无限阻塞因为Redis内部对单次Scan的工作量有上限控制不会真的扫完你指定的所有桶才返回。2.3 遍历期间的数据变更不保证、不承诺、但也不会崩这是Scan最让人纠结的地方。官方文档说得很清楚如果在遍历过程中有键被添加或删除Scan不保证能遍历到所有元素也不保证不会返回重复元素。具体来说遍历开始时就存在、遍历结束前一直没被删除的键一定会被返回至少一次可能多次。遍历期间新增的键可能被返回也可能不被返回取决于它落在哪个桶以及遍历进度。遍历期间被删除的键如果还没被扫到就不会再出现如果已经被扫过那它已经在你手里了。这个特性听起来很不靠谱但换个角度想它其实给了你一个弱一致性快照的语义。对于大多数扫描一批键做统计、做迁移、做清理的场景这种弱一致性完全够用。真正需要强一致性的场景本来就不该用Scan而应该用其他机制比如在业务层加锁或者用Redis的持久化文件离线分析。我踩过的一个坑是曾经写了个脚本用Scan遍历所有键边遍历边删除符合条件的键结果发现有些键被删了两次因为重复返回有些键明明符合条件却没被删因为遍历期间被其他逻辑改了。后来改成先Scan收集到列表再批量处理问题就消失了。所以我的建议是如果遍历过程中会修改数据最好先把结果收集起来遍历结束后再统一处理避免边遍历边改带来的不确定性。3. 手把手写一个正确的Scan循环3.1 最基础的循环骨架不管用什么语言Scan的正确循环结构都是固定的。我用Python的redis-py库举例其他语言逻辑完全一样import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) cursor 0 keys [] while True: cursor, batch r.scan(cursorcursor, matchuser:*, count100) keys.extend(batch) if cursor 0: break print(f共扫描到 {len(keys)} 个键)这段代码有几个关键点必须注意第一cursor初始值必须是0这是约定的起点。第二循环的终止条件是返回的游标等于0而不是返回结果为空。因为完全可能出现返回结果为空但游标不为0的情况如果你用结果为空就退出会漏掉后面的数据。第三match参数是模式匹配支持glob风格的通配符*、?、[]但它是在服务端过滤的也就是说Redis会扫描所有桶只把匹配的键返回给你。这意味着即使你只想要user:*底层依然会遍历整个键空间只是返回时做了过滤。所以match能减少网络传输但不能减少服务端的扫描工作量。3.2 用SCAN遍历大哈希HSCAN的正确姿势HSCAN用于遍历一个哈希类型的所有字段。假设你有一个存了百万级字段的大哈希比如用户属性表想分批读取cursor 0 fields {} while True: cursor, batch r.hscan(nameuser:1001:profile, cursorcursor, count200) fields.update(batch) if cursor 0: break注意HSCAN返回的batch是一个字典字段到值的映射而SCAN返回的是列表。SSCAN返回的是集合成员列表ZSCAN返回的是成员-分值的列表在Python里通常表现为列表元素是成员和分值交替或者字典取决于客户端实现。这里有个容易忽略的点HSCAN的COUNT参数同样只是提示而且对于哈希来说底层是ziplist或hashtable两种编码。如果哈希很小字段数少于hash-max-ziplist-entries配置默认512它用的是ziplist编码此时HSCAN会一次性返回所有字段游标直接归零COUNT参数完全不起作用。只有当哈希大到用hashtable编码时HSCAN才会真正分批。所以如果你测试时发现小哈希一次就返回完了那是正常的不是bug。3.3 游标归零不等于扫完了所有数据的边界情况有一个反直觉的边界情况值得单独说当键空间在遍历期间发生了rehash游标可能会回退到之前出现过的值。比如你第一次拿到游标17扫了几轮后又拿到17。这不是死循环而是因为哈希表扩容/缩容导致桶的映射关系变了算法需要重新遍历某些桶以保证不遗漏。你的代码只要坚持游标为0才退出就不会有问题。但如果你在循环里做了记录已访问游标、遇到重复游标就退出的优化反而会提前终止漏掉数据。另一个边界是如果遍历期间所有键都被删光了Scan可能返回空结果但游标不为0继续调用几次后游标才归零。这也是正常的因为算法还在按桶推进只是桶里没数据了。4. 那些年我踩过的Scan坑与排查实录4.1 坑一COUNT设太大导致单次阻塞有一次我为了减少往返次数把COUNT设成了10000想着一次多拿点。结果在一个存了千万级键的实例上单次Scan耗时飙升到几百毫秒监控上出现了明显的延迟毛刺。原因是COUNT越大单次扫描的桶越多虽然Redis内部有工作量上限但那个上限也随着COUNT增大而增大。后来我把COUNT降到500毛刺就消失了。排查这个问题的思路是用SLOWLOG看有没有Scan相关的慢查询用INFO commandstats看scan命令的平均耗时。如果平均耗时超过几毫秒就该考虑调小COUNT了。我的经验阈值是单次Scan耗时控制在1到5毫秒以内比较安全超过10毫秒就要警惕。4.2 坑二边遍历边删除导致数据不一致前面提过我写过一个扫描并删除过期键的脚本边Scan边DEL。结果发现有些键没被删掉。排查过程是这样的我先怀疑是match模式写错了检查后发现没问题然后怀疑是游标逻辑写错了打印了每轮的游标和返回数量发现确实有键跳过了。最后才意识到是遍历期间删除导致的——删除操作改变了哈希表结构触发了rehash而rehash过程中Scan的遍历顺序会调整某些键就被跳过了。修复方案很简单先Scan收集所有目标键到内存遍历结束后再批量删除。如果键太多内存放不下可以分批收集、分批删除但每一批的收集和删除要分开不要在同一轮Scan里既收集又删除。4.3 坑三把游标当数字做运算有个同事为了并行扫描想把游标拆成几段让多个线程各扫一段。他的做法是线程A从游标0开始线程B从游标总数/2开始。结果线程B扫出来的数据乱七八糟大量重复和遗漏。原因就是前面说的游标是反向二进制迭代的产物不是线性偏移量你无法通过简单的除法来分段。如果真需要并行扫描正确的做法是按业务维度分片比如按key的前缀分user:a*、user:b*每个线程扫一个前缀。或者用Redis Cluster的多节点特性每个节点独立Scan。绝对不要试图对游标做数学运算来分片。4.4 坑四在集群模式下Scan只扫当前节点Redis Cluster有16384个槽分布在多个节点上。你在任意一个节点上执行SCAN它只会遍历该节点负责的槽里的键不会跨节点。很多人第一次在集群里用Scan发现怎么只扫到一部分键就是这个原因。正确做法是先通过CLUSTER NODES或客户端API获取所有主节点然后对每个节点分别执行Scan最后合并结果。大多数Redis客户端库比如redis-py的RedisCluster已经封装了这个逻辑但你要确认你用的方法确实做了全节点遍历。如果用的是原生连接就得自己写循环。5. Scan与其他遍历方案的对比选型5.1 Scan vs KEYS什么时候可以用KEYSKEYS的唯一优势是一次性拿到全部结果代码简单。但它的代价是阻塞主线程键越多阻塞越久。我的判断标准是场景推荐方案理由生产环境键数超过1万SCANKEYS会阻塞风险高本地开发/测试环境KEYS数据少图方便从库只读副本上做离线分析KEYS阻塞从库不影响主库键数少于1000且能接受短暂阻塞KEYS简单直接注意即使在从库上用KEYS也要确认从库没有承载读流量。如果从库也在服务线上读请求KEYS同样会造成延迟。5.2 Scan vs 有序集合范围查询按需选择如果你的数据本身就用有序集合ZSET存储并且分值score有业务含义比如时间戳那么用ZRANGEBYSCORE分页查询可能比ZSCAN更合适。因为ZRANGEBYSCORE可以精确控制范围而且天然有序适合按时间窗口拉取的场景。ZSCAN则适合遍历所有成员不关心顺序的场景。举个例子你要拉取某个用户最近7天的操作记录记录存在ZSET里score是时间戳。这时候用ZRANGEBYSCORE key start_ts end_ts LIMIT offset count分页拉取比ZSCAN遍历全部再过滤要高效得多。反过来如果你要统计这个ZSET里一共有多少种不同的操作类型那就得遍历所有成员用ZSCAN更合适。5.3 大键遍历的替代思路拆分数据结构有时候问题的根源不是怎么遍历而是为什么会有这么大的键。一个哈希存了百万字段一个集合存了千万成员这种大键本身就是隐患——不仅遍历慢删除也慢DEL大键会阻塞迁移也麻烦。更好的做法是在业务层就把大键拆成多个小键比如按用户ID哈希取模分成100个小哈希每个小哈希存1万字段。这样遍历时可以用HSCAN逐个小键扫也可以并行扫灵活得多。我做过一个迁移项目原来一个ZSET存了500万成员每次操作都提心吊胆。后来按时间分片每天一个ZSET每个ZSET最多几十万成员不仅遍历轻松过期清理也简单直接删旧日期的键。所以遇到大键先想想能不能拆而不是硬着头皮遍历。6. 生产环境使用Scan的几条硬核经验6.1 控制单次扫描的节奏给主线程留喘息空间即使Scan不阻塞如果你在脚本里疯狂循环调用不给其他命令留执行机会依然会挤占主线程。我的做法是每轮Scan之间加一个极短的sleep比如1毫秒或者每扫N轮就主动让出一次。对于Python脚本time.sleep(0.001)就够对于其他语言可以用异步或协程让出。这样虽然总耗时略增但对线上服务的影响降到最低。另外尽量在从库上做Scan。如果业务允许把遍历任务指向只读副本主库只承担写和关键读。这样即使Scan有轻微开销也不影响主库的稳定性。6.2 用游标做断点续传Scan的游标天然适合做断点续传。如果你要遍历一个超大键空间中途脚本崩了或者被kill了只要把当前游标存下来下次从那个游标继续就行。我写过一个扫描并导出的工具每扫100轮就把游标写到一个文件里重启后读文件继续。这样即使跑几个小时的任务中断了也不用从头再来。但要注意断点续传的前提是数据没有大变动。如果中断期间有大量键增删续传的结果可能不完整。所以这个技巧适合数据相对稳定的离线任务不适合数据频繁变动的实时场景。6.3 监控Scan的耗时和返回数量在生产环境跑Scan一定要加监控。我通常记录三个指标每轮Scan的耗时、每轮返回的元素数量、总轮数。如果发现某轮耗时突然飙高可能是遇到了大桶或者rehash如果返回数量长期为0但游标不归零可能是键空间在剧烈变动如果总轮数远超预期比如预期100轮结果跑了1000轮可能是COUNT设太小了。这些指标不需要多复杂的监控系统在脚本里打印日志就行。关键是你要定期看这些日志而不是跑完就完事。我见过太多人脚本跑完只看最终结果对不对不看过程指标结果出了问题也不知道是哪一轮出的。6.4 关于MATCH的性能陷阱MATCH参数虽然方便但它是在服务端做glob匹配匹配逻辑本身有开销。如果你用了一个很复杂的模式比如user:*:order:*:detail每次匹配都要做字符串解析会拖慢Scan。更好的做法是如果前缀固定用简单前缀匹配user:*如果需要复杂过滤先Scan拿到全部键在客户端做过滤。客户端过滤虽然传输量大但服务端压力小而且客户端可以做更复杂的逻辑正则、多条件组合。还有一个细节MATCH对HSCAN、SSCAN、ZSCAN同样适用但匹配的是字段名或成员名不是键名。别搞混了。7. 把Scan用对比用快更重要绕了一圈回到最核心的认知Scan的价值不在于快而在于不阻塞。它用多次小批量遍历换取了主线程的平稳代价是可能重复、可能不完整、游标不透明。你用Scan之前先问自己三个问题我的场景能接受弱一致性吗我的数据在遍历期间会变动吗我有没有更好的替代方案比如拆分大键、用ZSET范围查询这三个问题想清楚了Scan用起来就踏实了。我个人在实际操作中的体会是Scan最适合离线统计、批量迁移、渐进式清理这三类任务而且最好在从库上跑、加监控、控制节奏。至于那些要求强一致、要求实时的场景Scan本来就不是为它们设计的硬用只会给自己找麻烦。最后分享一个小技巧如果你不确定COUNT设多少合适就从默认的10开始逐步翻倍测试观察单次耗时和总轮数找到那个耗时可控、轮数可接受的平衡点。这个平衡点因实例而异没有万能值只能实测。