分层缓存调度:削峰控压下的 Feed 流高性能设计

系统整体架构流程图

用户请求与三级缓存架构简易版

三级缓存架构

一、业务背景与核心痛点

针对首页公共Feed、我的文章双场景,Feed流系统存在典型高并发读写痛点:

  • 峰值压力大:热点页面QPS极高,数据库频繁面临击穿、洪峰压力

  • 延迟要求高:用户侧需要秒级响应,传统单级Redis缓存存在网络、序列化开销

  • 数据一致性难平衡:高频更新、点赞收藏计数变更,无法做到强一致,需要秒级最终一致

  • 个性化与公共缓存冲突:用户点赞、收藏等个人态会污染公共缓存,导致命中率暴跌

核心架构目标:极致降低读延迟、收敛后端DB压力、保障秒级最终一致、解耦页面装配与个性化逻辑。

二、核心架构思想

摒弃传统单级缓存粗暴方案,确立两大核心设计哲学,支撑整套三级缓存体系:

  • 动静解耦、分层装配:页面骨架(静态结构)+ 条目碎片(动态内容)+ 用户个性化(实时覆盖)三层完全解耦

  • 缓存分层、成本逐级递增:本地内存→Redis骨架→Redis碎片→DB,按访问成本、速度、命中率分层治理

  • 缓存只读公共态,个性化上层叠加:彻底杜绝个性化数据污染公共缓存

  • 事件失效+定时纠偏:实现高性能下的秒级最终一致

  • idsKey 存当前页面文章ID["1234567890", "1234567891", "1234567892", ..., "1234567909"]

    ↑ ↑ ↑ ↑

    第1篇文章ID 第2篇文章ID 第3篇文章ID 第20篇文章ID

三、三级缓存分层架构详解

按照「速度从快到慢、成本从低到高、数据粒度从粗到细」分层设计

1. L2 本地内存缓存(Caffeine)------ 热点极致加速层

  • 公共页面缓存key:

    java 复制代码
    private String cacheKey(int page, int size) {
            return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
        }
    复制代码
    LAYOUY_VER 缓存版本号:后续可以通过修改这个批量让缓存失效
  • 存储内容:完整Feed页面响应(条目数组(这一页的具体数据)、分页、hasMore)

  • 适用场景:公共Feed热点页、个人近期知文热页

  • 核心优势:无网络IO、无需序列化,毫秒级返回,扛峰值QPS

  • 策略:短TTL+热键动态续期,热点常驻缓存

    java 复制代码
    // 对返回列表中的每个条目进行热度统计--并试图延长TTL
                for (FeedItemResponse item : local.items()) {
                    recordItemHotKey(item.id());
                }

    同时维护一个hasMore键判断是不是还有下一页,这个键设置前端是否还显示下一页按钮

java 复制代码
String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";
  • 隔离能力:命中直接返回,不再穿透Redis与DB

2. L1Redis页面骨架缓存 ------ 页面装配调度层

  • 存储内容:页面ID列表、hasMore分页元数据、轻量索引

  • 核心定位:解决页面结构复用,稳定装配链路

  • 设计价值:无需DB查询,仅通过ID索引拼装页面,规避重复排序、分页计算

  • TTL策略:短TTL+随机抖动,规避大面积同时失效

3. L0 Redis碎片缓存 ------ 数据最小粒度层

  • 存储内容:单条目维度碎片(作者、封面、时间、置顶标记、点赞/收藏计数)

  • 核心定位:可复用的最小数据单元,支持跨页面、跨场景复用

  • 能力:批量读取、缺片按需回源补齐,提升装配成功率与命中率

  • TTL策略:三级最长,保证碎片高可用

四、完整读写链路流程(贴合源码)

1. 读链路:自上而下逐级命中

  • 优先查询 L2 本地缓存,命中直接返回+个性化叠加,并尝试进行热度统计缓存时间延长

**本地缓存key:**String localPageKey = cacheKey(safePage, safeSize);

java 复制代码
private String cacheKey(int page, int size) {
        return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
    }
