# 方案:被动式健康检查 API(数据健康 + 服务健康) > 目标:在增量同步进程内内置一个轻量 HTTP endpoint,对外暴露结构化健康快照,覆盖「服务健康」与「数据健康」两层。 > 形态:单进程内置(不另起服务、不新增 NSSM 配置),HTTP server 跑在后台线程,主同步循环零侵入。 --- ## 一、设计原则 1. **单进程内置**:HTTP server 用标准库 `http.server` + 后台线程,跑在同步进程内。不另起进程、不新增 NSSM 服务、不引入 Flask 等第三方依赖。 2. **主循环零侵入**:健康检查线程只读共享状态、只读 SQL,不触碰 capture/apply/cleanup 任何路径。HTTP server 崩溃不影响同步。 3. **数据实时查**:服务健康(最后 cycle 时间/队列状态)从进程内存的状态对象读;数据健康(Access↔SQL 漂移、dead 行)实时查 SQL Server 和最近 compare 报告。 4. **配置可选**:通过 `config.yaml` 的 `health` 段控制开关/端口/路径,默认开启但端口可配,关闭时不起线程。 --- ## 二、健康检查覆盖的两层 ### 服务健康(Service Health) 回答:"同步进程在跑吗?最近正常工作吗?队列有没有卡死?" | 指标 | 来源 | 含义 | |------|------|------| | `status` | 综合判定 | healthy / degraded / unhealthy | | `pid`, `started_at`, `uptime_seconds` | 进程 | 进程存活与运行时长 | | `last_cycle_at`, `last_cycle_duration_s` | 内存状态 | 最近一次 cycle 时间与耗时 | | `cycle_lag_seconds` | 实时计算 = now - last_cycle_at | **核心指标**:距上次 cycle 间隔,超过阈值→unhealthy | | `last_cycle_active` | 内存状态 | 上轮是否处理了变更 | | `queue` | 实时查 SyncQueue | pending/error/dead/cleaned 计数(dead>0→degraded) | | `capture_since_start` | 内存状态累计 | 服务启动至今的 deferred/aged_out/enqueued 累计(aged_out>0→degraded) | ### 数据健康(Data Health) 回答:"Access 和 SQL 数据一致吗?最近核对结果如何?" | 指标 | 来源 | 含义 | |------|------|------| | `last_compare` | 解析最近的 `compare_ids_*.log` 报告头 | 最近一次 compare 的时间、mismatch 表数、是否一致 | | `drift_tables` | 实时查 SyncLogArchive | 启动至今出现过的降级/异常记录涉及的表(AgedOut、Original≠Processed) | | `dead_rows_sample` | 实时查 SyncQueue dead 行 | 卡死行的样本(表名/RecordID/错误),辅助定位 | > 注:数据健康**不**在每次请求时跑全量 compare(太重),而是读"最近一次定时 compare 的结果" + "归档表/队列里的异常痕迹"。全量漂移检测仍由每日 05:08 的 `compare_ids` 计划任务兜底。 --- ## 三、status 综合判定逻辑 ``` unhealthy : cycle_lag_seconds > 3 × poll_interval (主循环停滞) 或 进程内存状态长时间未更新(疑似卡死) degraded : queue.dead > 0 (有放弃的变更,数据可能缺) 或 queue.error > 0 (有失败待重试) 或 capture.aged_out > 0 (有读不到的 Insert 被判死) 或 last_compare.mismatch (最近核对不一致) healthy : 其余情况 ``` `cycle_lag` 用 `3 × poll_interval`(默认 poll=10s → 30s)作阈值:正常空闲 cycle 间隔就是 10s 左右,超过 3 倍说明主循环被卡(比如 Access 锁等待)。 --- ## 四、接口契约 **请求**:`GET /health`(也可配 `GET /` 简化) **响应**:HTTP 200 + JSON(无论 healthy/degraded/unhealthy 都返回 200,状态在 body 里;这样探活失败和网络故障可区分——网络故障是连不上/超时,服务不健康是 200 但 status 字段非 healthy)。 > 可选:对 unhealthy 同时返回 HTTP 503,便于直接接入按状态码告警的监控平台。这点待定(见决策点)。 **响应示例**: ```json { "service": "DataMacroSync", "status": "healthy", "checked_at": "2026-08-05T11:30:00", "pid": 1234, "started_at": "2026-08-05T11:01:51", "uptime_seconds": 1689, "service_health": { "last_cycle_at": "2026-08-05T11:29:50", "last_cycle_duration_s": 1.05, "cycle_lag_seconds": 10.0, "last_cycle_active": false, "queue": {"pending": 0, "error": 0, "dead": 0, "cleaned": 3829}, "capture_since_start": {"enqueued": 103, "deferred": 0, "aged_out": 0} }, "data_health": { "last_compare": { "at": "2026-08-05T05:08:21", "granularity": "ids", "tables_compared": 81, "mismatch": false, "mismatch_tables": 0 }, "drift_tables": [], "dead_rows_sample": [] } } ``` --- ## 五、实现拆解 ### 5.1 新增 `src/sync/health.py`(核心,约 200 行) - `ServiceState` 数据类:进程内全局状态对象,由 `cycle()` 每轮更新(last_cycle_at / duration / active / 累计计数)。 - `HealthChecker` 类:持有 `ServiceState` + `SyncConfig`,方法 `snapshot()` 返回上面 JSON 对应的 dict。实时查 SQL 用一次性 SqlWriter(查完即关)。 - `run_health_server(state, cfg, host, port)`:起 `http.server.ThreadingHTTPServer`,handler 调 `HealthChecker.snapshot()` 序列化返回。 ### 5.2 改 `src/sync/service.py` - `run()` 入口创建 `ServiceState` 实例,传给 `cycle()`;`cycle()` 结束时更新 state(last_cycle_at 等)。 - `run()` 起健康检查后台线程(`threading.Thread(target=run_health_server, daemon=True)`),主循环照常。 - 新增累计:把每轮 `CaptureStats` 的 enqueued/deferred/aged_out 累加进 `ServiceState`。 ### 5.3 改 `src/sync/config.py` RuntimeConfig 或新增 HealthConfig: ```python class HealthConfig(BaseModel): enabled: bool = True host: str = "0.0.0.0" # 监听地址,内网可访问 port: int = 8421 # 健康检查端口 path: str = "/health" # URL 路径 ``` SyncConfig 增 `health: HealthConfig = HealthConfig()`(默认值,老 config.yaml 无需改动)。 ### 5.4 改 `config.example.yaml` 补 `health` 段示例。 --- ## 六、不改动的地方 | 模块 | 是否改动 | 原因 | |------|---------|------| | capture/apply/cleanup | ❌ | 主循环逻辑零侵入,只由 cycle 更新一个内存 state | | sql_writer 的写方法 | ❌ | 健康检查只用现有的只读查询方法(queue_status_summary 等),不新增写操作 | | NSSM 配置 | ❌ | 单进程内置,端口由进程自己起 | | main.py | 小改 | `incremental --loop` 分支照常走 `service.run`(健康线程在 run 内起) | --- ## 七、改动文件清单 | 文件 | 改动 | |------|------| | `src/sync/health.py` | **新增** ServiceState + HealthChecker + run_health_server | | `src/sync/config.py` | 新增 HealthConfig,SyncConfig 加 health 字段 | | `src/sync/service.py` | run() 起 health 线程 + 传 state;cycle() 更新 state | | `config.example.yaml` | 补 health 段示例 | | `tests/test_health.py` | **新增** 覆盖 status 判定、snapshot 结构、各状态组合 | **零 SQL 改动、零 schema 改动、零新第三方依赖(标准库 http.server)。** --- ## 八、待你确认的决策点 1. **端口** `8421` 是否合适?(避开 114 上已有服务端口) 2. **unhealthy 的 HTTP 状态码**:始终 200(状态在 body)vs unhealthy/degraded 返回 503(便于按码告警)?我倾向后者。 3. **host 监听地址**:`0.0.0.0`(内网/FRP 都可访问)vs `127.0.0.1`(仅本机,需配合 FRP 转发)?我倾向 `0.0.0.0`。 4. **drift_tables 查询范围**:查"启动至今"的异常归档 vs 查"最近 N 小时"?我倾向"启动至今"(量可控,且能发现历史遗留)。 确认后即实施。