找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

667

积分

1

好友

83

主题
发表于 14 小时前 | 查看: 3| 回复: 0

先回顾一个典型的业务场景。在一个促销活动中,商品详情页使用了Redis进行缓存加速,核心代码如下:

@Service
public class ProductService {
    @Autowired
    private RedisTemplate<String, Product> redisTemplate;
    @Autowired
    private ProductMapper productMapper;

    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        // 查缓存
        Product product = redisTemplate.opsForValue().get(key);
        if (product != null) {
            return product;
        }
        // 缓存没有,查数据库
        product = productMapper.selectById(productId);
        // 放入缓存,过期时间1小时
        redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
        return product;
    }
}

问题现象

  • 促销活动开始,瞬时10万用户涌入访问商品详情页。
  • 由于缓存过期时间统一设置为1小时,导致大量缓存键(Key)在同一时刻失效。
  • 海量请求瞬间穿透缓存,直接击穿到数据库,导致数据库CPU使用率飙升至100%。
  • 服务响应时间从平均50ms恶化到5秒以上,出现大量超时错误。

初步排查思路

  1. 查看Redis监控,发现缓存命中率从95%骤降至10%。
  2. 查看数据库监控,QPS从平时100激增到10000。
  3. 应用日志中出现大量RedisConnectionFailureException
  4. 网络层面检查,Redis的Ping延迟正常。

初步判断:这看起来是典型的缓存雪崩,当时认为简单地给缓存过期时间加上随机值就能解决。

首次尝试:引入随机过期时间

第一次优化,我为缓存过期时间添加了随机偏移量,避免同时失效。

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;
    }
    product = productMapper.selectById(productId);
    // 加随机过期时间,避免同时过期
    int randomTime = 3600 + new Random().nextInt(600); // 3600-4200秒
    redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
    return product;
}

结果:缓存雪崩现象得到一定缓解,但数据库依然承受着巨大压力,频繁告警。

深入排查:揭开复合问题的面纱

单一的“加随机数”方案未能根治问题,促使我们进行更深入的排查。

第一步:分析Redis缓存命中率 通过Redis命令行工具查看核心指标,发现命中率极低。

$ redis-cli info stats | grep keyspace_hits
keyspace_hits:1000
$ redis-cli info stats | grep keyspace_misses
keyspace_misses:9000
// 命中率 = 1000 / (1000 + 9000) = 10%

第二步:分析Key分布 使用--hotkeys命令试图找出热点Key,并使用keys命令(生产环境慎用)分析特定模式的Key数量,发现可能存在大量对不存在的商品ID的查询。

第三步:分析数据库慢查询 检查MySQL慢查询日志,发现了大量查询不存在的商品ID的语句,例如:

SELECT * FROM product WHERE id = 99999; -- 0 rows

真相浮出水面

  1. 缓存穿透:大量请求查询数据库中根本不存在的数据(如无效商品ID),缓存无法命中,请求直达数据库。
  2. 缓存雪崩:大量Key同时过期,请求并发查询数据库。
  3. 热点Key失效:热点商品的缓存过期后,瞬间收到海量查询请求。

缓存穿透、雪崩、热点失效的三重打击下,数据库最终不堪重负。

根源分析:不完善的缓存设计

回头审视最初的代码,发现了几个关键的设计缺陷:

public Product getProduct(Long productId) {
    ...
    product = productMapper.selectById(productId);
    // 问题1:无论商品是否存在,都进行缓存
    // 问题2:对于不存在的商品,也设置了长达1小时的缓存,且缓存的是null
    redisTemplate.opsForValue().set(key, product, 1, TimeUnit.HOURS);
    return product;
}

主要问题包括:无防穿透机制、未使用布隆过滤器、缓存过期时间策略单一、未合理缓存空值。

综合治理方案

方案1:缓存空值(防御穿透)

对数据库中不存在的数据,也进行短时间缓存,避免反复穿透。

public Product getProduct(Long productId) {
    String key = "product:" + productId;
    Product product = redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;
    }
    product = productMapper.selectById(productId);
    if (product != null) {
        // 商品存在,缓存1小时(加随机时间)
        int randomTime = 3600 + new Random().nextInt(600);
        redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
    } else {
        // 商品不存在,缓存一个空对象5分钟,拦截后续穿透请求
        redisTemplate.opsForValue().set(key, new Product(), 300, TimeUnit.SECONDS);
    }
    return product;
}
方案2:引入布隆过滤器(终极防穿透)

在查询缓存和数据库前,先用布隆过滤器判断数据是否存在,可以高效拦截大量无效请求。在使用Java和Spring Boot构建后端服务时,引入此类组件需注意初始化时机。

@Service
public class ProductService {
    private BloomFilter<Long> productBloomFilter;

    @PostConstruct
    public void initBloomFilter() {
        // 初始化布隆过滤器,预计100万商品,误判率1%
        productBloomFilter = BloomFilter.create(Funnels.longFunnel(), 1000000, 0.01);
        // 预热:加载所有已存在的商品ID
        List<Long> allProductIds = productMapper.selectAllIds();
        for (Long id : allProductIds) {
            productBloomFilter.put(id);
        }
    }

