第23章 热点Key问题排查与解决:大促场景经典坑

所属模块:模块四:缓存架构深水区

热点Key问题排查与解决:大促场景经典坑

一、真实场景

大促期间某个爆款商品的详情页缓存Key被海量用户同时高频访问,尽管这个key在缓存里的命中率是100%(数据确实在缓存里),但由于Redis是单线程处理命令(集群模式下具体到某个slot所在的单个节点),海量请求全部集中打到这一个key所在的节点上,导致该节点CPU被打满,即使整个Redis集群的其他节点都很空闲,这一个节点也扛不住,拖累了整体服务的响应时间。

典型的现象是:监控面板上整个集群的平均CPU、平均QPS看起来都很健康,但某一个节点的CPU单独飙到100%,同时该节点所在的slot范围内的请求延迟大幅上升------如果只看集群整体指标很容易被掩盖,必须下钻到单节点粒度才能定位。

场景变体:写热点------秒杀库存扣减

热点Key不仅体现在读请求上,写请求同样会形成热点。典型案例是秒杀场景下对同一个商品库存key执行 DECR 扣减,成千上万个请求并发对同一个key做原子自减操作,即使Redis单条命令执行极快,但当并发量达到十万级QPS时,单个key的写入依然会成为整个链路的瓶颈,并且写热点比读热点更难通过简单的多副本读分散来解决,因为多个副本之间的库存数据需要保持一致,不能像读请求那样随意路由到任意副本。

二、原理拆解

2.1 热点Key与缓存穿透 / 雪崩的本质区别

热点Key问题与缓存穿透/雪崩有本质区别:穿透/雪崩是"数据没在缓存里"导致请求打到数据库;热点Key是"数据明明在缓存里,但访问过于集中在少数几个key/少数几个节点"导致单点过载

问题类型 根本原因 受影响对象 典型解法方向
缓存穿透 查询不存在的数据,缓存和数据库都没有命中,每次都穿透到数据库 数据库 布隆过滤器、缓存空值
缓存雪崩 大批key在同一时刻集中过期,或Redis节点整体宕机 数据库 / 整个缓存集群 过期时间加随机扰动、多级缓存、集群高可用
热点Key 少数key的访问量远超其他key,集中压垮其所在的单个节点 该key所在的单个Redis节点 本地缓存兜底、多副本打散、单机限流

这意味着简单的"扩容Redis节点数量"未必能解决热点Key问题------因为一致性哈希或者slot分配机制下,同一个key始终会被路由到固定的某个节点,加再多节点,这个热点key所在的节点依然要独自承受全部相关流量。

2.2 为什么加节点没用:Redis 集群的路由机制

Redis Cluster 把整个 key 空间划分成 16384 个 slot,每个 key 通过 CRC16(key) % 16384 计算得到固定的 slot 编号,每个 slot 又被分配给某一个具体的节点负责。这个映射关系只跟 key 的内容有关,跟集群里有多少个节点无关------也就是说,无论集群扩容到多少个节点,同一个 key(比如 product:12345)永远会被路由到同一个 slot、同一个节点上。当这个 key 本身成为热点时,压力天然集中在这一个节点,加节点只会让其他节点更空闲,却丝毫不能分担这个节点的压力,这是热点 Key 问题在架构层面无法通过单纯扩容解决的根本原因。

2.3 热点Key探测方法

Redis自身提供了--hotkeys选项,可以通过扫描分析出访问频率较高的key;更实时的方式是在客户端SDK层面做请求埋点统计,对访问频率超过阈值的key做本地统计上报,能更快速地发现新出现的热点(而不是等redis-cli扫描周期性触发)。

  • 被动扫描型:redis-cli --hotkeys、Redis 4.0+ 的 LFU(最近最少使用频率)淘汰策略配合 OBJECT FREQ 命令,可以事后分析出哪些 key 访问频率高,但存在一定滞后性;
  • 客户端埋点型:在 SDK 或代理层(如 Codis、Twemproxy 之类的中间代理)对每次访问做本地滑动窗口计数,一旦某个 key 短时间内访问次数超过阈值,立即标记为热点并上报,能做到秒级甚至更快的发现速度;
  • 监控指标型:监控每个 Redis 节点的 CPU、QPS、网络带宽等指标,一旦发现某个节点显著偏离集群平均水平,结合该节点负责的 slot 范围反查具体是哪些 key 在被高频访问;
  • 业务侧预判型:大促、秒杀等场景通常提前知道哪些商品是爆款,可以在活动开始前就主动对这些已知的 key 做预热和打散处理,而不必等到问题真正发生后才被动探测。

2.4 解决思路:分散和前置

核心思路是分散和前置------把压力从 Redis 集群的单点,尽量往请求链路的更前端疏导,或者把单个 key 的压力人为拆分到多个 key / 多个节点上。

方案一:本地缓存兜底

在应用服务的内存里(比如用 Caffeine)对访问频率极高的数据做一层本地缓存,大部分请求根本不需要再打到 Redis,天然分散了压力(每个应用实例各自独立缓存,不存在单点瓶颈)。这本质上是构建了"本地缓存(L1)+ Redis(L2)+ 数据库(L3)"的多级缓存架构,越靠近请求入口的一层,扛住的流量比例应该越高。

