MongoDB: Change Streams:实时变更监听

Change Streams 是 MongoDB 的实时变更通知——CDC(Change Data Capture)和事件驱动应用的基础。

1. 你将学到


100%
graph LR
    A[应用 1<br/>插入订单] -->|oplog| DB[(MongoDB<br/>oplog)]
    B[应用 2<br/>更新订单] -->|oplog| DB
    C[应用 3<br/>删除订单] -->|oplog| DB

    DB -->|Change Stream| CS[db.collection.watch]

    CS -->|operationType=insert| N1[邮件通知<br/>WebSocket 推送]
    CS -->|operationType=update| N2[Elasticsearch<br/>同步]
    CS -->|operationType=delete| N3[Redis 缓存<br/>失效]

    style CS fill:#d4edda
    style N1 fill:#cce5ff

2. Change Streams 基础

概念说明:Change Streams 是 MongoDB 3.6+ 提供的实时变更监听 API,基于副本集 oplog 实现。它让应用无需轮询就能实时感知数据变更——是 CDC(Change Data Capture)和事件驱动架构的基础。

OpLog 原理:Oplog(Operation Log)是副本集中记录所有写操作的特殊固定集合(capped collection)。Primary 将每个写操作(insert/update/delete)追加到 oplog,Secondary 通过 tailing oplog 实现数据复制。Change Streams 本质上是对 oplog 的结构化封装——将原始 oplog 条目转换为易读的变更事件,提供过滤、断点续传等高级功能。

100%
graph TB
    subgraph "OpLog 工作原理"
        W1[写入操作] --> OP[(oplog<br/>固定集合)]
        OP --> S1[Secondary 1<br/>tailing oplog]
        OP --> S2[Secondary 2<br/>tailing oplog]
        OP --> CS[Change Stream<br/>结构化事件]
    end

    subgraph "Change Stream vs 轮询"
        P1[轮询: 每秒查询] --> C1[高延迟<br/>高负载<br/>重复查询]
        CS --> C2[实时推送<br/>低负载<br/>无重复]
    end

    style CS fill:#d4edda
    style C2 fill:#d4edda
    style C1 fill:#f8d7da

Change Streams vs 轮询对比

维度 轮询(Polling) Change Streams
延迟 1-60 秒(取决于间隔) < 100ms(实时推送)
数据库负载 高(每次查询扫描索引) 低(被动接收 oplog 事件)
事件完整性 可能漏事件(间隔内多次变更) 不会漏(oplog 有序)
断点续传 需自行实现 内置 resumeToken
资源消耗 持续占用连接 + CPU 长连接,低 CPU

Change Event 结构

JAVASCRIPT
// === 监听集合变更 ===
const changeStream = db.products.watch();

changeStream.on('change', (change) => {
  console.log('Change detected:', change);
  // {
  //   _id: { _data: '...' },           // resumeToken(断点续传用)
  //   operationType: 'insert',          // 操作类型
  //   fullDocument: { _id: ..., sku: ..., title: ..., ... },  // 完整文档
  //   ns: { db: 'shopdb', coll: 'products' },  // 命名空间
  //   documentKey: { _id: ObjectId('...') }    // 文档主键
  // }
});
operationType 含义 fullDocument
insert 新文档插入 ✅ 完整文档
update 文档更新 ❌ 仅变更字段(需 updateLookup)
replace 文档替换 ✅ 完整文档
delete 文档删除 ❌ 无(文档已删除)
drop 集合删除 ❌ 无
rename 集合重命名 ❌ 无
invalidate 流失效(集合被删等) ❌ 无

要点解析

  1. Change Streams 必须运行在副本集或分片集群上(依赖 oplog)
  2. update 事件默认不返回完整文档,需设 fullDocument: 'updateLookup'
  3. _id 字段是 resumeToken,用于断点续传——保存它,重启后从上次位置继续监听

3. 管道过滤

概念说明:Change Streams 支持聚合管道过滤——在 oplog 事件流上应用 $match$project 等阶段,只传递感兴趣的事件给应用。过滤在服务器端执行,减少网络传输和应用层处理开销。

工作原理watch() 接受聚合管道数组作为参数,管道阶段在 MongoDB 服务器端执行。事件先经过管道过滤,只有通过的事件才推送给客户端。支持 $match$project$addFields$replaceRoot 等阶段。

过滤策略

