文章

CPU 不是問題:自架 PostHog 怎麼在沒人發現的情況下丟了四分之一的事件

同事問的是「CPU 這麼高要不要擴機」。真正的問題是單 partition 的 ingestion pipeline 消費不過生產,積壓撞到 24 小時 retention,五天丟了 22–32% 的事件,而能抓到它的告警十天前被我自己刪掉。

  • PostHog
  • Capacity
  • Alerting
  • Incident

九月中,一位同事在群組問:PostHog 主機的 ClickHouse CPU 很高,有影響應用嗎?要不要擴機器?

查完的答案是:CPU 不是問題。問題是 ingestion pipeline 的消費速度追不上生產速度,積壓在五天內從幾分鐘長到 24 小時,撞到訊息佇列的 retention 上限,之後進來的事件有 22–32% 直接被丟掉,救不回來。而原本能提早五天抓到這件事的告警,十天前被我刪了。

這篇記錄怎麼從「CPU 高」走到「資料在丟」,中間走錯的一步,以及哪些東西可以帶到別的系統去用。

系統長什麼樣

這套 PostHog 是自架的,整套跑在一台 r5.2xlarge(8 vCPU、64 GiB)上,用官方的 docker compose:capture 收事件寫進 Redpanda(相容 Kafka),一個 Node.js 的 ingestion 服務消費後寫進 ClickHouse,旁邊還有 Postgres、Redis、Celery worker 和 Temporal。

兩個結構性的限制決定了後面發生的一切:

  • 事件 topic 只有 1 個 partition,消費者也只有 1 個,單執行緒。消費速度上限實測約 470–500 events/s。
  • Redpanda 的 retention 是 24 小時。事件進了佇列但 24 小時內沒被消費走,就會被 retention 清掉。

24 小時這個值是我在九月初設的。那之前是 1 小時:九月初一次根磁碟寫滿讓 Redpanda 退出、capture 收不進事件,兩天的資料永久遺失,1 小時的 retention 是放大器。我把它改成 24 小時,換來的是「PostHog 掛掉 24 小時內 = 資料只是晚到」,代價是磁碟多留一天的事件。同一週我也從零建了這台主機的監控:Prometheus、node-exporter、cAdvisor、blackbox,加上一個 Grafana dashboard 和十幾條告警,全部進 Git 由 Argo CD 管。

所以到九月中,這台機器是有監控、有告警的。問題不在沒有監控。

CPU 為什麼不是問題

同事問 CPU 的時候,CloudWatch 上這台主機的日均 CPU 是 88%、尖峰 99%,已經連續一週是一條直線。拆開看,ClickHouse 佔了約 2.5 核,幾乎全是背景 merge 和插入 pipeline,使用者查詢一天只用掉 1.7 CPU 小時。

CPU 高是真的,但它是結果,不是瓶頸。ClickHouse 在忙著 merge,是因為寫入量大;寫入量大,是因為 ingestion 一直在全速消費。沒有任何東西在等 CPU。

排隊系統的瓶頸要看生產率對服務率,不看 CPU 使用率。這台機器的數字是:

09-06 09-14
進線(生產率) 441 events/s 610 events/s(+38%)
消費上限(服務率) 470–500 events/s 470–500 events/s
佇列積壓 220 萬筆 5,260 萬筆

生產率過了服務率,積壓就只會單調增加,和 CPU 幾趴沒有關係。擴機器把 CPU 壓下來,單 partition、單消費者的服務率還是 500/s。

積壓怎麼變成資料遺失

每天的最大入庫延遲,從主機上的 Prometheus 回放:

日期 每日最大延遲
09-07 17 分鐘
09-08 58 分鐘
09-09 2.5 小時
09-10 7.4 小時
09-11 13.3 小時
09-14 24 小時

延遲到 24 小時,就是積壓的尾端撞到 retention:佇列最舊的事件還沒被消費,就被清掉了。從 09-13 下午起,每小時進來的事件只有 68–78% 真的進了 ClickHouse。這是 22–32% 的永久遺失,不是晚到。

這裡有一個監控上的陷阱。Dashboard 上「最新事件的年齡」一直是綠的,因為它看的是插入時間:只要 ingestion 還在跑,永遠有剛插入的事件。它不告訴你插入的這筆事件是 24 小時前發生的。看積壓要看佇列本身:consumer 的 CURRENT offset 對 LOG-START offset 的距離,或是事件的發生時間對現在的差。

為什麼沒有告警

告警是有的。我在九月初建監控時就有一條「入庫延遲超過 30 分鐘」。上線後它每天凌晨到上午都叫:那個時段的進線尖峰是 750–870 events/s,超過服務率,延遲衝到一個多小時,中午過後自己排空。我把它當成誤報,先放寬到 90 分鐘,還是每天叫,09-10 就把它刪了。Git 裡那次 commit 的理由寫著:危險線由其他規則覆蓋。

事後看,它每天都是對的:pipeline 每天早上都在輸,只是下午還追得回來。它在告訴我安全邊際已經很薄,我聽到的是噪音。

