使用 go-redis SCAN 与 Pipeline 高效清理无 TTL 的 Key:del-keys-without-ttl 示例深度解析

原创2026-09-30 21:26:57152 阅读
文章标签:后端数据库客户端缓存

使用 go-redis SCAN 与 Pipeline 高效清理无 TTL 的 Key:del-keys-without-ttl 示例深度解析

本指南以 go-redis 官方示例 example/del-keys-without-ttl 为骨架,讲解如何用 SCAN 游标遍历 + TTL 探测 + Pipeline 批量删除,在超大 Key 空间中安全、低阻塞地清理所有"永不过期"的 Key。读完本文你将掌握:基于 redis.ScanIterator 的无阻塞遍历方式、基于管道批处理与并发流水线的删除架构,以及如何在真实 Redis 实例上运行并验证这套方案。

一、示例背景:为什么要"删除没有 TTL 的 Key"

Redis 中每个 Key 都可以设置过期时间(TTL)。运维实践中常见一类隐患:应用写入数据时忘了设置过期时间,导致 Key 永远不释放内存,最终撑满 maxmemory 触发淘汰,甚至影响整实例稳定性。

本示例的定位非常明确——只清理"没有 TTL"的 Key,保留所有设置了过期时间的 Key(包括永不过期的 -1 与已过期的 -2 之外的有效值)。这在 go-redis 仓库中是一个独立的、可 go run . 直接运行的实战范例,其运行方式在 example/del-keys-without-ttl/README.md 中有明确说明:

go run .

注意:该示例按设计会删除本地 Redis 中所有无 TTL 的 Key,请在测试环境或确认安全的前提下运行,切勿直接在生产库上执行。

二、示例运行环境与依赖

示例的依赖声明位于 example/del-keys-without-ttl/go.mod:

  • 模块名:github.com/redis/go-redis/example/del-keys-without-ttl,Go 版本要求 go 1.24;
  • 依赖 github.com/redis/go-redis/v9 v9.23.0-beta.1,并通过 replace github.com/redis/go-redis/v9 => ../.. 指向仓库根目录的本地源码,即示例直接复用当前仓库实现;
  • 日志组件使用 go.uber.org/zap v1.24.0。

示例启动后首先连接默认地址 :6379(见 main.go),随后构造三类测试数据:

  • key_with_ttl:设置 1 分钟 TTL,应被保留;
  • key_without_ttl_1、key_without_ttl_2:TTL 为 0(不设置过期时间),应被删除。

三、核心流程:SCAN 遍历 → 批量 TTL 探测 → 并发删除

整个程序由两个阶段组成,可拆解为三步:

  1. 全量遍历:用 rdb.Scan(ctx, 0, "", 0).Iterator() 获得游标迭代器,逐个取出 Key 交给 KeyChecker;
  2. 批量探测:内部 goroutine 将 Key 攒满一批(默认 100 个)后,通过一次 Pipeline 批量发送 TTL 命令,筛出 TTL 为 -1(无过期时间)的 Key;
  3. 并发删除:另一个 goroutine 持续消费筛出的 Key,再次用 Pipeline 按批次执行 DEL。

主流程代码位于 main.go,结构如下:

checker := NewKeyChecker(rdb, 100)
start := time.Now()
checker.Start(ctx)

iter := rdb.Scan(ctx, 0, "", 0).Iterator()
for iter.Next(ctx) {
    checker.Add(iter.Val())
}
if err := iter.Err(); err != nil {
    panic(err)
}

deleted := checker.Stop()
fmt.Println("deleted", deleted, "keys", "in", time.Since(start))

3.1 SCAN 游标遍历:为什么不用 KEYS

在大数据量下使用 KEYS * 会阻塞 Redis 主线程,因此 go-redis 官方一贯推荐 SCAN 系列命令。Scan 的底层命令构造位于 generic_commands.go,可以看到它对 match 与 count 参数做了可选拼接:

func (c cmdable) Scan(ctx context.Context, cursor uint64, match string, count int64) *ScanCmd {
    args := []interface{}{"scan", cursor}
    if match != "" {
        args = append(args, "match", match)
    }
    if count > 0 {
        args = append(args, "count", count)
    }
    cmd := NewScanCmd(ctx, c, args...)
    ...
}

示例中调用 rdb.Scan(ctx, 0, "", 0) 表示从游标 0 开始、不匹配模式、使用 Redis 默认 count。客户端负责把返回的游标回传给下一次调用,直到游标回到 0 完成遍历。

Iterator() 返回的 ScanIterator 在仓库中的实现位于 iterator.go,它内部封装了 ScanCmd,具备三个关键方法:

  • Next(ctx):判断当前页内是否还有未读元素,没有则自动用最新游标发起下一页 SCAN,Redis 偶发返回空页时还会自动重试;
  • Val():返回当前位置的 Key;
  • Err():返回遍历过程中的错误。

因此调用方只需 for iter.Next(ctx) 循环即可拿到全部 Key,无需手动管理游标,这正是示例主循环能如此简洁的原因。

3.2 批量 TTL 探测:一次 RTT 检查 100 个 Key

KeyChecker.checkKeys 是判定"无 TTL"的核心(main.go):

cmds, err := c.rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
    for _, key := range keys {
        pipe.TTL(ctx, key)
    }
    return nil
})
...
for i, cmd := range cmds {
    d, err := cmd.(*redis.DurationCmd).Result()
    ...
    if d == -1 {
        c.delCh <- keys[i]
    }
}

