追查一個卡住的重新索引,結果牽出兩個不相關的 bug
我們的部分資料來源是某家公司完整的招募頁面,而不只是該公司的工程職缺。對一個範圍廣的 ATS 看板進行爬取時,會把該頁面列出的所有內容——油漆工、倉庫理貨員、司機、護理師——連同我們真正鎖定的軟體職缺一起帶回來。這類雜訊大部分會在進入時被過濾掉。但有一些不會:職稱過於籠統,系統無法辨識它們屬於科技業,也無法明確辨識它們不屬於科技業,因此它們就以完全沒有專長分類的狀態進入目錄。無法依職缺類型搜尋、無法篩選,只是夾在有人真正想找的職缺之間的雜訊。
我們決定停止把這類職缺放進搜尋、放進 LLM 強化佇列,以及放進 embedding 佇列——這三個各自獨立的地方,都在默默地把資源花在根本沒人能依職缺類型找到的職缺上。程式碼的改動很小。要把這項改動套用到既有的索引資料,意味著必須完整重建搜尋索引。故事才正要從這裡開始。
跑不完的重新索引
我們啟動了重建作業,然後就放著不管。四個小時過去,它仍在執行中——沒有進度紀錄、沒有錯誤訊息,只有一片寂靜。systemctl status 顯示該行程仍存活,幾乎沒消耗 CPU。Postgres 顯示它根本沒有任何正在執行的查詢。這種組合——存活、閒置、看不見——通常代表兩種情況之一:死鎖,或是正在等待 I/O,慢到看起來像是停止不動。
我們對該行程送出 SIGQUIT。Go 的 runtime 在行程結束前會傾印每個 goroutine 的堆疊,而這份傾印結果很清楚:主要 goroutine 阻塞在讀取一條 SQL 查詢的結果,正處於迴圈中,一次處理一間公司——總共超過 236,923 間。每間公司一次網路往返。在一般負載下這很慢但還撐得住;在當天該主機實際承受的負載下,需要好幾個小時才能走到把單一份文件送進搜尋的那一步。
修法並不巧妙——把 500 間公司批次成一次查詢,而不是一間公司一次查詢。其中一個階段(同職缺重複發布的合併)可以安全地批次處理,無需額外顧慮;它的比對鍵本來就包含公司,因此跨批次分組不會混雜到不同公司的資料列。另一個(跨來源重複抑制,以職稱文字進行比對)就沒有這樣的保證——直接批次處理會讓兩間剛好都使用相同職稱(“Backend Engineer”)的公司,在落入同一批次的瞬間發生跨公司比對。這需要在每一條比對路徑上明確加上公司範圍的防護,並寫一支專門用兩間公司同時貼出相同職稱來設法打破這個防護的測試。
上線後,同樣的重建作業從原本卡了四小時,變成端到端在個位數以內就跑完。
我們去查了些東西,但沒查到
在寫這篇文章之前,我們想驗證一件縮小索引理應能修好的事:記憶體壓力。Meilisearch 大約吃掉了主機三分之一左右的 RAM,而整個資料目錄(搜尋索引加上語意索引,以及其他幾個較小的索引)在這台 30GB RAM 的機器上佔了 34GB。這是個真實的數字,讓人很想直接寫「索引放不下 RAM,這就是造成停機的原因」。
我們去查證了。翻了三十天的核心 log、三十天 Meilisearch 自己的 log——從未發生過任何 out-of-memory kill。那個說法並不屬實,所以我們不會這樣寫。
真正發生、而且六天前就已經造成一場真實 outage 的是:Meilisearch 每次推送都會重新合併整個倒排索引,不論實際變更的文件數量多寡。成本是隨著索引總大小而增加,而不是隨著變更量而增加。當我們增量索引器的逾時設定被調得太緊、無法承受這項成本時,一筆正常但偏慢的推送會被誤判為失敗,接著退而採取一次推送一份文件的做法——把一個慢的批次變成數百個各自昂貴的推送,而且全都搶著爭用網頁伺服器光是為了接受連線就需要的那一份磁碟 I/O。這造成了兩段真實的 504 錯誤時段,直到我們修正了逾時邏輯本身才解決。
縮小索引並不會消滅那種故障模式,但會降低之後每一次推送的成本——也就能降低下次附近又有別的事情出包時的慘況。這是個雖然戲劇性較低、但同樣真實的理由,說明為什麼我們在意索引大小。我們寧可這樣寫,也不願意用一個聽起來更乾淨、卻拿不出證據的原因來充篇幅。
數字
- 搜尋索引:2,735,456 → 998,841 份文件(縮減約略超過 60%)
- 同一份索引的磁碟佔用:約 9.96GB → 約 3.71GB
- 我們用來篩選的全部 36 個真實專長分類——後端、前端、設計、管理、業務,以及其他——逐項比對重建前後:每份文件都完全一致,沒有變動。消失的只有那個「連這到底是什麼都看不出來」的桶子。
資料庫裡沒有刪除任何東西。每一筆職缺仍然原封不動地保存著原貌——變動的只有對搜尋開放的範圍。如果某個職稱之後被歸進了真實的分類,它在下次重建時就會自動重新出現在搜尋結果中。
教訓
真正讓我們賠上四小時的那個 bug,跟我們原本打算做的改動毫無關係。「執行重新索引以套用篩選器」這一步,把一個可能已經在不知不覺中慢慢惡化的擴展性問題掀了出來,之前它一直躲在每幾小時自動跑一次的排程任務後面。而那個看起來最像頭條的數字——30GB 的機器上塞了 34GB——在我們去尋找證據、而不是去找一個說法之後,才發現它根本不是真正的故事。
這一切的根源——那個決定什麼才算真實分類的字典——只會解析有人明確教過它的東西;它從不自行猜測。技能面向也是同樣的道理,只是多了一層:issue #1613 涵蓋了整個職業類別(業務、客服、產品),而這份字典在這些領域幾乎還沒什麼詞彙,所以它們的技能篩選效果遠比工程類別差。如果你想自己補上這種缺口,改動本身是自足的、只需要動一個檔案。
整個 pipeline 都是開源的——篩選器、批次處理的修法,以及造成先前那場 outage 的逾時邏輯,全都放在同一個 repo 裡。如果你對這類東西有興趣,一個 ⭐ on GitHub 可以幫助其他人找到它。