ZSet不支持成员级过期,所谓“过期分数”是业务用时间戳作score的隐式设计;Redis只排序不清理,需用ZREMRANGEBYSCORE主动删除低分旧数据。

为什么 ZSet 里的“过期分数”不会自动删除
Redis 的 ZSET 本身不支持为单个成员设置过期时间。你看到的“过期历史分数”,其实是业务层用时间戳当 score 存进去的,比如 ZADD user:activity:2024 1698765432 "login",靠 score 数值隐式表达“时间早于某刻即视为过期”。但 Redis 不会主动扫描、比较或清理这些低分成员——它只管排序和范围查询,score 是纯数值,不是 TTL。
这意味着:即使你把一年前的 score 全设成 Unix 时间戳,只要没手动删,它们就一直占着内存。而 ZSET 的底层是跳跃表(skiplist)+ 哈希表,每个成员至少占用 64 字节以上(含指针、SDS、节点结构),几百万条冷数据能吃掉数 GB 内存。
- 别指望
EXPIRE或PEXPIRE能作用到 ZSet 成员上——这些命令只对整个 key 生效 -
TTL查不到成员级过期信息,因为根本不存在 - 惰性删除、定期删除机制对 ZSet 成员完全不触发
用 ZREMRANGEBYSCORE 清理低分旧数据最直接
这是唯一能物理释放 ZSet 成员内存的标准命令。它按 score 区间批量删除,原子执行、不阻塞主线程(只要范围不过大),且释放的内存立即可被其他 key 使用。
假设你用秒级时间戳作 score,想删掉 30 天前的所有记录:
127.0.0.1:6379> ZREMRANGEBYSCORE user:activity:2024 -inf (1698765432
其中 (1698765432 表示“严格小于该值”,避免误删刚好卡在边界的新数据。
- 务必加括号
(实现开区间,否则1698765432本身也会被删 - 不要用
ZREMRANGEBYRANK替代——它按排名删,无法对应时间语义,且可能误删高频新数据 - 如果 score 是毫秒级,注意单位统一,别混用秒和毫秒导致范围错位
- 线上执行前先用
ZCOUNT估算数量:ZCOUNT user:activity:2024 -inf (1698765432
大批量清理时避免阻塞,拆成小批次执行
单次 ZREMRANGEBYSCORE 删除超过 10 万成员,可能引起毫秒级延迟抖动(尤其在高负载实例上)。这不是命令本身慢,而是跳跃表节点回收、内存归还、后台合并等操作带来的瞬时压力。
更稳妥的做法是分批删,每次控制在 1k~5k 成员内:
- 先用
ZRANGE user:activity:2024 0 999 WITHSCORES拿头一批,看最小 score 是多少 - 再用
ZREMRANGEBYSCORE user:activity:2024 -inf (那个最小 score)删掉这批 - 重复直到
ZCARD返回 0 或剩余成员 score 都大于截止时间 - 脚本里加
time.sleep(0.1)避免连续压测,给 serverCron 留出调度空间
注意:不要依赖游标(ZSCAN 不支持 score 过滤),也不要自己遍历 + ZREM ——那会放大网络和命令解析开销,实际更慢。
长期设计建议:别把时间戳硬编码进 score
把时间戳当 score 是常见做法,但会让清理变成定时运维任务,也限制了 ZSet 的多维排序能力(比如你想按活跃度+时间双维度排序就做不了)。
更健壮的替代方案:
- 用独立 key 存时间元数据,比如
HSET user:activity:meta 20240101 "1698765432",主 ZSet 只存业务 score - 按天/小时切分 ZSet,如
user:activity:20241001,过期后直接DEL整个 key(释放更快、更彻底) - 若必须用时间戳,score 设计为
timestamp * 1000000 + priority,高位保时间精度,低位留业务权重,方便后续灵活截断
真正容易被忽略的是:ZSet 成员删除后,内存不会立刻归还给操作系统(glibc malloc 有缓存),而是留在 Redis 进程内供后续分配。所以 used_memory_human 下降不明显,但 mem_allocator_bytes 会减少——得看后者才反映真实释放效果。











