返回文章列表

Key 设计、TTL 与生命周期管理

系统梳理 Redis Key 命名、TTL 语义、过期删除、UNLINK、SCAN、BigKey、HotKey、Keyspace Notification 与 Go 生命周期治理。

第 3 章:Key 设计、TTL 与生命周期管理

版本基线:本章以 Redis Open Source 8.8 和 go-redis/v9 为主要参考。Redis 8.8 于 2026 年 5 月发布;go-redis/v9 官方仓库已列出对 Redis 8.8 的支持。涉及旧版本差异时会单独说明。(Redis)


1. 本章定位

Key 看起来只是一个字符串,但在生产系统中,它同时承担以下职责:

  • 标识数据的业务归属和结构版本;
  • 隔离环境、服务和租户;
  • 决定数据如何过期、更新和迁移;
  • 支撑问题排查、容量分析与批量治理;
  • 影响内存、网络流量、热点分布和清理延迟。

TTL 也不只是“缓存多久”。它还关系到缓存雪崩、会话失效、内存增长、主从复制、故障切换和过期事件的可靠性。

因此,本章不把 Key 命名、TTL、删除、扫描、BigKey、HotKey 分成互不相关的知识点,而是把它们放进同一条生命周期中理解:

创建 Key

设置或继承 TTL

读取、更新、续期

自然过期 / 主动删除 / 版本切换

后台释放内存

监控、审计与遗留 Key 清理

2. 学习目标

完成本章后,你应当能够:

  1. 设计具有业务前缀、结构版本和租户隔离能力的 Key。
  2. 说明 Key 长度对内存、网络和可维护性的影响。
  3. 准确解释 EXPIREPEXPIREEXPIREATTTLPTTLPERSIST
  4. 判断一次写操作会覆盖、保留还是清除已有 TTL。
  5. 解释 Redis 的惰性过期和定期主动过期机制。
  6. 正确选择 DELUNLINK 和 lazyfree。
  7. 使用 SCAN 编写可控、幂等的在线扫描和批量清理程序。
  8. 区分 BigKey 与 HotKey,并设计发现和治理方案。
  9. 说明 Keyspace Notification 的可靠性边界。
  10. 在 Go 服务中实现带超时、TTL 抖动和负载控制的生命周期代码。

2.1 本章边界与跳转

本章是 Key 命名、TTL API 和生命周期管理的主章节。Redis 如何在内存层执行过期删除、主动过期、淘汰和碎片治理,统一见第 13 章;缓存雪崩、击穿、穿透和热 Key 的架构治理见第 18 章;不要把 Keyspace Notification 当可靠延迟队列,List、Streams 与延迟任务的可靠性边界见[第 8 章](/blog/tech/Redis/08.PubSub、List 队列与 Streams/)和第 19 章

3. 核心概念

3.1 Key 不是字符串,而是数据接口

一个设计良好的 Key 应当让开发、运维和监控系统都能理解它的含义。

推荐的通用结构是:

{environment}:{service}:{resource}:v{schema}:{tenant}:{identifier}[:{dimension}]

例如:

prod:trade:order:v2:t42:90001
prod:iam:session:v1:t42:7f3cbd9a
prod:catalog:product-cache:v3:t42:1001
prod:risk:user-daily-counter:v1:t42:20260621:10001

各字段的职责如下:

字段示例作用
环境prodstaging避免测试与生产互相污染
服务tradeiam标识数据所有者
资源ordersession表达业务对象
结构版本v2支持模型升级、灰度和回滚
租户t42降低租户之间的 Key 冲突
标识90001定位具体对象
维度日期、渠道、区域表达聚合或时间窗口

命名原则

第一,稳定字段在前,变化字段在后。

下面的形式更便于按固定前缀扫描和统计:

prod:trade:order:v2:t42:90001

不建议把随机 ID 放在最前面:

90001:prod:trade:order:t42

第二,版本号表示数据结构版本,而不是应用发布版本。

例如:

prod:catalog:product:v1:t42:1001
prod:catalog:product:v2:t42:1001

当序列化格式或字段语义发生不兼容变化时,可以:

  1. 新代码开始写入 v2
  2. 读请求优先读 v2,未命中再降级读 v1
  3. 迁移完成后停止读取 v1
  4. 通过 TTL 或离线扫描回收旧版本。

相比直接覆盖原 Key,这种方案更容易灰度和回滚。

第三,租户前缀只是逻辑隔离,不是安全边界。

prod:trade:order:v2:t42:90001

可以降低不同租户产生相同业务 ID 时的冲突,也便于统计和清理。但只要调用方拥有访问整个 Redis 的权限,就仍可能读取其他租户的数据。

真正的安全隔离还需要结合:

  • 应用层租户校验;
  • Redis ACL Key Pattern;
  • 独立实例或独立集群;
  • 网络和凭据隔离;
  • 审计与访问日志。

第四,不要直接把敏感信息写进 Key。

下面的 Key 会出现在命令日志、慢日志、监控和排障输出中:

user:13800138000:session
user:[email protected]:profile
reset-password:raw-token-value

更稳妥的做法是使用内部 ID 或不可逆摘要:

prod:iam:session:v1:t42:user-10001
prod:iam:reset-token:v1:t42:sha256-abcd...

3.2 Key 长度的成本

Key 越长,通常会带来三类成本。

1. 内存成本

Redis 不仅存储 Value,也要存储 Key 本身以及对象、哈希表节点、过期信息和内存分配器产生的管理开销。

例如,假设每个 Key 因命名冗余多出 50 字节,系统中有 5000 万个 Key:

50 × 50,000,000 ≈ 2.5 GB

这还没有计算对象头、字典节点和内存碎片。

MEMORY USAGE 返回的是 Key 和 Value 所需的内存分配量,其中包含管理开销;较长的 Key 和 Value,其内存占用总体呈近似线性增长。聚合类型默认通过抽样估算,SAMPLES 0 会遍历全部元素。(Redis)

2. 网络成本

每次命令请求通常都要传输完整 Key:

GET prod:catalog:product-cache:v3:tenant-000042:product-0000001001

高 QPS 下,即使单个 Key 只多几十字节,也会被每秒数十万次请求放大。

3. 维护成本

过短的缩写虽然节约少量字节,却可能降低可读性:

p:t:o:2:42:90001

几个月后,很难判断 pto 分别代表什么。

所以生产设计应当遵循:

在含义清晰的前提下保持紧凑,而不是单纯追求最短。

不存在适用于所有业务的“最佳 Key 长度”。应使用真实数据、真实 Redis 版本和真实内存分配器,通过 MEMORY USAGE、容量压测和网络监控做判断。


3.3 TTL 命令体系

Redis 中没有过期时间的 Key 称为持久 Key;关联了过期时间的 Key 通常称为 volatile Key。

命令时间单位时间含义典型用途
EXPIRE key seconds从当前时间开始的相对 TTL普通缓存、会话
PEXPIRE key milliseconds毫秒相对 TTL精细时间窗口
EXPIREAT key timestampUnix 绝对时间固定截止时刻
PEXPIREAT key timestamp毫秒Unix 绝对时间毫秒级绝对截止
TTL key查询剩余时间运维检查
PTTL key毫秒查询剩余时间精细诊断
PERSIST key移除 TTL转为持久 Key

TTLPTTL 的特殊返回值为:

-2:Key 不存在
-1:Key 存在,但没有过期时间
>=0:剩余 TTL

TTL 从 Redis 2.8 起区分 -2-1。(Redis)

相对时间与绝对时间

相对 TTL:

EXPIRE session:10001 1800

含义是“从命令执行时刻开始,1800 秒后过期”。

绝对时间:

EXPIREAT settlement:20260621 1782057600

含义是“到指定 Unix 时间戳时过期”。

选择原则:

  • 缓存存活时长通常使用相对 TTL;
  • 令牌、活动结束时间等明确截止时刻适合绝对时间;
  • 对严格时间要求较高时,应统一时钟源并监控机器时钟漂移。

