Files
ProductionDataBaseSync_Data…/docs/incremental-sync-flow.md
Misaka_Company 7cef5153e6 fix(capture): 读不到源行时按日志年龄延迟重试,根治降级Delete致数据丢失
Insert/Update 日志回读不到源行时,旧逻辑无条件降级为 Delete,在 ACE
引擎可见性延迟(批量插入约11s窗口)下误伤,导致该 Insert 的行不仅没进
SQL 反而被空打 Delete 并清理 Access 日志,数据永久丢失(8/4 事件根因:
氩弧焊.接收 16255-16259 缺失)。

改为按 Access 日志年龄(capture_defer_seconds=60)决定动作:
- 年龄 < 阈值:DEFER,跳过不入队,Access 日志保留,下一轮 cycle 重读;
- 年龄 >= 阈值:AGED-OUT,入队直接标 dead 待人工(usp_SyncApply 只选
  pending,不会执行任何破坏性 SQL),归档表记 ProcessedOperateType=AgedOut。

零 schema/SQL 改动:cleanup 只删 Status=applied 的日志,DEFER 行不入队
则无 applied 行、AGED-OUT 标 dead 非 applied,两者 Access 日志均保留,
与既有 cleanup/apply/service 逻辑天然自洽。

详见 docs/plan-capture-defer-by-age.md 与 docs/incremental-sync-flow.md。
2026-08-05 11:00:58 +08:00

12 KiB
Raw Permalink Blame History

增量同步流程详解Incremental Sync Flow

本文档梳理 Access → SQL Server 增量同步的完整流程,逐节点说明「发生了什么、读了/写了什么、状态如何流转」作为重构「Insert 降级 Delete」逻辑的决策依据。

涉及代码:src/sync/service.py(主循环 cyclesrc/sync/capture.py(捕获)、sql/02_sync_apply.sql(应用)、src/sync/cleanup.py(清理)、src/sync/sql_writer.pySQL 端读写)、src/sync/access_reader.pyAccess 端读取)。


一、整体架构:一轮 cycle 的三段流水线

每个 cycle默认间隔 poll_interval_seconds=10s)跑一遍三段,顺序固定、不可调换:

flowchart TD
    START([cycle 开始<br/>分配 cycle_id]) --> CAP

    CAP["【1. Capture 捕获】<br/>逐库读 TableChangeLog → 回读整行 → 入队 SyncQueue<br/><i>src/sync/capture.py</i>"]
    CAP --> APPLY

    APPLY["【2. Apply 应用】<br/>调 usp_SyncApply 把 SyncQueue pending 行落到镜像表<br/><i>sql/02_sync_apply.sql</i>"]
    APPLY --> HEALTH

    HEALTH["【2.5 队列健康检查】<br/>查 error/dead 卡死行并告警"]
    HEALTH --> CLEAN

    CLEAN["【3. Cleanup 清理】<br/>删 Access 已应用日志 → SyncQueue 行标 cleaned<br/><i>src/sync/cleanup.py</i>"]
    CLEAN --> PURGE

    PURGE["【3.5 Purge 回收】<br/>删 SyncQueue 中超保留期的 cleaned 行"]
    PURGE --> DONE([cycle 结束<br/>休眠 poll_interval])

    style CAP fill:#e3f2fd,stroke:#1976d2
    style APPLY fill:#fff3e0,stroke:#f57c00
    style HEALTH fill:#fce4ec,stroke:#c2185b
    style CLEAN fill:#e8f5e9,stroke:#388e3c
    style PURGE fill:#f3e5f5,stroke:#7b1fa2

关键设计约束(决定重构可行性的红线):

  • Access 的 TableChangeLog 是 append-only无状态字段——它只是个待处理队列,无法在上面记录「已重试几次」。
  • 日志清除的唯一依据是 SyncQueue 的 Status='applied'——只要一条日志对应的队列行不是 appliedcleanup 就不会删它(详见第三节)。
  • SyncQueue 有唯一索引 (SourceFile,SourceTable,SourceLogID) 去重——同一日志第二次入队会被静默跳过,不会覆盖原行、不会自增计数。

