13 KiB
ProductionDataBaseSync_DataMacro
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 镜像表。单库一轮分三段:
| 阶段 | 动作 | 说明 |
|---|---|---|
| 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,控制表增长。
环境要求
- 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要求)。
安装
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 存储过程(两个脚本都幂等,可重复执行):
sqlcmd -S <SERVER>,1433 -U <USER> -P <PASSWORD> -d <DB> -C -N o -i sql/01_sync_queue.sql
sqlcmd -S <SERVER>,1433 -U <USER> -P <PASSWORD> -d <DB> -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 复制并填入真实凭据;该文件 gitignored)。关键段:
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,不在命令中指定:
.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 之间的漂移。会清空目标表再全量写入,绕开增量队列:
.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(三段说明见上面「架构」)。这是主用模式,生产上常驻运行。两种调用方式:
.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 个 + 总数)。
.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):
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 服务
测试
.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。- 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 文件。