Redis 内部把过期时间保存为绝对 Unix 毫秒时间戳,因此系统时钟大幅跳变可能使 Key 提前或延后过期。(Redis)


3.4 TTL 何时覆盖、保留或清除

这是面试和生产事故中最常见的知识点之一。

1. 普通 SET 会清除已有 TTL

SET cache:product:1001 old-value EX 3600
SET cache:product:1001 new-value
TTL cache:product:1001

最后返回:

-1

原因是第二个 SET 完整替换了原有值,成功执行后会丢弃旧 TTL。(Redis)

2. SET ... KEEPTTL 可以保留原 TTL

SET cache:product:1001 new-value KEEPTTL

但需要注意:

  • 如果原 Key 有 TTL,则保留;
  • 如果原 Key 本来是持久 Key,它仍然没有 TTL;
  • KEEPTTL 不会自动创建新的 TTL。

KEEPTTL 从 Redis 6.0 开始提供。(Redis)

3. 带过期选项的 SET 会设置新的 TTL

SET cache:product:1001 new-value EX 3600
SET cache:product:1001 new-value PX 5000
SET cache:product:1001 new-value EXAT 1782057600
SET cache:product:1001 new-value PXAT 1782057600000

这会覆盖旧 Value,并按新参数设置过期时间。

相比下面的两条命令:

SET cache:product:1001 new-value
EXPIRE cache:product:1001 3600

单条 SET ... EX 更安全。两条命令之间如果发生进程崩溃、网络中断或超时,可能遗留一个没有 TTL 的 Key。Pipeline 可以减少网络往返,但并不自动赋予两条命令原子性。

4. 原地修改通常保留 TTL

以下命令修改现有数据,但不完整替换 Key,因此通常保留 TTL:

INCR counter
LPUSH queue item
HSET user:10001 name alice

SETGETSET、部分 *STORE 命令以及删除操作会覆盖或移除原对象,从而清除原 TTL。PERSIST 会显式删除 TTL;RENAME 会把源 Key 的 TTL 一并迁移到新 Key。(Redis)

5. 再次执行 EXPIRE 会更新 TTL

EXPIRE session:10001 1800
EXPIRE session:10001 3600

第二次执行后,TTL 以新的 3600 秒为准。

Redis 7.0 起还支持:

EXPIRE key 3600 NX
EXPIRE key 3600 XX
EXPIRE key 3600 GT
EXPIRE key 3600 LT

语义分别为:

参数含义
NX仅当 Key 当前没有 TTL 时设置
XX仅当 Key 当前已有 TTL 时设置
GT仅当新过期时间晚于当前过期时间时设置
LT仅当新过期时间早于当前过期时间时设置

这些条件可以避免并发请求无意中缩短或延长 TTL,但它们不能替代完整的业务版本控制。(Redis)


3.5 TTL 抖动与缓存雪崩

假设系统在部署或批量预热时,为 100 万个缓存 Key 都设置:

TTL = 30 分钟

30 分钟后,大量 Key 可能集中失效,业务请求同时回源数据库,形成缓存雪崩。

常见处理方式是增加随机抖动:

actualTTL = baseTTL + random(0, maxJitter)

例如:

baseTTL   = 30 分钟
maxJitter = 5 分钟
实际 TTL  = 30~35 分钟

抖动的作用是把集中到期分散到一个时间区间内。

需要区分两类场景:

场景是否适合正向 TTL 抖动
普通查询缓存适合
商品详情、配置缓存通常适合
会话空闲超时需结合安全要求判断
验证码严格有效期通常不应延长
优惠活动固定结束时间不应越过业务截止时间
法规要求的数据删除期限不应随机延长

对于严格截止时间,可以:

  • 使用 EXPIREATPXAT
  • 只在刷新时间上加抖动,不改变最终失效时间;
  • 在 Value 中保存业务截止时间,由应用再次校验;
  • 使用逻辑过期加异步刷新,而不是延长硬过期时间。

3.6 惰性过期与主动过期

Redis 不会为每个 Key 单独创建一个高精度定时器。那样会消耗大量内存和调度成本。

它采用两种机制共同清理过期 Key。

惰性过期

客户端访问某个 Key 时,Redis 检查它是否已经过期:

客户端 GET key

检查过期时间

已过期:删除并按不存在处理
未过期:返回 Value

优点是只检查正在被访问的数据。

缺点是一个已经过期但再也没人访问的 Key,不能仅靠惰性过期得到清理。

主动过期

Redis 会周期性从带 TTL 的 Key 中抽样检查,并删除已经到期的 Key。这样可以处理长期无人访问的过期数据。

因此,“TTL 到 0”与“物理对象立即被回收”不是完全相同的时刻。过期 Key 可能在访问时被惰性清理,也可能被后续主动过期周期发现。(Redis)

为什么不一次扫描全部 Key

如果每个周期都遍历全部 Key,单次工作量为 O(N)。当数据库拥有数千万甚至更多 Key 时,会周期性占用核心命令执行路径,造成明显延迟。

抽样算法本质上是在三者之间取平衡:

  • CPU 消耗;
  • 过期 Key 的残留时间;
  • 前台命令延迟。

Redis 配置中的 active-expire-effort 可以调整主动过期投入,但更高的清理力度会消耗更多 CPU,不能只为了“更快删除”就无限提高。

复制环境中的过期

在主从复制中,过期决定主要由主节点完成。主节点删除过期 Key 时,会向 AOF 和副本传播相应删除操作。副本保存过期元数据,但在仍作为副本时不会独立决定该 Key 的最终过期删除;被提升为主节点后,它可以自行处理过期。(Redis)


DEL

DEL huge:hash:10001

DEL 会先把 Key 从 Keyspace 移除,并在命令执行路径中完成对象释放。

删除小字符串时,释放成本通常很低;但删除包含大量字段、成员或节点的 Hash、List、Set、ZSet 时,对象析构可能占用较长时间。

这时,客户端看到的不是单纯的网络延迟,而是:

命令开始执行

从 Keyspace 移除

遍历并释放大量内部对象

命令结束

较长的同步释放可能拖慢同一实例上的其他请求。

UNLINK huge:hash:10001

UNLINK 会在前台把 Key 从 Keyspace 中摘除,使后续读请求立即认为它不存在;实际内存释放工作交给后台线程。

其前台阶段按每个 Key 近似 O(1),后台线程仍需完成与对象分配数量相关的释放工作。(Redis)

命令执行线程                 后台释放线程
     │                            │
从 Keyspace 摘除 Key              │
     │────────提交待释放对象──────→│
返回客户端                        遍历并释放对象

因此,UNLINK 不是“零成本删除”,而是把高成本的物理回收从前台延迟路径迁移到了后台。

可能出现:

  • Key 已经查不到;
  • 物理内存和 RSS 尚未完全下降;
  • 后台释放线程持续消耗 CPU;
  • 删除速度高于释放速度时产生积压。

lazyfree

Redis 的 lazyfree 配置可以让更多删除路径采用异步释放,例如:

lazyfree-lazy-eviction
lazyfree-lazy-expire
lazyfree-lazy-server-del
lazyfree-lazy-user-del
replica-lazy-flush

其中:

lazyfree-lazy-user-del yes

可使用户执行的 DEL 按类似 UNLINK 的方式异步释放。Redis 8.8 默认配置中这些选项大多仍需显式开启。(GitHub)

选择原则:

情况更倾向的方案
小字符串、少量小 KeyDELUNLINK 均可
大 Hash、大 List、大 Set优先评估 UNLINK
在线批量清理SCAN + 小批次 UNLINK
极端内存紧张不能只依赖异步释放,应限速并监控积压
需要立即释放物理内存UNLINK 不保证立即完成物理回收

3.8 KEYSSCAN

KEYS

KEYS prod:trade:order:*

