feat(health): 被动式健康检查 API(FastAPI,覆盖服务健康+数据健康)

在同步进程内内置 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]。
This commit is contained in:
Misaka_Company
2026-08-05 12:13:14 +08:00
parent 7cef5153e6
commit 03d7bcea39
13 changed files with 1081 additions and 15 deletions

View File

@@ -0,0 +1,164 @@
# 方案:被动式健康检查 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()` 结束时更新 statelast_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` | 新增 HealthConfigSyncConfig 加 health 字段 |
| `src/sync/service.py` | run() 起 health 线程 + 传 statecycle() 更新 state |
| `config.example.yaml` | 补 health 段示例 |
| `tests/test_health.py` | **新增** 覆盖 status 判定、snapshot 结构、各状态组合 |
**零 SQL 改动、零 schema 改动、零新第三方依赖(标准库 http.server**
---
## 八、待你确认的决策点
1. **端口** `8421` 是否合适?(避开 114 上已有服务端口)
2. **unhealthy 的 HTTP 状态码**:始终 200状态在 bodyvs 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 小时"?我倾向"启动至今"(量可控,且能发现历史遗留)。
确认后即实施。

View File

@@ -0,0 +1,215 @@
# 方案 v2FastAPI 健康检查(面向多接口扩展)
> 基于"后期会引入更多运维接口"的前提,采用 FastAPI。
> **与 v1标准库 http.server的核心差异在部署模型**——FastAPI/uvicorn 阻塞主线程,必须重新设计进程结构。本方案先解决这个架构问题,再展开实现。
---
## 一、核心架构决策:同步循环放哪个线程?
FastAPI 的标准运行方式 `uvicorn.run(app)` 会**阻塞主线程**。而现有项目里 `service.run()`(同步循环)是主线程。两者都要"常驻",必须有一个让出主线程。这是用 FastAPI 唯一的硬约束,两条路径:
### 方案 AFastAPI 主线程 + 同步循环后台线程(✅ 推荐)
```
NSSM 启动 → main.py incremental --loop
├─ 主线程: uvicorn.run(app) ← FastAPI 常驻
└─ 后台线程(daemon): service cycle ← 同步循环搬到后台
```
- **优点**FastAPI 在主线程signal handling、uvicorn 内部机制都按官方推荐姿势跑,最稳;未来加接口、加中间件、接 Prometheus 都顺畅。
- **代价**:同步循环从主线程移到后台线程。但 `cycle()` 本身是纯函数式的(每轮独立、用完即关 writer搬到后台线程风险可控——它本来就是为"被反复调用"设计的。
### 方案 B同步循环主线程 + FastAPI 后台线程(❌ 不推荐)
- **缺点**uvicorn 官方明确不推荐嵌入非主线程signal handler、事件循环绑定有边角问题且 NSSM 的进程身份含糊(管的是"同步服务"还是"Web服务"?)。
**采用方案 A。** 下面所有实现都基于 A。
---
## 二、进程结构与生命周期
```mermaid
flowchart TD
NSSM[NSSM 启动 main.py incremental --loop] --> MAIN[主线程]
MAIN -->|"启动顺序"| S1[1. setup_logging]
S1 --> S2[2. 创建 ServiceState 全局状态]
S2 --> S3[3. 启动同步后台线程 daemon]
S3 --> S4[4. uvicorn.run 主线程阻塞]
S3 -.->|daemon 线程| CYC[cycle 循环<br/>每轮更新 ServiceState]
S4 -.->|HTTP 请求到来| APP[FastAPI app]
APP -->|读| S2
APP -->|实时查 SQL| DB[(SQL Server)]
CYC -->|写| S2
CYC -->|读写| AC[(Access)] & DB
style S4 fill:#e3f2fd,stroke:#1976d2
style CYC fill:#fff3e0,stroke:#f57c00
style APP fill:#e8f5e9,stroke:#388e3c
```
关键点:
- **ServiceState 是两线程间的唯一桥梁**同步线程写、HTTP 线程读。用 `threading.Lock` 保护(或用简单的不可变快照替换,避免锁)。
- **daemon 线程**:同步循环线程设 `daemon=True`,主进程退出时自动终止,不留孤儿。
- **uvicorn 退出即进程退出**NSSM stop → uvicorn 收到信号 → 主线程结束 → daemon 同步线程随之终止。
---
## 三、健康检查覆盖的两层(同 v1
### 服务健康Service Health
| 指标 | 来源 |
|------|------|
| `status` | 综合判定 healthy/degraded/unhealthy |
| `pid`, `started_at`, `uptime_seconds` | 进程 |
| `last_cycle_at`, `last_cycle_duration_s` | 内存状态(同步线程每轮更新) |
| `cycle_lag_seconds` | now - last_cycle_at**核心指标** |
| `queue` | 实时查 SyncQueuepending/error/dead/cleaned |
| `capture_since_start` | 内存累计enqueued/deferred/aged_out |
### 数据健康Data Health
| 指标 | 来源 |
|------|------|
| `last_compare` | 解析最近 `compare_ids_*.log` 报告头 |
| `drift_tables` | 实时查 SyncLogArchiveAgedOut、Original≠Processed |
| `dead_rows_sample` | 实时查 SyncQueue dead 行样本 |
**数据健康不跑全量 compare**(太重),靠"最近定时 compare 结果 + 异常痕迹实时查"。全量漂移仍由每日 05:08 compare 兜底。
---
## 四、status 综合判定(同 v1
```
unhealthy : cycle_lag_seconds > 3 × poll_interval (主循环停滞)
degraded : queue.dead > 0 或 queue.error > 0
或 capture.aged_out > 0
或 last_compare.mismatch
healthy : 其余
```
unhealthy/degraded 时 HTTP 返回 **503**healthy 返回 200便于按状态码告警。
---
## 五、FastAPI 应用结构(为后期扩展铺路)
```
src/sync/
web/
__init__.py
app.py # FastAPI 实例 + 全局依赖state/cfg 注入)
deps.py # Depends(): 取 ServiceState / SyncConfig
schemas.py # Pydantic 响应模型HealthResponse 等)
routes/
__init__.py
health.py # GET /health本次实现
# 后期: compare.py (触发/查询核对)、queue.py (死信管理)、metrics.py...
```
- 后期加接口只需在 `routes/` 加文件 + 在 `app.py` `include_router`,结构清晰。
- 用 FastAPI 的 `Depends` 注入共享的 `ServiceState`,避免全局变量。
- 响应用 Pydantic 模型(`schemas.py`),自动生成 `/docs` 给运维查阅。
**本次只实现 `routes/health.py`**,但目录结构一步到位,后期加接口零结构改动。
---
## 六、配置config.py 新增)
```python
class HealthConfig(BaseModel):
enabled: bool = True
host: str = "0.0.0.0" # 监听地址
port: int = 8421 # 健康检查端口
# uvicorn 运行参数
log_level: str = "warning" # uvicorn 自身日志级别(避免刷屏)
class SyncConfig(BaseModel):
# ... 既有字段 ...
health: HealthConfig = HealthConfig() # 默认开启,老 config.yaml 无需改
```
---
## 七、service.py 改造(线程模型变更)
`run()` 从"主线程跑循环"变为"启动后台同步线程 + 主线程跑 uvicorn"
```python
def run(cfg):
setup_logging(cfg.logging)
state = ServiceState(started_at=datetime.now(), pid=os.getpid())
# 后台同步线程
sync_thread = threading.Thread(
target=_sync_loop, args=(cfg, state), daemon=True, name="sync-cycle"
)
sync_thread.start()
# 主线程跑 FastAPI阻塞
if cfg.health.enabled:
from sync.web.app import create_app
import uvicorn
app = create_app(state, cfg)
uvicorn.run(app, host=cfg.health.host, port=cfg.health.port,
log_level=cfg.health.log_level)
else:
# 健康检查关闭:主线程直接跑同步循环(兼容旧行为)
_sync_loop(cfg, state)
def _sync_loop(cfg, state):
"""同步循环(原 run() 的 while True 主体,抽出供后台线程调用)。"""
idle_since = None
while True:
t0 = time.monotonic()
active = cycle(cfg, state) # cycle 增加 state 参数用于更新状态
state.update_cycle(active, duration=time.monotonic()-t0) # 新增
# idle heartbeat 逻辑保留...
time.sleep(cfg.runtime.poll_interval_seconds)
```
`cycle(cfg, state)` 末尾新增 `state.update_cycle(...)`,并把每轮 `CaptureStats` 累计进 state。
---
## 八、依赖更新
`requirements.txt` 增加:
```
fastapi>=0.110.0
uvicorn[standard]>=0.27.0
```
`uvicorn[standard]` 含 uvloop/httptools性能更好纯 Windows 下 uvloop 不可用会自动降级,无影响。)
测试依赖dev不入 requirements`httpx`FastAPI 测试用 `TestClient` 依赖它)。
---
## 九、改动文件清单
| 文件 | 改动 |
|------|------|
| `src/sync/web/app.py` | **新增** create_app + FastAPI 实例 |
| `src/sync/web/deps.py` | **新增** Depends 注入 |
| `src/sync/web/schemas.py` | **新增** Pydantic 响应模型 |
| `src/sync/web/routes/health.py` | **新增** GET /health |
| `src/sync/health.py` | **新增** ServiceState + HealthChecker业务逻辑与 Web 解耦) |
| `src/sync/service.py` | 改 run() 线程模型 + cycle() 更新 state |
| `src/sync/config.py` | 新增 HealthConfig |
| `requirements.txt` | 加 fastapi / uvicorn |
| `config.example.yaml` | 补 health 段示例 |
| `tests/test_health.py` | **新增** 覆盖 status 判定/snapshot/TestClient |
**零 SQL 改动、零 schema 改动。Web 层与业务逻辑HealthChecker解耦后期加接口只动 web/routes/。**
---
## 十、待确认决策点
1. **端口 8421**
2. **unhealthy/degraded 返回 503**(按码告警友好)确认?
3. **host 0.0.0.0**(内网+FRP 可访问)确认?
4. **同步循环放后台 daemon 线程**方案A确认这是与 v1 最大的结构差异。
5. **drift_tables 查询范围**:启动至今 vs 最近 N 小时?倾向启动至今。
确认后实施。