08. MongoDB Change Streams 实时同步

MongoDB Change Streams: 实时监听数据变更、CDC 架构、Resume Token 与容错设计

Change Streams 是 MongoDB 3.6 引入的变更流功能,允许应用以拉取或推送的方式实时监听集合、数据库甚至整个集群的数据变更。它是构建事件驱动架构、数据同步、缓存失效、审计日志等场景的核心基础设施。Change Streams 底层基于 oplog(操作日志),天然继承了 MongoDB 复制机制的高可用与顺序性保证,同时对外屏蔽了 oplog 的复杂格式,提供结构化的变更事件流。与直接轮询相比,Change Streams 具有低延迟、低负载、强一致的优势,已成为 MongoDB 生态中实现 CDC(Change Data Capture)的首选方案。

1. 基本原理

1.1 Change Streams 与 oplog 的关系

MongoDB 的复制集通过 oplog 记录所有写操作,Secondary 节点异步拉取并回放 oplog 以保持数据一致。Change Streams 本质上是在 oplog 之上构建的「安全包装层」:MongoDB 服务器内部维护一个 Change Stream 游标,将 oplog 中的原始条目解析为结构化事件,再通过长连接(或轮询游标)推送给客户端。

┌─────────────┐     ┌──────────────┐     ┌──────────────────┐
│   应用层     │←────│ Change Stream │←────│      oplog       │
│  (Consumer) │     │   游标/解析    │     │ (capped collection)│
└─────────────┘     └──────────────┘     └──────────────────┘

关键点:

  • Change Streams 只能运行在复制集(Replica Set)或分片集群(Sharded Cluster)上,因为单机实例没有 oplog。
  • 每个变更事件携带 clusterTime(逻辑时间戳),保证全局有序。
  • Change Stream 游标最终读取的是主节点(Primary)的 oplog,因此网络分区或主节点切换可能导致短暂中断,需要配合 Resume Token 做容错恢复。

1.2 事件类型详解

事件类型说明适用场景
insert新文档插入数据同步、缓存预热
update文档字段更新增量索引、通知触发
replace整文档替换全量覆盖场景
delete文档删除缓存失效、级联删除
invalidate监听目标被删除或重命名需要重新建立 Change Stream
drop集合被删除清理下游资源

1.3 事件结构字段完整说明

{
  "_id": { "_data": "8262..." },
  "operationType": "update",
  "fullDocument": { "_id": "...", "status": "shipped", "updatedAt": "..." },
  "ns": { "db": "shop", "coll": "orders" },
  "documentKey": { "_id": "..." },
  "updateDescription": {
    "updatedFields": { "status": "shipped" },
    "removedFields": [],
    "truncatedArrays": []
  },
  "clusterTime": { "$timestamp": { "t": 1692000000, "i": 1 } },
  "txnNumber": 1,
  "lsid": { "id": "...", "uid": "..." }
}
  • _id._data:Resume Token,用于断点续传。
  • operationType:事件类型。
  • fullDocument:完整文档,只有 insert、replace 或配置了 updateLookup 的 update 才包含。
  • ns:Namespace,标识数据库与集合。
  • documentKey:被变更文档的 _id。
  • updateDescription:update 事件中描述字段变更细节。
  • clusterTime:事件在集群中的逻辑时间戳,用于排序和一致性判断。
  • txnNumber / lsid:多文档事务相关标识。

2. 基础用法

2.1 Mongo Shell

const pipeline = [
    { $match: { "fullDocument.status": "paid" } },
    { $project: { "fullDocument": 1, "operationType": 1, "clusterTime": 1 } }
];

const changeStream = db.orders.watch(pipeline, {
    fullDocument: "updateLookup"
});

changeStream.on("change", (change) => {
    console.log(change);
});

2.2 Java 驱动

MongoClient client = MongoClients.create("mongodb://localhost:27017");
MongoDatabase db = client.getDatabase("shop");

MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor =
    db.getCollection("orders").watch()
        .fullDocument(FullDocument.UPDATE_LOOKUP)
        .cursor();

while (cursor.hasNext()) {
    ChangeStreamDocument<Document> change = cursor.next();
    switch (change.getOperationType()) {
        case INSERT -> handleInsert(change.getFullDocument());
        case UPDATE -> handleUpdate(change.getFullDocument());
        case DELETE -> handleDelete(change.getDocumentKey());
    }
}

2.3 Node.js 驱动(mongodb 包)

const { MongoClient } = require('mongodb');

async function run() {
    const client = new MongoClient('mongodb://localhost:27017');
    await client.connect();
    const db = client.db('shop');

    const pipeline = [
        { $match: { operationType: { $in: ['insert', 'update'] } } },
        { $project: { fullDocument: 1, ns: 1, operationType: 1 } }
    ];

    const changeStream = db.collection('orders').watch(pipeline, {
        fullDocument: 'updateLookup',
        maxAwaitTimeMS: 3000
    });

    for await (const change of changeStream) {
        console.log('Event:', change.operationType, 'on', change.ns.coll);
        if (change.fullDocument) {
            console.log('Doc:', change.fullDocument);
        }
    }
}

run().catch(console.error);

2.4 Python(pymongo)驱动

from pymongo import MongoClient

client = MongoClient('mongodb://localhost:27017')
db = client['shop']

pipeline = [
    {"$match": {"operationType": {"$in": ["insert", "update"]}}},
    {"$project": {"fullDocument": 1, "ns": 1, "operationType": 1}}
]

with db.orders.watch(pipeline, full_document='updateLookup') as stream:
    for change in stream:
        print(f"Type: {change['operationType']}")
        print(f"Token: {change['_id']}")
        if 'fullDocument' in change:
            print(f"Doc: {change['fullDocument']}")

2.5 过滤管道详解

Change Streams 支持完整的聚合管道阶段,常用阶段包括:

阶段作用示例
$match按条件过滤事件{ $match: { "fullDocument.status": "paid" } }
$project投影,减少字段{ $project: { "fullDocument.name": 1, "operationType": 1 } }
$addFields添加派生字段{ $addFields: { eventTime: "$clusterTime" } }
$redact基于条件控制字段可见性可结合 $$DESCEND / $$PRUNE 做安全过滤

2.6 fullDocument 选项

选项值说明
defaultupdate 事件不包含完整文档
updateLookupupdate 事件会查询当前文档状态并返回
required要求必须返回完整文档,否则报错
whenAvailable当 post-image 可用时返回(MongoDB 6.0+)

MongoDB 6.0 开始支持 changeStreamPreAndPostImages,可以在集合级别启用前置/后置镜像,Change Stream 通过 fullDocumentBeforeChange 获取更新前的文档快照。

db.runCommand({
    collMod: "orders",
    changeStreamPreAndPostImages: { enabled: true }
});

3. Resume Token 与容错

3.1 Resume Token 序列化与反序列化

Resume Token 本质是 BSON 格式的 _id 字段,需要安全地序列化为字符串后持久化到外部存储(Redis、ZooKeeper、数据库等)。

// 序列化:Token → String
String tokenString = resumeToken.toJson();
redisTemplate.opsForValue().set("changeStream:orders:token", tokenString);

// 反序列化:String → BsonDocument
String saved = redisTemplate.opsForValue().get("changeStream:orders:token");
BsonDocument restoredToken = BsonDocument.parse(saved);

3.2 Token TTL 与 oplog 窗口关系

Resume Token 的有效期取决于 oplog 的保留窗口。如果 Token 指向的 oplog 条目已被覆盖(oplog 淘汰),MongoDB 将抛出 ChangeStreamFatalError,此时无法 Resume,只能从头重新监听。

因此生产环境中需监控 oplog.rs 的窗口大小:

db.getReplicationInfo().logLengthStartToEnd;

建议:

  • 配置足够大的 oplog(根据写吞吐量评估)。
  • 消费端发生故障时尽快恢复,避免 Token 过期。
  • 定期将当前的 Resume Token 保存到可靠的外部存储。

3.3 批量处理与背压控制

在高吞吐场景下,逐条处理变更事件可能成为瓶颈。可以利用驱动提供的批量游标接口实现背压控制:

ChangeStreamIterable<Document> iterable = db.getCollection("events").watch()
    .batchSize(100)
    .maxAwaitTime(5, TimeUnit.SECONDS);

try (MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor = iterable.cursor()) {
    List<ChangeStreamDocument<Document>> batch = new ArrayList<>();
    while (cursor.hasNext()) {
        batch.add(cursor.next());
        if (batch.size() >= 100) {
            processBatch(batch);
            saveTokenToRedis(batch.get(batch.size() - 1).getResumeToken());
            batch.clear();
        }
    }
}

3.4 断线重连指数退避策略

BsonDocument resumeToken = loadTokenFromRedis();
int retryCount = 0;
final int MAX_BACKOFF_MS = 60000;

while (true) {
    try {
        ChangeStreamIterable<Document> iterable;
        if (resumeToken != null) {
            iterable = db.getCollection("orders").watch().resumeAfter(resumeToken);
        } else {
            iterable = db.getCollection("orders").watch();
        }

        try (MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor = iterable.cursor()) {
            retryCount = 0; // 连接成功后重置计数器
            while (cursor.hasNext()) {
                ChangeStreamDocument<Document> change = cursor.next();
                process(change);
                resumeToken = change.getResumeToken();
                saveTokenToRedis(resumeToken);
            }
        }
    } catch (MongoException e) {
        int backoff = Math.min((int) Math.pow(2, retryCount) * 1000, MAX_BACKOFF_MS);
        log.warn("Stream interrupted, retry {} after {} ms", retryCount, backoff);
        retryCount++;
        Thread.sleep(backoff);
    }
}

4. 常见 CDC 架构

4.1 MongoDB → Debezium → Kafka Connect

Debezium 是开源的分布式 CDC 平台,内置 MongoDB Connector。它利用 Change Streams 捕获变更,将事件序列化为 Kafka 消息,供下游消费者订阅。

架构示意:

MongoDB Primary ──Change Streams──→ Debezium Connector
                                          │
                                          ▼
                                    Kafka Topic
                                          │
                    ┌─────────────────────┼─────────────────────┐
                    ▼                     ▼                     ▼
               ElasticSearch         ClickHouse              业务服务
               (全文检索)             (OLAP 分析)            (事件处理)

Debezium 配置要点:

  • capture.mode:设置为 change_streams_update_full 以获取完整文档。
  • snapshot.mode:首次启动时可选择全量快照 + 增量流。
  • tombstones.on.delete:控制是否对 delete 事件发送 tombstone 消息。

4.2 MongoDB → AWS Lambda 触发器

MongoDB Atlas 提供与 AWS 的原生集成,支持在集合上配置触发器(Trigger),当 Change Stream 产生事件时自动调用 AWS Lambda 函数。

// AWS Lambda 处理函数示例(Node.js)
exports.handler = async (event) => {
    const changeEvent = event.fullDocument;
    if (event.operationType === 'insert') {
        await sendWelcomeEmail(changeEvent.email);
    }
    return { statusCode: 200 };
};

优势:无需自建长连接消费者,Serverless 弹性扩缩容。
劣势:事件延迟受 Atlas 触发器轮询间隔影响(通常数百毫秒到数秒)。

4.3 MongoDB → Elasticsearch 实时索引同步

通过 Change Streams 将 MongoDB 中的文档变更实时同步到 Elasticsearch,是最常见的搜索增强架构。

