使用 go-redis SCAN 与 Pipeline 高效清理无 TTL 的 Key:del-keys-without-ttl 示例深度解析
使用 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 探测 → 并发删除
整个程序由两个阶段组成,可拆解为三步:
- 全量遍历:用
rdb.Scan(ctx, 0, "", 0).Iterator()获得游标迭代器,逐个取出 Key 交给KeyChecker; - 批量探测:内部 goroutine 将 Key 攒满一批(默认 100 个)后,通过一次 Pipeline 批量发送
TTL命令,筛出 TTL 为-1(无过期时间)的 Key; - 并发删除:另一个 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 组合拳:
ScanIterator——以游标方式无阻塞遍历全部 Key,避免KEYS造成的服务停顿;Pipelined/Pipeline——将成百上千条TTL、DEL打包为少量网络往返,是"大规模清理"类任务的标准姿势;- 基于 channel +
sync.WaitGroup的生产者-消费者流水线,把遍历、探测、删除三个速度不匹配的阶段解耦,并保证程序结束时所有删除任务可靠落库。
相关实现文件可直接对照阅读:main.go、iterator.go、generic_commands.go、pipeline.go 与 redis.go,从命令构造到管道执行形成完整闭环。