数据库基础体系 · 第 121/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Redis 过期与 Keyspace 通知:惰性删除、主动删除、事件和可靠性

**版本与范围:**以 Redis 官方稳定版本公开语义为准。文中的通知示例以单机 Redis、逻辑数据库 0、非事务命令为边界;集群、复制和持久化会单独说明。

一、先区分四个容易混淆的概念

Redis 中“过期”至少涉及四个不同问题:

  1. 过期时间是否已经到达
  2. 过期键是否已经从数据库中删除
  3. 删除动作是否产生了通知事件
  4. 客户端是否可靠地收到通知

它们不是同一时刻发生的事情。

设一个键的绝对过期时间为 DD,Redis 当前时间为 tt

tDt \ge D

只能说明这个键在语义上已经过期,不能推出它已经从内存中删除。Redis 可能在以下时刻删除它:

  • 客户端访问该键时;
  • Redis 的主动过期周期扫描到它时;
  • 某些命令或内部流程触发检查时。

因此可以把实际删除时刻记为 RR,通常满足:

RDR \ge D

R>DR>D 时,键已经过期但仍可能短暂占用内存,也可能在某些没有访问它的时间窗口内继续出现在内部数据结构中。

这就是后文必须区分的:

  • 惰性删除(lazy expiration):访问时发现过期,再删除;
  • 主动删除(active expiration):Redis 后台周期性寻找并删除过期键;
  • 过期事件(expired event):删除动作发生时,按通知配置发布的事件;
  • 通知可靠性:订阅者能否完整、按需、持久地获得这些事件。

二、Redis 如何保存过期时间

2.1 过期时间不是键值本身的一部分

Redis 的逻辑结构可以简化为:

key -> value
key -> absolute expire time

只有设置过过期时间的键才有对应的 expire 信息。常见命令包括:

SET session:42 abc EX 60
EXPIRE session:42 60
PEXPIRE session:42 60000
EXPIREAT session:42 1735689600
PEXPIREAT session:42 1735689600000

EXPIREPEXPIRE 接收相对时间,EXPIREATPEXPIREAT 接收绝对 Unix 时间。Redis 内部会依据绝对时间判断是否过期,因此系统时钟变化也会影响过期判断。

查看剩余时间:

TTL session:42
PTTL session:42

返回值有特殊含义:

-1  键存在,但没有设置过期时间
-2  键不存在
>=0 剩余秒数或毫秒数

例如:

SET k v
TTL k

可能得到:

-1

然后:

EXPIRE k 10
TTL k

可能得到:

10

但由于 TTL 在倒计时,实际返回值会随时间减少,并不保证每次都恰好等于预期设置值。

2.2 修改键可能保留或移除过期时间

下面的命令会设置过期时间:

SET k v EX 10

对已有键重新执行普通 SET 时,通常会替换值,并清除原有过期时间:

SET k v EX 10
SET k new-value
TTL k

此时 TTL k 会返回 -1

因此,缓存更新代码如果先设置 TTL、再单独覆盖值,可能意外把 TTL 清除。更安全的形式通常是:

SET k new-value EX 10

删除过期时间:

PERSIST k

删除键:

DEL k

DEL 是显式删除,不是过期删除。即使这个键的过期时间已经到达,如果它先被 DEL 删除,相关通知属于 del,而不是 expired


三、惰性删除:访问路径上的过期检查

3.1 基本过程

当客户端执行读取或其他需要判断键是否存在的命令时,Redis 会检查该键是否已过期。逻辑过程可以表示为:

收到命令
  ↓
定位 key
  ↓
检查 key 是否有过期时间
  ↓
如果未过期:继续执行命令
如果已过期:删除 key,再按“键不存在”继续执行

例如:

SET token abc EX 1

等待超过 1 秒后执行:

GET token

返回:

(nil)

这次 GET 触发了对 token 的过期检查。实际删除时,Redis 可能同时产生过期通知,前提是通知已启用。