二、Capture 阶段(数据捕获)—— 重构的核心战场

逐库处理,每个 Access 文件独立隔离(单库失败不影响其它)。

flowchart TD
    A([开始 capture 某个文件]) --> B["读 Access TableChangeLog<br/>最旧 N 条(按 ID 升序)<br/>N = capture_batch_size=500"]
    B --> C{有日志行?}
    C -- 否 --> Z([capture 结束])
    C -- 是 --> D[逐行处理]

    D --> E{"表是否在同步范围内?<br/>is_synced_table"}
    E -- 否 --> F["跳过out_of_scope<br/>不计入,下轮仍会读到"]
    F --> D
    E -- 是 --> G{"OperateType?"}

    G -- Insert/Update --> H["🔑 回读整行<br/>read_row(table, record_id)<br/>SELECT * FROM 表 WHERE ID=?"]
    H --> I{读到行?}
    I -- 是 --> J["row_data = JSON 序列化<br/>op 保持 Insert/Update"]
    I -- ❌否 --> K["⚠️ 降级 op = Delete<br/>row_data = None<br/>写 DOWNGRADE 警告日志"]

    G -- Delete --> L["op = Delete<br/>row_data = None<br/>不回读Delete 无需数据)"]

    G -- 其它未知 --> M["跳过unknown_op<br/>下轮仍会读到"]

    J --> N["写 SyncLogArchive永久审计<br/>记录 OriginalOperateType + ProcessedOperateType"]
    K --> N
    L --> N

    N --> O["入队 SyncQueue去重插入<br/>INSERT...WHERE NOT EXISTS"]
    O --> P{插入成功?}
    P -- 是 --> Q["enqueued +1"]
    P -- 否(去重命中)--> R["dedup_skipped +1<br/>说明上轮 apply 失败残留"]

    Q --> S{还有下一行?}
    R --> S
    F --> S
    M --> S
    S -- 是 --> D
    S -- 否 --> T["打印 capture summary<br/>read/enqueued/downgraded/..."]
    T --> Z

    style K fill:#ffcdd2,stroke:#c62828,stroke-width:3px
    style H fill:#fff9c4,stroke:#f9a825
    style I fill:#fff9c4,stroke:#f9a825

🔴 问题节点:降级 Deletecapture.py:93-108

if op in ("Insert", "Update"):
    d = reader.read_row(lr.table_name, lr.record_id)
    if d is None:
        op = "Delete"          # ← 问题根源:读不到就降级
        st.downgraded += 1
        log.warning("capture DOWNGRADE %s->Delete ...")

为什么读不到?两个场景无法区分:

  1. 真删除:行被 Insert 后又 DeleteAccess 客户端先插后删)→ 这时降级 Delete 是「碰巧正确」。
  2. 可见性延迟(本次事件的根因):行已插入但 ACE 引擎尚未对其他 ODBC 连接可见(批量插入时窗口可达 11 秒)→ 这时降级 Delete 是有害的误伤

代码当前无法区分这两种情况,统一降级为 Delete。这就是要重构的核心。


三、Apply 阶段(数据应用)

调用存储过程 usp_SyncApply按表分组、集合化处理所有 pending 行。

