Redis 大 Key 与热点 Key 生产治理:发现、拆分、限流与本地缓存的组合策略
1. 从一次线上抖动说起:为什么需要这套治理方案
先看一个实际场景。某电商系统在促销开始后两分钟,订单服务开始大面积超时。监控面板上 Redis 集群的 CPU 使用率只有 30%,网络带宽也没有打满,但 P99 延迟从 2ms 涨到 800ms。运维同学一边扩容分片,一边怀疑是网络抖动。
把慢查询日志打开后,真相浮出水面:有一条 HGETALL 命令耗时 320ms,还有一个 GET 命令耗时 180ms。前者取的是一个有 80 万个字段的 Hash------这就是大 Key;后者取的是一个商品详情字符串,大小约 6MB,而全站每秒有 3 万次请求都在读同一个键------这就是热点 Key。集群加机器没用,因为这两个 Key 各自只落在一个分片上,扩容动摇不了它们的集中度。
大 Key 和热点 Key 之所以难治,是因为它们不是「容量问题」,而是「集中度问题」。容量问题可以加内存、加机器;集中度问题只能靠拆散访问路径来解决。本文的目标是给你一套能落地的组合拳:先能发现,再能拆分,再能挡住流量,最后能用本地缓存削掉峰值。
2. 一句话模型与全局框架
先记住一个最小模型:大 Key 是「单点数据太大」,热点 Key 是「单点流量太高」,两者都会把集群的并行能力退化成单机能力。
把整体拆成三部分来理解它们的关系:
- 数据侧:Key 本身。大 Key 是它的体积过界,热点 Key 是它的访问频次过界。
- 访问侧:请求路径。请求从应用进程发出,经过客户端路由,落到某个分片,再由 Redis 单线程(指命令执行主线程)串行处理。
- 治理侧:发现、拆分、限流、本地缓存。发现是前提,拆分改变数据分布,限流改变流量分布,本地缓存把流量拦在 Redis 之前。
先看它们怎样串起来:
text
业务请求
|
v
[应用进程]
| 1. 查本地缓存(Caffeine / 进程内 Map)
| 命中 -> 直接返回,不碰 Redis
| 未命中
v
[Redis 客户端]
| 2. 热点探测:同一 Key 短时间内命中计数超阈值
| 触发本地缓存回填 + 可选限流
v
[Cluster 路由: CRC16(key) % 16384]
| 3. 命中槽位 -> 落到具体分片
v
[目标分片主节点]
| 4. 单线程执行命令
| 若 Key 是大 Key,单次耗时飙升,阻塞后续命令
| 若是热点 Key,QPS 压满该分片
v
[返回结果]
这张图里有三个关键判断点:本地缓存决定「要不要碰 Redis」,热点探测决定「要不要拦流量」,分片路由决定「压力落到哪里」。后文所有机制都围绕这三点展开。
3. 大 Key 与热点 Key 的判定标准和危害路径
3.1 判定不是拍脑袋,要有量化阈值
日常沟通里说「这个 Key 有点大」没有意义,治理需要可执行阈值。业界常用的经验值是:
| 类型 | 参考阈值 | 说明 |
|---|---|---|
| String 大 Key | value > 10KB 关注,> 100KB 治理 | 网络传输和反序列化成本线性增长 |
| 集合类大 Key | 元素数 > 5000 关注,> 10000 治理 | Hash/List/Set/ZSet 均适用 |
| 热点 Key | 单分片 QPS 占比 > 10%,或单 Key QPS > 分片容量 20% | 具体数值取决于分片规格 |
| 慢查询 | 单命令 > 10ms 关注,> 50ms 治理 | 与 slowlog-log-slower-than 对齐 |
阈值不是教条。一台 8 核 16GB 的分片和一台 2 核 4GB 的分片,承受能力差别很大。建议先用压测得到自己集群的「单命令耗时随 value 大小变化曲线」,再反推阈值。
3.2 危害路径:为什么大 Key 比你想的更致命
Redis 执行命令的主线程是单线程的。一条 320ms 的 HGETALL 执行期间,这个分片上所有其他命令都在排队。这带来三个连锁反应:
第一,延迟毛刺。正常 P99 是 2ms,出现一条大 Key 命令就变成 300ms+,上游线程池被占满。
第二,主从复制延迟。大 Key 的写命令会以同样大小同步给从节点和 AOF 文件,一条 DEL 一个百万字段的 Hash,在从节点上重放可能耗时数百毫秒,主从延迟瞬间拉高。
第三,删除阻塞。Redis 4.0 之后引入了 UNLINK 和 lazyfree,把删除的内存回收放到后台线程,但 DEL 大 Key 仍然会阻塞。这是很多人踩过的坑:以为删掉就没事了,结果删除瞬间把实例打挂。
热点 Key 的危害路径不同:它不拖慢单条命令,而是把某个分片的 QPS 打满。此时集群其他分片可能很空闲,但客户端看到的整体错误率已经上去了。这就是为什么「扩容分片数」对热点 Key 无效。
4. 发现:怎么在出事故之前把这两个问题找出来
4.1 扫描大 Key:SCAN 系列命令与 MEMORY USAGE
不要在生产环境用 KEYS *,它会阻塞主线程。正确做法是用 SCAN 增量遍历,再配合 MEMORY USAGE 或类型专属命令估算大小。下面这段是排查脚本的核心逻辑(Bash + redis-cli):
bash
# 目标:扫描指定 DB 中所有 Key,输出体积排名前 20 的 Key
# 前置:已安装 redis-cli,能连上目标实例;生产环境务必在从节点执行
redis-cli -h 10.0.0.11 -p 6379 -n 0 --scan --count 1000 \
| while read key; do
# 跳过明显的小 Key 类型判断,先看类型
type=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 type "$key")
case "$type" in
string) size=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 strlen "$key") ;;
hash) size=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 hlen "$key") ;;
list) size=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 llen "$key") ;;
set) size=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 scard "$key") ;;
zset) size=$(redis-cli -h 10.0.0.11 -p 6379 -n 0 zcard "$key") ;;
*) size=0 ;;
esac
# string 用字节数,集合类用元素个数,阈值分开设置
if [ "$type" = "string" ] && [ "$size" -gt 10240 ]; then
echo "$size\t$type\t$key"
elif [ "$type" != "string" ] && [ "$size" -gt 5000 ]; then
echo "$size\t$type\t$key"
fi
done | sort -rn | head -20
关键点说明:--scan --count 1000 让每次游标迭代返回约 1000 个 Key,降低单次阻塞。MEMORY USAGE key 可以给出更精确的字节数,但它是 O(N) 操作,对大集合本身也是负担,建议只对可疑 Key 使用。这段脚本的输出是「元素数」而非精确字节数,用于排序足够,不要当成精确报告。
4.2 发现热点 Key:三种手段的取舍
热点 Key 比大 Key 更难发现,因为 Redis 本身不提供「按 Key 统计 QPS」的官方命令。常见三种手段:
| 手段 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
redis-cli --hotkeys |
基于 LFU 淘汰策略采样 | 零侵入,一条命令 | 需 maxmemory-policy 为 allkeys-lfu,采样有误差 | 已开启 LFU 的实例 |
| MONITOR | 实时打印所有命令 | 数据最全 | 生产禁用,性能下降 30% 以上 | 测试环境定位 |
| 客户端埋点 | 在 Jedis/Lettuce 拦截器里计数 | 精准、可自定义 | 有少量性能开销 | 生产常规手段 |
生产上推荐客户端埋点,因为只有客户端才知道「哪个 Key 被哪个业务在读」。下面是一个基于 Spring AOP 的简化统计器(简化代码,不能直接运行,仅展示计数逻辑):
java
// 简化代码:展示热点计数思路,非完整可运行示例
public class HotKeyCollector {
// 时间窗口 10 秒,每 10 秒滚动一次
private final LongAdder[] buckets = new LongAdder[10];
public void record(String key) {
int idx = (int) ((System.currentTimeMillis() / 1000) % 10);
buckets[idx].increment();
}
public boolean isHot(String key, long threshold) {
long sum = 0;
for (LongAdder b : buckets) sum += b.sum();
return sum > threshold;
}
}
这里用环形数组做滑动窗口,避免每次统计都聚合全量 Key。真实生产还需要 Key 维度的粒度、分位统计和上报,但核心思想就是「时间窗口 + 计数器 + 阈值」。
5. 拆分:把集中度打散的根本手段
5.1 大 Key 拆分:从 Hash 分片开始
拆分的原则是「把一个大容器变成多个小容器,用确定性的路由函数找到它们」。最常用的是哈希分片:把原 Hash 的 field 按 hash(field) % N 分到 N 个子 Hash。
先看一个贴近业务的例子:商品详情缓存原本用 product:1001 这个 Hash 存了 SKU、库存、图片、参数等 8 万个字段。我们把字段数降到 200 以内,再按字段名哈希分到 16 个子 Key。下面是一个完整可运行的 Java 示例。
目标 :用 Jedis 对一个大 Hash 执行分片读写。
前置环境 :JDK 8+,Maven 依赖 redis.clients:jedis:4.4.3,本地 Redis 6.x 运行在 6379。
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Response;
import redis.clients.jedis.Pipeline;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class HashShardingDemo {
private static final int SHARD_COUNT = 16;
private static final String KEY_PREFIX = "product:1001:shard:";
/** 根据 field 的 hash 找到分片序号,保证同一 field 永远落在同一分片 */
private static int shardOf(String field) {
// 用 & 0x7fffffff 去掉符号位,避免负数取模
return (field.hashCode() & 0x7fffffff) % SHARD_COUNT;
}
private static String shardKey(String field) {
return KEY_PREFIX + shardOf(field);
}
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
try {
jedis.flushDB();
// 1. 写入 1000 个字段,分散到 16 个分片
Map<String, Map<String, String>> byShard = new HashMap<>();
for (int i = 0; i < 1000; i++) {
String field = "attr_" + i;
String value = "v" + i;
byShard.computeIfAbsent(shardKey(field), k -> new HashMap<>())
.put(field, value);
}
Pipeline pipe = jedis.pipelined();
for (Map.Entry<String, Map<String, String>> e : byShard.entrySet()) {
pipe.hset(e.getKey(), e.getValue());
}
pipe.sync();
// 2. 精确读取单个字段:只需要 O(1) 定位到分片
String targetField = "attr_512";
String value = jedis.hget(shardKey(targetField), targetField);
System.out.println("单字段读取 [" + targetField + "] = " + value);
// 3. 统计每个分片的字段数,验证是否均匀
int total = 0;
int max = 0;
for (int i = 0; i < SHARD_COUNT; i++) {
long len = jedis.hlen(KEY_PREFIX + i);
total += len;
max = (int) Math.max(max, len);
}
System.out.println("总分片字段数 = " + total + ",单分片最大 = " + max);
} catch (Exception ex) {
// 生产环境应记录日志并触发告警,而不是只打印堆栈
System.err.println("Redis 操作异常: " + ex.getMessage());
} finally {
// 必须释放连接,否则连接池会被耗尽
jedis.close();
}
}
}
预期输出类似:
text
单字段读取 [attr_512] = v512
总分片字段数 = 1000,单分片最大 = 73
关键步骤解读:shardOf 是整套方案的地基,它必须确定性------同一个 field 每次都算出同一个分片,否则读不到数据。用 & 0x7fffffff 是为了处理 hashCode() 为负数时 % 得到负值的问题,这是 Java 里非常常见的坑。用 Pipeline 批量写入是因为 16 次 HSET 走 16 次 RTT 太慢,Pipeline 把它们合并成一次网络往返。
容易改错的地方有三个:一是分片数写死成 16,未来扩容要重新分布,建议一开始就按「预计最大字段数 / 单分片上限」预留;二是没有处理跨分片聚合查询,如果你需要 HGETALL 全部字段,仍然要遍历 16 个分片,这是拆分的固有代价;三是没有 hot shard 保护,如果某个 field 访问特别频繁,仍然会集中到单个分片。
5.2 什么时候不该拆
拆分不是万能药。以下情况保持原样更合适:
- 这个 Key 是冷数据,一天访问不到 10 次,拆了只是增加复杂度。
- 业务上必须原子性地一次性读全部字段,拆分会破坏原子性,除非改用 Lua 脚本做跨分片聚合,但那样又会引入新的大 Key 问题。
- 数据量虽大但有天然的时间维度,可以考虑改成「按天分 Key」而不是哈希分片,因为时间维度天然带上过期语义,清理更简单。
6. 本地缓存:把流量拦在 Redis 之前
6.1 一句话模型与三级结构
先把本地缓存理解成「进程内的一小块副本」。请求先问本地缓存,命中就直接返回,不产生任何网络调用。它就是用来削掉热点 Key 的重复读流量。
热点 Key 的典型形态是「少量 Key + 极高 QPS」。假设商品 A 的详情每秒被读 3 万次,Redis 单分片只能扛 1 万次,那剩下的 2 万次就会超时。如果把这 3 万次里的 99% 拦在应用进程内,Redis 只需要扛 300 次,压力瞬间消失。
三级缓存的结构是:本地缓存 → Redis → 数据库。每一级的容量和一致性都不同:
| 层级 | 容量 | 访问延迟 | 一致性 | 失效方式 |
|---|---|---|---|---|
| 本地缓存 | 受 JVM 堆限制,通常单 Key 级 | 纳秒级 | 最弱,多实例间不共享 | TTL + 主动失效 |
| Redis | 受集群内存限制 | 亚毫秒级 | 较强,单分片内原子 | TTL + 内存淘汰 |
| 数据库 | 受磁盘限制 | 毫秒到几十毫秒 | 最强 | 事务 + 手工更新 |
6.2 用 Caffeine 实现带过期和容量上限的本地缓存
不要自己用 HashMap 做本地缓存,因为它没有容量上限也没有过期清理,很容易 OOM。Caffeine 是当前 Java 生态里最成熟的选择,它的核心能力是 W-TinyLFU 淘汰算法,能在有限容量下达到较高命中率。下面是一个完整可运行的示例。
目标 :实现「本地缓存 + Redis 回源」的两级读取,展示热点流量被本地缓存拦截。
前置环境 :JDK 8+,Maven 依赖 com.github.ben-manes.caffeine:caffeine:3.1.8 与 redis.clients:jedis:4.4.3。
java
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import redis.clients.jedis.Jedis;
import java.time.Duration;
import java.util.concurrent.atomic.AtomicLong;
public class TwoLevelCacheDemo {
private final Jedis jedis;
private final Cache<String, String> localCache;
// 统计 Redis 被真实访问的次数,用来证明本地缓存确实拦截了流量
private final AtomicLong redisHits = new AtomicLong();
public TwoLevelCacheDemo() {
this.jedis = new Jedis("127.0.0.1", 6379);
this.localCache = Caffeine.newBuilder()
.maximumSize(10_000) // 最多缓存 1 万个 Key,防止 OOM
.expireAfterWrite(Duration.ofSeconds(30)) // 写入 30 秒后过期
.recordStats() // 开启命中率统计
.build();
}
/** 两级读取:先本地,再 Redis,最后回填本地 */
public String get(String key) {
// 第一级:本地缓存,getIfPresent 不触发加载函数,语义更明确
String local = localCache.getIfPresent(key);
if (local != null) {
return local;
}
// 第二级:Redis
String remote = jedis.get(key);
redisHits.incrementAndGet();
if (remote != null) {
localCache.put(key, remote);
}
return remote;
}
/** 写路径:更新 Redis 后主动失效本地缓存,降低不一致窗口 */
public void put(String key, String value) {
jedis.set(key, value);
localCache.invalidate(key);
}
public static void main(String[] args) {
TwoLevelCacheDemo demo = new TwoLevelCacheDemo();
try {
String hotKey = "item:detail:1001";
demo.put(hotKey, "Apple iPhone 15 Pro 详情");
// 模拟 10000 次请求都读同一个热点 Key
for (int i = 0; i < 10_000; i++) {
String v = demo.get(hotKey);
if (v == null) {
System.out.println("第 " + i + " 次读取为空");
}
}
System.out.println("总请求数 = 10000");
System.out.println("真实访问 Redis 次数 = " + demo.redisHits.get());
System.out.println("本地缓存命中率 = " + demo.localCache.stats().hitRate());
} finally {
demo.jedis.close();
}
}
}
预期输出类似:
text
总请求数 = 10000
真实访问 Redis 次数 = 1
本地缓存命中率 = 0.9999
这个结果说明:1 万次请求只有 1 次真的打到 Redis,其余 9999 次都在本进程内解决了。热点 Key 的压力被削掉了 99.99%。
关键步骤解读:maximumSize 必须有,否则本地缓存会被大 Key 撑爆堆内存;expireAfterWrite(30s) 是本地缓存的兜底一致性手段,即使主动失效失败,30 秒后也会自动过期。invalidate 在写路径上被调用,保证「写后立刻读」不会拿到旧值。
容易改错的地方:一是多个应用实例各自持有本地缓存,实例 A 更新后实例 B 仍可能读到旧值,最多差一个 30 秒窗口,这是本地缓存的固有代价;二是本地缓存不适合放「变化极其频繁」的数据,比如秒杀库存,那种场景应该直接用 Redis 加 Lua 保证原子性;三是 maximumSize 设太大(比如 100 万)会导致 GC 压力剧增,建议按「热点 Key 数量 × 单 Key 大小」反推。
6.3 本地缓存的失效:广播还是短 TTL
多实例场景下,主动失效需要广播。常见两种做法:一是用 Redis 的 Pub/Sub 发布失效消息,各实例订阅后清理本地缓存;二是用短 TTL 加随机抖动,接受短暂不一致。
Pub/Sub 的优点是失效及时,缺点是 Redis 重启时会丢消息,需要 TTL 兜底。短 TTL 的优点是实现简单,缺点是不一致窗口固定存在。工程上常见的组合是「短 TTL + Pub/Sub 广播」,两者互为补丁。
7. 限流:当拆分和缓存都不够时
7.1 限流应该放在哪一层
限流的核心是「在入口处拒绝一部分请求,保证系统不雪崩」。针对热点 Key,限流有四个可放置的位置:
| 位置 | 作用范围 | 优点 | 缺点 |
|---|---|---|---|
| 网关层 | 全站 | 集中配置,粒度粗 | 无法针对单个 Key |
| 应用层(本地令牌桶) | 单实例 | 无网络开销 | 实例间不共享配额 |
| Redis 分布式令牌桶 | 全集群 | 配额全局精确 | 每次限流判断要访问 Redis |
| 热点 Key 级熔断 | 单 Key | 保护最脆弱的点 | 需要精准的热点发现 |
生产上推荐组合:网关做总量兜底,应用层做本地令牌桶处理突发,热点 Key 级做精细保护。
7.2 完整示例:基于 Redis + Lua 的单 Key 限流
下面这个示例把「热点 Key 保护」做成一个可复用的限流器。它用 Redis 的原子操作保证多实例共享配额,非常适合「单 Key QPS 超过阈值就拒绝」的场景。
目标 :对热点 Key 做每秒 N 次的分布式限流。
前置环境 :同前,Maven 依赖 redis.clients:jedis:4.4.3。
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;
public class HotKeyRateLimiter {
private final Jedis jedis;
public HotKeyRateLimiter(Jedis jedis) {
this.jedis = jedis;
}
/**
* 固定窗口计数限流:每秒最多 limit 次
* 返回 true 表示放行,false 表示被限流
*/
public boolean tryAcquire(String key, int limit) {
long second = System.currentTimeMillis() / 1000;
String counterKey = "rl:" + key + ":" + second;
// INCR 是原子操作
long current = jedis.incr(counterKey);
if (current == 1) {
// 第一次计数时设置 2 秒过期,留 1 秒冗余避免边界问题
jedis.expire(counterKey, 2);
}
return current <= limit;
}
public static void main(String[] args) {
Jedis jedis = new Jedis("127.0.0.1", 6379);
try {
HotKeyRateLimiter limiter = new HotKeyRateLimiter(jedis);
String hotKey = "item:detail:1001";
int limit = 100;
int pass = 0;
int reject = 0;
for (int i = 0; i < 500; i++) {
if (limiter.tryAcquire(hotKey, limit)) {
pass++;
} else {
reject++;
}
}
System.out.println("放行 = " + pass + ",拒绝 = " + reject);
} catch (Exception ex) {
System.err.println("限流器异常: " + ex.getMessage());
} finally {
jedis.close();
}
}
}
预期输出(同一秒内执行):
text
放行 = 100,拒绝 = 400
关键步骤解读:incr 是 Redis 的原子自增,多实例并发调用不会算错。expire 只在第一次计数时设置,避免每次请求都刷新过期时间导致 Key 永不释放。设置 2 秒过期而不是 1 秒,是因为跨秒边界时 System.currentTimeMillis() / 1000 可能刚好切换,留冗余更稳。
容易改错的地方:固定窗口限流存在「临界问题」------第 1 秒最后 100ms 和第 2 秒前 100ms 可能合计放行 2 倍配额。如果需要更平滑,改用滑动窗口或令牌桶。另一个坑是每次限流判断都要访问 Redis,如果被限流的 Key 本身就在打 Redis,会形成二次压力,所以本地令牌桶作为第一道更合适。
7.3 限流的边界:拒绝之后做什么
限流不是把请求丢掉就完事。拒绝之后有三条路:
- 快速失败:直接返回错误码,让上游降级。适合非核心业务。
- 返回兜底数据:比如返回上一次的缓存结果或静态占位页。适合商品详情这类读多写少场景。
- 排队等待:用信号量让请求短暂等待,适合有明确处理能力的后台任务。
选择哪条取决于业务能否接受「不精确」。商品价格不能接受不精确,但商品评价数可以接受几秒前的值。
8. 三个机制如何协同:一次大促请求的完整流转
现在把前面所有机制串起来,看一次大促请求如何从进入到返回。
text
[用户请求商品详情]
|
v
[网关限流] --超限--> [返回 429 或兜底页]
| 放行
v
[本地令牌桶] --超限--> [降级返回缓存旧值]
| 放行
v
[Caffeine 本地缓存] --命中--> [直接返回]
| 未命中
v
[热点探测: 该 Key 是否超阈值]
| 是 -> 标记为热点 Key,加大本地缓存 TTL 并触发预热
v
[Redis Cluster 路由]
|
v
[目标分片] -- 若是大 Key 拆分后的子 Key --> [返回小结果]
|
v
[回填本地缓存 + 记录监控指标]
这张图的关键在于:限流在缓存之前,缓存对外部流量做减法,拆分在数据侧做除法。三者顺序不能颠倒------如果先查缓存再限流,被限流的请求已经消耗了本地资源;如果拆分放在缓存之后,缓存里存的仍然是大对象。
9. 常见误区
误区一:用 KEYS * 找大 Key。 KEYS 是 O(N) 且会阻塞主线程,在百万 Key 的实例上可能阻塞数秒。用 SCAN 增量遍历替代。
误区二:用 DEL 删大 Key。 百万字段的 Hash 用 DEL 删除会同步释放内存,阻塞主线程。改用 UNLINK,它把内存回收交给后台线程。
误区三:以为扩容分片能解决热点 Key。 Cluster 的分片是按 Key 哈希的,同一个 Key 永远落在同一个槽位。除非改 Key 名(比如加随机后缀),否则加机器无效。
误区四:本地缓存不设容量上限。 只设 TTL 不设 maximumSize,在流量突增时会被大量不同 Key 撑满堆内存,触发 Full GC。
误区五:把限流阈值配得和 Redis 容量一样高。 限流的目标是保护系统,阈值应该设在「系统能稳定处理的量」的 70%-80%,留出突发余量。
误区六:拆分后忘记更新写路径。 读路径拆了分片,写路径还在写原 Key,会读到脏数据。读写必须用同一个 shardOf 函数。
10. 生产实践建议
把上面所有结论收成一份可执行清单:
- 发现:从节点上每天跑一次大 Key 扫描;客户端埋点统计热点 Key,每 10 秒聚合一次上报监控。
- 拆分:集合类 Key 元素数超过 5000 时启动拆分,分片数按「预计最大值 / 单分片上限」预留,读写共用一个确定性哈希。
- 本地缓存 :必须同时设置
maximumSize和expireAfterWrite;只缓存读多写少的热点数据;写路径主动invalidate,并用短 TTL 兜底。 - 限流:网关做全站总量,应用层做本地令牌桶,热点 Key 用 Redis 分布式计数做精细保护;阈值设为稳定容量的 70%-80%。
- 删除 :统一用
UNLINK代替DEL;maxmemory-policy优先用allkeys-lfu便于热点识别。 - 监控:重点看「单命令 P99」「主从复制延迟」「慢查询数量」「单分片 QPS 占比」四个指标。
判断用哪套组合,可以按下面的逻辑走:
| 现象 | 首选手段 | 辅助手段 |
|---|---|---|
| 单命令耗时长、有慢查询 | 大 Key 拆分 | UNLINK 删除、Pipeline 批量 |
| 单分片 QPS 打满、其他分片空闲 | 本地缓存 + 热点限流 | 热点 Key 探测、二级缓存 |
| 主从复制延迟高 | 检查大 Key 写入 | 拆分写路径、减小 value |
| 内存使用率持续上涨 | 大 Key 扫描 + LFU | 设置合理的 maxmemory |
| 缓存雪崩 | 过期时间加随机抖动 | 多级缓存、限流兜底 |
11. 排障清单
线上出现 Redis 相关抖动时,按这个顺序排查:
- 看慢查询:
SLOWLOG GET 10,确认是否有超过 10ms 的命令。 - 看单命令延迟:
redis-cli --latency -h <host> -p <port>,确认是 Redis 本身慢还是网络慢。 - 看大 Key:对可疑 Key 执行
MEMORY USAGE <key>,超过 10KB 的 String 或超过 5000 元素的集合重点看。 - 看热点:对比各分片的
instantaneous_ops_per_sec,如果某分片明显高于平均值,说明有热点。 - 看主从延迟:
INFO replication的master_repl_offset差值,超过 1MB 要警惕。 - 看内存:
INFO memory的used_memory与maxmemory比值,接近 1 时可能触发淘汰。 - 看连接数:
INFO clients的connected_clients,突增说明可能有连接泄漏。
12. 面试/复盘问题
这些问题可以用来检验自己是否真的理解了整条链路:
- 为什么
DEL一个大 Hash 会阻塞 Redis,而UNLINK不会?两者的内存回收路径有什么区别? - Cluster 模式下,为什么给热点 Key 加随机后缀能分散压力,但会导致什么新问题?(提示:读放大、写一致性)
- Caffeine 的 W-TinyLFU 淘汰算法相比 LRU 在高命中率场景下有什么优势?为什么它更适合本地缓存?
- 固定窗口限流的临界问题具体怎么发生?滑动窗口和令牌桶如何解决?
- 本地缓存在多实例部署时如何保证一致性?Pub/Sub 广播和短 TTL 各自的失效场景是什么?
- 大 Key 拆分后,如果业务需要一次性读取所有字段,有哪几种方案?各自的代价是什么?
13. 总结
回到开头那句话:大 Key 是单点数据太大,热点 Key 是单点流量太高。
治理这两个问题的本质,是承认 Redis 的单线程执行模型和 Cluster 的哈希路由模型都会制造单点集中。你手里的四张牌各有明确的边界:
- 发现 解决「不知道有没有问题」,用
SCAN、MEMORY USAGE、客户端埋点。 - 拆分解决「数据太集中」,用确定性哈希把大容器变成小容器,代价是跨分片聚合变复杂。
- 本地缓存解决「读流量太集中」,把 99% 的重复读拦在进程内,代价是多实例一致性变弱。
- 限流解决「流量超过承载能力」,用分布式计数保护最脆弱的点,代价是部分请求被拒绝。
四者的组合顺序是:限流在最外层挡住过度流量,本地缓存在中间削掉重复读,拆分在数据层打散存储。任何一层单独用都不够,组合起来才能把「单点」重新变回「可并行的集群」。
14. 参考资料
- Redis 官方文档:MEMORY USAGE 命令说明(https://redis.io/commands/memory-usage/)
- Redis 官方文档:UNLINK 命令说明(https://redis.io/commands/unlink/)
- Redis 官方文档:SCAN 命令说明(https://redis.io/commands/scan/)
- Redis 官方文档:键的过期与淘汰策略(https://redis.io/docs/manual/keyspace-notifications/ 与 https://redis.io/docs/reference/eviction/)
- Redis 官方文档:Cluster 分区与哈希槽(https://redis.io/docs/reference/cluster-spec/)
- Caffeine 官方文档(https://github.com/ben-manes/caffeine/wiki)
- Jedis 官方文档(https://github.com/redis/jedis)
- 《Redis 设计与实现》黄健宏著,机械工业出版社
- 《Redis 深度历险:核心原理与应用实践》钱文品著,电子工业出版社