ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MongoDB实时分析系统实战:变更流+聚合管道+物化视图

MongoDB实时分析系统实战:变更流+聚合管道+物化视图

既然聊到 MongoDB 实时分析系统,我得先泼一盆冷水:把“实时”二字想得越简单,后面踩的坑就越深。很多人一听实时分析,第一反应就是上 Kafka、上 Flink,好像不堆一堆流处理框架就不配叫实时。但如果你手里的业务规模还没到那个量级,MongoDB 本身就足够扛起一套相当能打的实时分析链路,关键看你怎么设计。

这篇文章不聊虚的,直接基于 MongoDB 官方特性,拆解一套基于变更流、聚合管道、物化视图和分片设计组合出来的实时分析系统。这套方案适合把 MongoDB 当作主要业务数据库、同时需要秒级到分钟级分析反馈的团队,也适合那些想在不引入额外重型中间件的前提下,把 MongoDB 用得更透的开发者。下面内容我尽量按实际动手的顺序写,从架构取舍到具体配置到踩坑经验,一次性说清。

1. 实时分析系统的整体设计思路

1.1 先搞清楚你说的“实时”到底是多实时

设计之前,一定要和业务方对齐实时性的口径。是 500 毫秒?3 秒?还是 30 秒?这个数字决定了整个技术选型的方向。我之前遇到过一个项目,业务方口口声声说“要实时大屏”,结果仔细一问,数据延迟 1 分钟都能接受。这种需求如果用毫秒级方案去做,纯粹是拿钱和复杂度打水漂。

实时分析的延迟目标大致可以分三档:

  • 毫秒级:通常用于在线推荐、风控决策,要求事件发生后立即影响查询结果。
  • 秒级:适合监控大屏、订单看板、用户行为漏斗,允许几秒内看到最新数据。
  • 分钟级:适合报表统计、趋势分析,数据落库后到分析结果可见有 1-5 分钟延迟也能接受。

MongoDB 的实时分析方案覆盖的是“秒级到分钟级”这一档,绝大多数业务实时分析需求其实都落在这个区间。我的建议是,先把这个数字写进需求文档,然后再开始设计,不然最后一定会在 Etl 链路和存储选型上扯皮。

1.2 为什么选择 MongoDB 作为实时分析底座

现在市面上分析型数据库一大把,ClickHouse、Doris、Elasticsearch 各有各的长处。但有一个场景他们都不如 MongoDB 用得顺手:业务数据本身就在 MongoDB 里,实时分析想要的是“从业务库到分析库”的极短路径,甚至干脆复用同一个库。

MongoDB 作为实时分析底座有几个天然优势:

  • 文档模型灵活,分析维度变化时不需要改表结构,加字段就行。
  • 变更流(Change Streams)原生支持数据变更监听,相当于内置了一套 CDC 能力。
  • 聚合管道的表达能力很强,group、lookup、unwind、project 组合起来能处理很多分析需求。
  • 如果只是做预聚合统计,物化视图($merge 输出集合)可以做到秒级更新。
  • 分片集群下,聚合管道能下推到各个分片并行执行,规模化后也能撑住。

最大的误区是一上来就要引入异构系统做数据同步。结果同步链路没搞好,业务库和分析库数据不一致,最终背锅的还是自己。先问问自己:分析场景的 QPS 和数据集量级,MongoDB 是否真的扛不住?大部分时候答案是扛得住。

1.3 实时分析系统的整体架构选型

基于 MongoDB 设计实时分析系统,常见的架构有三种,我分别说明适用场景:

第一种,轻量直接查询型。业务数据写入 MongoDB,分析端直接对着业务集合跑聚合查询。适合数据量几亿以内、查询超时容忍度高(2-5 秒)的场景。这种方案最简单,但并发上来以后很容易把业务库的 CPU 打爆。

第二种,变更流 + 预聚合物化视图。通过 Change Streams 监听业务集合变化,把增量数据写入或合并到专门的统计集合中。查询端只读统计集合,不再碰业务集合。这种方案能做到 1-5 秒的数据延迟,适合绝大多数实时看板、实时报表场景。

