在这里插入图片描述

前言

今天开始整理项目中的 Feed 流系统。

Feed 流是内容社区中访问频率非常高的接口。用户打开首页时,往往第一时间请求 Feed 列表,如果每次都直接查数据库,再逐条查点赞数、收藏数、作者信息,压力会非常大。

所以项目中给 Feed 设计了一套三级缓存:

L1:本地 Caffeine 缓存
L2:Redis 页面骨架缓存
L3:Redis 内容片段缓存

其中公共 Feed 的缓存不是简单存一整页 JSON,而是拆成:

feed:public:ids:{size}:{hourSlot}:{page}
feed:item:{postId}
feed:public:ids:{size}:{hourSlot}:{page}:hasMore

这样做的好处是:页面顺序和单条内容片段可以拆开缓存,同一篇内容出现在不同页面或不同场景时,片段可以复用。


一、Feed 接口入口

Feed 接口在 KnowPostController 中:

// src/main/java/com/tongji/knowpost/api/KnowPostController.java
@GetMapping("/feed")
public FeedPageResponse feed(@RequestParam(value = "page", defaultValue = "1") int page,
                             @RequestParam(value = "size", defaultValue = "20") int size,
                             @AuthenticationPrincipal Jwt jwt) {
    Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
    return feedService.getPublicFeed(page, size, userId);
}

这里有一个细节:Feed 支持匿名访问。

如果用户没登录,jwt == null,那么 liked/faved 就会返回 false

如果用户已登录,就会实时判断当前用户是否点赞、收藏过这篇知文。


二、Feed 响应结构

单条 Feed 使用 FeedItemResponse

// src/main/java/com/tongji/knowpost/api/dto/FeedItemResponse.java
public record FeedItemResponse(
        String id,
        String title,
        String description,
        String coverImage,
        List<String> tags,
        String authorAvatar,
        String authorNickname,
        String tagJson,
        Long likeCount,
        Long favoriteCount,
        Boolean liked,
        Boolean faved,
        Boolean isTop
) {}

分页响应:

// src/main/java/com/tongji/knowpost/api/dto/FeedPageResponse.java
public record FeedPageResponse(
        List<FeedItemResponse> items,
        int page,
        int size,
        boolean hasMore
) {}

这里要注意,likeCount/favoriteCount 是全局计数,而 liked/faved 是当前用户维度状态。

所以公共缓存里不能直接缓存 liked/faved,否则不同用户之间会互相污染。


三、数据库查询 Mapper

首页 Feed 只查询已发布、公开可见的内容。

<!-- src/main/resources/mapper/KnowPostMapper.xml -->
<select id="listFeedPublic" resultType="com.tongji.knowpost.model.KnowPostFeedRow">
    SELECT
        p.id,
        p.title,
        p.description,
        p.tags,
        p.img_urls AS imgUrls,
        u.avatar AS authorAvatar,
        u.nickname AS authorNickname,
        u.tags_json AS authorTagJson,
        p.publish_time AS publishTime,
        p.is_top AS isTop
    FROM know_posts p
    JOIN users u ON p.creator_id = u.id
    WHERE p.status = 'published'
      AND p.visible = 'public'
    ORDER BY p.publish_time DESC
    LIMIT #{limit} OFFSET #{offset}
</select>

数据库只作为缓存未命中时的最终回源。

正常情况下,热门首页应该尽量命中 Caffeine 或 Redis。


四、Caffeine 本地缓存配置

本地缓存配置在 CacheConfig 中:

// src/main/java/com/tongji/cache/config/CacheConfig.java
@Bean("feedPublicCache")
public Cache<String, FeedPageResponse> feedPublicCache(CacheProperties props) {
    return Caffeine.newBuilder()
            .maximumSize(props.getL2().getPublicCfg().getMaxSize())
            .expireAfterWrite(Duration.ofSeconds(
                    props.getL2().getPublicCfg().getTtlSeconds()
            ))
            .build();
}

默认配置:

// src/main/java/com/tongji/cache/config/CacheProperties.java
public static class PublicCfg {
    private int ttlSeconds = 15;
    private long maxSize = 1000;
}

公共 Feed 本地缓存默认保留 15 秒,最大 1000 条。

Caffeine 是进程内缓存,速度非常快,适合作为第一层抗高并发读。


五、公共 Feed 缓存 Key 设计

private static final int LAYOUT_VER = 1;

private String cacheKey(int page, int size) {
    return "feed:public:" + size + ":" + page + ":v" + LAYOUT_VER;
}

本地缓存 Key 示例:

feed:public:20:1:v1

这里带上 LAYOUT_VER,是为了后续 Feed 返回结构变更时可以平滑切换缓存版本。

Redis 片段缓存还引入了小时槽:

long hourSlot = System.currentTimeMillis() / 3600000L;

String idsKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage;
String hasMoreKey = "feed:public:ids:" + safeSize + ":" + hourSlot + ":" + safePage + ":hasMore";

示例:

feed:public:ids:20:493201:1
feed:public:ids:20:493201:1:hasMore

按小时拆分可以降低跨时间段内容变化时的缓存影响范围。


六、第一层:读取 Caffeine

FeedPageResponse local = feedPublicCache.getIfPresent(localPageKey);

if (local != null && local.items() != null) {
    for (FeedItemResponse item : local.items()) {
        recordItemHotKey(item.id());
    }

    List<FeedItemResponse> enrichedLocal =
            enrich(local.items(), currentUserIdNullable);

    return new FeedPageResponse(
            enrichedLocal,
            local.page(),
            local.size(),
            local.hasMore()
    );
}