KEYS 在一次命令中遍历当前数据库的 Keyspace,并一次性返回匹配结果。

它的问题不是“命令名字危险”,而是单次工作量和返回结果都可能过大:

  • 单次遍历复杂度为 O(N)
  • 核心命令执行路径可能被长时间占用;
  • 大量结果会占用服务端输出缓冲区、网络和客户端内存;
  • 即使只匹配少量 Key,也通常仍需检查大量 Key。

因此,KEYS 更适合:

  • 本地开发;
  • 隔离测试环境;
  • 已确认规模很小的专用实例;
  • 故障排查中有严格风险控制的场景。

不应把它放进普通在线业务路径。

SCAN

SCAN 0 MATCH 'prod:trade:order:v2:t42:*' COUNT 200

SCAN 使用游标渐进遍历:

  1. 第一次传入游标 0
  2. 保存服务端返回的新游标;
  3. 使用新游标继续调用;
  4. 直到服务端再次返回游标 0
SCAN 0
  ↓ cursor=128
SCAN 128
  ↓ cursor=64
SCAN 64
  ↓ cursor=0,完成一轮

COUNT 是工作量提示,而不是严格的返回数量上限。一次调用可以返回少于 COUNT 个、超过 COUNT 个,甚至返回零个元素;只要游标不为 0,扫描就尚未完成。

MATCH 也不是预先缩小整个 Keyspace。Redis 通常先进行一部分扫描工作,再过滤匹配结果,所以匹配条件很稀疏时,经常会出现“返回空数组但游标不为 0”。(Redis)

SCAN 的一致性保证

一次完整扫描期间:

  • 从扫描开始到结束始终存在的元素,应当被返回;
  • 扫描过程中新增、删除或反复变化的元素,是否返回是不确定的;
  • 同一个元素可能返回多次;
  • SCAN 不是 Keyspace 快照。

官方文档明确要求应用自行处理重复元素。(Redis)

为什么 SCAN 可能返回重复元素

SCAN 只维护很少的游标状态,而不是记录“已经访问过哪些 Key”的完整集合。

当扫描期间哈希表发生扩容、缩容或渐进式重哈希时,桶的位置和映射可能变化。同一个元素可能在不同阶段被游标覆盖,因此协议允许重复返回。

生产程序应采用以下设计:

  • 删除操作使用幂等的 UNLINK
  • 数据修复按 Key 写入最终状态,而不是做非幂等累加;
  • 发送消息时使用业务幂等键;
  • 必要时在客户端维护去重集合;
  • 不把“扫描数量”直接当作唯一 Key 数量。

还要注意一个更隐蔽的竞态:

扫描返回 key:A

其他请求删除并重新创建 key:A

清理任务随后 UNLINK key:A

此时可能误删新创建的数据。因此,大规模破坏性清理更稳妥的做法是:

  1. 先切换到新版本前缀;
  2. 停止旧前缀写入;
  3. 等待在途请求结束;
  4. 再扫描并删除旧前缀。

3.9 BigKey 与 HotKey

BigKey

BigKey 不是指“Key 名字很长”,而是该 Key 对应的数据对象显著偏大。

它可能表现为:

  • 一个数十 MB 的 String;
  • 一个包含大量字段的 Hash;
  • 一个拥有大量成员的 Set 或 ZSet;
  • 一个长期未裁剪的 Stream;
  • 一个包含大量节点的 List。

BigKey 的判断不应只看字节数,还应看:

  • 元素数量;
  • 单次命令需要遍历的元素数量;
  • 网络返回体大小;
  • 删除和复制成本;
  • 是否影响持久化、主从同步和故障恢复。

不存在适用于所有系统的固定阈值。应根据同类型 Key 的分布、P99 延迟、网络带宽和故障恢复目标定义告警线。

BigKey 的典型风险包括:

大对象读取 → 大量网络传输
大对象删除 → 长时间同步释放或异步释放积压
大对象修改 → AOF、复制流量增加
持久化期间频繁修改 → Copy-on-Write 内存压力
单次全量读取 → 客户端内存和 GC 压力

发现手段:

MEMORY USAGE some:key
MEMORY USAGE some:hash SAMPLES 0

以及:

redis-cli --bigkeys
redis-cli --memkeys
redis-cli --keystats
redis-cli --scan --pattern 'prod:trade:*'

redis-cli --keystats 综合了大小、类型、基数和分布信息;扫描时可以通过 -i 增加间隔以降低服务器负载。(Redis)

HotKey

HotKey 指访问负载明显集中在少数 Key 上。它可以非常小,例如一个只有几个字节的计数器,却承担每秒数十万次请求。

HotKey 的主要风险是:

  • 单节点 CPU 被集中消耗;
  • 单分片网络流量打满;
  • 连接队列和响应延迟升高;
  • 某个 Redis Cluster 分片过载,而其他分片空闲;
  • 热点失效时大量请求同时回源。

Redis 8.6 起提供 HOTKEYS 命令族,可按 Key 消耗的 CPU 时间占比和网络字节占比进行概率化 Top-K 跟踪。(Redis)

示例:

HOTKEYS START METRICS 2 CPU NET COUNT 20 DURATION 30 SAMPLE 10
HOTKEYS GET
HOTKEYS STOP
HOTKEYS RESET

SAMPLE 10 表示概率抽样。更高的抽样比可以降低运行开销,但可能遗漏部分热点。

旧版本也可以使用:

redis-cli --hotkeys

但该模式依赖 LFU 类型的 maxmemory-policy。(Redis)

BigKey 与 HotKey 的区别

维度BigKeyHotKey
核心问题对象体积或基数过大访问流量过度集中
是否一定占用大量内存通常是不一定
是否一定高 QPS不一定通常是
主要风险网络大包、删除阻塞、复制和持久化压力CPU、网络、单分片过载
发现方式MEMORY USAGE--keystats、基数命令HOTKEYS、应用埋点、代理统计
常见治理拆分、分页、裁剪、异步删除本地缓存、请求合并、分片、限流

一个 Key 可以同时既大又热,这通常是风险最高的组合。


3.10 Keyspace Notification 的能力边界

开启过期事件通知:

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

其中:

  • E 表示 Key-event 类型频道;
  • x 表示过期事件;
  • 至少需要包含 KE,否则不会产生对应通知。

Keyspace Notification 默认关闭,因为它会增加一定 CPU 开销。(Redis)

典型事件频道:

__keyspace@0__:some-key  → 消息内容是事件名
__keyevent@0__:expired   → 消息内容是 Key 名

但是它有四个重要边界。

1. 消息不持久化

Keyspace Notification 基于 Pub/Sub。订阅客户端断线期间产生的事件会丢失,没有重放、确认和消费组机制。(Redis)

2. 事件时间不精确

expired 事件在 Redis 实际删除 Key 时产生,而不是 TTL 理论上刚好变成 0 的瞬间。对于长期无人访问且 TTL Key 很多的实例,事件可能明显延迟。(Redis)

3. 集群中事件是节点级的

Redis Cluster 中,每个节点只发布自己负责 Keyspace 的事件。订阅一个节点无法获取整个集群的全部 Keyspace Notification,需要分别订阅相关节点。(Redis)

4. 事件不等于业务事实

收到 expired 只能说明 Redis 删除了某个 Key,不能自动证明:

  • 订单应当关闭;
  • 支付应当退款;
  • 优惠券应当作废;
  • 数据库事务已经完成;
  • 下游处理一定成功。

适合的用途:

  • 非关键缓存清理提示;
  • 监控和指标统计;
  • 可补偿的后台任务触发;
  • 本地状态失效通知。

不适合单独承担:

  • 支付超时关单;
  • 严格延迟任务;
  • 财务结算;
  • 唯一可靠的订单状态机。

可靠业务事件更适合使用数据库 Outbox、Redis Streams、Kafka 或其他具有持久化、确认和补偿能力的机制。


4. 命令与 Go 使用方法