惰性删除的优点是:

  • 不需要主动扫描所有过期键;
  • 只有被访问的键才会产生检查成本;
  • 过期键不会在访问结果中继续作为有效数据返回。

但它有明显边界:从未再次访问的过期键可能暂时留在内存中。因此,惰性删除不能单独保证内存会及时回收。

3.2 惰性删除不是“到点自动执行的定时器”

下面这个推理是错误的:

设置 EX 60 后,60 秒一到 Redis 就一定立刻删除键,并且立刻发布事件。

正确的语义是:

  1. 过期时间达到后,键不应再被当作有效键使用;
  2. Redis 会在访问检查或主动过期周期中发现它;
  3. 实际删除时间可能晚于过期时间;
  4. 过期通知对应的是删除动作,而不是一个严格准点的计时器回调。

如果业务需要“在某个时间点必须执行一次任务”,不能只把 EXPIRE 当作可靠调度器。


四、主动删除:后台过期周期

4.1 为什么需要主动删除

如果 Redis 只做惰性删除,那么大量从未被访问的过期键会继续占用内存。主动过期机制由 Redis 的周期性任务完成,通常称为 active expire cycle。

它不会每次都遍历所有键,而是从可能包含过期键的结构中抽样检查。抽象流程如下:

周期任务开始
  ↓
选择逻辑数据库及其过期键集合
  ↓
抽取一批候选键
  ↓
检查每个候选键的绝对过期时间
  ↓
删除已经过期的键
  ↓
如果本轮过期比例较高,继续处理更多样本
  ↓
达到本轮时间预算后结束

这种设计在“扫描成本”和“回收速度”之间做折中:

  • 全量扫描会带来明显 CPU 开销;
  • 扫描太少又会让过期键积压;
  • 当抽样中发现较多过期键时,Redis 会倾向于继续处理;
  • 每轮主动过期都有时间预算,避免长期阻塞正常命令。

Redis 的 hzactive-expire-effort 等配置会影响周期任务的调度频率或主动过期投入,但它们不构成“精确到某毫秒删除”的保证。具体算法和内部优化属于实现细节,不能把当前版本的抽样比例当作永久 API 契约。

4.2 主动删除可能延迟多久

设:

  • DD:键的过期时间;
  • RR:主动扫描实际删除时间;
  • L=RDL=R-D:过期后的删除延迟。

Redis 只要求业务语义上在过期后不再把该键作为有效值返回;对于后台回收而言,LL 受以下因素影响:

  • 服务器事件循环负载;
  • 逻辑数据库数量;
  • 过期键数量;
  • 主动过期配置;
  • 单次事件循环可用时间;
  • 其他命令或模块任务的竞争。

因此不能用:

TTL = 0

推断:

键已经立刻从内存中删除

也不能用过期时间精确触发业务动作。

4.3 主动删除和内存淘汰不是一回事

Redis 的 maxmemory 淘汰解决的是:

内存达到限制时,选择哪些键释放空间。

过期删除解决的是:

键自身设置的过期时间已经到达,删除这个键。

例如,一个没有 TTL 的键也可能因为 maxmemory 策略被淘汰;一个有 TTL 的键也可能先因内存压力被淘汰。二者事件类型不同:

  • expired:因过期删除;
  • evicted:因内存淘汰删除。

如果业务把两者都当成“缓存自然过期”,就会误判缓存失效原因。


五、过期键在命令、复制和持久化中的行为

5.1 过期键在命令结果中的表现

对已经过期但尚未被主动扫描的键,客户端不会因为“内部还存在”而读取到旧值。访问路径会先执行过期检查,然后把它当作不存在的键处理:

SET k value EX 1

等待过期后:

EXISTS k
GET k
TTL k

典型结果分别是:

(integer) 0
(nil)
(integer) -2

这体现了:

  • 内存回收时间可以晚于逻辑过期时间;
  • 逻辑读取语义不会把过期值当作有效值返回。

5.2 复制环境中的边界

