门户服务缓存架构优化:从分级缓存到双缓存,彻底解决毛刺现象与一致性问题
一、背景与挑战
在门户服务这类高并发读多写少的场景中,缓存是提升性能的核心手段。通常采用本地缓存(如 Caffeine/Guava)+ 分布式缓存(Redis) 的多级缓存架构,既能利用本地缓存的纳秒级响应速度,又能通过 Redis 实现跨节点数据共享。
然而在实际落地中,我们遇到了两个典型痛点:
- 毛刺现象:本地缓存设置固定过期时间,当大量 Key 同时失效时,请求会穿透到 Redis,跨网络 IO 导致响应时间瞬时飙高(从 1ms 涨到 20ms+),形成毛刺。
- 一致性问题:本地缓存与 Redis、Redis 与 DB 之间的数据如何保持最终一致,避免脏读。
本文将分享一套经过生产验证的优化方案:双缓存机制 + 异步定时刷新 + 延迟双删 + MQ 兜底,彻底解决上述问题。
二、分级缓存基础架构
先回顾经典的分级缓存读写流程:
命中
未命中
命中
未命中
客户端请求
本地缓存命中?
返回数据
Redis 命中?
回填本地缓存
查询数据库
回填 Redis
优点 :本地缓存扛住绝大部分读请求,Redis 作为二级缓存和数据同步中心。
缺点:本地缓存若设置固定 TTL,到期瞬间产生大量 Redis 请求,造成毛刺。
三、毛刺现象分析与双缓存解决方案
3.1 毛刺成因
假设本地缓存 TTL = 60 秒,Redis 响应时间约 0.5~2ms。当 1000 个 Key 在同一秒失效,这 1000 个请求会同时穿透到 Redis,造成该秒平均 RT 从 0.2ms 暴涨到 20ms+,形成"锯齿"状毛刺。
3.2 常见解法与不足
- TTL 随机偏移:在基准值上加减随机秒数,打散过期时间,但只能缓解,无法根治。
- 主动刷新:通过后台线程提前刷新即将过期的 Key,实现较复杂。
3.3 双缓存机制(核心优化)
核心思想 :将本地缓存拆分为主缓存 和备份缓存,主缓存永不过期(依靠异步定时任务更新),备份缓存设置较短的空闲过期时间(expireAfterAccess),用于兜底突发穿透。
3.3.1 数据结构设计
| 缓存层 | 实现 | 过期策略 | 作用 |
|---|---|---|---|
| 主缓存 (L1) | Caffeine | 无固定过期(expireAfterWrite 不设),但会被定时任务覆盖更新 | 承载绝大部分读请求,永不过期避免批量失效 |
| 备份缓存 (L2) | Caffeine | expireAfterAccess(5min) -- 5分钟无访问则淘汰 | 当主缓存因故缺失时(如重启或定时任务未覆盖),临时从 Redis 加载并放入备份,避免直接穿透到 Redis |
| Redis | Redis | 业务 TTL(如 1 小时) | 集中式缓存,跨节点共享 |
| DB | MySQL | -- | 最终数据源 |
3.3.2 读取流程
命中
未命中
命中
未命中
命中
未命中
读请求
获取主缓存
返回
获取备份缓存
直接返回
不填充主缓存
从 Redis 读取
填充备份缓存
查询数据库
填充 Redis
填充备份缓存
关键点:
- 主缓存缺失时不立即回填,而是先查备份缓存,避免主缓存被大量临时数据污染。
- 备份缓存若命中,直接返回,但不会反向填充主缓存,主缓存只由定时任务统一刷新,保证数据权威性。
- 备份缓存采用
expireAfterAccess(空闲过期),热点数据自动保留,冷数据自动淘汰。
3.3.3 为什么能消除毛刺?
- 主缓存永不过期,不会出现集体失效事件。
- 定时任务每隔 1 分钟从 Redis 拉取最新数据,覆盖更新主缓存(后面详述)。
- 即使定时任务尚未更新某个 Key,且主缓存因重启等原因为空,请求也会走备份缓存(备份缓存可能存储的是稍旧但可用的数据),避免瞬间大量请求压迫 Redis。
四、本地缓存与 Redis 的一致性保证
4.1 异步定时刷新机制
由于主缓存永不过期,我们必须通过后台任务主动保持它与 Redis 一致。策略如下:
业务读取
是
否
读主缓存
是否存在?
返回
走备份或 Redis 逻辑
定时任务 (每60秒)
扫描需要预热的热点 Key 列表
批量从 Redis 读取最新数据
更新 Caffeine 主缓存
具体实现:
- 维护一个热点 Key 集合(可静态配置,也可动态统计访问频率)。
- 定时任务每 60 秒执行一次,遍历这些 Key,从 Redis 获取最新值,调用
Caffeine.put()刷新主缓存。 - 若某个 Key 在 Redis 中已不存在(被删除),则同步从主缓存中移除。
为什么能保证最终一致 :
本地缓存与 Redis 的最大不一致窗口 = 定时任务间隔(1 分钟)。对于门户服务这种对一致性要求不苛刻(允许秒级延迟)的场景,完全可接受。
4.2 兜底查询
当主缓存和备份缓存均无数据时,请求会回源到 Redis。此时 Redis 若存在数据,则填充备份缓存(不填充主缓存),保证后续请求不再穿透。
五、Redis 与 DB 的一致性保证
5.1 标准策略:先更新 DB,再删除缓存
Redis DB App Redis DB App 1. 更新数据 更新成功 2. 删除 Key 删除成功
为什么是删除而不是更新?
更新缓存可能引发并发竞争(如线程 A 更新 DB 后准备写缓存,线程 B 又改了 DB,导致缓存脏数据)。删除缓存让下次读请求自然重建,逻辑更简单。
5.2 失败兜底方案
删除缓存操作可能因网络、Redis 故障等原因失败。我们采用三级兜底:
- 过期时间兜底:Redis 中的 Key 虽然会被删除,但若删除失败,依赖其业务 TTL(如 1 小时)自动失效,保证最终一致。
- 延迟双删:在更新 DB 后先删一次缓存,休眠一小段时间(例如 500ms),再删一次。用于解决"读请求在第一次删除后、第二次删除前将旧数据重新载入缓存"的极端情况。
- MQ 异步重试:将删除失败的任务发送到消息队列(RocketMQ/RabbitMQ),由消费者进行重试删除,直到成功。
成功
失败
成功
重试多次仍失败
失败也可选择]
更新 DB
删除 Redis 缓存
结束
发送 MQ 延迟消息
MQ 消费者重试删除
记录失败日志 + 人工介入
延迟双删:sleep 500ms 后再次删除
生产推荐组合:
- 常规场景:
更新DB → 删除缓存,借助 Redis TTL 兜底。 - 高一致性场景:
更新DB → 删除缓存(同步) → 发送MQ(异步补偿)+ 延迟双删(可选)。
六、完整代码示例(Spring Boot + Caffeine + Redis)
6.1 Maven 依赖
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6.2 缓存配置类
java
@Configuration
public class CacheConfig {
// 主缓存:永不过期,最大1万条,弱引用key
@Bean
public Cache<String, Object> primaryCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();
}
// 备份缓存:空闲5分钟过期,最大5000条
@Bean
public Cache<String, Object> backupCache() {
return Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.maximumSize(5_000)
.recordStats()
.build();
}
}
6.3 服务层读取逻辑
java
@Service
@Slf4j
public class PortalService {
@Autowired
private Cache<String, Object> primaryCache;
@Autowired
private Cache<String, Object> backupCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private PortalMapper portalMapper;
public Object getData(String key) {
// 1. 主缓存命中
Object val = primaryCache.getIfPresent(key);
if (val != null) {
return val;
}
// 2. 备份缓存命中
val = backupCache.getIfPresent(key);
if (val != null) {
log.debug("Backup cache hit, key: {}", key);
return val;
}
// 3. 查 Redis
val = redisTemplate.opsForValue().get(key);
if (val != null) {
log.debug("Redis hit, fill backup cache, key: {}", key);
backupCache.put(key, val);
return val;
}
// 4. 查 DB
val = portalMapper.selectByKey(key);
if (val != null) {
log.debug("DB hit, fill Redis & backup cache, key: {}", key);
redisTemplate.opsForValue().set(key, val, 1, TimeUnit.HOURS);
backupCache.put(key, val);
return val;
}
return null;
}
}
6.4 定时刷新主缓存
java
@Component
@Slf4j
public class CacheRefresher {
@Autowired
private Cache<String, Object> primaryCache;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 每秒执行一次(实际按需调整,示例用固定延迟)
@Scheduled(fixedDelay = 60_000)
public void refreshPrimaryCache() {
// 获取需要预热的 key 列表(可来自配置或动态统计)
List<String> hotKeys = getHotKeyList();
for (String key : hotKeys) {
Object val = redisTemplate.opsForValue().get(key);
if (val != null) {
primaryCache.put(key, val);
} else {
// Redis 中已无该数据,从主缓存中移除
primaryCache.invalidate(key);
}
}
log.info("Primary cache refreshed, size: {}", primaryCache.estimatedSize());
}
private List<String> getHotKeyList() {
// 可返回静态配置的热点 key,或从 Redis 的 ZSET 统计读取
return Arrays.asList("portal:config", "portal:menu", "portal:banner");
}
}
6.5 Redis 与 DB 一致性(写操作)
java
@Transactional
public void updateData(String key, Object newValue) {
// 1. 更新数据库
portalMapper.updateByKey(key, newValue);
// 2. 删除 Redis 缓存
try {
redisTemplate.delete(key);
} catch (Exception e) {
log.error("Delete redis failed, send to MQ, key: {}", key, e);
mqProducer.send(new CacheDeleteMessage(key));
}
// 3. 延迟双删(可选)
CompletableFuture.runAsync(() -> {
try { Thread.sleep(500); } catch (InterruptedException ignored) {}
redisTemplate.delete(key);
});
}
七、详情页场景优化
门户服务的详情页(如文章详情、商品详情)同样适用上述模式,但注意:
- 热点 Key 更集中:详情页通常呈现 28 定律,少数热门详情承载绝大多数流量。建议将详情 ID 加入定时刷新的 hotKeyList。
- 防止缓存击穿 :对于极度热点数据,可在备份缓存基础上,增加
Redis 分布式锁,避免多个线程同时回源 DB。 - 缓存空对象:若 DB 查询结果为 null,可缓存一个特殊空标记(TTL 短一些,如 1 分钟),防止不存在的数据频繁穿透。
八、总结与最佳实践
| 问题 | 解决方案 | 适用场景 |
|---|---|---|
| 本地缓存批量失效导致毛刺 | 双缓存(主缓存永不过期 + 备份空闲过期) + 定时刷新 | 读多写少,允许秒级不一致 |
| 本地与 Redis 一致 | 异步定时任务(1分钟)刷新主缓存 | 热点 Key 集合相对固定 |
| Redis 与 DB 一致 | 先更新 DB 再删缓存 + TTL 兜底 + MQ 补偿 | 所有写场景 |
| 极端情况缓存穿透 | 备份缓存 + Redis 分布式锁 + 空对象缓存 | 高并发详情页 |
最终效果:
- 本地缓存命中率 ≥ 95%,平均 RT < 1ms。
- 无毛刺现象,RT 曲线平滑。
- 数据最终一致窗口≤1分钟(本地与Redis)+ 1小时(Redis与DB兜底)。
这套方案已在日均亿级请求的门户系统中稳定运行,可根据自身业务对一致性和实时性的要求,调整定时刷新间隔和兜底策略。
🔔 提示:若业务要求强一致性(如金融交易),则不应使用本地缓存,应直接走 Redis 或 DB。本文方案适用于内容型、配置型门户。