4.1 redis-cli 常用命令

创建带 TTL 的缓存

SET prod:catalog:product:v2:t42:1001 \
    '{"name":"keyboard","price":299}' EX 3600

查询 TTL

TTL prod:catalog:product:v2:t42:1001
PTTL prod:catalog:product:v2:t42:1001

修改 TTL

EXPIRE prod:catalog:product:v2:t42:1001 7200
PEXPIRE prod:catalog:product:v2:t42:1001 4500

设置绝对过期时间

EXPIREAT prod:catalog:product:v2:t42:1001 <unix-seconds>
PEXPIREAT prod:catalog:product:v2:t42:1001 <unix-milliseconds>

移除 TTL

PERSIST prod:catalog:product:v2:t42:1001

更新 Value 并保留 TTL

SET prod:catalog:product:v2:t42:1001 \
    '{"name":"keyboard","price":279}' KEEPTTL

条件设置 TTL

EXPIRE session:10001 1800 NX
EXPIRE session:10001 3600 GT

渐进式扫描

SCAN 0 MATCH 'prod:catalog:product:v2:t42:*' COUNT 200

返回示例:

1) "128"
2) 1) "prod:catalog:product:v2:t42:1001"
   2) "prod:catalog:product:v2:t42:1002"

下一次必须使用返回的游标:

SCAN 128 MATCH 'prod:catalog:product:v2:t42:*' COUNT 200

异步删除

UNLINK \
  prod:catalog:product:v2:t42:1001 \
  prod:catalog:product:v2:t42:1002

检查对象内存

MEMORY USAGE prod:catalog:product:v2:t42:1001
MEMORY USAGE prod:trade:order-index:v1:t42 SAMPLES 0

4.2 go-redis/v9 命令映射

Redis 命令go-redis/v9 方法
EXPIREExpire
PEXPIREPExpire
EXPIREATExpireAt
PEXPIREATPExpireAt
TTLTTL
PTTLPTTL
PERSISTPersist
SCANScan
DELDel
UNLINKUnlink

go-redis/v9 的模块路径为:

github.com/redis/go-redis/v9

(GitHub)


4.3 Go:TTL 抖动、安全读写与批量删除

下面的示例面向单机 Redis 或单个主节点,包含:

  • 连接和命令超时;
  • redis.Nil 区分;
  • 原子设置 Value 和 TTL;
  • TTL 随机抖动;
  • 游标正确推进;
  • 小批次 UNLINK
  • 批次间限速;
  • Context 取消。
package keylife

import (
	"context"
	"crypto/rand"
	"errors"
	"fmt"
	"math/big"
	"time"

	"github.com/redis/go-redis/v9"
)

var ErrCacheMiss = errors.New("cache miss")

func NewClient(addr string) *redis.Client {
	return redis.NewClient(&redis.Options{
		Addr:         addr,
		DialTimeout:  time.Second,
		ReadTimeout:  800 * time.Millisecond,
		WriteTimeout: 800 * time.Millisecond,
		PoolTimeout:  time.Second,
	})
}

// TTLWithJitter 返回 [base, base+maxJitter] 区间内的 TTL。
func TTLWithJitter(
	base time.Duration,
	maxJitter time.Duration,
) (time.Duration, error) {
	if base <= 0 {
		return 0, errors.New("base TTL must be positive")
	}
	if maxJitter < 0 {
		return 0, errors.New("max jitter must not be negative")
	}
	if maxJitter == 0 {
		return base, nil
	}

	const maxDuration time.Duration = 1<<63 - 1
	if maxJitter > maxDuration-base {
		return 0, errors.New("TTL duration overflow")
	}

	// maxJitter 已经过溢出检查,因此 +1 不会超过 int64。
	n, err := rand.Int(
		rand.Reader,
		big.NewInt(int64(maxJitter)+1),
	)
	if err != nil {
		return 0, fmt.Errorf("generate TTL jitter: %w", err)
	}

	return base + time.Duration(n.Int64()), nil
}

// SetCache 使用一条 SET 命令同时写入 Value 和 TTL,
// 避免 SET 成功但 EXPIRE 未执行的窗口。
func SetCache(
	ctx context.Context,
	rdb *redis.Client,
	key string,
	value []byte,
	baseTTL time.Duration,
	maxJitter time.Duration,
) error {
	if rdb == nil {
		return errors.New("nil redis client")
	}
	if key == "" {
		return errors.New("empty redis key")
	}

	ttl, err := TTLWithJitter(baseTTL, maxJitter)
	if err != nil {
		return err
	}

	opCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	if err := rdb.Set(opCtx, key, value, ttl).Err(); err != nil {
		return fmt.Errorf("set cache %q: %w", key, err)
	}
	return nil
}

func GetCache(
	ctx context.Context,
	rdb *redis.Client,
	key string,
) ([]byte, error) {
	if rdb == nil {
		return nil, errors.New("nil redis client")
	}

	opCtx, cancel := context.WithTimeout(ctx, 800*time.Millisecond)
	defer cancel()

	value, err := rdb.Get(opCtx, key).Bytes()
	switch {
	case errors.Is(err, redis.Nil):
		return nil, ErrCacheMiss
	case err != nil:
		return nil, fmt.Errorf("get cache %q: %w", key, err)
	default:
		return value, nil
	}
}

func sleepContext(ctx context.Context, d time.Duration) error {
	if d <= 0 {
		return nil
	}

	timer := time.NewTimer(d)
	defer timer.Stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-timer.C:
		return nil
	}
}

// UnlinkByPrefix 渐进扫描固定前缀,并分批执行 UNLINK。
//
// 注意:
//  1. prefix 必须是服务端定义的固定命名空间,不能直接使用用户输入。
//  2. SCAN 不是快照,可能返回重复项。
//  3. 此实现只针对一个 Redis 节点,不代表完整 Cluster 扫描。
func UnlinkByPrefix(
	ctx context.Context,
	rdb *redis.Client,
	prefix string,
	scanCount int64,
	batchSize int,
	pause time.Duration,
) (int64, error) {
	if rdb == nil {
		return 0, errors.New("nil redis client")
	}
	if prefix == "" {
		return 0, errors.New("empty prefix is forbidden")
	}
	if scanCount <= 0 {
		scanCount = 200
	}
	if batchSize <= 0 {
		batchSize = 100
	}
	if pause < 0 {
		return 0, errors.New("pause must not be negative")
	}

	var (
		cursor  uint64
		removed int64
	)

	for {
		scanCtx, cancelScan := context.WithTimeout(
			ctx,
			2*time.Second,
		)

		keys, nextCursor, err := rdb.Scan(
			scanCtx,
			cursor,
			prefix+"*",
			scanCount,
		).Result()

		cancelScan()

		if err != nil {
			return removed, fmt.Errorf(
				"scan prefix %q at cursor %d: %w",
				prefix,
				cursor,
				err,
			)
		}

		for start := 0; start < len(keys); start += batchSize {
			end := start + batchSize
			if end > len(keys) {
				end = len(keys)
			}

			deleteCtx, cancelDelete := context.WithTimeout(
				ctx,
				2*time.Second,
			)

			n, err := rdb.Unlink(
				deleteCtx,
				keys[start:end]...,
			).Result()

			cancelDelete()

			if err != nil {
				return removed, fmt.Errorf(
					"unlink keys [%d:%d]: %w",
					start,
					end,
					err,
				)
			}

			removed += n

			if err := sleepContext(ctx, pause); err != nil {
				return removed, fmt.Errorf(
					"batch deletion interrupted: %w",
					err,
				)
			}
		}

		cursor = nextCursor
		if cursor == 0 {
			break
		}
	}

	return removed, nil
}

关键设计说明

1. *redis.Client 应当复用。

go-redisClient 管理连接池,可以被多个 Goroutine 并发使用,不应为每个请求创建一个新客户端。(GitHub)

2. 每次调用仍需要 Context 截止时间。

