Java 项目实战: 交友项目 - 查询推荐动态

概述

好友动态查的是自己的收件箱(movement_timeline),推荐动态查的既不是 movement 全量、也不是 recommend_user 表,而是推荐系统提前写进 Redis 的一份 pid 列表。这里先把这份「推荐结果」的存储结构、查询语义和边界情况理清楚,再把 GET /movements/recommend 从 Controller 一路打到 MongoTemplate 的调用链落地,过程中重点掰扯两个容易忽略的架构问题,推荐排序语义到底在哪一层、为什么分页只能在内存做。

需求与功能分析

业务场景:为什么需要「推荐」tab

圈子页有两个并列的 tab:好友动态和推荐动态。好友动态解决「我认识的人在干什么」,推荐动态解决「我不认识但可能感兴趣的人在干什么」。两者展示形式完全一样(同样的卡片、同样的 MovementsVo),差异只有一个,数据从哪来。

维度 好友动态 推荐动态
驱动方式 关系驱动:靠好友关系链扩散 兴趣驱动:靠行为特征计算
数据生产方 好友发动态时,系统做「推模式」写入自己的时间线 大数据推荐系统离线/实时计算后写入
后端在本功能的角色 查自己的 movement_timeline 收件箱 只读推荐结果,不做任何计算
数据载体 MongoDB movement_timeline 集合 Redis MOVEMENTS_RECOMMEND_{userId}

推荐动态的「后端只读不算」和今日佳人功能是同一个思路:推荐这件事由独立的大数据系统完成,Java 服务端只负责两件事,把用户的行为(浏览、点赞、评论、喜欢)暴露出去供采集,以及把算好的结果查询出来展示。推荐系统本身的算法与执行流程在后续推荐系统章节展开,这里只关心「结果存在哪、怎么查」。

推荐系统采集的行为特征,就是用户在客户端里的日常操作:查看了谁的朋友圈、对哪条动态点了赞、评论了什么、喜欢了谁。这些行为客观上表达了「你对某一类内容感兴趣」,系统采集后实时计算,把算出来的动态 pid 列表写进 Redis。所以查询推荐动态的本质是:从 Redis 拿到一份 pid 清单,再回到 MongoDB 把动态详情捞出来。

数据来源:实读结论

先说结论,这个结论以 05-圈子互动章工程快照(mf-app-server 的 MovementService.findRecommendMovements)实读为准:

推荐动态的数据源是 Redis ,key 为 MOVEMENTS_RECOMMEND_ 拼接用户 id,value 是逗号分隔的动态 pid 字符串 。查询时先读 Redis 拿 pid 列表,在内存里做分页截取,再经 Dubbo 调用按 pid in (...) 反查 MongoDB 的 movement 集合。不存在 movement 集合全量扫描,也不经过 recommend_user 集合。

这里最容易和今日佳人混淆。今日佳人的推荐结果在 MongoDB 的 recommend_user 集合里(userId + toUserId + score),查询按 toUserId 过滤;而推荐动态的推荐结果在 Redis 里,value 是 pid 串。两个「推荐」共用同一套推荐系统基建,但落地的存储完全不同。

Redis 里的实际数据长这样(讲义原文示例):

shell 复制代码
192.168.31.81:6379> get MOVEMENTS_RECOMMEND_1
"2562,3639,2063,3448,2128,2597,2893,2333,3330,2642,2541,3002,3561,3649,2384,2504,3397,2843,2341,2249"

结构拆解:

组成 内容 说明
key MOVEMENTS_RECOMMEND_{userId} 常量 Constants.MOVEMENTS_RECOMMEND + 用户 id,String 结构
value "2562,3639,..." 动态 pid 用逗号拼接的字符串,顺序由推荐系统决定
pid Long 型唯一数字标识 发布动态时生成的自增字段,专门为推荐系统建模使用(Movement.pid)

为什么推荐系统要一个 pid 而不用 MongoDB 的 ObjectId?因为推荐模型的特征计算跑在 Spark 这类大数据组件上,Long 型数字比 24 位十六进制字符串省得多,跨系统传递也更友好。Movement 实体里的注释写得很直白(实读原文):

java 复制代码
@Document(collection = "movement")
public class Movement implements java.io.Serializable {
    private ObjectId id; //主键id
    private Long pid;    //Long类型,用于推荐系统的模型(自动增长)
}

注意集合名实读是全小写 movement,别和好友动态用的 movement_timeline 搞混,前者是动态主表,后者是推模式生成的时间线收件箱。

好友动态 vs 推荐动态:数据源对比图

#mermaid-svg-9eeMscGJDbLgSHKB{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9eeMscGJDbLgSHKB .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9eeMscGJDbLgSHKB .error-icon{fill:#552222;}#mermaid-svg-9eeMscGJDbLgSHKB .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9eeMscGJDbLgSHKB .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9eeMscGJDbLgSHKB .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9eeMscGJDbLgSHKB .marker.cross{stroke:#333333;}#mermaid-svg-9eeMscGJDbLgSHKB svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9eeMscGJDbLgSHKB p{margin:0;}#mermaid-svg-9eeMscGJDbLgSHKB .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9eeMscGJDbLgSHKB .cluster-label text{fill:#333;}#mermaid-svg-9eeMscGJDbLgSHKB .cluster-label span{color:#333;}#mermaid-svg-9eeMscGJDbLgSHKB .cluster-label span p{background-color:transparent;}#mermaid-svg-9eeMscGJDbLgSHKB .label text,#mermaid-svg-9eeMscGJDbLgSHKB span{fill:#333;color:#333;}#mermaid-svg-9eeMscGJDbLgSHKB .node rect,#mermaid-svg-9eeMscGJDbLgSHKB .node circle,#mermaid-svg-9eeMscGJDbLgSHKB .node ellipse,#mermaid-svg-9eeMscGJDbLgSHKB .node polygon,#mermaid-svg-9eeMscGJDbLgSHKB .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9eeMscGJDbLgSHKB .rough-node .label text,#mermaid-svg-9eeMscGJDbLgSHKB .node .label text,#mermaid-svg-9eeMscGJDbLgSHKB .image-shape .label,#mermaid-svg-9eeMscGJDbLgSHKB .icon-shape .label{text-anchor:middle;}#mermaid-svg-9eeMscGJDbLgSHKB .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9eeMscGJDbLgSHKB .rough-node .label,#mermaid-svg-9eeMscGJDbLgSHKB .node .label,#mermaid-svg-9eeMscGJDbLgSHKB .image-shape .label,#mermaid-svg-9eeMscGJDbLgSHKB .icon-shape .label{text-align:center;}#mermaid-svg-9eeMscGJDbLgSHKB .node.clickable{cursor:pointer;}#mermaid-svg-9eeMscGJDbLgSHKB .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9eeMscGJDbLgSHKB .arrowheadPath{fill:#333333;}#mermaid-svg-9eeMscGJDbLgSHKB .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9eeMscGJDbLgSHKB .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9eeMscGJDbLgSHKB .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9eeMscGJDbLgSHKB .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9eeMscGJDbLgSHKB .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9eeMscGJDbLgSHKB .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9eeMscGJDbLgSHKB .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9eeMscGJDbLgSHKB .cluster text{fill:#333;}#mermaid-svg-9eeMscGJDbLgSHKB .cluster span{color:#333;}#mermaid-svg-9eeMscGJDbLgSHKB div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9eeMscGJDbLgSHKB .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9eeMscGJDbLgSHKB rect.text{fill:none;stroke-width:0;}#mermaid-svg-9eeMscGJDbLgSHKB .icon-shape,#mermaid-svg-9eeMscGJDbLgSHKB .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9eeMscGJDbLgSHKB .icon-shape p,#mermaid-svg-9eeMscGJDbLgSHKB .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9eeMscGJDbLgSHKB .icon-shape .label rect,#mermaid-svg-9eeMscGJDbLgSHKB .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9eeMscGJDbLgSHKB .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9eeMscGJDbLgSHKB .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9eeMscGJDbLgSHKB :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 推荐动态(兴趣驱动)
写推荐结果
取 pid 子列表

