【知识获取与分享社区项目 | 项目日记第 13 天】首页 Feed 三级缓存设计:Caffeine + Redis 页面骨架 + Redis 片段缓存

前言
今天开始整理项目中的 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 的读性能,也能避免公共缓存被用户个性化状态污染。
更多推荐




所有评论(0)