过滤目标 管道阶段 示例
操作类型 $match: {operationType} 仅监听 insert
文档字段 $match: {'fullDocument.field'} 仅特定分类
变更字段 $match: {'updateDescription.updatedFields'} 仅价格变更
组合条件 $match: {$or: [...]} insert 或价格变更
JAVASCRIPT
// === 过滤特定操作 ===
const changeStream = db.products.watch([
  { $match: { operationType: 'insert' } }
]);

// === 过滤特定字段 ===
const changeStream = db.products.watch([
  { $match: { 'fullDocument.category': 'Electronics' } }
]);

// === 过滤价格变化 ===
const changeStream = db.products.watch([
  {
    $match: {
      $or: [
        { operationType: 'insert' },
        { operationType: 'update', 'updateDescription.updatedFields.price': { $exists: true } }
      ]
    }
  }
]);

要点解析

  1. 过滤在服务器端执行,减少无用事件的网络传输
  2. $match 的字段路径使用 oplog 事件结构(如 fullDocument.category),不是原始文档字段
  3. 不支持 $group$limit 等需要全局状态的阶段

4. fullDocument 配置

概念说明fullDocument 选项控制 Change Stream 事件中是否包含完整文档。默认情况下,update 事件仅返回变更的字段(updateDescription),不返回完整文档。设置 fullDocument: 'updateLookup' 后,MongoDB 会额外查询一次当前文档完整内容。

工作原理

updateLookup 的权衡

维度 whenAvailable updateLookup
完整文档 仅 insert/replace 所有操作类型
性能 基准 额外查询开销(+10-20%)
数据新鲜度 变更时刻 查询时刻(可能有微小延迟)
适用场景 仅需知道哪些字段变了 需要完整文档做后续处理
100%
sequenceDiagram
    participant App as 应用
    participant DB as MongoDB
    participant Doc as 文档

    App->>DB: watch([], {fullDocument: 'updateLookup'})
    DB->>DB: oplog 产生 update 事件

    Note over DB: update 事件默认仅含变更字段

    DB->>Doc: 额外查询当前完整文档
    Doc-->>DB: 返回最新文档
    DB-->>App: 推送事件 + fullDocument

    Note over App: 事件中包含完整文档
JAVASCRIPT
// === update 时返回完整文档 ===
const changeStream = db.products.watch([], {
  fullDocument: 'updateLookup'
});

changeStream.on('change', (change) => {
  if (change.operationType === 'update') {
    console.log('Updated doc:', change.fullDocument);
    // 完整文档(默认值变化后)
  }
});

// === 仅返回变更字段 ===
const changeStream = db.products.watch([], {
  fullDocument: 'whenAvailable'  // 默认
});

要点解析

  1. updateLookup 会做一次额外查询,高频更新场景下会增加数据库负载
  2. updateLookup 返回的是查询时刻的最新文档,可能与变更时刻有微小差异(其他并发修改)
  3. delete 事件即使设 updateLookup 也无法返回文档(文档已不存在)

5. mongoose 集成

概念说明:mongoose 6+ 原生支持 Change Streams,通过 Model.watch() 返回变更流。与原生 MongoDB 驱动的 API 完全一致,但直接在 Model 层使用,更符合 mongoose 的开发习惯。

事件驱动架构:Change Streams 是事件驱动架构的核心组件——数据库变更作为事件源,驱动下游的缓存更新、搜索同步、通知推送等副作用。

100%
graph TB
    subgraph "事件源"
        DB[(MongoDB<br/>oplog)]
    end

    subgraph "Change Stream 总线"
        CS[Model.watch<br/>变更流]
    end

    subgraph "事件消费者"
        N1[邮件通知]
        N2[Elasticsearch<br/>搜索同步]
        N3[Redis<br/>缓存失效]
        N4[WebSocket<br/>实时推送]
        N5[审计日志<br/>合规记录]
    end

    DB --> CS
    CS --> N1
    CS --> N2
    CS --> N3
    CS --> N4
    CS --> N5

    style CS fill:#d4edda
JAVASCRIPT
// === mongoose Change Streams(mongoose 6+)===
const Product = mongoose.model('Product', productSchema);

// 监听 Product 集合变更
const changeStream = Product.watch();

changeStream.on('change', (change) => {
  console.log(`${change.operationType}:`, change.fullDocument);
});

// 过滤
const filteredStream = Product.watch([
  { $match: { operationType: { $in: ['insert', 'update'] } } }
]);

要点解析

  1. mongoose 的 watch() 返回的是原生 MongoDB Change Stream,API 完全一致
  2. 监听期间需要保持数据库连接,连接断开 Change Stream 自动关闭
  3. 生产环境建议封装 Change Stream 管理器:自动重连 + resumeToken 持久化