pid in (...)
用户行为:浏览/点赞/评论/喜欢
大数据推荐系统

实时计算
Redis

MOVEMENTS_RECOMMEND_{userId}
movement 集合
好友动态(关系驱动)
推模式写收件箱
按 friendId 查时间线

再按 movementId 查详情
好友发布动态
movement_timeline

(含 friendId、movementId)
movement 集合

两条链路最后都落到 movement 集合取详情,但「筛出哪几条」的依据完全不同:好友动态靠关系链写出的时间线,推荐动态靠推荐系统写出的 Redis 结果。

接口设计

接口文档对应原型系统的 movements 模块。实读 MovementController(类级 @RequestMapping("/movements")):

项 内容
请求路径 GET /movements/recommend
请求参数 page(页码,默认 1)、pagesize(每页条数,默认 10)
返回结构 PageResult<MovementsVo>
鉴权 请求头 token,拦截器解析后放入 UserHolder

MovementController 类级 @RequestMapping("/movements"),接口方法如下:

java 复制代码
/**
 * 查询推荐动态
 */
@GetMapping("/recommend")
public ResponseEntity recommend(@RequestParam(defaultValue = "1") Integer page,
                                @RequestParam(defaultValue = "10") Integer pagesize) {
    PageResult pr = movementService.findRecommendMovements(page,pagesize);
    return ResponseEntity.ok(pr);
}

响应 JSON 示例(PageResult 常规序列化形态):

json 复制代码
{
  "page": 1,
  "pagesize": 10,
  "counts": 0,
  "items": [
    {
      "id": "616f3c9e2b2a5a1f3c7b9d12",
      "userId": 106,
      "avatar": "https://oss.example.com/avatar/106.png",
      "nickname": "小妹",
      "gender": "woman",
      "age": 23,
      "tags": ["健身", "旅行"],
      "textContent": "今天天气不错,出去走走",
      "imageContent": ["https://oss.example.com/movement/1.jpg"],
      "distance": "500米",
      "createDate": "2022-10-12 10:00:00",
      "likeCount": 12,
      "commentCount": 0,
      "loveCount": 0,
      "hasLiked": 1,
      "hasLoved": 0
    }
  ]
}

counts 在该接口的公共组装方法里固定传 0(真实总数无法从 pid 串之外廉价获得),客户端按「items 为空即没有更多」处理翻页,这点在联调时容易踩,提前说明。

查询语义:分页、顺序与复用

排序不在查询层,在 Redis 的 pid 顺序里

findMovementsByPids 的实读实现里没有任何 Sort:

java 复制代码
//根据pid查询
public List<Movement> findMovementsByPids(List<Long> pids) {
    Query query = Query.query(Criteria.where("pid").in(pids));
    return mongoTemplate.find(query,Movement.class);
}

推荐结果的排序语义(谁更感兴趣谁靠前)由推荐系统写入 Redis 时的 pid 顺序表达,服务端按顺序截取、按顺序返回,不重复排序。这和好友动态按 created 倒序的时间线排序是两种完全不同的排序来源。

pid 列表的「内存分页」

Redis 里存的可能是 10 条、20 条甚至上百条 pid,而接口是分页的。能不能把整个串拿出来丢给 MongoDB 分页?可以但效率很低。正确做法是对 pid 数组在内存里做截取:第 1 页取前 10 个,第 2 页取第 11~20 个,以此类推,skip/limit 用在 String 数组上,而不是用在 MongoDB 查询上。

java 复制代码
String[] values = redisValue.split(",");
//判断当前页的起始条数是否小于数组总数
if( (page -1) * pagesize < values.length) {
    List<Long> pids = Arrays.stream(values).skip((page - 1) * pagesize).limit(pagesize)
            .map(e->Long.valueOf(e))
            .collect(Collectors.toList());
    list = movementApi.findMovementsByPids(pids);
}

(page - 1) * pagesize < values.length 这个前置判断就是分页越界保护:起始下标已经超出 pid 总数时直接跳过查询,list 保持空集合,最终返回空的 PageResult。

与好友动态共用 getPageResult

拿到 List<Movement> 之后的事情,和好友动态完全一样:提取 userId 批量查 UserInfo、MovementsVo.init() 组装、顺带修点赞状态。这部分已经抽成私有方法 getPageResult(page, pagesize, list),好友动态与推荐动态共用(该方法在查询好友动态:代码实现一篇已逐行拆过,不重复)。复用的关键在于两个接口的差异被压到了「数据来源」这一步,好友动态传时间线查出的列表,推荐动态传 pid 反查出的列表,后面的组装逻辑一行不改。

执行流程(需求侧时序)

