MongoDB: Change Streams:实时变更监听
Change Streams 是 MongoDB 的实时变更通知——CDC(Change Data Capture)和事件驱动应用的基础。
1. 你将学到
- Change Streams 概念(基于 oplog)
- db.collection.watch() 监听
- 管道过滤($match)
- fullDocument: 'updateLookup'
- 与 mongoose 集成
- 典型场景(实时通知、数据同步)
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 条目转换为易读的变更事件,提供过滤、断点续传等高级功能。
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 结构:
// === 监听集合变更 ===
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 |
流失效(集合被删等) | ❌ 无 |
要点解析:
- Change Streams 必须运行在副本集或分片集群上(依赖 oplog)
update事件默认不返回完整文档,需设fullDocument: 'updateLookup'_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 或价格变更 |
// === 过滤特定操作 ===
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 } }
]
}
}
]);
要点解析:
- 过滤在服务器端执行,减少无用事件的网络传输
$match的字段路径使用 oplog 事件结构(如fullDocument.category),不是原始文档字段- 不支持
$group、$limit等需要全局状态的阶段
4. fullDocument 配置
概念说明:fullDocument 选项控制 Change Stream 事件中是否包含完整文档。默认情况下,update 事件仅返回变更的字段(updateDescription),不返回完整文档。设置 fullDocument: 'updateLookup' 后,MongoDB 会额外查询一次当前文档完整内容。
工作原理:
whenAvailable(默认):insert/replace 事件包含完整文档,update 事件仅包含变更字段,delete 事件无文档updateLookup:所有事件都包含完整文档(update 事件会额外查询最新文档),但有额外读取开销和微小延迟required:必须返回完整文档,否则报错
updateLookup 的权衡:
| 维度 | whenAvailable | updateLookup |
|---|---|---|
| 完整文档 | 仅 insert/replace | 所有操作类型 |
| 性能 | 基准 | 额外查询开销(+10-20%) |
| 数据新鲜度 | 变更时刻 | 查询时刻(可能有微小延迟) |
| 适用场景 | 仅需知道哪些字段变了 | 需要完整文档做后续处理 |
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: 事件中包含完整文档
// === 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' // 默认
});
要点解析:
updateLookup会做一次额外查询,高频更新场景下会增加数据库负载- updateLookup 返回的是查询时刻的最新文档,可能与变更时刻有微小差异(其他并发修改)
- delete 事件即使设 updateLookup 也无法返回文档(文档已不存在)
5. mongoose 集成
概念说明:mongoose 6+ 原生支持 Change Streams,通过 Model.watch() 返回变更流。与原生 MongoDB 驱动的 API 完全一致,但直接在 Model 层使用,更符合 mongoose 的开发习惯。
事件驱动架构:Change Streams 是事件驱动架构的核心组件——数据库变更作为事件源,驱动下游的缓存更新、搜索同步、通知推送等副作用。
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
// === 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'] } } }
]);
要点解析:
- mongoose 的
watch()返回的是原生 MongoDB Change Stream,API 完全一致 - 监听期间需要保持数据库连接,连接断开 Change Stream 自动关闭
- 生产环境建议封装 Change Stream 管理器:自动重连 + resumeToken 持久化
6. 实战场景
概念说明:Change Streams 在生产环境有三大经典场景:实时通知、数据同步(CDC)、缓存失效。每个场景都是事件驱动架构的典型实现。
(1) 实时通知系统
场景:ShopHub 电商需要在新订单创建时,实时发送邮件通知和 WebSocket 推送给商家后台。
// === 监听新订单,发送通知 ===
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 架构:
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
// === 监听 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 缓存商品详情页,当商品数据变更时自动清除缓存,避免脏数据。
// === 监听商品变更,清除 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 断点续传
// 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();
输出:
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 将失效,无法断点续传。
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 实时订单通知实战
// 场景:监听新订单,实时发送邮件 + 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 驱动。
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 跨实例广播,避免重复推送。
❓ 常见问题
resumeAfter 断点续传。resumeToken 存储在外部(如 Redis),重启时恢复。📖 小节
- Change Streams 基于 oplog 的实时变更通知
- watch() 方法监听 + $match 过滤
- operationType:insert / update / delete / replace / drop
- fullDocument: 'updateLookup' 获取完整文档
- 典型场景:实时通知、CDC、缓存失效
- 必须副本集,oplog 大小限制
📝 作业
- 基础题(⭐):监听 products 集合的所有变更,打印到控制台。
- 基础题(⭐):用 $match 过滤 insert 操作。
- 进阶题(⭐⭐):监听新订单并发送邮件通知(mock 邮件函数)。
- 进阶题(⭐⭐):实现商品缓存失效(Redis 同步)。
- 挑战题(⭐⭐⭐):完整 CDC 系统(MongoDB → Elasticsearch 实时同步)。