6. 实战场景

概念说明:Change Streams 在生产环境有三大经典场景:实时通知、数据同步(CDC)、缓存失效。每个场景都是事件驱动架构的典型实现。

(1) 实时通知系统

场景:ShopHub 电商需要在新订单创建时,实时发送邮件通知和 WebSocket 推送给商家后台。

JAVASCRIPT
// === 监听新订单,发送通知 ===
const OrderStream = db.orders.watch([
  { $match: { operationType: 'insert' } }
]);

OrderStream.on('change', async (change) => {
  const order = change.fullDocument;

  // 发送邮件
  await sendEmail(order.userId, 'Order confirmation', `Order ${order._id} received`);

  // 推送通知
  await pushNotification(order.userId, {
    title: 'New Order',
    body: `Order total: $${order.total}`
  });

  // WebSocket 实时推送
  io.emit('new_order', order);
});

(2) 数据同步(CDC)

场景:ShopHub 需要将 MongoDB 商品数据实时同步到 Elasticsearch,支持全文搜索。Change Streams 实现零延迟的 CDC 管道。

CDC 架构

100%
graph LR
    A[MongoDB<br/>商品数据] -->|Change Stream| B[CDC Worker<br/>Node.js进程]
    B -->|insert/update| C[Elasticsearch<br/>搜索索引]
    B -->|delete| C
    B -->|变更记录| D[(Redis<br/>resumeToken)]
    D -->|重启恢复| B

    style B fill:#d4edda
JAVASCRIPT
// === 监听 MongoDB 变更,同步到 Elasticsearch ===
const ProductStream = db.products.watch();

ProductStream.on('change', async (change) => {
  switch (change.operationType) {
    case 'insert':
    case 'update':
    case 'replace':
      await elasticsearch.index({
        index: 'products',
        id: change.documentKey._id.toString(),
        body: change.fullDocument
      });
      break;
    case 'delete':
      await elasticsearch.delete({
        index: 'products',
        id: change.documentKey._id.toString()
      });
      break;
  }
});

(3) 缓存失效

场景:ShopHub 使用 Redis 缓存商品详情页,当商品数据变更时自动清除缓存,避免脏数据。

JAVASCRIPT
// === 监听商品变更,清除 Redis 缓存 ===
const ProductStream = db.products.watch();

ProductStream.on('change', async (change) => {
  const productId = change.documentKey._id.toString();
  await redis.del(`product:${productId}`);
  console.log(`Cache cleared for ${productId}`);
});

▶ 示例 1:Change Stream 断点续传

JAVASCRIPT
// Alice 的 TechCorp 系统:Change Stream 断点续传,重启后不丢事件
async function startResumableStream() {
  // 1. 从 Redis 获取上次保存的 resumeToken
  let resumeToken = await redis.get('product_stream_token');
  let options = { fullDocument: 'updateLookup' };

  if (resumeToken) {
    options.resumeAfter = JSON.parse(resumeToken);
    console.log('Resuming from saved token');
  }

  // 2. 启动监听
  const changeStream = db.products.watch([], options);

  changeStream.on('change', async (change) => {
    // 处理变更...
    console.log(`${change.operationType}: ${change.documentKey._id}`);

    // 3. 每次事件后保存 resumeToken
    await redis.set('product_stream_token', JSON.stringify(change._id));
  });

  changeStream.on('error', async (err) => {
    console.error('Stream error:', err.message);
    // 4. 出错后延迟重连
    setTimeout(startResumableStream, 5000);
  });
}

startResumableStream();

输出:

TEXT 📖 仅展示
Resuming from saved token
Stream error:

7. Change Streams 限制

概念说明:Change Streams 依赖 oplog 和副本集,有明确的限制。理解这些限制是设计可靠实时系统的前提。

限制详解

限制 说明 原因 应对策略
必须副本集 单机不支持 依赖 oplog 开发环境用单节点副本集
oplog 大小限制 默认 5% 磁盘空间 oplog 是 capped collection 调大 oplogSize 或监控窗口
不能跨集群 单个集群内 oplog 不跨集群 用 Kafka 连接多集群
不支持 $where 某些操作符 oplog 不记录查询细节 用 fullDocument 字段过滤
事件顺序 单集合有序,跨集合无全局序 oplog 按集合分片 应用层做跨集合排序
内存占用 每个 watch 连接占内存 服务器维护游标状态 限制 watch 连接数

