MongoDB的野性难驯:我花了三年摸透WiredTiger的那些疯狂设计

一场半夜的P0故障,让我把MongoDB的存储层扒了个精光

一场半夜的P0故障,让我把MongoDB的存储层扒了个精光
一场半夜的P0故障,让我把MongoDB的存储层扒了个精光

那天凌晨两点,监控告警群炸了。长话短说,线上一套MongoDB分片集群,读写延迟突然飙升到20秒,CPU 100%,磁盘IO打满。我们是做内容平台的,用户上传的稿子、评论、元数据全在MongoDB里。延迟一高,整个App感觉得了帕金森——卡得不行。说实话,第一反应是扩容加机器,但查了一圈发现,就是那个WiredTiger的checkpoint机制搞的鬼。这玩意儿,你不真正理解它的脏页刷盘逻辑,在流量高峰期就是颗定时炸弹。

WiredTiger:一棵野生B树的自我修养

很多人以为MongoDB还是那个早期内存映射文件时代的玩具,其实从3.0开始,默认存储引擎WiredTiger让它彻底翻身——不过,翻身翻得有点野。WiredTiger底层数据组织是B+树吗?不,它用的是变种B树,更接近LSM树和B树的混血。具体来说,文档写入先到内存的跳表(skiplist)结构,然后刷盘成B树页。这种架构的好处是写入速度极快——因为内存跳表是log-structured的,append only,没有随机写惩罚。但坏处呢?读操作可能需要合并内存跳表和磁盘B树的数据,这就引入了读放大。有一次,我们一个业务更新了文档里一个几百KB的字段,结果发现CPU突然飙高,查下来就是WiredTiger在解压页面、应用修改、再压缩——讲真,这压缩算法(默认snappy)虽然快,但反复解压重压对大字段简直是噩梦。

类比一下,WiredTiger就像一个疯狂的图书管理员。他面前有两张桌子:一张是临时的“修改桌”(内存跳表),所有借还书操作都先写在便签上贴在这里;另一张是永久书架(磁盘B树),书是按索书号排好的。每隔一段时间(checkpoint),管理员就把修改桌上的便签整理到书架上——他会把整排书架拿出来,修改,再放回去。这中间如果有人来借书,可能既要看修改桌的便签,又要看书架。如果便签堆太多,书架整理时间过长,整个图书馆就堵住了。这就是checkpoint引发IO争抢的根源。

MongoDB WiredTiger存储引擎内存跳表与B树交互示意图
MongoDB WiredTiger存储引擎内存跳表与B树交互示意图

一次压测让我看清了MongoDB的吞吐真相

为了验证优化效果,我们搭建了相同规格的物理机(128G内存,NVMe SSD),对比了MongoDB 4.4、MySQL 8.0(用JSON字段)和PostgreSQL 13(jsonb)在相同文档模型下的性能。测试场景:1000万文档,单个平均2KB,50%读、40%写、10%更新,并发200线程。结果让我有点意外:MongoDB的写入吞吐达到3.8万 ops/sec,而MySQL JSON只有1.2万,PG jsonb是2.1万。但读取延迟的P99,MongoDB在默认配置下是45ms,PG却能做到12ms。为什么?因为MySQL和PG的B树索引对于范围查询和二级索引的支持更成熟,MongoDB的读取路径需要额外合并MVCC版本链。不过话说回来,当文档结构嵌套很深时,MongoDB一条查询就能捞出一个完整用户画像,MySQL得join好几次,这时候MongoDB的优势就出来了——我们一个推荐系统接口,改MongoDB聚合管道后,RT直接从800ms降到120ms。

所以说,没有银弹,只有适不适合。文档模型、灵活的schema、横向扩展能力,这才是MongoDB的杀手锏,而不是单纯比单机读写性能。

MongoDB与MySQL PostgreSQL JSON性能压测柱状图对比
MongoDB与MySQL PostgreSQL JSON性能压测柱状图对比