    public Product getProduct(Long productId) {
        // 先过布隆过滤器
        if (!productBloomFilter.mightContain(productId)) {
            return null; // 大概率不存在,直接返回
        }
        // ... 后续缓存查询逻辑
    }
}

依赖:

<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.1-jre</version>
</dependency>
方案3:构建多级缓存(缓解雪崩)

引入本地缓存(如Caffeine)作为一级缓存,Redis作为二级缓存。即使Redis集群出现波动或Key大面积失效,本地缓存也能抵挡大部分请求,为系统提供缓冲。

@Service
public class ProductService {
    @Autowired
    private Cache<String, Product> localCache; // Caffeine本地缓存

    public Product getProduct(Long productId) {
        String key = "product:" + productId;
        // 1. 查本地缓存
        Product product = localCache.getIfPresent(key);
        if (product != null) return product;
        // 2. 查Redis缓存
        product = redisTemplate.opsForValue().get(key);
        if (product != null) {
            localCache.put(key, product); // 回填本地缓存
            return product;
        }
        // 3. 查数据库并回填两级缓存...
        return product;
    }
}

本地缓存配置示例:

@Bean
public Cache<String, Product> localCache() {
    return Caffeine.newBuilder()
            .maximumSize(1000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();
}
方案4:熔断降级(保障最终可用性)

当数据库压力过大或响应超时时,启动熔断机制,快速失败并返回降级数据(如默认商品、友好提示),保护下游数据库和中间件不被拖垮。

public Product getProduct(Long productId) {
    CircuitBreaker circuitBreaker = circuitBreakerFactory.create("product");
    return circuitBreaker.run(() -> {
        // 正常业务逻辑
        return getProductFromCache(productId);
    }, throwable -> {
        // 降级逻辑
        return new Product(); // 返回降级/默认商品
    });
}

熔断器配置(Resilience4j示例):

resilience4j:
  circuitbreaker:
    instances:
      product:
        failureRateThreshold: 60
        waitDurationInOpenState: 10000ms
        ringBufferSizeInClosedState: 100

监控与告警闭环

建立完善的监控体系至关重要,除了监控缓存命中率,还需关注数据库和中间件如Redis的连接数、内存使用率等。

缓存命中率监控示例

@Component
public class CacheMetrics {
    @Scheduled(fixedRate = 60000)
    public void reportCacheMetrics() {
        // 获取Redis info stats信息,计算命中率
        // 命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)
        // 可将此数据上报至监控系统(如Prometheus)
    }
}

Prometheus告警规则示例

groups:
  - name: cache
    rules:
      - alert: CacheHitRateLow
        expr: cache_hit_rate < 0.5
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: “缓存命中率低于50%”

总结与实践模板

排查四步法

  1. 看命中率redis-cli info stats,命中率低于50%需警惕。
  2. 看Key分布:分析热点Key和大Key(生产环境慎用keys *,建议用scan)。
  3. 看数据库:检查慢查询日志,识别异常查询模式。
  4. 看系统资源:监控Redis、数据库的连接数、CPU、网络IO。

防御组合拳(按优先级)

  • 必须做:缓存空值 + 随机过期时间 + 布隆过滤器。
  • 建议做:多级本地缓存 + 熔断降级 + 请求限流。
  • 可选做:缓存预热 + 热点Key探测与分离。

终极版缓存模板

public Product getProduct(Long productId) {
    // 1. 布隆过滤器拦截
    if (!bloomFilter.mightContain(productId)) return null;
    String key = "product:" + productId;
    // 2. 本地缓存
    Product product = localCache.getIfPresent(key);
    if (product != null) return product;
    // 3. Redis缓存
    product = redisTemplate.opsForValue().get(key);
    if (product != null) {
        localCache.put(key, product);
        return product;
    }
    // 4. 数据库查询(带熔断保护)
    try {
        product = circuitBreaker.run(() -> productMapper.selectById(productId));
        // 5. 回填缓存
        if (product != null) {
            int randomTime = 3600 + new Random().nextInt(600);
            redisTemplate.opsForValue().set(key, product, randomTime, TimeUnit.SECONDS);
            localCache.put(key, product);
        } else {
            redisTemplate.opsForValue().set(key, new Product(), 300, TimeUnit.SECONDS);
        }
        return product;
    } catch (Exception e) {
        // 降级逻辑
        return new Product();
    }
}

缓存问题往往是“穿透、雪崩、击穿”的复合体。抓住 “缓存空值、随机过期、布隆过滤” 这三个核心点,能解决绝大部分问题。剩余的极端场景,则由 “多级缓存、熔断降级” 体系来兜底,从而构建起一个健壮、高性能的缓存系统。




上一篇:大模型框架深度评测:Megatron、vLLM与RLHF框架的选型与实践指南
下一篇:Nexus YUM代理仓库配置实战:企业级网络仓库自动同步与管理指南
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-10 20:07 , Processed in 0.091127 second(s), 37 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表