Java 项目实战: 交友项目 - 圈子互动章总结与自检清单

概述

本文干了两件事:把圈子动态的查询补成三视图(好友动态走 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 仔细点一遍才会露馅。复盘同构代码时把常量单独过一遍,应该成为习惯。

如果只记一句话:本文练的不是某个接口怎么写,而是「数据特点 → 存储方案 → 组装方式」这条推导链。下一章的「附近的人」会把它再演一遍,只是这次的数据特点是「距离」。

相关推荐
xyouyuweizhu1 小时前
字节流与字符流:Java IO 的两条主线该怎么选
java·开发语言·python
代码方舟2 小时前
零信任架构实战:基于天远人企关联构建自动化供应链核心企业关联穿透与利益冲突审查网关
java·人工智能·架构·自动化
liyi_hz20082 小时前
O2OA(翱途) V10.1 即将发布|功能再升级,安全再加固,协同更高效!
java·javascript·windows·开源软件
骑着蜗牛撵大象3272 小时前
AI Agent 工程化开发实战(三):用 StateGraph 给智能体搭一条可回放的状态流水线
java·开发语言·jvm·ai·状态管理·langgraph·stategraph
鬼手点金3 小时前
KV Cache 未必是一次性的:可编辑、可组合的“笔记”
java·人工智能·笔记·学习·自动化·1024程序员节
摇滚侠3 小时前
《On Java 中文版 基础卷》阅读笔记 初始化和清理 08
java·开发语言·笔记
海鸥-w3 小时前
Redis 配置与排错说明
java·数据库·redis·bootstrap
XiYang-DING3 小时前
P4009.和为K的子数组个数 & P5522.异或和为 k 的子数组个数
java·开发语言
TinyKey3 小时前
malloc_debug_notes
java