在同步进程内内置 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]。
216 lines
8.3 KiB
Markdown
216 lines
8.3 KiB
Markdown
# 方案 v2:FastAPI 健康检查(面向多接口扩展)
|
||
|
||
> 基于"后期会引入更多运维接口"的前提,采用 FastAPI。
|
||
> **与 v1(标准库 http.server)的核心差异在部署模型**——FastAPI/uvicorn 阻塞主线程,必须重新设计进程结构。本方案先解决这个架构问题,再展开实现。
|
||
|
||
---
|
||
|
||
## 一、核心架构决策:同步循环放哪个线程?
|
||
|
||
FastAPI 的标准运行方式 `uvicorn.run(app)` 会**阻塞主线程**。而现有项目里 `service.run()`(同步循环)是主线程。两者都要"常驻",必须有一个让出主线程。这是用 FastAPI 唯一的硬约束,两条路径:
|
||
|
||
### 方案 A:FastAPI 主线程 + 同步循环后台线程(✅ 推荐)
|
||
|
||
```
|
||
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` | 实时查 SyncQueue(pending/error/dead/cleaned) |
|
||
| `capture_since_start` | 内存累计(enqueued/deferred/aged_out) |
|
||
|
||
### 数据健康(Data Health)
|
||
| 指标 | 来源 |
|
||
|------|------|
|
||
| `last_compare` | 解析最近 `compare_ids_*.log` 报告头 |
|
||
| `drift_tables` | 实时查 SyncLogArchive(AgedOut、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 小时?倾向启动至今。
|
||
|
||
确认后实施。
|