电商相关Redis+Caffeine缓存

前提概要,在安居链项目中
有些商品信息需要缓存,
如果数据是图片、视频或静态 JSON 等内容,将其推送到 CDN(内容分发网络),让用户从离自己最近的边缘节点获取,完全绕过自己的应用和数据库。
比如商品名称,规格,标签等等,常用redis缓存即可解决

但是如果信息需要提供给上游服务商获取,那么数据量是 400 万条商品数据,每条平均 1.3KB,总共 6GB 数据。
放在 3 主 3 从的 Redis Cluster 即可解决问题,但是每次批量查询1M数据,QPS 峰值是 10000,需要的网络带宽就是 10000 × 1MB = 10GB/s
补充一下redis集群架构
1.主从复制 (主写从读) bgsave+offset增量更新
无法保证系统的高可用
2.哨兵模式
心跳检测,选举机制:优先级->offer大小->兜底id
3.集群模式 3主3从
分片集群
但是Redis Cluster(3 主 3 从)的网络容量呢?万兆网卡理论速度是 1.28GB/s,只有 3 个主节点在接收请求,总容量就是 3 × 1.28 = 3.84GB/s。
注意有热点数据,根据日志分析/redis-cli --hotkeys/SkyWalking、Zipkin、New Relic 等。
方案一 redis扩容
方案二 使用高性能的本地缓存框架,如 Caffeine 或 Guava Cache
缓存策略: 针对热点数据设置较短的过期时间(例如 5 秒或 10 秒)。
那么其实热点数据只占有60MB,引入Caffeine,查询热点数据,如果不存在则去找redis,如果不存在最后走DB
使用8-12 秒的随机过期时间,减少过期埋点同步操作,还有缓存雪崩问题

CaffeineConfig配置

@Configuration
publicclass 
CaffeineConfig{

    @Bean
    public Cache<String, Poi> poiCache() {
        Random random = new Random();
        int expireSeconds = random.nextInt(5) + 8; // 8-12秒随机
      
        return Caffeine.newBuilder()
                // 最大缓存 5 万条
                .maximumSize(50_000)
              
                // 写入后 8-12 秒过期
                .expireAfterWrite(expireSeconds, TimeUnit.SECONDS)
              
                // 开启统计
                .recordStats()
              
                // 移除监听器(可选)
                .removalListener((key, value, cause) -> {
                    log.debug("缓存移除:key={}, cause={}", key, cause);
                })
              
                .build();
    }
}

缓存读取,首先走本地缓存,然后再走Redis,最后走db
注意点,走完db,先写到本地缓存,然后再写到Redis

@Service
publicclass PoiService {

    @Autowired
    private Cache<String, Poi> cache;

    @Autowired
    private RedisTemplate<String, Poi> redisTemplate;

    @Autowired
    private PoiMapper poiMapper;

    public Poi getPoi(String poiId) {
        // 第一层:Caffeine 本地缓存
        Poi poi = cache.getIfPresent(poiId);
        if (poi != null) {
            log.debug("命中 Caffeine 缓存:{}", poiId);
            return poi;
        }
      
        // 第二层:Redis 缓存
        String redisKey = "poi:" + poiId;
        poi = redisTemplate.opsForValue().get(redisKey);
        if (poi != null) {
            log.debug("命中 Redis 缓存:{}", poiId);
            // 回写到 Caffeine
            cache.put(poiId, poi);
            return poi;
        }
      
        // 第三层:数据库
        poi = poiMapper.selectById(poiId);
        if (poi != null) {
            log.debug("查询数据库:{}", poiId);
            // 回写到 Redis 和 Caffeine
            redisTemplate.opsForValue().set(redisKey, poi, 1, TimeUnit.HOURS);
            cache.put(poiId, poi);
        }
      
        return poi;
    }

    // 批量查询(上游系统常用)
    public List<Poi> batchGetPoi(List<String> poiIds) {
        List<Poi> result = new ArrayList<>();
        List<String> missIds = new ArrayList<>();
      
        // 先从 Caffeine 批量获取
        for (String poiId : poiIds) {
            Poi poi = cache.getIfPresent(poiId);
            if (poi != null) {
                result.add(poi);
            } else {
                missIds.add(poiId);
            }
        }
      
        if (missIds.isEmpty()) {
            return result;
        }
      
        // 未命中的从 Redis 批量获取
        List<Poi> redisPois = redisTemplate.opsForValue().multiGet(
            missIds.stream().map(id -> "poi:" + id).collect(Collectors.toList())
        );
      
        // 回写到 Caffeine
        for (Poi poi : redisPois) {
            if (poi != null) {
                result.add(poi);
                cache.put(poi.getId(), poi);
            }
        }
      
        return result;
    }
}

