艾体宝干货 | 【Redis实用技巧#10】警惕!这个 Redis Key 设计模式正在榨干你的内存

7 阅读6分钟

两个月前,我们一位客户的 Redis 实例在业务高峰期内存突增至 100%,导致 API 接口频繁返回 500 错误,用户无法下单,公司因此每分钟都在遭受直接经济损失。

令人费解的是,客户原以为配置已尽善尽美:所有 Key 均设置了过期时间(TTL),启用了逐出策略(Eviction Policy),并且实施了 24 小时不间断的内存监控。一切看似万无一失,直到故障发生。

事后复盘揭示,我们陷入了一个常见的 Redis 反模式陷阱。而讽刺的是,这一问题早已在官方文档中明确指出。不少工程师在读文档时深以为然,却在生产环境中全然遗忘。今天将分享这段极具价值的经验,剖析事件的来龙去脉。

拖垮系统的 Key 模式

当时,客户的缓存 Key 是这样设计的:

# 错误示范 1:缓存用户会话def cache_user_session(user_id, timestamp):# 将时间戳直接拼接到 Key 中
    key = f"session:{user_id}:{timestamp}"
    redis.set(key, session_data, ex=3600)# 错误示范 2:缓存 API 响应def cache_api_response(endpoint, params, request_id):# 将请求 ID 拼接到 Key 中
    key = f"api:{endpoint}:{params}:{request_id}"
    redis.set(key, response_data, ex=300)

问题出在哪里?

客户在 Key 中直接包含了时间戳(Timestamp)和唯一请求 ID(Request ID) ,这导致每次请求都会生成全新的 Key。尽管设置了 TTL(ex=3600),但忽视了 Redis 底层处理过期数据的机制。

这种情况被称为 “Key 泄露”“Key 爆炸” ,是导致 Redis 内存异常膨胀的主要原因之一。

为什么 TTL 没能奏效

Redis 对过期 Key 的处理并非实时且精确,主要依赖两种机制:

  • 惰性删除(Passive Expiration): 仅在访问某个 Key 时,若发现其已过期,Redis 才会将其删除并返回空值。若该 Key 从未再次被访问,它将一直占据内存。
  • 定期删除(Active Expiration): Redis 每秒执行 10 次随机抽样,从已设置 TTL 的 Key 中随机选取 20 个进行检查;若发现超过 25% 已过期,则重复该过程。

问题在于: 当新 Key 的生成速度远超 Redis 清理旧 Key 的速度时,内存中将堆积大量“逻辑上已过期但物理上未删除”的数据垃圾。

在本案例中,高峰期每分钟约生成 50,000 个新 Key。即便设置了 5 分钟的过期时间,任意时刻 Redis 中可能堆积多达 25 万个 Key,其中绝大多数早已应被清除。

被忽略的元数据开销

即便是一个简单的字符串 Key,在 Redis 中也存在额外开销。一个键值对的内存消耗包括:

  • Key 本身: 字符串长度加上结构体开销(例如一个 32 字符的 Key 约占用 90 字节)。
  • Value 及其包装: 数据本身大小加上 Redis Object 对象头。
  • 元数据: 包括过期时间、编码方式、引用计数等信息。

这意味着,即使 Value 只有 100 字节,在 Redis 中的实际占用可能接近 200 字节。

举例计算: 25 万个 Key 的元数据就可消耗近 50MB 内存。虽然看似不多,但当 Key 数量达到千万级,元数据就可能占用数 GB。客户曾为 Redis 分配 16GB 内存,原以为存 8GB 数据绰绰有余,结果完全忽略了底层开销。

Big Key 问题

在排查过程中,我们还发现了 Big Key 问题。在 Redis 中,超过 1MB 的字符串或元素数量过万的集合都会被视为 Big Key。

此前为了省事,我们将整个 API 响应体,甚至复杂的用户画像对象,直接全部存入:

# 错误示范def cache_full_user_profile(user_id):# 获取用户的所有数据并打包成一个巨大的 JSON
    user_data = {'profile': get_profile(user_id),'preferences': get_prefs(user_id),  
        'order_history': get_history(user_id), # 这个列表可能无限增长'recommendations': get_recs(user_id)}# 一个 Key 存了 5MB 数据
    redis.set(f"user:{user_id}", json.dumps(user_data), ex=3600)

一个 5MB 的 Key 会导致 Redis 在进行内存回收(Eviction)或主从同步时产生阻塞,严重拖慢性能。

逐出策略的坑

屋漏偏逢连夜雨,当时客户将逐出策略设为 volatile-lru。该策略的逻辑是:在已设置 TTL 的 Key 中,淘汰最近最少使用的(LRU) 。看似合理,实则不然。

由于每个请求都会生成新 Key,这些 Key 一经创建便被写入 Redis。对 Redis 而言,它们全是“新”的,没有一个是“旧”的。在这种“全是新 Key”的场景下,LRU 完全失效,Redis 无法有效判断淘汰对象,最终只能拒绝写入,导致 API 报错。


该怎么做

理解了病根,药方也就清晰了:

移除键名中的动态数据

不再把时间戳或请求 ID 塞进 Key。如果数据需要更新,直接覆盖原来的 Key。

Python

# 优化后:固定 Key 格式
key = f"session:{user_id}" 

# 对于需要区分参数的 API 缓存,使用哈希(Hash)处理
import hashlib
# 对参数进行排序并取哈希值,确保 key 的唯一性和长度固定
params_str = json.dumps(query_params, sort_keys=True).encode()
params_hash = hashlib.md5(params_str).hexdigest()
key = f"api_cache:{endpoint}:{params_hash}"

化整为零,拆分大 Key

利用 Redis 的 Hash(哈希表) 结构来存储相关联的字段,比存一个巨大的 JSON 字符串要省得多。

Python

# 使用 Hash 结构存储,内存更高效
redis.hset(f"user_data:{user_id}", mapping={
    'profile': json.dumps(profile_info),
    'settings': json.dumps(user_settings),
    'order_ids': json.dumps(recent_orders)
})

修正逐出策略

将策略改为 allkeys-lru,并调整了内存限制。

Bash

# redis.conf 核心配置
maxmemory 14gb  # 建议设置为物理内存的 80%-85%
maxmemory-policy allkeys-lru # 对所有 Key 启用 LRU 剔除
maxmemory-samples 5 # 采样数,5 是性能与准确度的平衡点

插曲:整数溢出 Bug

令人意外的是,我们帮客户处理问题时,还发现了一个因代码逻辑导致的 TTL 永不过期问题。

在计算过期时间时,采用了“当前时间戳 + 过期秒数”的方式,但在某个旧模块中,该计算使用了 32 位整数。当时间戳过大溢出为负数时,Redis 的 EXPIRE 命令会失效,使这些 Key 变成永不过期的“僵尸 Key”。

教训: TTL 应始终传相对秒数(如 3600),切勿传绝对时间戳


总结与优化效果

实施上述改动后,系统性能得到显著提升:

  • 内存占用: 从 98% 且频繁 OOM 降至稳定的 45%
  • Key 数量: 从 1200 万骤减至 28 万
  • P99 延迟: 从 850ms 降低到 120ms
  • 成本: 原计划升级至 64GB 实例,如今 16GB 即可高效运行

💡 Redis 健康检查建议

不要等到报错才排查,立即运行以下命令对 Redis 展开自检:

  1. INFO memory:查看内存碎片率(Fragmentation Ratio),超过 1.5 表示浪费严重
  2. redis-cli --bigkeys:快速定位影响性能的大键
  3. INFO keyspace:查看带 TTL 的 Key 占比,比例过低需警惕 Key 泄露

你会为 Redis 的 Key 添加时间戳或 UUID 吗?欢迎在评论区分享你的 Redis 排坑经验。