oplog 窗口监控:oplog 是固定大小,旧条目会被覆盖。如果 Change Stream 消费速度慢于 oplog 覆盖速度,resumeToken 将失效,无法断点续传。

100%
graph LR
    A[oplog 写入速度<br/>1000 ops/s] --> B[oplog 容量<br/>5% 磁盘 ≈ 50GB]
    B --> C[oplog 窗口<br/>约 72 小时]
    C --> D{消费速度?}
    D -->|跟上| E[✅ 正常]
    D -->|落后 > 72h| F[❌ resumeToken 失效<br/>需全量重新同步]

    style E fill:#d4edda
    style F fill:#f8d7da
监控项 命令 告警阈值
oplog 窗口 rs.printReplicationInfo() < 24 小时
oplog 使用率 db.oplog.rs.stats() > 80%
Change Stream 连接数 db.currentOp() > 100
事件延迟 应用层监控 > 10 秒

▶ 示例 2:Change Streams 实时订单通知实战

JAVASCRIPT
// 场景:监听新订单,实时发送邮件 + WebSocket 推送 + 同步到 Elasticsearch

// 1. 启动监听(在 Node.js 应用中)
const { MongoClient } = require('mongodb');

async function startOrderListener() {
  const client = new MongoClient('mongodb://localhost:27017/?replicaSet=rs0');
  await client.connect();
  const orders = client.db('shopdb').collection('orders');

  // 监听订单集合的所有变更
  const changeStream = orders.watch([
    {
      $match: {
        operationType: 'insert',  // 仅监听插入
        'fullDocument.status': 'paid'  // 仅处理已支付订单
      }
    }
  ]);

  changeStream.on('change', async (change) => {
    const order = change.fullDocument;
    console.log(`新订单: ${order._id}, 金额: $${order.total}`);

    // 1. 发送邮件通知
    await sendEmail(order.userId, {
      subject: '订单确认',
      body: `您的订单 ${order._id} 已提交,总金额 $${order.total}`
    });

    // 2. WebSocket 实时推送给商家后台
    io.to('merchant-dashboard').emit('new_order', {
      orderId: order._id,
      total: order.total,
      items: order.items,
      timestamp: order.createdAt
    });

    // 3. 同步到 Elasticsearch 供搜索
    await elasticsearch.index({
      index: 'orders',
      id: order._id.toString(),
      body: order
    });
  });

  // 4. 监听商品变更,同步更新 Elasticsearch
  const productStream = client.db('shopdb').collection('products').watch();
  productStream.on('change', async (change) => {
    if (change.operationType === 'delete') {
      await elasticsearch.delete({
        index: 'products',
        id: change.documentKey._id.toString()
      });
    } else if (change.fullDocument) {
      await elasticsearch.index({
        index: 'products',
        id: change.documentKey._id.toString(),
        body: change.fullDocument
      });
    }
  });

  console.log('Change Streams listening...');
}

// 5. 断点续传(应用重启后恢复监听进度)
async function resumeAfterRestart() {
  const resumeToken = await redis.get('change_stream_resume_token');
  const changeStream = orders.watch([], {
    resumeAfter: JSON.parse(resumeToken),
    fullDocument: 'updateLookup'
  });

  changeStream.on('change', async (change) => {
    // 处理变更...
    // 保存 resume token
    await redis.set('change_stream_resume_token', JSON.stringify(change._id));
  });
}

startOrderListener().catch(console.error);

// 2. 在 mongosh 中手动测试
// 触发事件:插入新订单
db.orders.insertOne({
  userId: 'user_001',
  items: [{ sku: 'PHONE-001', qty: 1, price: 599 }],
  total: 599,
  status: 'paid',
  createdAt: new Date()
});
// 应用立即收到通知:发送邮件 + WebSocket 推送 + ES 索引

输出:订单插入后 < 100ms 触发,自动完成邮件、推送、搜索同步等所有副作用,无需轮询。

▶ 示例 3:Change Stream + WebSocket 实时仪表盘

将 Change Stream 与 WebSocket 结合,可以构建实时数据仪表盘——数据库变更自动推送到前端,无需轮询。本示例实现一个订单监控仪表盘:新订单实时推送、订单状态变更实时更新、异常订单实时告警,所有数据由 Change Stream 驱动。

JAVASCRIPT
const { WebSocketServer } = require('ws');
const http = require('http');

// === 1. WebSocket 服务器 ===
const server = http.createServer(app);
const wss = new WebSocketServer({ server });