添加Caffeine缓存监控

@Component
publicclass CacheMonitor {

    @Autowired
    private Cache<String, Poi> cache;

    @Scheduled(fixedRate = 60000) // 每分钟统计一次
    public void monitorCache() {
        CacheStats stats = cache.stats();
      
        log.info("Caffeine 缓存统计:" +
                "命中率={}, " +
                "命中次数={}, " +
                "未命中次数={}, " +
                "加载成功次数={}, " +
                "加载失败次数={}, " +
                "驱逐次数={}",
                String.format("%.2f%%", stats.hitRate() * 100),
                stats.hitCount(),
                stats.missCount(),
                stats.loadSuccessCount(),
                stats.loadFailureCount(),
                stats.evictionCount()
        );
      
        // 命中率过低告警
        if (stats.hitRate() < 0.8) {
            log.warn("⚠️ Caffeine 缓存命中率过低:{}", stats.hitRate());
        }
    }
}

注意部分问题
缓存击穿 (Cache Breakdown) 在 getPoi 中,
如果一个 热点 Key 恰好在 Redis 中过期,导致大量并发请求同时穿透到数据库,数据库可能瞬间崩溃。

加锁: 在查询数据库前,使用 分布式锁(如 Redis SETNX 或 Redisson)对该 poiId 加锁,
确保只有一个线程去查询数据库和回写缓存。

批量查询未击穿 batchGetPoi 方法中,如果 Redis 未命中,数据查询流程就断了,没有继续查询 poiMapper。 补全 L3 查询: 应该对最终未命中的 ID 列表调用

poiMapper.batchSelectByIds(finalMissIds),然后将结果回写到 L2/L1,再返回。
批量查询击穿 如果 batchGetPoi 中的大量 missIds 同时穿透到 DB,会触发 DB 的 大批量查询压力。
批量查询加锁: 对批量查询的 Key 列表进行去重和加锁处理,或在 Mapper 层使用 where id in (...) 减少数据库 IO。

代码冗余 getPoi 中有大量逻辑(Redis Key 构造、回写逻辑)与 batchGetPoi 的循环逻辑相似。 考虑引入 CacheLoader 接口或使用 Caffeine 的 get(key, k -> load(k)) 方法,将加载逻辑统一封装,提高代码可读性。

// 批量查询(上游系统常用)如果请求多,会导致redis数据全部写到Caffeine

Caffeine 是本地缓存,数据更新后需要等待过期才能生效,所以只适合对实时性要求不高的场景。可接受的延迟: 秒级或分钟级不适合的场景: 强一致性要求(如金融交易、库存扣减、订单状态)

配合Caffeine告警,做降级开关,注意执行的AOP代理,底层要操作原值bean对象才能修改属性
服务降级方案,万一 Caffeine 出问题了(比如命中率突然下降、内存占用过高),要能快速切回 Redis:

@Service
publicclass PoiService {

    @Value("${cache.caffeine.enabled:true}")
    privateboolean caffeineEnabled;

    public Poi getPoi(String poiId) {
        try {
            // 如果 Caffeine 开关关闭,直接查 Redis
            if (!caffeineEnabled) {
                return getFromRedis(poiId);
            }
          
            // 先查 Caffeine
            Poi poi = cache.getIfPresent(poiId);
            if (poi != null) {
                return poi;
            }
          
            // 再查 Redis
            poi = getFromRedis(poiId);
            if (poi != null) {
                cache.put(poiId, poi);
            }
            return poi;
          
        } catch (Exception e) {
            log.error("Caffeine 查询失败,降级到 Redis", e);
            // 降级:直接查 Redis
            return getFromRedis(poiId);
        }
    }

    private Poi getFromRedis(String poiId) {
        String redisKey = "poi:" + poiId;
        Poi poi = redisTemplate.opsForValue().get(redisKey);
        if (poi == null) {
            poi = poiMapper.selectById(poiId);
            if (poi != null) {
                redisTemplate.opsForValue().set(redisKey, poi, 1, TimeUnit.HOURS);
            }
        }
        return poi;
    }
}

缓存穿透问题

private staticfinal Poi NULL_POI = new Poi(); // 空对象标记

