Chapter 07
自测题库
前六章建立了从文档模型到分片选型的完整链条。这一章不讲新东西,只逼着把它取出来用——题目分三层梯度,最后六道跨章场景题是真正检验"换没换对脑子"的地方。
怎么用这一章
- 所有答案集中在文末一个折叠块里。先把答案写在纸上或编辑器里,再展开对照——直接看答案等于把题目当又读了一遍,零收益。
- 三层梯度:概念层考词汇与定义,原理层考机制(为什么这么设计),应用判别层给场景逼你在多章方案间做选择。
- 卡在原理层或判别层是正常的、也是有用的——那说明题目正好压在还没真正内化的地方。
▸题目的难度梯度
三层不是按章节分,而是按认知动作分:能复述 ≠ 懂机制 ≠ 会在真实场景里选对。判别层最少,却最能暴露问题。
L1概念层(对应 01 / 03)
L2原理层(对应 02 / 04 / 05)
$lookup为什么是嵌套循环而不是 hash join?foreignField 无索引时复杂度是多少?→ 02 §2.3- 多计划竞速(multi-planner)和 plan cache 怎么协作?什么时候会 replan?→ 02 §2.4
- WiredTiger 为什么没有 VACUUM?旧文档版本最终去了哪里?→ 04 §4.2
- cache 满到阈值后会发生什么?为什么把它叫"延迟悬崖"而不是"逐渐变慢"?→ 04 §4.3
- checkpoint 和 journal 各自负责持久性的哪一段?崩溃恢复怎么用它们?→ 04 §4.4
- oplog 条目为什么必须幂等?举一个被改写成幂等形式的操作。→ 05 §5.1
- 副本集复制协议和 Raft 的两个关键差异是什么?→ 05 §5.2
w:majority和readConcern: majority各自保证什么?组合起来排除了哪种故障?→ 05 §5.3
L3应用判别层(跨章场景 · 最难)
每道题给一个真实场景,要求在多章的方案之间做选择并说出判据。这一层是这份教程的综合压轴——不是考"会不会用某个 API",而是考"换没换对模型"。
- 建模 × 查询:电商系统里"商品"和"库存",库存被多个端高频独立改写,而商品详情页要同时显示商品信息和当前库存。库存该嵌入商品文档还是引用?说出判据,并指出嵌入会带来哪种写入代价。(01 建模 + 03 嵌入决策 + 02
$lookup代价) - 查询 × 索引:一条聚合很慢,
explain显示它对$lookup的 foreignField 做全集合扫描,且末尾有一个超 100MB 的$sort。瓶颈在哪两处?分别怎么修?(02$lookup/执行 + 03 索引/ESR) - 分片 × 访问模式:多租户 SaaS,写入按租户高度不均(少数大租户占大部分写),偶尔要跨租户出 BI 报表。shard key 选什么?跨租户报表该怎么处理?(06 shard key + 01 访问模式 + 02 scatter-gather)
- 一致性 × 读写关注:用户改完个人资料立刻跳转详情页,必须看到刚改的值;而全站用户列表页允许看到稍旧的数据。两处分别怎么配 read preference / readConcern / 会话?(05 读写关注 + 因果一致性)
- 建模 × 存储:IoT 设备每秒一条约 200 字节的读数。直接把读数 push 进设备文档的数组里,约 22 小时就撞 16MB。正确的文档粒度是什么?背后是哪个设计模式、为什么也对 cache 友好?(01 16MB + 03 Bucket + 04 cache)
- 选型判别:什么时候 PostgreSQL 的 JSONB 比 MongoDB 更合适?反过来,哪三个特征出现时 MongoDB 才真正胜过带 JSONB 的 Postgres?(06 选型 + 01 访问模式主线)
合上教程,在纸上或 Excalidraw 里默画 MongoDB 的概念地图——只画 8 个节点(文档模型 / 查询·聚合 / 索引 / schema 设计 / WiredTiger / 副本集 / 分片 / 选型)和它们之间的依赖箭头。画完回到起点页图 0 对照:你有没有画出"索引"和"schema 设计"之间那条"同一个决策"的边?如果没画出来,回 03 章重读——那条边正是这份教程要你换上的那块模型。
第二张(可选):默画图 5.2 的写入时间线——标出 w:1 在哪个时刻返回成功、哪个时刻起才不可回滚。中间那段缝隙能不能讲清,决定了你有没有真正区分"持久"和"可见"。
▸答案
先把上面三层都做完,再展开。给自己的判别层答案打分时,标准不是"措辞对不对",而是"判据指对没指对、有没有落回访问模式"。
展开全部答案(先做完再点)
概念层
- 多了带类型与长度前缀的二进制字段编码,以及 JSON 没有的类型(ObjectId / Decimal128 / Date / Binary / 区分 32/64 位整数)。长度前缀让引擎能不解析整个文档就跳过字段、直接定位到一个嵌套路径。
- 规则:强制存在、不可变、自带唯一索引。ObjectId = 4 字节时间戳 + 5 字节随机值 + 3 字节自增计数器,三段都能在客户端本地生成,无需访问数据库取序号。
- 提醒检查:是不是把一个无界增长的数组(评论、事件、历史)嵌进了文档。逼近上限几乎总意味着访问模式选错了粒度,应改成分桶或引用。
- 约 100 万 × 20 = 2000 万个索引项(multikey 给数组每个元素各存一项)。意味着每次改这个数组都要维护多个索引项,数组越大写入越慢——所以数组要有界。
- E = Equality(等值)、S = Sort(排序)、R = Range(范围)。Equality 放最前是因为它把 B-tree 扫描缩到一段连续区间,后面的 Sort 和 Range 只在这一小段上工作;放后面则失去这个收窄效果。
- 解决高频小数据无界增长的问题——把按时间/批次产生的小数据分桶成多条有界文档,而不是无限嵌进一个数组。典型场景:IoT 读数、日志、按小时聚合的指标。
原理层
- 因为
$lookup对左侧每个输入文档都去右集合查一次,没有关系型优化器的 hash/merge join 策略。foreignField 无索引时,每个左文档触发一次右集合全扫描,复杂度 O(N×M)。修法:给 foreignField 建索引、先$match缩小左侧、或干脆嵌入。 - 第一次见到某查询形状,multi-planner 并行试跑多个候选计划一小段,选"产出最多/做最少工作"的胜者存进 plan cache;后续相同形状复用缓存。当数据分布变化或缓存计划表现退化时触发 replan。
- 因为旧版本不就地覆盖、也不靠后台清理:eviction 做页重整(reconciliation)时,最新值进数据文件、旧版本进磁盘上的 history store。回收发生在 eviction/checkpoint 流程里,不需要 Postgres 那样的 VACUUM 扫描死元组。
- 越过 eviction 阈值后,应用线程被"征召"同步参与 eviction 才能继续推进自己的操作。这是一道悬崖而非缓坡——working set 一旦超过 cache,p99 延迟陡升,因为前台线程突然要替引擎打工,而不是后台默默降速。
- checkpoint(约 60s)落一份一致的磁盘快照;journal/WAL 覆盖两次 checkpoint 之间的窗口。崩溃恢复 = 加载最后一个完整 checkpoint,再重放它之后的 journal。
- 因为 secondary 可能重复重放同一批 oplog,幂等保证重放不出错。例:
$inc: {n:1}被改写成绝对值$set: {n: 实际新值};一条改多文档的更新被拆成逐文档条目。 - 任选两个:(1) 副本集日志是幂等操作,不是状态机命令;(2) 复制是 secondary 主动 pull,不是 Raft 的 leader push(AppendEntries);(3) commit point 靠 primary 轮询多数节点的 lastDurable 算出,不是逐条计票;(4) 多了 priority takeover 和 dry-run election。
w:majority保证写在多数节点 durable 后才返回(故不会因 failover 被回滚);readConcern: majority保证读到的是 majority commit point 的快照(读到的数据不会被回滚)。组合起来排除了"读到一个稍后会被回滚的写"这种故障。
应用判别层
- 引用。库存命中两条"倾向引用"的判据——高频独立写入、写入时机与商品信息不一致;嵌入会造成更新放大(改库存要重写整个商品大文档,并制造版本churn)。详情页同时要两者,可在读取时
$lookup(给 inventory 的 productId 建索引),或用 Computed/Subset 模式冗余一个"是否有货"的轻量标志到商品文档、精确库存再查。核心:按写入模式决定,不按"库存属于商品"这种逻辑归属决定。 - 两处瓶颈:(1)
$lookup的 foreignField 全扫描——给 foreignField 建索引,并把$match提到$lookup之前缩小左侧输入;(2) 超 100MB 的$sort内存溢出——用索引支撑的排序(按 ESR 把 sort 字段放进复合索引的正确位置),实在不行才开allowDiskUse。两处分别对应 02 章的执行机制和 03 章的索引设计。 - shard key 用 compound,如
{tenantId, 某高基数字段}:tenantId 提供租户隔离与查询定向,第二字段把大租户内部的写入分散开,避免单调递增热点。纯tenantId会让大租户变成 jumbo chunk。跨租户 BI 报表是即席聚合的弱区,且会变成 scatter-gather——把分析数据 ETL 到列存/数仓单独跑,不要在 OLTP 分片库上硬跑全表聚合。一个系统两种负载分开放。 - 详情页要 read-your-writes:用因果一致性会话(causally consistent session)+
readConcern: majority,或直接读 primary,保证读到刚写的值。列表页容忍旧值:可读 secondary +readConcern: local换吞吐。关键是按访问点分别选一致性级别,不是全站一刀切——这正是把一致性当连续谱而非布尔。 - 正确粒度是设备 + 时间窗(如每设备每小时一条文档,内含该小时的读数数组),即 Bucket 模式。它对 cache 友好的原因:WiredTiger 以整个文档为载入单位,有界的小桶文档不会像无界大文档那样把热数据挤出 cache;分桶把读写都限制在固定大小的文档上。
- JSONB 更合适当:JSON 只是整体关系模型里的少数字段、需要把 JSON 与强类型表 join、或 schema 迟早会硬化下来(GIN 索引能查 containment)。MongoDB 真正胜出需要三个特征同时出现:整个模型是文档 + 读写以单个文档为单位 + 需要原生水平写分片。只满足"有点 JSON"不构成换库理由。
把这份教程压成一页选型备忘
不看教程,写一页"MongoDB 选型 + 建模"备忘录给团队:包含 (1) 三个"该上 MongoDB"的信号、(2) 三个"别上"的信号、(3) 嵌入 vs 引用的四问、(4) shard key 的两条红线、(5) 一句话的持久性配置默认建议。写完和各章对照,缺哪条就回哪章。
提示(卡住再展开)
骨架:该上=schema 多变 + 以文档为读写单位 + 要水平写扩展;别上=重多实体事务 + 复杂即席 join/BI + 强关系约束。四问=一起读/无界/共享/写入时机。红线=不用单调递增键、不用低基数键。默认=w:majority 起步,按访问点调 readConcern。每一条都能在前六章找到出处。