凌晨 2:17,订单履约系统的告警突然炸响。

监控大屏上,MySQL 的 QPS 从平时的 800 飙升至 12000,CPU 使用率突破 95%,大量订单状态更新请求超时。与此同时,Redis 集群的某个分片内存使用率在 30 秒内从 45% 暴涨到 98%,紧接着触发了 OOM 强制淘汰策略,缓存命中率从 99.6% 骤降至 18%。

我们以为是缓存击穿,结果排查后发现,这是一场典型的“热点 Key + 缓存雪崩”复合型灾难。更讽刺的是,问题根源竟是我们自己写的“防雪崩”逻辑——一个看似聪明的本地缓存降级方案,反而成了压垮系统的最后一根稻草。

问题拆解:从现象到误判

故障发生时,用户集中访问一批限时秒杀商品(商品 ID 前缀为 sku_8888),这些商品的库存查询和订单创建请求全部命中同一个 Redis 分片。由于 Redis 单线程特性,该分片瞬间成为热点,处理延迟从 1ms 上升至 80ms。

此时,我们部署在应用层的“缓存降级策略”开始生效:当 Redis 响应超过 50ms,自动切换至本地 Caffeine 缓存。这本意是减轻 Redis 压力,但问题在于——

  • 本地缓存未设置合理的过期时间,导致大量线程同时穿透到数据库;
  • 每个实例独立加载热点数据,形成“缓存雪崩”式的数据库冲击;
  • 数据库连接池被打满,进一步引发线程阻塞,形成恶性循环。

最初我们误判为“缓存击穿”,试图通过加分布式锁缓解,结果锁竞争反而加剧了延迟。直到查看慢查询日志,才发现真正的问题是:热点 Key 触发了错误的降级逻辑,导致缓存雪崩

核心原理:为什么“善意”的降级会适得其反?

1. 热点 Key 的本质

Redis 热点 Key 是指某个 Key 被高频访问,导致其所在分片负载不均。在集群模式下,Key 通过 CRC16 算法分片,若大量请求命中同一分片(如 sku_8888*),就会形成性能瓶颈。

2. 缓存雪崩的触发条件

缓存雪崩通常发生在缓存大规模失效时。但在本案例中,并非 TTL 到期导致,而是主动降级策略错误地将正常请求判定为“异常”,从而绕过 Redis,直接访问数据库。这种“逻辑失效”比物理失效更隐蔽,也更危险。

3. 本地缓存的双刃剑

Caffeine 等本地缓存虽能减少网络开销,但在高并发场景下,若缺乏协调机制,会导致:

  • 多个实例同时加载同一数据,形成“惊群效应”;
  • 本地缓存过期时间不一致,造成数据不一致;
  • 无法感知集群状态,误判 Redis 健康度。

方案实现:从“被动降级”到“主动治理”

第一步:识别热点 Key

我们引入 Redis 的 hotkeys 命令(需开启 redis-cli --hotkeys)和 Prometheus + Grafana 监控,实时追踪 Top 10 热点 Key。同时,在应用层埋点,记录 Key 访问频率。

// 示例:Key 访问频率统计
public void recordKeyAccess(String key) {
    String metricKey = "redis.key.access.count";
    MeterRegistry.counter(metricKey, "key", key).increment();
}
第二步:多级缓存 + 一致性保障

放弃“Redis 超时即降级”的策略,改为:

  • 第一层:本地缓存(Caffeine),设置短 TTL(如 100ms),仅用于抗瞬时高峰;
  • 第二层:Redis 集群,作为主缓存,设置合理 TTL 和随机抖动;
  • 第三层:数据库,作为最终数据源。

关键改进:本地缓存不主动加载,仅在 Redis 未命中时,由单个线程加载并广播至其他实例(通过 Redis PUB/SUB 或 MQ)。

@Cacheable(value = "sku_stock", key = "#skuId", sync = true)
public Integer getStock(String skuId) {
    // 同步加载,避免缓存击穿
    return jdbcTemplate.queryForObject(
        "SELECT stock FROM sku WHERE id = ?", Integer.class, skuId
    );
}
第三步:热点 Key 动态分片