Redis 复制是异步的。主节点和副本节点可能处于不同时间状态:

  • 主节点过期并删除键后,会把删除结果传播给副本;
  • 副本在复制状态下通常不会独立地把主节点数据当作自己的业务状态随意过期,而是依赖主节点传播的删除;
  • 主节点和副本之间存在网络延迟时,副本短时间内可能保留已经在主节点删除的键;
  • 发生故障转移时,新主节点的过期判断和本地时间也会影响最终删除时机。

因此,过期不是跨节点精确同步的定时事件。对于复制、哨兵或集群环境,必须说明事件监听的是哪个节点。

5.3 RDB 与 AOF 的影响

RDB 保存过期信息。加载 RDB 时,如果键在加载时已经过期,Redis 不应把它恢复为有效键。

AOF 会记录导致状态变化的命令。过期键实际被删除时,Redis 可能追加相应的删除记录,以便重放后得到一致状态。恢复过程的本质是重建数据状态,而不是重放一个可靠的“业务过期回调”。

这意味着:

  • Redis 重启后不会因为错过某个 Pub/Sub 消息而自动补发业务事件;
  • 持久化保证的是数据状态恢复,不是通知消费位点恢复;
  • 不能用 AOF 或 RDB 替代业务事件日志。

六、Keyspace 通知到底通知什么

6.1 两种频道模型

Redis Keyspace Notifications 提供两类 Pub/Sub 频道。

假设:

数据库:0
键名:mykey
事件:expired

Keyevent 频道

频道名包含事件类型:

__keyevent@0__:expired

消息内容是键名:

mykey

Keyspace 频道

频道名包含键名:

__keyspace@0__:mykey

消息内容是事件类型:

expired

两者表达的是同一个事件,只是索引方向不同:

订阅方式 频道 消息
Keyevent __keyevent@0__:expired mykey
Keyspace __keyspace@0__:mykey expired

如果消费者关心“所有过期键”,Keyevent 形式通常更直接;如果消费者只关心某个键发生了什么变化,Keyspace 形式更便于按键订阅。

6.2 通知默认关闭

Keyspace Notifications 默认关闭,因为它会增加事件生成和发布成本。使用前需要配置通知类型:

CONFIG SET notify-keyspace-events Ex

这里:

  • E:启用 Keyevent 频道;
  • x:启用 expired 事件。

然后在另一个终端订阅:

PSUBSCRIBE '__keyevent@0__:expired'

再执行:

SET demo:token abc EX 2

等待几秒,订阅端可能收到:

1) "pmessage"
2) "__keyevent@0__:expired"
3) "__keyevent@0__:expired"
4) "demo:token"

不同客户端对 Pub/Sub 返回值的展示格式可能不同,但核心信息是:

频道:__keyevent@0__:expired
消息:demo:token

配置状态可以查看:

CONFIG GET notify-keyspace-events

在需要两类频道时:

CONFIG SET notify-keyspace-events Kx

表示:

  • K:启用 Keyspace 频道;
  • x:启用 expired 事件。

实际生产配置应根据版本文档和所需事件选择标志,不要无差别打开所有通知类型。写操作、列表变化、集合变化、淘汰和过期都可能产生大量事件。

6.3 expired 不是“时间到达事件”

这是 Keyspace Notifications 最重要的语义边界。

Redis 官方语义强调,expired 事件是在 Redis 实际删除过期键时生成的,而不是在键的 TTL 刚好变成零的瞬间生成的。

因此:

过期时间到达
    ≠
expired 通知立即产生

可能的过程是:

t=0      SET k v EX 2
t=2      k 达到过期时间,但尚未被访问或扫描
t=3      客户端 GET k
t=3      Redis 发现 k 已过期,删除 k
t=3      发布 expired 事件

也可能是主动过期周期在 t=2.1 删除并发布事件。

反例:

t=0      SET k v EX 2
t=2.5    DEL k

