Chapter 06

分片与选型

05 章把数据复制到多节点保证高可用;这章把数据水平切分到多组副本集——分片——并最终回答那个选型问题:什么时候该用 MongoDB,什么时候不该。每个分片本身就是 05 章那样的一个副本集。

本章你将建立的 schema

  • 分片机制:chunk / shard key / balancer / mongos / config server;targeted vs scatter-gather 查询。
  • shard key 选择:单调递增 = 热点;低基数 = jumbo chunk;hashed / compound 的取舍。
  • 何时用 / 何时不用 MongoDB;对比 Postgres(JSONB) / MySQL / DynamoDB / Cassandra。
  • "MongoDB 丢数据"声誉的事实时间线——真相是哪个默认值、什么时候改的。

6.1分片机制

mongos 按 shard key 把数据切成 chunk 分布到多个分片,每个分片是一个副本集,config server 存元数据。

为什么需要它

副本集解决高可用,不解决容量:每个节点都存全量数据,写入也都先过同一个 primary。当数据量或写吞吐超过单台机器,唯一的横向出路是把数据切开、分到多组机器各管一段。分片就是这一步——把"一个副本集装不下"变成"N 个副本集分着装"。

底层机制(比文档深一层):客户端不直接连分片,而是连 mongos 路由器。mongos 读 config server 上的元数据,把一个集合按 shard key 的取值范围切成一段段 chunk(块),每个 chunk 落在某个分片上。一条查询走哪条路,由它带不带 shard key 决定:带 shard key 的查询是 targeted,mongos 算出该值落在哪个 chunk、只把请求发给持有它的那个分片;不带 shard key 的查询是 scatter-gather,mongos 把请求广播到所有分片、各自执行后再合并,整体延迟等于最慢那个分片的延迟——这和 02 章 $lookup 是同一种形态:代价随规模放大。balancer 跑在 config server 的 primary 上,监控各分片的 chunk 数量、在分片间迁移 chunk 以均衡分布。这里有一个硬约束:一个 chunk 大到超过约 2 倍平均大小、且其 shard key 取值已无法再细分时,成为 jumbo chunk,balancer 无法迁移它——它是坏 shard key 的直接后果,后面 §6.2 会还这笔账。

mongos 路由器 读元数据 · 转发查询 config server 元数据 + balancer Shard A 副本集 Primary Secondary Shard B 副本集 Primary Secondary Shard C 副本集 Primary Secondary targeted 带 shard key → 单分片 scatter-gather 不带 shard key → 全广播
图 6.1分片架构与两类查询路径。注意:带 shard key 的查询只打一个分片,不带的广播全部——选 shard key 的本质是让你最高频的查询落在 targeted 这一侧。
想一想

一个分片集群有 6 个分片,某条高频查询不带 shard key。它的尾延迟(p99)大致由什么决定?加分片能改善吗?

展开答案(先停 10 秒)

它是 scatter-gather:mongos 广播到全部 6 个分片,必须等最慢那个返回才能合并。所以尾延迟由 6 个分片里最慢的一个决定,分片越多、撞上一个慢分片的概率越高——加分片不仅不改善,反而会推高这条查询的尾延迟。改善的方向是让这条查询带上 shard key(变 targeted),或为它单独建合适索引。

6.2shard key 选择

好的 shard key = 高基数 + 写入分散 + 高频查询常带上它;坏的 shard key 制造热点或 jumbo chunk。

为什么需要它

shard key 决定每条文档落在哪个 chunk、每条查询走 targeted 还是 scatter-gather。选错了,水平扩展的钱白花——加再多分片,写入仍挤在一台机器上。它是分片集群里最难改、影响最深的一个决策。

底层机制(比文档深一层):三类坏选择各有机制根源。单调递增键(ObjectId、时间戳——接 01 章 §1.2 埋的伏笔):因为新值总是当前最大,所有新写入落到持有"最大范围"那个 chunk、那个分片,形成写入热点,其余分片闲置,横向扩展失效。低基数键(布尔、国家这类取值很少的字段):取值少意味着 chunk 切到某个粒度后无法再按 shard key 细分,单个 chunk 持续膨胀成 jumbo chunk、无法迁移、堆在一个分片上。hashed 键:对 shard key 取哈希再分布,写入摊得很均匀,代价是牺牲范围查询的局部性——一个范围查询的连续值被哈希打散到各分片,退化成 scatter-gather。compound 键(多字段组合):用前缀字段保证分散、后续字段保留查询局部性,兼顾基数与 targeted。5.0(2021)起支持在线 reshardCollection 改 shard key,但它要重写并重新分布整个集合,代价高——最好一次选对,把它当不可逆决策来设计。

