Java 项目实战: 交友项目 - 圈子功能实现:总结

概述

圈子这一章把「发一条动态」背后的一整套存储设计摊开了:MongoDB 存详情、时间线表做收件箱、Redis 存推荐列表,三套存储各管一段。这里做全章复盘,知识地图、五个可复用的架构套路、十二个实测踩过的坑、自检清单和四个动手练习,最后指向下一章的圈子互动。

纲要

  • 知识地图:六条主线从发布到互动的完整链路
  • 可复用套路清单:五个能搬去任何 Feed 系统的方案
  • 关键坑回顾:十二个实测复现过的坑(坑表)
  • 自检清单:合上讲义能答上来才算过
  • 技术练习:四个「改哪里 / 怎么验证」明确的动手项
  • 下一章预告:圈子互动

知识地图

先把全章 six 条主线放一张图里,箭头是数据流向:
#mermaid-svg-ftndKUvpNFWoOJMh{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-ftndKUvpNFWoOJMh .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ftndKUvpNFWoOJMh .error-icon{fill:#552222;}#mermaid-svg-ftndKUvpNFWoOJMh .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ftndKUvpNFWoOJMh .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ftndKUvpNFWoOJMh .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ftndKUvpNFWoOJMh .marker.cross{stroke:#333333;}#mermaid-svg-ftndKUvpNFWoOJMh svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ftndKUvpNFWoOJMh p{margin:0;}#mermaid-svg-ftndKUvpNFWoOJMh .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ftndKUvpNFWoOJMh .cluster-label text{fill:#333;}#mermaid-svg-ftndKUvpNFWoOJMh .cluster-label span{color:#333;}#mermaid-svg-ftndKUvpNFWoOJMh .cluster-label span p{background-color:transparent;}#mermaid-svg-ftndKUvpNFWoOJMh .label text,#mermaid-svg-ftndKUvpNFWoOJMh span{fill:#333;color:#333;}#mermaid-svg-ftndKUvpNFWoOJMh .node rect,#mermaid-svg-ftndKUvpNFWoOJMh .node circle,#mermaid-svg-ftndKUvpNFWoOJMh .node ellipse,#mermaid-svg-ftndKUvpNFWoOJMh .node polygon,#mermaid-svg-ftndKUvpNFWoOJMh .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ftndKUvpNFWoOJMh .rough-node .label text,#mermaid-svg-ftndKUvpNFWoOJMh .node .label text,#mermaid-svg-ftndKUvpNFWoOJMh .image-shape .label,#mermaid-svg-ftndKUvpNFWoOJMh .icon-shape .label{text-anchor:middle;}#mermaid-svg-ftndKUvpNFWoOJMh .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ftndKUvpNFWoOJMh .rough-node .label,#mermaid-svg-ftndKUvpNFWoOJMh .node .label,#mermaid-svg-ftndKUvpNFWoOJMh .image-shape .label,#mermaid-svg-ftndKUvpNFWoOJMh .icon-shape .label{text-align:center;}#mermaid-svg-ftndKUvpNFWoOJMh .node.clickable{cursor:pointer;}#mermaid-svg-ftndKUvpNFWoOJMh .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ftndKUvpNFWoOJMh .arrowheadPath{fill:#333333;}#mermaid-svg-ftndKUvpNFWoOJMh .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ftndKUvpNFWoOJMh .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ftndKUvpNFWoOJMh .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ftndKUvpNFWoOJMh .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ftndKUvpNFWoOJMh .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ftndKUvpNFWoOJMh .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ftndKUvpNFWoOJMh .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ftndKUvpNFWoOJMh .cluster text{fill:#333;}#mermaid-svg-ftndKUvpNFWoOJMh .cluster span{color:#333;}#mermaid-svg-ftndKUvpNFWoOJMh 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-ftndKUvpNFWoOJMh .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ftndKUvpNFWoOJMh rect.text{fill:none;stroke-width:0;}#mermaid-svg-ftndKUvpNFWoOJMh .icon-shape,#mermaid-svg-ftndKUvpNFWoOJMh .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ftndKUvpNFWoOJMh .icon-shape p,#mermaid-svg-ftndKUvpNFWoOJMh .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ftndKUvpNFWoOJMh .icon-shape .label rect,#mermaid-svg-ftndKUvpNFWoOJMh .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ftndKUvpNFWoOJMh .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ftndKUvpNFWoOJMh .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ftndKUvpNFWoOJMh :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} state=0 未审
异步改造
发布动态

POST /movements
movement

动态详情
RabbitMQ / @Async
movement_timeline

好友时间线
我的动态

按 userId 查详情
好友动态

