4. 键空间设计与大key治理

Redis 的性能问题很少来自“键太多”,更多来自“单个键太大”。键空间设计的第一原则不是命名规范,而是控制单键的体量与访问代价。一个 String 键存 200MB 的 JSON、一个 Hash 存 500 万个 field、一个 List 存 1000 万元素,都会让 Redis 从“内存数据库”退化成“单线程搬运工”。本节围绕大 key 的识别、影响、拆分与删除展开。

一、什么算大 key:从字节与元素两个维度判断

大 key 没有绝对阈值,通常从两个维度衡量:值本身的字节数(String 类)和集合内元素数量(Hash/List/Set/ZSet 类)。工程上常用的经验线是:String 值超过 10KB 需关注,超过 100KB 视为大 key;集合类元素数超过 5000 需关注,超过 1 万视为大 key。更严谨的做法是结合实例规格:若单实例内存 8GB,一个 100MB 的 key 已占 1.25%,且其删除、迁移、持久化开销都会被放大。

需要区分“大 key”与“热 key”:大 key 是体量大,热 key 是访问频次高。二者常同时出现,但治理手段不同——大 key 靠拆分,热 key 靠本地缓存或多副本。

二、识别方法:--bigkeys 与 SCAN 采样

redis-cli --bigkeys 是最快的入口。它内部使用 SCAN 遍历键空间,对每种数据类型统计最大 key,并给出类型分布。示例输出:

redis-cli -h 10.0.0.12 -p 6379 --bigkeys
# Scanning the entire keyspace to find biggest keys...
# Biggest string found 'user:profile:88213' has 524288 bytes
# Biggest list   found 'feed:timeline:1001' has 120034 items
# Biggest hash   found 'cart:items:5521' has 83012 fields
# 12 strings with 3MB, 4 lists with 1.2M items ...

它的局限是:只报告每类最大的一个 key,且基于采样估算内存,不给出精确字节。要拿到完整清单,应使用 SCAN 游标遍历并配合 MEMORY USAGE 或 STRLEN/HLEN/LLEN/SCARD/ZCARD:

SCAN 0 COUNT 1000
# 对返回的每个 key 判断类型后:
MEMORY USAGE user:profile:88213 SAMPLES 5
HLEN cart:items:5521
LLEN feed:timeline:1001

生产环境建议用只读从节点执行扫描,避免阻塞主节点;SCAN 的 COUNT 不宜过大,1000 左右较稳。若使用 Redis 4.0+,可开启 lazyfree-lazy-user-del yes 配合后续删除策略。

大 key 识别与治理链路 识别 redis-cli --bigkeys SCAN 游标 + MEMORY USAGE STRLEN / HLEN / LLEN SCARD / ZCARD 建议在只读从节点执行 影响 单线程阻塞:DEL / 序列化 内存倾斜:单分片压力高 主从复制延迟、AOF 重写慢 集群迁移卡顿、超时 P99 延迟可放大数十倍 治理 拆分:按业务维度分片 压缩:gzip / 序列化优化 渐进删除:UNLINK / SCAN+HDEL 过期策略:TTL + 分批清理 避免 DEL 大 key 拆分策略示例:大 Hash 按业务维度分片 cart:items:5521 83012 fields · 单键过大 cart:items:5521:0 按 hash 槽分片 cart:items:5521:1 每片约 1 万 field … 删除方式对比 DEL bigkey → 同步释放,阻塞主线程 100 万 field 可能阻塞数百毫秒 UNLINK bigkey → 异步回收,立即返回 后台线程释放,主线程不阻塞

三、大 key 对性能与容量的双重影响

性能上,Redis 命令执行是单线程的。对大 key 执行 DEL、HGETALL、LRANGE 0 -1、SMEMBERS 时,命令耗时随元素数线性增长。一个 100 万 field 的 Hash 执行 HGETALL 可能占用几十毫秒,期间所有其他请求排队,P99 延迟瞬间飙升。持久化同样受影响:AOF 重写和 RDB 快照需要遍历并序列化大 key,fork 后的写时复制也会因大 key 被修改而放大内存占用。

容量上,大 key 导致内存分布不均。在 Redis Cluster 中,槽位按 key 分配,一个大 key 可能让某个分片内存远高于其他分片,触发 maxmemory 淘汰,而其他分片仍空闲。主从复制时,大 key 的同步和增量传播也会造成从节点延迟。

四、拆分策略:按访问模式切分

拆分不是简单地把 key 改小,而是按业务访问模式重新组织。常见三种方式:

  • 按时间分片:如 feed:timeline:1001:202406,每月一个 List,读取时按需合并。适合日志、消息流。
  • 按哈希槽分片:如 cart:items:5521:{0..15},用 hash(field) % 16 决定分片,读取时先定位再取。适合大 Hash。
  • 按业务主键分片:如 user:profile:88213:base 与 user:profile:88213:ext,把冷热字段分离,热字段单独存放。

拆分后要保证原子性边界:如果业务需要跨分片原子操作,应改用 Lua 脚本或重新评估是否真的需要单键。多数场景下,读多写少,最终一致即可接受。

五、渐进式删除与 UNLINK

Redis 4.0 引入 UNLINK,它把 key 从键空间摘除后,将内存释放交给后台线程,主线程立即返回。对于大 key,应优先使用 UNLINK 替代 DEL。若版本低于 4.0,或需要更细粒度控制,可用 SCAN + HDEL 分批删除:

# 分批删除大 Hash,每批 500 个 field
local cursor = "0"
repeat
  local res = redis.call("HSCAN", KEYS[1], cursor, "COUNT", 500)
  cursor = res[1]
  local fields = res[2]
  if #fields > 0 then
    redis.call("HDEL", KEYS[1], unpack(fields))
  end
until cursor == "0"
redis.call("UNLINK", KEYS[1])

同时建议开启 lazyfree-lazy-expire yes、lazyfree-lazy-eviction yes、lazyfree-lazy-server-del yes,让过期、淘汰和内部删除也走异步释放。删除后通过 INFO memory 观察 used_memory 是否回落,并用 SLOWLOG GET 确认没有新的慢查询。

治理大 key 的闭环:先用 --bigkeys 和 SCAN 定位,再按访问模式拆分,最后用 UNLINK 或分批删除清理。每一步都应在从节点验证后再上主节点。