Redis 大 Key 与热点 Key 生产治理:发现、拆分、限流与本地缓存的组合策略

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 相关抖动时,按这个顺序排查:

  1. 看慢查询:SLOWLOG GET 10,确认是否有超过 10ms 的命令。
  2. 看单命令延迟:redis-cli --latency -h <host> -p <port>,确认是 Redis 本身慢还是网络慢。
  3. 看大 Key:对可疑 Key 执行 MEMORY USAGE <key>,超过 10KB 的 String 或超过 5000 元素的集合重点看。
  4. 看热点:对比各分片的 instantaneous_ops_per_sec,如果某分片明显高于平均值,说明有热点。
  5. 看主从延迟:INFO replication 的 master_repl_offset 差值,超过 1MB 要警惕。
  6. 看内存:INFO memory 的 used_memory 与 maxmemory 比值,接近 1 时可能触发淘汰。
  7. 看连接数:INFO clients 的 connected_clients,突增说明可能有连接泄漏。

12. 面试/复盘问题

这些问题可以用来检验自己是否真的理解了整条链路:

  1. 为什么 DEL 一个大 Hash 会阻塞 Redis,而 UNLINK 不会?两者的内存回收路径有什么区别?
  2. Cluster 模式下,为什么给热点 Key 加随机后缀能分散压力,但会导致什么新问题?(提示:读放大、写一致性)
  3. Caffeine 的 W-TinyLFU 淘汰算法相比 LRU 在高命中率场景下有什么优势?为什么它更适合本地缓存?
  4. 固定窗口限流的临界问题具体怎么发生?滑动窗口和令牌桶如何解决?
  5. 本地缓存在多实例部署时如何保证一致性?Pub/Sub 广播和短 TTL 各自的失效场景是什么?
  6. 大 Key 拆分后,如果业务需要一次性读取所有字段,有哪几种方案?各自的代价是什么?

13. 总结

回到开头那句话:大 Key 是单点数据太大,热点 Key 是单点流量太高。

治理这两个问题的本质,是承认 Redis 的单线程执行模型和 Cluster 的哈希路由模型都会制造单点集中。你手里的四张牌各有明确的边界:

  • 发现 解决「不知道有没有问题」,用 SCAN、MEMORY USAGE、客户端埋点。
  • 拆分解决「数据太集中」,用确定性哈希把大容器变成小容器,代价是跨分片聚合变复杂。
  • 本地缓存解决「读流量太集中」,把 99% 的重复读拦在进程内,代价是多实例一致性变弱。
  • 限流解决「流量超过承载能力」,用分布式计数保护最脆弱的点,代价是部分请求被拒绝。

四者的组合顺序是:限流在最外层挡住过度流量,本地缓存在中间削掉重复读,拆分在数据层打散存储。任何一层单独用都不够,组合起来才能把「单点」重新变回「可并行的集群」。

14. 参考资料

相关推荐
ShineWinsu3 小时前
对于Redis:AOF持久化的解析
linux·数据库·redis·缓存·面试·持久化·aof
仍然.4 小时前
Redis---集群
数据库·redis·缓存
hweiyu005 小时前
Redis命令:HSCAN
redis·缓存
ly76897 小时前
Redis 分布式锁的边界条件:Redlock 争议、锁续期与客户端崩溃后的互斥失效
数据库·redis·分布式·分布式锁·watchdog·redlock
ShineWinsu10 小时前
对于Redis:RDB持久化的解析
linux·数据库·redis·缓存·面试·持久化·rdb
hweiyu001 天前
Redis命令:HTTL
redis·缓存
樱花落木兰1 天前
分布式登录实战:Session 会话共享改造,Redis 存储用户登录状态
java·javascript·数据库·redis·分布式·缓存
ShineWinsu1 天前
对于Redis:Steam、Geospatial、Hyperloglog、Bitmap、Bitfield类型的解析
数据库·c++·redis·分布式·缓存·面试·zset
海绵宝宝转agent1 天前
基于Redis ZSet+AOP+注解实现限流注解算法
数据库·redis·算法