9.9 KiB
Context
目前 6 個服務各自處理大查詢,缺乏統一保護:
| 服務 | 查詢類型 | 現有保護 | 缺口 |
|---|---|---|---|
| reject-history | 日期 + 工單/Lot/GD 展開 | L1+L2 快取、read_sql_df_slow |
無記憶體守衛、limit=999999999、缺分塊查詢 |
| hold-history | 日期 | L1+L2 快取、read_sql_df_slow |
無記憶體守衛、缺時間分塊 |
| resource-history | 日期 + 設備 ID | L1+L2 快取、1000 筆分批 | 無記憶體守衛 |
| mid-section-defect | 日期 → 偵測 → 族譜 → 上游 | Redis 快取、EventFetcher 分批 | 無偵測數量上限 |
| job-query | 日期 + 設備 ID | 1000 筆分批、read_sql_df_slow |
無結果快取、缺時間分塊 |
| query-tool | 多種 resolver → container ID | 輸入筆數限制、resolve route 短 TTL 快取、EventFetcher 快取 | 多數查詢仍走 read_sql_df(55s timeout)、缺統一分塊編排 |
參考實作:
EventFetcher:batch 1000 + ThreadPoolExecutor(2) +read_sql_df_slow_iterstreaming + Redis 快取 — 已是最佳實作LineageEngine:batch 1000 + depth limit 20 — 族譜專用引擎
目標:建立 BatchQueryEngine 共用模組,任何服務接入即獲得完整保護。
Goals / Non-Goals
Goals:
- 統一 parquet-in-Redis 存取為共用模組(消除 3 處重複)
- 提供時間範圍分解(長日期 → ~31 天月份區間)
- 提供 ID 批次分解(工單/Lot/GD 展開後的大量 container ID → 1000 筆一批)
- 記憶體守衛:每個 chunk 結果檢查 memory_usage,超過閾值中止
- 結果筆數限制:可配置上限,超過時截斷並標記
- 受控並行:預設循序、可選並行、semaphore 感知
- Redis 分塊快取 + 部分命中
- 統一使用
read_sql_df_slow(300 秒 dedicated connection) - 定義 query_hash 與 chunk 邊界語意,避免跨服務行為不一致
- 定義 chunk cache 與服務 L1/L2 dataset cache 互動規則
Non-Goals:
- 不修改 SQL 語句本身
- 不引入新的外部依賴
- 不改變前端 API 介面(前端無感知)
- 不替換 EventFetcher / LineageEngine(它們已各自最佳化,引擎提供可選接入點)
- 不改變 trace_job_service 的 RQ 非同步架構
Decisions
Decision 1: 提取 redis_df_store.py 共用模組
選擇:從 reject/hold/resource_dataset_cache 提取相同的 _redis_store_df / _redis_load_df 到 src/mes_dashboard/core/redis_df_store.py。
替代方案:(A) 保持各自複製 → 已有 3 處重複,維護困難。
理由:parquet-in-Redis 是 DataFrame 序列化工具,與快取策略(TTL、LRU)屬不同層次。
Decision 2: BatchQueryEngine 作為工具類而非基底類別
選擇:提供獨立函式(decompose_by_time_range、decompose_by_ids、execute_plan、merge_chunks),各服務按需調用。
替代方案:(A) 抽象基底類別 BaseDatasetCache → 三個 dataset cache 差異大(SQL、policy filter、衍生計算),強制繼承會過度耦合。
理由:工具類模式讓服務保持現有結構,僅在主查詢路徑決定是否啟用分解。閾值以下的查詢完全不經過引擎。
Decision 3: 預設循序、可選並行、semaphore 感知
選擇:execute_plan(parallel=1) 預設循序。實際並行上限 = min(requested, semaphore_available - 1)。
替代方案:(A) 預設並行 → 可能耗盡 semaphore;(B) 完全不並行 → 失去速度。
理由:Oracle 連線稀缺(Production 預設 DB_SLOW_MAX_CONCURRENT=5,Development 常見為 3)。reject_dataset_cache 查詢最重可設 parallel=2,其他預設循序最安全。
Decision 4: 記憶體守衛 + 結果筆數限制
選擇:每個 chunk 查詢後檢查 df.memory_usage(deep=True).sum(),超過 BATCH_CHUNK_MAX_MEMORY_MB(預設 256MB)時中止該 chunk 並標記失敗。同時提供 max_rows_per_chunk 參數,在 SQL 中加入 FETCH FIRST N ROWS ONLY。
替代方案:(A) 無限制 → 現狀,OOM 風險高;(B) 全域限制 → 不夠靈活。
理由:chunk 級別的記憶體守衛是最後一道防線。分解後每個 chunk 的日期/ID 範圍已大幅縮小,記憶體超限通常代表異常資料,應中止而非繼續。
Decision 5: 分塊快取 + 部分命中
選擇:Redis 鍵 batch:{prefix}:{hash}:chunk:{idx},每個 chunk 獨立 SETEX。
替代方案:(A) 只快取最終結果 → 無法部分命中。
理由:使用者常見操作是「先查 1-6 月,再查 1-8 月」。分塊快取讓前 6 個月直接複用,只查 7-8 月。
Decision 6: 引擎路徑統一使用 slow-query 路徑(且不佔用主 pool)
選擇:所有經過引擎的查詢統一使用 slow-query 路徑(300s timeout, semaphore 控制);未經引擎的既有短查詢路徑保持原狀。 慢查詢執行策略採兩層:
- 主路徑:使用既有獨立
SLOW POOL(小容量)做 checkout/checkin。 - fallback:當 SLOW POOL 不可用時,降級為 slow direct connection。
替代方案:
(A) 引擎路徑混用 read_sql_df(主 pool, 55s timeout)→ 長查詢高超時風險且會壓縮一般 API 吞吐。
(B) 慢查詢直接共用主 pool → 高峰時造成 pool 爭用與整體延遲放大。
理由:經過引擎的查詢本身就是「已知可能很慢」的查詢。慢查詢與主 pool 隔離可避免互相影響;SLOW POOL 讓連線重用與隔離同時成立,fallback direct connection 保障可用性。
Decision 7: 部分失敗處理
選擇:某個 chunk 失敗時記錄錯誤、繼續剩餘 chunk。merge_chunks() 回傳成功部分,metadata 標記 has_partial_failure=True。
替代方案:(A) 全部回滾 → 已成功的 chunk 浪費。
理由:歷史報表場景下,部分結果比完全失敗更有價值。metadata 標記讓服務可決定是否警告使用者。
Decision 8: Chunk Cache 與服務 L1/L2 Dataset Cache 互動
選擇:先讀 chunk cache(Redis)組裝結果;組裝後回填既有 service dataset cache(L1 process + L2 Redis)以維持現有 /view 路徑與 query_id 行為。
替代方案:(A) 只使用 chunk cache,不回填 service cache → 現有 view/query_id 流程失效或重複查詢。
理由:需要兼容既有 two-phase dataset API(primary query + cached view),chunk cache 是引擎層優化,不應破壞服務層介面。
Decision 9: query_hash 規格
選擇:query_hash 使用 canonical JSON(sorted keys、穩定 list 順序、字串正規化)後 SHA-256 前 16 碼;hash 僅包含會影響原始資料集合的參數(不含純前端呈現參數)。
替代方案:(A) 每服務自由實作 hash → 跨服務不可預測且難除錯。
理由:chunk key、progress key、merge key 需可重現,否則無法保證 cache 命中與部分重用。
Decision 10: 時間分解邊界語意
選擇:採閉區間 chunk [chunk_start, chunk_end];下一段從 chunk_end + 1 day 開始;最後一段可小於 grain_days;輸入日期以服務既有時區/日界線為準,不在引擎層重新解釋時區。
替代方案:(A) 半開區間或依月份動態切割但不定義邊界 → 容易重疊或漏資料。
理由:邊界語意固定後,merge 去重、統計一致性與測試可驗證性都會提升。
Decision 11: 大結果採 Parquet 落地,Redis 僅保留 metadata/熱快取
選擇:對長查詢(尤其 reject-history)引入 spill-to-disk:
- chunk 查詢與 chunk cache 保持現行(Redis,短 TTL)
- merge 後若結果超過門檻(rows / memory / serialized size),寫入 Parquet 至本機 spool 目錄
- Redis 僅保存 metadata(query_id, file_path, row_count, schema_hash, created_at, expires_at)
/view//export優先透過 metadata 讀取 parquet;metadata 不存在時回退現行 cache 行為- 背景清理器定期移除過期 parquet 與孤兒 metadata
替代方案:
(A) Redis 全量承載所有結果(現況)→ 記憶體壓力高,易引發 lock timeout/OOM 連鎖
(B) 直接落 DB(例如 SQLite)→ 寫入鎖衝突與維運複雜度高(目前已有 database is locked 觀察)
理由:Redis 是記憶體快取,不適合長時間承載大結果;Parquet 落地可把大結果轉移到磁碟,降低 worker/Redis 記憶體峰值。
Risks / Trade-offs
[Redis 記憶體增長] → 分塊快取增加 key 數量(365 天 ≈ 12 個 chunk key)。 → 緩解:TTL 自動過期(900s);chunk 結果經 parquet 壓縮(通常 10:1 壓縮比)。
[Semaphore 爭用] → 並行 chunk 消耗更多 permit。 → 緩解:感知可用數量,不足時自動降級循序。預設 parallel=1。
[時間分解後的資料一致性] → 不同月份 chunk 在不同時間點查詢。 → 緩解:歷史報表資料更新頻率低(日級),短窗口內變動極低。可接受。
[遷移風險] → 先修改 3 個 dataset cache,再擴展至其他服務,整體範圍仍大。 → 緩解:閾值控制(短查詢不經過引擎)+ P0/P1/P2/P3 分階段導入 + 每階段獨立驗證。
[磁碟 I/O 與容量壓力] → Parquet 落地會增加磁碟讀寫,若清理策略失效可能累積大量檔案。 → 緩解:設定 spool 容量上限、TTL 清理、啟動時 orphan 掃描、超限時回退到「不落地僅回應摘要」保護模式。
[Stale metadata / orphan file] → Redis metadata 與實體檔案可能不一致。 → 緩解:讀取前校驗檔案存在與 schema hash;不一致時自動失效 metadata 並記錄告警。
Open Questions
mid_section_defect_service的 4 階段管線(偵測 → 族譜 → 上游歷史 → 歸因)中,哪些階段適合接入引擎?偵測查詢可日期分解,但族譜/上游已透過 EventFetcher 處理。query_tool_service有 15+ 種查詢類型,是否全部接入還是只處理最易超時的(split_merge_history、equipment_period)?