Journal
如何有效率地監控一個龐大的資料群體:怪乙口的 GA Watcher
怪乙口(GEAIKOU) 的卡牌資料是鏡像官方 Grand Archive 的 API 來的。麻煩在於官方什麼時候更新我不會知道,沒有 webhook,我又不想每次都把整包資料拖回來比一遍。GA Watcher 就是專門回答「到底有沒有變」這一個問題的東西。它的做法很省,但省是有代價的,代價藏在一個假設裡。
難題:偵測一個你控制不了的來源是否有變化
鏡像外部資料,最笨的做法是定時把全部資料拉回來、跟本地逐筆比對。資料一多,這件事又慢又浪費——大部分時候什麼都沒變,卻付了全量傳輸的成本(卡片每一系列只要更新就是上百張…)。
設計了一個自動化偵測和判斷的工具 GA Watcher,GA Watcher 不抓抓全部的資料做比對,改抓一份輕量指紋。每次檢查只並行拉幾個比較輕量的訊號:changelog 的 uuid 清單、set 清單、featured set 的 uuid、卡片與版本的總數(total_cards / total_editions),以及每個欄位的 term 數量。把這些組成一份 snapshot 文件存起來。
判斷「有沒有變」,就是拿當前 snapshot 去雙向比對:一邊比上一份 snapshot(來源端有沒有動),一邊比本地資料庫(我這邊有沒有漏)。只有在偵測到差異時,才真的去把完整的 terms 拉回來做同步。平常的檢查成本,就只是那幾個計數和清單。
省下來的成本,藏在一個假設裡
snapshot 為了輕量,term 只存數量,不存內容。可是當數量從 40 變成 42,我還想知道「新增的是哪兩個」。手上只有數量,怎麼還原新增的值?當時的做法是:把這次完整拉回來的值陣列,用 slice(0, prevCount) 切出「前 prevCount 個」當作舊值,剩下的當新增:
const prevValues = allValues?.slice(0, prevCount) ?? []
const newValues = allValues?.filter(v => !prevValues.includes(v)) ?? []
這一步假設官方 API 回傳的順序是穩定、而且只在後面追加的:新的永遠加在尾巴、舊的順序不動。這個假設現在成立,但沒人保證它一直成立。哪天官方改了排序、或在中間插一個值,這段就會把「新增的 term」認錯。它能用,純粹是因為官方到目前為止都乖乖 append——我等於押了對方的行為模式,所以程式碼裡那行也標了註解,提醒未來的自己這裡有個賭注。
其他該有的保護
除了指紋差異,還有幾個讓它跑得住的細節:
- single-flight 鎖:用一個靜態旗標擋住排程觸發和手動觸發同時跑,避免兩次檢查交疊。
- 冪等寫入:同步走
bulkWrite搭$setOnInsert,重複執行不會產生重複資料。 - 並行拉取:那幾個指紋 endpoint 用
Promise.all一次打完,檢查本身夠快才敢排得密。
值不值得這樣省
鏡像一個你控制不了的來源,就是在找「有沒有變」的訊號。全量比對最保險也最貴;指紋差異便宜,但它站在一組對方行為的假設上(風險會在於,對方把結構改變或是計數方式調整了)。這個取捨我到現在還覺得划算,只是那個 slice 的假設,是整個設計裡唯一會哪天突然壞、又不容易當場看出來的地方,所以我寧可把它寫得囉唆一點。