REDIS · BIG KEY · HOT KEY · --BIGKEYS · --HOTKEYS · UNLINK · LOCAL CACHE

一个 key 太胖,或者一个 key 太红,都能拖垮一个节点

Redis 把压力分摊开靠的是“key 很多、每个都不大、访问也分散”。大 key 打破了“每个都不大”,热 key 打破了“访问分散”。两种都会让某一个节点、某一条命令、某一段网络单独被压垮,而加机器往往没用。

大 keybig key

一个 key 里塞了几十万个元素,或者一个值就有几 MB。读它、删它、迁移它,都要一次干完,主线程和网络被它独占。

热 keyhot key

节点 A
节点 B
节点 C

一个 key 每秒被读几万次。它只能在一个槽、一个节点上,那个节点 CPU 和网卡被打满,别的节点却很闲。

胖子big key

值太大或元素太多,一次操作就要很久。

问题在“大小”
网红hot key

同一时间所有人都来读它,分不出去。

问题在“集中”
雷达--bigkeys · --hotkeys

先找到是哪个 key,再对症下药。

尽量在从节点或离线做
切开拆分 · 复制 · 本地缓存

大的切小,热的多放几份、挡在外面。

都有代价

大 key:多大算大,大了有什么坏处

没有统一标准,常见的经验线是:String 超过 10 KB;Hash、List、Set、ZSet 的元素超过 5000 个,或者总大小超过 10 MB。真正要看的是:操作它一次要多久、传它一次要多少流量。

卡住主线程HGETALL、SMEMBERS、LRANGE 0 -1、DEL 都要处理全部元素,这期间所有请求排队(Redis 第 07 篇)。
打满网卡一个 1 MB 的值每秒读 1000 次,就是 1 GB/s,远超千兆网卡的 125 MB/s。
集群数据倾斜一个 key 只能在一个节点上,那个节点内存比别人大一截,先触发淘汰或 OOM。
迁移和扩容卡住槽迁移用 MIGRATE 一次搬整个 key,大 key 搬一次就阻塞两边,还可能超时失败。
过期、淘汰时卡顿大 key 到期或被淘汰,同样要在主线程里释放,除非打开 lazyfree(Redis 第 05 篇)。
客户端也难受Java 一次反序列化几 MB 的对象,GC 压力大;超时设置短了还会读到一半就断。

大 key 计算器:一个 Hash 有多危险

字段个数:
每个字段+值:
每秒读几次:
存法:

粗略估算:读每个元素约 1 微秒,释放每个元素约 0.5 微秒,每个元素额外开销约 40 字节;实际和机器、编码有关,只看数量级。

大 key 怎么治

核心就一句:别让一次操作碰到太多数据。能拆就拆,拆不了就少读、分批读、后台删。

拆分:按字段的哈希值分到 N 个小 Hash 里
user:follows:1001一个 Hash,100 万个字段
…:1001:0…:1001:1…:1001:2…:1001:3……:1001:96…:1001:97…:1001:98…:1001:99各 1 万个

读写某个字段时,先算 hash(field) % 100 找到它在哪一份,只碰那一份。集群下这 100 份还会落到不同节点,内存也均匀了。

拆分后的读写StringRedisTemplate
static final int SHARDS = 100;

String shardKey(long userId, String field) {
    int i = Math.floorMod(field.hashCode(), SHARDS);
    return "user:follows:" + userId + ":" + i;
}

redis.opsForHash().put(shardKey(uid, f), f, value);   // 写只碰一份
redis.opsForHash().get(shardKey(uid, f), f);          // 读也只碰一份
拆不了的时候

少读:用 HMGET 取需要的字段,别 HGETALL;LRANGE 只取一页。

分批读:HSCAN、SSCAN、ZSCAN 每次取几百个。

后台删:用 UNLINK,或打开 lazyfree-lazy-user-del;Redis 4.0 之前只能用 HSCAN + HDEL 一批批删。

压缩大 String:几 MB 的 JSON 用 gzip 或 snappy 压一下,或者干脆放到对象存储,Redis 里只存地址。别用 JDK 序列化,它会让值变大很多(Redis 第 04 篇)。

热 key:为什么加机器没用

秒杀商品、热搜第一、明星官宣、首页配置,这些 key 一个就能占掉一个节点大半的流量。集群按 key 分片,同一个 key 永远在同一个槽、同一个节点(Redis 第 03 篇)。加再多节点,分走的也只是别的 key。

热 key 实验台:集群总共每秒 15 万次请求

热 key 占比:
主节点数:
普通 key 的请求热 key 的请求从节点分担的读虚线:每个节点约 7 万 QPS 的上限

示意模型:普通 key 均匀分布;热 key 本来全在节点 B。本地缓存命中 90%;复制 3 份并保证落在 3 个不同节点;从节点分担热 key 一半的读;限流把打到 Redis 的热 key 请求控制在每秒 2 万。

热 key 怎么治

思路是让热 key 的请求“到不了”或者“分得开”。最有效的通常是在应用里加一层本地缓存,因为热 key 的特点就是一份数据被反复读。

本地缓存挡在前面Caffeine,过期时间很短
Cache<String, String> local = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofSeconds(2))   // 最多 2 秒的旧数据
        .build();

String get(String key) {
    // 同一个 key 并发时只有一个线程去 Redis(MyBatis 第 04 篇讲过)
    return local.get(key, k -> redis.opsForValue().get(k));
}
热 key 复制成多份写全部,读随机挑一份
static final int COPIES = 3;

void setHot(String key, String v) {
    for (int i = 0; i < COPIES; i++)
        redis.opsForValue().set(key + "#" + i, v, Duration.ofMinutes(10));
}

