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. 学习目标
完成本章后,你应当能够:
- 设计具有业务前缀、结构版本和租户隔离能力的 Key。
- 说明 Key 长度对内存、网络和可维护性的影响。
- 准确解释
EXPIRE、PEXPIRE、EXPIREAT、TTL、PTTL和PERSIST。 - 判断一次写操作会覆盖、保留还是清除已有 TTL。
- 解释 Redis 的惰性过期和定期主动过期机制。
- 正确选择
DEL、UNLINK和 lazyfree。 - 使用
SCAN编写可控、幂等的在线扫描和批量清理程序。 - 区分 BigKey 与 HotKey,并设计发现和治理方案。
- 说明 Keyspace Notification 的可靠性边界。
- 在 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
各字段的职责如下:
| 字段 | 示例 | 作用 |
|---|---|---|
| 环境 | prod、staging | 避免测试与生产互相污染 |
| 服务 | trade、iam | 标识数据所有者 |
| 资源 | order、session | 表达业务对象 |
| 结构版本 | 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
当序列化格式或字段语义发生不兼容变化时,可以:
- 新代码开始写入
v2; - 读请求优先读
v2,未命中再降级读v1; - 迁移完成后停止读取
v1; - 通过 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
几个月后,很难判断 p、t、o 分别代表什么。
所以生产设计应当遵循:
在含义清晰的前提下保持紧凑,而不是单纯追求最短。
不存在适用于所有业务的“最佳 Key 长度”。应使用真实数据、真实 Redis 版本和真实内存分配器,通过 MEMORY USAGE、容量压测和网络监控做判断。
3.3 TTL 命令体系
Redis 中没有过期时间的 Key 称为持久 Key;关联了过期时间的 Key 通常称为 volatile Key。
| 命令 | 时间单位 | 时间含义 | 典型用途 |
|---|---|---|---|
EXPIRE key seconds | 秒 | 从当前时间开始的相对 TTL | 普通缓存、会话 |
PEXPIRE key milliseconds | 毫秒 | 相对 TTL | 精细时间窗口 |
EXPIREAT key timestamp | 秒 | Unix 绝对时间 | 固定截止时刻 |
PEXPIREAT key timestamp | 毫秒 | Unix 绝对时间 | 毫秒级绝对截止 |
TTL key | 秒 | 查询剩余时间 | 运维检查 |
PTTL key | 毫秒 | 查询剩余时间 | 精细诊断 |
PERSIST key | — | 移除 TTL | 转为持久 Key |
TTL 和 PTTL 的特殊返回值为:
-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
而 SET、GETSET、部分 *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 抖动 |
|---|---|
| 普通查询缓存 | 适合 |
| 商品详情、配置缓存 | 通常适合 |
| 会话空闲超时 | 需结合安全要求判断 |
| 验证码严格有效期 | 通常不应延长 |
| 优惠活动固定结束时间 | 不应越过业务截止时间 |
| 法规要求的数据删除期限 | 不应随机延长 |
对于严格截止时间,可以:
- 使用
EXPIREAT或PXAT; - 只在刷新时间上加抖动,不改变最终失效时间;
- 在 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)
3.7 DEL、UNLINK 与 lazyfree
DEL
DEL huge:hash:10001
DEL 会先把 Key 从 Keyspace 移除,并在命令执行路径中完成对象释放。
删除小字符串时,释放成本通常很低;但删除包含大量字段、成员或节点的 Hash、List、Set、ZSet 时,对象析构可能占用较长时间。
这时,客户端看到的不是单纯的网络延迟,而是:
命令开始执行
↓
从 Keyspace 移除
↓
遍历并释放大量内部对象
↓
命令结束
较长的同步释放可能拖慢同一实例上的其他请求。
UNLINK
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)
选择原则:
| 情况 | 更倾向的方案 |
|---|---|
| 小字符串、少量小 Key | DEL 或 UNLINK 均可 |
| 大 Hash、大 List、大 Set | 优先评估 UNLINK |
| 在线批量清理 | SCAN + 小批次 UNLINK |
| 极端内存紧张 | 不能只依赖异步释放,应限速并监控积压 |
| 需要立即释放物理内存 | UNLINK 不保证立即完成物理回收 |
3.8 KEYS 与 SCAN
KEYS
KEYS prod:trade:order:*
KEYS 在一次命令中遍历当前数据库的 Keyspace,并一次性返回匹配结果。
它的问题不是“命令名字危险”,而是单次工作量和返回结果都可能过大:
- 单次遍历复杂度为
O(N); - 核心命令执行路径可能被长时间占用;
- 大量结果会占用服务端输出缓冲区、网络和客户端内存;
- 即使只匹配少量 Key,也通常仍需检查大量 Key。
因此,KEYS 更适合:
- 本地开发;
- 隔离测试环境;
- 已确认规模很小的专用实例;
- 故障排查中有严格风险控制的场景。
不应把它放进普通在线业务路径。
SCAN
SCAN 0 MATCH 'prod:trade:order:v2:t42:*' COUNT 200
SCAN 使用游标渐进遍历:
- 第一次传入游标
0; - 保存服务端返回的新游标;
- 使用新游标继续调用;
- 直到服务端再次返回游标
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
此时可能误删新创建的数据。因此,大规模破坏性清理更稳妥的做法是:
- 先切换到新版本前缀;
- 停止旧前缀写入;
- 等待在途请求结束;
- 再扫描并删除旧前缀。
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 的区别
| 维度 | BigKey | HotKey |
|---|---|---|
| 核心问题 | 对象体积或基数过大 | 访问流量过度集中 |
| 是否一定占用大量内存 | 通常是 | 不一定 |
| 是否一定高 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表示过期事件;- 至少需要包含
K或E,否则不会产生对应通知。
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 方法 |
|---|---|
EXPIRE | Expire |
PEXPIRE | PExpire |
EXPIREAT | ExpireAt |
PEXPIREAT | PExpireAt |
TTL | TTL |
PTTL | PTTL |
PERSIST | Persist |
SCAN | Scan |
DEL | Del |
UNLINK | Unlink |
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-redis 的 Client 管理连接池,可以被多个 Goroutine 并发使用,不应为每个请求创建一个新客户端。(GitHub)
2. 每次调用仍需要 Context 截止时间。
连接池安全不等于请求不会无限等待。应用层 Context、客户端 ReadTimeout、WriteTimeout 和上游 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 常见命令复杂度
| 操作 | 前台复杂度 | 后续工作 | 说明 |
|---|---|---|---|
EXPIRE、TTL、PERSIST | 通常 O(1) | 无或少量 | 修改或读取过期元数据 |
KEYS pattern | O(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.8 | TTL 开始用 -2 区分不存在的 Key |
| 4.0 | 提供 UNLINK 和 MEMORY USAGE |
| 6.0 | SET 增加 KEEPTTL |
| 6.2 | SET 增加 EXAT、PXAT |
| 7.0 | EXPIRE 增加 NX、XX、GT、LT |
| 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 子集。扫描一个节点不能代表扫描了整个集群。
生产清理程序需要:
- 获取当前主节点列表;
- 对每个主节点分别执行完整
SCAN; - 在拓扑变化时重新确认节点;
- 接受扫描期间迁移和写入造成的非快照语义;
- 按分片路由批量删除,避免跨槽多 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;若对象极大,先从业务模型上按时间或分片拆分。
预防
为聚合类型设置基数阈值;大对象删除统一进入限速清理任务。
事故五: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、应用校验或独立实例。
面试官追问
- 为什么需要版本号?
- 租户前缀能否完全阻止越权访问?
- 为什么不能把随机 ID 放在最前面?
常见错误回答
“Key 越短越好,用业务 ID 就够了。”
评分点
- 初级:知道使用冒号分隔和业务前缀。
- 中级:能说明环境、版本、租户和可维护性。
- 高级:能讨论迁移、回滚、安全边界和批量治理。
10.2 Key 越长会有什么影响?BigKey 是不是 Key 名很长?
问题
Redis Key 越长有什么问题?BigKey 如何定义?
推荐回答
Key 变长会增加内存和网络成本;BigKey 指 Value 对象或集合基数过大,不是 Key 名字长。
每个请求通常都要传输完整 Key,Key 本身也占内存。BigKey 需要结合对象字节数、元素数、命令复杂度、网络返回量和删除成本判断,不能只用一个固定 MB 阈值。可以用 MEMORY USAGE、redis-cli --keystats 和集合基数命令排查。(Redis)
面试官追问
- 50 字节的 Key 一定合理吗?
- 一个 10 字节但百万 QPS 的 Key 属于什么问题?
- 如何定义本公司的 BigKey 告警线?
常见错误回答
“超过 1 MB 的 Key 就叫 BigKey。”
评分点
- 初级:知道长 Key 占内存。
- 中级:能区分 Key 长度、BigKey 和 HotKey。
- 高级:能按数据类型、延迟和分布建立动态阈值。
10.3 哪些操作会清除或保留 TTL?
问题
一个已经有 TTL 的 Key,执行更新后 TTL 会怎样?
推荐回答
普通 SET 会清除旧 TTL;SET ... KEEPTTL 保留;SET ... EX/PX 设置新 TTL;INCR、HSET、LPUSH 等原地修改通常保留 TTL。
PERSIST 会移除 TTL,RENAME 会转移源 Key 的 TTL。判断原则是:完整替换对象通常清除旧 TTL,原地修改对象通常保留。(Redis)
面试官追问
SET key value后如何避免 TTL 丢失?KEEPTTL遇到持久 Key 会怎样?- Pipeline 执行
SET和EXPIRE是否原子?
常见错误回答
“只要 Key 原来有 TTL,更新后都会自动保留。”
评分点
- 初级:知道普通
SET会丢 TTL。 - 中级:能列出
KEEPTTL和原地修改语义。 - 高级:能联系到两命令窗口、故障和并发续期。
10.4 EXPIRE、PEXPIRE、EXPIREAT 有什么区别?
问题
如何选择 Redis 的过期命令?
推荐回答
EXPIRE 和 PEXPIRE 是相对 TTL,单位分别是秒和毫秒;EXPIREAT 和 PEXPIREAT 是绝对 Unix 时间。
普通缓存更适合相对 TTL,明确业务截止时刻更适合绝对时间。TTL 和 PTTL 用于查询,PERSIST 用于移除 TTL。绝对时间依赖稳定的系统时钟。(Redis)
面试官追问
TTL返回-1和-2分别是什么?- 为什么绝对过期时间对系统时钟敏感?
- 验证码应使用相对还是绝对时间?
常见错误回答
“这些命令只有时间单位不同。”
评分点
- 初级:知道秒与毫秒。
- 中级:知道相对与绝对语义。
- 高级:能结合硬截止时间、时钟漂移和业务二次校验。
10.5 Redis 如何删除过期 Key?
问题
Redis 是不是 TTL 一到就立即删除 Key?
推荐回答
不是严格的一 Key 一定时器模型,而是惰性过期和主动抽样过期共同完成。
访问 Key 时会检查是否过期;后台也会周期性抽样检查带 TTL 的 Key。这样可以避免为海量 Key 维护独立定时器,同时在 CPU、内存残留和请求延迟之间取平衡。(Redis)
面试官追问
- 为什么只做惰性过期不够?
- 为什么不定期扫描全部 Key?
- 副本会不会独立删除过期 Key?
常见错误回答
“Redis 为每个 Key 创建一个定时任务。”
评分点
- 初级:知道惰性和主动两种方式。
- 中级:能说明抽样原因。
- 高级:能联系主从传播、CPU 权衡和事件延迟。
10.6 为什么要给 TTL 加随机值?
问题
缓存 TTL 为什么通常需要抖动?
推荐回答
为了避免大量 Key 在同一时刻失效,导致缓存命中率骤降和数据库回源洪峰。
常见方式是:
ttl = base + random(0, jitter)
但验证码、Token、活动结束时间等严格截止场景不能随意向后延长,可以改为固定绝对截止时间,或只对刷新时间加抖动。
面试官追问
- 为什么有时不能使用对称抖动?
- 热点缓存除了抖动还能怎么处理?
- 抖动范围应如何确定?
常见错误回答
“所有 TTL 都随机加 10 分钟。”
评分点
- 初级:知道防缓存雪崩。
- 中级:知道不同业务使用不同抖动。
- 高级:能结合回源容量、热点重建和硬截止约束设计。
10.7 DEL 与 UNLINK 有什么区别?
问题
删除大 Key 时为什么建议使用 UNLINK?
推荐回答
两者都会使 Key 不再可见,但 DEL 通常在前台完成对象释放,UNLINK 把实际内存回收交给后台线程。
所以 UNLINK 能降低删除大型聚合对象时的前台阻塞,但总 CPU 工作没有消失,内存回收也可能滞后。删除速度高于后台释放速度时仍可能产生压力。(Redis)
面试官追问
UNLINK返回后内存是否一定下降?- 什么是
lazyfree-lazy-user-del? - 小 Key 是否必须使用
UNLINK?
常见错误回答
“UNLINK 不消耗 CPU,而且会立即释放内存。”
评分点
- 初级:知道同步与异步释放。
- 中级:知道逻辑删除和物理释放不同。
- 高级:能讨论后台积压、限速和内存峰值。
10.8 为什么线上不建议使用 KEYS?SCAN 是否完全无害?
问题
KEYS 和 SCAN 如何选择?
推荐回答
KEYS 在一次命令中遍历整个 Keyspace;SCAN 将完整遍历拆成多个增量调用,更适合在线治理,但总工作量仍是 O(N)。
COUNT 只是提示,SCAN 可能返回空结果但游标仍未结束。过大的 COUNT、无间隔扫描或大量结果处理,仍然会消耗 CPU、网络和客户端内存。(Redis)
面试官追问
COUNT 1000是否保证返回 1000 个 Key?- 什么条件表示一轮扫描完成?
- 为什么
MATCH很严格时会返回空数组?
常见错误回答
“SCAN 是 O(1),所以对生产没有任何影响。”
评分点
- 初级:知道
KEYS阻塞风险。 - 中级:理解游标和
COUNT。 - 高级:能设计限速、超时和容量评估。
10.9 SCAN 为什么会返回重复 Key?
问题
如何处理 SCAN 的重复结果?
推荐回答
因为 SCAN 只保存很少的游标状态,扫描期间哈希表扩缩容或重哈希时,同一元素可能在不同阶段被访问,因此协议允许重复。
应用要保证处理幂等。删除可以使用 UNLINK;发送消息或修改数据库时应使用业务幂等键。还要防止旧前缀扫描误删扫描期间重新创建的新数据。(Redis)
面试官追问
- 客户端必须保存所有已见 Key 吗?
UNLINK是否天然适合重复执行?- 如何避免误删新建 Key?
常见错误回答
“只要 COUNT 不变,SCAN 就不会重复。”
评分点
- 初级:知道可能重复。
- 中级:知道使用幂等操作。
- 高级:能指出重建同名 Key 的竞态和版本切换方案。
10.10 如何区分和治理 BigKey、HotKey?
问题
BigKey 和 HotKey 的发现及治理有什么不同?
推荐回答
BigKey 是对象体积或基数过大,HotKey 是访问负载集中;一个 Key 可以只大不热、只热不大,也可以两者兼有。
BigKey 可通过 MEMORY USAGE、--keystats、HLEN、SCARD 等发现,常用治理是拆分、分页、裁剪和异步删除。HotKey 可通过 Redis 8.6+ 的 HOTKEYS、应用埋点和代理统计发现,治理手段包括本地缓存、请求合并、限流和逻辑分片。(Redis)
面试官追问
- 小 Key 能否成为 HotKey?
- 为什么读副本不能解决所有 HotKey?
- 大 Hash 应按什么维度拆分?
常见错误回答
“BigKey 和 HotKey 都是 Value 太大。”
评分点
- 初级:能说出大小和热度的区别。
- 中级:能列出发现工具和治理方案。
- 高级:能讨论分片倾斜、一致性和故障恢复成本。
10.11 Keyspace Notification 能否实现可靠延迟队列?
问题
能否监听 expired 事件来可靠执行关单?
推荐回答
不能把它作为唯一可靠机制。它基于 Pub/Sub,断线会丢消息;事件在 Key 实际删除时产生,可能晚于理论 TTL;Cluster 中还需要分别订阅各节点。
它适合作为快速触发信号,但必须有数据库任务记录、消息队列或定期补偿扫描作为事实来源。(Redis)
面试官追问
- 为什么事件可能延迟?
- 消费者重启后能否补收?
- 如何设计可靠的订单超时关闭?
常见错误回答
“Redis 很快,所以过期事件一定准时且不会丢。”
评分点
- 初级:知道需要开启配置。
- 中级:知道 Pub/Sub 会丢失。
- 高级:能提出 Outbox、补偿扫描和幂等状态机方案。
10.12 如何用 Go 安全地扫描并删除一类 Key?
问题
使用 go-redis/v9 编写前缀清理任务时要注意什么?
推荐回答
共享并发安全的 *redis.Client,但每轮游标和处理状态放在任务局部;用 SCAN 推进到游标 0,小批次 UNLINK,设置 Context 超时、速率限制和幂等处理。
还要注意:
COUNT不是严格批量大小;SCAN可能重复;- 超时不代表命令没执行;
- Pipeline 实例不能跨 Goroutine 共享;
- Cluster 必须分别扫描各主节点;
- 破坏性清理前应停止旧前缀写入。(GitHub)
面试官追问
- 为什么不启动 100 个 Goroutine 同时扫描?
- 删除失败后如何重试?
- 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. 本章总结
- Key 命名是长期数据接口,应包含业务归属、结构版本和必要的租户维度。
- Key 越长,内存和网络成本越高,但不能为了短而牺牲可维护性。
- 普通
SET会清除旧 TTL;原地修改通常保留 TTL。 - Value 与 TTL 应尽量通过一条命令完成,避免两命令故障窗口。
- 缓存 TTL 抖动可以缓解集中失效,但不能突破严格业务截止时间。
- Redis 通过惰性过期和主动抽样过期共同清理 Key。
UNLINK降低前台删除延迟,但后台释放仍然需要 CPU 和时间。SCAN适合增量遍历,但不是快照,允许重复,完整扫描仍是O(N)。- BigKey 是对象大,HotKey 是访问热,两者的发现和治理方法不同。
- Keyspace Notification 是尽力而为的通知,不是可靠消息系统。
- 大规模清理应采用版本切换、停止旧写入、分片扫描、限速删除和可观测性。
- Go 客户端可以并发复用,但任务游标、Pipeline 和可变状态必须正确管理。
13. 自测清单
- 为什么普通
SET会让已有 TTL 消失? TTL返回-1与-2分别表示什么?EXPIRE GT和EXPIRE LT适合解决什么问题?- 为什么 Redis 不为每个 Key 创建独立定时器?
UNLINK返回成功后,为什么物理内存可能尚未下降?SCAN COUNT 1000是否保证每次返回 1000 个 Key?- 为什么
SCAN的处理函数应当具备幂等性? - 如何避免批量清理时误删刚重新创建的同名 Key?
- BigKey 和 HotKey 分别应使用哪些方法发现?
- 为什么 Keyspace Notification 不能单独实现可靠的订单超时关闭?
14. 官方资料
- Redis Open Source 8.8 Release Notes。(Redis)
- Redis
EXPIRE命令与过期实现说明。(Redis) - Redis
SET命令及EX、PX、EXAT、PXAT、KEEPTTL。(Redis) - Redis
TTL命令返回语义。(Redis) - Redis
SCAN命令、遍历保证和重复结果说明。(Redis) - Redis
UNLINK命令。(Redis) - Redis 8.8
redis.conf中的 lazyfree 配置说明。(GitHub) - Redis
MEMORY USAGE命令。(Redis) - Redis CLI 的 BigKey、Memory Key 和 Key Statistics 工具。(Redis)
- Redis 8.6+
HOTKEYS命令族。(Redis) - Redis Keyspace Notification 官方说明。(Redis)
go-redis/v9官方仓库与并发安全说明。(GitHub)