而「其他規則覆蓋」那句話沒有驗證。事後回頭看,當時沒有任何一條規則在看「24 小時」這條線:有事件 15 分鐘沒進 ClickHouse 會叫、Redpanda 掛了會叫、磁碟滿會叫,但「ingestion 還在跑,只是跑不贏」這個狀態,什麼都不會叫。

09-21 我把它補回來,改成兩層:3 小時 warning、12 小時 critical。用 30 天的資料回放,3 小時的門檻在這 30 天裡只叫過一次,就是這次事故,從 09-09 開始叫,比人發現早了五天。

刪告警的規則很簡單,只是做的時候沒有遵守:刪除要證明覆蓋,不能宣稱覆蓋。宣稱很便宜,證明也不貴,就是把其他規則的表達式拿出來、對著這次要刪的那條線問一次「這個狀態下誰會叫」。

一個走錯的方向

處理中間有一段插曲。有人把 ClickHouse 的 system.kafka_consumers 丟給 AI,得到一份建議:17 條 SYSTEM STOP KAFKA,理由是這些 Kafka 消費者在耗 CPU。

我沒有照做,先去找一個能否證它的指標。top -H 看執行緒:ClickHouse 的 Kafka 執行緒只佔 4% CPU。再查 system.query_log,七天內沒有人執行過 STOP KAFKA。AI 說對的部分只有一件:Redpanda 裡少了 11 個 topic,ClickHouse 每分鐘寫三萬條「拿不到 assignment」的 log,但這是噪音,不是負載。

同一天 CPU 從 88% 掉到 60%,看起來像那份建議生效了。真正的原因是我在主機上發現兩個沒關掉的 docker compose stats,各握著 7,885 條到 docker.sock 的連線、各讀 5 MB/s。關掉它們,CPU 就掉了。這和事故無關,只是順手清掉的。

AI 給的結論要先找到一個能否證它的指標再動 production。這次一個 top -H 就夠。

怎麼收尾的

量化之後有兩條路:擴容,或是減量。

擴容的方案我寫了:partition 1 → 4、加第二個消費者,單機服務率大約到 1,000/s;再上去要換 16 vCPU 的機器跑 3–4 個消費者,約 2,000/s。但加 partition 不是熱操作(PostHog 用 token + distinct_id 做 partition key 保證同一個人的事件有序),換機器對單機 ClickHouse 也做不到真正的藍綠,要停 5–10 分鐘。這條路的天花板也看得到:以當時的成長率,1,000/s 撐約 20 天,2,000/s 撐約 70 天。

減量這條路,資料來自另一個方向:這台機器 98% 以上的事件來自 API 層的五個埋點,是八月底上線的,從每天 30 萬筆長到每天 4,500 萬筆。這些事件在資料倉儲那邊已經有另一條管道可以算出同樣的指標。資料團隊和研發討論後,決定把這五個埋點下線;這是他們的決定和他們的工作,我提供的是上面那些數字和兩條路的代價。

09-18 傍晚埋點下線,積壓以約 650/s 排空,09-19 下午歸零。之後的數字:進線 610/s → 3.8/s,積壓 5,260 萬 → 0,主機 CPU 日均從 88% 掉到 17%(CloudWatch),ClickHouse 從約 2.5 核掉到 0.2–0.35 核,merge 歸零。

要說清楚的是:瓶頸消失了,但不是修好的,是輸入拿掉了。單 partition、單消費者、500/s 的上限還在。等下一個大量埋點上線,它會再撞一次。這點我寫進了交接文件,連同上面的擴容方案和它的成本。

可以帶走的幾件事

  • 佇列看生產率對服務率,不看 CPU。CPU 高可能只是服務率已經跑滿的結果。擴機器之前先問:加了資源,服務率會變嗎?單 partition 的答案是不會。
  • retention 是你對故障的容忍時間。它不是磁碟參數,是「pipeline 可以停多久而不丟資料」。設 1 小時就是容忍 1 小時。要同時設時間和位元組上限,前者決定容忍時間,後者保證撐不爆磁碟。
  • 「最新事件的年齡」這種指標要看它量的是哪個時間。插入時間永遠新鮮,發生時間才會暴露積壓。
  • 刪告警要證明覆蓋。把其他規則的表達式拿出來,對著要刪的那條線問一次「這個狀態誰會叫」。降噪的第一選擇是刪除沒錯,但刪之前要做這一步。
  • 每天都叫、每天都自己好的告警,先問它是不是對的。它可能在量一個真的每天都在發生的事,只是後果還沒累積到看得見。
  • AI 的結論先找否證指標。它看到的是你貼給它的那一張表,看不到 top -H。
  • 「問題消失」和「問題修好」要分開記。寫進交接的是哪一種,決定下一個人會不會再撞一次。

我會怎麼做

這次事故的每一個環節都有我。監控是我建的,retention 是我設的,告警是我刪的,分析也是我做的。能提早五天發現的那條告警,是被我自己用「其他規則會覆蓋」這句沒驗證的話刪掉的。

如果重來,我會在建監控的第一天就把積壓當成這台機器唯一重要的指標,用佇列 offset 或事件發生時間來量,門檻直接用 retention 的一個比例(例如 retention 的 1/8),而不是先從「30 分鐘」這種直覺的數字開始再往上調。誤報是門檻設在錯的量上面的症狀,不是門檻太低。

← 全部文章