flowchart TD
    A([call_apply<br/>EXEC usp_SyncApply]) --> B["重置 error 行<br/>RetryCount < max_retries 的 → pending"]
    B --> C["按 TargetSchema+TargetTable 分组<br/>遍历每个目标表"]

    C --> D{该表有 pending 行?}
    D -- 否 --> C
    D -- 是 --> E["统计 pending 数 / distinct RecordID 数"]

    E --> F["BEGIN TRAN"]

    F --> G["🔑 构建列清单<br/>从 sys.columns 读目标表所有列<br/>(排除 ID/computed/identity/timestamp"]

    G --> H["构建动态 SQL"]

    H --> I["分支1: UpsertMERGE<br/>ranked CTE: 按 RecordID 分区,<br/>SourceLogID DESC 取 rn=1<br/>仅 OperateType∈Insert/Update 且 RowData 非空"]
    I --> J["SET IDENTITY_INSERT ON<br/>MERGE 目标表<br/>匹配则 UPDATE, 不匹配则 INSERT<br/>@merged = @@ROWCOUNT"]

    J --> K["分支2: Delete<br/>同一 ranked CTE 的 rn=1 行<br/>仅 OperateType=Delete"]
    K --> L["DELETE 目标表 WHERE ID IN (...)<br/>@deleted = @@ROWCOUNT"]

    L --> M["把该表所有 pending 行<br/>Status → applied, AppliedAt = now<br/>@applied = @@ROWCOUNT"]

    M --> N[COMMIT]

    N --> O["写 SyncApplyRunLog 审计<br/>pending/merged/deleted/applied/<br/>error/dead + CycleID + 耗时"]
    O --> C

    N -.失败.-> X["ROLLBACK"]
    X --> Y["超 max_retries → dead<br/>否则 → error下轮重试"]
    Y --> O

    style I fill:#e3f2fd,stroke:#1976d2
    style K fill:#ffcdd2,stroke:#c62828
    style M fill:#fff3e0,stroke:#f57c00

关键:保序「最后操作胜」(02_sync_apply.sql:95-135

单个 ranked CTE 同时供 Upsert 和 Delete 两个分支使用:

ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn
  • rn=1 是该 RecordID 真正的最后一条日志
  • Upsert 分支:rn=1 AND OperateType IN ('Insert','Update')
  • Delete 分支:rn=1 AND OperateType='Delete'

所以降级成 Delete 的行,在这里会真的去 SQL 端执行 DELETE。本次事件中 5 行 Delete 的 @deleted=0SQL 里本就没这些行,空打),但 @applied=5(队列行照样被标 applied


四、Cleanup 阶段(日志清除)—— 决定「重试」能否成立的命脉

flowchart TD
    A([cleanup 某个文件]) --> B["查 SyncQueue 中<br/>Status=applied 的 SourceLogID 列表<br/>applied_log_ids(file)"]
    B --> C{有 applied 行?}
    C -- 否 --> Z([cleanup 结束, 返回 0])
    C -- 是 --> D["DELETE FROM Access.TableChangeLog<br/>WHERE ID IN (上述列表)<br/>分批 + 锁重试"]
    D --> E{删除数 == 预期?}
    E -- 否 --> F["WARNING: 部分日志已不在<br/>(被外部或中断的运行删过)"]
    E -- 是 --> G["mark_cleaned:<br/>这些队列行 Status → cleaned<br/>CleanedAt = now"]
    F --> G
    G --> H["INFO: 删除了 N 条 Access 日志"]
    H --> Z

    style D fill:#e8f5e9,stroke:#388e3c,stroke-width:2px
    style B fill:#fff9c4,stroke:#f9a825

🔑 cleanup 的判定条件是重构的支点

cleanup 只删 Status='applied' 的日志sql_writer.py:264-272applied_log_ids)。这意味着:

capture 对该日志的处理 SyncQueue 行状态 cleanup 是否删 Access 日志 后果
降级 Delete现状 applied 删除 日志消失,再无重试机会
跳过不入队(重构后) (无对应行) 不删 日志保留,下轮重读

结论:重构只要做到「读不到 → 不入队」cleanup 这一段天然会把日志保留下来,无需改动 cleanup.py。 这是「跨 cycle 重试」能够成立的根基。


五、数据流转全景:一条日志的完整生命周期

