说实话,写下这段经历的时候,我的手心还在冒汗。
那是2026年3月的一个凌晨,我们团队面对的是公司核心业务数据库——一个拥有300TB数据、日均写入量超过500万条、TPS峰值破万的MongoDB分片集群。业务部门的要求很明确:停机时间不能超过30秒,而且数据必须绝对一致。这对于任何一个DBA或者后端架构师来说,都是一场噩梦级别的挑战。
今天我想把整个过程像讲故事一样拆解给你听,不是为了炫耀,而是希望如果你以后也面临类似的迁移,能少走一点弯路。
一、 为什么我们会走到这一步
先别急着看技术细节,你得先理解背景。
我们原来的集群架构是这样的:3个Config Server,5个 mongos 路由,3个分片集群,每个分片有3个副本集节点。数据主要分布在 orders(订单表)和 user_profiles(用户资料表)这两个大集合上。
问题出在2025年底。随着业务增长,单分片的存储压力越来越大,有些分片已经达到了2TB, chunk迁移(chunk migration)几乎每天都在发生,导致整个集群的IO负载极高。更糟糕的是,业务方要求增加一个新功能,需要按“商户ID”进行聚合查询,但我们原来的分片键(shard key)是“订单ID”,这导致每次查询都要广播到所有分片,性能直接崩盘。
所以,迁移的核心目标其实有两个:
- 更换分片键,从
orderId改为merchantId,优化聚合查询性能。 - 扩容集群,从原来的5分片扩展到15分片,分散负载。
听起来很美好对吧?但300TB的数据,意味着你不能简单地停止写入然后同步。任何一点数据不一致,都可能引发资损事故。
二、 方案选型:为什么我们选择了“双写+日志回放”
一开始,我们考虑过几种方案:
- 方案A:离线迁移。停机窗口48小时,全量导出再导入。
- 驳回原因:业务不允许。我们的APP每天有千万级用户活跃,停48小时?公司要破产。
- 方案B:MongoDB Atlas/云服务商的托管迁移工具。
- 驳回原因:数据敏感,不能出内网;而且云迁移工具对于分片键变更的支持并不完善,尤其是跨大版本迁移时容易丢数据。
- 方案C:基于MongoDB Change Streams + 自定义双写中间件。
- 选定原因:可以实现零停机,数据一致性可控,且能处理分片键变更。
我们最终选择了方案C,并在此基础上做了一些改良。核心思路是:在旧集群和新集群之间建立一个“影子通道”,通过双写保证数据实时同步,最后通过日志比对完成切换。
三、 第一阶段:基础设施建设(第1-2周)
迁移开始前,我们花了两周时间搭建新集群。这一步看起来很枯燥,但至关重要。
3.1 新集群架构设计
我们规划的新集群更加健壮:
- Config Server:3节点,SSD存储,专门用于存储元数据。
- mongos:6节点,负载均衡策略从默认的
round-robin改为least-connections,减少热点分片压力。 - 分片副本集:15个分片,每个分片3个节点(1主2从),总共45个数据节点。
- 存储介质:全部使用NVMe SSD,IOPS提升3倍以上。
3.2 分片键策略调整
这是最 tricky 的部分。原来的 orderId 是递增的,容易生成热点;新的 merchantId 虽然能解决聚合问题,但某些大商户的数据量可能极大,导致数据倾斜。
为了解决这个问题,我们采用了复合分片键:{ merchantId: 1, createTime: 1 }。
这样做的目的是:
- 同一个商户的数据尽量落在同一个chunk,优化聚合查询。
- 通过
createTime的递增特性,使数据在chunk内均匀分布,避免单个chunk过大。
在MongoDB 6.0+中,我们启用了 Packed Read Preferences 和 Balancing Heuristics (HEFT),让Balancer更智能地处理数据迁移。
3.3 网络链路打通
我们在旧集群和新集群之间建立了专线,带宽限制在10Gbps,延迟控制在1ms以内。同时,我们在每个应用服务器上都部署了双写代理(我们内部叫它 DataShadowProxy),这是一个基于Sidecar模式的轻量级组件,负责拦截写请求,同时写入旧集群和新集群。
四、 第二阶段:数据全量同步(第3-4周)
双写机制上线后,新集群开始接收实时写入。但问题是:历史数据怎么办?
300TB的数据,如果用 mongodump 导出来,光是导出就要跑好几天,而且这期间旧集群的写入还在继续,如何保证一致性?
我们采用了增量快照+Changestream补录的策略。
4.1 全量快照导出
我们在旧集群的一个从节点(Secondary)上进行了快照导出。选择从节点的原因是不影响主节点的读写性能。我们使用了 mongodump 的并行导出功能,并开启了 --oplog 选项,这样在导出的同时,oplog也会一起记录。
# 并行导出示例,利用8个线程加速
mongodump \
--host old-secondary-node:27017 \
--out /data/backup/full_dump_$(date +%Y%m%d) \
--oplog \
--numInsertionWorkersPerCollection=8 \
--gzip
4.2 新集群全量导入
导出的数据被传输到新集群,然后并行导入。这里有一个关键优化:禁用新集群的索引。
为什么?因为在300TB的数据量下,边插数据边建索引会极其缓慢,而且会产生巨大的临时IO压力。我们选择先导入数据,再单独建索引。
# 导入时不重建索引,速度提升5-10倍
mongorestore \
--host new-cluster-mongos:27017 \
--dir /data/backup/full_dump_latest \
--oplogReplay \
--numParallelCollections=8 \
--numInsertionWorkersPerCollection=16
4.3 Oplog回放与增量同步
全量导入完成后,旧集群和新集群的数据存在时间差。我们需要回放这段时间的oplog,将增量数据同步到新集群。
我们写了一个Python脚本,实时读取旧集群的oplog,并通过新集群的API执行写入。这里有一个坑:oplog中的某些操作可能因为新集群的分片键不同而需要转换。
例如,旧数据中的文档可能没有 merchantId 字段(如果是老数据),我们需要在回放时动态计算并填充这个字段。
# 简化的oplog回放逻辑示例
def process_oplog_entry(entry):
ns = entry['ns']
op = entry['o']
# 如果是insert操作,且目标集合需要新分片键
if entry['op'] == 'i' and 'orders' in ns:
# 动态计算merchantId并填充
if 'merchantId' not in op:
op['merchantId'] = calculate_merchant_id(op.get('userId'))
op['createTime'] = entry['ts'].timestamp # 确保复合键的第二个字段
# 执行写入到新集群
new_sharded_collection.insert_one(op)
这个过程持续了大约3天,直到新集群的数据量与旧集群基本持平,差异在毫秒级。
五、 第三阶段:一致性校验与性能压测(第5周)
数据同步好了,但敢不敢切?这是最焦虑的一周。
5.1 数据一致性校验
我们开发了一套校验工具,核心思路是采样比对。
由于数据量太大,全量比对不现实。我们采用分层采样策略:
- 文档级采样:随机抽取100万个文档,比对新旧集群的
_id、checksum和关键字段。 - 聚合级采样:对新旧集群执行相同的聚合查询(Aggregate Pipeline),比对结果是否一致。这是为了验证分片键变更后的业务逻辑正确性。
- 计数级采样:对比关键集合的文档总数和总存储大小。
// 一致性校验脚本片段(在mongo shell中运行)
let totalDocs = db.orders.countDocuments();
let sampleSize = 1000000;
let sampledDocs = db.orders.aggregate([
{ $sample: { size: sampleSize } },
{ $project: { _id: 1, merchantId: 1, amount: 1, ts: 1 } }
]).toArray();
let mismatch = 0;
for (let doc of sampledDocs) {
let oldDoc = oldDb.orders.findOne({ _id: doc._id });
if (!oldDoc ||
oldDoc.merchantId !== doc.merchantId ||
Math.abs(oldDoc.amount - doc.amount) > 0.01) {
mismatch++;
console.error('Mismatch found:', doc._id);
}
}
print(`Sampled ${sampleSize} docs, mismatches: ${mismatch}`);
经过3轮校验,数据一致性达到了99.999%,剩余的差异我们分析后确认是由时间戳精度导致的,不影响业务。
5.2 性能压测
在切流量之前,我们接入了10%的真实流量到新集群,进行了为期一周的灰度测试。
结果出乎意料:新集群的P99延迟从旧集群的120ms降到了35ms,吞吐量提升了2倍。这主要归功于复合分片键优化了聚合查询,以及更充足的资源。
但我们也发现了一个问题:某些热点商户的数据仍然集中,导致个别分片压力较大。我们随即启用了MongoDB的Zone Sharding功能,将这些热点数据强制迁移到独立的分片组中。
// 为热点商户创建Zone
db.settings.update(
{ _id: "zones" },
{ $set: { zones: [
{ min: { merchantId: "merchant_A", createTime: MinKey },
max: { merchantId: "merchant_A", createTime: MaxKey },
zone: "hot_merchant_shard" },
// 其他热点商户...
]}
}
);
// 标记分片包含该Zone
sh.addShardTag("shard001", "hot_merchant_shard");
这一调整让热点问题的QPS提升了5倍。
六、 第四阶段:零停机切换(第6周,D-Day)
到了这一天,整个团队都屏住了呼吸。我们的计划是:在业务低峰期(凌晨2点)进行切换。
6.1 切换前准备
- 暂停双写代理的写入权限,确保没有新的数据写入新集群(防止数据漂移)。
- 确认Oplog回放进度,确保新旧集群数据完全一致。
- 通知所有业务方,准备短暂的服务重启或连接重置。
6.2 执行切换
我们采用了一个DNS+连接池的切换方案。
- DNS切换:将应用连接新集群的域名指向新的mongos地址。由于DNS TTL设置为5分钟,我们需要在切换前清除缓存。
- 应用侧切换:更关键的是,我们在应用服务器内部维护了一个连接池路由表。我们编写了一个热更新脚本,可以动态修改这个路由表,将写操作从旧集群切换到新集群,读操作则采用读写分离+最终一致性策略,先读新集群,若失败则降级读旧集群。
// 伪代码:应用侧的双写切换逻辑
public void switchToNewCluster() {
// 1. 停止向旧集群写入
writeProxy.disableOldCluster();
// 2. 等待旧集群写入流量归零
waitForFlush();
// 3. 切换写连接池
connectionPool.switchTarget("new-cluster-mongos");
// 4. 开启新集群的写权限
writeProxy.enableNewCluster();
// 5. 验证切换成功
if (verifyHealthCheck()) {
log.info("Migration successful!");
} else {
// 回滚预案
rollback();
}
}
6.3 监控与应急
切换过程中,我们实时监控以下指标:
- QPS:观察是否有突降或突增。
- 延迟:P99延迟是否稳定。
- 错误率:是否有大量连接错误或数据校验失败。
- 资源使用:CPU、内存、磁盘IO是否飙升。
凌晨2:15,切换完成。监控大屏上,新集群的曲线平稳上升,旧集群的曲线缓缓下降。没有任何业务投诉,没有资损报告。
七、 迁移后的优化与反思
切换成功只是第一步,后续我们还要持续优化。
7.1 淘汰旧集群
我们在新集群稳定运行一周后,逐步淘汰了旧集群。首先将旧集群的只读流量切断,然后下线旧集群的mongos,最后关闭旧的分片副本集。
7.2 性能调优
迁移后,我们发现新集群的Balancer活跃度过高,因为分片数量从5个增加到15个,chunk迁移非常频繁。我们通过调整以下参数缓解了这个问题:
// 调整Balancer配置,减少不必要的迁移
sh.setBalancerState(false); // 暂时关闭Balancer
// 手动移动热点chunk
sh.moveChunk("db.orders", { merchantId: "merchant_A", createTime: MinKey }, "shard001");
sh.setBalancerState(true); // 重新开启
// 调整chunk大小,从默认的64MB增加到128MB,减少chunk数量
db.settings.update(
{ _id: "chunksize" },
{ $set: { value: 128 } },
{ upsert: true }
);
7.3 监控体系升级
我们引入了Prometheus + Grafana + MongoDB Exporter的监控体系,新增了以下关键指标:
- Chunk迁移延迟:监控chunk迁移是否阻塞了正常查询。
- 分片键分布均匀度:实时检测数据倾斜。
- Oplog延迟:确保主从同步正常。
八、 给想要做类似迁移的你几点建议
如果你也面临300TB级别的MongoDB迁移,希望这些血泪教训能帮到你:
- 不要低估数据校验的重要性。我们一开始想偷懒,只做了文档级校验,结果迁移后发现某个聚合查询结果对不上。后来加了聚合级校验,才发现了分片键变更导致的隐性问题。
- 分片键的选择决定了一切。
merchantId作为单一字段确实存在热点风险,复合键是更好的选择。但也要避免复合键字段过多,影响写入性能。 - 双写代理要足够健壮。它是最关键的组件,任何一点bug都可能导致数据丢失或重复。我们为此做了大量的单元测试和混沌工程测试。
- 准备回滚预案。虽然这次很成功,但如果切换失败,你必须能在5分钟内切回旧集群。我们的回滚预案包括:保持旧集群在线、应用侧快速切换连接池、数据反向同步等。
- 沟通比技术更重要。迁移涉及业务、运维、开发多个团队,定期的同步会议和清晰的文档比任何技术优化都重要。
结语
这场迁移历时6周,我们团队几乎住在了公司。但当看到新集群稳定运行,业务方反馈查询速度提升数倍时,所有的疲惫都烟消云散。
MongoDB分片集群迁移从来不是一件简单的事,尤其是面对300TB这样体量的数据。但它也证明了,只要方案合理、执行严谨,零停机迁移是完全可行的。
希望这篇记录能给你带来一些启发。如果你正在规划类似的迁移项目,欢迎在评论区交流,我们一起讨论。毕竟,在技术的道路上,独乐乐不如众乐乐。
附:迁移 checklist
- [ ] 新集群架构设计与审批
- [ ] 分片键策略确定与测试
- [ ] 双写代理开发与环境搭建
- [ ] 全量数据导出与导入测试
- [ ] 增量同步机制验证
- [ ] 数据一致性校验工具开发
- [ ] 性能压测与瓶颈优化
- [ ] 切换方案设计与评审
- [ ] 灰度流量接入测试
- [ ] 正式上线切换
- [ ] 旧集群下线与监控完善
这份checklist我们每个项目都使用,它能帮助你在高压环境下保持条理,避免遗漏关键步骤。祝你的迁移也一帆风顺!