连接池安全不等于请求不会无限等待。应用层 Context、客户端 ReadTimeoutWriteTimeout 和上游 HTTP/RPC 超时需要形成合理的层级关系。

3. SET 的 duration 参数会把 Value 和 TTL 放入一次 Redis 命令。

这比先 Set(..., 0)Expire(...) 更安全。

4. SCAN COUNT 和删除批次是不同参数。

  • scanCount 控制每次扫描建议做多少工作;
  • batchSize 控制一次 UNLINK 携带多少 Key;
  • pause 控制批次之间主动让出负载。

它们都需要通过压测校准,不能机械照搬示例值。

5. 返回的 removed 不是“唯一匹配 Key 数量”。

以下情况都会使它小于扫描返回数量:

  • SCAN 返回重复 Key;
  • Key 被其他请求提前删除;
  • Key 已自然过期;
  • 同一批参数中出现重复 Key。

6. Pipeline 对象不能被多个 Goroutine 共享。

*redis.Client 可并发复用,但一个具体的 Pipeline 实例不是并发安全的。若采用 Pipeline,应由单个 Goroutine 创建、填充、执行和销毁。(GitHub)

7. 该删除函数不是全局一致性方案。

如果旧前缀仍有写入,扫描过程中可能删除刚重新创建的数据。生产迁移应先切断旧前缀写流量,再执行清理。


5. 典型业务场景

业务场景是否适合 Redis TTL数据规模特征一致性要求主要风险推荐替代或补充
商品详情缓存适合大量小 Key,读多写少允许短暂旧数据集中过期、穿透、热点TTL 抖动、请求合并、版本 Key
登录会话适合,但需谨慎大量小 String/Hash中高TTL 被覆盖、续期竞态Token 内嵌截止时间、服务端二次校验
验证码或一次性令牌适合小 Value、高创建率TTL 被意外延长、重复消费固定截止时间、原子读取删除
限流计数器适合大量短生命周期计数器依算法而定计数与 TTL 分两步、热点Lua、原子复合命令、分片
缓存结构升级适合新旧版本并存可灰度误删新数据、旧版本残留版本前缀、停止旧写入后清理
用户行为临时聚合适合Hash/ZSet 基数可能增长通常最终一致BigKey、同步删除按日期/分片拆分、定期裁剪
支付超时关单不适合作为唯一机制事件量大很高通知丢失、过期事件延迟数据库任务表、消息队列、补偿扫描
订单权威状态不适合作为唯一存储长生命周期很高内存淘汰、故障恢复、误过期数据库为事实源,Redis 仅做加速

6. 底层实现

6.1 Keyspace 与过期元数据

从概念上看,Redis 需要维护两类信息:

主 Keyspace:
key → value object

过期元数据:
key → absolute expire timestamp

只有设置了 TTL 的 Key 才需要额外的过期信息。

这种设计的好处是:

  • 持久 Key 不需要参与过期检查;
  • TTL 查询和修改可在接近常数时间内完成;
  • 主动过期可以集中检查带 TTL 的 Key。

代价是:

  • 每个 volatile Key 需要额外内存;
  • 大量短 TTL Key 会增加主动过期工作;
  • 同一时间大量到期会产生删除和内存释放压力。

6.2 常见命令复杂度

操作前台复杂度后续工作说明
EXPIRETTLPERSIST通常 O(1)无或少量修改或读取过期元数据
KEYS patternO(N)大结果传输一次遍历整个数据库
单次 SCAN官方标注 O(1)完整轮次总计 O(N)COUNT 会影响每次实际工作量
DEL 小 String较低前台完成释放
DEL 大聚合对象与对象析构规模相关可能造成长尾延迟
UNLINK每 Key 前台近似 O(1)后台与对象分配数量相关物理释放异步完成
MEMORY USAGE与采样数相关SAMPLES 0 可能遍历全部成员

SCAN 只是把一次大工作拆成多个小工作,不会使完整遍历从 O(N) 变成“没有成本”。(Redis)

6.3 命令执行、网络 I/O 与异步释放必须区分

讨论 Redis 延迟时,需要区分三个阶段。

命令执行

例如 KEYS、大范围集合遍历和同步删除,会占用核心命令执行路径。

网络 I/O

大批量结果还需要:

  • 写入客户端输出缓冲区;
  • 经网络传输;
  • 在客户端反序列化;
  • 进入 Go 堆并产生 GC 压力。

即使命令执行本身不算特别慢,大响应也可能使端到端延迟明显增加。

异步释放

UNLINK 把对象析构交给后台线程,只缩短前台等待时间,并不消除:

  • CPU 工作;
  • 内存写操作;
  • 分配器回收;
  • 物理内存下降的等待时间。

Redis 的核心命令处理总体仍以串行化执行为主,同时可以使用侧线程处理 UNLINK、部分慢 I/O 和可选的 Socket 读写。因此,“启用了 I/O 线程”不能抵消 KEYS 或大型同步删除造成的命令执行阻塞。(GitHub)

6.4 版本差异

Redis 版本与本章相关的变化
2.8TTL 开始用 -2 区分不存在的 Key
4.0提供 UNLINKMEMORY USAGE
6.0SET 增加 KEEPTTL
6.2SET 增加 EXATPXAT
7.0EXPIRE 增加 NXXXGTLT
8.6提供 HOTKEYS 命令族
8.8本章采用的当前 Redis OSS 版本基线

升级时不能只看服务端版本,还应确认:

  • 托管 Redis 是否开放相应命令;
  • 代理层是否支持;
  • ACL 是否允许;
  • 客户端版本是否实现了对应 API;
  • 生产环境是否实际启用了相关配置。

7. 高性能、高并发与高可用

7.1 高性能

控制 Key 和 Value 大小

  • Key 保持含义明确但不过度冗长;
  • 大对象按业务维度拆分;
  • 列表、Stream 和时间序列设置裁剪策略;
  • 避免一次读取整个大型集合。

避免集中失效

  • 缓存 TTL 加随机抖动;
  • 热点缓存使用逻辑过期和异步重建;
  • 预热任务分批执行;
  • 不要让所有 Key 对齐到同一整点过期。

控制扫描负载

  • 采用 SCAN
  • 调整 COUNT
  • 分小批次处理;
  • 批次间增加暂停;
  • 在业务低峰执行;
  • 设置总执行时长和取消机制。

控制删除负载

  • 删除大对象优先评估 UNLINK
  • 即使使用 UNLINK 也要限制生产速率;
  • 监控 CPU、内存、延迟和后台释放积压;
  • 不要在实例接近内存上限时一次提交大量异步删除。

谨慎开启通知

只开启实际需要的事件类型:

CONFIG SET notify-keyspace-events Ex

不要为了方便直接在高负载实例上开启所有事件,然后让多个订阅者重复消费。


7.2 高并发

Value 与 TTL 应尽量一次写入

错误方式:

SET key value
EXPIRE key 60

并发和故障下存在无 TTL 窗口。

更好的方式:

SET key value EX 60

续期不是简单执行 EXPIRE

以滑动会话为例:

请求 A 读取会话
请求 B 删除会话或使其失效
请求 A 随后执行 EXPIRE

如果 Key 已被重新创建,请求 A 可能给新对象错误续期。

高一致性场景需要把以下信息一起验证:

  • 当前 Value 或版本号;
  • 当前业务状态;
  • 是否允许续期;
  • 新的截止时间。

可使用条件写、Lua 脚本或事务,把状态校验和续期放在服务端原子执行。

超时不代表命令没有执行

客户端可能在以下时刻超时:

请求已到达 Redis
命令已经执行
响应尚未到达客户端

此时盲目重试:

  • UNLINK 通常仍是幂等的;
  • SET 写同一值通常可以重试;
  • INCR、发消息、扣库存等非幂等操作可能执行两次。

因此,重试策略必须按命令语义设计,而不是统一“失败就重试”。

扫描处理必须幂等