public Poi getPoi(String poiId) {
    // 从 Caffeine 获取
    Poi poi = cache.getIfPresent(poiId);
    if (poi == NULL_POI) {
        returnnull; // 之前查过,数据不存在
    }
    if (poi != null) {
        return poi;
    }

    // 从 Redis 获取
    poi = getFromRedis(poiId);
    if (poi == null) {
        // 数据不存在,缓存空对象
        cache.put(poiId, NULL_POI);
        returnnull;
    }

    cache.put(poiId, poi);
    return poi;
}

缓存击穿问题
核心机制 - 异步刷新:Key 写入缓存 8 秒后,如果再次被访问,Caffeine 会:

  1. 立即返回旧值 (不阻塞用户请求);
  2. 派生一个异步任务 去执行 build 方法(即 getFromRedis(key)),获取最新值并替换缓存。
LoadingCache<String, Poi> cache = Caffeine.newBuilder()
    .maximumSize(50_000)
    .expireAfterWrite(10, TimeUnit.SECONDS)
    // 8秒后异步刷新,不会阻塞请求
    .refreshAfterWrite(8, TimeUnit.SECONDS)
    .build(key -> {
        // 从 Redis 加载数据
        return getFromRedis(key);
    });
  1. 注意内存占用虽然我们只缓存 60MB 数据,
    但还是要定期检查内存占用:@Scheduled(fixedRate = 300000) // 每5分钟检查一次
public void checkMemory() {
    Runtime runtime = Runtime.getRuntime();
    long totalMemory = runtime.totalMemory();
    long freeMemory = runtime.freeMemory();
    long usedMemory = totalMemory - freeMemory;

    double usedPercent = (double) usedMemory / totalMemory * 100;

    log.info("内存使用情况:总内存={}MB, 已用={}MB, 使用率={}%",
            totalMemory / 1024 / 1024,
            usedMemory / 1024 / 1024,
            String.format("%.2f", usedPercent));

    // 内存使用率超过 80% 告警
    if (usedPercent > 80) {
        log.warn("⚠️ 内存使用率过高:{}%", String.format("%.2f", usedPercent));
    }
}
  1. 灰度发布引入 Caffeine 是个大改动,建议灰度发布:第一步: 先在 1-2 台服务器上开启 Caffeine,观察效果。第二步: 如果效果好,逐步扩大到 50% 的服务器。第三步: 最后全量上线。# 通过配置中心控制
cache:
  caffeine:
    enabled: true
    gray-ratio: 0.1  # 10% 的流量走 Caffeine
@Service
publicclass PoiService {

    @Value("${cache.caffeine.gray-ratio:0}")
    privatedouble grayRatio;

    public Poi getPoi(String poiId) {
        // 根据灰度比例决定是否使用 Caffeine
        boolean useCaffeine = Math.random() < grayRatio;
      
        if (useCaffeine) {
            return getFromCaffeine(poiId);
        } else {
            return getFromRedis(poiId);
        }
    }
}

Caffeine 有 maximumSize 配置,超过这个大小会自动淘汰冷数据。而且使用的是 W-TinyLFU 算法,会优先淘汰访问频率低的数据。只要合理设置 maximumSize,不会出现内存溢出。

但是需要注意
Caffeine 是内存访问,纳秒级响应,比 Redis 快 1000 倍。但 Caffeine 是本地缓存,不能跨服务器共享,也不能保证数据一致性。

  1. 缓存读取顺序(毋庸置疑)读取时必须遵循从快到慢、从近到远的顺序,以保证性能最大化:
    Client->Caffeine->Redis->Database

缓存回写(数据写入缓存)的最佳顺序当数据从 L3 (DB) 加载上来时,回写的顺序决定了数据在整个集群中的可见性和延迟。
在 两级缓存 架构中,最健壮、最能发挥各层优势的回写顺序是:
方案一:DB->Redis->Caffeine (主流且推荐)
先保证集群节点数据共享,再保证本地速度

方案二:DB->Caffeine->Redis
主要风险:
如果服务器 A 刚把数据写入自己的 Caffeine(L1),但此时服务器 B 发起了对同一 Key 的查询:
B 的 L1 不命中。
B 查询 Redis (L2),发现数据仍为空。
B 穿透到 DB,查询并获取数据。
数据冗余和脏数据: 此时,A 和 B 都可能持有这份数据的副本,
导致两次回写 Redis,并可能造成不必要的数据竞争

posted @ 2025-11-27 09:12  8023渡劫  阅读(14)  评论(0)    收藏  举报