第三种,变更流 + 异构存储同步。Change Streams 被消费后写入消息队列,再写入 ClickHouse 或 Elasticsearch。适合超大级数据量或复杂分析查询(全文检索、超多维交叉分析)场景。这个方案架构最重,但查询能力最强。

我强烈建议:如果不是数据量大到实在不行,优先选择第二种方案。它把实时分析和 OLTP 业务解耦,但又没有引入额外的技术栈。MongoDB 的物化视图虽然不是传统数据库意义上的持久化视图,但结合 $merge 的增量合并能力,完全够用。

2. 核心组件拆解:变更流、聚合管道与物化视图

2.1 变更流:MongoDB 实时分析的“心跳”

Change Streams 是 MongoDB 从 3.6 版本开始提供的功能,本质上是通过 oplog 实现的一套事件流接口。它能够实时推送集合、库、甚至整个集群的变更事件,包括 insert、update、replace、delete 等操作。使用方式类似消息队列的消费者模式,开发者可以用游标持续监听。

一个典型的变更流事件长这样(以 update 为例):

{ "_id": { "_data": "826..." }, "operationType": "update", "clusterTime": { "$timestamp": "..." }, "fullDocument": { "orderId": "10086", "status": "paid", "amount": 299 }, "ns": { "db": "shop", "coll": "orders" }, "documentKey": { "_id": ObjectId("...") } }

有几个关键字段要注意:

  • operationType:区分操作类型,update 事件默认不带全文档,需要配置 fullDocument: 'updateLookup' 才能拿到最新文档。
  • clusterTime:事件在集群中的逻辑时间,可以用来做幂等判断。
  • documentKey:被修改文档的主键,增量合并时靠它定位目标记录。

消费变更流时,必须自己维护一个 resumeToken(就是 _id._data 字段)。MongoDB 支持从指定 resumeToken 之后继续消费,这样系统宕机重启后能够从上次断点接着跑,不会丢数据。resumeToken 可以存在 MongoDB 自己的集合里,也可以存在 Redis 里,看你们习惯。

这里有一个容易忽略的点:变更流是基于 oplog 实现的,而 oplog 有大小上限(默认通常是磁盘的 5%)。如果业务写入量很大,而消费者消费不及时,oplog 覆盖了旧事件,那么 resumeToken 就会失效,变更流会抛异常。所以消费端必须保证足够的处理速度,并且要根据写入量合理设置 oplog 大小。

建议设计时将每个变更事件的消费、处理逻辑做成可重入的。比如基于事件中的 _id、clusterTime 做唯一键,确保同一条事件重复消费不会重复计算。

2.2 聚合管道:做分析的“瑞士军刀”

MongoDB 聚合管道支持的操作符非常丰富,实时分析最常用的几个组合是:

  • $match:过滤数据范围,必须在管道最前面,能显著减少后续处理的数据量。
  • $project:裁剪字段,减少内存和网络开销。
  • $group:按维度分组,做 sum、avg、min、max、count。
  • $unwind:数组拆解,处理明细展开。
  • $lookup:关联其他集合,相当于 SQL 的 left join。但注意 $lookup 很吃内存,实时分析场景尽量不要在分析管道里用。
  • $merge:把计算结果写回目标集合,这是物化视图的核心步骤。
  • $set:新增或修改字段。

举个例子,做订单维度的实时销售额统计,每过来一条订单变更事件,聚合管道可以做这样的事:

db.orders.aggregate([ { $match: { operationType: { $in: ['insert', 'update'] } } }, { $project: { orderId: 1, amount: 1, status: 1, channel: 1, eventTime: '$clusterTime' } }, { $group: { _id: { channel: '$channel', date: { $dateToString: { format: '%Y-%m-%d', date: '$eventTime' } } }, totalAmount: { $sum: '$amount' }, orderCount: { $sum: 1 } } }, { $merge: { into: 'daily_sales_stats', on: '_id', whenMatched: 'replace', whenNotMatched: 'insert' } } ])

