分类:数据库与中间件 · 作者:管理员 · 发布于 2026-10-05 · 阅读 0 · 标签:Redis / 缓存一致性 / 布隆过滤器 / 多级缓存 / 电商中台

一、痛点:缓存是救星,也是放大器

电商中台是典型的读多写少场景,商品详情、库存、价格都是热点数据。引入 Redis 后,绝大多数请求被挡在缓存层,数据库压力骤降。但缓存设计不当,会把「单次数据库慢查询」放大成「数据库整体宕机」:

  • 穿透:大量查询必然不存在的 key,Redis 永远 miss,请求全部穿透到数据库;
  • 击穿:某个热点 key 恰好过期,一瞬间几千个请求同时回源重建;
  • 雪崩:大量 key 在同一时刻过期,数据库被打出明显的波峰。

下面的代码在一个典型的商品详情服务(app-backend)中落地,技术栈为 Spring Boot 3 + Redisson 3 + Caffeine 3。

二、缓存穿透:布隆过滤器 + 空值兜底

穿透的本质是「查询了一个必然不存在的数据」,比如攻击者循环请求不存在的商品 ID。对策分三层:参数校验直接拒绝非法 ID;布隆过滤器判断 ID 是否「可能存在」;即使查不到也写空值缓存,堵住漏网之鱼。

@Service
public class ProductBloomFilter {
    private static final int EXPECTED = 1_000_000;
    private static final double FPR = 0.01;
    private final BloomFilter<Long> filter =
            BloomFilter.create(Funnels.longFunnel(), EXPECTED, FPR);

    @PostConstruct
    public void init() {
        // 启动时从数据库加载全量商品 ID,新增商品时增量 put
        productDao.scanAllIds().forEach(filter::put);
    }

    public boolean mightContain(Long id) {
        return filter.mightContain(id);
    }
}
public ProductVO getDetail(Long id) {
    if (!bloomFilter.mightContain(id)) {
        return null; // 一定不存在,不碰 Redis 和 DB
    }
    String key = "product:detail:" + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (json != null) return JSON.parseObject(json, ProductVO.class);

    ProductVO vo = productDao.selectById(id);
    if (vo == null) {
        // 空值也缓存 5 分钟,防止恶意 ID 反复穿透
        stringRedisTemplate.opsForValue().set(key, "{}", 5, TimeUnit.MINUTES);
        return null;
    }
    stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES);
    return vo;
}

踩坑:布隆过滤器有假阳性,会放行少量不存在 ID,所以空值缓存必须保留;但它不会漏判已存在的 ID,因此新增商品后要同步 put。过滤器只增不删,商品下架久了误判率会缓慢上升,需要定期重建。

三、缓存击穿:key 级互斥锁 + 双检重建

击穿是「单点热点 key 过期」,秒杀详情页是典型场景。粗暴做法是所有人同时回源,正确做法是只让一个请求回源重建,其余请求等待后重读。

public ProductVO getDetailWithMutex(Long id) {
    String key = "product:detail:" + id;
    String json = stringRedisTemplate.opsForValue().get(key);
    if (json != null) return JSON.parseObject(json, ProductVO.class);

    String lockKey = "product:lock:" + id;
    RLock lock = redissonClient.getLock(lockKey);
    boolean locked = lock.tryLock(100, 5, TimeUnit.SECONDS);
    if (!locked) {
        // 别人正在重建,短暂自旋后重读缓存(实际建议用循环控制次数)
        try { Thread.sleep(50); } catch (InterruptedException ignored) {}
        return getDetailWithMutex(id);
    }
    try {
        // 双检:上一个持锁者可能已经写好
        json = stringRedisTemplate.opsForValue().get(key);
        if (json != null) return JSON.parseObject(json, ProductVO.class);
        ProductVO vo = productDao.selectById(id);
        stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.MINUTES);
        return vo;
    } finally {
        lock.unlock();
    }
}

