电商相关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 会:
- 立即返回旧值 (不阻塞用户请求);
- 派生一个异步任务 去执行 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);
});
- 注意内存占用虽然我们只缓存 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));
}
}
- 灰度发布引入 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 是本地缓存,不能跨服务器共享,也不能保证数据一致性。
- 缓存读取顺序(毋庸置疑)读取时必须遵循从快到慢、从近到远的顺序,以保证性能最大化:
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,并可能造成不必要的数据竞争

浙公网安备 33010602011771号