概述
圈子的「写」链路跑通了:发布动态落库、异步写好友时间线、能查自己的动态。现在补全「读」链路,好友动态、推荐动态、单条动态,三个查询入口共用一套 VO 组装逻辑。这里是这一天任务的导引:先盘昨天留下的能力,再按依赖顺序拆今天的活。
纲要
- 昨日回顾:写链路就位,今天能查到什么
- 发布动态:OSS 上传 +
@Async异步写时间线 - 三表设计:movement / movement_timeline / friend
- 首页推荐:MongoTemplate 分页 + 跨存储组装
- MongoDB 集群:副本集与分片的取舍
- 任务书:三个查询接口的需求、产出与验收标准
- 任务依赖关系(Mermaid 图)
- 本章代码产出预告(目录树)
- 学习建议与常见误解澄清
昨日回顾:写链路已经就位
之前的工作建立在 day03 打下的 MongoDB 基础上,实体用 @Document 做映射、MongoTemplate 做 CRUD、mongo 服务与 mysql 服务之间的跨存储组装模式。这些能力昨天全部铺开用了一遍,今天查询侧继续复用。逐项对照:
| 昨天做了什么 | 产出什么 | 今天怎么用 |
|---|---|---|
| 首页推荐:分页查询推荐好友列表 | RecommendUserApi.queryRecommendUserList,Mongo 分页 + 批量查 UserInfo 组装 VO |
同一套「列表 + 详情批量组装」骨架,今天三个查询接口照抄这个模式 |
| MongoDB 集群:副本集解决单点故障,分片解决海量数据 | 对分片键的认知:动态表按 userId 分片、时间线表按 friendId 分片 | 查询条件要顺着分片键走,好友动态按 friendId 查时间线正是为此 |
| 圈子表设计方案:三种方案对比,选定第三种 | movement(动态总表)、movement_timeline(好友时间线)、friend(好友关系)三张表 | 好友动态的数据源就是昨天设计好的时间线表 |
发布动态:文字校验、图片传 OSS、@Async 异步写时间线 |
MovementApi.publish + TimeLineService.saveTimeLine |
好友动态能「只查一张表」的前提,就是发布时已经把动态分发到每个好友的时间线里 |
| 我的动态:按 userId 分页查 movement | MovementApi.findByUserId |
今天查询单条动态、推荐动态的实现模板都从这里出 |
有一个衔接点值得单独说:昨天发布动态拆成了「主表同步写 + 时间线异步写」,当时看是为了发布接口快。今天写好友动态查询时会发现这个拆分的另一半价值,读的时候只需要查自己的一份时间线,不用遍历好友逐个去查他们的动态。
今日任务书
按讲义与源码顺序,今天一共三块活,外加对下一章互动功能的预告:
| 板块 | 做什么 | 产出物 | 验收标准 |
|---|---|---|---|
| 查询好友动态 | 需求分析 + 代码实现:先查自己的时间线表(friendId = 当前用户),提取 movementId 批量查动态详情,再组装 MovementsVo |
MovementApi.findFriendMovements 及其实现 |
用 106 号用户登录,只返回其好友发布的动态,按时间倒序分页 |
| 查询推荐动态 | 需求分析 + 代码实现:从 Redis 取推荐 pid 列表做内存分页;无推荐数据时用 $sample 随机取 10 条兜底 |
MovementApi.findMovementsByPids、MovementApi.randomMovements、Constants.MOVEMENTS_RECOMMEND 常量 |
分页返回推荐动态;清掉 Redis 键后仍能随机返回数据,不报空指针 |
| 查询单条动态 | 根据动态 id 查详情 | MovementApi.findById 及其实现 |
按动态 id 返回单条 MovementsVo,作为详情页与评论列表的前置接口 |
| 预告:圈子互动 | 评论、点赞、喜欢,下一章的内容,这里不展开 | 无 | 无 |
三个查询接口的对比,差异全在数据源和分页方式上:
| 接口 | 数据源 | 分页方式 |
|---|---|---|
GET /movements(好友动态) |
movement_timeline,条件 friendId = 我 |
Mongo skip / limit,按 created 倒序 |
GET /movements/recommend(推荐动态) |
Redis 中的 pid 逗号串,兜底查 movement 表 | 应用内存里对 pid 数组做 skip / limit 流式截取 |
GET /movements/{id}(单条动态) |
movement,条件 _id |
无分页 |
GET /movements/all(我的动态,昨天完成) |
movement,条件 userId = 我 |
Mongo skip / limit |
推荐动态这条要展开一句:推荐系统算完的结果是写进 Redis 的,形如 "2562,3639,2063,...",一堆 pid 由逗号分隔。MongoDB 里存的只有动态原始数据,「第几页推荐了谁」这件事 Redis 不管、Mongo 也不管,得自己在 Service 层切。所以它的分页代码和另外两个长得不一样。
任务依赖关系
#mermaid-svg-zU2h1SpgoU6KhZt7{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-zU2h1SpgoU6KhZt7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zU2h1SpgoU6KhZt7 .error-icon{fill:#552222;}#mermaid-svg-zU2h1SpgoU6KhZt7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zU2h1SpgoU6KhZt7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .marker.cross{stroke:#333333;}#mermaid-svg-zU2h1SpgoU6KhZt7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zU2h1SpgoU6KhZt7 p{margin:0;}#mermaid-svg-zU2h1SpgoU6KhZt7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster-label text{fill:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster-label span{color:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster-label span p{background-color:transparent;}#mermaid-svg-zU2h1SpgoU6KhZt7 .label text,#mermaid-svg-zU2h1SpgoU6KhZt7 span{fill:#333;color:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .node rect,#mermaid-svg-zU2h1SpgoU6KhZt7 .node circle,#mermaid-svg-zU2h1SpgoU6KhZt7 .node ellipse,#mermaid-svg-zU2h1SpgoU6KhZt7 .node polygon,#mermaid-svg-zU2h1SpgoU6KhZt7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .rough-node .label text,#mermaid-svg-zU2h1SpgoU6KhZt7 .node .label text,#mermaid-svg-zU2h1SpgoU6KhZt7 .image-shape .label,#mermaid-svg-zU2h1SpgoU6KhZt7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zU2h1SpgoU6KhZt7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .rough-node .label,#mermaid-svg-zU2h1SpgoU6KhZt7 .node .label,#mermaid-svg-zU2h1SpgoU6KhZt7 .image-shape .label,#mermaid-svg-zU2h1SpgoU6KhZt7 .icon-shape .label{text-align:center;}#mermaid-svg-zU2h1SpgoU6KhZt7 .node.clickable{cursor:pointer;}#mermaid-svg-zU2h1SpgoU6KhZt7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .arrowheadPath{fill:#333333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zU2h1SpgoU6KhZt7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zU2h1SpgoU6KhZt7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zU2h1SpgoU6KhZt7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster text{fill:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 .cluster span{color:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 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-zU2h1SpgoU6KhZt7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zU2h1SpgoU6KhZt7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zU2h1SpgoU6KhZt7 .icon-shape,#mermaid-svg-zU2h1SpgoU6KhZt7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zU2h1SpgoU6KhZt7 .icon-shape p,#mermaid-svg-zU2h1SpgoU6KhZt7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zU2h1SpgoU6KhZt7 .icon-shape .label rect,#mermaid-svg-zU2h1SpgoU6KhZt7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zU2h1SpgoU6KhZt7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zU2h1SpgoU6KhZt7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zU2h1SpgoU6KhZt7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} @Async 异步写入
发布时 idWorker 生成 pid
pid 逗号串
动态 pid 池
day03 MongoDB 基础
实体映射 / MongoTemplate / 跨存储组装
昨日:发布动态
movement 动态表
movement_timeline 时间线表
friend 好友表
今日:好友动态
friendId=我 查时间线
推荐系统离线计算
Redis
MOVEMENTS_RECOMMEND_用户id
今日:推荐动态
pid 内存分页,缺失时 $sample 兜底
今日:单条动态
详情页与评论列表的前置
读法:Mongo 能力是所有功能的地基;好友动态依赖昨天异步写入的时间线数据,没有发布动态,就没有东西可查;推荐动态依赖推荐系统提前写进 Redis 的 pid 列表;单条动态排在最后,因为它服务于从列表点进详情的场景,下一章的评论列表也要先拿它取到动态。
本章代码产出预告
实读 04-圈子功能实现/01-代码/mf 工程,与圈子动态相关的类分布如下(今天的新增以方法为主,集中在 MovementApi 一侧):
tree
mf
├── mf-app-server
│ ├── src/main/java/com/mf/server/controller/MovementController.java
│ │ └── 今天新增:GET /movements、GET /movements/recommend、GET /movements/{id}
│ ├── src/main/java/com/mf/server/service/MovementService.java
│ │ └── 今天新增:findFriendMovements、findRecommendMovements、findById、公共组装方法 getPageResult
│ └── src/test/java/com/wb/test/MovementApiTest.java
├── mf-dubbo
│ ├── mf-dubbo-interface/src/main/java/com/mf/dubbo/api/MovementApi.java
│ │ └── 今天新增:findFriendMovements、findMovementsByPids、randomMovements、findById
│ └── mf-dubbo-mongo/src/main/java/com/mf/dubbo
│ ├── api/MovementApiImpl.java # MongoTemplate 查询落地,含 $sample 随机取样
│ └── utils/TimeLineService.java # 昨天的异步写时间线,今天读侧的数据来源
├── mf-model/src/main/java/com/mf/model
│ ├── mongo/Movement.java # 动态表实体,pid 字段是推荐系统的关联键
│ ├── mongo/MovementTimeLine.java # 时间线实体,friendId 是好友动态的查询条件
│ └── vo/MovementsVo.java # 三个查询入口共用的返回 VO
└── mf-commons/src/main/java/com/mf/commons/utils
└── Constants.java # 今天新增 MOVEMENTS_RECOMMEND 常量
学习建议
- 好友动态是异步改造的读侧回报。昨天发布动态时把时间线写入拆成异步,图的是发布接口快;今天查好友动态只要一张时间线表加一次批量 in 查询,不用遍历好友。写侧拆分换来了读侧简单,这条「写时多写一份、读时少扫多表」的取舍在时间线类业务里反复出现,值得对照代码把两条链路都走一遍。
- 推荐动态是「读多写多」场景的典型取巧 :推荐结果一次性从 Redis 取出,翻页在应用内存里 skip / limit,不再反复打 Redis;没有推荐数据时用
$sample随机取样兜底,保证页面永远有内容。代价是 pid 列表长了以后内存分页有浪费,这也是后面章节引出更完整方案的原因。 - 三个查询共用一套组装逻辑 。
getPageResult抽出来复用,MovementsVo.init负责动态数据 + 用户详情的拼装。看到重复的组装代码就往公共方法收,别复制三份。
常见误解澄清
| 常见误解 | 实际情况 |
|---|---|
| 查好友动态要遍历每个好友,逐个查他们发布的 movement | 只查自己的 movement_timeline 表(friendId = 我),发动态时已经替你分发好了 |
| 推荐动态的结果在 MongoDB 里按分数排好队 | 推荐结果在 Redis 里,是一串 pid 文本;MongoDB 只有动态原始数据,分页逻辑要自己写 |
| 分页一律丢给数据库 skip / limit | Redis pid 列表的分页在应用内存里用流式 skip / limit 完成,两者不能混 |
| 查询单条动态是个独立功能点 | 它是详情页和评论列表的前置接口,下一章评论功能第一步就是拿动态 id 查详情 |
| 时间线表是冗余,删掉也能跑 | 功能能跑,但查询退化成全表扫好友动态;分片场景下按 friendId 分片的设计也一并失效 |
API 速览
| 接口 | 方法 | 参数 | 说明 |
|---|---|---|---|
/movements |
GET | page、pagesize | 好友动态分页 |
/movements/recommend |
GET | page、pagesize | 推荐动态分页 |
/movements/{id} |
GET | 路径参数动态 id | 单条动态详情 |
/movements/all |
GET | userId、page、pagesize | 我的动态(昨天已完成) |
官方文档
总结
- 昨天的产出(三表设计、异步写时间线、Mongo 分页组装模板)构成了今天全部查询的地基,尤其时间线表是好友动态的直接数据源。
- 今天的顺序是好友动态 → 推荐动态 → 单条动态:前两者数据源不同、分页方式不同,但共用一套 VO 组装;单条动态为下一章互动功能铺路。
- 做的时候抓住一条主线:同样是「查动态列表」,数据放哪(Mongo / Redis)、怎么分页(数据库 / 内存)决定了每份实现长什么样。