这里有两个值得注意的细节:

  • 判定标准:Redis TTL 命令返回 -1 表示"Key 存在但没有设置过期时间",这正是示例要删除的对象;返回 -2(Key 不存在)或其他正数秒数(有 TTL)都会被跳过;
  • 管道返回值顺序:Pipelined 返回的 []Cmder 与入队顺序一一对应,因此示例用 cmds[i] 与 keys[i] 下标对齐,逐个断言为 *redis.DurationCmd 后取结果。

Pipelined 的调用链在 redis.go 中委托给 Pipeline(),最终由 pipeline.go 实现:先执行入队回调,再统一 Exec。Exec 在命令列表为空时直接返回 nil, nil(见 pipeline.go),这一行为保证示例在末尾补一次 Exec 也不会出错。

为什么用 Pipeline?SCAN 遍历出的 Key 通常数量巨大,若逐个发 TTL 命令会产生海量 RTT。将 100 个 TTL 打包成一次网络往返,删除阶段同理打包 DEL,吞吐量可提升约一个数量级,且对 Redis 与客户端压力都更小。

3.3 双 goroutine 流水线:探测与删除解耦

KeyChecker 的核心设计是用两个通道(channel)串起"探测 → 删除"两个独立 goroutine(main.go):

  • 第一个 goroutine 运行 del(ctx):消费 delCh,把待删 Key 逐个入队到删除 Pipeline,pipe.Len() 达到 batchSize 即 Exec 一次;退出循环后再补一次 Exec 清空余量;
  • 第二个 goroutine 负责"攒批":从 ch 收取遍历线程送来的 Key,len(keys) == cap(keys)(即攒满 100 个)时调用一次 checkKeys,并用 keys = keys[:0] 复用底层数组,避免反复分配;最后处理不足一批的尾数。

两个 goroutine 通过 sync.WaitGroup 管理生命周期:Start 注册两个任务,Stop 关闭 ch 后 wg.Wait() 等待全部处理完毕,最终返回删除总数(main.go)。

del 阶段的实现(main.go)同样遵循"攒满一批再 Exec"的模式:

pipe := c.rdb.Pipeline()
for key := range c.delCh {
    fmt.Printf("deleting %s...\n", key)
    pipe.Del(ctx, key)
    c.deleted++
    if pipe.Len() < c.batchSize {
        continue
    }
    if _, err := pipe.Exec(ctx); err != nil {
        return err
    }
}
if _, err := pipe.Exec(ctx); err != nil {
    return err
}

四、参数与调优要点

示例通过 NewKeyChecker(rdb, 100) 传入批大小,理解该参数对线上调优很有帮助:

参数 默认值(示例) 作用 调优方向
batchSize 100 单次 Pipeline 打包的命令数(TTL 探测与 DEL 删除共用) 过小则 RTT 开销大;过大则单次命令包体过大、占用连接更久。建议在 50~500 之间根据 Key 平均长度实测
ch / delCh 缓冲 batchSize 解耦 SCAN 遍历速度与处理速度,防止生产者被阻塞 若 SCAN 很快而 TTL/DEL 较慢,可适当加大缓冲
Scan 的 count 0(使用 Redis 默认) 控制每次 SCAN 返回的元素数量 可改为如 1000,减少往返次数,但单次 SCAN 阻塞时间变长

另外,示例把 deleted 计数放在 del goroutine 内自增,且由 Stop() 在 wg.Wait() 之后读取,天然避免了并发计数竞态,这个"先同步再读数"的模式也值得借鉴。

五、运行与验证

在仓库根目录执行:

cd example/del-keys-without-ttl && go run .

前提是本机 :6379 已启动 Redis 服务(默认无密码)。正常输出形如:

deleting key_without_ttl_1...
deleting key_without_ttl_2...
deleted 2 keys in 1.2312ms

验证方式:运行后用 redis-cli 检查 TTL key_with_ttl 应返回正数秒(被保留),EXISTS key_without_ttl_1 应返回 0(已被删除)。

示例还会打印耗时 time.Since(start),可据此对比不同 batchSize 下的清理效率。若需扩展到生产场景,只需把 rdb.Scan(...) 换成带 MATCH 前缀的模式(如 rdb.Scan(ctx, 0, "cache:*", 1000)),并接入真实的连接配置(如 redis.NewClient(&redis.Options{Addr, Password, DB}))即可复用同一套 KeyChecker 逻辑。

六、小结

del-keys-without-ttl 示例完整展示了 go-redis 在高吞吐运维场景下的三个核心 API 组合拳:

  1. ScanIterator——以游标方式无阻塞遍历全部 Key,避免 KEYS 造成的服务停顿;
  2. Pipelined / Pipeline——将成百上千条 TTL、DEL 打包为少量网络往返,是"大规模清理"类任务的标准姿势;
  3. 基于 channel + sync.WaitGroup 的生产者-消费者流水线,把遍历、探测、删除三个速度不匹配的阶段解耦,并保证程序结束时所有删除任务可靠落库。

相关实现文件可直接对照阅读:main.go、iterator.go、generic_commands.go、pipeline.go 与 redis.go,从命令构造到管道执行形成完整闭环。

登录后查看全文
go-redis