图 1:本文封面,分片集群的三类组件与片键三问。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「MongoDB 分片是怎么回事」是数据库面试的常客,能背出 mongos、config server、shard 三个名词的人不少,但真正的分水岭在追问里:片键选错会发生什么,什么量级才值得上分片。
为什么需要分片:单机到顶之后的水平出路。
单机数据库的压力来自两个方向:数据量(磁盘与内存装不下,热数据无法常驻内存)和写吞吐(单机的写入上限被持续压满)。垂直扩容(换更贵的机器)有硬上限且成本陡增,分片(sharding)是 MongoDB 给出的水平答案:把数据集切成若干子集,分散到多台机器上,容量与吞吐随机器数近似线性扩展。先记住结论再展开:分片解决的是「装得下、写得动」,不管「挂不了」——高可用是副本集的职责(本批另一篇真题专门讲),两者正交。
分片集群的三类组件。
MongoDB 官方文档把分片集群的组件列为三类。第一类是 shard:每个分片持有数据集的一个子集,且每个分片本身必须部署为一个副本集——分片管扩展,副本集管分片内部的高可用。第二类是 mongos:查询路由器,应用不直接连分片,只跟 mongos 对话;mongos 拿着元数据判断目标数据在哪个分片,把请求路由过去,涉及多分片的查询再合并结果。第三类是 config server:存储集群的元数据与配置——核心就是「哪个数据块在哪个分片」这张映射表,它也必须部署为副本集。生产集群的典型形态是:3 节点的 config server 副本集、每个分片各 3 节点、一个或多个 mongos。
数据怎么被切开:片键、范围与数据块。
分片的魔法全在一个字段上:片键(shard key)。片键是集合中一个带索引的字段(或复合字段),MongoDB 把片键的值域划分成互不重叠的范围,每个范围对应一个数据块(chunk),后台均衡器在分片之间迁移数据块,让各分片负载尽量均匀。
一次写入的旅程是:应用把文档发给 mongos;mongos 按片键值查到它属于哪个范围、该范围当前在哪个分片;请求只打到那个分片。查询同理——带片键条件的查询能精确定位单个分片,不带片键的查询只能广播到所有分片做散射收集,这也是片键影响查询性能的原因。
片键三问:选错全盘皆输。
片键在集合分片那一刻就基本绑定了(新版本支持补充字段或整体重分片,但都是要停机评估的大动作),所以面试里「怎么选片键」比「分片是什么」值钱得多。三个检查问题:
基数够大吗?片键取值太少,值域就切不出足够多的范围,数据块无法继续分裂,出现无法迁移的超大块(jumbo chunk),分片数再多也用不上。
分布均匀吗?取值集中在少数几个键上,对应的数据块会持续膨胀成热点,均衡器疲于迁移也追不上。
会单调递增吗?这是最经典的坑:用时间戳或自增 id 做范围分片的片键,新写入的键值永远落在最大范围,而最大范围永远在某个「最后」的分片上——集群再大,写入也全压一台机器。解法之一是换哈希片键:MongoDB 对片键值做哈希再切范围,单调的键被打散,写入近似均匀铺开,代价是范围查询失去局部性。
用代码把写热点模拟出来。
下面这段脚本模拟 3 个分片、3 万条单调递增键的写入:范围分片对照哈希打散后的分布差异。用 Node.js 22.15.0 运行(node shardkey.js):
const SHARDS = ['Shard-1', 'Shard-2', 'Shard-3'];
function routeRange(key) { // 范围分片:键值落在哪段就去哪个分片
if (key < 33333) return SHARDS[0];
if (key < 66666) return SHARDS[1];
return SHARDS[2];
}
function hash32(x) { // FNV-1a,模拟哈希片键的打散效果
let h = 0x811c9dc5;
for (const ch of String(x)) {
h ^= ch.charCodeAt(0);
h = Math.imul(h, 0x01000193) >>> 0;
}
return h % 100000;
}
const N = 30000;
const mono = Object.fromEntries(SHARDS.map(s => [s, 0]));
const hashed = Object.fromEntries(SHARDS.map(s => [s, 0]));
for (let i = 0; i < N; i++) {
const ts = 70000 + i; // 新写入的键持续增大(时间戳语义)
mono[routeRange(ts)]++;
hashed[routeRange(hash32(ts))]++;
}
const pct = o => SHARDS.map(s => `${s}=${(o[s] / N * 100).toFixed(1)}%`).join(' ');
console.log('单调递增片键写入占比:', pct(mono));
console.log('哈希片键写入占比: ', pct(hashed));
实测输出:
单调递增片键写入占比: Shard-1=0.0% Shard-2=0.0% Shard-3=100.0%
哈希片键写入占比: Shard-1=33.3% Shard-2=33.6% Shard-3=33.1%
单调片键下 3 万条写入 100% 落在 Shard-3——扩容扩了个寂寞;哈希打散后三个分片各占约三分之一,这才是分片该有的样子。
图 2:集群架构、查询路由流程与片键三问的全景。
什么时候才需要分片。
分片不是默认配置,而是最后的扩容手段。值得上分片的信号:数据量或热数据内存占用持续超出单机可承受范围,或者写吞吐长期压满单机上限,且读扩展、索引优化等手段已经用尽。不满足这些信号时,优先用副本集扛住读流量和高可用——分片集群多出了 mongos 层、config server、均衡器与跨分片事务的复杂度,运维成本高得多,提前上马是给自己加戏。向量库与特征存储面对「数据超单机后按什么键切分」时,片键的基数、单调性与写热点分析可以整套迁移。
回到面试现场,建议的答题结构:先一句话定位「分片管水平扩展,副本集管高可用」,再讲三类组件与一次查询的路由流程,然后重点展开片键三问与单调键写热点(能画出分布对比更加分),最后给出「什么量级才值得分片」的判断标准。组件名单是及格线,片键分析与上马时机才是区分度。你经手的系统里,有没有遇到过片键或分库分表键选错、后期不得不迁移的经历?欢迎讲讲当时的代价。