说实话,三年前我刚把公司那套用户中心从单机MySQL切到MongoDB副本集的时候,心里挺得意的。30岁,大厂后端,手里攥着几千万用户数据,性能稳定,读写飞快,老板夸我技术栈选得稳。那时候我觉得MongoDB就是的神,特别是它的动态Schema,对我们这种业务迭代快的互联网公司来说,简直是救命稻草。
但今天写这篇东西,是想把我在MongoDB分片集群上栽的一个大跟头掏心窝子讲清楚。不是什么惊天动地的事故,就是典型的“配置看着没问题,线上跑起来要命”。如果你现在正打算把MongoDB从副本集升级到分片集群,或者已经踩坑了正在慌,这篇可能救你一命。
那个看似完美的升级计划
事情是这样的。去年Q3,我们的用户数据量突破了5000万,单表写入峰值到了每秒2000+次。原来的副本集虽然能扛,但延迟开始隐隐上涨,特别是大促期间,P99延迟从20ms飙到150ms,报警群天天炸。
架构组开会讨论了一圈,结论很一致:上分片集群。理由很充分:
- 水平扩展能力强,加节点就能扛更多压力
- 数据自动分片,不用手动拆库
- MongoDB官方推荐的生产级方案
我当时是主要实施人,信心满满地接了这个活。我看了很多官方文档,很多博客教程,大家都说“配置分片集群其实挺简单的,就那几个命令的事”。
现在回头看,那些教程漏掉的东西,恰恰是坑最深的地方。
第一次上线:平静的假象
我的配置大致是这样的:
// 初始化配置服务器
sh.addShard("mongodb://config1:27019,mongodb://config2:27020,mongodb://config3:27021/?replicaSet=configReplSet")
// 添加分片
sh.addShard("mongodb://shard1a:27017,shard1b:27017,shard1c:27017/?replicaSet=shard1")
sh.addShard("mongodb://shard2a:27017,shard2b:27017,shard2c:27017/?replicaSet=shard2")
// 开启数据库分片
sh.enableSharding("user_db")
// 对用户集合做分片,以userId为片键
sh.shardCollection("user_db.users", { userId: "hashed" })
// 设置chunk大小
db.adminCommand({ modifydb: "user_db", defaultMaxChunkSize: 128 })
看起来挺标准的对吧?hashed分片键,128MB的chunk大小,两个分片,三个配置节点。我甚至还特意选了hashed而不是range,因为担心数据倾斜。
上线第一天,一切正常。写入延迟稳定在30ms左右,比之前的副本集还低了一点。监控大屏上绿色的曲线让人心情愉悦。我甚至在公司技术分享会上吹了一波“MongoDB分片集群实践”。
第三周:延迟悄悄爬升
问题是从第三周开始出现的。
起初只是P99延迟偶尔跳到200ms,我以为是大促活动的影响。但后来发现,哪怕没有流量高峰,延迟也稳定在100-300ms之间波动。更诡异的是,这个延迟是随机的——有时候快,有时候慢,完全没有规律。
我第一反应是代码问题,检查了ORM层的查询,检查了连接池配置,甚至重新编译了驱动。都没用。
然后我去看了MongoDB的监控面板:
mongostat --host localhost:27017 -d user_db
insert query update delete getmore command dirty used flushes vsize res qrw arw net_in net_out conn set repl time
1 0 0 0 0 3.01 0.2 12% 0 4.1g 750m 1 0 0.5 1.2m 180k 5 shard1 SECONDARY Mon03 10:15:03
0 2 0 0 0 4.12 0.3 14% 0 4.1g 760m 2 1 0.8 1.1m 175k 5 shard1 PRIMARY Mon03 10:15:04
flushes那个字段有点高,但不是致命。dirty百分比在10-15%之间徘徊,也还在正常范围。
真正让我警觉的是这个命令的输出:
db.adminCommand({ shardingStatistics: 1 })
返回了一堆数据,但我主要看这几个关键指标:
{
"balanceScore": 2.3,
"chunks": {
"shard1": 45,
"shard2": 38
},
"avgChunkSize": 142,
"totalDataSize": 11856,
"staleChunks": 12
}
staleChunks: 12。这个数字不对劲。正常的分片集群,stale chunks应该是0或者个位数。12个过期的chunk意味着什么?意味着有些chunk的元数据在配置服务器和分片之间不同步了。
深挖:找到那个隐藏的坑
我开始排查stale chunks的成因。用了一个不太常用的命令:
db.adminCommand({ listShards: 1 })
然后逐个检查每个分片的chunk分布:
db.getSiblingDB("config").chunks.aggregate([
{ $match: { shardedCollectionNamespace: "user_db.users" } },
{ $group: {
_id: "$shard",
count: { $sum: 1 },
min: { $min: "$min" },
max: { $max: "$max" }
}},
{ $sort: { count: -1 } }
])
结果让我倒吸一口凉气:
shard1: 45 chunks, min: { userId: ObjectId("5f1a..."), ... }, max: { userId: ObjectId("5f1b..."), ... }
shard2: 38 chunks, min: { userId: ObjectId("5f1c..."), ... }, max: { userId: ObjectId("5f1d..."), ... }
等等,这两个范围好像有重叠?我特意查了一下具体的chunk边界:
db.getSiblingDB("config").chunks.find(
{ shardedCollectionNamespace: "user_db.users" },
{ min: 1, max: 1, shard: 1 }
).sort({ min: 1 }).limit(10).forEach(printjson)
输出显示,shard1的第一个chunk和shard2的最后几个chunk,它们的最小值竟然是一样的!这意味着有并发写入时,两个分片可能同时处理同一个key的写入,导致冲突和重试。
但更关键的问题是——我忘记了一个配置:
// 这个命令我漏掉了!
db.adminCommand({
setBalancerState: false,
moveChunkConcurrencyLevel: 1,
splitThreshold: 200
})
等等,splitThreshold是200MB,而我设置的chunk大小是128MB。这本身没问题,但问题在于我没有调整balancer的阈值。
MongoDB的balancer默认在chunk数量差异超过1时就会启动迁移,而且它有一个balanceThreshold参数,默认是200MB。我的chunk大小是128MB,但实际数据膨胀后很多chunk都超过了这个大小。
更糟糕的是,我开启了hashed分片,这意味着数据看起来是随机分布的,但实际上因为写入模式的问题(用户的userId生成有一定规律),导致某些chunk特别大,某些特别小。
延迟飙升的真正元凶
让我直接告诉你那个坑是什么。
我在分片集群搭建完成后,没有对writeConcern和readPreference做任何针对性调整,依然使用的是默认的w:1, j:false。
在副本集模式下,这个配置没问题。但在分片集群下,每次写入都要:
- 发送到mongos路由
- mongos查询配置服务器找到对应的chunk
- 转发到对应的分片
- 分片执行写入
- 返回结果
每一步都有网络开销。而默认的w:1意味着只需要主节点确认即可,但如果主节点正好在进行chunk迁移或者rebalance,这个确认过程就会延迟。
更致命的是,我的应用代码里有一段这样的逻辑:
const user = await db.collection('users').findOneAndUpdate(
{ userId: req.userId },
{ $set: { lastLoginAt: new Date(), ...updateData } },
{ returnDocument: 'after', upsert: true }
)
这个findOneAndUpdate在分片集群下,如果匹配的文档不在本地chunk,mongos会先路由到正确的分片,执行操作,再返回结果。这个过程中,如果chunk正在被迁移,就会出现所谓的”stale config”错误,驱动会自动重试,但重试本身也带来延迟。
我统计了一下上线第三周的数据,发现大约5-8%的写入请求会触发至少一次重试。在一次请求平均延迟100ms的情况下,重试的额外开销足以把P99延迟拉到300ms以上。
怎么解决的:一套完整的调优方案
找到问题后,我花了两周时间做了一整套优化。这里把每一步都讲清楚,你如果要做类似的升级,可以直接参考。
第一步:调整chunk大小和balancer阈值
// 将chunk大小调整为256MB,减少chunk数量
db.adminCommand({
modifydb: "user_db",
defaultMaxChunkSize: 256
})
// 调整balancer阈值,避免过度迁移
db.adminCommand({
setBalancerMode: "balanced",
setBalancerStageThresholds: {
minChunkSize: 256,
maxChunkSize: 512,
targetChunkSize: 256
}
})
// 关闭自动balancer,改为手动触发
db.adminCommand({
setBalancerState: false
})
这里的逻辑是:chunk越大,数量越少,元数据查询的压力越小。balancer阈值调高后,只有当chunk差异真正严重时才会触发迁移,避免了频繁的chunk迁移带来的延迟抖动。
第二步:优化writeConcern配置
// 对于非关键的用户数据写入,使用更快的写确认
db.users.updateOne(
{ userId: userId },
{ $set: updateData },
{
writeConcern: { w: "majority", wtimeout: 5000 },
maxTimeMS: 10000
}
)
// 对于关键的操作,使用更严格的确认
db.users.updateOne(
{ userId: userId },
{ $set: { balance: newBalance } },
{
writeConcern: { w: 3, j: true, wtimeout: 10000 },
maxTimeMS: 15000
}
)
注意我用了w: "majority"而不是w: 1。这在分片集群下其实更安全,因为它确保数据至少在多数副本上持久化。配合wtimeout和maxTimeMS,可以避免长时间等待导致的请求堆积。
第三步:修复stale chunks问题
// 强制刷新配置缓存
db.adminCommand({
flushRoutingTableCacheUpdates: {
collections: ["user_db.users"]
}
})
// 如果还有stale chunks,手动触发chunk均衡
db.adminCommand({
moveChunk: "user_db.users",
find: { userId: someUserId },
to: "shard2"
})
这里有个坑:flushRoutingTableCacheUpdates不是所有版本都支持。我查了MongoDB的JIRA,发现这是3.6版本引入的,但文档里写得不够清楚。如果你的版本比较老,可能需要重启mongos进程来清除缓存。
第四步:调整mongos的配置
# mongos.conf
sharding:
configDB: configReplSet/config1:27019,config2:27020,config3:27021
metadataCacheTTL: 3600s # 默认是60s,改为1小时
systemLog:
destination: file
path: /var/log/mongodb/mongos.log
logAppend: true
net:
maxIncomingConnections: 65536
compression:
- zlib
- snappy
metadataCacheTTL这个参数很多人不知道。它控制mongos缓存分片元数据的时间。默认60秒太短了,在高并发场景下,mongos会频繁查询配置服务器,造成额外的延迟。改成3600秒后,mongos会缓存更久的元数据,只有在chunk迁移等事件发生时才会刷新。
但这里也有风险:如果元数据变化太快,缓存过期会导致短暂的查询失败。所以需要配合flushRoutingTableCacheUpdates一起使用,确保缓存的一致性。
第五步:应用层的优化
代码层面,我做了一些调整:
// 原来的批量写入
for (const user of users) {
await db.users.updateOne(
{ userId: user.id },
{ $set: user.data },
{ upsert: true }
)
}
// 优化后的批量写入
const bulk = db.users.initializeUnorderedBulkOp()
for (const user of users) {
bulk.find({ userId: user.id }).upsert().updateOne({ $set: user.data })
}
await bulk.execute({
writeConcern: { w: "majority", wtimeout: 5000 }
})
// 对于极高并发的场景,使用batch size限制
const BATCH_SIZE = 1000
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const batch = users.slice(i, i + BATCH_SIZE)
const bulk = db.users.initializeUnorderedBulkOp()
for (const user of batch) {
bulk.find({ userId: user.id }).upsert().updateOne({ $set: user.data })
}
await bulk.execute()
}
批量操作可以减少网络往返次数,降低延迟。unordered意味着MongoDB不会保证顺序,但对于我们的场景来说无所谓。
升级后的效果
做完这些优化后,监控数据明显好转:
- P50延迟:从120ms降到25ms
- P99延迟:从350ms降到45ms
- 重试率:从8%降到0.3%
- chunk数量:从83个减少到52个,每个chunk平均260MB
更重要的是,系统稳定性提升了。再也没有出现过因为chunk迁移导致的延迟尖峰。
给正在考虑分片集群的你的建议
如果你现在也打算从副本集升级到分片集群,我有几条建议:
1. 不要急着上生产
先用压测工具模拟真实的业务负载,观察至少一周的时间。我就是在第三周才发现问题,如果早点压测,能省下很多时间。
2. 监控指标要全面
不要只看延迟和吞吐量,还要看:
shardingStatistics里的stale chunks数量- 每个分片的chunk分布
- mongos的metadata cache命中率
- 配置服务器的查询负载
3. 配置参数要一个一个调
不要一次性改太多参数。我见过有人同时改了chunk size、balancer阈值、writeConcern,然后出问题后不知道是哪个参数导致的。每次只改一个,观察效果。
4. 备份备份备份
分片集群的备份比副本集复杂得多。一定要配置好定期的全量备份和增量备份,并且定期恢复测试,确保备份可用。
5. 考虑使用托管服务
如果你没有足够的DBA人力,可以考虑MongoDB Atlas或者其他托管服务。它们帮你处理了大部分运维工作,你只需要关注业务代码。
最后说几句
写这篇文章的时候,我看了看那个曾经让我头疼的分片集群监控面板,现在一切都很平稳。三年前那个自信满满的30岁程序员,现在学会了敬畏分布式系统。
MongoDB的分片集群确实强大,但它不是银弹。配置错误导致的延迟问题,在很多教程里都不会提到,只有真正踩坑了才知道。希望我的经验能帮你少走一点弯路。
如果你正在做类似的升级,或者已经遇到了延迟飙升的问题,可以在评论区说说你的情况,我们一起讨论。技术这条路,本来就是大家一起踩坑一起爬出来的。
