仿小红书源码的发现页通常采用双列瀑布流展示图文与短视频。页面看起来只是左右两列内容卡片,后台实际需要同时处理内容排序、分页稳定性、浏览去重、分类偏好、互动热度以及媒体比例等数据。
用户第一次进入发现页时,可以看到近期发布且具备一定互动的数据;继续上拉后,接口需要返回下一批内容,并尽量避免出现上一页已经展示过的作品。用户浏览、点赞、评论、收藏或关注作者后,后续推荐顺序还会发生变化。
这类信息流不适合只使用传统页码分页。随着点赞数、评论数和收藏数不断变化,同一条内容可能在不同时间进入不同排序位置,使用 page=2、page=3 查询时容易产生重复或遗漏。
更适合双列瀑布流的方案,是通过 Spring Boot 组织推荐流程,使用 Redis ZSet 保存候选内容与排序分值,再使用游标分页控制连续加载。

一条发现页请求经过哪些数据层
发现页接口可以拆成四个阶段,而不是直接执行一条复杂 SQL:
javascript
用户发起发现页请求
│
▼
读取推荐候选内容
│
▼
过滤已浏览、已失效数据
│
▼
补充作者与互动状态
│
▼
返回瀑布流卡片和下一页游标
推荐候选层只负责确定"哪些内容可能被展示";过滤层排除已删除、已隐藏和近期重复曝光的内容;数据组装层补充作者头像、话题标签、封面比例以及当前用户是否点赞、收藏和关注;游标层则记录本次查询结束位置。
这种分层方式可以让发现页、关注页和附近页复用卡片结构,同时保持不同的数据筛选规则。
发现页按照兴趣与热度排序,关注页以关注作者和发布时间为主,附近页则增加区域范围条件。三个页面不需要分别维护一套内容对象,只需要使用不同的候选集合。
瀑布流卡片先确定高度再加载图片
双列瀑布流的核心不只在于排序,还在于卡片高度计算。
后台返回内容列表时,需要同时返回封面的原始宽高。客户端拿到数据后,可以在图片尚未完成加载时计算卡片尺寸:
卡片图片高度 = 当前列宽 × 原图高度 ÷ 原图宽度
图文内容通常使用第一张图片作为封面,短视频使用独立的视频封面。多图内容还可以返回图片数量,用于在卡片角标中展示。
推荐列表中的基础字段可以包括:
| 字段 | 用途 |
|---|---|
| contentId | 内容唯一标识 |
| contentType | 区分图文与短视频 |
| title | 卡片标题或正文摘要 |
| coverUrl | 瀑布流封面 |
| coverWidth | 封面原始宽度 |
| coverHeight | 封面原始高度 |
| authorId | 作者标识 |
| authorName | 作者昵称 |
| avatar | 作者头像 |
| likeCount | 点赞数量 |
| topicNames | 关联话题 |
| publishTime | 发布时间 |
点赞状态、收藏状态和关注状态属于用户个性化字段,不建议直接写入公共内容缓存。不同用户请求同一篇内容时,公共卡片信息可以复用,个性化状态则单独批量查询。

推荐分值不等于点赞数量
只按照点赞数排序,会让发布时间较早的内容长期占据靠前位置。只按照发布时间排序,又会让新内容快速覆盖已有优质作品。
仿小红书源码的基础推荐可以先采用可解释的分值模型,将互动数据和时间衰减组合起来:
javascript
推荐分值 =
点赞数 × 1.0
+ 评论数 × 2.2
+ 收藏数 × 3.0
+ 有效浏览数 × 0.2
+ 完播数 × 1.5
+ 关注作者加权
+ 分类偏好加权
- 发布时间衰减
图文和短视频可以设置不同权重。
图文内容更适合关注收藏、评论和阅读停留时长;短视频则可以增加播放完成率、重复播放和有效播放时长。商品内容进入推荐流时,还可以参考收藏、加购和详情访问,但订单金额和支付状态不应直接参与普通内容热度计算。
推荐分值可以通过定时任务周期性重算,也可以在点赞、评论、收藏等互动发生后增量更新。
Redis ZSet 的成员保存内容 ID,分值保存当前推荐得分:
javascript
discover:content:all
10281 86.42
10519 81.70
10366 78.35
10902 74.18
ZSet 能够按照分值倒序读取候选内容,适合作为发现页的第一层排序结构。
Redis ZSet如何写入推荐候选
内容进入公开状态后,可以先计算初始分值,再加入对应的推荐集合。互动行为发生时,只更新关联分值,不必重建整张列表。
javascript
@Service
public class RecommendScoreService {
private static final String DISCOVER_KEY = "discover:content:all";
@Resource
private StringRedisTemplate redisTemplate;
public void addContent(ContentStatistics statistics) {
double score = calculateScore(statistics);
redisTemplate.opsForZSet().add(
DISCOVER_KEY,
statistics.getContentId().toString(),
score
);
}
public void increaseScore(Long contentId, double delta) {
redisTemplate.opsForZSet().incrementScore(
DISCOVER_KEY,
contentId.toString(),
delta
);
}
private double calculateScore(ContentStatistics data) {
double interactionScore =
data.getLikeCount() * 1.0
+ data.getCommentCount() * 2.2
+ data.getCollectCount() * 3.0
+ data.getValidViewCount() * 0.2;
double hourAge = Duration.between(
data.getPublishTime(),
LocalDateTime.now()
).toHours();
double timeDecay = Math.log10(hourAge + 10);
return interactionScore - timeDecay;
}
}
第一处代码示例展示了基础分值写入和增量更新。
实际项目中,不需要每次浏览都立即修改 ZSet。浏览行为频率较高,可以先在 Redis 中累加,再由 Quartz 定时任务批量汇总。点赞、评论和收藏的业务频率相对较低,可以在事务完成后发布事件,由推荐服务增量调整分值。
作品被作者隐藏、删除或后台下架时,需要从推荐集合中移除。内容状态以数据库为准,Redis 只保存可以重建的排序结果。