另一种思路是逻辑过期:缓存永不过期,value 里带 expireTime 字段;读到逻辑过期数据时先返回旧值,再异步加锁重建。适合容忍短暂旧数据的读多场景,秒杀详情页常用。

注意:Redisson 的 tryLock 带看门狗自动续期;锁粒度必须到 key 级别(product:lock:{id}),绝不能用一个全局锁把不相关商品的查询串行化,否则热点越多吞吐越低。

四、缓存雪崩:过期时间打散 + 多级缓存

雪崩的根源是「过期时间过于整齐」。对策有三:

  1. 过期时间加随机值,比如 30 + RandomUtil.randomInt(0, 300) 秒;
  2. 多级缓存:Caffeine 本地缓存挡第一层,Redis 挡第二层,DB 兜底第三层;
  3. Redis 不可用时优雅降级:直接读 DB,并用 Sentinel 限流保护数据库。
@Bean
public Cache<String, ProductVO> localCache() {
    return Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.SECONDS)
            .build();
}

读链路变成:本地缓存 → Redis → DB。本地缓存 10 秒过期、Redis 30 分钟,数据库只有在两层都 miss 时才被打到,回源量级直接下降两个数量级。

踩坑:本地缓存是 JVM 级的,多实例部署时各节点独立缓存,TTL 必须设短;更新商品后要广播失效(Redis Pub/Sub 或 MQ),否则节点间会出现明显的不一致。

五、双写一致性:Cache Aside + 延迟双删 / Canal

缓存和数据库无法强一致,工程目标是把不一致窗口缩到可接受范围。最常用的是 Cache Aside:读 miss 则读库回填;写操作「先更新数据库,再删缓存」。

为什么删缓存而不是更新缓存?并发写场景下更新缓存会产生「旧值覆盖新值」的竞态,删缓存让下一次读回填,天然规避。

但「先更新库再删缓存」存在失败窗口:删缓存失败会让缓存长期持有旧值。兜底方案:

  1. 延迟双删:更新库后删一次缓存,sleep 几百毫秒再删一次,消除「旧请求回填」窗口;
  2. 失败补偿:删除操作丢进 MQ 重试;
  3. 终极方案:Canal 订阅 MySQL binlog,在独立消费者里统一失效缓存,与业务代码完全解耦。
@RabbitListener(queues = "cache-invalid-queue")
public void onProductUpdate(ProductUpdateEvent event) {
    // event 由 Canal 解析 binlog 后发出
    stringRedisTemplate.delete("product:detail:" + event.getProductId());
    localCache.invalidate("product:detail:" + event.getProductId());
    log.info("缓存已失效 productId={}", event.getProductId());
}

binlog 方案把缓存一致性从业务代码中剥离:任何服务更新了商品表都会触发失效,最省心,也最适合中台多服务共享同一张表的场景。

六、踩坑清单

  • 空值缓存不设 TTL,恶意 ID 会把 Redis 内存打满——空值 TTL 必须短;
  • 互斥锁用成全局锁,热点商品互相等待,QPS 反而下降——锁粒度必须到 key;
  • 删除缓存用 del 而不是写入旧值,否则并发写会互相覆盖;
  • 本地缓存 TTL 与 Redis TTL 差距过大,多实例节点间不一致明显;
  • 大 value(超过 100KB)频繁读写会拖垮 Redis 单线程模型——拆 key 或压缩;
  • 缓存命中率下降不代表业务故障,要把 miss 率与 DB QPS 曲线一起监控才能定位问题。

七、最佳实践小结

  1. 穿透:参数校验 + 布隆过滤器 + 短 TTL 空值缓存,三层叠加;
  2. 击穿:key 级互斥锁 + 双检重建,读多写少用逻辑过期;
  3. 雪崩:过期时间随机化 + Caffeine 多级缓存 + 降级限流;
  4. 一致性:Cache Aside 为基线,延迟双删兜底,Canal binlog 订阅做最终一致;
  5. 接受「短暂不一致」,目标是窗口小、可恢复、可监控。