2026 年 4 月,我们一个用 MongoDB 6.0 做主存储的物联网平台(iot-platform)P99 一夜之间从 80ms 飙到 7.4 秒,持续 6 小时,API 网关 5xx 比例 23%。表面看是"数据库慢",但 Atlas 监控里的 cluster CPU 35%、网络 20 MB/s、磁盘 IO 30%——所有硬指标都在健康线以下。"基础设施健康但业务慢"是最耐人寻味的故障类型,通常意味着应用层正在以低效方式调用底层资源。
3 天的排查最终定位到根因:一周前业务侧上线了一个新接口,需要把设备主表(分片 key 为 tenant_id)和设备最新状态表(分片 key 为 device_id)用 $lookup join 起来返回。这个 lookup 在分片集群里触发了"scatter-gather + 跨分片 fan-out"——每条来自 device 表的记录都要去全部分片找匹配的 status 记录,4 个分片下,1000 条结果集 = 4000 次远程子查询。压力一上来 P99 直接崩。
故障背景:这个集群的规模
| 维度 | 规模 | 备注 |
|---|---|---|
| MongoDB 版本 | 6.0.13 | Atlas M40 集群 |
| 分片数 | 4 | 每分片 3 节点 PSS |
| 主集合 devices | 2.8 亿文档,1.2TB | 分片 key:tenant_id (hashed) |
| 状态集合 device_status | 2.8 亿文档,400GB | 分片 key:device_id (hashed) |
| QPS | 峰值 18k | 读 16k 写 2k |
| 正常 P99 | 80 ms | 故障前一周稳定值 |
| 故障 P99 | 7.4 s | 峰值时段 |
新上的接口长这样,看起来简单到不能再简单:
// 新接口:按 tenant 返回设备列表 + 每个设备的最新状态
db.devices.aggregate([
{ $match: { tenant_id: "t-9821" } },
{ $lookup: {
from: "device_status",
localField: "device_id",
foreignField: "device_id",
as: "status"
}},
{ $unwind: "$status" },
{ $project: { device_id: 1, name: 1, "status.online": 1, "status.last_seen": 1 } }
]);
第一眼看是标准的 join 写法,跑过 PostgreSQL 的人都觉得"就这样啊"。问题恰恰在这——关系型数据库的 join 经验在 MongoDB 分片集群上是有毒的。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 09:15 | 新接口灰度 10%,无异常,继续放量 |
| D1 14:30 | 放到 100%,2 小时后 P99 开始抖动,从 80ms 到 200ms |
| D1 22:00 | 夜间 QPS 涨到 18k,P99 直接飙到 7.4s,告警炸开 |
| D2 00:30 | 第一波处理:扩容 cluster 到 M60,无效;CPU 从 35% 涨到 42%,响应没改善 |
| D2 01:00 | 临时把新接口下线,P99 立刻回到 90ms,确认根因在这个接口 |
| D2 09:00 | 开始排查 $lookup 行为,看 explain plan,发现 "stage": "REMOTE_CURSOR" |
| D2 13:00 | 开 mongos 日志,看到大量 "scatter-gather" 标记,确认跨分片 lookup |
| D2 16:00 | 方案对比:重设计分片 key vs 反规范化 vs 应用层 join,三选一 |
| D3 10:00 | 选择反规范化:把 device_status 关键字段冗余到 devices 集合,改造接口 |
| D3 18:00 | 灰度 30%,P99 = 75ms;次日全量,故障彻底消失 |
第一轮排查:被 CPU 利用率骗了
事故初期我们盯着 Atlas 仪表盘,CPU 35%、内存 60%、磁盘 IO 30%——一切看起来都健康。第一反应是"基础设施够,肯定是连接数不够",于是把 client 端连接池从 100 拉到 500。结果P99 反而升到了 11 秒,因为更多连接进来排队的请求更多。
// 错误的修法:加大连接池
const client = new MongoClient(uri, {
maxPoolSize: 500, // 从 100 拉到 500
minPoolSize: 50
});
// 结果:更多请求挤进来,mongos 队列更长,P99 更糟
这是个典型的"扩容反向加重瓶颈"情况——当瓶颈在下游(mongos 调度跨分片查询),加大上游并发只会让队列更深。第一原则:不要在没找到瓶颈点之前盲目扩容。
第二轮排查:explain 看到了 REMOTE_CURSOR
意识到要先看 query plan,跑 explain:
// 关键诊断命令
db.devices.aggregate([
{ $match: { tenant_id: "t-9821" } },
{ $lookup: { from: "device_status", localField: "device_id", foreignField: "device_id", as: "status" } },
{ $unwind: "$status" }
]).explain("executionStats");
// 输出关键段(简化):
// "stages": [
// { "$cursor": { ..., "executionStats": { "nReturned": 1200, "executionTimeMillis": 12 } } },
// { "$lookup": {
// ...,
// "executionStats": {
// "nReturned": 1200,
// "executionTimeMillis": 7320, // 整个 lookup 阶段耗时 7.3 秒
// "totalDocsExamined": 0
// },
// "shardsTargeted": ["shard-0", "shard-1", "shard-2", "shard-3"],
// "executionMode": "scatter-gather" // !!!这就是问题
// }
// }
// ]
关键证据是 "executionMode": "scatter-gather" + "shardsTargeted" 列出了全部 4 个分片。这意味着每条 device 文档(1200 条)都触发了一次"问全部 4 个分片"的查询,本质 4800 次小查询串行执行。即使每次只 1.5ms,4800 × 1.5 = 7.2 秒,精确对上 lookup 阶段耗时。
问题本质:MongoDB 分片集群中 $lookup 的执行机制
MongoDB 的 $lookup 在非分片集合之间表现很好——它会在本地完成 join,基本接近关系型数据库的体验。但当 from 集合是分片集合时,情况截然不同:
核心规则总结:
| $lookup 的 from 集合 | localField 是 from 的分片 key 值? | 性能 |
|---|---|---|
| 未分片 | — | 好(本地 join) |
| 分片 | 是 | 可接受(每条精确路由) |
| 分片 | 否 | 灾难(每条 scatter-gather) |
我们的场景就是最后一行——devices 的 device_id 字段不是 device_status 的分片 key(device_status 用 device_id hashed 分片,值是哈希后的桶),结果每条 device 都要去问 4 个分片。这种 lookup 的复杂度是 O(N × shards),不是想象中的 O(N)。
修法 1:反规范化(我们最终选的)
对这个场景最干净的修法是反规范化——把 device_status 里业务真正需要的几个字段(online、last_seen)冗余到 devices 文档里,用 CDC 保持同步。
// 在 devices 文档里加内嵌字段
{
_id: ObjectId(...),
device_id: "dev-9281",
tenant_id: "t-9821",
name: "Living Room Sensor",
current_status: { // 新增内嵌字段
online: true,
last_seen: ISODate("2026-04-15T10:23:01Z"),
updated_at: ISODate("2026-04-15T10:23:01Z")
}
}
// 改造后的接口,完全不需要 lookup
db.devices.find(
{ tenant_id: "t-9821" },
{ device_id: 1, name: 1, "current_status.online": 1, "current_status.last_seen": 1 }
);
同步用 MongoDB Change Stream:
// 监听 device_status 变化,实时同步到 devices.current_status
const changeStream = db.collection("device_status").watch([
{ $match: { operationType: { $in: ["insert", "update", "replace"] } } }
]);
changeStream.on("change", async (change) => {
const { device_id, online, last_seen } = change.fullDocument;
await db.collection("devices").updateOne(
{ device_id },
{ $set: { current_status: { online, last_seen, updated_at: new Date() } } }
);
});
性能验证:18k QPS 下 P99 = 75ms,完全恢复正常。代价是写入路径多一次 update(由 Change Stream consumer 异步执行,业务无感),数据有 <50ms 延迟(对设备状态展示完全可接受)。
修法 2:重新设计分片 key
另一种思路是改 device_status 的分片 key,让 tenant_id 成为复合分片 key 的一部分:
// 改为复合分片 key:{ tenant_id: 1, device_id: "hashed" }
// 这样按 tenant_id 查询时可以路由到特定分片范围
sh.shardCollection("iot.device_status", {
tenant_id: 1,
device_id: "hashed"
});
// 改造后的 lookup 可以加上分片 key 提示
db.devices.aggregate([
{ $match: { tenant_id: "t-9821" } },
{ $lookup: {
from: "device_status",
let: { dev_id: "$device_id" },
pipeline: [
{ $match: { $expr: { $and: [
{ $eq: ["$tenant_id", "t-9821"] }, // 关键:把 tenant_id 加进去,让 mongos 能路由
{ $eq: ["$device_id", "$$dev_id"] }
] } } }
],
as: "status"
}}
]);
这个方案理论上可行,但代价是重新分片现有 4 亿数据,迁移时间预估 18 小时,且需要停业务窗口。我们权衡后选了修法 1。
修法 3:应用层 join
最朴素的方案是放弃 $lookup,在应用层手动 join:
// 应用层 join:两次查询然后合并
async function fetchDevicesWithStatus(tenantId) {
// 1. 取设备列表(走 devices 分片 key,精确路由)
const devices = await db.collection("devices")
.find({ tenant_id: tenantId })
.project({ device_id: 1, name: 1 })
.toArray();
// 2. 批量取 status(用 $in,mongos 会合并去重)
const deviceIds = devices.map(d => d.device_id);
const statuses = await db.collection("device_status")
.find({ device_id: { $in: deviceIds } })
.toArray();
// 3. 内存里合并
const statusMap = new Map(statuses.map(s => [s.device_id, s]));
return devices.map(d => ({
...d,
status: statusMap.get(d.device_id)
}));
}
性能比 $lookup 好很多——单次 $in 查询 mongos 会优化成"按分片分组的并发查询",1200 个 device_id 分散到 4 个分片,每个分片处理 300 条,并发完成。实测 P99 = 180ms,虽然不如反规范化的 75ms,但实现简单,适合不愿意维护 Change Stream 的场景。
修法 4:用 view 提前 join 好
如果业务侧能接受"准实时"(分钟级延迟),可以用 $merge 定时把两表 join 后的结果落地到第三个集合:
// 每分钟跑一次,把 join 结果落地
// MongoDB scheduled job 或外部 cron
db.devices.aggregate([
{ $lookup: {
from: "device_status",
localField: "device_id",
foreignField: "device_id",
as: "status"
}},
{ $unwind: "$status" },
{ $project: {
device_id: 1, tenant_id: 1, name: 1,
"status.online": 1, "status.last_seen": 1
}},
{ $merge: {
into: "devices_with_status", // 落地集合
on: "device_id",
whenMatched: "replace",
whenNotMatched: "insert"
}}
]);
业务读 devices_with_status 这个第三方集合,完全是单集合查询,性能稳定。代价是数据滞后 1 分钟,加上 $merge 本身的资源消耗(我们测过 4 亿数据 $merge 一次 23 分钟,所以不能一分钟一次,只能每小时一次)。这个方案适合"看板类业务"——展示数据稍滞后可以接受。
性能基准:四种方案的对比
| 方案 | P99 (18k QPS) | 数据延迟 | 实现复杂度 | 额外存储 |
|---|---|---|---|---|
| 原始 $lookup | 7400 ms | 0 | 低 | 0 |
| 反规范化 + Change Stream | 75 ms | ~50 ms | 中 | + 80 GB |
| 重设计分片 key + $lookup | 180 ms | 0 | 高(需迁移) | 0 |
| 应用层 $in 批量 join | 180 ms | 0 | 低 | 0 |
| $merge 物化视图 | 40 ms | 10-60 分钟 | 中 | + 200 GB |
决策树:MongoDB 分片下 join 该怎么选
我们立的 10 条 MongoDB 分片设计纪律
- 任何 $lookup 必须 explain:看 executionMode 是否是 scatter-gather。
- $lookup 跨分片集合时,localField 必须是 from 集合的分片 key 值;否则等于自杀。
- 设计分片 key 时考虑 join 路径:常一起查询的两个集合,分片 key 应该有"共同列"。
- 反规范化优先于跨分片 join:MongoDB 设计哲学就是"为查询模式建模"。
- Change Stream 是反规范化的标配:任何冗余字段必须有同步机制,别让人脑同步。
- $lookup 在 hot path 上禁用 unsharded → sharded 的 join:这种 join 在 6.0 以上才支持,且性能差。
- $out / $merge 物化视图必须有重试机制:中途失败可能留下不一致状态。
- 所有 aggregate pipeline 在 PR 阶段必须 explain:CI 加自动 explain 校验。
- 分片集合迁移有窗口,大表迁移必须演练:不要在大促窗口前一周改分片 key。
- 批量查询用 $in,不要循环单查:N+1 问题在 mongos 上一样会爆。
引申一:为什么 PostgreSQL 经验在 MongoDB 上不能照搬
这次故障的根本原因是我们用 PostgreSQL 思维设计 MongoDB。两者哲学差异巨大:
| 方面 | PostgreSQL | MongoDB |
|---|---|---|
| 设计原则 | 第三范式,join 自由组合 | 为查询模式建模,内嵌优先 |
| join 性能 | 优化器很强,跨表 join 良好 | $lookup 是补丁,不是核心 |
| 分布式 | 单机为主,Citus/CockroachDB 才分布 | 原生分片,但分片设计深度影响性能 |
| 事务 | 核心特性 | 4.0 才支持,且跨分片代价高 |
| "正确"的写法 | 规范化 | 反规范化 |
关键认知:MongoDB 不是"无 schema 的关系数据库",它是一个用文档建模业务实体的工具。如果你的数据天然就是层次结构(订单含订单项、设备含传感器读数、文章含评论),内嵌就是最佳实践;如果你硬把它拆成多个 collection 再 join,本质是在跟数据库哲学对着干。这也是"不要用一种数据库工具的思路去用另一种"的经典案例。
引申二:为什么灰度 10% 看不出问题
事故后大家最困惑的是:灰度 10% 时为什么 P99 没有抖动?复盘原因有三:
- 10% 流量下,scatter-gather 之间还有空闲时间:4 个分片的并发查询能力没饱和,串行 4800 次小查询大约 1 秒就能跑完,不至于触发告警阈值;
- cache 起作用:10% 流量下,常访问的 device_status 集中在 WiredTiger cache 里,scatter-gather 的每次远程查询几乎都命中 cache,延迟稳定在 < 1ms;
- mongos 队列还没堆:18k QPS 才把 mongos 的请求队列堆起来,1.8k QPS 排队几乎为 0。
这就是"非线性问题"的典型表现——10% 流量不代表 10% 影响,而是可能 1% 影响,但到 100% 流量时是 100 倍影响。我们后来把灰度规约改成:所有涉及 $lookup / aggregate 的接口必须做 100% 流量影子测试(不真返回给用户,但实际执行),才能看到饱和效应。
引申三:Atlas 监控盲区
事故另一个反思是 Atlas 仪表盘的盲区。Atlas 默认展示的指标(CPU、网络、磁盘 IO、连接数)对这种"分布式查询调度问题"是看不出的——它们都是节点级指标,而我们的问题在 mongos 路由层和分片间通信层。事后我们加了几个自定义指标:
// 用 currentOp 抓 mongos 上的长查询
db.currentOp({
$and: [
{ "active": true },
{ "secs_running": { $gt: 1 } },
{ "op": "command" }
]
})
// 解析输出,提取 scatter-gather 标记
// 关键字段:planSummary、shards 列表、msg 中的 "scatter"
// 暴露成 Prometheus metric
mongo_scatter_gather_active_total{cluster="iot"} 47
mongo_scatter_gather_slow_seconds{cluster="iot",percentile="p99"} 6.8
这些指标加上之后,任何新接口上线立刻能看到是否触发了 scatter-gather,不用等到 P99 飙了才发现。这条经验泛化:每个分布式系统都有"框架自带监控看不到的盲区",要在自己的业务里识别这些盲区并补足。Kubernetes 是 Pod 间通信、Kafka 是 consumer rebalance、Redis 是 client-side latency,MongoDB 就是 mongos 路由模式。
引申四:Change Stream 也有自己的坑
修法 1 里我们用 Change Stream 做反规范化同步,落地时也踩了几个坑,顺便分享:
- Change Stream 必须配 resume token 持久化:消费者挂了重启,不持久化 token 就会跳过窗口期内的变更;
- 消费者必须幂等:Change Stream 在某些边界条件(如 primary 切换)会重复推送,Mongo 5.0+ 提供了
fullDocumentBeforeChange但开销大; - 不能在 Change Stream consumer 里做重 IO:consumer 慢会让 oplog 涨,严重时拖垮主节点;
- oplog size 必须足够容纳 consumer 的 lag:Atlas 默认 oplog 是磁盘 5%,大集群里要手动调大到 20-50GB;
- Change Stream 的 update event 默认只带变更字段:如果你下游需要全文档,要加
fullDocument: 'updateLookup',但这会增加额外读 IO。
引申五:MongoDB 7.0 / 8.0 的新特性能救场吗
事故发生在 6.0 集群上,我们也评估了升级到 7.0/8.0 能否避免这类问题:
- MongoDB 7.0:引入了
$lookup的 Streaming join,跨分片性能改善 30-50%,但仍不如反规范化;新增了sharded $lookup的部分 push-down 优化; - MongoDB 8.0:进一步优化
$lookup,加入了 cost-based optimizer 早期版本,会主动避免明显的 scatter-gather;但优化器有时仍会判错; - 所有版本都解决不了"本质就该反规范化"的场景:升版本能让你的写法"变得不那么慢",但永远比不上"为查询模式设计的数据模型"。
结论:升级 MongoDB 是渐进的改善,不是设计错误的免罪牌。设计错了,版本升再高都救不回来。
引申六:为什么 IoT 场景天然就该用 time-series collection
事故后我们做了更大的反思——iot-platform 这种业务,设备状态变化本质上是时序事件流,我们却用普通文档集合存储,本身就是浪费。MongoDB 5.0 起引入了 time-series collection,专门为这种场景设计:
// 创建时序集合
db.createCollection("device_telemetry", {
timeseries: {
timeField: "ts",
metaField: "device_id",
granularity: "seconds"
},
expireAfterSeconds: 7776000 // 90 天自动过期
});
// 写入设备数据
db.device_telemetry.insertOne({
ts: new Date(),
device_id: "dev-9281",
temperature: 23.5,
humidity: 60,
online: true
});
// 查询某设备最近 1 小时数据 (内部走列存优化)
db.device_telemetry.find({
device_id: "dev-9281",
ts: { $gte: new Date(Date.now() - 3600000) }
});
time-series collection 内部用列存压缩(类似 ClickHouse 的 bucketing),存储成本能比普通集合少 50-70%,查询时间范围数据也快很多。如果一开始就用时序集合 + 一个 device 主表(只存稳定属性),根本不会有 $lookup 跨分片的问题——因为没有那张同样大的 device_status 表去 join。选错存储模型的代价,远比 $lookup 写错大。
引申七:shard key 选错后 Atlas Online Archive 怎么救场
我们的 device_status 集合 400GB,如果真要改 shard key,数据迁移要 18 小时。但 Atlas Online Archive 提供了一个曲线救国的方案:把冷数据自动归档到 S3,主集群只保留近期热数据:
// 配置 Atlas Online Archive 策略
{
"collection": "iot.device_status",
"criteria": {
"type": "DATE",
"dateField": "updated_at",
"expireAfterDays": 30 // 30 天前的数据归档到 S3
},
"archiveAfterDays": 30
}
// 查询时 Atlas 自动合并主集群和 S3 数据
// 业务代码不需要任何改动
db.device_status.find({ device_id: "dev-9281" });
// 内部: 主集群查询 + S3 Federated Query 合并
归档后主集群从 400GB 降到 60GB,scatter-gather 的代价也跟着降下来(每个分片需要扫描的数据量小了 6 倍)。这不是治本但能给重构争取时间窗口。大表治理的优先级:能归档先归档,能拆表再拆表,最后才考虑改 shard key——最贵的方案永远是数据迁移。
引申八:为什么我们没用 GraphQL DataLoader 模式
有人会问:既然问题是"对每条 device 都查一次 status",为什么不在应用层用 DataLoader 那种"批量 + 去重"模式?其实修法 3 的 $in 批量查询本质上就是 DataLoader 的思路。我们没用完整 DataLoader 框架是因为:
- DataLoader 主要解决 N+1 + 同请求内去重:我们的接口是单次查询返回 1200 条,不存在同请求内重复 device_id,所以去重价值不大;
- DataLoader 的优势在嵌套层级深的场景:典型 REST 的 over/under-fetch。">GraphQL 树形查询(user → orders → items → product),每层都批量。我们这是单层 join,直接
$in就够; - 引入 DataLoader 增加抽象层成本:小团队为了 1 个接口加一套框架不划算,业务理解成本会上升。
但如果你的业务是 GraphQL 后端 + MongoDB,DataLoader + $in 是标配。任何 resolver 里直接调 db.collection.findOne 都要警惕 N+1,这跟 ORM 时代的教训完全一样。
引申九:scatter-gather 不只 $lookup 才有,这些场景同样中招
事故后我们扫了整个代码库,发现 scatter-gather 风险远不止 $lookup 一处。MongoDB 分片集群里还有这些操作天然就是 scatter-gather:
| 操作 | 触发 scatter-gather 的条件 | 影响 |
|---|---|---|
| find() 不带分片 key | 查询条件里没有分片 key | 每个分片都要扫描 |
| count() 不带分片 key | 同上 | 所有分片返回部分计数后聚合 |
| aggregate $group 跨分片 | group key 不是分片 key | 每分片局部聚合 + mongos 全局合并 |
| distinct() | 字段不是分片 key | 全分片扫描去重 |
| $facet 多维聚合 | 任一子 pipeline 跨分片 | 所有子 pipeline 都 scatter |
| sort 无索引 | 排序字段无分片索引 | 每分片排序 + mongos merge-sort |
| $graphLookup | 递归 join 任一层跨分片 | 比 $lookup 更糟,递归 scatter |
我们在 PR 模板里加了一个 checklist:任何新查询接口必须列出"用到的分片 key",如果列不出来,默认拒 merge。这个机制粗暴但有效——它强迫开发者在写代码前先想"我这个查询会路由到几个分片",而不是事后被 P99 教育。
引申十:从这次事故学到的"故障复盘开会姿势"
除了技术层面的复盘,这次事故也让我们重新审视了团队复盘流程。我们之前的复盘会经常陷入"找人背锅"的氛围,这次我们刻意避开这条路,采用的姿势是:
- 先讲时间线,不讲谁做的:让所有人对"事实"有共识,再聊"决策";
- 把所有"我们试过但失败的修法"也写进文档:加大连接池失败、扩容 M60 失败,都列出来。下次有人想这么干能少走弯路;
- 追问"为什么 code review 没拦下":这不是问 reviewer 失职,是问流程哪里漏了。我们最终的答案是:reviewer 没在分片集群里跑过 explain;
- 把规矩写进自动化,不靠人记:CI 加 explain 校验、PR 模板加分片 key 列表,这些都比"我们以后注意"管用 100 倍;
- 事故故事讲给新人听:每个新人入职第一周必须听一遍这种故事,理解为什么 MongoDB 哲学和 PG 不同。这比读文档高效得多。
这套姿势让我们的复盘会从"批斗"变成"补漏",团队心态健康了很多,后续 6 个月生产事故数量下降了 40%。技术债是债,流程债更是债——而且利息更高。
引申十一:容量规划阶段就该跑的"分片压测"
这次事故让我们补上了一个之前没有的环节——新接口上线前在生产规模的影子集群里跑"分片压测"。我们的影子集群只有生产的 1/4 数据量,但保留了完整的分片结构。任何新接口必须在影子集群上跑出 explain plan 和 10 分钟稳定负载,通过后才能上灰度:
# 影子集群压测脚本(自动化 CI 步骤)
mongosh "mongodb+srv://shadow-cluster..." --eval '
const result = db.devices.aggregate([...]).explain("executionStats");
if (result.stages.some(s => s["$lookup"] && s["$lookup"].executionStats.executionMode === "scatter-gather")) {
print("FAIL: scatter-gather detected");
quit(1);
}
print("PASS");
'
# 配合 k6 跑 10 分钟负载
k6 run --vus 200 --duration 10m shadow-load.js \
--threshold 'p99<200' \
--threshold 'http_req_failed<0.01'
这个机制让我们抓到了后续 3 个潜在的 scatter-gather 接口,都在上线前修掉。影子集群的成本(我们花 $1200/月)远低于一次生产事故——这次 6 小时故障,光赔偿 SLA + 工程师加班复盘,折算下来 $40000 上下。多花 $14400/年买保险,这账怎么算都划算。
引申十二:开发者心智的"分布式回流"
这次事故最让我印象深刻的是一位资深 PG 开发者的反思:"我以为 $lookup 就是 MongoDB 版的 JOIN,我错了"。这种心智迁移的盲区在跨技术栈迁移时极其常见,我列几个我见过的同类错误:
- 把 Redis 当 SQL 用:用 SCAN 模糊匹配大量 key,实质 O(N) 扫整库;
- 把 Elasticsearch 当 SQL 用:复杂 aggregation 嵌套 5 层,内存全炸;
- 把 Kafka 当 RabbitMQ 用:用单分区追求顺序,丢了吞吐;
- 把 K8s 当 systemd 用:Pod 里跑 sidecar 当 daemon,生命周期管理一团糟;
- 把 GraphQL 当 REST 用:每个字段 resolver 单独查库,N+1 爆炸。
每一种"误用"背后都是开发者把旧工具的思维模式硬套到新工具上。真正掌握一个工具的标志,不是会用它的 API,而是知道它"不适合"做什么。这条经验我会反复跟新人说。
引申十三:为什么我们最终没全栈切到 PostgreSQL
事故后内部讨论过激进方案:既然 MongoDB 的 join 这么难用,干脆全切回 PostgreSQL 用 JSONB 列存设备属性算了。我们花了一周做技术评估,最终决定不切,理由如下:
- 数据量 + 写入吞吐 PG 扛不住:2.8 亿设备 + 每秒 2k 写入 + 90 天保留,PG 单实例顶到天花板,要走 Citus 或 CockroachDB,迁移代价比 MongoDB 调优还高;
- MongoDB 的水平扩展能力依然有价值:加分片就能加容量,这个能力 PG 系生态没有同等优雅的方案;
- 反规范化方案已经把痛点解决了:P99 75ms 完全达标,没必要为"哲学纯洁"付迁移代价;
- 团队 MongoDB 沉淀已有 3 年:运维剧本、监控告警、备份策略都成熟,推翻重来等于把成熟度归零。
这个决策也成了我们后续技术选型的范式:"工具用错"和"工具不适合"是两回事,前者改用法,后者才换工具。换工具是核武器,要慎用——一次不当的技术大迁徙,可能让团队损失 6-12 个月的产出。
总结
这次故障的表层是一个 $lookup 写法问题,深层是"用关系型思维设计文档数据库"这个老问题。MongoDB 分片集群里的跨分片 $lookup 会触发 scatter-gather,复杂度从 O(N) 退化成 O(N × shards),在生产负载下立刻崩溃。
修复的核心思路:反规范化优先,Change Stream 同步,应用层 $in 兜底。比修复更重要的是认知——MongoDB 的设计哲学就是"为查询模式建模",任何需要 $lookup 的场景都值得先反问"我的数据模型是不是有问题"。一行 $lookup 的代价可能是 7 秒 P99,这是关系型数据库里完全无法想象的成本。选对数据库容易,用对数据库难——前者只需要看 benchmark,后者需要重新校准你脑子里的"正确架构图"。下次你在 MongoDB 上写 $lookup 之前,先问自己:这两个字段为什么不在同一个文档里?如果答得不清楚,reschema 的成本永远比生产事故便宜。
—— 别看了 · 2026