概述
我们要把项目从「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快照):
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 项目的经典问题,记住现象和排除写法即可,下章还会遇到类似的事。
本章知识地图
读这张图的角度:左上是存量能力(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查必然全集合扫描。练习内容:
// 建(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 的「静默失败」(字段名错、分页参数错、无索引)比报错更危险,自检清单里每一项的「怎么验」就是对抗静默失败的手段
- 四个练习任务分别对应索引优化、缓存、存储迁移取舍、配置化降级,都是后续章节会真实用到的能力