如果 DEL 先删除了这个键,通知应体现显式删除,而不是把它改写成 expired 事件。消费者不能仅凭“最终键不存在”推断删除原因。


七、一个完整的可运行验证

以下示例使用单机 Redis 和 redis-cli

7.1 验证过期时间与访问行为

终端一:

FLUSHDB
SET cache:user:1 '{"name":"Ada"}' EX 3
TTL cache:user:1

预期得到一个接近 3 的整数。

等待 4 秒后执行:

GET cache:user:1
EXISTS cache:user:1
TTL cache:user:1

典型结果:

(nil)
(integer) 0
(integer) -2

这里的 GET 可能触发惰性删除。即使不执行 GET,主动过期机制也可能已经完成删除。

7.2 验证 Keyevent expired 通知

终端二:

CONFIG SET notify-keyspace-events Ex
PSUBSCRIBE '__keyevent@0__:expired'

终端一:

SET cache:user:2 value EX 2

等待事件,终端二会收到键名:

cache:user:2

注意几个实验风险:

  1. CONFIG SET 修改的是当前 Redis 实例,重启后是否保留取决于配置管理方式;
  2. 如果实例启用了 ACL,执行配置命令可能没有权限;
  3. 如果通知配置没有包含 Ex,Keyevent 频道不会收到 expired;
  4. 如果键在通知配置生效前已经被删除,不会补发历史事件;
  5. 事件到达时间可能晚于 TTL 到期时间。

7.3 验证 Keyspace 形式

终端二改为:

PSUBSCRIBE '__keyspace@0__:cache:user:3'

终端一:

SET cache:user:3 value EX 2

收到的消息内容类似:

expired

此时必须同时启用 Keyspace 和 expired:

CONFIG SET notify-keyspace-events Kx

如果希望同时测试两类频道,可以配置:

CONFIG SET notify-keyspace-events KEx

八、通知的生命周期、并发和事务边界

8.1 通知是实时 Pub/Sub 消息,不是消息队列

普通 Redis Pub/Sub 的核心模型是:

发布者执行命令
    ↓
Redis 生成通知
    ↓
向当前在线的匹配订阅者发送消息

它没有:

  • 消费位点;
  • 消息确认;
  • 持久化消息日志;
  • 断线补发;
  • 消费者组;
  • 自动重试。

如果订阅客户端在事件发布时断开连接,这条消息通常就丢失了。消费者重连后只能收到之后的新事件,不能要求 Redis 补发断线期间的 expired 通知。

所以以下设计是不可靠的:

收到 expired 事件
    ↓
执行唯一一次关键业务操作

如果这条事件丢失,关键业务操作就永远不会执行。

8.2 并发下不能假设全局顺序

单个 Redis 实例的命令执行具有顺序性,但通知涉及多个客户端、多个键和多个数据库时,业务不能假设:

  • 不同订阅者看到的处理完成时间相同;
  • 不同 Redis 节点之间存在统一顺序;
  • 事件到达消费者的顺序等于业务最终处理完成顺序;
  • 收到事件时键仍然存在;
  • 收到事件时键的旧值仍可读取。

例如,消费者收到:

expired cache:user:1

再执行:

GET cache:user:1

得到 nil 是正常的。事件本身只说明删除事件已经发布,不会提供被删除值的快照。

8.3 事务和 Lua 脚本

MULTI/EXEC 中,命令先排队,之后在 EXEC 中按顺序执行。通知通常对应实际执行的命令,而不是命令进入队列的时刻。

例如:

MULTI
SET k v EX 10
GET k
EXEC

客户端看到的是事务执行结果;不能把通知当作事务外部的可靠提交日志。

Lua 脚本在 Redis 中以原子方式执行。脚本内部的键修改不会被其他客户端插入,但通知消费者属于外部客户端,仍然面临:

  • 断线丢失;
  • 处理失败;
  • 重复处理;
  • 多节点监听不完整。

