好久没聊 Redis 的底层细节了。前两天同事跑过来问我,说生产环境有个缓存 key 忘了设置过期时间,结果内存被撑爆,直接触发 OOM 挨了顿批评。他问我:设置过期时间这种基础操作,到底有没有一套标准的、稳妥的写法?我当时愣了一下,因为这问题看似简单,真往深了挖,能挖出一大串值得注意的细节。
先给结论:过期时间不是“设置完就完事”的附加功能,而是 Redis 内存管理里必须前置考虑的设计决策。无论你是刚接触 Redis 的新手,还是已经在生产环境摸爬滚打多年的老手,这篇内容都值得从头到尾捋一遍。我会从底层过期机制讲起,再把各种设置命令的细节对比、分布式场景下的坑、监控排查的手段全部展开,最后给出一套可以直接抄的实操方案。
1. 先从核心问题说起:Redis 到底是怎么实现“过期”的
1.1 过期键的两种存储方式
Redis 里每个键值对本质上是一个字典结构。当对某个 key 设置了过期时间,这个 key 并不会立刻从内存里消失,而是会被记录到另一个独立的字典中,这个字典叫“过期字典”(expires dict)。
这里有一个非常关键的概念:过期字典里存的是“key 的指针”和“过期时间戳”的映射关系。也就是说,被设置过期时间的 key 本身还在主字典里正常存储,只是多了一条过期记录。我见过不少初学者以为“设置过期时间等于定时删除”,其实完全不是一回事。这个设计直接影响后面要讲的删除策略。
举个例子,你执行:
SET user:10001 "supersun" EX 300这条命令执行后,内存中会发生三件事:
- 主字典写入
user:10001 -> "supersun"; - 过期字典写入
user:10001 -> 当前时间戳 + 300秒; - 返回 OK。
注意,这个 key 此刻依然是“活着”的,能被正常读取,也占着内存空间。真正的删除动作要等“删除策略”来触发。
1.2 三种过期删除策略的取舍
Redis 实际使用的过期键删除策略是“惰性删除 + 定期删除”的混合方案。你需要先理解这个混合方案的来龙去脉,才能真正明白为什么有时候 key 过期了,内存却迟迟没降下来。
惰性删除的逻辑很直观:每次读取 key 时,先检查这个 key 在不在过期字典里,如果在且已过期,就立即删除并返回 nil。这样能保证“读到的键一定不是过期键”,但问题也很明显——如果一个 key 过期后再也没人访问,它就永远躺在内存里,变成“僵尸数据”。
定期删除就是为了解决僵尸数据问题。Redis 每隔一段时间(默认 100ms 一次)随机抽取一批设置了过期时间的 key,检查并删除其中已过期的键。这里的关键词是“随机抽取”,不是全表扫描。因为如果 key 的数量特别多,全表扫描会阻塞主线程,后果就是所有请求排队等待,延迟飙升。
我打个比方:惰性删除就像商场门口的保安,只在有人进门时验票;定期删除就像巡逻队,每隔几分钟随机抽查一批人。但这两种方式都不能保证“所有过期票立刻被清走”。所以 Redis 还有一个兜底机制:内存淘汰策略。
1.3 内存淘汰策略与过期时间的联动
当 Redis 内存达到maxmemory限制时,会根据配置的淘汰策略主动清理数据。这个机制跟过期时间的关系非常密切:
allkeys-lru/allkeys-lfu/allkeys-random:对所有 key 生效,包括没设置过期时间的 key;volatile-lru/volatile-lfu/volatile-random/volatile-ttl:仅对设置了过期时间的 key 生效。
如果你只给部分 key 设计了过期时间,却把淘汰策略配成allkeys-lru,那么那些“永久 key”也有可能被 Redis 主动清掉。这招在某些业务场景下没问题,但如果你把 Redis 当数据库用,存了不能丢的数据,那就得格外小心了。
我建议所有使用 Redis 的人都养成一个习惯:在配置文件里写明maxmemory-policy,不要依赖默认值。我见过太多事故就是因为默认策略 + 无限内存 + 忘记过期时间,最终导致服务不可用。
提示:关于内存淘汰策略的详细配置,可以直接运行
CONFIG GET maxmemory-policy查看当前实例的配置。
2. 设置过期时间的四种核心命令:命令选择与参数语义
2.1 EXPIRE:最基础但也最容易踩坑的命令
EXPIRE是设置过期时间最原始的命令,语法如下:
EXPIRE key seconds这条命令是“以秒为单位”设置过期时间。我见过有人连续执行两次EXPIRE,以为第二次会“追加”时间,其实不是,第二次会直接覆盖第一次设置的值。
EXPIRE user:10001 300 EXPIRE user:10001 600上面两行执行完后,这个 key 的剩余生存时间变成 600 秒,而不是 900 秒。这个特性有时候蛮好用的,比如“续期”操作;但如果你没意识到它是覆盖语义,就可能出现“本来想延长时间,结果反而缩短了”的意外。
另外一个坑是:EXPIRE对不存在的 key 执行会返回 0。我在脚本里见过有人拿这个返回值做“key 是否存在”的判断,其实不完全可靠,因为返回 0 只能说明“要么 key 不存在,要么设置过期时间失败”。
2.2 SETEX 与 SET 命令的 EX 选项:原子性与可读性
SETEX是“SET + EXPIRE”的原子结合体,语法:
SETEX key seconds value它的最大价值是原子性。如果你在旧版本 Redis 上分开执行SET再执行EXPIRE,中间一旦发生崩溃或者其他客户端修改了这个 key,就可能留下一个“永久 key”。但SETEX一步到位,不存在中间状态。
在新版本 Redis(6.2+)里,我更推荐直接用SET命令自带的可选参数:
SET user:10001 "supersun" EX 300或者更精确一点,用毫秒级的PX:
SET user:10001 "supersun" PX 300000这里有个冷门但实用的选项:NX(只在 key 不存在时写入)和XX(只在 key 存在时写入)。它们可以和过期时间组合使用,组成分布式锁:
SET lock:order:10001 "worker-A" EX 30 NX这行命令的语义是:当且仅当锁不存在时,写入一个 30 秒后自动过期的锁。这一条命令同时实现了“加锁”“防重入”“自动过期”三个能力,是 Redis 分布式锁的基石。
2.3 EXPIREAT / SETEXAT / PEXPIREAT:指定时间戳的场景
有些场景下,你需要把过期时间设置为“某个未来的精确时间点”,而不是相对当前时间的偏移量。比如:会员到期时间是 2025-06-30 23:59:59,那最稳妥的做法不是计算“距离现在多少秒”,而是直接指定时间戳。
EXPIREAT user:vip:8888 1751299199 PEXPIREAT user:vip:8888 1751299199000EXPIREAT用秒级时间戳,PEXPIREAT用毫秒级时间戳。我强烈建议用毫秒级那个,因为有些业务场景对精度有要求,而且整数溢出风险更小(在 32 位系统上秒级时间戳在 2038 年会有问题,虽然 Redis 服务器基本是 64 位,但这个习惯值得养成)。
这里有一个很容易被忽视的语义:如果你传一个“已经过去”的时间戳,Redis 会立刻删除这个 key。这在某些清理逻辑里可以当“手动立即过期”来用。
2.4 TTL / PTTL:别忽略返回值“-2”和“-1”的含义
设置完成之后,怎么确认过期时间生效了?答案是TTL命令(秒级)和PTTL命令(毫秒级)。
TTL user:10001 PTTL user:10001返回值有几种情况,必须记清楚:
- 正整数:剩余存活秒数/毫秒数;
-1:key 存在但没有设置过期时间;-2:key 不存在或已过期。
很多人只记得检查“小于等于 0 就算过期”,但其实-1和-2的区别非常有用。在排查问题时,看到-1说明“这个 key 是永久 key”,看到-2说明“这个 key 已经不存在”。这两个值能帮你在几秒钟内判断出到底是谁的锅。
注意:在 Redis 6.0 之前,
TTL对不存在的 key 返回 -2;如果 key 存在但无过期时间,返回 -1。6.0 之后这个行为没变。但有些客户端库(例如老版本的某些中间件)会把这两个值都映射成“异常”,导致误判。建议直接在命令行用redis-cli验证返回值。
3. 底层数据结构解析:过期字典、时间戳与内存占用
3.1 过期字典的结构
我前面已经说过,过期字典是独立于主字典的。如果你用redis-cli的INFO命令查看内存详情,会发现expires这个字段统计的就是“设置了过期时间的 key 数量”。
这个字段非常有用。比如你想知道“当前实例有多少 key 最终会自动消失”,直接看INFO里的expires字段就行。
从源码角度讲,过期字典的值是一个long long类型的时间戳,单位是毫秒。每次EXPIRE设置时,Redis 会把这个值计算成“当前时间戳 + 秒数 × 1000”,然后存进去。每次访问 key 时,Redis 拿当前时间戳跟这个值比较,判断是否过期。
这个“值就是绝对时间戳”的设计,带来一个有意思的推论:只要服务器时间不跳变,过期时间的计算就是精确的。但如果管理员手动修改了系统时间,或者 NTP 同步发生大跨度跳变,就可能出现“key 集体提前过期”或“key 集体延迟过期”的现象。我真实遇到过一例:某台服务器 NTP 跳变,导致一批会话 key 提前失效,所有用户集体掉线。排查了半天才发现不是业务代码的问题,而是系统时间。
3.2 内存占用评估:过期时间本身也会占用内存
这一点很少人注意到:设置过期时间,本身是“有成本”的。每个进入过期字典的 key,都需要额外存储“key 指针 + 过期时间戳”这对映射关系。虽然单条开销不大,但如果你有几千万个 key 都设置了过期时间,这部分内存就不是小数了。
我做过一次粗略估算:一个平均长度的 key(比如 20 字节),加上过期字典的指针和时间戳开销,大约要多占 40~80 字节。一千万个 key 就是 400MB~800MB 的额外内存。这还没算主字典本身的开销。
所以内存规划时,不建议“不管有没有必要,一律都设过期时间”。正确的姿势是:该过期的才过期,不该过期的(比如长期不变的配置类数据)就不设置。如果你担心“忘记设置过期时间导致内存膨胀”,更好的办法是配合maxmemory-policy里volatile-*系列的淘汰策略,强制让“必须能清理的数据”都进入过期字典管理范围。
3.3 过期键的删除时机:为什么内存不会立即下降
继续深入聊删除时机。前面的混合删除策略决定了:你设置了一个 60 秒过期的 key,可能在 60 秒后的几毫秒内就被定期删除流程清掉,也可能因为一直没被访问、且一直没被抽样抽到,而继续停留很久。
这就有个实际体验:你用DEL删除大 key 时会发现内存曲线陡降,但用“等待过期”的方式清数据,内存曲线往往是楼梯状缓慢下降。有人觉得这是“内存泄漏”,其实是策略本身的特性。
想要“控制”删除节奏?可以调低hz参数,但这会影响定期删除的执行频率,也会影响其他后台任务的调度,不建议轻易动。还有一个偏门但有效的办法:主动访问那些“预期已过期但还没被删”的 key,触发惰性删除。
4. 从设计角度出发:什么时候该用过期时间,什么时候不该用
4.1 适合设置过期时间的场景
最常见的三类场景:
会话管理。用户登录 token、session 这类数据天然有生命周期。设置过期时间等于自动清理逻辑,不用专门写定时任务去扫描删除。
缓存提高性能。热点数据、接口响应结果、配置快照等,设置一个合理的 TTL,让它自动失效后重新加载,能保证数据最终一致性。
限流和防重。比如“5 分钟内最多允许请求 100 次”,会用到INCR + EXPIRE组合。这个组合有一个坑:如果第一次INCR成功后,还没来得及EXPIRE,进程就崩溃了,这个 key 就变成永久 key。解决方案是用 Lua 脚本把两步操作原子化,或者干脆用 Redis 官方推荐的滑动窗口限流脚本。
4.2 不适合设置过期时间的场景
持久化业务数据。比如订单状态、用户余额、消息记录。如果只用 Redis 存储这些关键数据,设置过期时间等于给自己埋雷。万一业务逻辑没及时续期,数据就消失得无影无踪。
需要长期保持一致性的计数器。比如某个累计统计值,过期后重新计数会导致报表断裂。除非你能接受重新计数,否则别设置过期时间。
共享给多个服务的“元数据”。比如服务发现信息、路由表,这些数据如果过期时间设置不合理,可能导致某个服务瞬间丢失全局视图,引发雪崩。这类数据建议“手动更新 + 心跳保活”,而不是单纯依赖过期。
4.3 过期时间长短的设计经验
这里没有统一标准,但有几个经验值可以借鉴:
- token 类:一般 30 分钟到 2 小时,具体取决于业务安全性要求;
- 验证码类:5~10 分钟;
- 热点配置类:5~30 分钟;
- 限流窗口类:取决于窗口长度,通常 1~60 秒;
- 会话类:如果用户活跃度高,考虑“滑动过期”,每次访问就重新
EXPIRE。
“滑动过期”是一个重要技巧,代码如下:
# 读之前先查 TTL,小于某个阈值就续期 if TTL session:user:8888 < 600 EXPIRE session:user:8888 1800 end这段逻辑如果用 Lua 执行,可以避免并发下多个客户端同时续期导致 TTL 被覆盖成小值的隐患。
5. 实操实录:分布式锁、限流器与缓存穿透处理
5.1 分布式锁的标准实现与过期时间设定
前面提到的SET key value EX seconds NX是分布式锁的推荐写法,但真正落地时还需要考虑锁续期问题。
一个常见问题:业务执行时间超过锁的过期时间,导致锁被自动释放,其他客户端趁虚而入。解决办法是“看门狗”机制:后台线程每隔一段时间检查锁是否还存在,如果存在且还是自己的标识,就重新设置过期时间。
但我不建议自己写看门狗,直接用成熟的客户端库更好。比如某些 Redis 客户端框架内置了“自动续期”的锁实现,会周期性执行PEXPIRE。如果你真的自己写,核心逻辑是这样的:
# 伪代码:每 10 秒执行一次续期,锁过期时间 30 秒 while (holding_lock) { if (GET lock:order:10001 == "worker-A") { PEXPIRE lock:order:10001 30000 } sleep(10) }5.2 限流器实现:INCR + EXPIRE 的原子性问题
限流器最简版本:
INCR rate:user:10001:minute EXPIRE rate:user:10001:minute 60这段代码的问题在于:如果INCR后EXPIRE前崩溃,key 就永不消失。用 Lua 脚本可以规避:
local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return current这样当计数第一次从 0 变 1 时,立即设置过期时间,之后只做递增,不再重复设置,既保证了时间窗口的准确性,又避免了永久 key。
5.3 缓存穿透与“空值缓存 + 短过期”
缓存穿透是指请求查一个必然不存在的数据,导致每次请求都打到数据库。常见解法是把空值也缓存起来,但设置一个较短的过期时间。
SET cache:user:99999 "" EX 60 NX这个场景下过期时间不要太长,30~60 秒比较合适。太短起不到保护作用,太长会导致“数据已经创建但缓存里还是空值”的延迟。
我用过很多次这个方案,配合布隆过滤器效果更好。不过布隆过滤器又是一套独立的体系,这里不展开,只提醒一点:布隆过滤器本身不支持删除操作,如果数据频繁增删,建议用增强版结构。
5.4 缓存雪崩中的过期时间“错峰”技巧
缓存雪崩常见的诱因之一是:大量 key 在同一时刻过期。比如你批量写了一批热点数据,全部设置成 3600 秒过期,那么一小时后它们会集体失效,数据库瞬间被打爆。
解决办法是在过期时间上加入随机扰动:
SET cache:hot:1001 "value" EX 3600 + random(0, 300)不同 key 的过期时间分布在 3600~3900 秒区间,错开峰值。这个技巧成本极低,效果立竿见影。我每次批量写入缓存时都会加这个随机偏移,实测能显著降低数据库压力波动。
6. 进阶话题:用 Lua 脚本保证原子性,用 SCAN 清理过期键
6.1 为什么需要脚本原子性
Redis 的单个命令是原子性的,但多个命令组合在一起就不是了。EXPIRE 和 SET、GET 和 EXPIRE 组合,都有中间状态窗口。在这个窗口里,其他客户端可能读到旧值或者干扰状态。
Lua 脚本在 Redis 里是原子执行的:执行期间不会插入其他命令,整个脚本作为一个整体被处理。所以复杂操作,尤其涉及“判断 + 操作”的多步流程,优先考虑 Lua。
下面这个脚本实现“如果 key 存在且值等于期望值,才更新并设置过期时间”:
if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3]) return 1 end return 0这种场景在“乐观锁更新”“条件续期”里特别常用。
6.2 用 SCAN 分批清理“漏网之鱼”
虽然 Redis 有定期删除,但有些命硬的过期 key 可能会残留较长时间。如果你想主动清理又不想阻塞主线程,千万别用KEYS *,那个命令在大数据量下会卡死 Redis。应该用SCAN命令分批迭代。
SCAN 0 COUNT 1000这会返回一个游标和一批 key。接着你可以在客户端对每个 key 执行TTL,如果发现TTL为 -2,说明已经不存在,不必处理;如果为 -1,说明是永久 key,需要你决定是补设过期时间还是手动删除。
示例思路(伪代码):
import redis r = redis.Redis(host='localhost', port=6379, db=0) cursor = 0 while True: cursor, keys = r.scan(cursor, count=1000) for key in keys: ttl = r.ttl(key) if ttl == -1 and key.startswith('cache:'): r.expire(key, 300) if cursor == 0: break这段代码会把所有cache:开头的永久 key 统一补设一个 300 秒过期时间,相当于做一次“逃生舱清理”。
6.3 Redis 7.x 的新特性:过期时间相关的改进
Redis 7.x 在过期处理上做了一些改进,比如更高效的后台淘汰机制、对--bigkeys扫描的增强。另外 7.4 引入了一些新的数据结构和命令,但与过期时间设置无本质变化,核心用法不变。
如果你还在用 Redis 5.x / 6.x,我也不建议为了“过期时间”这个功能专门升级。但如果你同时想用更完善的访问控制、更好的内存管理、AOF 相关的性能优化,升级到 7.x 是值得考虑的。唯一要注意的是兼容性测试,尤其是一些老客户端库可能不支持新特性。
7. 生产环境实战指南:配置、监控与排查
7.1 监控指标:INFO 命令里的关键字段
排查过期时间相关问题时,第一时间用redis-cli INFO查看几个关键指标:
| 指标 | 含义 |
|---|---|
expires | 设置了过期时间的 key 数量 |
expired_keys | 累计被删除的过期 key 数量 |
evicted_keys | 因内存淘汰被删除的 key 数量 |
keyspace_hits/keyspace_misses | 缓存命中与未命中次数 |
used_memory/maxmemory | 当前内存使用与上限 |
把这几个字段接入监控告警(比如每分钟采集一次),能第一时间发现异常。我习惯关注expired_keys的增速:如果某个时间段突然飙升,说明大量 key 集中过期,要检查是不是设置了相同的 TTL;如果它一直是 0,说明要么没有设置过期时间,要么定期删除根本没跑。
mem_fragmentation_ratio这个字段也要瞄一眼,它代表内存碎片率。如果过高(比如超过 1.5),即使过期 key 被删了,内存也不一定能马上返还给操作系统,因为碎片还在。
7.2 生产事故案例分析:忘了设置过期时间之后
说说我遇到的真实案例(细节做了脱敏):
某个内部系统的缓存层,所有 key 统一用一种 KeyGenerator 生成,代码里只写SET key value,完全没有EXPIRE。上线初期数据量小没事,半年后 key 数量累计到千万级,内存持续走高,最终触发 OOM。
排查过程:
- 先用
INFO keyspace查看当前 key 数量,发现异常庞大; - 用
MEMORY USAGE key抽样几个 key,看单个 key 的内存占用; - 用
redis-cli --bigkeys扫描,定位大 key; - 最后发现是缓存 key 没有过期时间,导致只增不减。
修复方案:
- 写脚本用
SCAN批量扫描,对所有cache:前缀的 key 补设过期时间,先压到 24 小时,逐步缩短到目标值; - 修改业务代码,在写入缓存时统一加
EX参数; - 配置
maxmemory-policy volatile-lru,确保即使有漏网之鱼也能被淘汰。
整个过程大概花了一个下午。事后复盘,最核心的教训是:生产环境里应该在缓存层封装一个“统一缓存读写工具类”,强制要求任何写缓存操作都必须指定 TTL。宁可把 TTL 设长一点,也不能不设。
7.3 快速排查命令速查表
| 场景 | 命令 |
|---|---|
| 查看单个 key 剩余时间 | TTL key或PTTL key |
| 查看某个 key 内存占用 | MEMORY USAGE key |
| 查看全部 key 数量 | DBSIZE |
| 查看当前库所有 key 概况 | INFO keyspace |
| 扫描大 key | redis-cli --bigkeys |
| 扫描所有 key(分批) | SCAN 0 COUNT 1000 |
| 查看淘汰策略 | CONFIG GET maxmemory-policy |
| 查看内存上限 | CONFIG GET maxmemory |
| 手动立刻过期某个 key | EXPIRE key 1(1 秒后过期)或者PEXPIREAT key 1 |
这套命令组合下来,绝大多数过期时间相关问题都能在 10 分钟内定位。
7.4 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| key 一直占内存,但 TTL 显示已过期 | 惰性删除还没触发,定期删除还没抽到 | 手动访问该 key 触发惰性删除,或等待定期删除执行 |
| TTL 返回 -1 | key 存在但没有设置过期时间 | 用EXPIRE补设过期时间 |
| TTL 返回 -2 | key 不存在或已过期删除 | 检查命令是否打错了 key,或确认过期策略是否意外删除 |
| 执行 EXPIRE 返回 0 | key 不存在 | 先 SET 再 EXPIRE,或用 SETEX 原子完成 |
| 设置了过期时间但内存不降 | 过期 key 删除后内存碎片未返还 | 重建实例或执行MEMORY PURGE(慎用) |
| 大量 key 同一时刻过期 | 统一 TTL 未加随机扰动 | 写缓存时加入随机偏移量 |
| 设置过期时间失败但返回值正常 | 某些旧客户端库不支持 EX/PX 参数 | 更新客户端库或改用 Lua 脚本 |
8. 命令行与主流语言客户端的落地写法
8.1 redis-cli 常用操作
命令行直接测试是最快的:
redis-cli SET token:abc "hello" EX 60 redis-cli TTL token:abc redis-cli PTTL token:abc如果希望“不存在才设置”且“附带过期时间”:
redis-cli SET lock:user "1" EX 30 NX返回 OK 表示设置成功,返回 nil 表示 key 已存在。
检查返回结果时注意redis-cli默认输出格式。SET成功返回OK,EXPIRE成功返回(integer) 1,失败返回(integer) 0。这些细节在生产脚本里经常被忽略。
8.2 Python 客户端:redis-py 的推荐写法
import redis r = redis.Redis(host='localhost', port=6379, decode_responses=True) # 推荐:SET + EX 一步到位 r.set('user:10001', 'supersun', ex=300) # 检查剩余时间 ttl = r.ttl('user:10001') print(ttl) # 设置过期时间(对已经存在的 key) r.expire('user:10001', 600) # 分布式锁写法 ok = r.set('lock:order:10001', 'worker-A', ex=30, nx=True)重点提示:redis-py的set方法里ex单位是秒,px单位是毫秒,两个参数不能同时传。如果传了nx=True,返回值是True或None,不是字符串'OK',判断逻辑不要搞错。
连接池方面,建议给不同业务场景配置不同的连接池,避免一个慢查询拖垮所有请求。
8.3 Java 客户端:Jedis 与 Lettuce 的写法
Jedis 示例:
Jedis jedis = new Jedis("localhost", 6379); jedis.set("user:10001", "supersun", new SetParams().ex(300)); Long ttl = jedis.ttl("user:10001"); jedis.expire("user:10001", 600); jedis.close();Lettuce 示例(Spring Boot 场景经常用):
stringRedisTemplate.opsForValue().set("user:10001", "supersun", Duration.ofSeconds(300)); Long ttl = stringRedisTemplate.getExpire("user:10001", TimeUnit.SECONDS); stringRedisTemplate.expire("user:10001", 600, TimeUnit.SECONDS);Java 生态里我特别提醒一点:很多团队封装了一个RedisUtil工具类,里面写缓存时统一调用一个set(key, value, timeout)方法,这个方法内部可能会先SET再EXPIRE。你去看它的实现,如果发现是两步操作,强烈建议改成SET命令自带的EX参数,或者用SETEX。否则在高并发 + 网络抖动环境下,可能出现“设置了值但没设置过期时间”的隐患。
8.4 Go 客户端:go-redis 的写法
import ( "context" "time" "github.com/redis/go-redis/v9" ) rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"}) ctx := context.Background() err := rdb.Set(ctx, "user:10001", "supersun", 300*time.Second).Err() if err != nil { panic(err) } ttl, err := rdb.TTL(ctx, "user:10001").Result() if err != nil { panic(err) } err = rdb.Expire(ctx, "user:10001", 600*time.Second).Err()在 Go 里注意:Set的过期时间参数类型是time.Duration,如果传 0 表示不过期。有些新手误以为传0会立即过期,实际效果是完全相反。想让 key 立刻过期,应该设置一个极小值或直接Del。
8.5 Node.js 客户端:node-redis 的写法
const { createClient } = require('redis'); const client = createClient({ url: 'redis://localhost:6379' }); await client.connect(); await client.set('user:10001', 'supersun', { EX: 300 }); const ttl = await client.ttl('user:10001'); await client.expire('user:10001', 600); await client.quit();Node.js 版本的 API 里,set的第三个参数是选项对象,属性名是EX(大写)和NX这种。不同版本的 node-redis 对EX选项的支持有细微差异,升级客户端版本后建议重新跑一遍集成测试。
9. 从实践角度总结的注意事项
写到这里,把这几天沉淀的实操经验浓缩成几条核心清单,供你在设计和排查时直接对照。
- 所有写缓存的代码路径,必须显式传入过期时间,禁止“裸 SET”。可以在封装层拦截,比如 key 上没有 TTL 就不让写入。
- 读多写少、允许短时间不一致的数据,优先设 TTL;强一致、不能丢的数据,不要依赖 TTL 清理,要用明确的淘汰/归档机制。
- 设置过期时间前先想清楚是“相对时间”还是“绝对时间”。业务上有明确到期日的,用
EXPIREAT系列;只是控制缓存长度的,用EX系列。 - 多个命令组合操作时,用 Lua 脚本保证原子性,尤其涉及“判断当前值再更新”的场景。
- 生产环境监控一定要覆盖
expired_keys和evicted_keys。前者异常激增,可能是有设计缺陷;后者出现,通常说明内存已满,在淘汰数据。 - 别在生产环境直接执行
FLUSHALL或FLUSHDB来清理过期键,这个操作是同步阻塞的,数据量大时会导致长时间的 Redis 不可用。真要清,用脚本分批删除。
Redis 的过期时间机制设计得相当精妙:它不追求“立即清理”,而是用惰性删除、定期删除和内存淘汰三层机制,在性能、内存和实现复杂度之间取得了平衡。理解这套机制后,你再遇到“为什么内存没有立刻降下来”这类问题,就不会慌了。
最后分享一个小经验:排查任何 Redis 问题时,第一步永远是看INFO里的内存和键统计数据,再看TTL,最后再进代码。这个顺序能帮你把“配置问题、数据问题、代码问题”快速区分开,少走很多弯路。希望这篇内容对你有实际帮助。