SCAN 与批量处理
为什么不要使用 KEYS *
KEYS 会一次遍历匹配键空间。在数据量大时,它可能长时间占用 Redis 主线程,并一次返回大量数据。
生产代码应使用增量迭代的 SCAN。SCAN 每次返回一部分键和下一次游标,不会要求一次处理全部数据。
Node-Redis 的异步迭代器
for await (const keys of client.scanIterator({
MATCH: "nloop:user-cache:*",
COUNT: 100,
})) {
if (keys.length === 0) {
continue;
}
const values: Array<string | null> = await client.mGet(keys);
console.log(keys, values);
}
官方示例中的 scanIterator() 每次迭代得到一批键,而不是单个键。
COUNT 是服务端每轮工作的提示值,不保证每次准确返回相同数量。
其他数据类型的扫描
for await (const { field, value } of client.hScanIterator("nloop:user:42")) {
console.log(field, value);
}
for await (const member of client.sScanIterator("nloop:tags")) {
console.log(member);
}
for await (const { score, value } of client.zScanIterator("nloop:ranking")) {
console.log(score, value);
}
它们分别对应 HSCAN、SSCAN、ZSCAN。
SCAN 的语义限制
扫描期间数据仍可能变化,因此:
- 同一个键可能被看到多次。
- 扫描开始后才创建的键不保证被看到。
- 扫描期间删除的键可能不再出现。
- 调用方应让处理逻辑具备幂等性。
SCAN 适合后台维护、诊断和渐进式迁移,不适合生成要求强一致的业务报表。
安全删除一类键
async function deleteByPattern(pattern: string): Promise<number> {
let deleted: number = 0;
for await (const keys of client.scanIterator({
MATCH: pattern,
COUNT: 100,
})) {
if (keys.length === 0) {
continue;
}
deleted += await client.del(keys);
}
return deleted;
}
删除大键时,DEL 释放内存也可能产生明显停顿。Redis 支持 UNLINK 异步回收值占用的内存:
await client.unlink(keys);
是否使用 UNLINK 应结合服务端版本和运维要求。
与 keyPrefix 一起使用的陷阱
如果客户端配置了 keyPrefix: "nloop:":
MATCH需要自己写nloop:user:*。- 扫描结果已经包含
nloop:。 - 把结果直接传回同一个带前缀客户端可能变成
nloop:nloop:user:1。
批量维护任务通常使用不配置自动前缀的专用客户端更清晰。
批量任务最佳实践
- 限制每批数量,不一次加载全部键和值。
- 记录进度、错误数量和耗时。
- 处理函数设计成重复执行也安全。
- 避开业务高峰期。
- Cluster 下扫描是节点级问题,需要按集群 API 和业务目标设计,不能假设一个游标覆盖全体节点。