// 简化示例:update 事件同步到 ES
ChangeStreamIterable<Document> iterable = db.getCollection("products").watch()
    .fullDocument(FullDocument.UPDATE_LOOKUP);

try (MongoChangeStreamCursor<ChangeStreamDocument<Document>> cursor = iterable.cursor()) {
    while (cursor.hasNext()) {
        ChangeStreamDocument<Document> change = cursor.next();
        String docId = change.getDocumentKey().getObjectId("_id").toString();
        switch (change.getOperationType()) {
            case INSERT, REPLACE, UPDATE -> {
                Document doc = change.getFullDocument();
                esClient.index(i -> i.index("products").id(docId).document(doc));
            }
            case DELETE -> esClient.delete(d -> d.index("products").id(docId));
        }
        saveTokenToRedis(change.getResumeToken());
    }
}

4.4 跨区域复制与数据迁移

对于需要跨可用区或跨云同步的场景,Change Streams 可作为轻量级复制管道:

MongoDB Cluster A (Region A)
         │
         ├── Change Streams ──→ 同步程序 ──→ MongoDB Cluster B (Region B)

同步程序负责:

  • 读取源端 Change Stream;
  • 将 insert / update / replace 映射为目标端写操作;
  • 将 delete 映射为目标端删除;
  • 通过 Resume Token 保证断点续传。

5. Change Streams 性能优化

5.1 batch size 调优

batchSize 控制一次网络往返传输的事件数量。过小的 batch size 导致频繁网络往返;过大则增加客户端内存压力和延迟。建议根据事件大小和下游处理能力动态调整,通常初始值设为 100-1000。

db.getCollection("events").watch().batchSize(500);

5.2 投影减少数据传输

由于 Change Stream 事件默认包含 fullDocument,对于大文档场景会产生大量网络 IO。应使用 $project 裁剪不必要的字段。

const pipeline = [
    { $project: { "fullDocument.content": 0, "updateDescription": 0 } }
];
db.articles.watch(pipeline);

5.3 高吞吐场景的分片策略

在分片集群上,Change Stream 需要聚合多个分片的 oplog。可以通过在 $match 阶段加入片键过滤,让 MongoDB 路由到特定分片,减少聚合开销。

const pipeline = [
    { $match: { "documentKey.shardKey": { $gte: 0, $lt: 100 } } }
];
db.orders.watch(pipeline);

另外,极端高吞吐场景下,可考虑按集合拆分多个 Change Stream 消费者实例,各自负责一部分片键范围,实现水平扩展。

6. Spring Data MongoDB Reactive 集成(WebFlux + Change Streams)

Spring Data MongoDB Reactive 提供了对 Change Streams 的原生支持,可与 WebFlux 结合构建响应式实时推送服务。

6.1 依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-mongodb-reactive</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

6.2 服务层监听 Change Stream

@Service
public class OrderChangeStreamService {

    @Autowired
    private ReactiveMongoTemplate mongoTemplate;

    public Flux<ChangeStreamEvent<Document>> streamOrderChanges() {
        ChangeStreamOptions options = ChangeStreamOptions.builder()
            .filter(Aggregation.match(Criteria.where("operationType").in("insert", "update")))
            .fullDocumentLookup(FullDocument.UPDATE_LOOKUP)
            .build();

        return mongoTemplate.changeStream("orders", options, Document.class)
            .doOnNext(event -> {
                System.out.println("Change: " + event.getOperationType());
            });
    }
}

6.3 WebFlux SSE 实时推送

@RestController
public class OrderStreamController {

    @Autowired
    private OrderChangeStreamService streamService;

    @GetMapping(value = "/orders/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> streamOrders() {
        return streamService.streamOrderChanges()
            .map(event -> ServerSentEvent.builder(event.getRaw().toJson()).build());
    }
}

客户端通过 EventSource 连接 /orders/stream 即可实时接收变更推送。

6.4 持久化 Resume Token