这段管道跑完后,daily_sales_stats 集合里就是按渠道和日期预聚合的销售统计。实时增量事件过来时,重复执行这条管道,就能把最新结果更新进去。读者如果理解了这个逻辑,就会发现物化视图的“更新”不是传统数据库的增量更新,而是“针对变更事件重复计算一次聚合”,因为 MongoDB 聚合管道本身操作的是“当前文档状态快照”,并非原始事件流。

2.3 物化视图:查询层与业务层的“缓冲垫”

MongoDB 官方文档里提到了“物化视图”的概念。它不是一个独立的存储引擎特性,而是通过聚合管道的 $merge 阶段,把计算结果输出到另一个集合中。你可以把它理解成一张预计算好的统计表,业务查询直接查这张表,完全不需要关心原始数据。

物化视图的核心价值有三个:

  • 查询性能大幅提升:统计集合的数据量远小于原始集合,且结构为最终分析结果。
  • 业务库负载降低:实时分析不再反复扫原始集合。
  • 语义更稳定:把分析口径固化到物化视图中,业务查询端只需要调用结果集,避免不同人写不同聚合逻辑。

建物化视图的时机也很重要。一般有全量初始化和增量更新两种组合:第一次部署时,用历史数据跑一次全量聚合,写入 stats 集合;之后持续用变更流触发增量 $merge。这样既能保证历史数据正确,又能让统计结果实时滚动。

需要注意 $merge 的 whenMatched 行为。如果统计维度发生变化,比如某个维度新增了一个字段,当 matched 时用 replace 会覆盖整条文档;如果只更新部分字段,可以用 pipeline 方式做局部更新。我建议大多数场景无脑用 replace,因为统计文档结构简单,覆盖更干净,不容易残留脏字段。

3. 实操过程:一步步搭出实时分析链路

3.1 前置准备与环境选型

假设我们的业务场景是一家电商平台,需要实时统计全平台订单销售额、各渠道订单量、支付成功金额等核心指标。业务数据库里有一张 orders 集合,字段大概长这样:

{ "_id": ObjectId("..."), "orderId": "202501010001", "userId": "u_1001", "channel": "app", "status": "paid", "amount": 199.9, "items": [...], "createdAt": ISODate("...") }

我们的目标:把订单状态变更为“paid”的事件实时捕获,并把销售数据按渠道、小时维度预聚合,提供给大屏查询端使用。

环境上,我建议起码用 MongoDB 4.4 以上版本,因为更早版本在 $merge 和 Change Streams 的支持上有不少缺陷。如果是集群环境,需要开启副本集(Change Streams 只在副本集和分片集群上支持)。如果已经用了独立单机版,需要先改造成副本集架构。

连接参数上,MongoDB 驱动需要设置 readPreference 为 secondaryPreferred。变更流监听可以放在从节点上读,减少主库压力。不过需要注意,$merge 写入目标集合仍然需要主节点完成,所以查询端连从节点、写统计结果依然走主节点,这个不要搞混。

3.2 设计并创建统计集合的 Schema

统计集合的 Schema 设计直接决定了查询端的开发体验。针对订单分析的例子,我设计了一个 daily_order_stats 集合,文档结构如下:

{ "_id": { "channel": "app", "date": "2025-01-08", "hour": 15 }, "totalAmount": 35672.5, "paidAmount": 34210.0, "orderCount": 328, "paidCount": 301, "avgAmount": 108.75, "updateTime": ISODate("...") }

_id 就是分组维度键,这样天然保证唯一性。查询端只需要按 _id 做点查或范围查,速度极快。如果没有预聚合,直接扫 orders 集合,几千万条数据,一个多维分组没个十来秒下不来。这就是物化视图的威力。

统计字段的设计有几个原则:

  • 所有指标都是数值类型,避免在查询端再做类型转换。
  • 只保留业务上需要的指标,不要贪多。每多一个指标,聚合管道的计算量就多一分。
  • 增加 updateTime 字段,方便排查数据更新延迟。

