From 7d11eddc7ae2aa2396876f2427544186fc554562 Mon Sep 17 00:00:00 2001 From: Misaka_Company Date: Wed, 15 Jul 2026 14:03:27 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=9D=20docs:=20document=20unified=20CLI?= =?UTF-8?q?,=20incremental=20sync,=20and=20compare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 191 ++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 170 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index b7bf006..513dc37 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,199 @@ # ProductionDataBaseSync_DataMacro -Access → SQL Server 增量同步(数据宏驱动)。设计详见 `docs/superpowers/specs/2026-07-14-access-datamacro-sync-design.md`。 +Access → SQL Server 单向增量同步(数据宏驱动)。把各 Access 库的业务数据周期性同步到 SQL Server 镜像表,作为 Access → SQL Server 迁移期的过渡数据层。 + +设计详见 `docs/superpowers/specs/2026-07-14-access-datamacro-sync-design.md`。 + +## 背景 + +生产数据实际承载在网络共享下的多个 Access `.accdb`(按车间/年份分库)。旧机制靠客户端前端 VBA 写变更日志,但 VBA 只在特定表单事件触发,批量改表、直接改表等路径会绕过 → 漏数据。 + +改用 Access **数据宏**(表级引擎触发器,任何写路径必触发):每张业务表挂 `After Insert/Update/Delete`,变更写入各库本地 `TableChangeLog`。本程序就是「读各库日志 → 增量同步到 SQL」的搬运器,理论上 100% 捕获、对客户端零侵入。 ## 架构 -每个 Access 库通过数据宏把变更写入本地 `TableChangeLog`;同步服务周期性地把这些变更搬运到 SQL Server 镜像表。单库一轮分三段: +每个 Access 库通过数据宏把变更写入本地 `TableChangeLog`;同步服务周期性把这些变更搬运到 SQL Server 镜像表。单库一轮分三段: | 阶段 | 动作 | 说明 | | --- | --- | --- | -| Capture | `SELECT` 读取 `TableChangeLog` 中最旧的 N 条 | 只读 Access | -| Apply | 调用 `dbo.usp_SyncApply` 写入 SQL 镜像表 | 按 `ID` 精确落库 | +| Capture | `SELECT` 读取 `TableChangeLog` 最旧的 N 条,I/U 按 `RecordID` 回读整行 | 只读 Access | +| Apply | 调用 `dbo.usp_SyncApply` 写入 SQL 镜像表 | 按 `ID` 精确落库,保序「最后操作胜」 | | Cleanup | `DELETE` 已应用的 `TableChangeLog` 行 | 按 `ID` 列表精确删除,遇锁自动退避重试 | -## 命令 +关键设计点: +- **无水位线表**——Access 日志「应用成功即删」,日志本身就是待处理队列;`dbo.SyncQueue` 的唯一索引 `(SourceFile, SourceTable, SourceLogID)` 兜底去重,重复捕获幂等。 +- **保序「最后操作胜」**——同一 `RecordID` 多次操作(先 Insert 后 Delete 等)按日志顺序取最后一条,保证最终态与 Access 一致。 +- **每表一个事务**——单表失败只回滚该表,失败行标 `error` 重试,超限标 `dead` 待人工。 +- **`SyncQueue` 长期保留**作审计/重试日志;`applied` 行清理后标 `cleaned`,超保留期再 purge,控制表增长。 -| 用途 | 命令 | -| --- | --- | -| 增量同步服务(常驻,由 nssm 托管 `DataMacroSync`) | `.venv/Scripts/python.exe -m sync.service` | -| 一次性全量同步(TRUNCATE + 全量 INSERT,所有库所有表) | `.venv/Scripts/python.exe -m sync.fullsync config.yaml` | +## 环境要求 -> 上述命令均使用项目自带的 `.venv`。同步类命令由 nssm 以服务方式运行,无需手动设置环境变量。 +- **Python 3.10+**(实测 3.13)。代码用 `X | None` 等新语法。 +- **ODBC 驱动**(系统级,非 pip 安装,需预先装好): + - `Microsoft Access Driver (*.accdb, *.mdb)`(ACE Redist 2016) + - `ODBC Driver 17 for SQL Server` +- **SQL Server ≥ 2017**(存储过程用 `STRING_AGG ... WITHIN GROUP`)。 +- 执行账号需对目标表有 `ALTER` 权限(`SET IDENTITY_INSERT` 要求)。 + +## 安装 + +```bash +python -m venv .venv +.venv/Scripts/python.exe -m pip install --upgrade pip +.venv/Scripts/python.exe -m pip install -r requirements.txt +``` + +依赖(`requirements.txt`):`pyodbc`、`PyYAML`、`pydantic`、`pytest`。 + +## SQL 端部署 + +首次需在目标库建好暂存表与 apply 存储过程(两个脚本都幂等,可重复执行): + +```bash +sqlcmd -S ,1433 -U -P -d -C -N o -i sql/01_sync_queue.sql +sqlcmd -S ,1433 -U -P -d -C -N o -i sql/02_sync_apply.sql +``` + +- `sql/01_sync_queue.sql`:建 `dbo.SyncQueue` + 去重/清理索引 + `CleanedAt` 列。 +- `sql/02_sync_apply.sql`:`dbo.usp_SyncApply` 集合化 apply 存储过程。 + +> 连接串/凭据以 `config.yaml` 为准,README 不硬编码。 ## 配置 -编辑 `config.yaml`(从 `config.example.yaml` 复制并填入真实凭据)。关键段: +编辑 `config.yaml`(从 `config.example.yaml` 复制并填入真实凭据;该文件 gitignored)。关键段: -- `sql_server`:SQL Server 连接串与 `SyncQueue` 表名。 -- `access`:ACE ODBC 驱动名与各根目录(`roots`)映射。 -- `runtime`:轮询间隔、批大小、重试与保留策略。 -- `files`:每个 Access 文件一条映射(`file` / `root` / `schema` / `year_suffix` / `exclude_tables` / `include_tables`)。 +- **`sql_server`**:`conn_str`(ODBC 连接串)与 `sync_queue_table`(默认 `dbo.SyncQueue`)。 +- **`access`**:`driver`(ACE 驱动名)与 `roots`(年份→根目录映射,如 `2026: "\\\\srv\\生产进度表\\2026年数据"`)。 +- **`runtime`**:`poll_interval_seconds`(轮询间隔)、`capture_batch_size`/`apply_batch_size`/`cleanup_batch_size`(各段批大小)、`max_retries`/`retry_backoff_seconds`(重试)、`cleanup_lock_retries`(Access 锁重试次数)、`cleaned_retention_hours`(`cleaned` 行保留多久后 purge)。 +- **`files`**:每个 Access 文件一条映射: + - `file` / `root`(对应 `access.roots` 的 key)/ `schema`(SQL 目标 schema)。 + - `year_suffix`:拼到表名后(`2026年数据` 用 `_YEAR2026`,`2025年数据`/合同表用 `""`)。 + - `exclude_tables` / `include_tables`:排除/包含规则,**exclude 优先于 include**。`TableChangeLog` 必须排除。 + +## 命令行 + +统一入口 `main.py`(仓库根目录),三个功能块都用它调用。配置固定读取同目录的 `config.yaml`,不在命令中指定: + +```bash +.venv/Scripts/python.exe main.py fullsync [--db FILE] [--table NAME] [--clear-change-log] +.venv/Scripts/python.exe main.py incremental [--loop] [--poll-interval N] +.venv/Scripts/python.exe main.py compare [--granularity count|ids] [--db FILE] [--table NAME] [--report PATH] +``` + +| 子命令 | 说明 | 退出码 | +| --- | --- | --- | +| `fullsync` | 一次性全量同步:TRUNCATE + 批量 INSERT,绕开增量队列。 | 0 | +| `incremental` | 增量同步一轮(capture→apply→cleanup)。`--loop` 切持续轮询(服务模式)。 | 0 | +| `compare` | 数据一致性核对:默认行数总量,`--granularity ids` 精确到 ID 集合差异。 | 全一致 0 / 有不一致 1 | + +> 三个块共用同一份 `config.yaml`,目标表集合完全一致(由 `sync.targets` 统一解析)。`main.py` 在根目录、自行把 `src/` 加入 `sys.path`,无需 `-m`、无需设环境变量;控制台强制 UTF-8,中文表名不乱码。 +> +> 旧入口 `-m sync.service` / `-m sync.fullsync` 保留为兼容,行为不变。 ## 全量同步 用于从零重建镜像表或修复 Access 与 SQL 之间的漂移。会**清空目标表再全量写入**,绕开增量队列: ```bash -.venv/Scripts/python.exe -m sync.fullsync config.yaml # 全部库、全部表 -.venv/Scripts/python.exe -m sync.fullsync config.yaml --db OEM.accdb # 仅单个库 -.venv/Scripts/python.exe -m sync.fullsync config.yaml --table 表壳焊接记录 # 仅单表(作用于所有库) -.venv/Scripts/python.exe -m sync.fullsync config.yaml --clear-change-log # 同步后同时清空 TableChangeLog(谨慎) +.venv/Scripts/python.exe main.py fullsync # 全部库、全部表 +.venv/Scripts/python.exe main.py fullsync --db OEM.accdb # 仅单个库 +.venv/Scripts/python.exe main.py fullsync --table 表壳焊接记录 # 仅单表(作用于所有库) +.venv/Scripts/python.exe main.py fullsync --clear-change-log # 同步后同时清空 TableChangeLog(谨慎) ``` - `year_suffix` 通过 `FileMapping` 拼到表名后(如 `表壳焊接记录` → `表壳焊接记录_YEAR2026`)。 +- 写入时 `SET IDENTITY_INSERT ON`,保留 Access 原 ID,保证后续增量的 `RecordID` 匹配不错位。 - 无镜像表的目标表按设计跳过(`target table missing`),不报错。 +## 增量同步 + +以 Access 数据宏日志为唯一变更源,每轮跑一遍 capture → apply → cleanup(三段说明见上面「架构」)。这是**主用模式**,生产上常驻运行。两种调用方式: + +```bash +.venv/Scripts/python.exe main.py incremental # 跑一轮就退出(手动/按需补跑) +.venv/Scripts/python.exe main.py incremental --loop # 持续轮询(服务模式,不退出) +.venv/Scripts/python.exe main.py incremental --loop --poll-interval 30 # 覆盖 runtime.poll_interval_seconds +``` + +- 生产环境以 nssm 服务 `DataMacroSync` 常驻(即 `--loop` 模式),见下文「NSSM 服务」;手动单轮适合验证或临时补跑积压。 +- `--loop` 持续轮询直到进程被停(`nssm stop` 或 Ctrl+C);默认单轮跑完即退出。 +- 单轮一次最多处理每库 `capture_batch_size` 条日志;积压多时连续跑几轮或用 `--loop` 直到清空。 +- 每个文件的 capture/cleanup 独立隔离,单文件失败不影响其它;apply 失败不阻塞 cleanup;失败行 `error` 下轮重试、超 `max_retries` 标 `dead` 待人工。 +- 幂等:`SyncQueue` 唯一索引去重,重复 capture、中断续跑都不会重写或漏写。 +- 与全量同步共用同一份 `config.yaml` 和 `sync.targets`,目标表集合完全一致;全量是「从零重建」的补充手段,不替代增量。 + +## 数据对比 + +核对 Access 源表与 SQL 镜像表是否一致。两种粒度: + +- **行数总量**(默认):逐表比对 `COUNT(*)`。 +- **ID 集合**(`--granularity ids`):逐表比对两边 `ID` 集合,报告「Access 有 / SQL 无」与「SQL 有 / Access 无」的 ID(每表前 50 个 + 总数)。 + +```bash +.venv/Scripts/python.exe main.py compare # 全部库、全部表,行数总量 +.venv/Scripts/python.exe main.py compare --db 氩弧焊.accdb # 仅单个库 +.venv/Scripts/python.exe main.py compare --granularity ids --table 表壳焊接记录 # 单表 ID 级 +.venv/Scripts/python.exe main.py compare --report report.txt # 同时写入报告文件(UTF-8) +``` + +- 无镜像表按设计跳过(`[SKIPPED no mirror]`),不计为不一致——这类表多半是该排除却没排除(如 `*_停` 停用表、`USysApplicationLog`),可作为配置清理的线索。 +- 任一表不一致时退出码 `1`(便于脚本化);全部一致为 `0`。 +- 实时增量同步存在秒级延迟窗口,刚写入 Access 的行可能尚未到 SQL,属正常(稍后再核或对照 `SyncQueue` 的 pending 行)。 + +## NSSM 服务(114) + +增量同步在 host 114 上以 nssm 服务 `DataMacroSync` 常驻运行。常用操作(经 `ssh 114`): + +```bash +ssh 114 "nssm status DataMacroSync" # 查状态(SERVICE_RUNNING / SERVICE_STOPPED) +ssh 114 "nssm stop DataMacroSync" # 停 +ssh 114 "nssm start DataMacroSync" # 起 +ssh 114 "nssm restart DataMacroSync" # 重启 +ssh 114 "nssm list" # 列出所有 nssm 服务 +``` + +## 测试 + +```bash +.venv/Scripts/python.exe -m pytest # 仅单元测试(默认) +RUN_INTEGRATION=1 .venv/Scripts/python.exe -m pytest # 含集成测试(需能连真实 Access + SQL Server) +``` + +- 单元测试用 mock,不依赖数据库;集成测试(`@pytest.mark.integration`)连 `config.yaml` 里的真实库,且自带清理。 +- `pyproject.toml` 仅用于配置 pytest(`pythonpath = ["src", "."]`、`testpaths`、`integration` 标记)。 + +## 项目结构 + +``` +main.py 统一命令行入口(fullsync / incremental / compare) +config.yaml 真实配置(gitignored);config.example.yaml 是模板 +requirements.txt 依赖 +pyproject.toml pytest 配置 +sql/ + 01_sync_queue.sql dbo.SyncQueue 建表 + 索引(幂等) + 02_sync_apply.sql dbo.usp_SyncApply 存储过程 +src/sync/ + config.py Pydantic 配置模型 + load_config + targets.py 共享目标表解析(exclude/include,全量/增量/对比共用) + serialize.py Access 值 → JSON 可序列化 + access_reader.py 读 Access(日志/整行/计数/ID/删除日志) + sql_writer.py 写 SQL(SyncQueue/apply/计数/ID/全量灌表) + capture.py 增量编排:读日志→回读整行→入队 + cleanup.py 清理编排:回删已应用日志 + service.py 主循环 cycle() / run() + fullsync.py 一次性全量同步 + compare.py 数据一致性对比(count / ids) + logging_setup.py 日志配置(滚动文件 + 控制台) +tests/ 单元 + 集成测试 +docs/superpowers/ 设计文档与实现计划 +``` + ## 常见问题 -- **cleanup 报 `-1102 无法更新;当前被锁定`**:Access 是文件型数据库,cleanup 反写 `DELETE` 与生产客户端数据宏写日志争用页级锁。服务已对锁冲突自动退避重试(`access_reader.delete_log_ids` 捕获 `pyodbc.Error` 并判断 `-1102`/“被锁定”)。偶发属正常,持续刷错再排查。 -- **`No module named 'pydantic_core'` / `pyodbc`**:venv 解释器与轮子 ABI 不匹配(常见于 Python 3.13 装到 cp310 轮子)。修复:` .venv/Scripts/python.exe -m pip install --force-reinstall --no-cache-dir pyodbc pydantic`。 +- **cleanup 报 `-1102 无法更新;当前被锁定`**:Access 是文件型数据库,cleanup 反写 `DELETE` 与生产客户端数据宏写日志争用页级锁。服务已对锁冲突自动退避重试(`access_reader.delete_log_ids` 捕获 `pyodbc.Error` 并判断 `-1102`/「被锁定」)。偶发属正常,持续刷错再排查。 +- **`No module named 'pydantic_core'` / `pyodbc`**:venv 解释器与轮子 ABI 不匹配(常见于 Python 3.13 装到 cp310 轮子)。修复:`.venv/Scripts/python.exe -m pip install --force-reinstall --no-cache-dir pyodbc pydantic`。 +- **compare/fullsync 报 `[SKIPPED no mirror]` / `target table missing`**:该表在 Access 里但 SQL 端没有镜像表(多为 `*_停` 停用表、`USysApplicationLog` 等系统表,或尚未建镜像的新表)。若是该停用的表,加进对应 `exclude_tables`;若该同步,先在 SQL 建表再 fullsync。 +- **`SyncQueue` 出现 `error`/`dead` 行**:`error` 会在下轮自动重试(未超 `max_retries`);`dead` 是超限放弃,需人工看 `ErrorMsg` 排查后处理。 +- **`UserWarning: Field name "schema" ... shadows ... BaseModel`**:`FileMapping.schema` 字段名与 Pydantic 基类属性重名,仅告警、不影响功能。 +- **控制台中文乱码**:`main.py` 已强制 stdout/stderr 为 UTF-8;若仍乱码,设环境变量 `PYTHONIOENCODING=utf-8`,或用 `compare --report` 输出 UTF-8 文件。