假的零:當缺值該存 NULL、不該存 0
一張 IG 分析圖在粉絲數持續上升時卻歸零躺平。問題不是沒資料——是把該存 NULL 的地方,存成了假的 0。
背景
我替一個媒體集團維護社群分析 pipeline:Facebook、Instagram、Threads、YouTube 每天早上拉進 BigQuery,用 dbt 建模,再呈現在編輯與行銷團隊真的會打開的 Streamlit dashboard 上。其中一張 IG 圖是每日淨追蹤——新增追蹤減掉取消追蹤, 淨增的日子畫綠柱、淨減畫紅柱。
某天下午有人問我那張圖怎麼像死掉了。從大約 06-12 之後就一路躺平——每根柱子都
剛好坐在零上,好像這個帳號突然完全不再增減粉絲了。
破綻
我第一個念頭是最無聊的那個——最近沒資料。圖表末端躺平通常就是這個原因。但這次說不通, 因為同一段期間這個帳號的粉絲數漲了 32。總量在往上爬,那每天的淨變化就不可能天天是 零——淨變化本來就是讓總量移動的東西。
單獨一天是零,還好。連續兩週完美躺平的零、旁邊還擺著一條在漲的粉絲線,這就不是安靜的 帳號,是 bug。於是我去把它找出來。
根本原因
Instagram 的 follows_and_unfollows insight 不會直接回一個數字。你要帶
breakdown=follow_type 去請求,它會拆成 FOLLOWER(新增追蹤)和 NON_FOLLOWER
(取消追蹤),淨值是 FOLLOWER − NON_FOLLOWER。到這裡都沒問題。
變的是——Meta 大約在 2026 年 6 月開始推這個行為——這些 breakdown 結果什麼時候定稿。
最近大約十天,API 會回一個空殼:breakdowns 結構在,但裡面的 results 陣列整個
不見。資料根本還沒算出來。
// 已定稿的日子(早於 ~D-10):results 有值
{ "name": "follows_and_unfollows",
"total_value": { "breakdowns": [ { "dimension_keys": ["follow_type"],
"results": [ { "dimension_values": ["FOLLOWER"], "value": 5 },
{ "dimension_values": ["NON_FOLLOWER"], "value": 2 } ] } ] } }
// 近期的日子(~D-10 內):結構在、results 不見了
{ "name": "follows_and_unfollows",
"total_value": { "breakdowns": [ { "dimension_keys": ["follow_type"] } ] } }
Instagram follows_and_unfollows——兩種回應形狀
我的 normalizer 處理得了第一種形狀,然後直接走進第二種。它用「預設空 list」去讀
results,建了一個空的對照表,然後照樣做減法:
results = tv["breakdowns"][0].get("results", []) # 不見 → []
breakdown_map = {r["dimension_values"][0]: r["value"] for r in results} # → {}
value = breakdown_map.get("FOLLOWER", 0) - breakdown_map.get("NON_FOLLOWER", 0)
# 0 - 0 = 0 ← 一個我們從來沒量到的數字
舊邏輯實際算出來的東西
於是每個「還沒定稿」的日子都回了一個 0——不是缺資料,是一個實際量到的值。圖表分不出
它跟「真的很安靜的一天」有什麼差別,就畫了一根零柱,然後翻頁。
為什麼假的 0 比沒資料更糟
這裡存 0 而不存 NULL,惹出兩個各自獨立的麻煩。NULL 會說「這個我們還不知道」;那個 0 說的是「我們查了,答案是零」——而我其實沒辦法替這句話背書。在 append-only 的倉儲裡,這個 小謊讓我付了兩次代價。
它毒害圖表。dashboard 可以把 NULL 丟掉、在末端留一個誠實的缺口;但丟不掉 0——0 是個 完全有效的量測值,所以會被畫出來,跟真正「沒有活動」的一天分不出來。
它把自己冰封起來。我的 raw table 是 append-only,dbt staging 層用
QUALIFY ROW_NUMBER() OVER (… ORDER BY ingested_at DESC) = 1 取每天最新一列。這本來是會
自我修復的:重抓某一天,較新的列勝出,真值蓋過佔位值。但前提是你真的有重抓它——而這個
指標當時跟那些定稿快的指標共用同一個 7 天窗口,它卻要到 ~D-10 才定稿。等上游有真值的時候,
那一天早就掉出窗口、再也沒有人去問它了。假的 0 就這樣留下來了。
修法
分兩部分,因為 bug 有兩半。第一,別再製造那個零——缺少或為空的 results 代表的是
還沒定稿,也就是 NULL,不是 0 − 0:
第二,讓自我修復真的搆得到這個指標。重抓窗口必須撐得比上游定稿延遲還久,否則 NULL 會用 跟先前那個 0 一模一樣的方式冰封。所以這一個指標拿到它自己、比較長的窗口:
# 定稿快的指標(views、互動、reach…):7 天綽綽有餘
for date in last_n_days(days_back): # days_back = 7
rows.append(normalize_total_value(fetch_total(date), slug, date))
# follows_and_unfollows 要 ~D-10 才定稿,所以重抓得拉到比它更前面
FOLLOWS_BACKFILL_DAYS = 14 # 14 > 10,留一點 margin
for date in last_n_days(FOLLOWS_BACKFILL_DAYS):
rows.append(normalize_total_value(fetch_follows(date), slug, date))
窗口必須撐得比延遲久
現在每個近期的日子先寫成 NULL,然後只要還在 14 天窗口內,每次跑都會被重抓。等它在上游定稿
那天(~D-10),下一次跑就拉到真實數字,較新的 ingested_at 在去重時勝出,NULL → 真值,
不需要人工 backfill。一次性修正之後(06-14/06-15 補回它們的真值,06-16 之後改存 NULL 而非
假 0),pipeline 從這裡開始會自己把末端補好。
用測試把它釘死
要小心的是:真正的 0 還是得活下來——一個明確 FOLLOWER 0 / NON_FOLLOWER 0 的日子是
貨真價實的零活動,該維持 0、不該變成 NULL。所以兩個新測試只蓋未定稿的形狀,原本那個
「真實零」的測試繼續守另一邊:
breakdowns在、但沒有results鍵 → NULLresults在、但是空 list → NULL- 明確的
FOLLOWER 0 / NON_FOLLOWER 0→ 仍是0(不變——真實量到的零)
176 個測試通過。dashboard 的 caption 現在也把潛台詞講出來:
這次學到什麼
值還沒到,就用 NULL。像 0 這種 default 看起來人畜無害,但它把「沒有答案」變成了一個答案, 下游每一張圖、每一個平均、每一個 alert 都會當它是真的。如果你替那個數字背不了書,就什麼 都別存。
Dedup-on-read 讓 append-only pipeline 能自我校正,但只校正你真的有再抓一次的日子。重抓 窗口是一個承諾:你會持續問多久。如果來源定稿的速度比你的窗口慢,你就會把暫定值冰封、再也 不會回頭拿真值。窗口要照來源多晚才定稿去抓,而不是照你方便。
沒有任何東西警示我——抓到這件事的是兩個數字兜不攏:淨追蹤躺平、粉絲總數卻在爬。拿一個 可疑指標去對一個你本來就信任的相關指標,大概是最便宜的資料品質測試,而且它抓得到任何 schema 或 not-null constraint 都抓不到的問題。