概述
本文干了两件事:把圈子动态的查询补成三视图(好友动态走 timeLine 收件箱、推荐动态走 Redis pid 列表、单条动态按 _id 直查),再把互动闭环(评论/点赞/喜欢)压进一张 comment 集合,用 commentType 区分类型,最后用 Redis Hash 把「我是否点过赞/喜欢过」这个高频读从 Mongo 里捞出来。这里不重复各篇的实现细节,只做三件事:把全文串成一张地图、沉淀五个可复用套路、给出可勾选的自检项,外加一个工程快照里真实存在的 bug,作为全文最有价值的教学案例。
纲要
- 本文复盘:动态查询三视图 + 互动闭环,各自的数据源与对应篇目
- 知识地图:从查询视图到互动存储到 Redis 标记的完整链路(Mermaid)
- 可复用套路清单:三合一互动存储、Redis Hash 状态标记、跨存储组装、条件下沉、推模式时间线
- 关键坑回顾表:全文十二个翻车点,含 hasLiked 被喜欢污染的现场分析
- 自检清单:每项都给出「怎么验」
- 练习建议:五个可验证的技术任务(原作业改造)
- 常见误解澄清 + 下一章预告
本文做了什么:两块内容的复盘
按讲义的推进顺序,本文两大功能块与已交付篇目的对应关系:
| 功能块 | 数据源 | 解决的问题 | 对应篇目 |
|---|---|---|---|
| 查询好友动态 | movement_time_line(收件箱) |
按 friendId 查时间线,再批查动态详情,VO 组装 | 圈子互动:概述圈子互动:业务特点分析及本文查询各篇 |
| 查询推荐动态 | Redis MOVEMENTS_RECOMMEND_{userId}(pid 逗号串) |
逗号串内存分页,空值走 $sample 随机兜底 |
同上 |
| 查询单条动态 | movement 本体 |
按 _id 直查,评论页入口 |
同上 |
| 环境搭建 + 发布评论 | comment 集合(commentType=2) |
三合一实体、CommentType 枚举、公共 save 链路 | 发布评论:步骤分析发布评论:代码实现发布评论:测试总结 |
| 评论列表查询 | comment 集合 |
publishId + commentType 过滤、created 倒序分页 | 查询评论列表 |
| 点赞/取消点赞 | comment + Redis Hash | hasComment 幂等判断、$inc 计数联动、状态标记 | 动态点赞:需求分析动态点赞:代码实现取消点赞取消点赞:需求分析 |
| 喜欢/取消喜欢 | 同点赞,type=3 | 同构复制,引出本文最大 bug | 喜欢与取消喜欢 |
本文结束时工程的形态(实读 01-代码/mf 快照):
tree
mf
├── mf-app-server # 应用层:MovementController / CommentsController
│ └── service
│ ├── MovementService # 三视图查询 + getPageResult 公共组装 + 互动入口
│ └── CommentsService # 评论查询/发布 + 点赞喜欢的 Redis 读写
├── mf-model
│ └── src/main/java/com/mf/model
│ ├── mongo
│ │ ├── Movement.java # 动态详情 + likeCount/commentCount/loveCount 冗余计数
│ │ ├── Comment.java # 互动三合一表(commentType 1/2/3)
│ │ └── MovementTimeLine.java # 好友动态收件箱
│ ├── enums
│ │ └── CommentType.java # LIKE(1) / COMMENT(2) / LOVE(3)
│ └── vo
│ ├── MovementsVo.java # 含 hasLiked / hasLoved 互动标记
│ └── CommentVo.java # 含 hasLiked(评论点赞用)
└── mf-dubbo
├── mf-dubbo-interface # MovementApi / CommentApi 契约
└── mf-dubbo-mongo
├── MovementApiImpl # findFriendMovements / findMovementsByPids / randomMovements / findById
└── CommentApiImpl # save / findComments / hasComment / delete(点赞评论喜欢四合一)
一个观察:CommentApiImpl 全类只有四个方法(save / findComments / hasComment / delete),却支撑了评论、点赞、喜欢三组共七个接口。这就是把「类型」做成数据而不是做成表带来的收敛,接口层的重复被消灭在了 API 层。
本文知识地图
#mermaid-svg-l6SBVAKa4DR4yUfP{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-l6SBVAKa4DR4yUfP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-l6SBVAKa4DR4yUfP .error-icon{fill:#552222;}#mermaid-svg-l6SBVAKa4DR4yUfP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-l6SBVAKa4DR4yUfP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-l6SBVAKa4DR4yUfP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-l6SBVAKa4DR4yUfP .marker.cross{stroke:#333333;}#mermaid-svg-l6SBVAKa4DR4yUfP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-l6SBVAKa4DR4yUfP p{margin:0;}#mermaid-svg-l6SBVAKa4DR4yUfP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster-label text{fill:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster-label span{color:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster-label span p{background-color:transparent;}#mermaid-svg-l6SBVAKa4DR4yUfP .label text,#mermaid-svg-l6SBVAKa4DR4yUfP span{fill:#333;color:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP .node rect,#mermaid-svg-l6SBVAKa4DR4yUfP .node circle,#mermaid-svg-l6SBVAKa4DR4yUfP .node ellipse,#mermaid-svg-l6SBVAKa4DR4yUfP .node polygon,#mermaid-svg-l6SBVAKa4DR4yUfP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-l6SBVAKa4DR4yUfP .rough-node .label text,#mermaid-svg-l6SBVAKa4DR4yUfP .node .label text,#mermaid-svg-l6SBVAKa4DR4yUfP .image-shape .label,#mermaid-svg-l6SBVAKa4DR4yUfP .icon-shape .label{text-anchor:middle;}#mermaid-svg-l6SBVAKa4DR4yUfP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-l6SBVAKa4DR4yUfP .rough-node .label,#mermaid-svg-l6SBVAKa4DR4yUfP .node .label,#mermaid-svg-l6SBVAKa4DR4yUfP .image-shape .label,#mermaid-svg-l6SBVAKa4DR4yUfP .icon-shape .label{text-align:center;}#mermaid-svg-l6SBVAKa4DR4yUfP .node.clickable{cursor:pointer;}#mermaid-svg-l6SBVAKa4DR4yUfP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-l6SBVAKa4DR4yUfP .arrowheadPath{fill:#333333;}#mermaid-svg-l6SBVAKa4DR4yUfP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-l6SBVAKa4DR4yUfP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-l6SBVAKa4DR4yUfP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l6SBVAKa4DR4yUfP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-l6SBVAKa4DR4yUfP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l6SBVAKa4DR4yUfP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster text{fill:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP .cluster span{color:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP 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-l6SBVAKa4DR4yUfP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-l6SBVAKa4DR4yUfP rect.text{fill:none;stroke-width:0;}#mermaid-svg-l6SBVAKa4DR4yUfP .icon-shape,#mermaid-svg-l6SBVAKa4DR4yUfP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-l6SBVAKa4DR4yUfP .icon-shape p,#mermaid-svg-l6SBVAKa4DR4yUfP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-l6SBVAKa4DR4yUfP .icon-shape .label rect,#mermaid-svg-l6SBVAKa4DR4yUfP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-l6SBVAKa4DR4yUfP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-l6SBVAKa4DR4yUfP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-l6SBVAKa4DR4yUfP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Redis 互动标记
互动三合一(comment 集合)
跨存储组装(MovementService.getPageResult)
动态查询三视图(数据源各异)
点赞/取消
喜欢/取消
列表页 O(1) 判断
findAndModify $inc
好友动态
movement_time_line
按 friendId 查收件箱
推荐动态
Redis pid 逗号串
内存分页 + $sample 兜底
单条动态
movement 本体按 _id 查
movement 列表
user_info 批查
userInfoApi.findByIds
Redis Hash 互动标记
hasLiked / hasLoved
commentType=1 点赞
$inc likeCount
commentType=2 评论
$inc commentCount
commentType=3 喜欢
$inc loveCount
CommentApiImpl.save/delete
一套代码按类型分发
key = MOVEMENTS_INTERACT_{movementId}
hashKey = MOVEMENT_LIKE_{userId}
hashKey = MOVEMENT_LOVE_{userId}
读图的角度:三个视图的数据来源完全不同(收件箱、缓存、本体),但出口收敛到同一个 getPageResult 组装;互动侧三个业务共用一套 save/delete,差异只剩一个 type 值和一条 $inc 分支;Redis Hash 只服务一件事,列表页高频读「我点过没有」,不参与计数(计数由 movement 冗余字段承担)。职责切分清楚,是本文架构上最干净的部分。
可复用套路清单
这一节是本文的沉淀,五个套路按「本文怎么用、后续哪里用」组织,值得逐条过。
| 套路 | 本文怎么用 | 后续哪里用 |
|---|---|---|
| 三合一互动存储 | comment 集合用 commentType 区分点赞/评论/喜欢,save/delete 一套代码按类型分发,防重靠 userId+publishId+commentType 组合判断 |
小视频章的评论点赞直接复用同一套 CommentApiImpl;任何「用户对内容的动作」类需求(收藏、转发、举报)都可以套这个模型 |
| Redis Hash 状态标记 | key 按动态分组(MOVEMENTS_INTERACT_{movementId}),hashKey 区分用户和动作,列表页 hasKey O(1) 判断 |
即时通信的未读数、小视频的点赞状态;本质是「高频读的状态放缓存,计数放冗余字段」的分工 |
| 跨存储组装 | 好友动态三层:timeLine → movement → user_info;推荐动态三层:Redis pid → Mongo movement → MySQL user_info,收敛在 getPageResult 一个方法里 |
下一章附近的人(位置数据 + 用户资料)、小视频列表(视频 + 用户 + 点赞状态),组装层数只增不减 |
| 条件下沉查询层 | 评论列表把 publishId + commentType 全部压进 Criteria;推荐分页先切 pid 列表再查,不让 Mongo 查全量回 Java 过滤 |
后台系统的多条件分页、小视频列表的过滤,原则不变:过滤逻辑和分页统计发生在同一层 |
| 推模式时间线 | 发布动态时写扩散投递到每个好友的 movement_time_line,读时只查自己的收件箱,一次查询出全部好友动态 |
即时通信的消息投递、后续任何 feed 流场景;写扩散的代价与收益见下方分析 |
套路一展开:三合一互动存储的账怎么算
如果把点赞、评论、喜欢拆成三张表,会得到三套几乎相同的 ApiImpl、三套防重逻辑、三个计数维护点,新增一种互动(比如「打赏」)要复制三个文件。三合一之后,新增类型的成本是:CommentType 加一个枚举值、save/delete 的 $inc 分支各加一行、Redis 加一个 hashKey 前缀。对比是压倒性的。
代价也有,两条:一是单集合数据量大(所有互动挤在一起),必须依赖组合索引兜住查询;二是所有查询都必须带 commentType,漏带就会把点赞记录混进评论列表,这正是坑表里「三合一忘带 commentType」的来源。收益换纪律,划算。
套路二展开:Redis Hash 的 key 跟着最高频读走
本文的 key 设计是 MOVEMENTS_INTERACT_{movementId},field 是 MOVEMENT_LIKE_{userId}。为什么按动态分组而不是按用户分组?看消费场景:列表页渲染 10 条动态,要回答的是「当前用户 对这 10 条 各点没点过」。按动态分组,一页就是 10 次 hasKey;按用户分组,同一个 field 下要看 10 个动态 field,次数一样,但动态的粒度更贴列表查询的批处理方式,且单条动态的互动用户集合规模可控。结论记住一句:key 的分组维度选最高频读路径上的那个「批量锚点」,本文的锚点是动态,因为列表页按动态翻页。
套路三展开:跨存储组装的层数在涨,模式没变
第三章的今日佳人是两层(Mongo 缘分值 + MySQL 资料),本文涨到三层,下一章还会涨。模式始终是同一个:先查主数据拿到 id 列表 → 提取 id 批查辅数据 → Map 对齐组装 → 缺失兜底跳过 。getPageResult 就是这个模式的教科书实现(CollUtil.getFieldValues 提取、findByIds 批查、map.get 对齐、null 判断兜底),后续章节基本是在它外面再包一层。
套路四展开:推荐分页是「条件下沉」的特殊形态
推荐动态的分页发生在 Redis 的 pid 逗号串上:Arrays.stream(values).skip((page-1)*pagesize).limit(pagesize),切出本页 pid 再去 Mongo 查。本文讨论的推荐分页其实是把「分页条件下沉」推到了极限,数据源是字符串,没有查询引擎可用,只能在内存切。注意边界判断 (page-1)*pagesize < values.length 不能省,否则 skip 超界的空流虽然不报错,但查回来的空列表会走 new PageResult(),前端拿到的是全空对象而不是正常的末页。
套路五展开:推模式时间线的代价与收益
发布一条动态,写扩散会给每个好友 的 timeLine 插一条投递记录。好友 500 人,一次发布就是 500 次写。收益在读:好友动态页一次收件箱查询搞定,不用遍历好友再合并排序。这个「写时贵、读时便宜」的取舍在粉丝量小的场景(本项目的交友场景,好友数有限)完全成立;但要想清楚边界,如果是微博那种百万粉丝的大 V,写扩散就不可行了,得换读扩散或混合模式。推拉结合的分界线是「单个发布者的投递成本是否失控」,这个判断比代码本身值钱。
关键坑回顾表
全文十二个翻车点,第一行是本文最有价值的发现,它真实存在于工程快照里:
| 坑 | 现象 | 正确姿势 |
|---|---|---|
| 复制同构代码漏改常量 | MovementService.getPageResult 判断互动标记时,hashKey 用的是 MOVEMENT_LOVE_HASHKEY(喜欢),却把结果塞给 vo.setHasLiked(1),点赞状态被喜欢状态污染:只要喜欢过,点赞图标就亮;纯点赞反而显示灰色 |
同构代码复制后逐行核对常量;更彻底的做法是把「查标记」抽成 hasInteract(type) 方法,type 作参数,杜绝两份代码各自维护 |
| delete 漏 commentType | 取消点赞的删除条件只带 userId + publishId,把用户在该动态下的评论也删了 | 删除条件三件套:userId + publishId + commentType,缺一不可 |
| 三合一忘带 commentType | 评论列表查出点赞记录,页面上出现「赞」字样的「评论」 | 查 comment 集合必带 commentType;组合唯一索引 {userId:1, publishId:1, commentType:1} 同时兜住防重 |
| 孤儿评论 | 动态删除后 comment 里的记录还在;评论列表还能翻,但点进详情因 movement 不存在,save 里 modify 为 null 直接 NPE |
删动态级联删 comment;或列表查询前先验 movement 存在性 |
| counts 写死 0 | new PageResult(page, pagesize, 0l, vos) 总数恒为 0,前端「没有更多了」永远提前出现 |
分页统计与查询同层:count 一次总数(套路四),或约定 lastPage 标记 |
| 取消关注残留投递 | 好友 A 发过 10 条动态后取关,B 的好友动态页仍能看到这 10 条,收件箱是发布时写的快照 | 取关时清理 timeLine 中该好友的投递,或查询时关联好友关系二次过滤(练习四) |
| 引用失效空洞 | movement 删除后,timeLine 和 Redis 推荐列表里的 pid/movementId 成了悬空引用,findMovementsByPids 查回来条数对不上 |
批查后按实际返回组装,容忍「列表比 pid 短」;定期清理悬空引用 |
| page=0 负 skip | 前端传 page=0,(page-1)*pagesize = -10,Mongo skip 负数直接 500 |
入口处 Math.max(page, 1) 钳位,或 @RequestParam(defaultValue = "1") + 参数校验 |
| 分页参数名不一致 | 前端传 pageSize、后端收 pagesize,Spring 静默落 defaultValue=10,一页永远 10 条 |
联调时核对参数名;文档(YApi)与代码字段逐一比对 |
| Redis 与 Mongo 状态脱节 | Mongo 里点赞记录存在、Redis 标记丢失(或反之),列表页图标与详情页状态打架 | 明确 Redis 只是读缓存的加速层,校准逻辑以 Mongo 为准;互动写路径两步操作的顺序与补偿要想清楚 |
| in 查询不保序 | findMovementsByPids 用 pid in (...) 查回的顺序是自然序,不是推荐系统的打分序,推荐位顺序错乱 |
查回后按 pid 建 Map,再按原 pid 列表顺序重排(Java 侧排序兜底) |
| $inc 与落库非原子 | save(comment) 和 findAndModify($inc) 是两次操作,中间宕机则评论在、计数少 1 |
教学项目容忍此误差;生产上用事务(Mongo 4.0+ 多文档事务)或对账任务修复 |
补一句第一行为什么值得单独说:它不是笔误级 bug,是「复制同构代码」这种最高效开发方式的系统性风险 。喜欢和取消喜欢的代码是从点赞复制来的,五处改动点(commentType、错误码、Redis hashKey、计数字段、方法名)漏改一处就是状态串台。本文快照漏的恰恰是最隐蔽的那处,因为 MOVEMENT_LOVE_HASHKEY 拼出来的 key 同样合法存在,功能「看起来是好的」,只有对照 UI 仔细点一遍才能发现。 复盘同构代码时,把所有常量/字面量拎出来单独过一遍,应该成为习惯。
自检清单
部署或复习完本文,逐项勾掉:
| 检查项 | 怎么验 |
|---|---|
| 好友动态分页正确 | 两个账号互加好友,A 发 3 条动态,B 请求 GET /movements?page=1&pagesize=2,返回 2 条、按发布时间倒序;page=2 返回剩余 1 条 |
| 推荐动态走 Redis | redis-cli 里 GET MOVEMENTS_RECOMMEND_1 确认 pid 串存在,请求 GET /movements/recommend 返回的动态 id 与串中 pid 对应;删掉该 key 再请求,走 $sample 随机 10 条不报错 |
| 单条动态按 _id 查 | 复制列表里某条动态的 id,请求 GET /movements/{id},返回详情且昵称头像正确 |
| 评论落库带 type=2 | 发一条评论后 db.comment.find({publishId: ObjectId('...')}),确认 commentType=2、publishUserId 是动态作者 |
| 点赞幂等 | 同一动态连点两次赞,第二次返回 likeError;db.comment 中该用户 type=1 的记录只有一条,likeCount 只加过一次 |
| 取消点赞不误删 | 点赞 + 评论后取消点赞,评论记录仍在、commentCount 不变、likeCount 减 1 |
| 计数联动正确 | 点赞/评论/喜欢各做一轮「加-减」,db.movement 里 likeCount/commentCount/loveCount 回到初值 |
| Redis 标记与 Mongo 一致 | 点赞后 redis-cli HGETALL MOVEMENTS_INTERACT_{movementId} 有 MOVEMENT_LIKE_{userId};取消后该 field 消失,同时 Mongo 记录已删 |
| hasLiked/hasLoved 状态隔离 | 账号 A 只喜欢未点赞,B 刷列表确认 A 的动态 hasLoved=1 而 hasLiked=0(快照 bug 修复后才能通过,见练习一) |
| 评论列表分页有序 | 一条动态下发 15 条评论,GET /comments?movementId=...&page=2&pagesize=10 返回第 11~15 条,created 倒序 |
练习建议
原课程作业四项(好友/推荐动态查询、点赞喜欢、评论、评论点赞拓展)偏「照流程再走一遍」,这里改写成五个可验证的技术任务,难度递增,前两个直接来自上面坑表的实锤:
任务一:修 hasLiked 的 key bug
改哪里:mf-app-server 的 MovementService.getPageResult,把 Constants.MOVEMENT_LOVE_HASHKEY 改为 Constants.MOVEMENT_LIKE_HASHKEY;顺手把这段判断抽成私有方法 hasInteract(String movementId, String hashKeyPrefix),喜欢标记用 MOVEMENT_LOVE_HASHKEY 再调一次,两份复制代码收敛成一处。
怎么验证:账号 A 对某动态「只喜欢不点赞」,B 刷好友动态列表,确认该动态 hasLiked=0、hasLoved=1;再用「只点赞不喜欢」的动态反向验一遍。
任务二:comment 集合加组合唯一索引
改哪里:Mongo shell 执行 db.comment.createIndex({userId:1, publishId:1, commentType:1}, {unique:true});同时思考 save 的防重路径,目前是应用层 hasComment 先查后存,两步之间并发仍可能双插,唯一索引是最后一道闸。给 CommentApiImpl.save 加 DuplicateKeyException 捕获,转为「已互动」业务异常。
怎么验证:写一个并发测试(两个线程同时以同一用户点赞同一动态),加索引前 comment 里出现两条 type=1 记录,加索引后只一条且接口返回幂等结果。
任务三:补 counts 真实统计
改哪里:CommentApiImpl.findComments 里补一个 mongoTemplate.count(query, Comment.class)(注意 count 的 Query 不带 skip/limit),CommentsService.findComments 把总数传进 PageResult 构造;好友动态的 getPageResult 同理,但 timeLine 视图的 count 要查 timeLine 表而不是 movement 表,想清楚每一层数据源。
怎么验证:一条动态下发 15 条评论,请求 page=1&pagesize=10,响应 counts=15、pages=2;翻到第 2 页返回 5 条。
任务四:取消关注时清理 timeLine 残留投递
改哪里:关注/取关功能在第四章,timeLine 投递在本文,两边要在取关路径上会师:取关时按 userId=被取关人 and friendId=操作人 删除 movement_time_line 记录(这一步要动 MovementApi 加删除方法,Dubbo 契约同步更新)。
怎么验证:A、B 互关,A 发 3 条动态,B 好友动态页可见 3 条;A 取关 B 后(或 B 取关 A,想清楚投递方向),B 的好友动态页这 3 条消失,但推荐动态页不受影响,收件箱和推荐是两套数据源,互不牵连。
任务五:给互动接口加频控
改哪里:点赞/喜欢/评论接口加简单频控,最简方案用 Redis:key = INTERACT_LIMIT_{userId}_{movementId},opsForValue().setIfAbsent(key, "1", 2, TimeUnit.SECONDS),存在则拒绝,天然防手抖连点。进阶:按 userId 全局限流(每分钟 N 次),key 过期时间用 INTERACT_LIMIT_{userId}。
怎么验证:脚本 1 秒内连发 10 次点赞请求,第 1 次成功、后续返回频控错误;Mongo 中 type=1 记录数与成功请求数严格相等。
原作业的「评论点赞」拓展(CommentVo 里已经预留了 hasLiked 字段)是三合一模型的自证:评论点赞 = 点赞逻辑的 publishId 换成评论 id,CommentApiImpl 一行不用改就能支撑,做完任务一、二之后把它实现掉,正好检验你有没有真的理解「类型是数据不是表」。
常见误解澄清
| 误解 | 事实 |
|---|---|
| 「点赞、评论、喜欢应该分三张表,职责清晰」 | 类型是数据不是表。三合一 + commentType 让新增互动类型的成本从「复制三个文件」降到「加一个枚举值」;代价是所有查询必须带 type,靠组合索引和纪律兜住 |
| 「Redis 里的点赞状态就是点赞数据本身」 | Redis 只是高频读的加速层,comment 集合(type=1)才是事实来源。两边会脱节,校准和对账永远以 Mongo 为准 |
| 「计数应该每次 count 一遍 comment 集合」 | 互动对查询效率要求极高,所以 movement 表冗余 likeCount/commentCount/loveCount 三个字段,写时 $inc 维护。用写时的成本换读时的 O(1),这是冗余字段的典型用法 |
| 「推荐动态的 pid 串存 Redis,是为了分页快」 | 更本质的原因是数据归推荐系统所有:它实时计算结果、整体覆盖写入,业务侧只消费。pid 串的分页是「别人家的数据,忍一下」的妥协,不是设计上的优雅 |
| 「好友动态和推荐动态展示一样,实现也差不多」 | 展示相同(同一个 MovementsVo),数据源完全不同:timeLine 收件箱、Redis pid 串、$sample 随机兜底,三层链路各自独立。三视图的差别全在取数层 |
「findMovementsByPids 查回来的顺序就是推荐的顺序」 |
in 查询不保序,查回的是自然序。要保住推荐排序,必须按原 pid 列表在 Java 侧重排 |
| 「MongoDB 单文档原子,所以 save + $inc 不会出中间状态」 | 原子性只覆盖单文档单操作。save 评论和 $inc 计数是两次操作,宕机会留下「评论在、计数少 1」,需要事务或对账兜底 |
下一章预告
下一章进入「附近的人」:按距离排序找出身边的城市搭子。技术上靠 MongoDB 的地理位置能力,2dsphere 索引加 $near 查询。这里有个现成的伏笔:Movement 实体从发布动态那章起就带着 longitude、latitude、locationName 三个字段,只是一直存而未用;下一章会给位置建索引、把经纬度真正变成查询条件。本文练的「数据特点决定存储方案」还会再演一遍,位置查询是关系型数据库做起来最别扭、Mongo 做起来最顺手的那类需求。
总结
本文的两块内容,表面上是「查询」和「互动」两件不相干的事,底层却是同一条思路:先想清楚数据的特点,再决定它该住在哪里。
- 动态查询三视图,数据源各异(收件箱、Redis pid 串、本体直查),但出口收敛到同一个
getPageResult。视图的差别全在取数层,组装层只写一遍。 - 互动闭环压进一张
comment集合,用commentType区分点赞/评论/喜欢,CommentApiImpl四个方法支撑七个接口。类型是数据不是表,新增一种互动的成本从「复制三个文件」降到「加一个枚举值」。 - Redis Hash 只干一件事:列表页高频读「我点过没有」。计数交给 movement 的冗余字段,状态交给缓存,事实来源永远是 Mongo。职责切分清楚,是本文架构上最干净的部分。
五个套路里,最值得带走的是两条判断力:一是key 的分组维度选最高频读路径上的批量锚点 ;二是推拉结合的分界线是单个发布者的投递成本是否失控。这两条比任何一段代码都更耐迁移。
而本文最有价值的教学案例,恰恰是那个真实存在的 bug:hasLiked 被 MOVEMENT_LOVE_HASHKEY 污染。它提醒我们,复制同构代码是最高效的开发方式,也是系统性风险的来源------拼出来的 key 同样合法,功能「看起来是好的」,只有对照 UI 仔细点一遍才会露馅。复盘同构代码时把常量单独过一遍,应该成为习惯。
如果只记一句话:本文练的不是某个接口怎么写,而是「数据特点 → 存储方案 → 组装方式」这条推导链。下一章的「附近的人」会把它再演一遍,只是这次的数据特点是「距离」。