Key 设计与命名规范:Redis 里唯一的结构约束

Redis Key 设计与命名规范实战:冒号分层命名约定与大小写规则、key 长度对内存的放大效应与实测数据、可解析性设计与禁用字符、哈希标签保证集群同槽、TTL 强制与随机抖动防雪崩、版本化前缀与双写迁移、常见反模式清单、规范落地与代码检查手段

关系数据库里,表结构是强约束:字段名、类型、索引都写在 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 位系统)说明
redisObject16 字节类型、编码、引用计数、LRU 字段
SDS 头(sdshdr)3~9 字节按字符串长度选择头部类型
dictEntry24 字节哈希表节点,含 key 指针、value 指针、next 指针
key 字符串本体长度 + 1(含 \0)实际内容
固定开销小计约 50~60 字节与 key 长度无关

也就是说,即使 key 名只有 1 个字符,也要占约 50 字节。对象编码与内存结构的细节属于底层实现的范畴,这里只需要记住固定开销的绝对值。

3.2 长 key 的放大效应

假设 100 万个 key:

key 平均长度key 本体占用固定开销合计相对短 key 的增幅
16 字节17 MB50 MB67 MB基准
32 字节33 MB50 MB83 MB+24%
64 字节65 MB50 MB115 MB+72%
128 字节129 MB50 MB179 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理由
会话 session1800s与登录态有效期对齐
业务缓存 cache300~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 且带抖动、需要多键操作时用哈希标签并保证标签粒度不过粗。剩下的细节可以随业务演进逐步补充,但这四条一旦破了,后期修复的成本会高得多。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「redis」更多文章

  1. 多租户隔离与资源配额:共享 Redis 的边界设计
  2. 代理与路由方案:客户端直连之外的另一种选择
  3. Kubernetes Operator 运维:Redis 集群的声明式管理