MongoDB MovementApi(Dubbo) Redis MovementService MovementController 客户端 MongoDB MovementApi(Dubbo) Redis MovementService MovementController 客户端 #mermaid-svg-iETFFfPVgYo8An27{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iETFFfPVgYo8An27 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iETFFfPVgYo8An27 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iETFFfPVgYo8An27 .error-icon{fill:#552222;}#mermaid-svg-iETFFfPVgYo8An27 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iETFFfPVgYo8An27 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iETFFfPVgYo8An27 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iETFFfPVgYo8An27 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iETFFfPVgYo8An27 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iETFFfPVgYo8An27 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iETFFfPVgYo8An27 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iETFFfPVgYo8An27 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iETFFfPVgYo8An27 .marker.cross{stroke:#333333;}#mermaid-svg-iETFFfPVgYo8An27 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iETFFfPVgYo8An27 p{margin:0;}#mermaid-svg-iETFFfPVgYo8An27 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iETFFfPVgYo8An27 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iETFFfPVgYo8An27 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iETFFfPVgYo8An27 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iETFFfPVgYo8An27 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iETFFfPVgYo8An27 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iETFFfPVgYo8An27 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iETFFfPVgYo8An27 .sequenceNumber{fill:white;}#mermaid-svg-iETFFfPVgYo8An27 #sequencenumber{fill:#333;}#mermaid-svg-iETFFfPVgYo8An27 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iETFFfPVgYo8An27 .messageText{fill:#333;stroke:none;}#mermaid-svg-iETFFfPVgYo8An27 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iETFFfPVgYo8An27 .labelText,#mermaid-svg-iETFFfPVgYo8An27 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iETFFfPVgYo8An27 .loopText,#mermaid-svg-iETFFfPVgYo8An27 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iETFFfPVgYo8An27 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iETFFfPVgYo8An27 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iETFFfPVgYo8An27 .noteText,#mermaid-svg-iETFFfPVgYo8An27 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iETFFfPVgYo8An27 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iETFFfPVgYo8An27 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iETFFfPVgYo8An27 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iETFFfPVgYo8An27 .actorPopupMenu{position:absolute;}#mermaid-svg-iETFFfPVgYo8An27 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-iETFFfPVgYo8An27 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iETFFfPVgYo8An27 .actor-man circle,#mermaid-svg-iETFFfPVgYo8An27 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iETFFfPVgYo8An27 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt Redis 无数据(冷启动) Redis 有数据 GET /movements/recommend?page=1&pagesize=10 findRecommendMovements(page, pagesize) GET MOVEMENTS_RECOMMEND_{userId} randomMovements(pagesize) $sample 随机取 pagesize 条 List<Movement> List<Movement> split(",") 后内存分页截取 pids findMovementsByPids(pids) pid in (...) 查 movement List<Movement> List<Movement> getPageResult(page, pagesize, list) 组装 Vo PageResult 200 PageResult<MovementsVo>

实现顺序遵循 controller → service → API 三层:Controller 接参,Service 负责「读 Redis、判空、分页截取」的数据编排,API 层补两个方法(按 pid 列表查询、随机获取)。

异常与边界

场景 现象 处理策略
冷启动:Redis 无该用户的推荐数据 redisValue 为 null 或空串 调 randomMovements(pagesize),用 MongoDB $sample 聚合随机构造一页动态,保证新用户不至于看到空白页(与今日佳人缺省推荐时的兜底思路一致:宁可随机,不给空页面)
分页越界:(page-1)*pagesize ≥ pid总数 截取结果为空 前置判断直接跳过 MongoDB 查询,返回空 PageResult,客户端据此停止翻页
引用失效:pid 对应的动态已被删除 pid in (...) 查不满一页 与好友动态时间线的「空洞」问题同源:间接层存的是引用,主表删除后引用不会级联清理。表现为某页条数少于 pagesize,无需报错
pid 串格式异常 split 出非数字 Long.valueOf 会抛 NumberFormatException,生产环境应关注推荐系统写入侧的数据质量
未登录/token 失效 进不到业务方法 拦截器统一拦截,UserHolder 拿不到用户 id 前请求已被拒绝

时效性单独说一句:Redis 里这份 pid 列表是推荐系统某一时刻的计算快照,推荐系统通常会在用户产生新行为后重写(或定时刷新)这个 key。查询侧不做过期判断,意味着两次刷新之间,用户可能反复刷到同一批动态,这是「结果查询与推荐计算解耦」的固有代价,属于产品可接受范围。

验证要点(需求侧)

  1. 造数 :向 Redis 手工写入一条 MOVEMENTS_RECOMMEND_106 = "16,17,18,19,20"(pid 用 movement 集合里真实存在的 pid,可在 MongoDB 里 db.movement.find({}, {pid: 1}) 查)
  2. 冷启动分支 :删掉该 key,请求 GET /movements/recommend?page=1&pagesize=10,应返回 10 条随机动态
  3. 正常分支:恢复 key 后请求第 1 页,核对返回动态的 pid 与串中前 pagesize 个一一对应、顺序一致
  4. 翻页:请求第 2 页,核对取的是串中第 pagesize+1 个起的连续片段
  5. 越界 :请求一个超出 pid 总数的页码,返回 items 为空的 PageResult,无异常
  6. 组装核对 :检查返回 Vo 中昵称、头像来自 UserInfo,hasLiked 与 Redis 点赞 Hash 一致(这部分走的是共用方法,好友动态验过一次即可)

常见误解澄清(需求侧)

误解 实际情况
推荐动态直接查 movement 集合(按 state 过滤或全量) 查询入口是 Redis pid 列表,movement 只按 pid in (...) 反查详情,绝无全量扫描
推荐动态经 recommend_user 集合反查 recommend_user 是今日佳人的存储(userId/toUserId/score),推荐动态的结果在 Redis,两者同源不同载
服务端需要实现推荐算法 服务端只读结果、暴露行为数据,算法在推荐系统章节实现
排序由 created 倒序决定 顺序语义完全由 Redis 中 pid 的排列顺序表达,查询层没有 Sort
分页交给 MongoDB 的 skip/limit 分页截取发生在 Service 层的 String 数组上,MongoDB 只做一次 pid in 查询
推荐结果永不过期 是计算快照,由推荐系统在新行为后重写,查询侧不做过期判断

代码实现

文件清单

模块 文件 职责
mf-app-server controller/MovementController.java 暴露 GET /movements/recommend,接分页参数
mf-app-server service/MovementService.java 核心编排:读 Redis、冷启动、内存分页、Dubbo 反查、组装 VO
mf-dubbo-interface api/MovementApi.java 新增 findMovementsByPids、randomMovements 两个接口方法
mf-dubbo-mongo api/MovementApiImpl.java Mongo 实现:pid in 查询 + $sample 随机采样
mf-commons utils/Constants.java Redis key 常量 MOVEMENTS_RECOMMEND
mf-model mongo/Movement.java 动态实体,pid 字段是本次查询的条件字段

工程结构(只列本次涉及的文件):

tree 复制代码
mf
├── mf-app-server
│   └── src/main/java/com/mf/server
│       ├── controller
│       │   └── MovementController.java      # GET /movements/recommend
│       └── service
│           └── MovementService.java         # findRecommendMovements + getPageResult
├── mf-dubbo
│   ├── mf-dubbo-interface
│   │   └── src/main/java/com/mf/dubbo/api
│   │       └── MovementApi.java             # Dubbo 接口:两个新方法声明
│   └── mf-dubbo-mongo
│       └── src/main/java/com/mf/dubbo/api
│           └── MovementApiImpl.java         # Mongo 实现:in 查询 + $sample
├── mf-commons
│   └── src/main/java/com/mf/commons/utils
│       └── Constants.java                   # MOVEMENTS_RECOMMEND = "MOVEMENTS_RECOMMEND_"
└── mf-model
    └── src/main/java/com/mf/model/mongo
        └── Movement.java                    # pid: Long,"用于推荐系统的模型(自动增长)"

