登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  数据库 >  Redis

Redis SCAN 为什么会返回重复键

来源:17golang原创

时间:2026-09-06 01:15:58 285浏览 收藏

Redis SCAN 返回重复键并不一定是 Redis 出错。它是游标式、弱状态的渐进遍历:一次调用只带回一小批结果,服务端只记住游标,不为客户端保存“已经见过哪些键”的集合。因此,同一个键在一次完整遍历中可能出现多次。真正需要修复的通常不是“让 SCAN 绝不重复”,而是让消费端能安全地重复处理。

只要从游标 0 开始,并持续使用上一轮返回的游标直到再次得到 0,才能算完成一次完整遍历;业务侧必须接受重复、空批次和遍历期间数据变化。
要点速览
  • 重复返回是 SCAN 的已知语义,不等同于循环代码重复调用。
  • COUNT 只影响每次工作的量,不是分页大小,也不能消除重复。
  • 删除、迁移、刷新 TTL 等副作用要做成幂等;需要严格一次时,把任务状态放到独立集合或表中。

先确认是重复返回,还是重复处理

排查时先给每轮响应记录 cursor、返回数量和键名,不要只看最终日志里的“处理次数”。下面的命令可以观察重复键,但它本身没有修改 Redis 数据:

SCAN 返回重复键的核心原因是 Redis 采用了增量迭代的设计,全程不做全表阻塞遍历,服务端不会主动过滤已经返回过的键,再配合哈希槽的扩容缩容、遍历中途发生键值变更等场景,就很容易出现重复输出的情况。这类特性是 Redis 官方明确说明的,业务侧自己做去重就能正常使用,不用额外修改服务端逻辑。
redis-cli SCAN 0 MATCH 'cache:user:*' COUNT 100
# 中文说明:从 0 开始,只筛选用户缓存键;COUNT 100 只是每轮工作量提示。

如果同一个键在不同游标批次出现,属于 SCAN 允许的重复返回;如果响应没有重复、业务表却重复写入,问题在消费端。另一个常见误判是把空数组当成结束:只要游标还不是 0,空批次也必须继续请求。

Redis SCAN 游标遍历中哈希表、游标批次与可能重复键的静态关系
图1:Redis SCAN 的游标、键空间和返回批次之间是弱状态关系;同一键可能出现在多个批次。

重复从哪里来:游标不是快照位置

SCAN 的游标描述的是服务端下一段遍历位置,不是固定页码,也不是某个键的唯一编号。底层字典发生扩容或缩容时,桶位置会重新分布;渐进式遍历跨越这些变化,就可能再次触达已经返回过的键。即使没有明显扩缩容,也不能把返回顺序当成稳定排序。

MATCH 是取出元素后的过滤条件,匹配很稀疏时,很多轮可能返回空结果;COUNT 是提示,服务端可以返回少于、多于甚至暂时为零的元素。小集合使用紧凑编码时,还可能一次返回全部元素。以上都不会改变“用游标直到 0”的结束条件。

现象正确判断处理方式
同一键出现两次允许发生消费端去重或保证幂等
某轮返回空数组不代表结束继续使用返回游标
改大 COUNT 后重复减少只是批次变化不能据此依赖唯一返回
遍历中新增或删除键结果存在边界不确定性限定窗口或接受最终一致

用游标闭环,并把副作用做成幂等

以 go-redis 为例,循环的关键是把本次返回的 nextCursor 原样交给下一次调用。示例用本地集合记录已处理键,避免一次任务中的重复副作用;集合过大时,应换成带任务编号和过期时间的外部存储。

var cursor uint64
seen := make(map[string]struct{})

for {
    keys, nextCursor, err := rdb.Scan(ctx, cursor, "cache:user:*", 100).Result()
    if err != nil {
        return fmt.Errorf("扫描 Redis keyspace 失败: %w", err) // 中文说明:保留原错误,便于重试和告警。
    }
    for _, key := range keys {
        if _, exists := seen[key]; exists {
            continue // 中文说明:同一轮任务中重复出现时,不重复执行副作用。
        }
        seen[key] = struct{}{}
        if err := refreshOne(ctx, key); err != nil {
            return fmt.Errorf("刷新键 %q 失败: %w", key, err) // 中文说明:失败立即停止,避免静默漏处理。
        }
    }
    cursor = nextCursor
    if cursor == 0 {
        break // 中文说明:只有服务端返回 0,完整遍历才结束。
    }
}

如果任务是删除过期缓存,重复执行 UNLINK key 通常比“先查再删”更容易做成幂等;如果是写数据库,就用业务唯一键、任务批次号或 upsert 约束重复提交。严格的一次性语义不能由 SCAN 单独提供。

Redis SCAN 返回游标进入幂等消费者后连接去重集合与可重试副作用的静态关系
图2:把 SCAN 的返回批次交给幂等消费者,去重状态与可重试副作用应处在独立边界。

线上排查要看哪些边界

生产任务建议同时记录任务 ID、起始时间、最后游标、扫描总数、唯一键数、重复数、空批次数和失败键。这样可以区分“Redis 返回重复”和“客户端重试整批”两类问题。若 keyspace 持续增长,完整遍历可能迟迟回不到 0;可以限定维护窗口、冻结写入,或接受只覆盖某个时间段的最终一致结果。

Redis Cluster 下,逻辑上的全量扫描还意味着分别遍历各个主节点,应用层需要为节点维护独立游标和去重边界;不要把一个节点的游标直接交给另一个节点。若只是按前缀清理,优先缩小 MATCH 范围并控制并发,避免把“重复键”误判成需要不断增大 COUNT 的性能问题。

常见问题

把 COUNT 调大能彻底消除重复吗?

不能。它只是服务端每次调用的工作量提示,返回数量和重复语义都没有变。

返回空数组是不是已经扫描完了?

不是。只有返回游标为 0 才结束;空数组且游标非零时必须继续。

需要严格一次处理,应该依赖 SCAN 吗?

不应该。把唯一性放到集合、数据库唯一键或任务状态表中,并让副作用支持重试。

把 SCAN 当作“可能重复、可能空批、非快照”的遍历接口,消费端再负责幂等和任务记录,重复键就会从故障信号变成可管理的正常边界。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>