去年这个时候我在一家做 SaaS 的公司,orders 集合涨到 2100 万条文档,平均 1.8KB 一条,一个后台的「订单明细导出」接口从 800ms 一路涨到 11 秒。我第一反应是加索引,加了 {userId: 1, createdAt: -1} 之后快了点,但运营一换成按商品 SKU 筛选,又原地爆炸。这个问题我前后查了两天才定位到,中间还怀疑过是内网抖动(并不是)。真正的原因是这个查询要横跨三个集合:orders、order_items、products,而我们是用 $lookup 把它们串起来的。
$lookup 不是 JOIN,它是嵌套循环
$lookup 在 MongoDB 里属于「能做但最好别做」的那一类。它的执行模型本质上是等值匹配加子管道,左边每一条文档都要去右边查一次,如果 from 的那个集合没走上索引,那就是实打实的全表扫描。我们那次的 from 是 products,本地测试 3000 条数据感觉飞一样快,生产环境 80 万条商品就完全不是一回事了。MongoDB 5.1 之后 $lookup 支持 from 是分片集合,但前提是 join 字段得是 shard key 的一部分或者前缀,我们不是,所以连分片这条退路也没有。最后我干的事很土:把 order_items 里的商品快照冗余进订单文档本身,就是下单那一刻把 name、price、sku 直接存成子文档,接口回到了 400ms 上下。这个做法在关系型数据库里叫反范式,写 SQL 的时候会被 DBA 骂,但在 MongoDB 里,它不是权宜之计,它就是这套模型的设计前提。
所谓 schemaless,是个挺大的幻觉
我见过太多团队一边说「我们用 MongoDB 就是因为不用改表结构」,一边在代码里老老实实写着 zod 或者 mongoose 的 strict schema。所以模式根本没有消失,它只是从 schema.sql 挪到了应用层、API 文档,还有某个已经离职的同事的脑子里。挪动是有代价的:当你想搞清楚 accounts 集合里到底存在多少种字段组合时,只能写聚合管道去 $objectToArray 扫全表。我上一家公司真这么干过,2100 万文档跑了 40 分钟,还必须带 allowDiskUse: true,不然中途就会撞上聚合管道 100MB 的内存上限直接报错。关系型数据库的 ALTER TABLE 是慢,但它至少给你一个确定的、可回滚的、写在 binlog 里的答案,而不是让你去猜。
几个你必须记住的数字
MongoDB 有几个硬限制是绕不过去的:单文档 16MB 上限,单集合最多 64 个索引,排序阶段默认 32MB 内存、超了要么走索引要么开 allowDiskUse。WiredTiger 的默认缓存是「物理内存减 1GB 再乘 0.5」,与 256MB 取较大值——我们那台 8 核 32G 的机器,算下来缓存约 15.5GB,但热数据加索引实际占了 22GB 左右,多出来的部分全靠磁盘扛,这就是很多人口中「MongoDB 越用越慢」的真实原因:热数据集超过了内存能兜住的比例。MySQL 遇到这个问题还能加从库、调 buffer pool 分摊,MongoDB 如果一开始没规划好 shard key,等到数据上亿再想分片,那个迁移窗口足够你通宵两个晚上。
我现在的判断标准:数聚合根,别看 QPS
判断一个业务该不该上 MongoDB,我现在不看 QPS,也不看什么 Web Scale 之类的说法,就看一件事:一次读操作要跨越几个「聚合根」。如果一个请求 90% 的情况下只碰一个聚合根(一个订单、一份用户画像、一篇文章及其评论),MongoDB 写起来确实舒服,字段随便加,不用 DDL,迭代速度是真的快。但如果你的查询天然就是多对多的连接、外加七八种筛选条件的自由组合,那 $lookup 早晚会教你怎么做人。我的办法很笨:把产品需求里最复杂的那三个报表拿出来,问自己「用文档模型能不能在应用层一次性读完」,读不了,就别上。反过来,如果你连自己最复杂的三个查询长什么样都说不出来,那用什么数据库其实都无所谓。