数据长什么样:Redis 里的 pid 串

推荐系统的输出不在本次代码范围内(后续章节完成),它算完后把结果写进 Redis,格式是逗号分隔的 pid 串:

shell 复制代码
192.168.31.81:6379> GET MOVEMENTS_RECOMMEND_1
"2562,3639,2063,3448,2128,2597,2893,2333,3330,2642,2541,3002,3561,3649,2384,2504,3397,2843,2341,2249"

这串数据决定了三件事:

  • 顺序即推荐排序。推荐系统按预估兴趣度排好序写入,第 1 个 pid 就是它认为你最可能感兴趣的动态。这个认知在后面「排序语义」一节会反复用到。
  • 分页只能自己切。Mongo 里没有一个「推荐列表」集合可以 skip/limit,数据形态就是一个字符串。
  • key 按用户隔离 。MOVEMENTS_RECOMMEND_{userId},每个用户一份推荐结果。

第一步:Controller 接请求

mf-app-server 的 MovementController,与好友动态接口风格完全一致,page 默认 1、pagesize 默认 10:

java 复制代码
package com.mf.server.controller;

import com.mf.model.mongo.Movement;
import com.mf.model.vo.MovementsVo;
import com.mf.model.vo.PageResult;
import com.mf.server.service.CommentsService;
import com.mf.server.service.MovementService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
@RequestMapping("/movements")
public class MovementController {

    @Autowired
    private MovementService movementService;

    @Autowired
    private CommentsService commentsService;

    /**
     * 查询推荐动态列表
     */
    @GetMapping("/recommend")
    public ResponseEntity recommend(@RequestParam(defaultValue = "1") Integer page,
                                    @RequestParam(defaultValue = "10") Integer pagesize) {
        PageResult pr = movementService.findRecommendMovements(page, pagesize);
        return ResponseEntity.ok(pr);
    }
}

Controller 本身没有逻辑,参数校验也不在这里做,所有分支判断都下沉到 Service。

第二步:Service 编排(核心)

MovementService.findRecommendMovements 是这里的主角。为了看清每一步在做什么,先看整体,再分四段拆。方法在工程中的原貌(MovementService.java:125-148):

java 复制代码
//查询推荐动态
public PageResult findRecommendMovements(Integer page, Integer pagesize) {
    //1、从redis中获取推荐数据
    String redisKey = Constants.MOVEMENTS_RECOMMEND + UserHolder.getUserId();
    String redisValue = redisTemplate.opsForValue().get(redisKey);
    //2、判断推荐数据是否存在
    List<Movement> list = Collections.EMPTY_LIST;
    if (StringUtils.isEmpty(redisValue)) {
        //3、如果不存在,调用API随机构造10条动态数据
        list = movementApi.randomMovements(pagesize);
    } else {
        //4、如果存在,处理pid数据   "16,17,18,19,20,21,10015,10020,10040,10064,10092,10093,10099,10067"
        String[] values = redisValue.split(",");
        //判断当前页的起始条数是否小于数组总数
        if ((page - 1) * pagesize < values.length) {
            List<Long> pids = Arrays.stream(values).skip((page - 1) * pagesize).limit(pagesize)
                    .map(e -> Long.valueOf(e))
                    .collect(Collectors.toList());
            //5、调用API根据PID数组查询动态数据
            list = movementApi.findMovementsByPids(pids);
        }
    }
    //6、调用公共方法构造返回值
    return getPageResult(page, pagesize, list);
}

类头部需要的注入与 import(工程原貌):

java 复制代码
package com.mf.server.service;

import cn.hutool.core.collection.CollUtil;
import com.mf.autoconfig.template.OssTemplate;
import com.mf.commons.utils.Constants;
import com.mf.dubbo.api.MovementApi;
import com.mf.dubbo.api.UserInfoApi;
import com.mf.model.domain.UserInfo;
import com.mf.model.mongo.Movement;
import com.mf.model.vo.ErrorResult;
import com.mf.model.vo.MovementsVo;
import com.mf.model.vo.PageResult;
import com.mf.server.exception.BusinessException;
import com.mf.server.interceptor.UserHolder;
import org.apache.dubbo.config.annotation.DubboReference;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;

@Service
public class MovementService {

    @DubboReference
    private MovementApi movementApi;

    @DubboReference
    private UserInfoApi userInfoApi;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;
}

段一:拼接 key 读 Redis

java 复制代码
String redisKey = Constants.MOVEMENTS_RECOMMEND + UserHolder.getUserId();
String redisValue = redisTemplate.opsForValue().get(redisKey);

两行做一件事:拼出当前用户的推荐 key,取值。两个细节:

  • 常量在 mf-commons 里定义,值是 "MOVEMENTS_RECOMMEND_"(下划线在值的末尾,常量名本身不带尾下划线)。key 拼接收敛到常量类,是为了避免「字符串在业务代码里到处硬编码,推荐系统那边改了 key 名,这边还要全工程搜索替换」:

    java 复制代码
    package com.mf.commons.utils;
    
    //常量定义
    public class Constants {
    
        //推荐动态
        public static final String MOVEMENTS_RECOMMEND = "MOVEMENTS_RECOMMEND_";
    
        //推荐视频
        public static final String VIDEOS_RECOMMEND = "VIDEOS_RECOMMEND_";
    }
  • UserHolder.getUserId() 的用户 id 来自登录拦截器解析 token 后塞进 ThreadLocal 的数据,Service 里不需要再传 userId 参数,这也是同章好友动态接口的统一写法。

段二:空值分支,冷启动

java 复制代码
List<Movement> list = Collections.EMPTY_LIST;
if (StringUtils.isEmpty(redisValue)) {
    //如果不存在,调用API随机构造10条动态数据
    list = movementApi.randomMovements(pagesize);
}

Redis 没数据(新用户还没积累行为特征,推荐系统没给他算过)不能直接返回空白页,那样客户端首页就空了。兜底策略是随机抽 pagesize 条动态凑一页,这就是冷启动。

注意兜底返回的也是 List<Movement>,和正常分支类型一致,后面的组装逻辑就可以共用。list 初始化成 Collections.EMPTY_LIST,让「翻页越界」这种中间情况不写额外代码,越界时 if 不进,list 保持空集合,getPageResult 返回空 PageResult。

段三:内存分页切 pid

java 复制代码
String[] values = redisValue.split(",");
//判断当前页的起始条数是否小于数组总数
if ((page - 1) * pagesize < values.length) {
    List<Long> pids = Arrays.stream(values).skip((page - 1) * pagesize).limit(pagesize)
            .map(e -> Long.valueOf(e))
            .collect(Collectors.toList());
    list = movementApi.findMovementsByPids(pids);
}