在响应式流中保存 Token 需要利用 doOnNext 或自定义 Subscriber:

private volatile BsonDocument lastResumeToken;

public Flux<ChangeStreamEvent<Document>> streamWithRecovery() {
    ChangeStreamOptions options = ChangeStreamOptions.builder()
        .resumeToken(lastResumeToken)
        .build();

    return mongoTemplate.changeStream("orders", options, Document.class)
        .doOnNext(event -> lastResumeToken = event.getRaw().getResumeToken());
}

生产环境中建议将 lastResumeToken 定期异步写入 Redis 或数据库,避免进程重启导致数据丢失。

7. 事务边界与 Change Streams

7.1 事务内 events 的 clusterTime 一致性

MongoDB 4.0+ 支持多文档事务(Replica Set),4.2+ 支持分片事务。当事务提交时,其中涉及的所有变更事件会共享相同的 clusterTime 和 txnNumber,并且通过 lsid(Logical Session ID)关联。

{
  "operationType": "insert",
  "clusterTime": { "$timestamp": { "t": 1692000100, "i": 1 } },
  "txnNumber": 5,
  "lsid": { "id": { "$binary": "...", "$type": "04" }, "uid": "..." }
}

同一事务内的所有事件 clusterTime 相同,txnNumber 递增。这意味着:

  • 下游消费者可以通过 lsid + txnNumber 将同一事务的事件分组处理。
  • 若事务回滚,对应的 Change Stream 事件 不会 被推送。

7.2 事务事件的消费策略

Map<Lsid, List<ChangeStreamDocument<Document>>> txnBuffer = new HashMap<>();

while (cursor.hasNext()) {
    ChangeStreamDocument<Document> change = cursor.next();
    if (change.getLsid() != null && change.getTxnNumber() != null) {
        txnBuffer.computeIfAbsent(change.getLsid(), k -> new ArrayList<>()).add(change);
        // 实际生产中需根据后续事件判断事务是否完整
        if (isTransactionComplete(change)) {
            processTransactionBatch(txnBuffer.remove(change.getLsid()));
        }
    } else {
        process(change);
    }
}

注意:事务事件通常不携带 fullDocument(即使配置了 updateLookup),需要通过 updateDescription 自行推断变更内容。

8. 生产监控与告警

8.1 MongoDB Atlas Change Streams 指标

MongoDB Atlas 监控面板提供以下 Change Stream 相关指标:

指标含义告警阈值建议
oplog GB/Houroplog 生成速率突增时检查是否有大批量写入
Change Stream Open Cursors当前活跃的游标数量超过预期值时检查消费者泄漏
[Replication](/posts/database/) LagSecondary 落后 Primary 的时间> 30s 告警,可能影响 Resume Token 窗口
Connections总连接数接近阈值时扩容或优化连接池

8.2 自定义 Prometheus Exporter

对于自建 MongoDB 集群,可以编写自定义 exporter 暴露 Change Stream 消费者健康状态。

from prometheus_client import Gauge, start_http_server
import time

change_stream_lag = Gauge('mongodb_change_stream_lag_seconds',
                          'Lag between clusterTime and consumer processing time',
                          ['collection'])

change_stream_cursor_count = Gauge('mongodb_change_stream_open_cursors',
                                   'Number of open change stream cursors')

def monitor():
    while True:
        # 示例:通过 MongoDB 命令获取当前 oplog 时间戳
        # 并与消费者最后处理的 clusterTime 对比计算 lag
        lag = calculate_lag()
        change_stream_lag.labels(collection='orders').set(lag)
        change_stream_cursor_count.set(get_open_cursor_count())
        time.sleep(15)

if __name__ == '__main__':
    start_http_server(9090)
    monitor()

配合 Prometheus + Grafana ,可以可视化 Change Stream 消费延迟、断路器状态、重连次数等关键指标,实现生产级可观测性。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践