flowchart LR
    subgraph Access["Access 端(.accdb"]
        T1[(业务表<br/>如 接收)]
        TCL[(TableChangeLog<br/>变更日志 append-only)]
    end

    subgraph SQL["SQL Server 端CompanyDB"]
        SQ[(SyncQueue<br/>待处理队列)]
        ARCH[(SyncLogArchive<br/>永久审计)]
        RL[(SyncApplyRunLog<br/>apply 运行日志)]
        MIRROR[(镜像表<br/>如 接收_YEAR2026)]
    end

    T1 -- "数据宏 After I/U/D<br/>写入一行日志" --> TCL
    TCL -- "① capture 读取" --> CAP[Capture]
    CAP -- "回读整行" --> T1
    CAP -- "② 入队(去重)" --> SQ
    CAP -- "② 审计存档" --> ARCH
    SQ -- "③ apply 处理" --> APPLY[usp_SyncApply]
    APPLY -- "MERGE/DELETE" --> MIRROR
    APPLY -- "pending→applied" --> SQ
    APPLY -- "记录运行结果" --> RL
    SQ -- "④ cleanup 查 applied" --> CLEAN[Cleanup]
    CLEAN -- "⑤ 删已应用日志" --> TCL
    CLEAN -- "applied→cleaned" --> SQ

    style CAP fill:#e3f2fd,stroke:#1976d2
    style APPLY fill:#fff3e0,stroke:#f57c00
    style CLEAN fill:#e8f5e9,stroke:#388e3c

六、本次事件的重放(在上述流程中的路径)

5 条 Insert 日志RecordID 16255-16259在一轮 cycle 中的遭遇:

flowchart TD
    A["8/3 09:01 Access 批量插入 5 行<br/>数据宏写 5 条 Insert 日志<br/>SourceLogID 15533-15537"] --> B

    B["09:00:50 Capture 读取这 5 条日志<br/>(注: CapturedAt 比 OriginalTime 早 11s<br/> = ACE 可见性窗口)"] --> C

    C["read_row 回读 5 行<br/>SELECT * FROM 接收 WHERE ID=16255..16259"] --> D

    D["❌ 全部返回 None<br/>(行尚未对其他连接可见)"] --> E

    E["🔴 降级为 Delete<br/>入队 SyncQueue, OperateType=Delete<br/>RowData=null"] --> F

    F["09:00:51 Apply: Delete 分支<br/>DELETE 接收_YEAR2026 WHERE ID IN(16255..16259)<br/>@deleted=0SQL 本就没这5行<br/>@applied=5队列行标 applied"] --> G

    G["队列行 Status = applied"] --> H

    H["Cleanup: 查到这5条 applied<br/>DELETE Access.TableChangeLog ID=15533-15537"] --> I

    I["🔴 日志被删5 条 Insert 证据消失<br/>这5行从此再无机会被同步进 SQL"] --> J

    J["8/4 05:08 compare: missing_in_sql=5<br/>ID 16255-16259"]

    style D fill:#ffcdd2,stroke:#c62828
    style E fill:#ffcdd2,stroke:#c62828,stroke-width:3px
    style H fill:#e8f5e9,stroke:#388e3c
    style I fill:#ffcdd2,stroke:#c62828
    style J fill:#fff9c4,stroke:#f9a825

链式灾难:降级 Deletecapture→ 空打 Delete 但标 appliedapply→ 删 Access 日志cleanup。三段配合下这 5 行被「合法地」从同步链路中抹除。只要在 capture 段断开第一环(不降级、不入队),后面 apply/cleanup 就不会碰它们,日志保留,下轮自然重试。


七、重构决策点(待定)

基于上述流程,重构的核心是改造 capture 阶段的「降级」分支。需要决策的问题:

  1. 读不到时的动作跳过不入队日志保留下轮重读vs 入队但标记 defer 状态?
  2. 重试上限的判定依据用「重试次数」需要存储计数vs 用「日志年龄时间窗」(无需存储,靠 now - OriginalTime > 阈值
  3. 终态处理:超限后该 Access 日志保留(占队列头持续重读)还是删除(丢证据)?
  4. Update 日志:是否套用同一套 defer 逻辑?

我倾向的方案:读不到 → 不入队 + 写 defer 审计;用日志年龄(如 120s作终态判据超限则入队标 dead保留 Access 日志不删,待人工)。零 schema 改动、零状态存储。等你审完这份流程图确认方向后,我再动手。