Java 项目实战: 交友项目 - MongoDB基础总结与自检清单

概述

我们要把项目从「MySQL 单存储」升级成了「MySQL + MongoDB 双存储」:通用设置和黑名单继续走 mf-dubbo-db,今日佳人把缘分值数据首次存进了 Mongo,mf-dubbo-mongo 模块从零搭起来并跑通了第一条 Dubbo 链路。这里不重复细节,只做三件事:把整章串成一张知识地图、沉淀可复用的套路清单、给出可勾选的自检项。

纲要

  • 本章复盘:四个功能块各自解决了什么问题,对应已交付的哪些篇目
  • 知识地图:从 MySQL 域到 Mongo 域的完整链路(Mermaid)
  • 可复用套路清单:新存储接入三步、MyBatis-Plus ↔ MongoTemplate 心智迁移、跨存储组装、条件下沉、insert vs save
  • 关键坑回顾表:全章最容易翻车的十来个点
  • 自检清单:每项都给出「怎么验」
  • 练习建议:四个可验证的技术任务(含作业改造)
  • 常见误解澄清 + 下一章预告

本章做了什么:四块内容的复盘

按讲义的推进顺序,本章四个功能块和已交付篇目的对应关系:

功能块 存储归属 解决的问题 对应篇目
通用设置(通知开关 + 陌生人问题) MySQL 单表 CRUD 的「存在则更新、不存在则保存」二段式写法 MongoDB:引入前置
黑名单分页 + 移除 MySQL 子查询跨表分页(tb_user_info where id in (select ...)),MyBatis-Plus 自定义分页 MongoDB:引入前置
MongoDB 概念 + 命令行 Mongo 本身 体系结构、BSON、insert/update/remove/find、索引与 explain MongoDB:介绍MongoDB的体系结构MongDB的入门:命令行操作
SpringData-Mongo + 今日佳人 Mongo + MySQL 组装 环境搭建、MongoTemplate CRUD、queryWithMaxScore、跨存储组装 VO SpringData-Mongo:环境搭建SpringData-Mongo:CRUD操作上/下今日佳人三篇、推荐好友列表:需求分析

本章结束时工程的形态(实读 01-代码/mf 快照):

tree 复制代码
mf
├── mf-app-server                  # 应用层:SettingsController / mfService(跨存储组装)
├── mf-model
│   └── src/main/java/com/mf/model
│       ├── domain                     # MySQL 实体:Settings / Question / BlackList / UserInfo
│       ├── mongo
│       │   └── RecommendUser.java     # 本章新增:@Document(collection = "recommend_user")
│       └── vo                         # SettingsVo / PageResult / TodayBest
└── mf-dubbo
    ├── mf-dubbo-interface         # 契约:SettingsApi / QuestionApi / BlackListApi / RecommendUserApi
    ├── mf-dubbo-db                # MySQL 实现:三套 ApiImpl + Mapper,端口不变
    └── mf-dubbo-mongo             # 本章新增:RecommendUserApiImpl,端口 18082,dubbo 20882

有一个贯穿全章的结构性变化值得单独记一笔:mf-model 引入 spring-boot-starter-data-mongodb 之后,所有 依赖它的模块都具备了 Mongo 自动装配能力,mf-dubbo-db 和 mf-app-server 明明不连 Mongo,启动时却会去找 localhost:27017 然后报错。解法是这两个模块的引导类排除 MongoAutoConfiguration 和 MongoDataAutoConfiguration。这个「依赖传递引入了不想要的自动配置」是 Spring Boot 项目的经典问题,记住现象和排除写法即可,下章还会遇到类似的事。

本章知识地图