需要权衡的问题:本地缓存会带来新的数据一致性问题:多个应用实例各自持有一份缓存副本,数据更新后如何让所有实例的本地缓存及时失效?常见做法是缩短本地缓存的过期时间(用短 TTL 换取一致性,牺牲一定的缓存效果),或者在数据更新时通过消息广播(如 Redis Pub/Sub、MQ 广播消息)通知所有应用实例主动清理本地缓存中对应的 key,后者时效性更好但实现复杂度更高。

方案二:多副本打散(影子Key)

针对已知的热点key,主动创建多个内容相同的"影子key"(比如product:12345:copy1product:12345:copy2),客户端读取时按一定策略(比如取模或随机)分散读取到不同的影子key上,人为把一个热点key的压力拆分到多个key(从而可能路由到不同节点)上。

  • 副本数量的选择需要参考预估 QPS 与单节点能承受的上限,通常按"预估峰值 QPS / 单节点安全阈值"估算所需副本数,并留出一定余量;
  • 这个方案天然适合读多写少的场景(如商品详情),对于写多的场景(如库存扣减),多副本之间的数据同步会成为新的复杂度来源,需要额外设计数据聚合或最终一致的同步机制;
  • 影子 key 的过期时间需要与主 key 保持同步,避免出现部分副本已过期、部分副本仍是旧数据的不一致现象。
方案三:写热点的分段处理

对于秒杀扣库存这类写热点场景,一种常见做法是把总库存拆分成多个分段计数器(如把 1000 件库存拆成 10 个 key,每个 key 存 100 件),扣减请求先根据一定策略(如随机或哈希)路由到某个分段上执行原子自减,从而把写压力分散到多个 key、可能分散到多个节点上;库存总量则通过汇总各分段剩余量得到。这个方案的代价是会引入"某个分段提前卖光、但其他分段仍有余量"的库存碎片问题,需要配合适当的重试或分段动态调整策略来缓解。

方案四:单机限流 / 熔断保护

作为兜底手段,可以在客户端针对已识别出的热点 key 做本地限流(如使用 Guava RateLimiter 或滑动窗口计数器),超出阈值的请求直接走降级逻辑(如返回稍旧的缓存数据、返回默认兜底数据,或直接拒绝并提示稍后重试),避免瞬时流量继续压垮后端节点,为本地缓存预热、影子 key 生效争取缓冲时间。

三、排查工具 / 关键命令

bash 复制代码
# Redis自带的热key发现工具(采样扫描方式,对生产环境影响较小)
redis-cli --hotkeys

# 查看集群模式下各slot的分布和对应节点负载,确认是否单节点过载
redis-cli --cluster check <host>:<port>

# 查看单个节点的实时命令统计,辅助判断是否存在异常集中的访问
redis-cli -h <host> -p <port> INFO commandstats

# 查看单个节点当前的 CPU、内存、连接数等基础指标
redis-cli -h <host> -p <port> INFO

# 结合 MONITOR 短时间抓取实时命令流,用于人工确认具体是哪些 key 访问异常密集
# 注意:MONITOR 会对性能有影响,生产环境仅建议短时间、低峰期使用
redis-cli -h <host> -p <port> MONITOR

四、代码示例

4.1 本地缓存兜底(Caffeine)

java 复制代码
LoadingCache<String, Product> localCache = Caffeine.newBuilder()
    .maximumSize(1000)
    .expireAfterWrite(10, TimeUnit.SECONDS) // 短过期时间,减少数据不一致窗口
    .build(key -> loadFromRedis(key));

public Product getHotProduct(String productId) {
    return localCache.get("product:" + productId); // 大部分请求命中本地缓存,不再打到Redis
}

4.2 本地缓存主动失效(数据更新时广播通知)

为了缓解本地缓存的数据一致性问题,在数据更新时通过消息广播通知所有应用实例主动清理本地缓存:

java 复制代码
// 数据更新方:更新数据库和 Redis 后,广播一条本地缓存失效消息
public void updateProduct(Product product) {
    productMapper.updateById(product);
    redisTemplate.delete("product:" + product.getId());

    // 广播消息,通知所有应用实例清理本地缓存(Topic 模式,每个实例都会收到)
    LocalCacheInvalidateMessage msg = new LocalCacheInvalidateMessage("product:" + product.getId());
    rocketMQTemplate.syncSend("local-cache-invalidate-topic", msg);
}

// 各应用实例:订阅广播消息,收到后清理自己的本地缓存
@RocketMQMessageListener(
    topic = "local-cache-invalidate-topic",
    consumerGroup = "local-cache-invalidate-consumer-${spring.application.instance-id}", // 每个实例独立消费组,确保广播效果
    messageModel = MessageModel.BROADCASTING
)
public class LocalCacheInvalidateConsumer implements RocketMQListener<LocalCacheInvalidateMessage> {
    @Override
    public void onMessage(LocalCacheInvalidateMessage msg) {
        localCache.invalidate(msg.getCacheKey());
    }
}