如果业务需要“修改数据和记录事件”具备统一的持久化语义,应将事件写入 Redis Stream、数据库 Outbox 表或其他持久化日志,而不是只依赖 Keyspace 通知。


九、可靠性:把通知当作提示,而不是事实日志

9.1 Keyspace 通知适合什么

它适合:

  • 缓存失效后的非关键刷新;
  • 本地缓存收到变化后的加速失效;
  • 开发和运维观察;
  • 触发可重复执行的异步优化;
  • 作为后台扫描的低延迟提示。

例如,多级缓存中,L1 本地缓存订阅 Redis 的过期或变更通知,可以尽快删除本地副本。但即使通知丢失,也应该通过 TTL、版本检查或定期校准恢复一致性。

9.2 不适合什么

不应单独用于:

  • 资金结算;
  • 订单状态推进;
  • 唯一一次的资源回收;
  • 必须执行的延迟任务;
  • 审计日志;
  • 可靠消息投递;
  • 跨节点一致性协议。

形式化地说,若业务要求对每个事件 ee 都满足:

最终处理(e)=1\text{最终处理}(e)=1

而 Pub/Sub 只能提供:

在线且匹配的订阅者可能收到 e\text{在线且匹配的订阅者可能收到 }e

那么二者的可靠性条件不相等。网络断开、进程崩溃或订阅配置错误都会使事件不可恢复。

9.3 更可靠的方案:持久化事件与补偿

如果删除动作必须驱动业务事件,常见方案是显式写入持久化结构。例如使用 Redis Stream:

XADD expiration-events * key cache:user:1 reason ttl

消费者使用消费组:

XGROUP CREATE expiration-events workers 0 MKSTREAM
XREADGROUP GROUP workers worker-1 COUNT 10 BLOCK 5000 STREAMS expiration-events >

但要注意:这只是展示可靠事件存储的基本工具,并没有自动把“Redis 过期删除”和 XADD 绑定成一个原子动作。Redis 不会因为一个键过期,就自动替业务把事件写入 Stream。

如果必须保证二者关联,需要重新设计数据模型,例如:

  1. 创建业务对象时,同时写入对象状态和待处理事件;
  2. 使用 Stream 或数据库 Outbox 持久化事件;
  3. 消费者使用确认、重试和幂等处理;
  4. 使用定期扫描补偿未完成任务;
  5. 以业务状态机而不是一次通知作为最终事实来源。

9.4 过期任务的可恢复设计

可以用有序集合保存执行时间:

ZADD jobs:due 1735689600000 job:42

其中 score 是 Unix 毫秒时间戳。工作进程周期性查询到期任务,并通过 Lua、锁或领取状态避免多个消费者重复领取。

不过,有序集合方案也不是天然可靠:

  • 工作进程可能在领取后崩溃;
  • ZRANGEBYSCORE 后的删除和处理可能不是原子操作;
  • 需要设计重试、租约、幂等和死信;
  • Redis 本身仍可能发生故障或数据丢失,持久化策略需要单独评估。

它的优势是任务记录可查询、可重试、可补偿,不依赖一次性的过期 Pub/Sub 消息。


十、集群、复制和订阅位置

10.1 Keyspace 通知是节点本地事件

在 Redis Cluster 中,键分布在不同主节点。某个键的过期事件由持有该键的节点产生,订阅一个节点的通知频道,不会自动收到整个集群的所有过期事件。

因此,集群监听通常需要:

  • 在每个相关主节点建立订阅连接;
  • 处理节点扩缩容和主从切换;
  • 识别事件来源节点;
  • 对重复或遗漏进行补偿;
  • 不把节点本地通知当成全局事件流。

Redis Cluster 通常只使用数据库 0,这与单机多逻辑数据库的频道编号行为不同。

10.2 副本上的通知不能直接当作主节点事件总线

通知在哪个节点生成、客户端连接到哪个节点,都会影响观察结果。复制延迟、故障转移和连接重建可能造成:

  • 同一个业务键在不同节点产生不同观察时间;
  • 订阅者漏掉原主节点上的事件;
  • 切换后新节点不会补发历史事件;
  • 以副本通知作为主节点业务事实时出现延迟或缺失。

