概述
好友动态查的是自己的收件箱(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。查询侧不做过期判断,意味着两次刷新之间,用户可能反复刷到同一批动态,这是「结果查询与推荐计算解耦」的固有代价,属于产品可接受范围。
验证要点(需求侧)
- 造数 :向 Redis 手工写入一条
MOVEMENTS_RECOMMEND_106 = "16,17,18,19,20"(pid 用 movement 集合里真实存在的 pid,可在 MongoDB 里db.movement.find({}, {pid: 1})查) - 冷启动分支 :删掉该 key,请求
GET /movements/recommend?page=1&pagesize=10,应返回 10 条随机动态 - 正常分支:恢复 key 后请求第 1 页,核对返回动态的 pid 与串中前 pagesize 个一一对应、顺序一致
- 翻页:请求第 2 页,核对取的是串中第 pagesize+1 个起的连续片段
- 越界 :请求一个超出 pid 总数的页码,返回
items为空的PageResult,无异常 - 组装核对 :检查返回 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 名,这边还要全工程搜索替换」:javapackage 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,..." 这样一个字符串,不是集合,分页得自己模拟:
split(",")切成 String 数组,数组顺序就是推荐系统的排序顺序;- 边界判断
(page - 1) * pagesize < values.length:本页起始下标没越过数组末尾才继续。以 14 条数据、每页 5 条为例,第 1 页起始 0 < 14 可以切,第 2 页 5 < 14,第 3 页 10 < 14,第 4 页 15 ≮ 14,直接走空集合; - 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\
原型线框图
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 条) |