#mermaid-svg-VUXIyeHxSRN4MjnA{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-VUXIyeHxSRN4MjnA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-VUXIyeHxSRN4MjnA .error-icon{fill:#552222;}#mermaid-svg-VUXIyeHxSRN4MjnA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-VUXIyeHxSRN4MjnA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-VUXIyeHxSRN4MjnA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-VUXIyeHxSRN4MjnA .marker.cross{stroke:#333333;}#mermaid-svg-VUXIyeHxSRN4MjnA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-VUXIyeHxSRN4MjnA p{margin:0;}#mermaid-svg-VUXIyeHxSRN4MjnA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster-label text{fill:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster-label span{color:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster-label span p{background-color:transparent;}#mermaid-svg-VUXIyeHxSRN4MjnA .label text,#mermaid-svg-VUXIyeHxSRN4MjnA span{fill:#333;color:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA .node rect,#mermaid-svg-VUXIyeHxSRN4MjnA .node circle,#mermaid-svg-VUXIyeHxSRN4MjnA .node ellipse,#mermaid-svg-VUXIyeHxSRN4MjnA .node polygon,#mermaid-svg-VUXIyeHxSRN4MjnA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-VUXIyeHxSRN4MjnA .rough-node .label text,#mermaid-svg-VUXIyeHxSRN4MjnA .node .label text,#mermaid-svg-VUXIyeHxSRN4MjnA .image-shape .label,#mermaid-svg-VUXIyeHxSRN4MjnA .icon-shape .label{text-anchor:middle;}#mermaid-svg-VUXIyeHxSRN4MjnA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-VUXIyeHxSRN4MjnA .rough-node .label,#mermaid-svg-VUXIyeHxSRN4MjnA .node .label,#mermaid-svg-VUXIyeHxSRN4MjnA .image-shape .label,#mermaid-svg-VUXIyeHxSRN4MjnA .icon-shape .label{text-align:center;}#mermaid-svg-VUXIyeHxSRN4MjnA .node.clickable{cursor:pointer;}#mermaid-svg-VUXIyeHxSRN4MjnA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-VUXIyeHxSRN4MjnA .arrowheadPath{fill:#333333;}#mermaid-svg-VUXIyeHxSRN4MjnA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-VUXIyeHxSRN4MjnA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-VUXIyeHxSRN4MjnA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VUXIyeHxSRN4MjnA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-VUXIyeHxSRN4MjnA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VUXIyeHxSRN4MjnA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster text{fill:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA .cluster span{color:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA 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-VUXIyeHxSRN4MjnA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-VUXIyeHxSRN4MjnA rect.text{fill:none;stroke-width:0;}#mermaid-svg-VUXIyeHxSRN4MjnA .icon-shape,#mermaid-svg-VUXIyeHxSRN4MjnA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-VUXIyeHxSRN4MjnA .icon-shape p,#mermaid-svg-VUXIyeHxSRN4MjnA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-VUXIyeHxSRN4MjnA .icon-shape .label rect,#mermaid-svg-VUXIyeHxSRN4MjnA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-VUXIyeHxSRN4MjnA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-VUXIyeHxSRN4MjnA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-VUXIyeHxSRN4MjnA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 应用层组装(mf-app-server)
MongoDB 域
MySQL 域(mf-dubbo-db)
Dubbo: queryWithMaxScore
Dubbo
Dubbo
缓存层
通用设置

SettingsApi / QuestionApi
黑名单分页

BlackListApi + 子查询
概念与体系结构

document / collection / BSON
命令行

insert / update / find / 索引
SpringData-Mongo

MongoTemplate + @Document
今日佳人 /mf/todayBest

Mongo 查缘分 + MySQL 查资料
推荐好友列表分页

批查 + 兜底
mf-dubbo-mongo

RecommendUserApiImpl
Redis

读这张图的角度:左上是存量能力(MySQL 域),中间是本章新练的内功(概念 → 命令行 → SpringData-Mongo),右下是内功第一次落地(mf-dubbo-mongo 模块 + 跨存储组装)。下一章的圈子功能,就是把中间这条 Mongo 链路全面铺开。

可复用套路清单

这一节是全章的沉淀,五个套路在后续章节反复出现,值得逐条过一遍。

套路一:新存储接入三步

本章接入 MongoDB 的过程可以抽象成固定三步,以后接入任何新存储(Redis、ES、OSS)都是这个节奏:

步骤 本章落地 对应的 MySQL 侧(完全对称)
依赖 + 连接配置 mf-dubbo-mongo 的 pom 加 starter,yml 配 spring.data.mongodb.uri,端口 18082 mf-dubbo-db 的 datasource 配置
实体注解映射 RecommendUser 标 @Document(collection = "recommend_user")、@Id 实体标 @TableName、@TableId
独立域服务模块 mf-dubbo-mongo 放 @DubboService 实现,包扫描 com.mf.dubbo.api mf-dubbo-db 同构

