数据自愈BUG Cursor 1
数据自愈启动缓慢与修复无效:根因与修复方案
现象(来自日志)
- 每次启动都对多个交易对(如 EIGEN、MOVE、FARTCOIN 等)做数据自愈。
- 部分对子始终显示「数量不足(2/3)」,连续 3 轮修复,每轮都「Level 1修复完成: 成功=1, 实际写入 1 条记录」。
- 3 轮后仍为「自愈结果: degraded | D级 (66.7%) | 数据量: 2 条」——修复没有提升可见条数。
完整因果链:输入 → 状态变化 → 调用路径 → 出错点 → 根因
1. 输入(Trigger)
| 项目 | 说明 |
|---|---|
| 入口 | 实时 K 线服务实例化:RealtimeKlineServiceBase.__init__()(src/services/realtime_kline_service_base.py L118) |
| 触发时机 | 构造时第 9.6 步执行 _run_data_healing()(L192),在加载配对缓存、构建订阅列表之前 |
| 输入数据 | heal_pairs = 候选配对 ∩ DB 中已有 zscore 的配对(L1808–1811);repair_count = ceil(144×5/240) = 3(L1846–1849);每个配对 (symbol, base_symbol) 与 repair_timeframe='4h' |
2. 状态变化(State Transitions)
| 阶段 | 状态 | 位置/条件 |
|---|---|---|
| S0 | DB 中某 (symbol, base_symbol) 在「某时间窗内」仅有 2 条 zscore_4h 记录(如 kline_time = T1, T2,且 T1 < T2) | 表 analysis_results |
| S1 | 加载得到 records = 2 条,按 kline_time 升序 |
_load_zscore_history(3) 返回,orchestrator L159 |
| S2 | 诊断:连续、无缺口、数量不足 → shortfall_targets = [earliest - 4h] = [T0] |
_diagnose() L262–263,L413–422 |
| S3 | 修复写入 1 条 (kline_time=T0) 到 analysis_results |
RepairExecutor.repair() → _repair_from_klines → _insert_records,L184–205 |
| S4 | 重载再次调用 _load_zscore_history(3),仍只返回 2 条(不包含 T0) |
L205,同一套 SQL 与时间窗 |
| S5 | 下一轮诊断仍为「数量不足(2/3)」,shortfall_targets 仍含 T0,循环重复 | L162–205,最多 3 轮 |
| S6 | 最终 records 仍为 2 条,_final_assessment 得到 degraded / 数据量 2 条 |
L207–209,L334–348 |
3. 调用路径(Call Path)
RealtimeKlineServiceBase.__init__()
└─ _run_data_healing() [realtime_kline_service_base.py L1766]
└─ for (symbol, base_symbol) in heal_pairs: [L1831]
└─ DataHealingOrchestrator(...).heal_and_prepare(required_count=3) [L1833, L1851]
├─ Phase 1: records = _load_zscore_history(required_count) [orchestrator.py L159]
│ └─ 循环 hours in [12,16,24]: WHERE kline_time >= NOW()-hours, ORDER BY kline_time DESC
│ break 当 len(rows)>=3;否则 records = rows(可能 2 条) [L458–489]
│
└─ for iteration in 1..max_iterations: [L162]
├─ diagnosis = _diagnose(records, required_count) [L166]
│ ├─ checker.check_continuity(records, 3) [L252–253]
│ ├─ 若 连续 且 无 gap 且 len(records)<3 → shortfall_targets = _generate_shortfall_targets(...) [L261–263]
│ │ └─ [earliest_time - i*interval for i in 1..need_count] [L419–422]
│ └─ is_healthy = False
│
├─ all_targets = _merge_repair_targets(diagnosis) [L176]
├─ repaired_count = executor.repair(all_targets, ...) [L184–189]
│ └─ RepairExecutor.repair → _repair_from_klines → _insert_records [repair_executor.py L54–86, L174–176]
│
└─ records = _load_zscore_history(required_count) [L205] ← 重载仍用同一时间窗
4. 出错点(Failure Point)
| 出错点 | 位置 | 错误行为 |
|---|---|---|
| F1 | _load_zscore_history 的 WHERE 与 break 条件(orchestrator.py L459–484) |
取数语义是「在 NOW()−N 小时以内的、按 kline_time DESC 的前 required_count 条」。补写的时间点 T0 = earliest−4h 早于现有两条,若 T0 落在当前尝试的窗口(如 12h)之外,则重载时该条不会被 SELECT 返回。 |
| F2 | 同上,重载与首次加载使用完全相同的 time_ranges_hours 与 SQL(L458–476) |
修复后不扩大窗口、也不改为「按条数取最近 N 条」,因此刚写入的 T0 若已在 12h 窗外,重载结果集不变,仍为 2 条。 |
| F3 | _generate_shortfall_targets 与 _load_zscore_history 的语义不一致(L413–422 vs L437–489) |
不足时补的是「更早时间」;加载看的是「最近 N 小时内」。二者未对齐,导致「补了也看不到」。 |
5. 根因(Root Cause)
- 设计根因:加载层采用「时间窗内的最近数据」语义,而数量不足修复采用「向更早时间扩展」语义;两者未统一,修复写入的条在重载的过滤条件下被排除。
- 实现根因:
_load_zscore_history仅用kline_time >= NOW() - make_interval(hours => %s)限定范围,且重载时未根据本次修复目标扩展窗口或改为按「最近 N 条」取数,导致补写对重载不可见,诊断循环无法收敛。
flowchart TB
subgraph input [输入]
I1["服务 __init__"]
I2["heal_pairs × repair_count=3"]
end
subgraph state [状态变化]
S1["加载 → records=2 条"]
S2["诊断 → shortfall=[T0]"]
S3["修复 → 写入 T0"]
S4["重载 → 仍 2 条"]
end
subgraph path [调用路径]
P1["_run_data_healing"]
P2["heal_and_prepare"]
P3["_load_zscore_history / _diagnose / repair / _load_zscore_history"]
end
subgraph fail [出错点]
F1["_load 用 NOW()-Nh 窗"]
F2["重载不扩窗、不按条数"]
F3["补早 vs 取窗内 语义不一致"]
end
subgraph root [根因]
R1["加载「时间窗内」与修复「向更早扩展」未统一"]
R2["重载未包含补写时间或未按最近 N 条取数"]
end
I1 --> I2
I2 --> P1
P1 --> P2
P2 --> P3
P3 --> S1
S1 --> S2
S2 --> S3
S3 --> S4
S4 --> F1
F1 --> F2
F2 --> F3
F3 --> R1
R1 --> R2
根因分析(简述)
1. 为何“修复”后重载仍只有 2 条?
加载逻辑在 src/utils/data_healing/orchestrator.py 的 _load_zscore_history 中:
- 用相对当前时间的时间窗加载:
kline_time >= NOW() - make_interval(hours => %s),依次尝试 12h、16h、24h。 - 取数语义是:在「当前时间往前 N 小时」的窗口内取数据,再按
kline_time DESC取前required_count条。
数量不足时的修复逻辑在 _generate_shortfall_targets:
- 在当前已有数据的最早时间之前补点:
earliest_time - i * interval(向更早扩展)。 - 因此补写的是比现有数据更早的 kline_time(例如日志里的
2026-02-22 00:00:00)。
矛盾在于:
- 若现有 2 条在「较久以前」(例如 04:00、08:00),补写的 00:00 更早。
- 重载时若先用 12h 窗:
NOW() - 12h之后的才被选中,00:00 可能落在 12h 窗外,查询结果里就看不到这条新记录。 - 于是重载仍然只得到原来的 2 条 → 下一轮诊断 again「数量不足」→ 再修同一时间点 → 形成无效多轮修复,且最终「数据量」仍为 2。
因此:修复是写入了的,但“按时间窗重载”的语义与“向更早时间补点”的语义不一致,导致补写条数在重载时被过滤掉。
2. 为何每次启动都要做大量修复?
- 自愈对所有「候选配对 ∩ DB 中已有 zscore 的配对」执行:
realtime_kline_service_base.py的_run_data_healing中heal_pairs为上述交集,且串行对每个 (symbol, base_symbol) 执行heal_and_prepare。 - 因此:
- 配对数量多 → 总耗时长。
- 凡在「时间窗内」不足 3 条的对子都会触发数量不足修复;又因上面 1 的 bug,部分对子会无意义地跑满 3 轮仍 2 条,进一步拉长启动时间。
3. 小结(因果链)
flowchart LR
subgraph load [加载阶段]
A["按 NOW()-Nh 窗加载"]
B["得到 2 条"]
end
subgraph repair [修复阶段]
C["向更早补 1 条"]
D["写入 00:00 等更早时间"]
end
subgraph reload [重载阶段]
E["再次按 NOW()-12h 等窗"]
F["00:00 在窗外 → 仍 2 条"]
end
A --> B
B --> C
C --> D
D --> E
E --> F
F --> B
- 直接原因:重载使用「固定时间窗」,补写使用「向更早扩展」,二者不一致,导致补写对重载不可见。
- 放大因素:对所有有数据的配对做自愈且串行,且无效轮次拉长单对耗时。
修复方案(建议)
方案 A:重载语义改为「最近 N 条」(推荐)
- 在
_load_zscore_history中,不再用「NOW() - N 小时」作为主语义,改为:- 按
(symbol, base_symbol)查询analysis_results,ORDER BY kline_time DESC LIMIT required_count(或略大一点如required_count + 2再取前required_count),保证取到的是按时间最近的 required_count 条,与「是否在 12h 内」解耦。
- 按
- 可选:仍保留一个最大时间范围(例如只考虑最近 7 天),避免扫到过旧数据:
WHERE ... AND kline_time >= NOW() - interval '7 days' ORDER BY kline_time DESC LIMIT required_count。 - 这样,只要补写成功(同 symbol/base_symbol),重载时就会把这「最近 N 条」包含进去,修复轮次即可收敛,避免同一对子无意义地跑满 3 轮。
涉及文件:src/utils/data_healing/orchestrator.py 中 _load_zscore_history 的 SQL 与循环逻辑。
方案 B:若保留时间窗,则重载时扩大窗口以包含本次修复范围
- 在修复后重载时,至少使用能覆盖「本次 shortfall 目标时间」的窗口:
- 例如用
min(shortfall_targets)与NOW()计算所需 hours,或直接使用required_count * interval_minutes对应的小时数(并乘一个系数如 1.5)作为单次重载的窗口。
- 例如用
- 这样重载能看到刚补的较早时间点,但逻辑比「按最近 N 条」更绕,且仍需注意与「新鲜度」判断的配合。
方案 C:减少启动时自愈范围/轮次(辅助)
- 仅对“明显不足”的配对自愈:例如仅当「当前窗内条数 < 某阈值(如 2)」或「完整度 < 某百分比」时才加入
heal_pairs或才执行多轮修复,避免对已经 3 条的对子再做一遍检查。 - 限制每对最大修复轮次:若检测到「本轮修复目标与上一轮完全相同」(例如 shortfall_targets 未变),可提前终止该对的自愈,避免无效 3 轮。
- 可选:对自愈做并行或批量(需注意 DB 连接与超时配置),减少总墙钟时间。
以上 A 解决「修复后仍 2 条」的根本问题;C 作为补充可明显缩短启动时间。
建议实施顺序
- 先做方案 A:改
_load_zscore_history为「最近 N 条」语义(可加最大时间范围),验证同一对子在 1 轮修复后重载即变为 3 条、不再出现 3 轮仍 2 条。 - 再视需要做方案 C:缩小启动时自愈范围或提前终止无效轮次,进一步缩短启动时间。
未改代码前,可先通过日志确认:修复目标时间(如 2026-02-22 00:00:00)是否总是早于 NOW() - 12h,以验证「时间窗排除补写点」的结论。