门户服务缓存架构优化:从分级缓存到双缓存,彻底解决毛刺现象与一致性问题

门户服务缓存架构优化:从分级缓存到双缓存,彻底解决毛刺现象与一致性问题

一、背景与挑战

在门户服务这类高并发读多写少的场景中,缓存是提升性能的核心手段。通常采用本地缓存(如 Caffeine/Guava)+ 分布式缓存(Redis) 的多级缓存架构,既能利用本地缓存的纳秒级响应速度,又能通过 Redis 实现跨节点数据共享。

然而在实际落地中,我们遇到了两个典型痛点:

  1. 毛刺现象:本地缓存设置固定过期时间,当大量 Key 同时失效时,请求会穿透到 Redis,跨网络 IO 导致响应时间瞬时飙高(从 1ms 涨到 20ms+),形成毛刺。
  2. 一致性问题:本地缓存与 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 故障等原因失败。我们采用三级兜底:

  1. 过期时间兜底:Redis 中的 Key 虽然会被删除,但若删除失败,依赖其业务 TTL(如 1 小时)自动失效,保证最终一致。
  2. 延迟双删:在更新 DB 后先删一次缓存,休眠一小段时间(例如 500ms),再删一次。用于解决"读请求在第一次删除后、第二次删除前将旧数据重新载入缓存"的极端情况。
  3. 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。本文方案适用于内容型、配置型门户。

相关推荐
rabbit_pro1 小时前
Spring AI使用Ollama
java·人工智能·spring
折哥的程序人生 · 物流技术专研1 小时前
出版社物流WMS智能调度实战(三):从“卡死”到“跑稳”——WMS机器学习运维监控与自动回滚实战
运维·人工智能·机器学习·架构·人机交互
Mr. zhihao1 小时前
[特殊字符] 从 Redis 缓存穿透到布隆过滤器,再到布谷鸟过滤器:一次穿透防护的进化之旅
数据库·redis·缓存
@小匠1 小时前
Redis 7 持久化机制
数据库·redis·缓存
SamDeepThinking2 小时前
DDD领域驱动设计三年落地实战-开篇词
后端·程序员·架构
小小小前端啊2 小时前
前端工程化与性能优化指南
架构
ZJY1322 小时前
1-1:搭建项目框架
架构
C137的本贾尼2 小时前
入主城堡:LangChain 核心架构与快速上手
架构·langchain
一切皆是因缘际会3 小时前
2026年AGI突围:自主智能体驱动,数字生命从架构落地到自我迭代全解析
人工智能·深度学习·机器学习·架构·系统架构·agi