Redis 取出的是 "16,17,18,19,20,..." 这样一个字符串,不是集合,分页得自己模拟:

  1. split(",") 切成 String 数组,数组顺序就是推荐系统的排序顺序;
  2. 边界判断 (page - 1) * pagesize < values.length:本页起始下标没越过数组末尾才继续。以 14 条数据、每页 5 条为例,第 1 页起始 0 < 14 可以切,第 2 页 5 < 14,第 3 页 10 < 14,第 4 页 15 ≮ 14,直接走空集合;
  3. Stream 的 skip(起始下标).limit(本页条数) 截出本页 pid,再 map(e -> Long.valueOf(e)) 转成 Long 集合,因为 Movement.pid 是 Long 类型(实体注释写明「Long类型,用于推荐系统的模型(自动增长)」)。

skip/limit 的语义和 Mongo 查询里 Query.skip().limit() 完全同构,只是执行位置从数据库搬到了应用内存。为什么必须这样,后文「内存分页 vs 数据库分页」专门讲。

段四:复用 getPageResult 组装

java 复制代码
return getPageResult(page, pagesize, list);

两个分支最终都拿到 List<Movement>,组装逻辑与好友动态完全一样:批量查发布人 UserInfo → 逐条 MovementsVo.init(userInfo, movement) → 补点赞状态 → 包成 PageResult。这段代码原来写在 findFriendMovements 里,本次实现时用 IDE 的 Extract Method 抽成了私有方法 getPageResult(MovementService.java:97-122)供两处复用:

java 复制代码
private PageResult getPageResult(Integer page, Integer pagesize, List<Movement> list) {
    if (CollUtil.isEmpty(list)) {
        return new PageResult();
    }
    //提取动态发布人的id列表
    List<Long> userIds = CollUtil.getFieldValues(list, "userId", Long.class);
    //根据用户的id列表获取用户详情(一次批量查询,避免循环单查)
    Map<Long, UserInfo> map = userInfoApi.findByIds(userIds, null);
    //一个Movement构造一个vo对象
    List<MovementsVo> vos = new ArrayList<>();
    for (Movement movement : list) {
        UserInfo userInfo = map.get(movement.getUserId());
        if (userInfo != null) {
            MovementsVo vo = MovementsVo.init(userInfo, movement);
            //修复点赞状态的bug,判断hashKey是否存在
            String key = Constants.MOVEMENTS_INTERACT_KEY + movement.getId().toHexString();
            String hashKey = Constants.MOVEMENT_LIKE_HASHKEY + UserHolder.getUserId();
            if (redisTemplate.opsForHash().hasKey(key, hashKey)) {
                vo.setHasLiked(1);
            }
            vos.add(vo);
        }
    }
    return new PageResult(page, pagesize, 0l, vos);
}

PageResult 的口径保持统一(counts 传 0、pages 由构造器算),客户端三种动态列表(好友/推荐/我的)用同一套 VO 和分页结构解析。

第三步:API 层两个方法

mf-dubbo-interface 的 MovementApi 补两个声明:

java 复制代码
package com.mf.dubbo.api;

import com.mf.model.mongo.Movement;
import com.mf.model.vo.PageResult;

import java.util.List;

public interface MovementApi {

    //根据pid数组查询动态
    List<Movement> findMovementsByPids(List<Long> pids);

    //随机获取多条动态数据
    List<Movement> randomMovements(Integer counts);
}

实现落在 mf-dubbo-mongo 的 MovementApiImpl:

java 复制代码
//根据pid查询
public List<Movement> findMovementsByPids(List<Long> pids) {
    Query query = Query.query(Criteria.where("pid").in(pids));
    return mongoTemplate.find(query, Movement.class);
}

//随机查询多条数据
public List<Movement> randomMovements(Integer counts) {
    //1、创建统计对象,设置统计参数
    TypedAggregation aggregation = Aggregation.newAggregation(Movement.class, Aggregation.sample(counts));
    //2、调用mongoTemplate方法统计
    AggregationResults<Movement> results = mongoTemplate.aggregate(aggregation, Movement.class);
    //3、获取统计结果
    return results.getMappedResults();
}

$sample:MongoDB 的随机采样聚合

randomMovements 用的是 MongoDB 聚合管道里的 $sample 阶段,作用是从集合中随机抽取 N 条文档。Spring Data MongoDB 对应 Aggregation.sample(counts),外面包一层 Aggregation.newAggregation(Movement.class, ...) 构成 TypedAggregation。

固定三步:建聚合对象 → mongoTemplate.aggregate 执行 → getMappedResults 拿结果。两个 Movement.class 的分工值得留意:

  • 第一个参数(newAggregation 里)指定聚合的目标集合 ,由实体的 @Document(collection = "movement") 解析出集合名;
  • 第二个参数(aggregate 的实参)指定结果映射类型,因为聚合结果可能是计数、分组统计等任意形状,这里明确告诉模板把每条结果映射回 Movement 对象。

这与 CRUD 下篇初窥聚合时「聚合阶段的输出形状由最后一个阶段决定」的说法呼应:$sample 只是管道里最简单的单阶段聚合,输出还是文档流,所以能直接映射回实体。

一个必须知道的特性:$sample 每次执行结果都不同 (除非加了 $sort 固定种子,工程里没加)。这对冷启动意味着什么,坑表里展开。

findMovementsByPids:in 查询与排序问题

Criteria.where("pid").in(pids) 翻译成 Mongo 查询就是 { pid: { $in: [16, 17, 18, 19, 20] } }。注意实体里集合名与字段名:

java 复制代码
package com.mf.model.mongo;

import com.mf.model.enums.CommentType;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.bson.types.ObjectId;
import org.springframework.data.mongodb.core.mapping.Document;

import java.util.List;

//动态详情表
@Data
@NoArgsConstructor
@AllArgsConstructor
@Document(collection = "movement")
public class Movement implements java.io.Serializable {

    private ObjectId id; //主键id
    private Long pid; //Long类型,用于推荐系统的模型(自动增长)
    private Long created; //发布时间
    private Long userId;
    private String textContent; //文字
    private List<String> medias; //媒体数据,图片或小视频 url
    private String longitude; //经度
    private String latitude; //纬度
    private String locationName; //位置名称
    private Integer state = 0; //状态 0:未审(默认),1:通过,2:驳回

    private Integer likeCount = 0; //点赞数
    private Integer commentCount = 0; //评论数
    private Integer loveCount = 0; //喜欢数
}

集合名 movement 全小写,条件字段 pid 也是小写,机翻稿里念的「PID 大写小写要注意」就是指这个:查询条件写错大小写会静默返回空列表,Mongo 不会报错。

排序语义在哪一层:一个被工程忽略的隐患

这是这里最值得带走的架构认知。先给结论,再给实读证据。