String getHot(String key) {
    int i = ThreadLocalRandom.current().nextInt(COPIES);
    return redis.opsForValue().get(key + "#" + i);
}
// 不能用 {key}#i 这种 hash tag,否则几份会落在同一个槽
方案怎么做效果代价
本地缓存(多级缓存)每台应用用 Caffeine 缓存热 key 几秒最有效,热 key 请求大部分不出应用几秒内数据不一致;要知道哪些是热 key
Redis 客户端缓存Redis 6 的 CLIENT TRACKING:客户端缓存读过的 key,值变了服务端主动通知失效一致性比固定过期更好要 RESP3;客户端要支持(Lettuce 6 有);被追踪的 key 多了服务端也要记
复制多份打散key#0~key#N 分到不同节点,读随机挑压力分到 N 个节点写要写 N 份,可能短暂不一致;多占内存
从节点分担读Lettuce 配 ReadFrom.REPLICA_PREFERRED读能力翻倍左右从节点有复制延迟;对所有 key 生效
限流降级Sentinel 的热点参数限流,超过阈值走兜底节点一定不被打垮一部分用户拿到降级结果
提前预热、不过期活动前把已知热点写进去,不设过期,定时刷新避免热 key 过期那一刻击穿到数据库要提前知道谁会热(Redis 第 01 篇)
加节点扩容集群对热 key 几乎没用花钱,只分走了别的 key

怎么找到它们

找大 key 和热 key 本身也可能伤到线上实例。能在从节点上做就别在主节点上做,能离线分析就别在线扫。

工具找什么原理注意
redis-cli --bigkeys大 key用 SCAN 扫一遍,每种类型报告元素最多(String 报字节最多)的那个只报每种类型的第一名;加 -i 0.1 每批之间歇一下
redis-cli --memkeys大 keySCAN + 对每个 key 调 MEMORY USAGE按真实内存排,比 --bigkeys 准,也更慢
MEMORY USAGE key单个 key 多大估算这个 key 占的字节数集合类默认抽样 5 个元素估算
离线分析 RDB大 key拿一份 RDB 文件,用 redis-rdb-tools 等工具统计对线上零影响,适合定期巡检
redis-cli --hotkeys热 keySCAN + OBJECT FREQ 看访问频率要求淘汰策略是 LFU 类,否则报错
MONITOR热 key实时打印所有命令,统计次数吞吐可能直接掉一半,只能短时间用
客户端或代理统计热 key在客户端埋点,按 key 计数,汇总后发现热点并推送给各实例最实时,能和本地缓存联动;要自己开发或用现成框架

Redis 版本,以及 JDK 8 和 JDK 17

治理手段越来越多,但大 key 和热 key 的本质没变。Java 这边主要是客户端能力和本地缓存库的版本。

Redis 4.0 ~ 5 · Boot 2

JDK 8+
  • 4.0 起有 UNLINK、lazyfree、LFU 淘汰策略和 OBJECT FREQ,--hotkeys 才能用
  • 4.0 起有 MEMORY USAGE
  • 本地缓存用 Caffeine 2.x;Boot 2.4 起默认的 Lettuce 已经是 6.x

Redis 6 ~ 7 · Boot 3

JDK 17+
  • 6.0 起支持 RESP3 和客户端缓存(CLIENT TRACKING),Lettuce 6 提供 ClientSideCaching
  • 6.0 起有 lazyfree-lazy-user-del,普通 DEL 也能后台释放
  • 本地缓存用 Caffeine 3.x;虚拟线程下更要防止大量请求同时去 Redis 读同一个热 key,用 cache.get(key, loader) 合并
没变的:大 key 拆小、少读、后台删热 key 挡在外面、分散开加节点治不了热 key

常踩的坑

很多大 key 和热 key 不是设计出来的,是慢慢长出来、突然火起来的。

一个“关注列表”的 key 越长越大

业务刚上线时只有几百个元素,几年后有个大 V 有几百万粉丝。设计时就按用户和业务 ID 拆分,并对元素个数做监控告警。

运维清理一个大 key,线上卡了几秒

用了 DEL。改用 UNLINK;老版本用 HSCAN / SSCAN 一批批删。

集群扩容迁移槽时,某个槽一直迁不过去

槽里有个大 key,MIGRATE 超时。先把大 key 拆掉或删掉,再迁移。

集群里一个节点内存总是比别人高很多

数据倾斜:要么有大 key,要么用 hash tag 把太多 key 塞进了同一个槽。用 --bigkeys 和 CLUSTER COUNTKEYSINSLOT 查。

大促时一个节点 CPU 100%,加了节点也没用

热 key。加本地缓存、复制多份打散;临时先对这个 key 限流。

热 key 过期那一刻,数据库被打爆

热 key 和缓存击穿常常一起出现。已知的热点提前预热、逻辑过期,重建时加互斥锁。

复制多份后,不同用户看到的价格不一样

写了 N 份但不是同时生效。对一致性要求高的数据别用这招,或者改完价格后等所有副本写完再生效。

本地缓存把应用内存撑爆了

没设 maximumSize,所有读过的 key 都缓存了。本地缓存只放热 key,并限制大小和过期时间。

为了找热 key 开了 MONITOR,结果线上更慢了

MONITOR 要把每条命令都发给你,吞吐可能掉一半。用 --hotkeys(需 LFU)、客户端统计,或者短时间在从节点上看。

--bigkeys 只报了一个,其他大 key 没发现

它每种类型只报最大的那个。要完整清单,用 --memkeys 或离线分析 RDB。