// 客户端连接管理:按角色分组
const clients = new Map(); // ws → { role, subscriptions }
wss.on('connection', (ws, req) => {
  const params = new URL(req.url, `http://${req.headers.host}`).searchParams;
  const role = params.get('role') || 'viewer';
  clients.set(ws, { role, subscriptions: new Set() });

  ws.on('message', (data) => {
    const msg = JSON.parse(data);
    if (msg.action === 'subscribe') {
      clients.get(ws).subscriptions.add(msg.collection);
    }
  });

  ws.on('close', () => clients.delete(ws));
});

// === 2. Change Stream 监听多个集合 ===
const collections = ['orders', 'products', 'users'];
collections.forEach(coll => {
  const stream = mongoose.connection.collection(coll).watch();

  stream.on('change', (change) => {
    const event = {
      collection: coll,
      operation: change.operationType,
      documentId: change.documentKey?._id,
      data: change.fullDocument || change.updateDescription,
      timestamp: change.clusterTime
    };

    // 广播给订阅了该集合的客户端
    broadcast(coll, event);

    // 异常检测:订单取消/退款 → 告警
    if (coll === 'orders' && change.operationType === 'update') {
      const updatedFields = change.updateDescription?.updatedFields || {};
      if (updatedFields.status === 'cancelled') {
        broadcastAlert('order_cancelled', event);
      }
    }
  });

  stream.on('error', (err) => {
    console.error(`Change Stream ${coll} 错误:`, err.message);
    // 自动重连
    setTimeout(() => startChangeStream(coll), 5000);
  });
});

// === 3. 广播与告警函数 ===
function broadcast(collection, event) {
  const message = JSON.stringify({ type: 'change', ...event });
  clients.forEach((meta, ws) => {
    if (ws.readyState === 1 && meta.subscriptions.has(collection)) {
      ws.send(message);
    }
  });
}

function broadcastAlert(alertType, event) {
  const message = JSON.stringify({ type: 'alert', alertType, ...event });
  clients.forEach((meta, ws) => {
    if (ws.readyState === 1 && meta.role === 'admin') {
      ws.send(message);
    }
  });
}

// === 4. 聚合管道过滤:只监听高价值订单 ===
const highValueStream = mongoose.connection.collection('orders').watch([
  { $match: { 'fullDocument.total': { $gte: 500 }, operationType: { $in: ['insert', 'update'] } } },
  {
    $project: {
      operationType: 1,
      documentKey: 1,
      'fullDocument.orderId': 1,
      'fullDocument.total': 1,
      'fullDocument.status': 1,
      'fullDocument.userId': 1,
      updateDescription: 1
    }
  }
]);

highValueStream.on('change', (change) => {
  broadcastAlert('high_value_order', {
    collection: 'orders',
    orderId: change.fullDocument?.orderId,
    total: change.fullDocument?.total,
    operation: change.operationType
  });
});

server.listen(3000);

输出:前端 WebSocket 连接后订阅指定集合,数据库变更实时推送。管理员额外收到异常告警(订单取消、高价值订单变更)。Change Stream 用 $match 管道过滤只监听高价值订单,减少不必要的推送。

Change Stream + WebSocket 的生产化建议:1. 心跳机制——WebSocket 每 30 秒发送 ping,超时自动断开重连;2. 背压控制——客户端消费慢时,服务端缓存事件而非丢弃(用 Redis 做缓冲队列);3. 断线重连——Change Stream 中断后用 resumeAfter 从断点恢复,避免丢失事件;4. 认证——WebSocket 连接时验证 JWT,未认证连接直接关闭;5. 水平扩展——多实例部署时用 Redis Pub/Sub 跨实例广播,避免重复推送。

❓ 常见问题

Q Change Streams 是实时吗?
A 近实时。oplog 写入后 Change Stream 立即触发,延迟 < 100ms。
Q Change Streams 会丢失事件吗?
A 不会(除非 oplog 滚动覆盖)。可用 resumeAfter 断点续传。
Q 如何持久化监听进度?
A 使用 resumeToken 存储在外部(如 Redis),重启时恢复。

📖 小节


📝 作业

  1. 基础题(⭐):监听 products 集合的所有变更,打印到控制台。
  2. 基础题(⭐):用 $match 过滤 insert 操作。
  3. 进阶题(⭐⭐):监听新订单并发送邮件通知(mock 邮件函数)。
  4. 进阶题(⭐⭐):实现商品缓存失效(Redis 同步)。
  5. 挑战题(⭐⭐⭐):完整 CDC 系统(MongoDB → Elasticsearch 实时同步)。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