--- id: scenario.data-sync title: 数据同步:业务库到搜索引擎 / 向量库 / 报表库,outbox 与 CDC、幂等、顺序、对账和重建 type: scenario domain: cross stack: [data-pipeline, postgresql, mongodb, elasticsearch, vector-db, mq] tags: [数据同步, outbox, CDC, 变更流, 幂等, 顺序, 对账, 全量重建] level: advanced prerequisites: [backend.data-pipeline.index, backend.mq.index] est_hours: 3 related: [backend.elasticsearch.scenario.kb-search-sync, backend.data-pipeline.scenario.third-party-sync, backend.mongodb.scenario.conversation-store, backend.mq.reliability] week: [6, 14] --- # 数据同步:业务库到搜索引擎 / 向量库 / 报表库,outbox 与 CDC、幂等、顺序、对账和重建 ## 业务背景与问题 业务数据在 PostgreSQL(订单、知识库文档)和 MongoDB(对话记录),需要同步到:ES(全文检索)、向量库(RAG)、报表库(报表 Agent)。现状是业务代码里“写完数据库顺手写 ES”: 1. 写 ES 失败时数据库已提交,两边不一致; 2. 同一条数据快速修改两次,ES 中留下的是旧版本; 3. 删除没有同步,搜索出已下架的商品; 4. 修改映射或换嵌入模型时不知道怎么重建。 ## 约束 - 允许秒级到分钟级的延迟。 - 不能明显增加业务写入的耗时。 - 下游可以从源头完全重建(源头是唯一可信的数据)。 ## 方案设计 1. **捕获变更**(二选一或组合): - **outbox 模式**:业务事务中同时写 outbox 表(事件类型、实体 id、版本号),后台进程投递到消息队列 → 保证“数据库提交 ⇔ 有事件”; - **CDC**:读取数据库日志(PostgreSQL 逻辑复制 / Debezium,MongoDB 变更流),业务代码零侵入。 2. **消费与写入**: - 消息只带 id 和版本,消费者回源读取最新数据再写下游(避免消息里的旧快照覆盖新数据); - 下游文档 id = 业务主键,写入带外部版本号,旧版本被拒绝; - 删除事件 → 删除下游文档(或标记删除); - 同一实体的事件按实体 id 分区,保证顺序; - 失败重试、死信、告警。 3. **对账**:每天比较源头与下游的数量和版本(按分片哈希),差异自动修复。 4. **全量重建**:新建索引 / 集合 → 全量导入(记录开始时间)→ 回放该时间之后的增量 → 校验 → 切换别名 → 保留旧索引回滚。 5. **监控**:同步延迟(事件产生到写入下游的时间)、积压量、失败数、对账差异。 ## 关键代码 outbox(同一事务中写业务数据和事件): ```sql BEGIN; UPDATE documents SET body = $2, version = version + 1, updated_at = now() WHERE id = $1; INSERT INTO outbox (aggregate, aggregate_id, event, version) SELECT 'document', id, 'updated', version FROM documents WHERE id = $1; COMMIT; ``` 消费者:回源 + 外部版本号(Elasticsearch Python 客户端,示意): ```python def handle(event: dict) -> None: doc = repo.get(event["aggregate_id"]) # 回源读取最新数据 if doc is None or doc.deleted: es.options(ignore_status=404).delete(index="kb", id=event["aggregate_id"]) return es.index(index="kb", id=doc.id, document=to_search_doc(doc), version=doc.version, version_type="external") # 版本小于等于已有版本时 ES 返回冲突,直接忽略 ``` 基础组件的可运行示例:[增量同步管道](../topics/backend/data-pipeline/examples/incremental-sync/)、[MongoDB 变更流](../topics/backend/mongodb/examples/conversations/)、[ES 别名切换](../topics/backend/elasticsearch/examples/hybrid-search/)。 ## 取舍 | 决策 | outbox | CDC | | --- | --- | --- | | 侵入性 | 需要改业务代码 | 业务零侵入 | | 事件语义 | 业务事件,含义清晰 | 行级变更,需要转换 | | 运维 | 简单 | 需要部署和维护 CDC 组件、处理日志保留 | | 适合 | 中小团队、事件少 | 表多、多个下游 | ## 易错点 - 业务代码中双写(数据库 + ES),没有事务保证。 - 消息中带完整快照,乱序时旧快照覆盖新数据。 - 忘记同步删除和权限变更。 - 重建期间的增量没有回放,切换后丢数据。 - 没有同步延迟监控,下游落后几小时才发现。 ## 面试怎么讲 > “我们把‘写库顺手写 ES’改成了 outbox:业务事务里同时写 outbox 表,后台投递到队列,保证提交和事件一致。消费者只拿 id 回源读最新数据,用业务主键作为文档 id、版本号作为外部版本,乱序和重复都不会让旧数据覆盖新数据;删除也同步。每天按分片哈希对账;换映射或嵌入模型时新建索引全量导入、回放增量、校验后切别名。监控同步延迟和积压。”