SCAN 允许重复返回,处理函数应满足:

handle(key)
handle(key)

与只执行一次的最终结果一致。

对于不能天然幂等的操作,应增加业务幂等键、状态表或去重记录。

HotKey 需要并发治理

常见方案包括:

  • 进程内短 TTL 缓存;
  • Singleflight 请求合并;
  • 读副本分担只读流量;
  • 逻辑 Key 拆成多个物理分片;
  • 限流和排队;
  • 热点预热;
  • 降低大响应体。

读副本方案要接受复制延迟,不能用于要求强一致读的场景。


7.3 高可用

TTL 状态也属于复制数据

主节点会把过期删除传播到副本;副本提升为主节点后,可以依据已有过期元数据继续处理 Key。(Redis)

但 Redis 高可用不等于所有故障场景下零数据丢失。写入和 TTL 更新是否已经复制到新主节点,取决于复制进度、故障时机以及部署策略。

Keyspace Notification 不是高可用消息系统

它没有:

  • 持久化消费位点;
  • 消息确认;
  • 断线重放;
  • 全集群自动汇总。

即使部署了 Sentinel 或 Cluster,也不能据此把通知当作可靠业务队列。

Cluster 扫描不是单游标全局快照

在 Redis Cluster 中,每个主节点管理自己的 Key 子集。扫描一个节点不能代表扫描了整个集群。

生产清理程序需要:

  1. 获取当前主节点列表;
  2. 对每个主节点分别执行完整 SCAN
  3. 在拓扑变化时重新确认节点;
  4. 接受扫描期间迁移和写入造成的非快照语义;
  5. 按分片路由批量删除,避免跨槽多 Key 命令失败。

版本前缀是重要的回滚手段

相比原地批量修改:

v1 → 原 Key 上直接覆盖

版本切换:

v1 保留
v2 新写入
读路径灰度切换

在故障时更容易回滚。确认稳定后,再异步清理 v1


8. 常见错误与生产事故

事故一:更新缓存后 TTL 消失

现象

Redis 内存持续增长,大量原本应在一小时内过期的缓存变成持久 Key。

根因

初始化时使用:

SET key value EX 3600

后续刷新时却使用:

SET key new-value

普通 SET 清除了原 TTL。

排查

TTL suspicious:key

返回 -1。再检查写入代码、命令审计或客户端埋点。

修复

根据业务语义使用:

SET key new-value EX 3600

或:

SET key new-value KEEPTTL

预防

封装统一缓存写接口,不允许业务代码随意使用无 TTL 的 SET;监控持久 Key 数量和 TTL 分布。


事故二:线上执行 KEYS * 导致延迟飙升

现象

Redis P99 延迟突然升高,请求排队,上游服务出现超时。

根因

运维脚本或业务接口执行:

KEYS *

单次遍历整个 Keyspace,并返回海量结果。

排查

检查:

  • 慢日志;
  • 命令审计;
  • 客户端连接来源;
  • CPU 和网络流量;
  • 大响应输出缓冲区。

修复

终止脚本,改为 SCAN,并采用小 COUNT、限速和低峰执行。

预防

通过 ACL 禁止普通业务账号执行高风险管理命令;代码评审禁止在业务路径使用 KEYS


事故三:固定 TTL 引发缓存雪崩

现象

某个整点 Redis 命中率骤降,数据库 QPS、连接数和 CPU 同时上升。

根因

批量预热的所有缓存均设置相同 TTL:

TTL = 3600 秒

导致同一时间集中失效。

排查

分析:

  • Key TTL 分布;
  • 缓存命中率时间序列;
  • 数据库 QPS;
  • 预热任务执行时间;
  • 过期 Key 速率。

修复

增加 TTL 抖动,热点 Key 采用逻辑过期和异步重建,并对回源请求限流。

预防

缓存 SDK 默认支持随机 TTL;上线前压测集中失效场景。


事故四:删除大 Hash 阻塞实例

现象

执行一次删除后,整个 Redis 实例出现明显延迟尖峰。

根因

使用 DEL 同步删除拥有大量字段的 Hash,命令执行路径承担了完整对象释放成本。

排查

检查:

  • 被删除 Key 的 HLEN
  • 删除前的 MEMORY USAGE
  • 延迟监控和慢日志;
  • 同一时刻是否存在大量批量删除。

修复

改为 UNLINK;若对象极大,先从业务模型上按时间或分片拆分。

预防

为聚合类型设置基数阈值;大对象删除统一进入限速清理任务。


现象

业务已经查不到 Key,但实例物理内存仍处于高位,甚至继续接近 OOM。

根因

UNLINK 只保证前台从 Keyspace 摘除,物理释放由后台完成。删除速度大于后台回收速度,或者内存分配器未立即把内存归还操作系统。

排查

观察:

  • Redis 内存和 RSS;
  • CPU;
  • 后台释放相关指标;
  • 批量删除速率;
  • Key 对象大小和数量。

修复

降低删除并发和批次,暂停新增大对象,必要时扩容或迁移流量。

预防

清理程序必须具备速率限制;不要把 UNLINK 理解成“立即释放全部内存”。


事故六:SCAN 重复导致业务处理两次

现象

扫描任务导致重复发券、重复发送消息或计数翻倍。

根因

程序假设一次完整 SCAN 中每个 Key 只出现一次,并对返回结果执行非幂等操作。

排查

比对:

  • 扫描日志中的重复 Key;
  • 业务幂等记录;
  • 任务执行时间内 Keyspace 是否发生扩缩容和大量变化。

修复

为业务操作增加幂等键,或在任务范围内去重。

预防

代码评审明确:SCAN 结果允许重复,所有处理逻辑默认按至少一次执行设计。


事故七:依赖过期通知关单,部分订单一直未关闭

现象

大部分超时订单正常关闭,但少量订单长期停留在处理中。

根因

系统把 Keyspace Notification 当作可靠延迟队列。消费者短暂断线时事件丢失,或过期事件晚于理论 TTL 产生。

排查

检查:

  • Pub/Sub 断连日志;
  • Redis 节点切换;
  • 消费者重启时间;
  • 数据库中未关订单;
  • Key 实际删除时间。

修复

以数据库状态为准执行补偿扫描;将超时任务迁移到可靠消息或任务表。

预防

Keyspace Notification 只作加速信号,必须有可重放的权威任务记录和定期补偿。


事故八:Cluster 清理只删除了部分 Key

现象

清理任务显示成功,但旧版本 Key 仍大量存在。

根因

程序只连接并扫描了一个 Cluster 节点,把该节点的游标结束误认为整个集群扫描完成。

排查

分别检查各主节点的 Key 数量和前缀分布。

修复

遍历所有主节点并分别完成 SCAN;删除时按节点或槽位路由。

预防

清理工具区分单机、Sentinel 和 Cluster 模式,并在报告中记录每个节点的扫描进度。


9. 方案选型与权衡

9.1 过期策略对比

策略适用场景优点缺点与风险
不设置 TTL权威配置、人工维护数据行为简单容易形成永久垃圾数据
固定相对 TTL普通缓存实现简单批量创建时可能集中失效
TTL 加随机抖动大规模查询缓存分散过期压力不适合严格截止时间
绝对过期时间Token、活动截止截止语义明确依赖稳定时钟
滑动 TTL会话、活跃窗口活跃数据持续保留并发续期复杂,可能被无限延长
逻辑过期超热点缓存避免硬失效瞬间回源可能短暂返回旧数据
版本前缀淘汰数据结构迁移容易灰度和回滚新旧版本会暂时双份占用内存

9.2 删除方案对比

