---
title: "Redis 大 Key 如何优化"
date: 2026-11-06 08:18:53
category: [缓存]
tags: [Redis, 性能优化, 缓存]
description: Redis 出现大 Key 后,直接删除或一味拆分都可能带来新问题。本文从大小、热度、操作复杂度和持久化影响四个维度定位风险,给出 String、Hash、List、Set、ZSet 的分片方案,并说明异步删除、迁移和监控的落地边界。
author: 苏三
sidebar: false
sticky: 1
head:
- - meta
- name: keywords
content: Redis大Key优化,Redis大Key排查,Redis热Key,Redis大Key拆分,Redis异步删除
---
最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。
前言
Redis 慢请求一多,很多人的第一反应是查大 Key。
这个方向通常没错,但还不够。一个 20MB 的 Key 如果一天只读两次,它主要影响内存和备份;一个只有几 KB 的 Key 如果每秒被请求十万次,也可能把单个分片打满。还有一类 Key 本身不算特别大,却因为代码里习惯性执行 HGETALL、LRANGE 0 -1 或 SMEMBERS,把一次读取放大成了整对象传输。
所以,优化大 Key 的第一步不是拆,也不是删,而是判断问题到底来自“占用大”“访问热”“单次操作重”,还是三者叠加。
本文会从识别、分类、拆分、删除、迁移和监控几个阶段往下走。你可以把它当成一份排查手册,也可以直接跳到最后的决策表。
大 Key 到底该如何定义?
网上经常能看到“String 超过 10KB、集合超过 5000 个元素就是大 Key”的说法。这个阈值可以作为排查起点,但不能当成统一标准。
Redis 实例的 maxmemory、网络带宽、持久化方式、业务延迟目标都不一样。同样大小的 Key,在内存充裕、访问很少的配置缓存里可能完全不是问题;在内存紧张、每次读取又要传给几十个客户端的场景里,可能就是风险源。
更实用的判断有四个维度:
| 维度 | 要回答的问题 | 常见现象 |
|---|---|---|
| 内存占用 | 单个 Key 占实例内存多大比例 | 内存水位高、碎片多、扩容收益不明显 |
| 单次负载 | 一次读取或写入传输多少数据 | 网络出口突增、客户端反序列化慢 |
| 操作复杂度 | 命令是否需要遍历整个集合 | 慢日志出现 HGETALL、SMEMBERS 等 |
| 全局影响 | 是否会拖慢删除、过期、RDB、AOF 或主从同步 | 持久化耗时上升、复制延迟、阻塞告警 |
这里要先区分两个经常被混用的概念。
大 Key 描述的是单个 Key 的存储体积、元素数量或单次操作成本。热 Key 描述的是访问集中度。两者可能重合,也可能完全无关。
一个以大对象形式缓存的全量商品分类是典型大 Key。一个秒杀商品库存的普通 String 可能是热 Key。前者要拆数据,后者更常见的手段是做本地缓存、请求合并、分桶或读扩散。
如果把热 Key 当大 Key 拆,往往解决不了分片热点;如果把大 Key 当热 Key 加本地缓存,可能只是把更大的对象复制到更多机器上。
先判断:它是大、热,还是操作方式有问题?
排查时我一般先拿一张“证据表”,把现象和命令对应起来,不凭感觉给 Key 下结论。
看单个 Key 的真实内存
MEMORY USAGE 可以查看 Key 及其值的大致内存占用:
redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE order:detail:10001如果关心嵌套结构的采样精度,可以使用:
redis-cli -h 127.0.0.1 -p 6379 MEMORY USAGE order:detail:10001 SAMPLES 0SAMPLES 0 会采样全部元素,结果更完整,但大集合上的开销也更高。不要在业务高峰对大量 Key 无节制执行。
不同数据类型的元素数量也要一起看:
STRLEN order:snapshot:10001
HLEN cart:10001
LLEN feed:10001
SCARD article:tags:10001
ZCARD rank:2026-11-06
XLEN stream:orders这些命令多数只需要读取元数据,适合做第一层确认。但元素数量不等于内存,内存不等于单次传输成本。例如一个字段很多的 Hash,HLEN 不大,整个值仍然可能很大;一个长度不长的 String,如果被接口原样返回给大量客户端,也可能带来明显的网络放大。
用 --bigkeys 做全局巡检,但别把它当完整报告
redis-cli --bigkeys 会通过扫描统计各类型中较大的 Key:
redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 0.1-i 0.1 表示每执行 100 次 SCAN 就暂停 0.1 秒,降低持续扫描对线上实例的冲击。具体间隔仍要根据实例压力调整。
这个命令适合快速找线索,但它不是“列出所有超过阈值的 Key”。它更偏向给出每种类型中的大项,内部统计也可能使用采样估算。看到结果以后,还要用 MEMORY USAGE 或业务数据量做二次确认。
集群环境更要注意:SCAN 和 --bigkeys 都不是天然的全局扫描。你需要逐个主节点执行,再汇总结果。
识别热 Key
热点通常从监控和慢日志里露头,而不是从内存排行榜里露头。
可以先看命令统计和慢查询:
redis-cli INFO commandstats
redis-cli SLOWLOG GET 20如果实例启用了 LFU 淘汰策略,可以使用:
redis-cli --hotkeys但 --hotkeys 依赖 LFU 统计信息。如果实例仍在使用 LRU 或 noeviction,它不能给你有效结果。临时用 MONITOR 抓一小段流量也可以,不过 MONITOR 会带来额外开销,只适合短时间、低并发排查。
访问频率之外,还要看单条命令的返回体积。热 Key 不只是 QPS 高,一个每次返回几 MB 的普通接口,也可能比高频小对象更早打满网络。
检查代码有没有主动放大 Key
有些问题不在数据,而在读法。
比如缓存了一个用户购物车 Hash,查询购物车时却执行:
HGETALL cart:10001如果这个 Hash 有几万个字段,客户端和 Redis 之间就要传输整个对象。页面明明只展示前 20 条,代码却先把全部数据取到 JVM,再截取前 20 条。这时优先改接口和读取方式,往往比设计一套分片迁移便宜得多。
先问一句:业务真的需要一次拿到全部数据吗?
用 SCAN 建立可重复的检查脚本
单次巡检可以靠命令行,长期治理最好有一个低影响的扫描任务,定期输出“Key 名称、类型、元素数、内存、TTL”这些信息。
下面是一个基于 Jedis 的简化示例:
public void scanLargeKeys(JedisPool pool, int batchSize, long thresholdBytes) {
try (Jedis jedis = pool.getResource()) {
ScanParams params = new ScanParams().count(batchSize);
String cursor = ScanParams.SCAN_POINTER_START;
do {
ScanResult<String> page = jedis.scan(cursor, params);
for (String key : page.getResult()) {
Long bytes = jedis.memoryUsage(key, 0);
if (bytes != null && bytes >= thresholdBytes) {
System.out.printf("%s,%d,%d%n",
key, bytes, jedis.ttl(key));
}
}
cursor = page.getCursor();
} while (!ScanParams.SCAN_POINTER_START.equals(cursor));
}
}这个示例省略了限流、日志脱敏和异常处理,也不建议原样放到线上高频执行。实际任务至少要考虑三件事:
- 在只读副本或低峰期执行全量扫描,控制并发和批次。
- 对 Key 名称做脱敏,避免把用户 ID、手机号写入普通日志。
- 记录扫描前后的延迟、网络和慢命令变化,确认巡检任务本身没有制造问题。
SCAN 保证的是从开始到结束期间一直存在过的元素最终会被返回一次或多次,它不提供快照语义,也可能返回重复项。统计时应允许误差,不要把一次扫描结果当成强一致盘点。
关于大规模 Key 遍历,之前写过一篇更完整的1 亿个 Redis Key 如何安全统计,这里不再展开。
优化时先改读法,再动数据结构
对于 Hash、Set、ZSet,Redis 提供了增量迭代命令:
HSCAN cart:10001 0 COUNT 100
SSCAN article:tags:10001 0 COUNT 100
ZSCAN rank:2026-11-06 0 COUNT 100它们适合“逐页读取”,但并不意味着可以把分页当成实时查询。HSCAN 不保证稳定快照,翻页过程中数据可能变化,也不能用游标位置代替业务排序。
如果是列表型数据,可以限制读取范围:
LRANGE feed:10001 0 19如果是排行榜,先取 Top N:
ZREVRANGE rank:2026-11-06 0 49 WITHSCORES这两条命令的复杂度与返回元素数量相关。返回 50 条通常可以接受,LRANGE key 0 -1 则等于把整个列表搬出来。
这一类优化不需要迁移历史数据,收益也最直接。适用条件是业务确实只需要局部数据。如果页面必须展示完整对象,或者数据必须一次性参与计算,就该进入拆分环节。
String:大 JSON 如何拆成可控的块?
String 最常见的问题是缓存一整个 JSON。商品详情、订单快照、报表结果都容易走到这一步。
可以先做两件成本很低的事:
- 删除接口不需要的字段,不要为了方便把数据库实体原样序列化。
- 评估压缩。文本重复度高时压缩可能有效,已经压缩过的图片、PDF 或随机二进制数据通常收益有限。
如果瘦身之后仍然大,再考虑分块:
product:10001:manifest
product:10001:chunk:0
product:10001:chunk:1
product:10001:chunk:2最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。
manifest 记录版本、分块数、每块长度和校验值,chunk 保存序列化后的片段。读取时先检查版本,再用 MGET 批量取块,最后按顺序拼接。
public String readProduct(RedisTemplate<String, String> redis, long productId) {
String base = "product:" + productId;
String manifest = redis.opsForValue().get(base + ":manifest");
if (manifest == null) {
return null;
}
int chunkCount = parseChunkCount(manifest);
List<String> keys = IntStream.range(0, chunkCount)
.mapToObj(i -> base + ":chunk:" + i)
.toList();
List<String> chunks = redis.opsForValue().multiGet(keys);
if (chunks == null || chunks.stream().anyMatch(Objects::isNull)) {
throw new IllegalStateException("cache chunk missing: " + productId);
}
return String.join("", chunks);
}分块不是免费的。
一次读取会变成多个 Key 的读取,更新也可能不再是单个 SET。如果多个分块来自不同版本,前端会读到拼接错误的数据。解决办法通常是“新版本完整写入后再切换 manifest”,旧版本保留一段时间,读取端只认 manifest 指向的版本。
在 Redis Cluster 中,如果希望一次 MGET 命中同一个节点,可以使用相同的 hash tag,例如 product:{10001}:chunk:0。代价是同一个业务对象的所有块都落在同一个分片,拆分解决了单 Key 过大的问题,却没有解决这个对象自身的分片热点。
是否接受这个代价,要看业务。只追求平均负载,可以把 tag 放在不同层级;要求同一对象的批量操作原子且方便,通常会让它们落在同一 slot。
Hash、List、Set、ZSet:不同结构不能套同一个拆法
集合类型拆分时,最常见的错误是只看元素数量,却不看业务操作方式。
Hash:按字段稳定分桶
假设购物车 Hash 有大量字段,可以先按字段做分桶:
public String cartShardKey(long userId, String skuId, int shardCount) {
int shard = Math.floorMod(skuId.hashCode(), shardCount);
return "cart:{" + userId + "}:items:" + shard;
}写入时用 HSET,查询某一页时用 HSCAN 或先查索引再 HMGET。如果业务需要一次拿到购物车汇总,应用层要聚合多个桶,这会增加延迟和代码复杂度。
还有一个容易忽略的问题:字段必须有一个稳定、可重复的分桶算法。若 hash 函数或桶数量变化,旧数据可能找不到。扩容分桶时要保留版本号或迁移计划,不能只修改一行代码。
List:按时间或业务 ID 分段
消息流、操作记录这类 List 可按天或按固定长度拆:
feed:2026-11-05
feed:2026-11-06优点是单段容量可控,历史数据也容易过期。代价是跨段查询要合并,删除中间数据也不能再依赖一个完整 List。
如果按固定长度切段,还要处理并发追加和分段边界。简单的 LLEN 再 LPUSH 不是原子操作,高并发下很容易多写或少写。要么让分段 ID 在业务层确定,要么用 Lua 脚本做有长度上限的原子追加。后者也要控制脚本工作量,不能借“原子”把大循环塞进 Redis 主线程。
Set:拆完后,集合运算成本会上升
Set 可以按成员哈希分桶,但 SINTER、SUNION、SDIFF 原本能在 Redis 内部完成的集合运算,拆桶后往往要客户端逐桶计算再合并。
如果核心需求只是判断某个成员是否存在,分桶比较自然。如果频繁做全集交并差,先检查业务是否真的需要实时全量计算,也许离线结果或专用分析存储更合适。
ZSet:排行榜优先保留 Top N
一个包含千万成员的 ZSet 做按月全量排行,成本会很高。
如果业务只展示前 100 名,可以让主数据留在数据库或分析系统,Redis 只保存热点榜单和短周期排行榜。历史榜单按天或按月独立存储,不要把历史分数无限追加到当前 Key。
分片 ZSet 同样要付出聚合成本。查询 Top N 时,每个分片都要先取候选,再在客户端归并。分片越多,候选集过大或聚合越频繁,收益越差。
压缩和更换序列化方式值得做吗?
值得评估,但别把它当成万能答案。
缓存 JSON 时,重复字段名、空字段和格式化空格都可能占空间。把 inDate、inUserId 之类的字段名缩短,或者改用更紧凑的二进制协议,确实可能减少内存和网络传输。
代价也很明确:缓存中的结构会变得不直观,调试成本更高;协议升级还要处理新旧数据兼容。
压缩则要看数据特征。日志、长文本、重复度高的 JSON 可能有明显效果;图片、PDF、加密数据通常已经接近不可压缩,先压缩再写入还可能增加 CPU 和延迟。
我的判断顺序通常是:
- 先删掉不必要字段,减少业务返回量。
- 再评估序列化冗余。
- 最后才有针对性地压缩。
如果第一步就能把对象缩小到可接受范围,没必要为了省一点内存把整条读写链路变复杂。
大 Key 怎么删,才不会把 Redis 卡住?
删除大 Key 是另一个高风险点。DEL 会删除整个 Key,操作期间可能产生明显阻塞。Redis 4.0 提供了 UNLINK,把释放内存的工作交给后台线程:
UNLINK order:snapshot:10001注意,UNLINK 只是让删除命令本身尽快返回,不代表内存立刻归零。客户端看不到长阻塞,但后台回收、内存碎片和瞬时占用仍然需要观察。
对于超大集合,更稳妥的做法往往是分批删除:
HSCAN cart:10001 0 COUNT 500
HDEL cart:10001 field-1 field-2 field-3示例中的字段只是占位。实际脚本要读取一批真实字段,再按小批次删除,并在批次之间做限速。不要让一个循环无间隔地把整库扫完。
不同结构的清理方式也不同:
- Hash、Set、ZSet 适合扫描后批量删除成员。
- List 可以按时间分段,用
LTRIM缩小范围,再删除空 Key。 - String 较大时可以考虑
UNLINK,但不要假设内存指标会瞬间下降。 - 集群模式要确认 Key 实际落在哪个节点,删除任务按 slot 分发。
还需要检查主从复制和持久化配置。一次写入或删除产生的数据量越大,AOF 写入、网络传输和副本回放的压力也越大。大 Key 清理不能只看当前主节点的命令耗时。
迁移时怎样避免读到半成品?
把一个大 Key 改成多个小 Key,本质上是一次数据结构迁移。最怕的情况是发布当天一边写旧 Key,一边读新 Key,结果两个地方的数据版本不一致。
比较稳的流程是:
- 先上线双读代码,但继续保留旧结构写路径。
- 增加新结构写路径,记录转换失败和数据差异。
- 用离线任务迁移历史数据,并校验条数、版本和抽样内容。
- 切换读路径,观察错误率、延迟和缺失率。
- 确认稳定后再关闭旧 Key,最后用
UNLINK或分批删除回收空间。
如果缓存可以从数据库重建,迁移可以更简单。但即使能重建,也要考虑重建期间数据库和 Redis 的瞬时压力。大促、批量任务、缓存预热叠加在一起,经常比平时的故障更难处理。
如果是数据库缓存,不影响数据一致性判断;如果 Redis 本身就是主存储,那还要考虑幂等、顺序和回滚。关于缓存和数据库双写的边界,可以参考高并发下如何保证数据库和缓存双写一致性。
上线后靠什么指标确认真的好了?
不要只看“内存下降了多少”。大 Key 优化的目标可能包括降低延迟、减少网络、降低持久化压力或避免删除阻塞,不同目标对应不同指标。
建议至少观察下面几组:
| 目标 | 需要观察的信号 |
|---|---|
| 降低存储压力 | 单 Key 大小、前缀内存、used_memory、碎片率 |
| 降低请求延迟 | P95/P99、慢日志、命令耗时、客户端超时 |
| 降低网络压力 | 输入输出流量、单次响应大小、连接数 |
| 降低持久化影响 | RDB 耗时、AOF 重写、复制延迟 |
| 降低热点风险 | 单 Key QPS、分片 QPS、CPU、eviction |
监控之外,最好把防线前移到写入口:
if (payloadBytes > maxPayloadBytes) {
metrics.counter("cache.payload.rejected").increment();
throw new IllegalArgumentException("cache payload over limit");
}这段代码只是示意。实际系统可能选择拒绝写入、压缩、异步落库或降级到其他存储,但必须有明确的处理策略,不能等到 Redis 内存告警后才补。
同样可以给集合键增加元素数量上限、给缓存统一设置 TTL,并在 CI 或代码评审中检查 HGETALL、SMEMBERS、KEYS 等容易放大数据量的调用。Redis 的性能文章里经常讲单线程命令为什么快,Redis 为什么能抗住百万并发讲的是执行模型;到了业务层,真正决定风险大小的仍然是你让一条命令搬了多少数据。
大 Key 优化方案怎么选?
如果只想要一张决策表,可以先按下面的顺序判断:
| 当前问题 | 优先方案 | 不建议直接做的事 | 主要代价 |
|---|---|---|---|
| 只读少量字段,却一次取全部 | 改为分页、HMGET、ZRANGE | 先迁库或盲目分片 | 接口改造 |
| 单个 JSON 很大,但业务确实需要完整对象 | 瘦身、压缩、分块 | 继续无限放大一个 String | 多 Key 一致性 |
| 集合元素持续增长 | 按时间或稳定哈希分桶 | 只调大内存 | 聚合与迁移复杂度 |
| 单 Key 访问集中 | 本地缓存、请求合并、复制读、分片 | 把所有热点都归为大 Key | 一致性和缓存成本 |
| 删除造成阻塞 | UNLINK 或小批次删除 | 高峰期直接 DEL 超大集合 | 后台回收延迟 |
| 历史数据没有 TTL | 补过期策略、归档冷数据 | 用一次全量过期清理 | 数据生命周期改造 |
这里最值得反复确认的是第一行。很多时候,代码不需要完整数据,只是最初图省事写了一个“全取”。先改读法,通常比拆分更便宜,也更容易回滚。
如果问题需要拆数据,再从“查询方式”反推结构。别先设计四五个分片,最后才发现业务每次都要读全部分片。
总结
Redis 大 Key 优化的核心不是背一条“超过多少字节就拆”的规则,而是判断风险来自内存、访问热度、命令复杂度还是全局影响。
单个对象大,可以先瘦身、压缩和分块;集合持续增长,可以按时间、字段哈希或业务 ID 拆分;访问集中,则要考虑本地缓存、请求合并和分片。删除大 Key 时优先 UNLINK 或小批次处理,迁移时用双写、双读、校验和灰度切换控制风险。
最后做一次低成本盘点:找出 Top Key,确认它们的读法,补上写入大小限制和 TTL,再挑一个可控的业务做灰度。大 Key 治理很少靠一次命令完成,它更像把缓存的数据边界重新画清楚。
你们遇到的大 Key,最后是通过改查询、拆结构,还是缩短数据生命周期解决的?
最近缺项目经历想快速提升项目实战能力(包含多个AI项目),或者最近找工作,或者想学习AI的小伙伴,可以看看下面👇🏻的这个链接(或许真的能够帮到你)。
