在同步进程内内置 FastAPI/uvicorn HTTP endpoint (GET /health),返回结构化 健康快照,便于运维/监控被动探活: - 服务健康:cycle_lag(核心指标,超过 3×poll_interval 判 unhealthy)、 队列状态(pending/error/dead/cleaned 实时查)、capture 累计(enqueued/ deferred/aged_out)、最近 cycle 时间/耗时/错误。 - 数据健康:解析最近一次定时 compare_ids 报告(不跑全量 compare 太重)、 实时查 SyncLogArchive 异常痕迹(AgedOut/降级)、SyncQueue dead 行样本。 status 三态:healthy(200) / degraded(503, 有dead/error/aged_out/compare 不一致) / unhealthy(503, 主循环停滞)。状态码映射便于按码告警。 线程模型:uvicorn 占主线程,capture/apply/cleanup 循环跑后台 daemon 线程, NSSM 停服务时主线程退出、daemon 自动终止。health.enabled=false 时退化为 旧行为(同步循环占主线程)。Web 层(routes/)与业务逻辑(HealthChecker) 解耦,后期加运维接口(compare触发/死信管理/metrics)零结构改动。 零 SQL/零 schema 改动。新增依赖 fastapi/uvicorn[standard]。
165 lines
7.6 KiB
Markdown
165 lines
7.6 KiB
Markdown
# 方案:被动式健康检查 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 小时"?我倾向"启动至今"(量可控,且能发现历史遗留)。
|
||
|
||
确认后即实施。
|