第三步的关键词是存储可插拔 :接口契约放在 mf-dubbo-interface,实现放在哪个模块,消费方完全不感知。哪天把黑名单从 MySQL 迁到 Mongo(练习建议里有这个任务),mf-app-server 一行代码都不用改。

套路二:MyBatis-Plus ↔ MongoTemplate 心智迁移表

写过 MyBatis-Plus 的人上手 MongoTemplate,真正要做的只是「翻译」:

操作意图 MyBatis-Plus 写法 MongoTemplate 写法
实体→表映射 @TableName("tb_settings") @Document(collection = "recommend_user")
主键 @TableId,Long 自增 @Id,ObjectId(12 字节,ObjectId.get() 生成)
字段改名 @TableField("user_id") @Field("username")
等值条件 qw.eq("user_id", userId) Criteria.where("toUserId").is(toUserId)
条件容器 QueryWrapper Query.query(criteria)
排序 qw.orderByDesc("score") .with(Sort.by(Sort.Order.desc("score")))
分页 Page 对象 + 分页插件 手动 count() + .skip((page-1)*size).limit(size)
查一条 selectOne(qw) findOne(query, Class)
新增 insert(entity) insert(entity) 或 save(entity)(语义有差别,见套路五)
删除 mapper.delete(qw) remove(query, Class)

分页这一行差异最大:MyBatis-Plus 有分页插件兜底,Mongo 侧的 skip/limit 和 count 是自己拼的,参数换算错一个符号就翻车(坑表里有)。

套路三:跨存储组装模式

Mongo 存关系数据(缘分值、后续的点赞评论),MySQL 存展示资料(昵称头像),应用层组装。本章落地了三次,形态略有不同:

场景 Mongo 侧查询 MySQL 侧查询 组装要点
今日佳人 queryWithMaxScore:单对象,score 倒序取第一条 userInfoApi.findById 单查 冷启动兜底:查不到就伪造 userId=1, score=99
黑名单分页 ---(全在 MySQL) 子查询分页一次查齐 条件下沉到 SQL,不在 Java 层过滤
推荐好友列表 按 toUserId 查一批 RecommendUser 按用户 id 批量查 UserInfo 批查防 N+1 :先收集 id 再 findByIds,不要循环单查;缺失兜底防 NPE

两次跨存储调用之间没有事务保证,这是这个模式的固有代价,所以 Mongo 里只放「价值较低、可再生成」的关系数据,核心账户资料仍然留在 MySQL。这个存储分工原则比代码本身更值得记住。

套路四:条件下沉 SQL

跨存储、跨表的过滤条件,必须下沉到查询层(SQL 的 where 子查询、Mongo 的 Criteria),不能查回来在 Java 里 filter。黑名单分页是最典型的反例教材:如果先查 tb_black_list 全量,再在 Java 里按页切割,counts(总数)和 pages(总页数)就算不对了,前端分页控件直接失真。过滤逻辑和分页统计必须发生在同一层。

套路五:insert vs save 语义

mongoTemplate 的两个保存方法长得像,语义不同:

方法 已存在相同 _id 时 典型场景
insert 抛 DuplicateKeyException 新增语义,主键由系统生成
save 整文档覆盖(相当于 upsert) 保存语义,不在乎是新增还是更新

MySQL 侧对应的「存在则更新、不存在则保存」二段式(saveQuestion 里先 findByUserId 再分支),在 Mongo 侧可以用 save 一步替代,但要清楚代价:save 是整文档替换,字段会被清掉,和 update 语句漏写 $set 是同一种事故(见坑表第一行)。

关键坑回顾表

全章最容易翻车的点,按出现顺序回顾:

坑 现象 正确姿势
update 漏写 $set db.user.update({id:1},{age:25}) 把整个文档替换成只剩 age 部分更新必须 {$set:{...}};Java 侧同理,save 会整文档覆盖
remove({}) 空条件 删光整个集合,无确认弹窗 删除必带条件;清空集合用 drop() 并想清楚
字段名写错(大小写) Criteria 里写 touserid,静默查不到,不报错 Mongo 无 schema 校验,字段名以插入时为准;排查手法:先 find() 看原始文档
skip/limit 换算错误 第 2 页查出了第 1 页的数据 skip((page-1)*size),注意 page 从 1 开始
userId/toUserId 写反 查「我的今日佳人」查出别人的缘分值 约定:toUserId 是「看的人」,userId 是「被推荐的人」
recommend_user 缺唯一索引 同一对用户重复插入推荐记录(这个坑黑名单场景已经是第三次出现了) 业务唯一键建唯一索引:db.recommend_user.createIndex({userId:1,toUserId:1},{unique:true})
兜底数据 NPE 冷启动伪造了 RecommendUser,但 userInfoApi.findById(1L) 查不到 id=1 的资料 兜底要兜两层:推荐数据兜了,资料也要有真实可用的用户
Double 截断 score=99.6 经 longValue() 变 99,前端显示少 0.6 缘分值 明确取整策略:longValue() 是截断不是四舍五入,需要精度就 Math.round
Java 层过滤 分页 total 与实际条数对不上 过滤条件下沉到 SQL/Criteria(套路四)
分页插件未配置 selectPage 静默查全量,数据小的时候根本发现不了 mf-dubbo-db 引导类注册 PaginationInnerInterceptor
Mongo 自动装配误伤 mf-dubbo-db / mf-app-server 启动报连接 localhost:27017 失败 引导类 exclude = {MongoAutoConfiguration.class, MongoDataAutoConfiguration.class}

自检清单

部署或复习完本章,逐项勾掉:

检查项 怎么验
Mongo 容器能连 docker ps 看到 27017 端口映射;shell 里 show dbs 有响应
mf-dubbo-mongo 注册成功 Nacos 控制台服务列表出现 mf-dubbo-mongo,端口 18082
实体映射正确 shell 执行 db.recommend_user.find().pretty(),字段名与 RecommendUser 的属性一致(驼峰,不是下划线)
CRUD 走通 测试类依次跑 save / find / update / remove,每步用 find() 核对结果
今日佳人返回缘分值 带 token 请求 GET /mf/todayBest,响应里有 fateValue 且非 0
冷启动兜底生效 清空 recommend_user 集合再请求,接口不报错,返回 userId=1 的默认资料和缘分值 99
通用设置二段式保存 同一用户连续两次设置陌生人问题,tb_question 里始终只有一条记录
黑名单分页正确 拉黑 3 人后请求 GET /settings/blacklist?page=1&size=2,counts=3、pages=2

练习建议

原课程的作业偏「照流程再走一遍」,这里改写成四个可验证的技术任务,难度递增:

任务一:给 recommend_user 的 toUserId 建索引

queryWithMaxScore 每次首页刷新都会执行,但 recommend_user 目前只有 _id 索引,按 toUserId 查必然全集合扫描。练习内容:

javascript 复制代码
// 建(toUserId, score)复合索引,查询条件与排序一次覆盖
db.recommend_user.createIndex({toUserId: 1, score: -1})

验证:建索引前后分别执行 db.recommend_user.find({toUserId: 1002}).sort({score: -1}).explain(),对比 winningPlan.stage 从 COLLSCAN 变为 IXSCAN。顺手灌 10 万条测试数据(for 循环插入,命令行篇章有模板),感受数量级差异。

任务二:todayBest 加 Redis 缓存

今日佳人是首页首个请求,每次都跨两次 Dubbo 调用不值当。设计要点:key 用 today_best::{userId},value 存 TodayBest 序列化结果,过期时间 5~10 分钟。要思考的问题是:推荐数据更新时缓存怎么办,先做「短过期时间自然淘汰」这个最简方案,把一致性方案的取舍留成注释。

任务三:黑名单改 Mongo 实现

新建 black_list 集合,BlackListApi 契约不动,在 mf-dubbo-mongo 里写一套新实现。做完对比:MySQL 版靠一条子查询 SQL 搞定分页,Mongo 版不支持 join,得先分页查 black_list、再批查 user_info 组装,多了一次跨存储往返,换来了关系数据迁出主库。这个取舍没有标准答案,写清楚你选哪个、为什么。

任务四:兜底数据配置化降级