收件箱推模式
Redis

MOVEMENTS_RECOMMEND_pid串
推荐动态
单条详情

GET /movements/&{id&}
互动入口

评论/点赞/喜欢

六条主线各自的核心结论:

主线 存储与关键设计 一句话结论
发布动态 movement 集合,state=0 未审默认值,pid 由 IdWorker 自增 详情只存一份;写扩散发生在发布之后
发布异步化 @Async 起步,MQ 改造收尾 时间线投递不能阻塞发布主链路,但要配套幂等与吞异常策略
好友动态 movement_timeline 收件箱,查询方按 friendId 查 推模式:发布时写每个好友的收件箱,读时只查自己的表
推荐动态 Redis MOVEMENTS_RECOMMEND_{userId} 存逗号分隔 pid 串 顺序即推荐排序,冷启动 $sample 兜底,分页只能在内存做
我的动态 / 单条详情 按 userId 查 movement;_id 用于详情、pid 用于推荐 两套 id 的分工在详情页交汇
互动 comment 集合三合一 + Redis Hash 记状态 评论/点赞/喜欢共用一张表靠 commentType 区分,细节在下一章

存储分层的意图要说透:MongoDB 管「内容真相」,时间线表管「可见性」,Redis 管「推荐序」 。动态本体永远只有 movement 里那一份,时间线表里存的是引用(movementId),推荐列表里存的是 pid。这带来一个必然结果,跨存储组装:不管哪条查询主线,最后一步都要拿 id 回 movement 反查详情,再拼 UserInfo 补头像昵称,才凑得出前端要的 MovementsVo。

读写两条路径