命中 Caffeine 后,并不是直接返回,而是做了一次 enrich

原因是本地缓存中不能固定保存某个用户的 liked/faved 状态。

所以缓存只保存基础内容,返回前再根据当前用户实时覆盖状态。


七、第二层:Redis 页面骨架 + 片段组装

如果本地缓存没命中,就尝试从 Redis 片段缓存组装页面:

FeedPageResponse fromCache = assembleFromCache(
        idsKey,
        hasMoreKey,
        safePage,
        safeSize,
        currentUserIdNullable
);

if (fromCache != null) {
    feedPublicCache.put(localPageKey, fromCache);

    for (FeedItemResponse item : fromCache.items()) {
        recordItemHotKey(item.id());
    }

    return fromCache;
}

这里 Redis 层不是单纯读取一个整页 JSON,而是调用 assembleFromCache 组装。


八、Redis 片段组装逻辑

private FeedPageResponse assembleFromCache(String idsKey,
                                           String hasMoreKey,
                                           int page,
                                           int size,
                                           Long uid) {
    List<String> idList = redis.opsForList().range(idsKey, 0, size - 1);
    String hasMoreStr = redis.opsForValue().get(hasMoreKey);

    if (idList == null || idList.isEmpty()) {
        return null;
    }

    List<String> itemKeys = new ArrayList<>(idList.size());
    for (String id : idList) {
        itemKeys.add("feed:item:" + id);
    }

    List<String> itemJsons = redis.opsForValue().multiGet(itemKeys);
}

这一步先读页面 ID 顺序:

feed:public:ids:{size}:{hourSlot}:{page}

再批量读取每个内容片段:

feed:item:{id}

如果某个 feed:item:{id} 缺失,就返回 null,触发数据库回源。


九、实时叠加计数和用户态

片段缓存中保存的是内容基础信息,读取时再叠加计数和用户状态:

Map<String, Long> counts = counterService.getCounts(
        "knowpost",
        String.valueOf(base.id()),
        List.of("like", "fav")
);

Long likeCount = counts.getOrDefault("like", 0L);
Long favoriteCount = counts.getOrDefault("fav", 0L);

boolean liked = uid != null
        && counterService.isLiked("knowpost", base.id(), uid);

boolean faved = uid != null
        && counterService.isFaved("knowpost", base.id(), uid);

这里的设计很关键:

  • 点赞数、收藏数从计数系统读取
  • 当前用户是否点赞、收藏从位图读取
  • 公共缓存不保存用户态

这样可以保证同一个公共 Feed 缓存被所有用户复用。


十、数据库回源与写缓存

如果 L1、L2 都没命中,就回源数据库:

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);
}

这里查询 size + 1 条,用来判断是否还有下一页。

然后映射成 Feed 条目:

List<FeedItemResponse> items = mapRowsToItems(rows, null, false);
FeedPageResponse respForCache =
        new FeedPageResponse(items, safePage, safeSize, hasMore);

注意这里 userIdNullable 传的是 null,是为了避免把当前用户态写进公共缓存。


十一、写入 Redis 片段缓存

private void writeCaches(String pageKey,
                         String idsKey,
                         String hasMoreKey,
                         int size,
                         List<KnowPostFeedRow> rows,
                         List<FeedItemResponse> items,
                         boolean hasMore,
                         Duration frTtl) {
    List<String> idVals = new ArrayList<>();

    for (KnowPostFeedRow r : rows) {
        idVals.add(String.valueOf(r.getId()));
    }

    if (!idVals.isEmpty()) {
        redis.opsForList().leftPushAll(idsKey, idVals);
        redis.expire(idsKey, frTtl);

        redis.opsForValue().set(
                hasMoreKey,
                hasMore ? "1" : "0",
                Duration.ofSeconds(10)
        );
    }

    for (FeedItemResponse it : items) {
        String itemKey = "feed:item:" + it.id();
        String itemJson = objectMapper.writeValueAsString(it);
        redis.opsForValue().set(itemKey, itemJson, frTtl);
    }
}

写入的主要是:

1. 当前页 ID 列表
2. 每条内容片段 feed:item:{id}
3. hasMore 软缓存

这就是公共 Feed 的 Redis 页面骨架 + 片段缓存。


十二、为什么不把用户态写入公共缓存?

如果公共缓存里保存了:

{
  "liked": true,
  "faved": false
}

那么这个缓存其实只适合某一个用户。

其他用户读到后就会出错。

所以项目中公共 Feed 的策略是:

公共缓存保存基础内容
返回前实时叠加 liked/faved

这是 Feed 缓存设计中非常重要的一点。


十三、知识点总结

1. Caffeine 适合做什么?

Caffeine 是进程内缓存,访问速度非常快,适合作为 Feed 热门页的第一层缓存。

2. Redis 为什么拆成 ids 和 item?

因为页面顺序和单条内容片段变化频率不同。

拆开后,内容片段可以复用,页面骨架也更轻。

3. 为什么 liked/faved 不进公共缓存?

因为它们是用户维度状态,不同用户不一样。

公共缓存只能保存所有用户共享的数据。

4. 为什么查询 size + 1?

为了判断是否还有下一页,避免额外执行一次 COUNT


总结

这一篇主要整理了项目首页 Feed 的三级缓存设计。

公共 Feed 并不是简单地缓存整页 JSON,而是通过 Caffeine、本地页面缓存,Redis 页面 ID 骨架,以及 Redis 内容片段缓存组合起来。基础内容可以缓存,用户态状态实时叠加,计数从计数系统读取。

这种设计既能提升首页 Feed 的读性能,也能避免公共缓存被用户个性化状态污染。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