同一份写入流,三种 shard key 的负载形状 单调递增 S1 S2 过载 S3 全压一根柱 hashed S1 S2 S3 摊成三根 · 范围打散 compound S1 S2 S3 均匀 + 范围局部
图 6.2同一份写入流,三种 shard key 的落点。注意:单调递增键把它压成一根柱子、hashed 摊成三根——shard key 决定的是负载形状,不是存储位置。
表 6.1 · shard key 候选 → 问题 → 修复
候选 shard key问题修复
_id(默认 ObjectId)单调递增 → 写入热点,全压最新分片改 hashed _id,或换业务键做 compound
createdAt 时间戳单调递增 → 同上,新数据全落一处{tenantId, createdAt} compound 或 hashed
country / 布尔标志低基数 → chunk 无法细分,jumbo chunk 堆积叠高基数字段成 compound,提升可分性
hashed userId写入均匀,但按 user 的范围查询变 scatter-gather查询若总带等值 userId 则无碍;要范围则用 compound
{region, userId}若某 region 数据极度倾斜,该前缀仍会热点评估各前缀值的数据量,必要时把高基数字段前置
想一想

用创建时间戳做 shard key,写入压力会怎样分布?

展开答案

几乎全压到持有最新时间范围的那个分片,其余分片闲置——这是典型的单调递增热点。加分片也不会提升写吞吐,因为新写入永远指向"当前最大值"那一段。改用 hashed 把写入打散,或用 {tenantId, timestamp} 这样的 compound 键:前缀把写入按租户分散,后缀仍保留按时间的范围局部性。

6.3何时用 / 何时不用 MongoDB

默认用你已有的关系型库,除非下面某一条判据明确翻转这个默认。

为什么需要它

对一个 PostgreSQL/MySQL 熟练的工程师,选型的起点不该是"MongoDB 能不能做这个"——几乎都能做。起点应是"现有的关系型库在这个场景上输在哪个具体机制点"。没有这样一个明确的失分点,换库只是增加运维面、损失事务与 JOIN 能力。

对比 Postgres(含 JSONB):Postgres 的 JSONB 在 JSON 只是数据里少数字段、其余实体需要 join typed 表、且 schema 会随时间硬化时是够用的——GIN 索引能对 JSONB 做 containment 查询。MongoDB 真正赢的场景是整个领域模型就是文档、读和更新以文档为单位、并且需要原生的水平写分片。Postgres 赢在多实体事务的完整性、即席 join 与报表、外键约束。

对比 MySQL:判据的轴线类似。MySQL/InnoDB 在给钱、库存、订单这类需要强原子性与约束的负载上更稳;它的成熟事务与外键不是 MongoDB 文档模型的强项。

对比 DynamoDB / Cassandra:当访问模式固定且已知、并且是极端规模的写入时,宽列 / KV 系统更合适——Cassandra 擅长写多、append 为主、多数据中心 active-active;DynamoDB 是 serverless、延迟可预测的 KV。MongoDB 在这条轴上的优势不是裸写规模,而是灵活的即席查询 + 二级索引 + 聚合管道;如果访问模式已经固化到不需要这些灵活性,宽列 / KV 往往更省。

MongoDB 的甜区:商品目录 / CMS / 内容系统;每个实体 schema 多变的文档;高写吞吐且要水平扩展;用聚合管道做实时分析;事件 / IoT 数据摄取。MongoDB 的错用:重多实体事务完整性的核心交易;复杂即席 join 与 BI 报表;强关系约束;以及 schema 其实稳定且本质关系型的负载(多数业务线应用属于此类)。

表 6.2 · 选型决策矩阵
场景MongoDB?更好的替代
商品目录 / CMS,每类商品字段差异大合适—(文档模型甜区)
支付 / 订单 / 库存,多实体强事务不合适Postgres / MySQL(InnoDB)
跨多实体的即席 join 与 BI 报表不合适Postgres + 列存 / 数仓
事件 / IoT 高写入摄取 + 实时聚合合适—(或写极端规模时 Cassandra)
访问模式固定的超大规模 KV可用但非最优DynamoDB / Cassandra
开始选型 schema 多变 & 以文档为读写单位? 关系型 否 是 复杂即席 join 或多实体强事务? 关系型 或谨慎评估 是 否 访问模式固定 且极端规模写? Dynamo / Cassandra 是 MongoDB 否
图 6.3选型决策树。注意:第一个分叉问的是访问模式与读写单位,不是"数据像不像表"——这正是 01 章那条主线(为访问模式建模)在选型层的复现。
想一想

一个团队给出的理由是"数据有嵌套结构,所以选 MongoDB"。按图 6.3,这条理由本身够不够支撑选型?

展开答案

不够。"数据有嵌套结构"在 Postgres 里用 JSONB + GIN 索引也能存能查。决策树的第一个分叉不是问"数据长不长得像嵌套文档",而是问读写是否以整个文档为单位 + schema 是否每实体多变。如果嵌套数据其实 schema 稳定、还需要和其他表 join 出报表,那它落在"否"或第二个分叉的"是",应留在关系型。嵌套结构是表象,访问模式与读写单位才是判据。

6.4"丢数据"声誉的事实时间线