mfService.todayBest() 里硬编码的 userId=1L, score=99d 有两个问题:id=1 的用户可能不存在(NPE 风险),换兜底用户要改代码重新发版。改造方向:抽成配置项(@ConfigurationProperties 或放 Nacos),再考虑兜底用户池(随机取一个真实存在的「官方账号」)。验收标准:把配置里的 userId 改成一个不存在的值,服务不崩、接口返回明确降级标记。

原作业里的「修改手机号」功能本质上是一次登录链路复用练习:发短信 → 校验验证码 → 保存手机号,三步分别对应登录功能已写过的组件,适合作为动手热身,此处不再展开。

常见误解澄清

误解 事实
「引入 MongoDB 是为了替代 MySQL」 两者分工:MySQL 存核心账户资料,Mongo 存海量、低价值密度的关系数据(推荐、后续的动态点赞评论),Redis 做缓存。选型依据是数据特点,不是新旧
「Mongo 没有 schema,所以不用设计字段」 恰恰相反:没有 schema 校验意味着写错字段名不报错、静默查不到。字段命名约定(统一驼峰)要靠代码纪律保证
「Mongo 不支持索引和执行计划」 createIndex / getIndexes / explain 全套都有,IXSCAN 与 COLLSCAN 的区别和 MySQL 的索引扫描/全表扫描一一对应
「save 就是更新,insert 就是新增」 save 是「存在则整文档覆盖,不存在则插入」,两个方法都能新增;差别在 _id 冲突时的行为
「实体类加 @Document 只影响 Mongo 模块」 依赖在 mf-model,所有引用它的模块都会触发 Mongo 自动装配,不连 Mongo 的模块必须显式 exclude
「分页插件配一次,所有模块都生效」 插件是 mf-dubbo-db 引导类里的 @Bean,只在配置了的模块生效;新模块要分页得重新注册

下一章预告

第四章进入圈子功能:发布动态、浏览好友动态、点赞、评论。这一章的价值密度很高,动态、评论、点赞全面使用 MongoDB 存储,本章练的 @Document 映射、MongoTemplate 查询、mf-dubbo-mongo 模块扩展会一次性铺开。工程实读也能佐证:本章 mf-dubbo-mongo 的快照里已经出现了 UserLike 相关的 import,说明圈子部分的代码与推荐模块同源生长。套路三的跨存储组装在圈子场景会升级成「动态(Mongo)+ 用户资料(MySQL)+ 点赞评论状态(Mongo)」的三方组装,批查防 N+1 会从建议变成刚需。

总结

  • 本章的工程成果是双存储格局落地:mf-dubbo-db(通用设置/黑名单)与 mf-dubbo-mongo(今日佳人/推荐)并列,接口契约统一放 mf-dubbo-interface,消费方对存储无感知
  • 最值得带走的不是 MongoTemplate 的 API,而是五个套路:新存储接入三步、MyBatis-Plus 心智迁移、跨存储组装(批查 + 兜底)、条件下沉、insert vs save 语义
  • 坑的共性也值得留意:Mongo 的「静默失败」(字段名错、分页参数错、无索引)比报错更危险,自检清单里每一项的「怎么验」就是对抗静默失败的手段
  • 四个练习任务分别对应索引优化、缓存、存储迁移取舍、配置化降级,都是后续章节会真实用到的能力
相关推荐
鬼手点金1 小时前
消息的四种角色
java·前端·人工智能·git·深度学习
zzu123zsw1 小时前
面对多 RPC 扇入超时崩溃:深入 asyncTool 核心 DAG 编排与线程池隔离防护
java·网络·spring boot·网络协议·rpc·并发编程
snpgroupcn1 小时前
SAP 系统退役的归档架构怎么设计:可检索、可理解、可追溯、可证明
java·数据库·架构
文丑颜不良啊1 小时前
LeetCode476~500题目解析
java·leetcode
雨落在了我的手上1 小时前
JavaEE初级(1):计算机是如何⼯作的
java·java-ee
VimorphTech1 小时前
文件名是 MP4 就能处理?用 ffprobe 检查视频流、音轨和时长
java·音视频开发
惊讶的猫1 小时前
Spring AOP
java·开发语言
用户3126874877201 小时前
Java 异常体系全链路:受检异常到底该不该用
java
学到老活到老2 小时前
集合换成线程安全的,就不用加锁了吗?
java·面试