Spring Boot与Redis ZSet实现仿小红书源码双列瀑布流推荐

仿小红书源码的发现页通常采用双列瀑布流展示图文与短视频。页面看起来只是左右两列内容卡片,后台实际需要同时处理内容排序、分页稳定性、浏览去重、分类偏好、互动热度以及媒体比例等数据。

用户第一次进入发现页时,可以看到近期发布且具备一定互动的数据;继续上拉后,接口需要返回下一批内容,并尽量避免出现上一页已经展示过的作品。用户浏览、点赞、评论、收藏或关注作者后,后续推荐顺序还会发生变化。

这类信息流不适合只使用传统页码分页。随着点赞数、评论数和收藏数不断变化,同一条内容可能在不同时间进入不同排序位置,使用 page=2page=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;

这是第二处代码示例。

接口返回列表时,将最后一条内容的 recommendScoreid 编码成下一页游标。客户端无需理解游标内部结构,只需要在下次请求时原样提交。

浏览记录既用于个人中心也用于推荐去重

浏览记录不能只作为个人主页中的历史列表使用。

当用户连续上拉发现页时,后台可以记录近期已经曝光的内容 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限制内容和用户数据的访问范围。

围绕这套链路,图文、短视频、话题标签、内容分类、浏览记录、点赞、评论、收藏、关注和商品信息能够自然进入同一条推荐流程,使仿小红书源码的发现页保持连续加载和稳定排序。

业务场景与页面之间的关系湖南宠友信息技术有限公司是一家专注社区交友类产品、企业即时通信软件开发,为企业提供即时通信工具、垂直类内容圈子,自主研发的业界知名友猫产品拥有广大的企业用户群体https://chongyou.info/1/product/xhs.html

相关推荐
没钥匙的锁11 小时前
16-Java反射机制:Spring IOC背后的核心技术
java·开发语言·spring
guhy fighting1 小时前
x-www-form-urlencoded,form-data,raw 表单请求格式的区别
前端·表单请求格式
geinvse_seg1 小时前
飞算JavaAI:把企业采购协同需求拆成一套可运行的慧采平台
java·ai·飞算
李明卫杭州1 小时前
Vue2 与 Vue3 自定义指令详解:钩子变化、迁移指南与实战示例
前端·javascript·vue.js
摸鱼号20232 小时前
在 Rocky Linux 10.1 Minimal 上安装 MySQL 9.7 的血泪史:MariaDB 到底有多“根深蒂固”?
mysql
问商十三载2 小时前
大模型GEO内容更新频率:3个节奏规律提收录权重,零成本提30%引用率附更新计划表
前端·人工智能·机器学习
yingyima2 小时前
sed 命令速查手册:从一次午夜事故说起
前端
七月丶2 小时前
线上出 Bug,你还在让用户"清一下缓存"?我用一个插件让每个请求自报版本
前端·javascript·面试
飘尘2 小时前
让AI能够预演现实的世界模型,到底是什么?
前端·人工智能
不简说2 小时前
JS 代码技巧 vol.3 — 10 个防抖节流深挖,小白到老炮的踩坑路
前端·javascript·面试