对于已知热点商品(如秒杀品),采用“Key 分片”策略:

原始 Key: sku_8888_stock
分片后: sku_8888_stock_0, sku_8888_stock_1, ..., sku_8888_stock_9

应用层通过哈希取模决定访问哪个分片,将压力分散到多个 Redis 节点。

public String getShardedKey(String baseKey, int shardCount) {
    int hash = Math.abs(baseKey.hashCode());
    int shard = hash % shardCount;
    return baseKey + "_" + shard;
}
第四步:熔断与降级策略优化

引入 Resilience4j 实现熔断机制:

  • 当 Redis 错误率 > 30% 或延迟 > 100ms 持续 10 秒,触发熔断;
  • 熔断期间,不降级到本地缓存,而是返回兜底值(如“库存紧张”);
  • 恢复期采用半开状态,逐步试探 Redis 健康度。
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
    .failureRateThreshold(30)
    .waitDurationInOpenState(Duration.ofSeconds(30))
    .slidingWindowType(SlidingWindowType.COUNT_BASED)
    .slidingWindowSize(10)
    .build();

CircuitBreaker circuitBreaker = CircuitBreaker.of("redis-circuit-breaker", config);

指标验证:从 12000 QPS 到 稳定 800

优化后,我们进行了一次压测:

| 指标 | 故障前 | 优化后 | |------|--------|--------| | MySQL QPS | 12000 | 800 | | Redis 延迟(P99) | 80ms | 3ms | | 缓存命中率 | 18% | 99.2% | | 订单创建成功率 | 62% | 99.8% |

更重要的是,系统具备了应对突发热点的能力。当再次出现类似 sku_9999 的爆款时,流量被均匀分散,未再出现单分片瓶颈。

技术补丁包

  1. 热点 Key 识别与监控 原理:通过 Redis hotkeys 命令或客户端埋点统计 Key 访问频率,结合 Prometheus 实现可视化监控。 设计动机:提前发现潜在性能瓶颈,避免突发流量冲击。 边界条件:hotkeys 命令在高负载下可能影响性能,建议在低峰期执行;客户端埋点需控制采样率。 落地建议:在生产环境部署 Key 访问频率监控面板,设置阈值告警。

  2. 多级缓存一致性保障 原理:本地缓存作为 Redis 的补充,采用“同步加载 + 广播通知”机制,避免多实例重复穿透。 设计动机:在降低 Redis 压力的同时,防止数据库被击穿。 边界条件:本地缓存 TTL 不宜过长,否则可能导致数据不一致;广播机制需考虑网络延迟。 落地建议:使用 Caffeine 的 sync=true 参数,或通过 Redis PUB/SUB 实现缓存失效通知。

  3. 热点 Key 动态分片 原理:将单一热点 Key 拆分为多个子 Key,通过哈希取模分散到不同 Redis 节点。 设计动机:解决 Redis 单分片性能瓶颈,提升整体吞吐量。 边界条件:分片数量需根据集群规模合理设置;数据读取需聚合多个分片结果。 落地建议:对已知热点商品(如秒杀品)预生成分片 Key,并在代码中封装分片逻辑。

  4. 熔断降级策略优化 原理:基于错误率和延迟动态判断 Redis 健康状态,熔断期间返回兜底值而非降级到本地缓存。 设计动机:防止“善意降级”引发雪崩,保障系统整体可用性。 边界条件:熔断阈值需根据业务容忍度调整;恢复期需逐步试探,避免瞬间流量冲击。 落地建议:使用 Resilience4j 或 Sentinel 实现熔断器,结合业务场景配置合理参数。

  5. 缓存 TTL 随机化 原理:为缓存设置基础 TTL 加上随机抖动(如 ±10%),避免大量 Key 同时失效。 设计动机:防止缓存雪崩,尤其是在定时任务或批量更新场景。 边界条件:随机范围不宜过大,否则可能影响缓存命中率。 落地建议:在设置缓存时,使用 baseTtl + random.nextInt(jitter) 计算实际过期时间。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