热点 Key 场景下 Redis 扛不住怎么办?一次讲清热 Key 识别、分片方案与降级治理
热点 Key 场景下 Redis 扛不住怎么办?一次讲清热 Key 识别、分片方案与降级治理
大家好,我是一名有 4 年工作经验的 Java 后端开发。
最近在系统整理高并发业务场景下的一些核心设计问题,准备沉淀成一个系列。
前面几篇我写了秒杀库存扣减、缓存一致性、MQ 幂等消费、支付超时库存回补,这一篇继续聊一个在高并发系统里非常常见、也非常容易被忽略的问题:热点 Key。
🦅个人主页
🐼
文章目录
一、前言
很多人提到 Redis 性能,第一反应通常是:
- Redis 很快
- 单机能扛十万级 QPS
- 放缓存就能抗流量
这些话并不完全错。
但真实线上场景里,Redis 扛不住的时候,往往不是因为“整体流量太大”,而是因为:
某一个 Key 太热了。
比如:
- 某个爆款商品详情页被疯狂访问
- 秒杀库存 Key 被大量并发扣减
- 首页配置 Key 被所有实例同时读取
- 热门直播间信息被持续刷取
- 某个活动资格 Key 在短时间内被反复判断
这时候你会发现:
- Redis 集群整体内存没问题
- CPU 也不一定全部打满
- 但某一个实例突然 RT 飙升
- 网络带宽打高
- 单个核心被打满
- 大量请求堆积,最终引发超时、降级、缓存穿透数据库
所以真正的问题不是“有没有上 Redis”,而是:
当一个 Key 被极端高频访问时,系统应该怎么把它稳住?
这篇文章就结合一个典型的热点商品详情场景,把热点 Key 的识别、治理和工程落地讲透。
二、业务场景
先假设这样一个场景。
2.1 场景设定
电商系统在大促期间,有一个爆款商品详情页。
业务链路如下:
- 用户访问商品详情页
- 服务先查询 Redis 缓存
- Redis 命中后返回商品详情
- 如果缓存未命中,再查 MySQL 并写回 Redis
平时这个流程很稳定。
但在活动开始后,这个商品详情页会在短时间内被大量访问。
2.2 流量特征
- 某个商品详情 Key:
product:detail:1001 - 平时 QPS:
500 - 活动高峰 QPS:
5 万 - 大量请求集中打到同一个 Key
- 服务实例数持续扩容
- 每台应用都会同时访问这个热点 Key
2.3 业务要求
这个场景下,通常需要满足下面这些要求:
- 商品详情页要尽量稳定返回
- Redis 不能因为单 Key 过热而被打挂
- 数据允许短时间最终一致
- 不能因为缓存失效把数据库打穿
- 系统要支持降级和限流
- 需要能识别和监控热点 Key
三、问题现象
很多项目里,一开始的 Redis 使用方式都很简单,比如:
public ProductDTO getProduct(Long productId) {
String key = "product:detail:" + productId;
String json = stringRedisTemplate.opsForValue().get(key);
if (json != null) {
return JSON.parseObject(json, ProductDTO.class);
}
Product product = productMapper.selectById(productId);
if (product == null) {
return null;
}
ProductDTO dto = ProductDTO.from(product);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(dto), Duration.ofMinutes(30));
return dto;
}
这段代码在大多数普通流量场景下都没问题。
但如果某一个 Key 突然非常热,就会暴露出一系列问题。
3.1 单 Key 访问过于集中
虽然 Redis 很快,但单个 Key 的访问最终还是会落到某个固定分片、某个固定实例,甚至固定线程上。
结果就是:
- 集群整体资源还够
- 但某个实例特别忙
- 热点实例 RT 飙升
- 应用侧开始超时重试
- 重试反过来进一步放大流量
3.2 缓存失效瞬间引发流量尖刺
如果这个热点 Key 突然过期,就会出现:
- 大量请求同时发现缓存失效
- 所有请求都回源数据库
- 数据库瞬间承压
- Redis 又被大量重建请求冲击
- 整体链路出现抖动
这本质上就是热点 Key 场景下的缓存击穿。
3.3 热点实例不均衡
在 Redis Cluster 场景下,即使你有很多节点,如果热点 Key 都落在同一个 slot,对应节点依然会成为瓶颈。
也就是说:
- 你以为你用了集群就会自动均衡
- 实际上热点 Key 天生就是“不均衡”的
3.4 热点 Key 连带放大下游风险
如果 Redis 响应变慢,应用可能开始:
- 超时重试
- 回源数据库
- 打印大量错误日志
- 开启熔断/降级
最后的结果可能不是“一个 Key 慢”,而是整个链路都开始不稳定。
四、原理分析
热点 Key 问题的本质,不是 Redis 不够快,而是:
流量分布不均,导致某一个 Key 对应的计算、网络、线程和下游资源被集中打爆。
4.1 为什么单 Key 会成为瓶颈?
因为 Redis 的高性能并不意味着单个 Key 可以无限放大。
一个热点 Key 被访问时,背后至少会消耗这些资源:
- Redis 网络 IO
- 单线程事件处理时间
- 序列化/反序列化成本
- 应用到 Redis 的连接和线程资源
- 集群某个节点的 CPU 和带宽
所以即使你有 10 个 Redis 节点:
- 如果大家都在打一个 Key
- 那还是一个节点更累
- 不会因为“集群有 10 台”就自然均摊
4.2 热点 Key 和大 Key 不是一回事
这两个概念很多人容易混。
- 热点 Key:访问频率特别高
- 大 Key:单个 Value 体积特别大
一个 Key 可以同时又热又大,这最危险。
比如:
- 一个超大的首页配置 JSON
- 一个超大的商品详情对象
- 一个超大的排行榜列表
这类 Key 的特点是:
- 请求多
- 返回大
- 网络占用高
- 序列化成本高
所以热点 Key 治理时,也要顺便看它是不是大 Key。
4.3 为什么简单扩容 Redis 往往不解决问题?
因为热点 Key 的问题本质是“流量打点”,不是“整体平均负载高”。
简单扩容后:
- 普通流量可能被分散
- 但热点 Key 还是会路由到固定分片
- 热点节点仍然可能成为瓶颈
所以治理热点 Key,核心不是只扩容,而是:
识别热点、分散热点、挡住热点、必要时降级热点。
五、常见方案对比
下面把几种常见的热点 Key 治理方案放在一起看一下。
| 方案 | 思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 本地缓存 | 在应用内存再缓存一层 | 命中快,能挡住大量 Redis 流量 | 多实例一致性变复杂 | 读多写少、允许短暂不一致 |
| 热 Key 永不过期 + 异步更新 | 避免热点 Key 同时失效 | 能减少缓存击穿 | 需要额外更新机制 | 热点详情、配置类数据 |
| Key 分片 | 一个热点数据拆成多个 Key | 分散请求到多个实例/slot | 实现复杂,读写逻辑更难 | 极高并发热点读写 |
| 互斥重建 | 缓存失效时只允许一个线程回源 | 防止击穿数据库 | 热点高峰下锁竞争明显 | 热点重建场景 |
| 限流降级 | Redis 压力过高时直接返回兜底数据 | 保证系统可用性 | 数据可能不够实时 | 核心链路保护 |
| 多级缓存 | 本地缓存 + Redis + DB | 性能强,抗热点能力好 | 架构更复杂 | 中大型系统常用 |
如果是大多数互联网业务,我更推荐这套组合:
本地缓存 + Redis 热 Key 永不过期 + 异步更新 + 限流降级。
如果已经到了极端热点场景,再考虑:
Key 分片 + 多级缓存 + 对账修正。
六、推荐方案设计
这里给一版更贴近线上落地的方案。
6.1 核心思路
以“爆款商品详情页”为例,我更倾向于这样做:
- 商品详情先查本地缓存
- 本地缓存没命中,再查 Redis
- Redis 命中直接返回
- Redis 未命中时,只允许少量线程回源数据库
- 热点 Key 尽量不设置固定短 TTL,避免同一时刻大面积失效
- 商品更新后,通过消息或异步任务主动刷新本地缓存和 Redis
- Redis 压力过高时,对详情接口做限流和兜底降级
6.2 为什么热点 Key 不建议简单设置固定 TTL?
因为固定 TTL 在热点场景下特别危险。
假设某个爆款商品 Key 过期时间是 30 分钟。
到了 30 分钟这个点,大量请求可能同时读到“缓存失效”。
结果就是:
- Redis 失去保护
- 所有请求一起打 DB
- 数据库和应用线程瞬间被冲击
所以对热点 Key 更常见的做法是:
- 逻辑过期
- 后台异步刷新
- 永不过期 + 主动更新
- 加随机过期时间避免同时失效
七、落地代码
下面给一版比较贴近实际项目思路的代码。
7.1 增加本地缓存,挡住一部分热点流量
可以先在应用内用 Caffeine 做一层本地缓存。
@Service
public class ProductQueryService {
private final Cache<Long, ProductDTO> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(5))
.build();
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ProductMapper productMapper;
public ProductDTO getProduct(Long productId) {
ProductDTO localValue = localCache.getIfPresent(productId);
if (localValue != null) {
return localValue;
}
String key = "product:detail:" + productId;
String json = stringRedisTemplate.opsForValue().get(key);
if (json != null && !json.isBlank()) {
ProductDTO dto = JSON.parseObject(json, ProductDTO.class);
localCache.put(productId, dto);
return dto;
}
return rebuildCache(productId, key);
}
private ProductDTO rebuildCache(Long productId, String key) {
Product product = productMapper.selectById(productId);
if (product == null) {
return null;
}
ProductDTO dto = ProductDTO.from(product);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(dto), Duration.ofMinutes(30));
localCache.put(productId, dto);
return dto;
}
}
这一层的作用很直接:
- 热点请求先在 JVM 内存命中
- 大量读流量不再每次都打 Redis
- 对热点详情类数据特别有效
7.2 缓存重建时加互斥控制,防止击穿数据库
如果 Redis 未命中,不能让所有线程一起查数据库。
private ProductDTO rebuildCache(Long productId, String key) {
String lockKey = "lock:product:detail:" + productId;
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (!Boolean.TRUE.equals(locked)) {
String json = stringRedisTemplate.opsForValue().get(key);
if (json != null && !json.isBlank()) {
ProductDTO dto = JSON.parseObject(json, ProductDTO.class);
localCache.put(productId, dto);
return dto;
}
return null;
}
try {
Product product = productMapper.selectById(productId);
if (product == null) {
return null;
}
ProductDTO dto = ProductDTO.from(product);
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(dto), Duration.ofMinutes(30));
localCache.put(productId, dto);
return dto;
} finally {
stringRedisTemplate.delete(lockKey);
}
}
这里的关键点是:
- 只有拿到锁的线程才允许回源数据库
- 其他线程短暂等待 Redis 重建结果
- 避免缓存失效瞬间把数据库打穿
7.3 热点 Key 永不过期,改为主动更新
对于极热数据,更推荐主动更新而不是被动过期。
@Transactional
public void updateProduct(ProductUpdateRequest request) {
productMapper.updateById(ProductConverter.toEntity(request));
Product product = productMapper.selectById(request.getId());
ProductDTO dto = ProductDTO.from(product);
String key = "product:detail:" + request.getId();
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(dto));
localCache.put(request.getId(), dto);
}
这种写法更适合:
- 商品详情
- 首页配置
- 运营活动信息
- 热门榜单快照
它的核心思路是:
- 既然这个 Key 一定会很热
- 那就不要依赖自然过期
- 而是在数据变更时主动刷新
7.4 极端热点场景下做 Key 分片
如果某个 Key 热度极端高,可以把一份数据复制成多个副本,读的时候随机打散。
比如原来只有:
product:detail:1001
现在扩展成:
product:detail:1001:0product:detail:1001:1product:detail:1001:2product:detail:1001:3
读取时随机选择一个:
public String buildShardingKey(Long productId) {
int index = ThreadLocalRandom.current().nextInt(4);
return "product:detail:" + productId + ":" + index;
}
更新时同步更新全部分片 Key:
public void refreshShardingCache(Long productId, ProductDTO dto) {
String json = JSON.toJSONString(dto);
for (int i = 0; i < 4; i++) {
String key = "product:detail:" + productId + ":" + i;
stringRedisTemplate.opsForValue().set(key, json, Duration.ofMinutes(30));
}
}
这种方案的本质是:
- 用空间换时间
- 用多副本把单点热点流量打散
它适合极端场景,但维护成本会明显更高。
7.5 Redis 扛不住时做限流和兜底
如果热点商品详情页已经是活动关键入口,建议一定要准备降级方案。
比如:
- 返回本地缓存旧值
- 返回简化版商品信息
- 暂时屏蔽一些非核心字段
- 直接对详情接口限流
简单示例:
public ProductDTO getProductWithDegrade(Long productId) {
if (isRedisOverloaded()) {
ProductDTO localValue = localCache.getIfPresent(productId);
if (localValue != null) {
return localValue;
}
return buildFallbackProduct(productId);
}
return getProduct(productId);
}
真正线上场景里,限流和降级往往不是“好不好看”的问题,而是系统能不能活下来。
八、为什么很多项目做了缓存,热点 Key 还是会出问题?
这也是线上非常常见的情况。
8.1 只上 Redis,不做本地缓存
这样每次请求都要过网络打到 Redis。
一旦某个 Key 特别热,Redis 实例和网络都会承压。
8.2 热点 Key 设置了固定 TTL
这会导致缓存在某个时刻集中失效,引发击穿。
8.3 以为 Redis 集群会自动均衡热点
普通 Key 会比较均衡,但热点 Key 天生就是集中访问,不会因为有集群就自然消失。
8.4 没有识别机制,不知道谁是热点 Key
很多系统出了问题才开始排查。
但如果平时没有:
- Key 访问次数统计
- 慢查询分析
- 实例级监控
- Top Key 分析
那问题出现时会非常被动。
8.5 Redis 慢了以后,没有降级兜底
最危险的不是 Redis 变慢,而是 Redis 慢了以后应用开始疯狂重试、回源数据库,最终把整个系统都带崩。
九、压测与监控怎么写,文章才更像做过项目的人写的?
热点 Key 这类文章,如果只讲概念,不讲观测和治理,很容易看起来像 Demo。
所以建议补上压测和监控视角。
9.1 压测场景示例
这里给一个适合写进文章的测试场景:
场景配置:
- 热点商品详情 Key:
product:detail:1001 - 峰值读 QPS:
50000 - Redis:
3 主 3 从 - 应用实例数:
6 - 本地缓存:Caffeine
- 数据库:MySQL 主从
对比方案:
- 方案 A:单层 Redis
- 方案 B:本地缓存 + Redis
- 方案 C:本地缓存 + Redis + 热点 Key 分片 + 限流降级
9.2 压测结果示例
| 指标 | 单层 Redis | 本地缓存 + Redis | 多级缓存 + 分片 + 降级 |
|---|---|---|---|
| Redis 实例峰值 QPS | 50000 | 12000 | 7000 |
| 商品详情平均 RT | 18ms | 7ms | 6ms |
| 热点节点 CPU | 92% | 58% | 43% |
| 数据库回源次数 | 高峰时明显抖动 | 较低 | 最低 |
| 系统稳定性 | 一般 | 较好 | 最好 |
从结果上可以看出:
- 单层 Redis 在热点场景下压力会非常集中
- 本地缓存能明显分担 Redis 读压力
- 如果再结合分片和降级,热点场景会更稳
说明:以上压测数据为示例写法,实际结果需要结合序列化方式、对象大小、网络带宽、Redis 部署方式综合评估。
9.3 线上建议重点监控哪些指标?
如果你准备真正落地热点 Key 治理,至少建议监控这些指标:
- Redis 单实例 QPS
- Redis 单实例 CPU
- 热点 Key Top N 访问次数
- Redis 网络流量和带宽占用
- 热点接口 TP95、TP99
- 本地缓存命中率
- Redis 命中率
- 数据库回源次数
- 限流触发次数
- 降级返回次数
这些指标一旦写进文章里,会明显更像真实线上经验总结。
十、面试中怎么回答这个问题?
如果面试官问你:
热点 Key 场景下 Redis 扛不住怎么办?
你可以这样回答。
10.1 回答思路
第一,热点 Key 的本质不是 Redis 总体性能不够,而是某一个 Key 的请求过于集中,导致对应实例、线程和网络资源成为瓶颈。
第二,普通扩容 Redis 往往不能彻底解决问题,因为热点 Key 仍然会落到固定分片,所以核心思路应该是识别热点、分散热点、限制热点和降级热点。
第三,常见治理方案包括在应用侧增加本地缓存、避免热点 Key 固定时间失效、缓存重建时增加互斥控制、防止击穿数据库。
第四,如果热点极端严重,可以对热点数据做多副本分片,把一个 Key 拆成多个副本,读取时随机路由,更新时同时刷新多个分片。
第五,如果 Redis 已经扛不住,还要准备限流和降级,比如返回本地缓存旧值、返回简化数据或者限制部分流量,避免 Redis 故障向数据库和整个系统扩散。
第六,线上一定要配套热点 Key 识别和监控能力,比如 Top Key 分析、单实例 CPU、命中率、数据库回源次数和降级次数。
10.2 面试官更想听到什么?
面试官真正想听的,通常不是一句“加本地缓存”,而是你有没有这些意识:
- 你知道热点 Key 和大 Key 的区别
- 你知道 Redis 集群不等于自动解决热点
- 你知道热点 Key 最怕固定 TTL 同时失效
- 你知道本地缓存是非常有效的第一层分流
- 你知道极端场景要用分片和降级
- 你知道治理热点 Key 必须有监控闭环
如果你能把这些点讲清楚,面试官会明显觉得你做过线上高并发缓存治理。
十一、总结
热点 Key 这个问题,真正难的不是“Redis 快不快”,而是如何在流量极度不均衡的情况下,把单点热点拆开、挡住、稳住。
如果只记一句结论,我觉得可以记住这句:
大多数热点 Key 场景下,优先用“本地缓存 + Redis 热 Key 主动更新 + 互斥重建 + 限流降级”来治理。
如果已经到了更极端的热点流量,再考虑:
Key 分片 + 多级缓存 + 对账修正。
这套方案不一定最简单,但通常更接近真实线上系统。
十二、后续准备继续写的内容
如果这篇你觉得还可以,后面这个系列我准备继续写:
- 分布式锁为什么经常用错?
- 本地消息表到底怎么设计才靠谱?
- 高并发系统的限流、降级、熔断怎么配合使用?
- 一次线上缓存击穿和热点 Key 故障排查复盘
- MySQL 慢 SQL 在高并发场景下怎么定位和治理?
如果你也在做高并发相关业务,欢迎交流。
十三、结尾
如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注。
后面我会继续输出一些偏实战的 Java 后端文章。
我是一个正在持续沉淀高并发与后端工程实践的 Java 开发,
也欢迎大家一起讨论更好的实现方案。
更多推荐




所有评论(0)