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 命令执行是单线程的。对大 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 确认没有新的慢查询。
--bigkeys 和 SCAN 定位,再按访问模式拆分,最后用 UNLINK 或分批删除清理。每一步都应在从节点验证后再上主节点。