一个常见问题是:要不要把 userId、商品 ID 这些明细字段放进去?我的建议是不要。预聚合集合的设计目标是“分析结果”,而不是“明细数据”。真要查明细,直接走原始 orders 集合。物化视图永远面向维度汇总,不面向明细查询,这个边界要清晰。

3.3 编写变更流监听服务

监听服务是整条实时链路的核心,可以单独写一个后台服务,也可以依托 MongoDB 触发器(Realm 函数)实现。我个人建议用后台服务方式,原因是很直观、好排查、容易控制消费速率。

用 Node.js 举例,监听 orders 集合的变更流,伪代码如下:

const { MongoClient } = require('mongodb'); const uri = 'mongodb://localhost:27017,localhost:27018,localhost:27019/?replicaSet=rs0'; const client = new MongoClient(uri); async function watchOrders() { await client.connect(); const db = client.db('shop'); const collection = db.collection('orders'); const pipeline = [ { $match: { operationType: { $in: ['insert', 'update'] }, 'fullDocument.status': 'paid' } } ]; const changeStream = collection.watch(pipeline, { fullDocument: 'updateLookup' }); changeStream.on('change', async (next) => { // 在这里处理业务逻辑 await processOrderEvent(next); }); changeStream.on('error', (err) => { console.error('change stream error', err); // 记录断点 resumeToken,尝试重启 }); } async function processOrderEvent(event) { const doc = event.fullDocument; const channel = doc.channel; const date = new Date(event.clusterTime); const key = { channel, date: formatDate(date), hour: date.getHours() }; // 执行聚合更新 await updateStats(key, doc); }

注意 $match 管道中的 fullDocument.status:这里依赖了 updateLookup 提供的完整文档。如果是 insert 事件,fullDocument 直接是插入后的文档,没问题。如果是 update 事件,必须确保更新操作包含 status 字段,否则 fullDocument 里可能取不到最新的 status 值。这个问题在真实业务中经常遇到,尤其是某些更新只改了 userId 没动 status,你会发现 fullDocument.status 根本没变化。

一个更稳妥的写法是:监听所有 update 事件,然后通过 $match 判断状态是否变化,或者直接在应用层判断。总之,不要在变更流管道里对 fullDocument 字段做过高预期,MongoDB 文档里也明确写了这一点。

3.4 用聚合管道实现增量统计合并

拿到变更事件后,最关键的就是把这条订单数据“合并”到统计集合中。这里有一个设计难点:如果是订单金额从 100 改成 200,该怎么更新统计值?

如果只是在原有统计值上加 200,那等于多了 100。所以必须考虑状态翻转的问题。

两种常见方案:

方案一,事件重算方案。每次变更事件到来时,不从增量上加,而是按当前状态重新聚合一次该订单所属维度的汇总值,然后用 $merge 覆盖目标文档。这种方案适合维度组合有限、变更频率不高的场景。

方案二,增量差值方案。通过 before/after 状态对比,计算差值,更新目标集合时用 $inc 操作符。这种方案性能更好,但逻辑更复杂,需要维护好每个维度的差值。

以我的经验,初期先用重算方案,因为逻辑简单,不会因为状态翻转产生脏数据。等到实时量真正上来了,再针对热点维度优化成 $inc 方案不迟。

重算方案的聚合管道长这样:

db.orders.aggregate([ { $match: { _id: ObjectId('...'), // 只处理当前订单 status: 'paid' } }, { $group: { _id: { channel: '$channel', date: { $dateToString: { format: '%Y-%m-%d', date: '$createdAt' } }, hour: { $hour: '$createdAt' } }, totalAmount: { $sum: '$amount' }, orderCount: { $sum: 1 } } }, { $merge: { into: 'daily_order_stats', on: '_id', whenMatched: 'replace', whenNotMatched: 'insert' } } ])

但这个方案有问题:如果该渠道、日期、小时维度下已经有多个订单,而这条订单只是其中之一,你只对这单做重算,最终会覆盖掉其他订单的统计值。所以重算不能只针对一单,而应该针对整个维度重新聚合。

正确的重算管道应该这样:

db.orders.aggregate([ { $match: { status: 'paid', channel: 'app', createdAt: { $gte: ISODate('2025-01-08T15:00:00Z'), $lt: ISODate('2025-01-08T16:00:00Z') } } }, { $group: { _id: null, totalAmount: { $sum: '$amount' }, orderCount: { $sum: 1 } } }, { $project: { _id: { channel: 'app', date: '2025-01-08', hour: 15 }, totalAmount: 1, orderCount: 1, updateTime: new Date() } }, { $merge: { into: 'daily_order_stats', on: '_id', whenMatched: 'replace', whenNotMatched: 'insert' } } ])

不过这种重算方式虽然准确,但如果每秒有成千上万个变更事件,每个事件都触发一次该维度重算,会导致大量重复计算。实际中我采用了一种“批量窗口合并”的策略:消费变更事件时,不立即触发聚合计算,而是把它们放入内存缓冲队列,每 2 秒或累计 100 条事件触发一次统一重算。这样聚合次数能减少两个数量级,数据延迟仍在秒级。

3.5 处理“黄金时间”的并发写压力

在实时链路里,很多人担心变更流消费不过来。实际上,只要合理分批或并行,每个分片键对应的变更流是相对隔离的。MongoDB 分片集群中,每个分片都有自己独立的 oplog,所以可以启动多个消费者,每个消费者监听不同的分片,实现天然并行。

但也要注意:变更流默认会开一个 Tailable Cursor,它需要消费者持续保持连接。在容器环境中要配置好优雅停机,确保 resumeToken 保存完后才关闭连接。否则一边发布一边消费,很容易丢一段事件。

另外,如果业务库写入量极大,oplog 的写入压力也会随之增大。建议巡检 oplog 时间窗口,可以用这条命令查看:

rs.printReplicationInfo()

重点关注 configured oplog size 和 oplog first event time 的相隔时长。如果时间窗口小于 1 小时,说明 oplog 偏小,消费端必须做到分钟级消费。否则建议调大 oplog,比如用下面命令修改(需要管理员权限):

db.adminCommand({ replSetResizeOplog: 1, size: 163840 })

单位是 MB,这里设置 160GB。别怕浪费磁盘,oplog 满了后最古老的数据会被覆盖,那才是灾难。

3.6 查询端只读统计分析集合

实时链路搭建完后,查询端就有福了。大屏接口只需要这样一条查询:

db.daily_order_stats.find({ _id: { channel: 'app', date: { $gte: '2025-01-08' } } }).sort({ _id: 1 })

返回结果就是一个小时级的订单指标序列。查询性能完全不用加索引就能扛住,因为集合很小。再加上一个稀疏索引还可以更快:

db.daily_order_stats.createIndex( { '_id.channel': 1, '_id.date': 1, '_id.hour': 1 }, { unique: true } )

这个索引唯一性同时还能帮我们防止并发 merge 时产生重复键。

实时大屏的前端只需要每秒轮询一次这个接口,或者使用 WebSocket 推送。由于聚合结果最多延迟几秒,用户体验上就是实时的。

4. 常见问题与排查技巧实录

4.1 变更流断连与 ResumeToken 失效

这是实时系统跑起来后最常遇到的故障。现象是日志里报 resume token 相关错误,消费者无法继续。

原因几种:

  • 消费者停太久,oplog 已经覆盖了 resumeToken 对应的位置。
  • oplog 大小太小,高峰期写入量大。
  • 容器重启后 resumeToken 没有持久化,消费者又从头监听。

解决办法:resumeToken 一定要持久化到数据库或 Redis。每次拿到事件后,commit 当前 tokens 到持久层。重启时先读 tokens,再启动 watch。如果 token 确实失效了,做一次全量同步重算,然后重置 token。另外,代码里要捕获 InvalidResumeToken 错误,把这个兜底逻辑写进去。

4.2 update 事件里 fullDocument 缺失

之前提过,update 事件如果想拿整行文档,必须在 watch 时传 fullDocument: 'updateLookup'。但即便设置了,也有拿不到的情况:如果 update 操作本身只更新了一个字段,而那个字段不在 collection watch 管道里用到的字段,最终 fullDocument 可能不会返回最新值。这是一个很容易踩的坑。

具体来说,$match 过滤 fullDocument 字段时,MongoDB 会先根据事件本身的字段做匹配,而不是根据 updateLookup 之后的文档做匹配,所以在某些老版本里会出现漏事件的情况。规避的方法是:监听所有 update 事件,然后在应用层根据 documentKey 重新查询文档,再做判断。虽然多了 一次读,但数据准确性有保障。

4.3 聚合重算导致的维度热更新

当你使用“整个维度重算”策略时,可能会遇到一个热点问题:某个渠道某个小时维度的订单特别多,比如晚上 8 点到 10 点的大促时段。每个事件触发一次整个小时的重算,聚合管道要对这个小时内所有订单重新扫描并求和,计算量非常大。

优化方向有三个:

第一,把小时维度拆成分钟维度,统计集合按分钟聚合,热点范围变小。 第二,用 $inc 增量更新替换全量重算,前提是能确保状态最终一致。 第三,引入本地缓存,对同一维度在同一时间窗口内的更新只执行一次。

实际项目中,我在 $inc 增量更新上吃过亏,原因是有时候业务系统会直接修改历史订单数据,导致状态反复横跳,增量值算不准。后来我改成:增量更新为主,每天晚上用离线任务跑一次全量校验修正。这样既保证了实时性,又保住了最终一致性。这才是生产环境该有的态度,不要追求系统绝对精确,而是通过补充机制兜住长尾问题。

4.4 物化视图集合要不要分片

当统计集合数据量本身也很大时,比如需要保留一年以上的秒级明细统计,物化视图集合同样可以使用分片。分片键建议选择 _id 中的日期部分,因为大多数查询都按时间范围走。不过分片会增加运维复杂度,数据量在 100GB 以下时,单副本集就够。

曾经有个同事非要把统计集合做成分片集群,结果查询时跨分片合并,反而比单副本集还慢。所以我劝一句:分片是最后的手段,不是一个“高级”的配置。先用索引和物化视图解决 90% 的问题,剩下的再考虑扩容。

5. 进阶优化:时序集合与实时分析更配

MongoDB 5.0 之后推出了时序集合(Time Series Collections),专门用于存储时间序列数据。虽然时序集合和实时分析不完全是一回事,但如果你的分析对象是设备指标、App 埋点日志、监控指标这种天生带时间属性的数据,时序集合是一个非常大的福音。

时序集合和普通集合的写入和查询方式基本一样,但内部存储做了列式优化和压缩。它支持对时间字段、元数据字段建立索引,聚合查询时性能比普通集合好不少。要注意的是,时序集合不支持某些普通集合操作(比如 update),它设计上就是 append-only 的。如果你的实时分析链路中,业务数据是流水型且不更新,建议直接使用时序集合存储原始事件,然后在上层跑预聚合。

一个典型场景是埋点日志实时分析:

db.createCollection('app_events', { timeseries: { timeField: 'timestamp', metaField: 'metadata', granularity: 'seconds' } })

写入时只需要往这个集合里 insert 文档,不需要每次 update。然后用 $merge 或者 $out 做聚合输出。我在一个监控项目中实测,同样的查询,时序集合比普通集合快了一倍多,磁盘占用还少了 60%。

关于 granularity 的选择:

  • seconds:适合每秒一条甚至更密集的数据。
  • minutes:适合每分钟一条;hours:适合每小时一条。
  • 选高了会损失查询精度,选低了浪费空间,按实际写入频率来。

时序集合不是万能药,但如果你已经有大量时间序列数据,还没用上这个特性,是非常可惜的。

6. 监控、容量规划与运维闭环

实时分析系统跑起来之后,监控是关键。MongoDB 自身提供了一些指标,但不足够。我平时主要盯几个核心指标:

  • oplog 时间窗口,确保消费能跟上。
  • 变更流消费者的消费延迟,即最新事件 clusterTime 与当前时间的差值。
  • 物化视图集合的最近更新时间,写一个监听检查任务,超过阈值告警。
  • 聚合管道的执行时间,用 profile 慢查询日志默认阈值 100ms 打开。

一个非常实用的监控方式:在统计集合的每个文档里维护 updateTime 字段,然后写一个巡检任务,每分钟扫描一次 updateTime 最旧的值,如果超过设定延迟(比如 5 分钟),就自动告警。这样业务看到的数据延迟可量化,出了问题也能定位到具体维度。

容量规划方面,重点估算两部分:oplog 的存储增长速率和统计集合的磁盘占用。可以按日均事件量乘以平均事件大小乘以保留天数来估算。比如每天 1000 万条变更事件,每条事件平均 500 字节,oplog 日志有循环覆盖特性,就不需要按总量算了,但为保证足够容错,要按“至少能保存 2 小时变更量”的倍数来配置。

另外一个很容易被忽略的点:Change Streams 本身对副本集主节点有少许性能开销,因为它需要在 oplog 上读取和发送事件。如果业务库写入压力本来就快饱和了,可以专门挂一个隐藏从节点(hidden secondary),只给实时分析服务消费。这是很标准的读写分离做法。隐藏从节点不参与选举,也不承担业务读流量,专门跑分析任务,效果立竿见影。

我在实际运维中还发现一件有趣的事:很多团队把物化视图的统计集合放在同一个副本集里,认为这样省事。但如果统计集合数据量大、查询高并发,反过来会影响业务集合的性能。建议如果预算和架构允许,把物化视图迁移到独立的 MongoDB 实例或副本集,通过 MongoDB 的精确同步(做一次导入导出)实现数据隔离。实时链路数据最终是几秒级同步,不会影响统计场景体验。

7. 最后分享几个让我少走弯路的经验

这篇文章写到这里,核心的架构和代码都聊透了。最后我再分享几个实操中让我印象深刻的点,算是给后来者的一点提醒。

第一个经验:实时分析链路一定要设计一个“全量对账任务”。不管你的变更流多稳,物化视图逻辑多完美,时间线上总有意外,比如代码发布期间漏消费、oplog 覆盖导致重放失败、某个 update 事件丢失。不要依赖理想化的流处理,每天晚上跑一次离线全量聚合,和实时统计结果做比对,把差值修正回来。成本很低,收益很大。

第二个经验:不要把业务复杂度堆在聚合管道里。很多人想用一条 MongoDB 聚合管道解决所有问题,结果 $lookup 嵌套了几层,性能直接拉垮。聚合管道适合做单集合的维度转换和汇总,如果涉及多集合关联,不如在应用层用代码先做 Join 再写入统计集合。虽然多写几行代码,但排查问题的时候你会感谢自己。

第三个经验:变更流的事件结构在不同驱动版本下有差异。不要在生产代码里写死字段路径,比如 _data 的格式在不同 MongoDB 大版本里可能变化。尽量使用官方驱动封装好的方法获取 resumeToken,不要自己去解析字符串,否则升级 MongoDB 版本时很容易踩雷。

第四个经验:查询端最好设置缓存。即使统计集合查询已经很快,但实时大屏通常是每秒轮询,如果每次请求都打到 MongoDB,难免增加不必要的负担。在查询端加一层 Redis 缓存,把缓存时间设为 1 秒,效果很好。实时性的损失肉眼根本感知不到,但 MongoDB 的负载能降下来一半。

这套方案我前后在不同项目里用过几次,虽然不是那种非常炫酷的大数据架构,但它足够可靠、足够省心,而且完全是围绕 MongoDB 原生的能力来构建的。如果你正在犹豫怎么做实时分析,我建议先在 MongoDB 里跑通这套链路,等真正遇到瓶颈再考虑换架构。大多数场景卡住的根本不是技术选型,而是业务口径和实时性定义没对齐,把这层理顺了,后面的路就好走了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进