推荐排序写在 Redis 的 pid 顺序里。 推荐系统按预估兴趣度从高到低排好 pid 写入 Redis,skip/limit 按数组下标切页,这一切成立的前提是:最终返回给客户端的动态列表顺序 == Redis 里 pid 的顺序。

但 in 查询不保序。 实读 MovementApiImpl.findMovementsByPids(MovementApiImpl.java:86-89):

java 复制代码
//根据pid查询
public List<Movement> findMovementsByPids(List<Long> pids) {
    Query query = Query.query(Criteria.where("pid").in(pids));
    return mongoTemplate.find(query, Movement.class);
}

没有 Sort,也没有查回后的重排。MongoDB 对 $in 查询的返回顺序不承诺 与 in 数组一致,实际顺序通常接近自然顺序或索引扫描顺序。也就是说:第 1 页从 Redis 切出 [16, 17, 18, 19, 20](推荐系统认为 16 最匹配你),Mongo 返回的可能是 [18, 16, 20, 17, 19]。推荐排序在最后一步被悄悄丢掉了。

用一张流程图标出顺序断裂的位置:
#mermaid-svg-thoaAeyMYWMy6J3k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-thoaAeyMYWMy6J3k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-thoaAeyMYWMy6J3k .error-icon{fill:#552222;}#mermaid-svg-thoaAeyMYWMy6J3k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-thoaAeyMYWMy6J3k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-thoaAeyMYWMy6J3k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-thoaAeyMYWMy6J3k .marker.cross{stroke:#333333;}#mermaid-svg-thoaAeyMYWMy6J3k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-thoaAeyMYWMy6J3k p{margin:0;}#mermaid-svg-thoaAeyMYWMy6J3k .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-thoaAeyMYWMy6J3k .cluster-label text{fill:#333;}#mermaid-svg-thoaAeyMYWMy6J3k .cluster-label span{color:#333;}#mermaid-svg-thoaAeyMYWMy6J3k .cluster-label span p{background-color:transparent;}#mermaid-svg-thoaAeyMYWMy6J3k .label text,#mermaid-svg-thoaAeyMYWMy6J3k span{fill:#333;color:#333;}#mermaid-svg-thoaAeyMYWMy6J3k .node rect,#mermaid-svg-thoaAeyMYWMy6J3k .node circle,#mermaid-svg-thoaAeyMYWMy6J3k .node ellipse,#mermaid-svg-thoaAeyMYWMy6J3k .node polygon,#mermaid-svg-thoaAeyMYWMy6J3k .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-thoaAeyMYWMy6J3k .rough-node .label text,#mermaid-svg-thoaAeyMYWMy6J3k .node .label text,#mermaid-svg-thoaAeyMYWMy6J3k .image-shape .label,#mermaid-svg-thoaAeyMYWMy6J3k .icon-shape .label{text-anchor:middle;}#mermaid-svg-thoaAeyMYWMy6J3k .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-thoaAeyMYWMy6J3k .rough-node .label,#mermaid-svg-thoaAeyMYWMy6J3k .node .label,#mermaid-svg-thoaAeyMYWMy6J3k .image-shape .label,#mermaid-svg-thoaAeyMYWMy6J3k .icon-shape .label{text-align:center;}#mermaid-svg-thoaAeyMYWMy6J3k .node.clickable{cursor:pointer;}#mermaid-svg-thoaAeyMYWMy6J3k .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-thoaAeyMYWMy6J3k .arrowheadPath{fill:#333333;}#mermaid-svg-thoaAeyMYWMy6J3k .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-thoaAeyMYWMy6J3k .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-thoaAeyMYWMy6J3k .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-thoaAeyMYWMy6J3k .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-thoaAeyMYWMy6J3k .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-thoaAeyMYWMy6J3k .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-thoaAeyMYWMy6J3k .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-thoaAeyMYWMy6J3k .cluster text{fill:#333;}#mermaid-svg-thoaAeyMYWMy6J3k .cluster span{color:#333;}#mermaid-svg-thoaAeyMYWMy6J3k div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-thoaAeyMYWMy6J3k .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-thoaAeyMYWMy6J3k rect.text{fill:none;stroke-width:0;}#mermaid-svg-thoaAeyMYWMy6J3k .icon-shape,#mermaid-svg-thoaAeyMYWMy6J3k .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-thoaAeyMYWMy6J3k .icon-shape p,#mermaid-svg-thoaAeyMYWMy6J3k .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-thoaAeyMYWMy6J3k .icon-shape .label rect,#mermaid-svg-thoaAeyMYWMy6J3k .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-thoaAeyMYWMy6J3k .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-thoaAeyMYWMy6J3k .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-thoaAeyMYWMy6J3k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 推荐系统

按兴趣度排序写入 Redis
Redis: '16,17,18,19,20'
Service 内存分页

skip/limit 按序切出 pids
Dubbo 传参 16,17,18,19,20
Mongo $in 查询

⚠ 返回顺序不保证与 in 数组一致
客户端展示顺序

≠ 推荐排序

实读结论:工程没有做「按 Redis 顺序重排」,这是一个真实的顺序丢失隐患。 教学工程里数据量小、没人较真展示顺序,问题被掩盖了;放到真实推荐场景,这就是「排序白算了」级别的事故。

修法不复杂:查回后以 pid 为键转 Map,再按 pids 顺序组装,一次遍历 O(n):

java 复制代码
//根据pid查询(按传入顺序返回,修复 in 查询不保序的问题)
public List<Movement> findMovementsByPids(List<Long> pids) {
    Query query = Query.query(Criteria.where("pid").in(pids));
    List<Movement> movements = mongoTemplate.find(query, Movement.class);
    //以pid为键建索引,再按pids的原顺序重排
    Map<Long, Movement> map = movements.stream()
            .collect(Collectors.toMap(Movement::getPid, m -> m, (a, b) -> a));
    return pids.stream()
            .map(map::get)
            .filter(Objects::nonNull) //剔除Redis中pid已失效的空洞
            .collect(Collectors.toList());
}

顺带处理了另一个问题:filter(Objects::nonNull) 把 Redis 里有、Mongo 里已被删除的 pid(引用失效空洞)过滤掉,避免 NPE。

对比一下同工程的好友动态实现,同样的 in 查询模式出现在 findFriendMovements 里:

java 复制代码
Query movementQuery = Query.query(Criteria.where("id").in(list));
return mongoTemplate.find(movementQuery, Movement.class);

好友动态不在乎顺序,它外面那层时间线查询已经 Sort.by(Sort.Order.desc("created")) 定过序,in 反查只是补详情。而推荐动态的顺序语义全押在 pid 顺序上,in 查询却没有任何排序兜底,两者的差别就在这。排序语义可以放在数据库层(Sort)、Redis 层(列表顺序)或应用层(重排),但必须有明确的一层负责,不能三层都缺席。

内存分页 vs 数据库分页

同章两种动态列表的分页方式对比,差异的根源是分页锚点数据存在哪:

对比项 好友动态 推荐动态
分页锚点 movement_time_line 集合(Mongo) Redis 里的 pid 字符串
分页执行位置 Mongo 端 Query.skip().limit() app-server 内存 Stream skip().limit()
排序 Mongo Sort by created desc Redis pid 顺序(理论上的推荐排序)
二次反查 id in 查动态详情 pid in 查动态详情
深分页代价 Mongo skip 越深扫描越多 整个 pid 串每次请求都读进内存

为什么推荐动态的分页只能在内存做?因为 pid 串是推荐系统的输出物,不是 Mongo 里的集合。要让 Mongo 分页,就得把推荐结果落库成集合再查询,多一份存储、多一份同步逻辑,而且推荐结果本来就是读多写少、生命周期短的缓存数据,放 Redis 是合理的。于是分页被「挤」到了应用内存:每次请求都要把完整的 pid 串拉出来 split 成数组,再切出本页那几个 pid。

代价与上限要一起看:

  • 代价:pid 串全量进内存。一条 pid 十几个字符,1 万条也就 100KB 级别,QPS 高时这个 split 会成为 CPU 热点,但不算致命。
  • 上限 :推荐列表天然有限(给用户推几十一条足够翻几页了),不像日志表那样无限增长。所以内存分页在这里是可接受的妥协 ,不是设计失误。真到推荐列表上万条的规模,正确做法是让推荐系统直接输出分页结果(或用 Redis List 结构 + LRANGE 分页),而不是在 Service 里切字符串。

全链路时序图

MongoDB MovementApi(Dubbo) Redis MovementService MovementController 客户端 MongoDB MovementApi(Dubbo) Redis MovementService MovementController 客户端 #mermaid-svg-sPjN27zdqaj11tcW{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sPjN27zdqaj11tcW .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sPjN27zdqaj11tcW .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sPjN27zdqaj11tcW .error-icon{fill:#552222;}#mermaid-svg-sPjN27zdqaj11tcW .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sPjN27zdqaj11tcW .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sPjN27zdqaj11tcW .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sPjN27zdqaj11tcW .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sPjN27zdqaj11tcW .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sPjN27zdqaj11tcW .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sPjN27zdqaj11tcW .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sPjN27zdqaj11tcW .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sPjN27zdqaj11tcW .marker.cross{stroke:#333333;}#mermaid-svg-sPjN27zdqaj11tcW svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sPjN27zdqaj11tcW p{margin:0;}#mermaid-svg-sPjN27zdqaj11tcW .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sPjN27zdqaj11tcW text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-sPjN27zdqaj11tcW .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sPjN27zdqaj11tcW .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-sPjN27zdqaj11tcW .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-sPjN27zdqaj11tcW .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-sPjN27zdqaj11tcW #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-sPjN27zdqaj11tcW .sequenceNumber{fill:white;}#mermaid-svg-sPjN27zdqaj11tcW #sequencenumber{fill:#333;}#mermaid-svg-sPjN27zdqaj11tcW #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-sPjN27zdqaj11tcW .messageText{fill:#333;stroke:none;}#mermaid-svg-sPjN27zdqaj11tcW .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sPjN27zdqaj11tcW .labelText,#mermaid-svg-sPjN27zdqaj11tcW .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-sPjN27zdqaj11tcW .loopText,#mermaid-svg-sPjN27zdqaj11tcW .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-sPjN27zdqaj11tcW .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-sPjN27zdqaj11tcW .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-sPjN27zdqaj11tcW .noteText,#mermaid-svg-sPjN27zdqaj11tcW .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-sPjN27zdqaj11tcW .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sPjN27zdqaj11tcW .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sPjN27zdqaj11tcW .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-sPjN27zdqaj11tcW .actorPopupMenu{position:absolute;}#mermaid-svg-sPjN27zdqaj11tcW .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-sPjN27zdqaj11tcW .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-sPjN27zdqaj11tcW .actor-man circle,#mermaid-svg-sPjN27zdqaj11tcW line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-sPjN27zdqaj11tcW :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt Redis 有推荐数据 Redis 无数据(冷启动) GET /movements/recommend?page=1&pagesize=10 findRecommendMovements(page, pagesize) GET MOVEMENTS_RECOMMEND_106 "16,17,18,19,20,..." split(",") → Stream skip/limit 内存分页 边界判断 (page-1)*pagesize < values.length findMovementsByPids(pids) {pid: {in: \[16,17,18,19,20\]}} List\(顺序不保证) List\ null randomMovements(pagesize) 聚合管道 \[{sample: {size: pagesize}}] 随机 pagesize 条 Movement List<Movement> findByIds(userIds) 批查用户资料 userInfo 批量查询 List<UserInfo> Map<Long, UserInfo> MovementsVo.init + 点赞状态 + PageResult PageResult 200 OK (PageResult)

原型线框图

doc 复制代码
+--------------------------------------+
|  圈子                    [推荐] [好友] |
+--------------------------------------+
|  (oo)  小妹            2小时前     |
|  今天天气不错,出去走走               |
|  [ 图片 ]                            |
|  ♡ 12    评论 3                      |
+--------------------------------------+
|  (oo)  慕课君              5小时前     |
|  新买的机械键盘到了,码字真香          |
|  [ 图片 ] [ 图片 ]                   |
|  ♡ 8     评论 1                      |
+--------------------------------------+
|              ...卡片流...             |
+--------------------------------------+

要点:

  • 「推荐」tab 默认选中(与好友动态 tab 平级切换),卡片结构与好友动态完全一致,用户无感知
  • 卡片元素:头像、昵称、发布时间、文字内容、图片流、点赞数与状态(hasLiked)、评论入口
  • 图片用 medias 数组渲染,1~N 张自适应排布
  • 翻页由客户端滚动触底时携带 page+1 请求下一页;items 为空即停止加载

坑表

坑 现象 原因与解法
Redis key 拼错 每个用户每次请求都走随机兜底,「推荐」变成纯随机流 key 与推荐系统写入侧不一致。统一用 Constants.MOVEMENTS_RECOMMEND 拼接,别手写字符串
in 查询顺序丢失 页面展示顺序与推荐排序不符,推荐排序形同虚设 $in 不保序(见上文实读结论)。查回后转 Map<pid, Movement> 按 pids 顺序重排
pid 引用失效空洞 列表偶尔少条目,或重排时 NPE Redis 里的 pid 对应动态可能已被删除,in 查询查不到。组装时 filter(Objects::nonNull) 兜底
$sample 每次结果不同 冷启动用户翻页时数据跳变:第 2 页和第 1 页可能重复或整屏变化 冷启动不是「一次性随机后缓存」,每次请求都重新 $sample。取舍见下文分析
Redis 与 Mongo 数据不同步 推荐结果长期不刷新,展示的是旧推荐甚至已下线内容 Redis 中的推荐结果没有过期机制。真实系统应给 key 设 TTL,由推荐系统定期重算覆盖
查询字段大小写写错 Criteria.where("PID") 静默返回空列表,不报错 Mongo 字段名区分大小写,实体里是全小写 pid、集合名 movement

