---
title: 数据对账(设计)
description: 独立核对 PAC 侧数据与各宿主源是否一致 —— 同闸口径、去重患者数、多宿主 provider、复用企微日报。
icon: Scale
---

<Callout type="warn">
**本文是设计文档,尚未实现**。记录对账机制的口径与方案,供评审 / 落地参照。
</Callout>

## 为什么要独立对账

现有的几层防线都是**单向补漏 / 水位监控**,抓不到"该有的数据到底在不在":

| 现有机制 | 作用 | 抓不到 |
|---|---|---|
| 48h 回看窗 | 容忍 DW 落库迟到 | 超 48h / 时间戳不跳的漏拉 |
| 主档反向强拉 | 补 cohort 的主档 | 非主档信号的积压 |
| ReconcileOrchestrator | 时间窗重拉补漏(手动) | 不告诉你"差了多少" |
| DwLagMonitor | cursor 距今 >24/48h 告警 | 只看水位新旧,不看行在不在 |

尤其前述三个已知漏点 —— [无 cursor 表积压](/docs/ingestion#21-增量-cohort-与无-cursor-表)、`updatedAt` 不跳漏拉、事务/fact 不一致 —— **都是补漏和水位监控抓不到的**。缺的是一个**双向数量核对**:定期比"源侧应有 vs PAC 实有",差超阈值告警。

## 三个难点与解法

对账看似简单(两边 count 一比),实则有三个坑,逐个解:

### 难点 1:多宿主 —— 源计数从哪来因宿主而异

PAC 是多宿主,不能绑死 DW —— 但对账**只覆盖 PAC 能直连源库的宿主**:

| 宿主类型 | 例 | 能直连源? | 对账 |
|---|---|---|---|
| **pull**(直连) | jvs-dw(ClickHouse) | ✅ | ✅ PAC 主动查源计数 |
| **push**(无直连) | friday(SaaS) | ❌ PAC 够不到其库 | **不对账** |

**push 宿主不对账**(定):PAC 够不到源库,唯一办法是宿主自报,但那要它维护一套"按 PAC 口径过滤后的计数",负担与可信度都不划算 —— 直接排除。push 宿主的数据质量靠既有的 push 路径告警(疑似改表 / 失败率超阈)兜。

抽象成:**PAC 只负责「拿到 `(scope, sourceCount)` → 对比 `pacCount` → 报 diff」;`sourceCount` 由每个可直连宿主的 provider 供给**(查询源库)。将来任何可直连的新宿主接入只需实现 provider,对账主逻辑不动。

### 难点 2:摄入是筛选子集 —— 不能数原表全量

PAC 摄入的**不是原表全量**,有大量故意筛选(结算 `status IN (1,3)`、瑞泰不摄、cohort 限有就诊、诊所范围…)。拿原表 count 比,diff 里全是"故意的差",没法用。

**解法:源计数复用摄入查询本身,只换投影。** manifest 的 `sql_source` 查询**就是"PAC 应该有什么"的定义**,把 `SELECT *` 换成 `count(distinct patient)`:

```sql
sourceCount = count(distinct patient) FROM ( «摄入用的那条 sql_source 查询,原样» )
```

两边都过了同一道筛选闸,diff 只剩**真漂移**。且筛选口径**零重复维护** —— 改了 `sql_source` 的 WHERE,对账自动跟着变(用的同一条查询),不会两份逻辑慢慢跑偏。

(push 宿主不对账,见难点 1,不涉及此。)

### 难点 3:fan-out 噪声 —— 不能比行数

一条源行可能拆成 N 条 fact(诊断+治疗+建议),加上版本流,行数根本不可比。

**解法:只比「去重患者数」,不比行数。** 干净、可比、且直接对应业务关切("多少患者的 X 信号 PAC 没跟上")。

## 对账口径:同闸 + 去重患者数 + 两层

只看总患者数**不够** —— 无 cursor 表积压时患者还在 PAC 里(只是他的回访/咨询行旧了),总量层 diff=0 会漏检。故分两层:

| 层 | sourceCount | pacCount | 抓什么 |
|---|---|---|---|
| **总量** | cohort 查询的 `count(distinct patient)` | PAC 该 host 的 `count(distinct patient)` | 整体漏人 |
| **信号** | 各资源 sql_source(含各自过滤)的 `count(distinct patient)` | PAC 里有对应 `fact_type` 的 `count(distinct patient)` | 某类信号积压(定位到无 cursor 表) |

**信号层示例**:`DW 有影像分析的患者 5.0 万 vs PAC 有 image_record 的患者 4.8 万,diff 2000` → 既可比又定位到"影像信号积压"。

**覆盖范围:全 fact 类型**(定)。不挑重点,每类都对(诊断 / 治疗 / 影像 / 回访 / 咨询 / 结算 / 转介…),一眼看全哪条信号在漂;性能由下节"每日一次 + 秒级 count"兜住,全覆盖不构成负担。

## 性能

对账要跑一堆 `count(distinct patient)`,得算清楚成本再上:

- **频率:每日一次**(不是每轮增量)。跟健康日报同一时刻(09:00 沪、DW 刷新后)跑一次即可 —— 漂移是慢变量,日粒度足够。
- **DW 侧**:每类信号一条 `count(distinct patient)`,全覆盖约 10 条。ClickHouse 的 `count(distinct)` 对 13M 行通常秒级;精度不敏感处可用 `uniq`(HyperLogLog,~0.5% 误差)换更低开销,要精确用 `uniqExact`。**上线前实测这 10 条的总耗时**,确认 < 分钟级。
- **PAC 侧**:`patient_facts` 按 `fact_type` 分组 `count(distinct patient_id)`,一条走索引的聚合查询,廉价。
- **不常驻**:对账是日报里的一个步骤,跑完即释放,无常驻连接 / 内存。

每日一次 + 秒级查询 → 全覆盖的性能开销可忽略,不影响在线摄入 / 重算。

## 基线:压掉"已知的正常差"

有些差**本来就该存在**,不压会天天误报 → 告警疲劳:

- 瑞泰品牌不摄(恒差)
- 无 cursor 表短期积压(会自愈)
- 源侧脏数据 / 时间窗边界

**基线放 manifest 配置**(每宿主声明"哪些 scope 差多少以内算正常"),只有**偏离基线**才告警,不是有差就报。

```yaml
# data/<host>/manifest.yaml(拟)
reconcile:
  baseline:
    total_patient:        { tolerance_pct: 2 }      # 总量差 2% 内正常
    fact.image_record:    { tolerance_pct: 5 }      # 影像积压容忍高些
    fact.payment_record:  { tolerance_abs: 200 }
```

## 汇报:复用企微,折进每日健康日报

**不新起消息流** —— 复用 `AlertService`(企微群机器人),把「数据对账」折进已有的 [每日健康日报](/docs/monitoring)(每天 09:00 沪、DW 刷新后跑)。对账项超基线时,把日报整体级别顶成 🟡/🔴(现有逻辑:取最差项)。

```
每日 09:00 健康日报(已有)增加一段「数据对账」:
  for each host:
    for each scope in [总量患者, 各 fact 类型患者]:
      sourceCount = provider(host, scope)         # pull 查询 / push 自报
      pacCount    = PAC 同口径 count(distinct patient)
      diff, diffPct
      超 manifest.reconcile.baseline → 标 🟡/🔴
  推企微(复用 AlertService,超基线才顶级别)
```

## 分期落地

**先只观察企微,不建表**(定)。

| 版本 | 做什么 | 不做 |
|---|---|---|
| **v1(先做)** | 同闸口径算 diff(全 fact 类型)+ 基线压噪 + 折进健康日报推企微 | **不建表**;趋势靠每日日报肉眼比 |
| v2(以后) | 若肉眼比不够,再建 `reconcile_checks` 表存快照 → 自动趋势 + 审计历史 | — |

先跑 v1 观察企微:口径准不准、会不会噪声、diff 稳不稳。够用就不上表。

## 已定 / 待决

**已定**:push 宿主(无直连)不对账 · 全 fact 类型覆盖 · 每日一次跑 · 先不建表只观察企微。

**待决**:基线初值怎么定 —— 建议**先不设容忍、原样把 diff 报进日报观察一两周**,摸清各 scope 的"正常差"后再回填 `manifest.reconcile.baseline`,避免拍脑袋定阈值。