4.3 多副本打散(影子Key)

java 复制代码
// 多副本打散(针对已知的极端热点key)
public Product getWithShardedKey(String productId, int shardCount) {
    int shard = ThreadLocalRandom.current().nextInt(shardCount);
    String shardedKey = "product:" + productId + ":shard" + shard;
    Product product = redisTemplate.opsForValue().get(shardedKey);
    if (product == null) {
        product = productMapper.selectById(productId);
        redisTemplate.opsForValue().set(shardedKey, product, 3600, TimeUnit.SECONDS);
    }
    return product;
}

// 数据更新时需要同步更新(或删除)所有影子副本,避免部分副本停留旧数据
public void updateShardedKeys(String productId, int shardCount) {
    for (int i = 0; i < shardCount; i++) {
        redisTemplate.delete("product:" + productId + ":shard" + i);
    }
}

4.4 写热点:库存分段扣减

java 复制代码
// 将总库存拆分为多个分段计数器,扣减请求随机路由到某个分段
public boolean deductStock(String productId, int segmentCount) {
    int segment = ThreadLocalRandom.current().nextInt(segmentCount);
    String segmentKey = "stock:" + productId + ":segment" + segment;
    Long remaining = redisTemplate.opsForValue().decrement(segmentKey);
    if (remaining != null && remaining >= 0) {
        return true; // 该分段扣减成功
    }
    // 该分段已耗尽,回滚并可选择重试其他分段(次数需要设置上限,避免无限重试)
    redisTemplate.opsForValue().increment(segmentKey);
    return false;
}

// 汇总所有分段剩余量,得到当前总库存(用于展示,非强一致,允许短暂误差)
public long getTotalStock(String productId, int segmentCount) {
    long total = 0;
    for (int i = 0; i < segmentCount; i++) {
        String value = redisTemplate.opsForValue().get("stock:" + productId + ":segment" + i);
        total += value == null ? 0 : Long.parseLong(value);
    }
    return total;
}

4.5 单机限流保护热点Key

java 复制代码
// 针对已识别出的热点key,在客户端做本地限流,超出部分走降级逻辑
private final RateLimiter hotKeyLimiter = RateLimiter.create(5000); // 每秒最多放行5000次

public Product getHotProductWithLimiter(String productId) {
    if (!hotKeyLimiter.tryAcquire()) {
        return getFallbackProduct(productId); // 降级:返回稍旧的缓存数据或默认兜底数据
    }
    return getHotProduct(productId);
}

五、常见误区

  • 遇到热点Key问题第一反应是"加Redis节点扩容"------忽视了问题的本质是访问集中在单个key而不是整体数据量或整体QPS超过了集群容量,加节点并不能改变这个特定key始终路由到固定节点这个事实,真正有效的手段是从客户端侧做本地缓存分流或者主动做key打散,而不是单纯依赖基础设施层面的扩容。
  • 只关注读热点,忽视写热点------秒杀扣库存这类写请求集中在同一个 key 上同样会形成热点,且写热点无法简单地靠多副本随机读来分散,需要专门的分段扣减等设计。
  • 本地缓存 TTL 设置过长------本地缓存虽然能有效分流,但过长的过期时间会放大数据不一致的时间窗口,需要结合业务对数据新鲜度的容忍程度,或配合主动失效广播机制来平衡。
  • 多副本打散后忘记同步更新所有副本------数据更新时如果只删除或更新了主 key,遗漏了某些影子副本,会导致部分用户读到新数据、部分用户读到旧数据,出现难以复现的"薛定谔"式数据不一致问题。
  • 把热点Key探测当作一次性工作------大促活动的爆款商品每次可能不同,热点Key的分布会随业务动态变化,需要建立常态化的探测和告警机制,而不是只在出问题后临时排查一次。

正确的核心认知:热点Key问题的本质是集群路由机制下的单点过载,而不是整体容量不足,解决思路要围绕"让压力不必到达那个单点"来设计------无论是提前在本地拦截,还是把压力人为打散到多个 key,核心都是不要让全部流量都压在同一个路由目标上。

相关推荐
geovindu1 小时前
go: Task Scheduler
开发语言·后端·golang
IT枫斗者枫哥2 小时前
Java 接口超时后,重试为什么会多建一条任务?
java
张三博客2 小时前
Java 开发实用工具类合集:从日期处理到并发控制
后端
用户970161501682 小时前
微服务里最危险的 DELETE,不是删不掉,是只删了一半
人工智能·后端
用户3126874877202 小时前
CAS 到底是怎么保证原子性的?从 Unsafe 到 VarHandle 的演进
java
Zane19942 小时前
线上突然OOM,你的排查顺序是先看日志还是先重启
java·后端
西门老铁2 小时前
UUID 还是雪花 ID?分布式唯一 ID 方案怎么选?
后端
吃饱了得干活2 小时前
Java设计模式实战:一个支付模块的重构之旅,层层递进理解设计模式精髓
后端·设计模式·架构