为什么瀑布流更适合游标分页
传统页码分页通常使用:
javascript
?page=1&size=20
?page=2&size=20
假设用户读取第一页后,一篇新内容获得大量收藏并进入列表前部,原本位于第一页末尾的作品可能被挤到第二页。用户请求第二页时,就可能再次看到这条内容。
游标分页不依赖固定页码,而是记录上一批内容的最后分值和内容 ID。
请求结构可以表示为:
javascript
首次请求:
cursorScore = null
cursorId = null
下一页请求:
cursorScore = 72.630
cursorId = 10831
只使用分值作为游标还不够,因为多条内容可能具有相同分值。可以将内容 ID 作为第二排序条件,保证分页边界稳定。
数据库查询可以按照"分值倒序、内容 ID 倒序"排列:
javascript
SELECT
c.id,
c.user_id,
c.title,
c.content_type,
c.cover_url,
c.cover_width,
c.cover_height,
c.like_count,
c.publish_time,
c.recommend_score
FROM content c
WHERE c.status = 'PUBLISHED'
AND (
:cursorScore IS NULL
OR c.recommend_score < :cursorScore
OR (
c.recommend_score = :cursorScore
AND c.id < :cursorId
)
)
ORDER BY c.recommend_score DESC, c.id DESC
LIMIT :pageSize;
这是第二处代码示例。
接口返回列表时,将最后一条内容的 recommendScore 和 id 编码成下一页游标。客户端无需理解游标内部结构,只需要在下次请求时原样提交。
浏览记录既用于个人中心也用于推荐去重
浏览记录不能只作为个人主页中的历史列表使用。
当用户连续上拉发现页时,后台可以记录近期已经曝光的内容 ID。在生成下一批结果时,优先过滤这些内容,减少同一作品频繁出现。
浏览数据可以分成两类:
曝光记录
内容卡片进入可视区域后产生,表示用户至少看到了这条内容。
有效浏览
用户进入详情页、停留达到一定时间,或者短视频播放达到有效阈值后产生。
曝光记录适合短期存放在 Redis 中,用于推荐去重;有效浏览记录可以写入数据库,用于个人浏览历史和兴趣偏好计算。
Redis Set 可以记录用户近期已经曝光的内容:
javascript
recommend:exposed:20861
├─ 10281
├─ 10519
├─ 10366
└─ 10902
这类集合可以设置较短的有效期,例如数小时或一天。长期不再推荐同一内容并不合理,经过一定时间后,优质内容仍然可以重新进入候选范围。

推荐接口如何过滤已浏览内容
读取 Redis ZSet 时,可以多取一部分候选数据,再结合曝光集合、内容状态和屏蔽关系过滤。
javascript
public RecommendPageVO getDiscoverPage(
Long userId,
Double maxScore,
int pageSize) {
int candidateSize = pageSize * 3;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeByScoreWithScores(
"discover:content:all",
0,
maxScore == null ? Double.MAX_VALUE : maxScore,
0,
candidateSize
);
if (CollectionUtils.isEmpty(tuples)) {
return RecommendPageVO.empty();
}
Set<String> exposedIds = redisTemplate.opsForSet()
.members("recommend:exposed:" + userId);
List<Long> contentIds = tuples.stream()
.map(ZSetOperations.TypedTuple::getValue)
.filter(Objects::nonNull)
.filter(id -> exposedIds == null || !exposedIds.contains(id))
.map(Long::valueOf)
.limit(pageSize)
.toList();
List<ContentCardVO> cards =
contentQueryService.queryCards(contentIds, userId);
exposureService.recordBatch(userId, contentIds);
return RecommendPageVO.of(cards);
}
这是第三处代码示例。
候选数量设置为页面数量的数倍,是为了给过滤逻辑留出空间。部分内容可能已经浏览、已经失效,或者作者被当前用户屏蔽,如果只读取等于页面大小的候选数据,最终返回数量可能不足。
数据组装时应尽量使用批量查询,避免循环读取作者资料、话题信息和互动状态。
用户兴趣如何影响推荐顺序
基础热度排序能够完成通用发现页,但不同用户最终看到的内容仍应存在差异。
用户兴趣可以从以下行为中提取:
javascript
浏览某个内容分类
收藏某类图文
完整观看某类短视频
搜索某个关键词
关注某类作者
参与某个话题
多次进入商品分类
系统可以为用户维护一组轻量偏好权重:
|------|-----|
| 标签 | 权重 |
| 旅行记录 | 8.4 |
| 摄影分享 | 6.9 |
| 城市生活 | 5.7 |
| 美食内容 | 3.2 |
候选内容本身也带有分类与话题标签。接口在读取通用热度分值后,可以增加用户偏好分,得到最终排序结果。
推荐不一定要在初期引入复杂模型。通过内容分类、话题标签、搜索记录、收藏行为和关注关系,也可以形成相对稳定的个性化结果。
对于刚注册、缺少行为数据的用户,可以使用热门内容、近期内容和分类均衡策略,避免发现页只集中展示单一类型。