方案前台延迟内存回收一致性特征适用场景
DEL小对象低,大对象可能高同步返回时已完成对象释放少量小 Key
UNLINK通常较低异步返回时 Key 已不可见,物理回收可能未完成大对象、在线清理
全局 lazyfree低延迟倾向异步影响更多删除路径经充分压测的专用实例
SCAN + DEL可分批,但每批可能阻塞同步非快照已知都是小 Key
SCAN + UNLINK较可控异步非快照,允许重复在线旧版本清理
版本切换后自然 TTL前台几乎无清理压力渐进旧数据暂时占内存缓存版本升级
整实例下线业务切换成本较高实例级回收边界清晰完全独立的临时数据集

没有一种策略在所有维度上都最优。选型时至少需要同时考虑:

  • 前台延迟;
  • 峰值内存;
  • 后台 CPU;
  • 删除完成时间;
  • 是否允许旧数据暂存;
  • 是否存在并发写入;
  • 是否需要回滚。

10. 高频面试题

10.1 你会如何设计 Redis Key?

问题

如何设计一个适合生产系统的 Redis Key?

推荐回答

先按环境、服务、资源、结构版本、租户和业务 ID 分层,例如 prod:trade:order:v2:t42:90001

这样做可以避免环境和业务冲突,支持版本迁移,也方便监控和批量治理。Key 应当稳定、可读、长度受控,不应包含密码、Token 原文或个人敏感信息。租户前缀只能提供逻辑隔离,安全隔离仍需 ACL、应用校验或独立实例。

面试官追问

  1. 为什么需要版本号?
  2. 租户前缀能否完全阻止越权访问?
  3. 为什么不能把随机 ID 放在最前面?

常见错误回答

“Key 越短越好,用业务 ID 就够了。”

评分点

  • 初级:知道使用冒号分隔和业务前缀。
  • 中级:能说明环境、版本、租户和可维护性。
  • 高级:能讨论迁移、回滚、安全边界和批量治理。

10.2 Key 越长会有什么影响?BigKey 是不是 Key 名很长?

问题

Redis Key 越长有什么问题?BigKey 如何定义?

推荐回答

Key 变长会增加内存和网络成本;BigKey 指 Value 对象或集合基数过大,不是 Key 名字长。

每个请求通常都要传输完整 Key,Key 本身也占内存。BigKey 需要结合对象字节数、元素数、命令复杂度、网络返回量和删除成本判断,不能只用一个固定 MB 阈值。可以用 MEMORY USAGEredis-cli --keystats 和集合基数命令排查。(Redis)

面试官追问

  1. 50 字节的 Key 一定合理吗?
  2. 一个 10 字节但百万 QPS 的 Key 属于什么问题?
  3. 如何定义本公司的 BigKey 告警线?

常见错误回答

“超过 1 MB 的 Key 就叫 BigKey。”

评分点

  • 初级:知道长 Key 占内存。
  • 中级:能区分 Key 长度、BigKey 和 HotKey。
  • 高级:能按数据类型、延迟和分布建立动态阈值。

10.3 哪些操作会清除或保留 TTL?

问题

一个已经有 TTL 的 Key,执行更新后 TTL 会怎样?

推荐回答

普通 SET 会清除旧 TTL;SET ... KEEPTTL 保留;SET ... EX/PX 设置新 TTL;INCRHSETLPUSH 等原地修改通常保留 TTL。

PERSIST 会移除 TTL,RENAME 会转移源 Key 的 TTL。判断原则是:完整替换对象通常清除旧 TTL,原地修改对象通常保留。(Redis)

面试官追问

  1. SET key value 后如何避免 TTL 丢失?
  2. KEEPTTL 遇到持久 Key 会怎样?
  3. Pipeline 执行 SETEXPIRE 是否原子?

常见错误回答

“只要 Key 原来有 TTL,更新后都会自动保留。”

评分点

  • 初级:知道普通 SET 会丢 TTL。
  • 中级:能列出 KEEPTTL 和原地修改语义。
  • 高级:能联系到两命令窗口、故障和并发续期。

10.4 EXPIREPEXPIREEXPIREAT 有什么区别?

问题

如何选择 Redis 的过期命令?

推荐回答

EXPIREPEXPIRE 是相对 TTL,单位分别是秒和毫秒;EXPIREATPEXPIREAT 是绝对 Unix 时间。

普通缓存更适合相对 TTL,明确业务截止时刻更适合绝对时间。TTLPTTL 用于查询,PERSIST 用于移除 TTL。绝对时间依赖稳定的系统时钟。(Redis)

面试官追问

  1. TTL 返回 -1-2 分别是什么?
  2. 为什么绝对过期时间对系统时钟敏感?
  3. 验证码应使用相对还是绝对时间?

常见错误回答

“这些命令只有时间单位不同。”

评分点

  • 初级:知道秒与毫秒。
  • 中级:知道相对与绝对语义。
  • 高级:能结合硬截止时间、时钟漂移和业务二次校验。

10.5 Redis 如何删除过期 Key?

问题

Redis 是不是 TTL 一到就立即删除 Key?

推荐回答

不是严格的一 Key 一定时器模型,而是惰性过期和主动抽样过期共同完成。

访问 Key 时会检查是否过期;后台也会周期性抽样检查带 TTL 的 Key。这样可以避免为海量 Key 维护独立定时器,同时在 CPU、内存残留和请求延迟之间取平衡。(Redis)

面试官追问

  1. 为什么只做惰性过期不够?
  2. 为什么不定期扫描全部 Key?
  3. 副本会不会独立删除过期 Key?

常见错误回答

“Redis 为每个 Key 创建一个定时任务。”

评分点

  • 初级:知道惰性和主动两种方式。
  • 中级:能说明抽样原因。
  • 高级:能联系主从传播、CPU 权衡和事件延迟。

10.6 为什么要给 TTL 加随机值?

问题

缓存 TTL 为什么通常需要抖动?

推荐回答

为了避免大量 Key 在同一时刻失效,导致缓存命中率骤降和数据库回源洪峰。

常见方式是:

ttl = base + random(0, jitter)

但验证码、Token、活动结束时间等严格截止场景不能随意向后延长,可以改为固定绝对截止时间,或只对刷新时间加抖动。

面试官追问

  1. 为什么有时不能使用对称抖动?
  2. 热点缓存除了抖动还能怎么处理?
  3. 抖动范围应如何确定?

常见错误回答

“所有 TTL 都随机加 10 分钟。”

评分点

  • 初级:知道防缓存雪崩。
  • 中级:知道不同业务使用不同抖动。
  • 高级:能结合回源容量、热点重建和硬截止约束设计。

问题

删除大 Key 时为什么建议使用 UNLINK

推荐回答

两者都会使 Key 不再可见,但 DEL 通常在前台完成对象释放,UNLINK 把实际内存回收交给后台线程。

所以 UNLINK 能降低删除大型聚合对象时的前台阻塞,但总 CPU 工作没有消失,内存回收也可能滞后。删除速度高于后台释放速度时仍可能产生压力。(Redis)

面试官追问

  1. UNLINK 返回后内存是否一定下降?
  2. 什么是 lazyfree-lazy-user-del
  3. 小 Key 是否必须使用 UNLINK

常见错误回答

UNLINK 不消耗 CPU,而且会立即释放内存。”

评分点

  • 初级:知道同步与异步释放。
  • 中级:知道逻辑删除和物理释放不同。
  • 高级:能讨论后台积压、限速和内存峰值。

10.8 为什么线上不建议使用 KEYSSCAN 是否完全无害?

问题

KEYSSCAN 如何选择?

推荐回答

KEYS 在一次命令中遍历整个 Keyspace;SCAN 将完整遍历拆成多个增量调用,更适合在线治理,但总工作量仍是 O(N)

COUNT 只是提示,SCAN 可能返回空结果但游标仍未结束。过大的 COUNT、无间隔扫描或大量结果处理,仍然会消耗 CPU、网络和客户端内存。(Redis)

面试官追问

  1. COUNT 1000 是否保证返回 1000 个 Key?
  2. 什么条件表示一轮扫描完成?
  3. 为什么 MATCH 很严格时会返回空数组?

常见错误回答