三个让我吐过血的坑,希望你不用再踩

三个让我吐过血的坑,希望你不用再踩
三个让我吐过血的坑,希望你不用再踩

坑一:索引的“易建难养”

MongoDB创建索引太容易了,一个命令就搞定。但索引维护的代价被很多人忽略。有一次,业务为了一个后台管理页面的模糊搜索,给一个文本字段建了全文索引。结果第二天,主库磁盘占用暴涨300%,写入延迟翻倍——全文索引的倒排表写入开销极大,比普通索引高一个数量级。解决方案是:先评估查询模式是否真的需要全文索引;如果只是前缀匹配,用普通索引+正则效率更高;实在要走全文,挪到ES或者独立部署藏库,别和在线业务混用。另外,MongoDB的索引交集(index intersection)是个坑,多个单字段索引可能被优化器组合使用,但效率往往低于复合索引,压测时直接用explain(‘executionStats’)看执行计划,别被allPlans迷惑。

坑二:内存的边界——WiredTiger cache的配置艺术

WiredTiger内部缓存默认是机器内存的50%减1G。这个值看似科学,实则粗暴。我们遇到过一种情况:机器内存256G,cache配了128G,但操作系统文件系统缓存也需要内存啊。MongoDB依赖文件系统cache来缓存压缩的磁盘页,如果这部分被挤占,读操作会频繁从磁盘解压,CPU和IO都暴涨。最佳实践?预留30%-40%内存给系统,用cgroup限制MongoDB进程不要占用全部。我们后来设置cacheSizeGB为总内存的60%,并且用swappiness=1,明显毛刺少了。但切记,单个节点的cache不是越大越好——因为checkpoint时脏页刷盘量会变得巨大,导致IO spike。所以规模大了得靠分片来摊薄。

坑三:分片键的诅咒

选分片键是最考验架构师功底的地方。我们最初用自增的userId做哈希分片,自以为均匀。但某个头部主播入驻,他的粉丝互动数据瞬间井喷,导致一个分片上热点集中,那个分片的cpu和io被打爆,迁移chunk都来不及。这就是写热点的典型。后来改成组合分片键:{region: 1, userId: 1},并且用range分区,把热点用户按地域打散。代价是跨region查询需要scatter-gather,所以我们把大部分查询限制在单个region内。分片键的选择没有标准答案,只能压测模拟真实流量,不断调整。还有个小技巧:开启shardTag,把某些分片固定在SSD节点上,比如日志数据放在机械盘,核心业务放在SSD。

MongoDB 5.0后的新曙光——时序与版本化

MongoDB 5.0后的新曙光——时序与版本化
MongoDB 5.0后的新曙光——时序与版本化

最近我们升级到了5.0,原生时序集合(time-series collections)解决了之前存储IoT数据的痛点。以前用普通集合存时序数据,写入放大严重,存储空间是实际数据的3倍。时序集合自动按时间段分桶,列式存储压缩,存储成本降了70%,查询速度反而更快——因为时间段分桶后,扫描范围小多了。另外,版本化API这个特性,老架构师会感动得想哭:终于不用再担心驱动升级后行为不兼容了。说实话,MongoDB的版本迭代曾经是运维的噩梦,现在总算给了个稳定承诺。

说到底,MongoDB是个优缺点极其鲜明的数据库。用好它的前提是,理解它每一个设计决策背后的权衡。别被“Schema-less”洗脑,真正的Schema-less意味着你把数据校验压力从数据库移到了应用层,代码里到处是字段存在性判断,恶心得很。我现在项目里都要求JSON Schema验证,强制文档结构——算是一种对自由的约束吧。有时候,走回约束的路,反而更轻松。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:MongoDB的野性难驯:我花了三年摸透WiredTiger的那些疯狂设计
文章链接:https://m.lfdjt.com/info_23_7727.html