"MongoDB 丢数据"的印象来自十多年前的驱动默认值,不是存储引擎缺陷;多个默认在 2012 至 2021 间已逐一改硬。

为什么需要它

这条声誉在技术选型讨论里反复出现,常被当成"别用 MongoDB"的终结性理由。对资深工程师,把它当事实时间线核对一遍,比接受或反驳一个标签更有用——结论应建立在"哪个默认值、什么时候改的"之上。

表 6.3 · 持久性相关默认值的事实时间线
时间事件对持久性的意义
2012 之前驱动默认 fire-and-forget(w:0,不确认)"丢数据"的真正根源——是驱动默认值,不是存储 bug
2012-11新 MongoClient 驱动默认改为 w:1(确认写入)写入默认开始等单节点确认
3.0(2015)引入 WiredTiger(文档级锁 + 压缩);MMAPv1(集合级锁)弃用并发与崩溃恢复显著改善;MMAPv1 于 4.2(2019)移除
4.0(2018)/ 4.2(2019)多文档 ACID 事务:副本集 → 分片集群跨文档原子性从无到有,覆盖到分片层
5.0(2021)默认 writeConcern 改为 w:majority默认即多数派持久,掉一个节点不丢已确认写

把这条时间线读完,现状是一句话:现代 MongoDB 默认 w:majority + WiredTiger,"丢数据"印象停留在十多年前那个 w:0 的驱动默认值上,与今天的默认行为不符。诚实地补一句与持久性无关、但影响选型的事实:MongoDB 自 2018 年起改用 SSPL 许可证(Server Side Public License,OSI 未认可其为开源许可证),它影响自托管再分发与 Linux 发行版打包决策——若计划把 MongoDB 作为服务再分发、或依赖发行版仓库直接提供,需要单独评估这一项。

校准 · 带边界

"默认 w:majority"指的是已确认的写入在多数派持久。它不保证未确认或被显式降级到 w:1 / w:0 的写入——持久性始终是 writeConcern 的函数,由调用方设定。结论不是"MongoDB 现在绝不丢数据",而是"它的默认值已不再是丢数据的原因",剩下的责任回到每次写入选用的 writeConcern 上(05 章的 w / j 语义)。

§本章 self-check

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. targeted 查询和 scatter-gather 查询的差别是什么?是什么决定一条查询属于哪一种?
  2. 为什么用单调递增字段(如时间戳或 ObjectId)做 shard key 是反模式?它具体破坏了哪个目标?
  3. (设计题)一个内容聚合站点:每篇内容字段差异大、读多写中、几乎不做跨实体 join。该用 MongoDB 还是 Postgres?说出判据。
  4. "MongoDB 丢数据"的事实根源是什么?它在何时、由哪个默认值的改变被修复?
答案(先做完再展开)
  1. targeted 只把请求发给持有相关 chunk 的那一个分片;scatter-gather 广播到所有分片再合并、延迟等于最慢分片。决定因素是查询带不带 shard key:带则 mongos 能算出目标分片(targeted),不带则只能全广播(scatter-gather)。
  2. 单调递增键的新值总是当前最大,所有新写入落到持有"最大范围"的同一个 chunk / 分片,形成写入热点,其余分片闲置。它破坏的是写入分散这个目标——分片本是为横向扩展写吞吐,热点让加分片失效。
  3. 倾向 MongoDB。判据:每实体 schema 多变(文档模型甜区)、以文档为读写单位、几乎不做跨实体 join(避开了关系型的强项区)、读多写中也契合。若该站点其实需要复杂即席报表或多实体强事务,则判据翻转、应留在 Postgres。
  4. 根源是 2012 之前驱动的 fire-and-forget 默认值(w:0,不确认写入)——是驱动默认值不是存储引擎 bug。2012-11 新 MongoClient 默认改 w:1,5.0(2021)默认进一步改为 w:majority,至此默认即多数派持久。
进阶挑战 · 刚好够不着

给一个多租户 SaaS 做选型判断

需求:多租户 SaaS,每个租户的数据结构可自定义字段(不同租户字段集不同);读多写中;偶尔要跨租户出 BI 报表。先不查资料,判断:MongoDB 是否合适?哪部分合适、哪部分不合适?shard key 选什么?

提示(卡住再展开)

分两种负载看。OLTP 主体(合适):自定义字段 + 每租户独立文档,正好是文档模型甜区;读多写中也契合。shard key 用 {tenantId, ...} 这样的 compound:前缀 tenantId 把租户数据分散到各分片(兼顾隔离与负载均衡),后缀按需保留查询局部性——避免单调递增热点,也避免单 tenantId 低基数导致 jumbo chunk(大租户可再叠一个高基数字段)。跨租户 BI 报表(不合适):这是即席 join / 大范围聚合的弱区,在 OLTP 库上硬跑会拖累线上、且多为 scatter-gather。把分析数据 ETL 到列存 / 数仓单独跑——一个系统里把 OLTP 和分析两种负载分开放,而不是让一个库同时承担两种相反的访问模式。