java 复制代码
FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);

        if (local != null && local.items() != null) {
            // 对返回列表中的每个条目进行热度统计--并试图延长TTL
            for (FeedItemResponse item : local.items()) {
                recordItemHotKey(item.id());
            }

            log.info("feed.public source=local localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
            List<FeedItemResponse> enrichedLocal = enrich(local.items(), currentUserIdNullable);

            return new FeedPageResponse(enrichedLocal, local.page(), local.size(), local.hasMore());
        }
  • L2未命中 → 查询 L1 页面骨架,拿到ID列表批量拉取 L0 碎片拼装页面

**文章ID列表缓存key:**里面存的是当前小时这个里面存的是当前小时sagePage页的safeSize个文章ID列表

java 复制代码
long hourSlot = System.currentTimeMillis() / 3600000L;
        String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;

**是否还有下一页key:**用来记录是否还有下一页,过期时间很短,记录前端是否显示下一页列表

java 复制代码
String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";

批量获取元数据,将数据存到公共缓存中-----这里虽然存的是个性数据,但每次用都会调用方法覆盖为调用用户的个人数据,没有影响

java 复制代码
// 构造内容元数据(标题,内容等)的 Redis Key
        List<String> itemKeys = new ArrayList<>(idList.size());
        for (String id : idList) {
            itemKeys.add("feed:item:" + id);
        }
        // 批量获取知文 元数据
        List<String> itemJsons = redis.opsForValue().multiGet(itemKeys);
  • L1未命中 → 触发单航班机制,唯一请求回源DB

  • 回源成功后自下而上回填 L0/L1/L2,形成闭环加速

2. 写回回填机制

  • DB回源后,优先写入L0碎片、再L1骨架、最后L2完整页面

  • 分层独立TTL+抖动策略,平滑失效流量

3. 个性化叠加机制

  • 公共缓存只存公共状态:不包含like/favor用户态

  • 返回前内存实时叠加用户个性化状态,不写缓存

  • 彻底解决缓存碎片化、命中率下降问题

java 复制代码
 synchronized (lock) {
            // 重查 L2 缓存,避免重复回源
            FeedPageResponse again = assembleFromCache(idsKey, hasMoreKey, safePage, safeSize, currentUserIdNullable);
            if (again != null) {
                feedPublicCache.put(localPageKey, again);
                // 对返回列表中的每个条目进行热度统计
                if (again.items() != null) {
                    for (FeedItemResponse item : again.items()) {
                        recordItemHotKey(item.id());
                    }
                }
                log.info("feed.public source=3tier(after-flight) localPageKey={} page={} size={}", localPageKey, safePage, safeSize);
                singleFlight.remove(idsKey);
                return again;
            }

            // 数据库回源:读取 size+1 以判断是否有下一页,后裁剪为当前页
            int offset = (safePage - 1) * safeSize;
            List<KnowPostFeedRow> rows = mapper.listFeedPublic(safeSize + 1, offset);
            boolean hasMore = rows.size() > safeSize;
            if (hasMore) {
                rows = rows.subList(0, safeSize);
            }

            // 构建基础列表(计数已填充),liked/faved 置为 null 以免污染用户维度缓存
            List<FeedItemResponse> items = mapRowsToItems(rows, null, false);

            FeedPageResponse respForCache = new FeedPageResponse(items, safePage, safeSize, hasMore);
            // 片段缓存(ids/item/count)TTL 更长并加入随机抖动,降低同一时刻大量过期
            int baseTtl = 60;
            //使用高并发线程安全工具,生成随机数0-29
            int jitter = ThreadLocalRandom.current().nextInt(30);
            Duration frTtl = Duration.ofSeconds(baseTtl + jitter);

            // 写入片段缓存与本地缓存
            writeCaches(localPageKey, idsKey, hasMoreKey, safeSize, rows, items, hasMore, frTtl);
            feedPublicCache.put(localPageKey, respForCache);

            // 返回时覆盖用户维度状态,不写回缓存
            List<FeedItemResponse> enriched = enrich(items, currentUserIdNullable);
            log.info("feed.public source=db localPageKey={} page={} size={} hasMore={}", localPageKey, safePage, safeSize, hasMore);
            // 释放单航班锁,允许后续请求正常进入
            singleFlight.remove(idsKey);

            return new FeedPageResponse(enriched, safePage, safeSize, hasMore);
        }

五、高并发防护体系

1. Single-Flight 单航班机制 ------ 防缓存击穿

以页面骨架Key为航班锁,并发同一页面仅一次DB回源,其余请求等待缓存结果,彻底解决缓存失效瞬间的DB击穿、惊群效应。

举例来说:

1000 个用户同时请求第 1 页(缓存都未命中)

999 个用户等待,1 个用户去查数据库

数据库查询完成,写入缓存

1000 个用户都获得相同的数据

锁的竞争过程:

时间线:
─────┬──────────────────────────────────────────

0ms│ 请求 1 获得锁,进入同步块
│ 请求 2-1000 在锁外等待(BLOCKED 状态)

│ ┌────────────────────────────────────┐
│ │ 请求 1 执行: │
│ │ 1. 重查 L2 缓存(未命中) │
│ │ 2. 查询数据库(耗时 50ms) │
│ │ 3. 写入 Redis 缓存 │
│ │ 4. 写入 Caffeine 缓存 │
│ │ 5. 调用 enrich() 叠加个性化状态 │
│ │ 6. 返回响应 │
│ │ 7. singleFlight.remove(idsKey) │
│ └────────────────────────────────────┘

55ms│ 请求 1 释放锁

56ms│ 请求 2 获得锁,进入同步块
│ ┌────────────────────────────────────┐
│ │ 请求 2 执行: │
│ │ 1. 重查 L2 缓存(✅ 命中!) │
│ │ 2. 写入 Caffeine 缓存 │
│ │ 3. 调用 enrich() 叠加个性化状态 │
│ │ 4. 返回响应(无需查数据库) │
│ │ 5. singleFlight.remove(idsKey) │
│ └────────────────────────────────────┘

57ms│ 请求 2 释放锁

58ms│ 请求 3 获得锁,进入同步块
│ ┌────────────────────────────────────┐
│ │ 请求 3 执行: │
│ │ 1. 重查 L2 缓存(✅ 命中!) │
│ │ 2. 直接返回(连 Caffeine 都不用写) │
│ └────────────────────────────────────┘

59ms│ 请求 3 释放锁

│ ... 请求 4-1000 依次快速返回

100ms│ 所有 1000 个请求都已完成 ✅
─────┴──────────────────────────────────────────

2. 双删失效策略 ------ 解决数据不一致

内容更新、置顶、可见性变更时,执行「立即删除+延迟二次删除」,杜绝并发回源旧值覆盖问题,保障秒级最终一致。

复制代码
/**
 * 为什么要先删除缓存:
 * 时间线:
 * T1: 线程 A 开始执行 confirmContent()
 * T2: 线程 A 执行第一次 delete → 清除旧缓存 ✅(如果没有这个删除,B会把数据库中旧数据写到缓存,如果在高并发下,此时有多个读取了旧数据)
 * T3: 线程 B 调用 getDetail() 查询同一条知文
 * T4: 线程 B 发现缓存未命中 → 但此时数据库还未更新
 * T5: 线程 B 等待...(或读取到旧数据,但不会写回缓存,因为 SingleFlight 机制)
 * T6: 线程 A 执行 mapper.updateContent() → 数据库更新为【新数据】
 * T7: 线程 A 执行第二次 delete → 清除可能在 T4-T6 期间产生的脏缓存 ✅
 * T8: 线程 C 调用 getDetail() → 缓存未命中 → 从数据库读取【新数据】→ 写入缓存 ✅
 * 防止并发污染:在数据库更新前清除旧缓存,避免并发请求在更新期间读取并回填旧数据
 * 保证最终一致性:通过"更新前删除 + 更新后删除"的双删策略,确保缓存最终一定是最新数据
 * 降低脏数据窗口期:将缓存不一致的时间窗口压缩到最小
 */

3. 热键动态TTL扩缩容

基于滑动窗口热度探测,热点页面自动延长L2缓存TTL,让高频流量永久滞留在缓存层,极致削峰。

4. 小时分片Key设计

分页维度+时间分片组合Key,规避整点大面积缓存失效,平滑流量波动。

选择小时的原因:

合理的 Key 数量:一天 24 个时段,不会过多

自然的业务周期:用户行为通常以小时为单位变化

足够的时间分散:60-90 秒的 TTL 在一个小时内均匀分布

便于清理:过期的时段可以批量删除

5. 随机防抖设计

设计片段缓存和随机防抖缓存,防止高并发下大量缓存同时过期造成缓存雪崩

java 复制代码
// 片段缓存(ids/item/count)TTL 更长并加入随机抖动,降低同一时刻大量过期
            int baseTtl = 60;
            //使用高并发线程安全工具,生成随机数0-29
            int jitter = ThreadLocalRandom.current().nextInt(30);
            Duration frTtl = Duration.ofSeconds(baseTtl + jitter);

六、数据一致性方案:秒级最终一致

  • 基础数据:内容变更事件驱动双删,实时失效

  • 计数数据:增量事件入聚合桶 + 定时任务折叠纠偏

  • 缺片兜底:碎片缺失时批量回源补齐,自动修复缓存脏数据/缺失数据

七、架构设计权衡与取舍

  • 为什么不做单级缓存? 单级本地缓存成本高、单级Redis延迟高、单级碎片装配复杂度高,三级分层各司其职,兼顾延迟、成本、稳定性。

  • 为什么个性化不进缓存? 避免缓存维度爆炸、命中率暴跌、雪崩风险抬升,实现公共缓存全局复用。

  • 为什么用抖动TTL? 打散过期时间,杜绝缓存集中失效洪峰。

  • 为什么采用事件+定时双保障? 兼顾实时失效与兜底纠偏,实现性能与一致性平衡。

八、最终落地效果

  • 热点页面本地命中率极高,P99延迟大幅下降

  • 绝大多数请求拦截在缓存层,DB QPS极致收敛

  • 彻底解决峰值惊群、击穿、数据不一致问题

  • 实现「热点本地返回、普通Redis拼装、极少落DB」的最优流量模型

九、总结与架构复用思想

整套三级缓存架构,通过分层存储、动静解耦、读写分离、个性后置、并发防护、最终一致纠偏,完美解决高并发Feed流的性能与一致性矛盾,可通用落地于所有信息流、列表页、推荐流等高读低改业务场景。

相关推荐
難釋懷6 小时前
Redis内存回收-过期key处理
数据库·redis·缓存
Nayxxu9 小时前
Gemini 长上下文成本估算表:输入、输出、缓存怎么拆
java·缓存
爱莉希雅&&&10 小时前
Redis哨兵模式和主从复制和集群模式搭建与扩容缩容
linux·redis·缓存·集群·哨兵·数据库同步
JohnnyDeng9410 小时前
OkHttp 拦截器链与缓存策略:深度解析网络层的核心机制
okhttp·缓存
MRSM_0111 小时前
Redis 缓存、队列、排行榜的核心用法
数据库·redis·缓存
Trouvaille ~11 小时前
【Redis篇】Redis 安装与启动:快速搭建一个 Redis 环境
数据库·redis·后端·ubuntu·缓存·环境搭建·安装教程
fengxin_rou11 小时前
【Feed 高并发架构实战】:雪花 ID + 三级缓存 + 计数旁路设计详解
数据库·redis·缓存·架构·事务·并发
Mahir0820 小时前
Spring 循环依赖深度解密:从问题本质到三级缓存源码级解析
java·后端·spring·缓存·面试·循环依赖·三级缓存
jran-1 天前
Redis 命令
数据库·redis·缓存