把六条主线再压缩一次,其实只有两条路径。写路径 只有一条:POST /movements → OSS 传图 → movement 落详情(含 pid 分配)→ 异步给每个好友投递时间线引用。读路径有三条分支,入口不同、第一步不同,第二步以后完全一样:
渲染错误: Mermaid 渲染失败: Lexical error on line 2. Unrecognized text. ... TB subgraph 读分支:第一步各查各的 F[" ----------------------^

三条分支在「第一步」上的差异正是存储分工的体现:好友动态解决可见性 (谁能看),推荐动态解决排序 (先看谁),我的动态解决归属 (谁发的)。而它们共享「回查详情 + 组装 VO」的后半段,说明内容真相始终只有一份。理解了这个结构,下一章互动数据的读法(评论列表按 publishId 挂到详情页)也只是再加一条读分支而已。

可复用套路清单

这五条不依赖项目的业务,做任何 Feed 流系统都用得上:

  • 推模式时间线写扩散 :发布时把动态引用写进每个好友的收件箱(movement_timeline),读路径退化为单表分页查询;用读频次的降低换写时一次 for 循环,适合好友数可控的社交产品。
  • MQ 异步解耦发布链路 :时间线投递从发布主流程里摘出去(@Async 起步、RabbitMQ 收尾),发布接口的耗时只包含详情落库;配套要做消息体约定、唯一索引幂等、消费吞异常防无限重投。
  • 三合一互动存储 :评论、点赞、喜欢共用 comment 集合,commentType 区分类型,读写逻辑复用一套分页与计数;代价是所有写删条件必须带全 (publishId, commentType, userId),漏一个就误伤。
  • Redis Hash 互动标记 :一条动态一个 Hash(key),一个用户一个 field,把「百万级 key」压成「万级 field」;hasLiked 回填时批量 HMGET 一次搞定。
  • 跨存储组装:id 列表先收集,再一次性回查详情与用户资料,内存拼 VO;禁止在循环里逐条远程调用,N+1 是这条套路的唯一大敌。

关键坑回顾

这一章的坑不是理论推演,全部在测试篇里实测复现过。十二行排一遍,修复成本从一行代码到一个删除策略不等:

坑 现象 根因 修复思路
multipart 默认 1MB 多图发布直接报超限异常 Spring Boot 单文件默认上限 1MB max-file-size: 5MB、max-request-size: 30MB,容得下 6 张图
publish 吞异常假成功 Mongo 挂了接口照样 200 publish 里 catch (Exception e) 只 printStackTrace 详情落库失败必须上抛或补偿,别把失败藏进日志
@Async 无 @EnableAsync 时间线不投递,且无任何报错 注解代理没开启,方法同步执行 启动类加 @EnableAsync,条件三件:注解、public 方法、跨 Bean 调用
白名单未落地(avatar 绕过人脸) 任意图片不经历审核进动态 头像接口的 MIME 白名单 + 人脸检测没有搬到发布链路,state=0 又默认未审 发布前复用头像的校验套路,并接内容审核任务消费 state
movement.getId() 回填时机 异步链路拿到 null id ObjectId 在 save 之后才回填,早于 save 引用会空 先 save 再取 id 传给异步任务
异步线程拿不到 UserHolder 异步方法里取登录人报错 ThreadLocal 不跨线程 主线程取完 userId 作为参数传入
默认线程池无上限 高并发下线程膨胀 @Async 默认执行器不复用线程 换有界线程池并显式命名
timeLine 残留投递 取关后还能刷到对方动态 发布时快照式投递,取关不清 movement_timeline 取关时 deleteMany({userId:对方, friendId:我}),或查询时按现好友列表过滤
引用失效空洞 删动态后分页少一条、counts 对不上 时间线引用还在,in 回查 movement 少一条 删动态时同步删时间线引用,查询侧不补救
counts 写死 0 PageResult.counts 恒为 0 findByUserId 直接 new PageResult(page, pagesize, 0l, ...) 补一次 count 查询回填
page=0 负 skip page=0 请求 500 skip((page-1)*pagesize) 算出 -10,Mongo 服务端报错 入口 Math.max(page, 1) 钳制
Map 接参类型不安全 key 拼错编译期无感,运行时 NPE;Integer 转 Long 炸 @RequestBody Map 绕过了类型系统 换 DTO 接参,字段类型编译期锁定

这十二个坑有三个共同点,值得记下来当 review 清单的视角:都发生在存储边界 (上传、落库、跨表、跨线程)、失败都偏安静 (不报错或报错难懂)、教学快照故意不修(留作分析素材)。

自检清单

合上讲义逐条自问,答不上来的回去翻对应篇目:

  • movement 集合里 _id 和 pid 各自被谁使用?为什么推荐系统要用自增 pid 而不是 ObjectId?
  • 发布动态时 state 默认值是多少?谁来把它改成 1?
  • 好友动态为什么查 movement_timeline 而不是直接查好友的 movement?推模式省了什么、付出了什么?
  • 推荐动态的 pid 串存在 Redis 的什么结构里?key 怎么按用户隔离?冷启动用户查不到 key 时走什么兜底?
  • 推荐动态的分页为什么只能在内存做,不能下推给 Mongo?
  • 时间线表里的 userId 和 friendId 各是什么语义?查询条件用的是哪一个?
  • 删除一条动态后,时间线里的引用会发生什么?用户端看到什么现象?
  • @Async 生效的三个条件是什么?默认线程池有什么问题?
  • comment 集合为什么把三种互动放一张表?写删时的安全边界是哪几个字段?

技术练习

四个练习都是把前几篇测试篇里指出的缺陷亲手修掉,每项都给出改哪里、怎么验证。

练习一:补上 @EnableAsync

改哪里 :mf-dubbo-mongo 的启动类 DubboMongoApplication,类上加 org.springframework.scheduling.annotation.EnableAsync;顺手定义一个有界线程池替换默认执行器:

java 复制代码
@Bean(name = "timeLineExecutor")
public Executor timeLineExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(4);
    executor.setMaxPoolSize(8);
    executor.setQueueCapacity(200);
    executor.setThreadNamePrefix("timeline-");
    executor.initialize();
    return executor;
}

TimeLineService.saveTimeLine 的 @Async 上指定 @Async("timeLineExecutor")。

怎么验证 :发一条动态,在 saveTimeLine 里打一条带 Thread.currentThread().getName() 的日志。修复前线程名是主线程(http-nio 执行线程),修复后变成 timeline-1;再查 movement_timeLine 确认投递记录存在。对照验证:把注解删掉重发,日志回到主线程名,亲眼看一次「代码看着异步、跑起来同步」。

练习二:修掉 publish 的吞异常

改哪里 :MovementApiImpl.publish 的 catch (Exception e)。最小改法是失败上抛:

java 复制代码
} catch (Exception e) {
    log.error("发布动态失败, userId={}", movement.getUserId(), e);
    throw new RuntimeException("发布动态失败", e);
}

注意抛出后 timeLineService.saveTimeLine 的异步调用不会执行,这正是期望行为:详情都没存成,不该给好友投递引用。

怎么验证 :停掉 MongoDB(或临时把集合名改成不存在的值触发异常),Postman 发 POST /movements。修复前返回 200 但库里无数据(假成功);修复后返回 500,日志里有完整堆栈和 userId。恢复 Mongo 后重新发布,一切正常。

练习三:给发布链路补白名单校验

改哪里 :MovementService.publishMovement,在 OSS 上传循环之前,对每个 MultipartFile 做三道校验,判空、大小、MIME 白名单(只认 image/jpeg、image/png),校验套路直接抄 02 章头像上传的实现:

java 复制代码
private static final List<String> ALLOWED_TYPES = Arrays.asList("image/jpeg", "image/png");

private void checkImage(MultipartFile file) {
    if (file == null || file.isEmpty()) {
        throw new BusinessException(ErrorResult.error("图片为空"));
    }
    if (!ALLOWED_TYPES.contains(file.getContentType())) {
        throw new BusinessException(ErrorResult.error("仅支持 jpg、png 格式"));
    }
}

这练习解决的是「avatar 绕过人脸」那行坑:头像接口有白名单 + 人脸检测,发布链路却裸奔,任何文件都能借 imageContent 进 OSS 并挂到动态上。完整方案还要接内容审核消费 state,本练习只做上传入口这一层。

怎么验证 :Postman 把一个 .html 文件改名为 .jpg 上传,修复前 200 且 OSS 里能找到该文件;修复后返回 500(业务异常),OSS 无新对象。再用正常 jpg 回归一遍确认误伤为零。

练习四:清理取关后的残留投递

改哪里 :取关(删除好友)的服务方法里,环信删联系人、删 friend 双记录之后,追加清理时间线:

java 复制代码
//清理取关对象投递到自己收件箱的动态引用
Query query = Query.query(
        Criteria.where("userId").is(friendId)     //发布人是被取关的人
                .and("friendId").is(currentUserId)); //收件箱是我的
mongoTemplate.remove(query, MovementTimeLine.class);

方向别写反:要删的是「对方发布、投给我」的记录;我此前转发到他收件箱的记录删不删取决于产品语义,练习里保持单向清理即可。

怎么验证 :A、B 互加好友,A 发两条动态;B 查 GET /movements 能看到两条。B 取关 A 后再查,A 的动态应全部消失;mongosh 里 db.movement_timeline.find({userId: A的id, friendId: B的id}) 应为空。反向回归:A 未取关 B,A 的列表里 B 的动态应仍在。

常见误解澄清

误解 实际情况
时间线表里存的是动态内容 只存 movementId 引用,内容真相只有 movement 一份
@Async 加了就异步 必须 @EnableAsync + public 方法 + 跨 Bean 调用,三条件缺一即同步执行且无报错
推荐动态的分页由 MongoDB 完成 pid 串在 Redis,分页用 Stream 在内存做,Mongo 只做 pid in 回查
发布动态会先经过审核 state 默认 0(未审),工程快照里没有审核任务,实际等于免审上线
取关后动态自然不可见 快照式投递不会自动回收,残留记录要靠取关时清理或查询时过滤

官方文档

总结

圈子这一章的真实主线不是 CRUD,而是存储分工:movement 存真相、movement_timeline 管可见性、Redis 管推荐序,三条查询主线最后都汇到「跨存储组装 VO」这一个动作上。十二个坑里最值得带走的是三个习惯:异步必须验证真的异步了、失败不能吞进日志、投递出去的引用要有人负责回收。下一章圈子互动接着把 comment 集合的三合一设计、点赞的 Redis Hash 标记和取消互动的误删坑展开,互动数据一动,本章的 counts 写死 0 也就到了该还账的时候。

相关推荐
SL_staff1 小时前
JVS私有化文档系统上线避坑指南:5大运维与业务协同断点及检测加固实践
java·开源·全栈
下一站春风里2 小时前
位运算完整学习笔记:按位与(&)+ 异或(^)
开发语言·c++·算法
FinelyYang3 小时前
考试系统简答题 AI 阅卷:覆盖度驱动、异步评分与人机协同落地实践
java·大数据·人工智能
天外飞雨道沧桑3 小时前
我在浏览器里练了一个 792 参数的大模型,它学会了补全 JavaScript
开发语言·javascript·ai·ecmascript·ai编程
hhhjhl3 小时前
Yup 跨端三方库仓颉适配全流程
java·开发语言
运维行者_3 小时前
网络监控工具怎么选?从设备监控到智能运维的能力清单
运维·服务器·开发语言·网络·人工智能·支持向量机·php
用户3126874877203 小时前
Java 对象内存布局:压缩指针、对齐填充与 JOL 实测
java
东华果汁哥3 小时前
【Spring AI 实战】从零开始调用大模型接口,构建 Java AI 应用
java·人工智能·spring
“AI国潮设计-小江”4 小时前
【AIGC实战】Python+SDXL打造“潮汕英歌舞IP”万能立牌插件,批量生成“发财元宝蛋糕”视觉营销方案(附源码)
开发语言·人工智能·python·prompt·aigc