关注页不需要复制发现页算法
关注页中的内容来自用户已经关注的作者,核心关系比较明确。
它更适合采用发布时间游标,而不是复杂热度分值。点赞、评论和收藏可以影响卡片统计,但不必大幅改变内容顺序。
关注页查询可以按照:
javascript
关注关系筛选
+
公开内容状态
+
发布时间倒序
+
内容ID倒序
附近页则可以根据区域编码或地理坐标获取候选内容,再使用发布时间和互动分值排序。
三个页面可以共享内容卡片组装服务:
javascript
发现页:推荐候选 → 内容卡片
关注页:关注作者 → 内容卡片
附近页:区域内容 → 内容卡片
搜索页:关键词结果 → 内容卡片
共享的是返回结构和数据组装,不是完全相同的排序规则。
搜索结果与推荐结果应保持职责分离
EasyES 主要负责关键词检索,例如用户输入作品标题、正文片段、话题标签或商品名称后,快速返回相关内容。
Redis ZSet 更适合保存已经计算好的推荐顺序。
两者可以在搜索推荐场景中配合,但不应混成同一套逻辑。
例如,用户搜索"城市徒步"后,EasyES 先筛选匹配关键词的作品,再根据相关度、发布时间和互动数据排序。用户返回发现页后,仍然读取通用推荐集合。
搜索行为可以反向影响用户兴趣标签,但不能因为用户搜索过一次关键词,就永久把发现页全部替换成同类内容。偏好权重应随时间衰减,并结合浏览、收藏和关注行为共同计算。

点赞数与收藏数如何保持查询效率
发现页每张卡片都会展示点赞数量,详情页还会显示评论、收藏和浏览数据。
这些统计字段如果每次通过关联表实时 COUNT,数据量增加后查询成本较高。可以在内容主表中保存冗余统计字段:
javascript
like_count
comment_count
collect_count
view_count
用户点赞成功后,既写入点赞关系,也增加内容统计值。取消点赞则删除关系并减少计数。
Redis 可以缓存高频内容的统计数据,数据库保留最终值。Quartz 定时任务可以根据互动关系表抽样校验计数,修正少量异常数据。
Druid 的慢 SQL 监控可以用于发现内容卡片批量查询、关注关系筛选和浏览历史列表中的低效语句。常用过滤字段如内容状态、作者 ID、分类 ID和发布时间,需要建立匹配实际查询顺序的索引。
商品内容进入信息流时保留交易边界
仿小红书源码包含商品分类、商品列表、商品详情、购物车、立即购买、订单管理和在线支付时,部分图文或短视频可能关联商品。
推荐卡片可以返回商品摘要,例如商品 ID、名称、价格和封面,但商品是否能够购买必须在进入商品详情或提交订单时重新校验。
推荐缓存中的价格和库存只用于展示,不能直接作为订单结算依据。用户点击立即购买后,交易服务仍要读取最新商品状态、库存和价格。
这种划分能够让内容推荐与商品交易保持连接,同时避免瀑布流缓存数据直接决定支付结果。

从一条内容进入发现页看完整轨迹
一篇作品进入推荐列表后,后台数据会沿着以下路径变化:
javascript
内容具备公开状态
↓
计算初始推荐分值
↓
加入Redis ZSet候选集合
↓
发现页通过游标读取
↓
过滤用户近期曝光记录
↓
批量组装封面、作者和互动状态
↓
用户浏览、点赞或收藏
↓
更新行为记录与内容分值
↓
影响后续推荐顺序
双列瀑布流只是页面展示方式,真正决定加载稳定性的是游标分页,决定内容先后顺序的是推荐分值,决定重复率的是曝光过滤,决定个性化程度的是分类、话题、搜索和互动行为。
Spring Boot负责组织推荐请求与数据组装,Redis ZSet保存候选内容和排序分值,EasyES处理内容与商品搜索,Quartz周期性重算数据,Druid观察数据库查询状态,Shiro限制内容和用户数据的访问范围。
围绕这套链路,图文、短视频、话题标签、内容分类、浏览记录、点赞、评论、收藏、关注和商品信息能够自然进入同一条推荐流程,使仿小红书源码的发现页保持连续加载和稳定排序。