如果业务事实在主节点完成,可靠事件也应在业务事实所在的持久化流程中产生,而不是依赖任意节点的本地通知。


十一、诊断通知没有到达的正确顺序

当消费者说“Redis 没有发送过期事件”时,可以按以下顺序排查。

11.1 先确认键真的设置了 TTL

TTL mykey
OBJECT ENCODING mykey
EXISTS mykey

如果 TTL 返回 -1,说明没有过期时间。常见原因是后续普通 SET 覆盖了带 TTL 的值。

11.2 确认通知配置

CONFIG GET notify-keyspace-events

需要根据订阅形式检查:

  • Keyevent:至少有 E 和对应事件标志;
  • Keyspace:至少有 K 和对应事件标志;
  • 过期事件:事件标志是 x
  • 淘汰事件:事件标志是 e,不能用 x 代替。

11.3 确认订阅频道、数据库和节点

频道必须匹配:

__keyevent@0__:expired

和下面这些并不等价:

__keyspace@0__:expired
__keyevent@1__:expired
__keyevent@0__:evicted

还要确认订阅客户端连接的是实际持有该键的实例,而不是另一个 Redis 节点或只读副本。

11.4 检查客户端是否在事件发生时在线

普通 Pub/Sub 不提供历史查询。订阅者晚启动、连接短暂断开、网络设备丢连接,都会导致事件无法补回。

11.5 检查是否把“过期”与“淘汰”混在一起

如果 Redis 达到 maxmemory 后删除的是淘汰键,观察到的应是 evicted,而不是 expired。可以结合:

INFO stats
INFO memory
CONFIG GET maxmemory
CONFIG GET maxmemory-policy

判断是否发生了内存淘汰。


十二、生产取舍

12.1 提高主动过期投入不是提高事件可靠性

调高 hz 或主动过期相关配置,可能让过期键更快被扫描,但它不能解决:

  • Pub/Sub 断线丢消息;
  • 集群节点漏订阅;
  • 消费者处理失败;
  • 事件重复处理;
  • 业务事件没有持久化。

它只影响 Redis 回收过期键的节奏,还会增加 CPU 竞争,必须结合实际负载验证。

12.2 事件消费者必须幂等

即使当前使用 Pub/Sub,消费者也应尽量支持重复执行。例如:

收到 expired(key)
  ↓
尝试删除本地缓存
  ↓
本地缓存已经不存在也视为成功

删除本地缓存通常是幂等的,因此适合使用通知作为加速信号。相反,直接执行扣款、发奖等非幂等动作就不合适。

12.3 需要一致性时,通知只能做加速路径

一个稳健的缓存系统通常把一致性分成两条路径:

Keyspace 通知:低延迟加速失效
TTL / 版本校验 / 定期扫描:最终补偿

通知到达时立即删除 L1 缓存;通知丢失时,L1 自身 TTL、版本号或后台校准仍能让系统恢复。这样通知改善延迟,而不是承担唯一正确性来源。


十三、核心结论

Redis 过期机制可以归纳为以下因果链:

设置绝对过期时间
    ↓
时间达到过期点
    ↓
访问检查或主动过期周期发现
    ↓
实际删除键
    ↓
按通知配置发布 expired 事件
    ↓
在线订阅者可能收到 Pub/Sub 消息

其中每一步都有独立边界:

  • 过期点到达不等于立刻物理删除
  • 物理删除不等于一定发布通知
  • 发布通知不等于订阅者一定收到
  • 收到通知也不等于业务处理已经成功

因此,惰性删除负责访问路径上的正确性,主动删除负责后台回收,Keyspace 通知负责提供实时但非持久化的变化信号。需要可靠业务事件时,应使用可持久化、可确认、可重试并可补偿的事件设计,把 Keyspace 通知定位为加速机制,而不是事实日志。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。