SCANO(1),所以对生产没有任何影响。”

评分点

  • 初级:知道 KEYS 阻塞风险。
  • 中级:理解游标和 COUNT
  • 高级:能设计限速、超时和容量评估。

10.9 SCAN 为什么会返回重复 Key?

问题

如何处理 SCAN 的重复结果?

推荐回答

因为 SCAN 只保存很少的游标状态,扫描期间哈希表扩缩容或重哈希时,同一元素可能在不同阶段被访问,因此协议允许重复。

应用要保证处理幂等。删除可以使用 UNLINK;发送消息或修改数据库时应使用业务幂等键。还要防止旧前缀扫描误删扫描期间重新创建的新数据。(Redis)

面试官追问

  1. 客户端必须保存所有已见 Key 吗?
  2. UNLINK 是否天然适合重复执行?
  3. 如何避免误删新建 Key?

常见错误回答

“只要 COUNT 不变,SCAN 就不会重复。”

评分点

  • 初级:知道可能重复。
  • 中级:知道使用幂等操作。
  • 高级:能指出重建同名 Key 的竞态和版本切换方案。

10.10 如何区分和治理 BigKey、HotKey?

问题

BigKey 和 HotKey 的发现及治理有什么不同?

推荐回答

BigKey 是对象体积或基数过大,HotKey 是访问负载集中;一个 Key 可以只大不热、只热不大,也可以两者兼有。

BigKey 可通过 MEMORY USAGE--keystatsHLENSCARD 等发现,常用治理是拆分、分页、裁剪和异步删除。HotKey 可通过 Redis 8.6+ 的 HOTKEYS、应用埋点和代理统计发现,治理手段包括本地缓存、请求合并、限流和逻辑分片。(Redis)

面试官追问

  1. 小 Key 能否成为 HotKey?
  2. 为什么读副本不能解决所有 HotKey?
  3. 大 Hash 应按什么维度拆分?

常见错误回答

“BigKey 和 HotKey 都是 Value 太大。”

评分点

  • 初级:能说出大小和热度的区别。
  • 中级:能列出发现工具和治理方案。
  • 高级:能讨论分片倾斜、一致性和故障恢复成本。

10.11 Keyspace Notification 能否实现可靠延迟队列?

问题

能否监听 expired 事件来可靠执行关单?

推荐回答

不能把它作为唯一可靠机制。它基于 Pub/Sub,断线会丢消息;事件在 Key 实际删除时产生,可能晚于理论 TTL;Cluster 中还需要分别订阅各节点。

它适合作为快速触发信号,但必须有数据库任务记录、消息队列或定期补偿扫描作为事实来源。(Redis)

面试官追问

  1. 为什么事件可能延迟?
  2. 消费者重启后能否补收?
  3. 如何设计可靠的订单超时关闭?

常见错误回答

“Redis 很快,所以过期事件一定准时且不会丢。”

评分点

  • 初级:知道需要开启配置。
  • 中级:知道 Pub/Sub 会丢失。
  • 高级:能提出 Outbox、补偿扫描和幂等状态机方案。

10.12 如何用 Go 安全地扫描并删除一类 Key?

问题

使用 go-redis/v9 编写前缀清理任务时要注意什么?

推荐回答

共享并发安全的 *redis.Client,但每轮游标和处理状态放在任务局部;用 SCAN 推进到游标 0,小批次 UNLINK,设置 Context 超时、速率限制和幂等处理。

还要注意:

  • COUNT 不是严格批量大小;
  • SCAN 可能重复;
  • 超时不代表命令没执行;
  • Pipeline 实例不能跨 Goroutine 共享;
  • Cluster 必须分别扫描各主节点;
  • 破坏性清理前应停止旧前缀写入。(GitHub)

面试官追问

  1. 为什么不启动 100 个 Goroutine 同时扫描?
  2. 删除失败后如何重试?
  3. Cluster 中如何保证扫描覆盖所有节点?

常见错误回答

“开更多 Goroutine,使用 KEYS 一次拿完再并发删除最快。”

评分点

  • 初级:会正确循环游标。
  • 中级:有超时、分批、错误处理和限速。
  • 高级:能讨论重复执行、超时歧义、Cluster 拓扑变化和迁移竞态。

11. 一分钟面试回答

Redis Key 应按环境、服务、业务资源、结构版本、租户和业务 ID 分层设计,既要可读,也要控制长度,租户前缀只是逻辑隔离。TTL 最好在写 Value 时通过一条 SET ... EX/PX 原子设置,普通 SET 会清除旧 TTL,KEEPTTL 才会保留。Redis 通过访问时惰性过期和后台主动抽样共同清理 Key,所以 TTL 到 0 不代表事件一定即时产生。删除大型聚合对象优先考虑 UNLINK,它只是把物理释放迁移到后台,并没有消除 CPU 和内存回收成本。线上遍历使用 SCAN,但它不是快照、可能重复,COUNT 也只是提示,因此处理必须幂等并限速。BigKey 关注对象大小和基数,HotKey 关注访问集中度。Keyspace Notification 基于 Pub/Sub,会丢消息且可能延迟,不能独立承担可靠关单或延迟任务。


12. 本章总结

  1. Key 命名是长期数据接口,应包含业务归属、结构版本和必要的租户维度。
  2. Key 越长,内存和网络成本越高,但不能为了短而牺牲可维护性。
  3. 普通 SET 会清除旧 TTL;原地修改通常保留 TTL。
  4. Value 与 TTL 应尽量通过一条命令完成,避免两命令故障窗口。
  5. 缓存 TTL 抖动可以缓解集中失效,但不能突破严格业务截止时间。
  6. Redis 通过惰性过期和主动抽样过期共同清理 Key。
  7. UNLINK 降低前台删除延迟,但后台释放仍然需要 CPU 和时间。
  8. SCAN 适合增量遍历,但不是快照,允许重复,完整扫描仍是 O(N)
  9. BigKey 是对象大,HotKey 是访问热,两者的发现和治理方法不同。
  10. Keyspace Notification 是尽力而为的通知,不是可靠消息系统。
  11. 大规模清理应采用版本切换、停止旧写入、分片扫描、限速删除和可观测性。
  12. Go 客户端可以并发复用,但任务游标、Pipeline 和可变状态必须正确管理。

13. 自测清单

  1. 为什么普通 SET 会让已有 TTL 消失?
  2. TTL 返回 -1-2 分别表示什么?
  3. EXPIRE GTEXPIRE LT 适合解决什么问题?
  4. 为什么 Redis 不为每个 Key 创建独立定时器?
  5. UNLINK 返回成功后,为什么物理内存可能尚未下降?
  6. SCAN COUNT 1000 是否保证每次返回 1000 个 Key?
  7. 为什么 SCAN 的处理函数应当具备幂等性?
  8. 如何避免批量清理时误删刚重新创建的同名 Key?
  9. BigKey 和 HotKey 分别应使用哪些方法发现?
  10. 为什么 Keyspace Notification 不能单独实现可靠的订单超时关闭?

14. 官方资料

  1. Redis Open Source 8.8 Release Notes。(Redis)
  2. Redis EXPIRE 命令与过期实现说明。(Redis)
  3. Redis SET 命令及 EXPXEXATPXATKEEPTTL。(Redis)
  4. Redis TTL 命令返回语义。(Redis)
  5. Redis SCAN 命令、遍历保证和重复结果说明。(Redis)
  6. Redis UNLINK 命令。(Redis)
  7. Redis 8.8 redis.conf 中的 lazyfree 配置说明。(GitHub)
  8. Redis MEMORY USAGE 命令。(Redis)
  9. Redis CLI 的 BigKey、Memory Key 和 Key Statistics 工具。(Redis)
  10. Redis 8.6+ HOTKEYS 命令族。(Redis)
  11. Redis Keyspace Notification 官方说明。(Redis)
  12. go-redis/v9 官方仓库与并发安全说明。(GitHub)