$sample 冷启动的随机性对分页的影响,单独展开。翻页时每次请求都会完整重跑一遍 findRecommendMovements:Redis 还是空 → 再来一次 $sample → 第二页抽出的 10 条与第一页毫无关联,既可能重复也可能「跳过」一批。工程直接接受了这个随机性,理由说得通:冷启动只是推荐数据缺失时的临时兜底 ,用户一旦产生行为,推荐系统就会算出结果写 Redis,随机态很快结束。更稳妥的做法也很简单,冷启动时把随机结果写回 Redis(SET + 过期时间),让后续翻页命中同一份 pid 串,保证会话内翻页一致;代价是随机结果被「固化」几分钟,对冷启动场景来说这反而是特性(用户看到的列表稳定)。

验证:可复现步骤

环境:Redis 已启动,mf-app-server 端口 18080(application.yml 中配置),手头有任一登录用户 token。

场景一:有推荐数据,核对顺序与内容。

shell 复制代码
# 手工模拟推荐系统写入(106 号用户,pid 串按你的库中真实 pid 调整)
redis-cli SET MOVEMENTS_RECOMMEND_106 "16,17,18,19,20"

# 请求第 1 页
curl "http://127.0.0.1:18080/movements/recommend?page=1&pagesize=5" \
     -H "Authorization: Bearer <token>"

核对两点:返回 items 数量 = 5;在第 1 页内(配合打断点或对比 pid)确认取的是 pid 串的前 5 个元素。第 2 页 ?page=2&pagesize=5 应对应串中第 6~10 个位置,注意若未应用上文的排序修复,items 的展示顺序可能与 pid 顺序不一致 ,这正好可以现场验证 in 不保序的结论。翻到第 2 页(14 条数据、每页 5 条的例子中 page=4)时 items 为空,验证边界判断生效。

场景二:删 key,观察 $sample 随机冷启动。

shell 复制代码
redis-cli DEL MOVEMENTS_RECOMMEND_106
curl "http://127.0.0.1:18080/movements/recommend?page=1&pagesize=5" \
     -H "Authorization: Bearer <token>"
# 连续执行两次,对比两次返回的动态列表

两次请求返回的列表内容不同,这就是 $sample 的随机性。也可以在 MovementApiImpl.randomMovements 打断点,确认 Redis 空值时走到了聚合分支。

常见误解澄清(代码侧)

误解 实际情况
「推荐动态是查询时实时算出来的」 推荐结果由推荐系统离线/实时算好后写入 Redis,查询侧只做读取、分页、反查
「in 查询会按传入数组顺序返回」 不会。$in 只过滤不排序,顺序语义必须由某一层显式负责
「分页都是数据库做的」 推荐动态的分页在应用内存里用 Stream skip/limit 完成,Mongo 只做按 pid 批量捞数据
「$sample 抽一次就固定了」 每次聚合执行都重新随机,冷启动翻页会出现重复/跳变
「冷启动随机 10 条是写死的」 随机条数传的是 pagesize,与请求的分页大小保持一致
「pid 是动态的数据库主键」 pid 是发布动态时由 IdWorker 生成的独立自增长 id,专为推荐系统建模使用,与 _id(ObjectId)是两个字段

总结

  • 查询推荐动态 = 读 Redis + 冷启动兜底 + 内存分页 + Dubbo 反查 四步,代码量不大,但每一层的数据职责要分清:Redis 存推荐排序,Mongo 存动态详情,谁都不越界。
  • 顺序丢失是本工程确认存在的隐患:findMovementsByPids 的 in 查询没有 Sort、没有重排,推荐排序在最后一步丢掉。修法是查回后按 pids 顺序组装,顺带过滤失效 pid。
  • 内存分页是数据形态决定的妥协:pid 串在 Redis,Mongo 没有可 skip/limit 的集合。推荐列表规模有限,这个妥协可接受;规模大了就该让推荐系统输出分页结果。
  • $sample 是 MongoDB 的随机采样聚合阶段,三步固定写法;它的随机性决定了冷启动翻页会跳变,想稳就在冷启动时把结果写回 Redis 并设过期。
  • 与好友动态共享 getPageResult 组装逻辑,三种动态列表对客户端呈现同一套 PageResult 口径,这是同章反复出现的「数据源不同、展示结构一致」的落地。

需求侧还要记牢:推荐动态与好友动态的展示层完全共用,唯一差异是数据来源,前者读 Redis 推荐结果(关系靠推荐系统),后者读 movement_timeline 收件箱(关系靠推模式)。Redis 结构为 MOVEMENTS_RECOMMEND_{userId} → 逗号分隔的 pid 串;pid 是 Movement 上的 Long 型自增字段,专为推荐模型设计。查询链路三步:读 Redis → 内存分页截取 pid → 按 pid in (...) 反查 MongoDB,之后复用 getPageResult 组装返回。排序语义在 pid 顺序里,不在查询层;冷启动用 $sample 随机兜底,越界返回空页,删除导致的空洞不报错。

下一篇进入同章最后一节查询单条动态,把动态详情接口补齐,圈子功能模块随之收尾。

API 速览

方法 路径 / 签名 参数 返回
recommend GET /movements/recommend page(默认 1)、pagesize(默认 10) PageResult(items 为 MovementsVo 列表)
findMovementsByPids Dubbo MovementApi List<Long> pids List<Movement>
randomMovements Dubbo MovementApi Integer counts List<Movement>(随机 counts 条)

官方文档

相关推荐
jaysee-sjc1 小时前
【苍穹外卖】Day10:定时任务与 WebSocket 实时提醒实战
java·开发语言·javascript·数据库·vue.js·websocket·网络协议
ysu_03141 小时前
【C++ 进阶之路】(二)从内联函数到类与对象:深挖内存对齐、this 指针与三大默认成员函数
开发语言·数据结构·c++·笔记·面向对象·this
独隅1 小时前
Claude 高效应用实战指南
android·java·开发语言·claude·claude code
布莱克6051 小时前
C++ 中 set 与 map 的区别详解
开发语言·c++·算法·哈希
WJ.Polar1 小时前
异常、泛型、集合、Stream流
java·数据结构
牧瀬クリスだ1 小时前
Redis五大数据类型全解析:从入门到实战
java·数据库·redis·后端·缓存
Nuanyt1 小时前
LeetCode Hot 100 · 双指针 3 题 Java
java·笔记·算法·leetcode·双指针
Wang's Blog2 小时前
Java 项目实战: 交友项目 - 圈子互动
java·开发语言·交友
Rain的Java大神之路2 小时前
北漂 13 年,我把 Java 家底钉成了这张知识图谱(全站免费)
java·后端·程序员