关系数据库里,表结构是强约束:字段名、类型、索引都写在 DDL 里,改一次要走迁移。Redis 什么约束都没有——key 是任意二进制字符串,value 是任意结构,没有 schema、没有校验、没有外键。这份自由在项目初期是效率,在项目中期是灾难:同一个业务概念出现 user:1、User_1、u:1:profile 三种写法,缓存失效时删不干净,排查问题时搜不到。
Key 设计规范不是形式主义。它直接决定三件事:内存占用(key 本身要占空间)、集群可用性(哈希标签决定能否多键操作)、可运维性(能不能按前缀批量处理)。本文给出可落地的命名约定、内存账、槽位约束与反模式清单。
一、命名规范:三段式冒号分层
事实标准是冒号分层 + 小写 + 业务前缀:
<业务/系统>:<实体>:<标识>[:<子维度>]
user:1001:profile
user:1001:session
order:20261007:detail:8899
cache:product:detail:3301
lock:order:8899
rate:api:user:1001
| 规则 | 说明 | 反例 |
|---|---|---|
用 : 分层 | 社区惯例,所有工具与客户端默认按此解析 | user_1001_profile |
| 全小写 | 避免大小写混用导致的「找不到 key」 | User:1001:Profile |
| 业务前缀在最左 | 便于 SCAN MATCH user:* 批量操作 | 1001:user:profile |
| 层级不超过 4 段 | 段数越多越难维护,也越费内存 | a:b:c:d:e:f:g |
| 不用空格与换行 | 命令行与日志中会带来转义麻烦 | user:1001:my profile |
| 标识用 ID 而非中文名 | 中文 key 虽支持但不易排查 | 用户:张三 |
1.1 分隔符为什么是冒号而不是别的
: 不是 Redis 的规定,而是约定。选择它的理由:
redis-cli与可视化工具(RedisInsight)默认按:做树状分组展示,用别的分隔符就只能看到一堆平铺的 key。SCAN MATCH的模式匹配天然友好:user:1001:*比user_1001_*更清晰。- 哈希标签
{}可以叠加:{user:1001}:profile既分层又能强制同槽。
不要用 |、/、. 做分隔符。/ 会让人误以为是路径,. 在部分客户端配置里是保留字符,| 在命令行里需要转义。
1.2 大小写:统一小写
Redis 的 key 是大小写敏感的:
SET user:1 a
GET User:1
# (nil)
user:1 与 User:1 是两个完全不同的键。混用会造成两类问题:一是「明明写进去了却读不到」,二是缓存清理时按前缀删不全。规范上强制全小写,代码评审时用正则兜底:
^[a-z0-9][a-z0-9:_\-{}]*$
二、可解析性:让 key 能被程序读懂
Key 不只是给人看的,很多运维与治理工具需要解析 key 的结构。例如:
- 按业务前缀统计内存占用 → 需要前缀是稳定的一段。
- 按业务前缀做限流或配额 → 需要前缀可枚举。
- 大 Key 治理时按业务归因 → 需要能从 key 反推业务方。
这要求 key 满足「可解析且无歧义」:
| 要求 | 说明 |
|---|---|
| 分隔符不出现在标识内部 | 若订单号可能含 :,就破坏了分段语义 |
| 前缀集合可枚举 | 维护一份前缀清单,禁止随意新增 |
| 不嵌入可变内容 | 不要用时间戳、随机数当层级名 |
| 避免超长动态部分 | 用哈希或截断,别把整段 JSON 塞进 key |
反例:
# 订单号里含冒号,分段语义被破坏
order:2026:10:07:8899
# 前缀随意新增,无法枚举
tmp_xxx_1001
正例:
order:detail:8899 # 时间信息放在 value 或独立索引里
tmp:task:1001 # 有统一前缀 tmp
2.1 禁用字符清单
| 字符 | 问题 | 替代 |
|---|---|---|
| 空格 | 命令行、日志、CSV 导出都要转义 | 用 - 或 _ |
换行 \n | 破坏日志与 --csv 输出 | 用 _ |
空字节 \x00 | 部分客户端截断 | 禁止 |
* ? [ ] | 与 SCAN MATCH 的模式语法冲突 | 避免或转义 |
| 大写字母 | 与规范冲突 | 全小写 |
* 与 ? 尤其隐蔽:key 里如果含 *,用 SCAN MATCH user:* 时匹配行为会出乎意料。虽然 Redis 的 MATCH 是 glob 匹配,key 中含 * 不会导致误匹配(因为 * 只在模式侧生效),但在可视化工具与自定义脚本里会引发混淆,规范上应禁止。
三、内存账:key 长度不是免费的
很多人觉得 key 名长一点无所谓,「反正才几十字节」。但在百万级 key 的规模下,key 本身的存储开销会变成可观的一部分。
3.1 每个 key 的固定开销
一个 key 在 Redis 内部至少涉及三块内存:
| 组成部分 | 大小(64 位系统) | 说明 |
|---|---|---|
redisObject | 16 字节 | 类型、编码、引用计数、LRU 字段 |
| SDS 头(sdshdr) | 3~9 字节 | 按字符串长度选择头部类型 |
| dictEntry | 24 字节 | 哈希表节点,含 key 指针、value 指针、next 指针 |
| key 字符串本体 | 长度 + 1(含 \0) | 实际内容 |
| 固定开销小计 | 约 50~60 字节 | 与 key 长度无关 |
也就是说,即使 key 名只有 1 个字符,也要占约 50 字节。对象编码与内存结构的细节属于底层实现的范畴,这里只需要记住固定开销的绝对值。
3.2 长 key 的放大效应
假设 100 万个 key:
| key 平均长度 | key 本体占用 | 固定开销 | 合计 | 相对短 key 的增幅 |
|---|---|---|---|---|
| 16 字节 | 17 MB | 50 MB | 67 MB | 基准 |
| 32 字节 | 33 MB | 50 MB | 83 MB | +24% |
| 64 字节 | 65 MB | 50 MB | 115 MB | +72% |
| 128 字节 | 129 MB | 50 MB | 179 MB | +167% |
结论很直接:key 从 16 字节涨到 64 字节,光 key 部分就多占 48 MB。在 100 万 key 的规模下这是几十 MB,在 1 亿 key 的规模下就是几个 GB。
3.3 实测 key 的内存占用
Redis 提供了直接测量单个 key 占用的命令:
redis-cli MEMORY USAGE user:1001:profile
# (integer) 104
redis-cli MEMORY USAGE user:1001:profile SAMPLES 0
# SAMPLES 0 表示精确计算(不抽样),大 key 上会很慢,慎用
对一个只存了 32 字节字符串的 key,MEMORY USAGE 通常返回 80~120 字节——这就是固定开销的直观体现。批量评估时用 SAMPLES 5 抽样即可。
也可以从整体反推:
redis-cli INFO memory | grep -E 'used_memory_human|used_memory_dataset'
redis-cli DBSIZE
# 平均每 key 占用 ≈ used_memory_dataset / DBSIZE
如果算出来平均每 key 超过 200 字节,而业务 value 本身很小,基本可以确定是 key 太长或数据结构选择不当。
3.4 缩短 key 的实践
# 改前(32 字节)
application:user:profile:1001
# 改后(14 字节),用约定好的缩写前缀
app:user:1001
# 改前
cache:product:detail:3301
# 改后(用业务域缩写)
c:prod:3301
但要权衡:过度缩写会牺牲可读性。建议只对「超高频、超大量」的 key 做缩写,并且把缩写映射写进规范文档。不要为了省几个字节把 order 缩成 o——三个月后没人记得 o 是什么。
一个实用的判断标准:key 长度控制在 64 字节以内。超过这个长度的通常说明信息塞错了位置(应该放 value 而不是 key)。
四、哈希标签:决定集群可用性
Cluster 模式把 key 映射到 16384 个槽(slot),映射规则是:
slot = CRC16(key) mod 16384
如果 key 中包含 {...},则只用花括号内的内容计算槽位:
CRC16("user:1001:profile") → 某个槽
CRC16("{user:1001}:profile") → 由 "user:1001" 决定
CRC16("{user:1001}:session") → 与上面同一个槽
这就是哈希标签(Hash Tag)。它的价值是让相关 key 落到同一槽,从而支持多键操作:
# 不加哈希标签:跨槽,报错
MGET user:1001:profile user:1001:settings
# (error) CROSSSLOT Keys in request don't hash to the same slot
# 加哈希标签:同槽,成功
MGET {user:1001}:profile {user:1001}:settings
必须使用哈希标签的典型场景:
| 场景 | 原因 |
|---|---|
MGET / MSET 多键 | 跨槽会被拒绝 |
MULTI / EXEC 事务 | 事务内所有 key 必须同槽 |
| Lua 脚本 | 脚本内访问的所有 key 必须同槽 |
SUNIONSTORE / ZUNIONSTORE | 多键聚合 |
RENAME | 源与目标必须同槽 |
4.1 三个容易踩的坑
坑一:只取第一个 {}。 {a}:{b}:c 只用 a 计算槽位,第二个花括号被忽略。
{a}:{b}:c → 用 "a" 计算
{}:x → 空内容,等价于没有花括号,用整个 key 计算
坑二:哈希标签过大导致热点。 如果所有 key 都用 {global} 打标签,全部落到同一个槽,集群的分片能力完全失效——退化成单机。哈希标签应该按业务实体粒度使用,而不是全局统一。
坑三:哈希标签改变后数据要迁移。 给已有 key 加 {} 会改变它的槽位,迁移前后必须做数据搬迁。槽位迁移的流程与代价见 Cluster 分片与扩容
。
五、生命周期:TTL 是默认选项
没有 TTL 的 key 是内存泄漏。 除了少量真正的配置类数据(如字典表、全局开关),所有 key 都应该设过期时间。
5.1 强制 TTL 的写法
# 写入时直接带 TTL,而不是分开两步
SET user:1001:session "..." EX 1800
SETEX user:1001:session 1800 "..."
# 批量写入时逐个设
反模式是「先 SET,事后再 EXPIRE」——中间如果进程崩溃,就留下一个永不过期的 key。更糟的是「忘了设」,这类 key 会一直累积。
5.2 随机抖动:避免雪崩
如果一批 key 的 TTL 完全一致,它们会在同一秒集体过期,缓存集体失效,请求同时打到数据库——这就是缓存雪崩。它是缓存架构里最经典的一类故障,常见的组合防御(空值缓存、互斥重建、多级缓存)可参考 缓存策略与模式 。
import random
def set_with_jitter(r, key, value, base_ttl: int) -> None:
"""在基础 TTL 上叠加 ±10% 的随机抖动,打散过期时刻。"""
jitter = random.randint(-base_ttl // 10, base_ttl // 10)
r.set(key, value, ex=max(1, base_ttl + jitter))
| TTL 策略 | 效果 |
|---|---|
| 固定 3600s | 所有 key 整点集体过期,雪崩风险高 |
| 3600 ± 360s | 过期时刻分散在 1 小时内,风险大幅降低 |
| 热点数据永不过期 + 主动更新 | 彻底消除雪崩,但需要后台更新机制 |
过期键的删除时机(惰性删除 + 主动淘汰循环)决定了 TTL 的实际生效时刻有延迟。设计 TTL 时不能假设「到期即消失」,需要给业务留出容错窗口。
5.3 大 Key 的 TTL 陷阱
TTL 与 key 大小有一层隐藏关联:一个 500 MB 的 Hash 到期时,删除操作会阻塞主线程。Redis 4.0 之后引入了异步删除(UNLINK、lazyfree-lazy-expire),但默认配置下过期删除仍是同步的:
# 让过期与淘汰走异步释放,避免大 key 删除阻塞
CONFIG SET lazyfree-lazy-expire yes
CONFIG SET lazyfree-lazy-eviction yes
CONFIG SET lazyfree-lazy-server-del yes
大 Key 的识别与拆分方法见 大 Key 与热 Key 治理 ——它与 key 设计规范是互补的:规范防止产生大 Key,治理手段处理已经产生的。
5.4 TTL 的命名约定
把 TTL 写进规范文档,避免每个开发者各自拍脑袋:
| Key 类型 | 建议 TTL | 理由 |
|---|---|---|
| 会话 session | 1800s | 与登录态有效期对齐 |
| 业务缓存 cache | 300~3600s(带抖动) | 容忍短暂陈旧 |
| 验证码 | 300s | 业务明确要求 |
| 分布式锁 | 业务超时 × 3 | 防止锁提前释放 |
| 限流计数 | 窗口长度的 2 倍 | 与滑动窗口对齐 |
| 排行榜快照 | 3600s | 定时重算 |
六、版本化:让 key 可以平滑演进
当 value 结构发生变化(如从 Hash 改成 JSON、字段增减),旧数据与新代码不兼容。这时有两种处理方式:
方式一:加版本前缀,双写双读。
user:v1:1001 # 旧结构
user:v2:1001 # 新结构
上线流程:
1. 部署新代码:读 v2,miss 时回退读 v1 并转换后写 v2(双读)
2. 观察 v2 命中率上升、v1 逐渐不被访问
3. 灰度期结束后删除 v1 写入逻辑
4. 批量清理残留的 v1 key
方式二:改 key 名,让旧 key 自然过期。
# 旧:user:profile:1001
# 新:user:detail:1001
改名的好处是不需要双写逻辑,代价是新 key 上线初期全部 miss,会有一波回源压力。如果缓存能承受全量回源,这是更简单的方案。
| 方式 | 迁移成本 | 回源压力 | 适用 |
|---|---|---|---|
| 版本前缀双写 | 高(需双读双写代码) | 低 | 大流量、不能回源 |
| 直接改名 | 低 | 高(一次性) | 缓存可回源 |
| 版本前缀 + 后台预热 | 中 | 低 | 两者兼顾 |
数据迁移的工具与在线搬迁策略需要单独规划,核心是保证迁移窗口内新旧 key 都可用。
七、反模式清单
| 反模式 | 问题 | 正确做法 |
|---|---|---|
user:1001 与 User:1001 并存 | 大小写敏感导致读写不一致 | 强制全小写 |
| key 里塞 JSON | 长度爆炸、无法按前缀治理 | 结构化分层,JSON 放 value |
| 无 TTL | 内存持续增长直至 OOM | 强制设 TTL + 抖动 |
{global} 全局哈希标签 | 所有 key 落一个槽,分片失效 | 按实体粒度打标签 |
用 KEYS user:* 清理 | O(N) 阻塞主线程 | 用 SCAN 游标迭代 |
| 前缀随意新增 | 无法枚举、无法统计 | 维护前缀清单 |
| 用时间戳做 key 层级 | key 数量随时间无限增长 | 时间放 value 或独立索引 |
| key 长度 > 128 字节 | 内存浪费 | 控制在 64 字节内 |
| 用业务可变字段做 key | 字段一变就找不到旧 key | 用稳定 ID |
| 单 key 承载所有租户 | 无法按租户清理与配额 | tenant:<id>:... 分层 |
其中「用 KEYS 清理」是最常见的线上事故来源:KEYS 是 O(N) 全量扫描,在百万级 key 的实例上执行会阻塞主线程数秒。正确做法是用 SCAN:
# 分批扫描并删除,每批 100 个,不阻塞
redis-cli --scan --pattern 'user:*' --count 100 | \
xargs -n 100 redis-cli DEL
八、规范落地:从文档到检查
写了规范没人执行等于没写。落地手段有三层:
第一层:前缀清单即代码。 把允许的 key 前缀写进配置文件,代码里只能从前缀常量构造 key:
public final class RedisKeys {
private static final String USER_PROFILE = "user:%d:profile";
private static final String ORDER_DETAIL = "order:detail:%d";
public static String userProfile(long uid) {
return String.format(USER_PROFILE, uid);
}
// 禁止在业务代码里手写字符串拼接 key
}
Redis 的 key 结构设计也与整体缓存层设计相关,分层缓存、缓存粒度选择的讨论可参考 Redis 进阶实践 。
第二层:上线前扫描。 用 --scan 统计实际 key 的分布,找出不符合规范的前缀:
# 导出所有 key 的前两段,统计分布
redis-cli --scan --count 1000 | awk -F: '{print $1":"$2}' | sort | uniq -c | sort -rn | head -30
第三层:运行时审计。 结合代理层或 MONITOR(仅排障用,性能开销大)做抽样审计,发现异常 key 时告警。INFO keyspace 里的 keys 与 expires 比值是判断「是否大量 key 无 TTL」的直接指标,应纳入日常监控。
redis-cli INFO keyspace
# db0:keys=1250000,expires=1180000,avg_ttl=3600000
# expires/keys ≈ 94%,说明 TTL 覆盖率良好;若远低于 50% 就要排查
小结
Key 设计规范约束的是 Redis 里唯一可以由团队自己定义的结构。它影响三件事:内存(key 长度在百万级规模下是几十到几百 MB 的差异)、集群可用性(哈希标签决定多键操作能否成立)、可运维性(前缀规范决定能否批量清理与统计)。
落地时抓住四条硬线就够了:全小写 + 冒号分层、key 长度控制在 64 字节内、所有 key 必设 TTL 且带抖动、需要多键操作时用哈希标签并保证标签粒度不过粗。剩下的细节可以随业务演进逐步补充,但这四条一旦破了,后期修复的成本会高得多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。