只是多兩個 Background Job,為什麼讓 Redis 記憶體暴增三倍?從 Hangfire 看需求背後的可靠性成本
前言
之前有一個專案,需要在每一局遊戲中新增兩個 Background Job,從原本一局有 5 個 Background Job 變成 7 個。
乍看之下,這件事真的很容易被當成小改動。
不就是多兩個 Background Job 嗎?
結果上到 UAT 之後,Redis 的記憶體使用量直接暴增三倍,差點把 Redis 的記憶體吃到見底。
這件事情最有價值的地方,不是我們最後找到了哪幾個 Redis Key 變大,而是它提醒了我們一件很容易被忽略的事:
在高頻系統裡,每一個 Background Job 都不是免費的。
Background Job 不單單只是「排程執行」這麼簡單的一件事而已。
它背後還包含 Job metadata、serialized arguments、state history、retry、failed details、Batch metadata、TTL 保留,以及後續清理成本。
這些成本如果沒有在需求設計階段就被看見,那它最後可 能就會在上線之後對 Redis 帶來極大的壓力,並且對系統的穩定度帶來極高的風險。
緣起:只是新增兩個 Job 啊
話說,在 2025 年春節之前,我們正在趕著讓新專案上線;而這個專案主要的功能,是要幫會員自動下單。
因為可以下單的時間剛好位於每場遊戲的開始與結束時間範圍裡,所以我們很自然地想到:既然原本就已經有一套用 Hangfire 的 Background Job 控制遊戲狀態的機制,那這次新增的自動下單流程,也可以沿用同樣的設計。
因為每一局裡面有兩個可以自動下單的時間點,於是,我們就在每局遊戲裡新增兩個 Background Job。
從開發角度看,這個設計很直覺,也沒有什麼特別奇怪的地方。
在測試環境裡,功能也都正常。
但上到 UAT 後,問題就出現了。
Redis 的記憶體使用量竟然暴增了三倍,幾乎快把 Redis 的記憶體吃光。
一開始,我們很直覺地以為是 Hangfire 的歷史資料保存太久,導致 Redis 被舊資料塞滿。所以就先把 Hangfire 的 Job 與 State 的 TTL,從原本的 16 天縮短到 3 天。
本來以為這樣應該可以快速把歷史資料清掉,但是改善的程度微乎其微。
深入鑽研到每個 Job 的 Redis Key 之後才發現,問題沒有那麼單純。
真正的問題不是 Redis 壞掉,也不是 Hangfire 本身設定錯誤,而是 Hangfire 在 Redis 裡為每一個 Job 建立與保留的資料結構會吃的記憶體,遠比我們原本想像得更多;而 Job 本身攜帶的參數,在序列化之後,也比我們想像得更佔空間。
接下來,我就用這次分析的經驗,從 Redis Key 與容量的 角度,整理一下 Hangfire 在 Redis 中會產生哪些 Key、這些 Key 的資料型別與用途,以及為什麼只是新增兩個 Job,最後卻有可能讓 Redis 記憶體暴增三倍。
被低估的 Background Job 成本
這次事件最重要的收穫,不是發現某一個 Redis key 特別大,而是讓我們重新意識到:
在高頻遊戲場景下,每新增一個 Background Job,都不是一個獨立的小功能,而是一個會被「場次數 × 保留時間 × 狀態歷史 × 失敗重試 × Batch metadata」放大的系統成本。
對產品負責人(PRD Owner)來說,「每局新增兩個任務」看起來可能只是多兩個流程節點。
但對系統來說,它代表的是:
- 每局多兩份 Job metadata
- 每局多兩份 serialized arguments
- 每局多兩份 state / history
- 每局多兩個可能 retry / failed / succeeded 的生命週期
- 如果使用 Batch,還會額外增加 Batch metadata 與狀態索引
- 如果 TTL 設得太長,這些資料會在 Redis 裡累積好幾天,甚至更久
所以,在評估這類需求時,不應該只問「開發需要多久的時間?」。
從架構師的角度來看,還要問:
這個需求會不會隨著場次、會員、訂單或交易數被放大?
如果答案是會,那就不能只用功能開發的角度來看它,而要把它放進容量、保留策略、失敗處理、可觀測性與清理機制裡一起評估。
架構師和產品負責人在設計這類需求時應該先問的問題
如果一個需求會依照「每局」、「每單」、「每會員」、「每交易」去新增 Background Job,那就不應該只從單一角色的角度來看。
產品負責人會關心的是:這個需求是不是必要、什麼情境要做、成功與失敗時的產品行為是什麼。
架構師要關心的是:這個需求會怎麼被流量放大、會產生多少資料、失敗時怎麼補償、資料要保留多久,以及這些成本會不會反過來影響系統可靠性。
兩邊看的角度不同,但目標其實是一樣的:
在需求進入開發之前,先把會被放大的成本與風險看出來。
這不是要阻止需求,而是要避免開發完成、上線之後,才發現容量、可靠性或維運成本比預期高很多。
產品負責人應該先問的問題
產品負責人不一定需要知道 Hangfire 在 Redis 裡會建立哪些 Key,但需要知道這個需求會在什麼情境下被觸發、會不會大量發生,以及失敗時對使用者或業務流程的影響。
| 問題 | 為什麼重要 |
|---|---|
| 這個 Job 是每局都要產生,還是只在特定條件下產生? | 決定資料量是否會被場次數線性放大 |
| 這個流程是否一定要非同步執行? | 有些流程可以同步完成,有些才真的需要 Background Job |
| 使用者或業務方是否需要知道這個 Job 的執行 結果? | 決定是否需要狀態查詢、通知、補償或人工介入 |
| Job 失敗時,業務上可以接受什麼結果? | 決定 retry、補償、告警與人工處理策略 |
| 這個需求是否有 SLA / RTO / RPO? | 決定可靠性要求與技術設計成本 |
| 是否需要保留完整執行紀錄?需要保留在哪裡、保留多久? | 影響 Hangfire Redis、Log、Audit Table 或其他儲存成本 |
| 這個需求未來是否可能擴大到更多遊戲、更多會員或更多交易? | 決定目前設計是不是只適合短期需求,還是需要預留成長空間 |
從產品角度看,這些問題的重點不是技術細節,而是要先釐清需求的「觸發頻率」、「失敗後果」和「業務可接受範圍」。
如果這些問題沒有先講清楚,後面架構師就很難判斷到底該做輕量設計、完整補償機制,還是需要更嚴格的可靠性設計。
架構師應該先問的問題
架構師要補上的,是需求被系統執行後會產生的真實成本。
也就是說,不只是問「這個功能做不做得到」,而是要問「它一天會發生幾次、每次會留下多少資料、失敗時會放大多少,以及這些資料要在系統裡活多久」。
| 問題 | 為什麼重要 |
|---|---|
| Job 是否真的需要獨立存在? | 有些流程可以合併、內聚,或改成事件內部處理 |
| 每日預估會新增多少 Job?尖峰每分鐘會新增多少? | 用來估容量、吞吐量與告警門檻 |
| Job 參數是否只傳 ID? | 傳 DTO 會讓 Redis 成本不受控 |
| Hangfire 的全域 Job expiration 與 Batch expiration 設定是否符合這個需求的量級? | Hangfire 通常不是針對單一 Job 設定 Redis 保存時間,而是透過整體 storage / expiration 策略影響資料保留量 |
| Job 是否需要 Batch 管理? | Batch 會帶來額外 metadata、state 與 index |
| Job 失敗時是否需要完整 stack trace 留在 Hangfire state? | 例外細節可能比正常參數更大 |
| retry 次數與補償策略是否明確? | 避免失敗資料、重試排程與狀態歷史無限制膨脹 |
| Hangfire 預設會把 failed Job 的 exception 寫回 Redis,這個保留時間是否符合排查需求? | 避免大量 failed Job 的 exception details 長時間累積在 Redis |
| 是否需要為這類 Job 建立專屬監控指標? | 例如 Job 建立量、失敗率、平均 payload 大小、Redis memory growth rate |
從架構角度看,這些問題的重點是把需求轉成容量模型與可靠性設計。
如果一個需求會被高頻觸發,那它就不只是功能設計,而是資料成長模型、失敗處理模型和維運模型。
兩個角色要對齊的不是答案,而是成本邊界
產品負責人和架構師不需要看同一種細節。
產品負責人不一定需要知道 hangfire:job:{id}:history 裡面長什麼樣子;架構師也不應該只用「Redis 會變大」來否定需求。
真正需要對齊的是幾個成本邊界:
- 這個需求一天最多可能產生多少 Job?
- 這些 Job 成功、失敗、重試時分別要保留多久?
- 哪些資料需要留在 Hangfire,哪些資料應該移到 Log / APM / Audit Table?
- 失敗時是自動補償、人工處理,還是可以接受 eventual consistency?
- 當 Redis memory growth rate 超過預期時,誰要收到告警,誰要決定處理策略?
這些問題如果能在需求設計階段先想清楚,後面要做架構設計、容量估算、告警設定或維運交接時,就不會每次都變成臨時補洞。
這也是我後來越來越在意 PRD Owner 和架構師協作方式的原因。
好的 PRD Owner 不只是把功能寫清楚,也應該能看出需求背後可能被放大的產品成本。
好的架構師也不只是把系統做出來,而是要能把這些成本翻譯成產品可以理解、也能一起做決策的風險邊界。
Hangfire 在 Redis 中常見的 Key 與資料結構
以我們使用的 Hangfire.Pro.Redis 2.8.3 版為例,Hangfire 在 Redis 中會使用多種 Key 來保存 Job、Queue、State、Server 等資訊。常見的 Key 與資料結構如下:
針對每個 Key 的資料結構與作用,簡單說明如下:
| Key 範例 | Redis 結構 | 作用 | 備註 |
|---|---|---|---|
hangfire:job:{id} | Hash | 單一 Job 的詳細資料 | 包含 Method、Type、ParameterTypes、Arguments、建立時間與狀態等 |
hangfire:recurring-job:{id} | Hash | 定期任務的 metadata | 包含 Cron、方法參考與參數 |
hangfire:queue:{queueName} | List | 儲存待處理 Job id | 隊列長度與 enqueued jobs 數量成正比 |
hangfire:processing | Sorted Set | 正在執行中的 Job id | 通常以時間相關 score 排序;實際語意以版本與實作為準 |
hangfire:failed | Sorted Set | 失敗 Job 的索引 | 常見大型 key;通常保存 Job id 與時間相關 score,不等於完整失敗明細 |
hangfire:deleted | List | 刪除 Job 的標記 | 保留已刪除 Job id 作後續清理 |
hangfire:queues | Set | 所有 queue 名稱 | 只是索引,容量很小 |
hangfire:recurring-jobs | Sorted Set | 定期任務索引 | 以識別碼排序、快速查找 |
hangfire:retries | Sorted Set | 需重試 Job 的清單 | 用於 retry 排程與延遲處理 |
hangfire:servers | Set | 已註冊 server id | 追蹤所有 active server |
hangfire:succeeded | List | 成功完成的 Job id | 主要用於成功 Job 的索引、統計或清理;實際用途以版本與 storage 實作為準 |
hangfire:stats | Hash | 系統統計資料 | 保存各種計數值與指標 |
hangfire:batch:{id} | Hash | 單一 Batch 的 metadata | 例如建立時間、描述等基本資訊 |
hangfire:batch:{id}:state | Hash | Batch 目前狀態 | 保存當前狀態名稱、時間與摘要 |
hangfire:batch:{id}:states | List | Batch 狀態歷史 | 保存狀態變更紀錄 |
hangfire:batch:{id}:pending | Sorted Set | Batch 中等待或尚未完成的 Job 索引 | 依 Hangfire Pro Redis 實作維護 |
hangfire:batch:{id}:succeeded | Sorted Set | Batch 中已成功完成的 Job 索引 | 依完成狀態維護 |
hangfire:batch:{id}:finished | Sorted Set | Batch 中已結束的 Job 索引 | 追蹤已結束 Job |
容易佔用大量記憶體的 Key 們
從我們這次看到的案例來看,真正需要優先檢查的通常不是 queue list 本身,而是每個 job 的 hash,以及該 job 的 state / history。
-
Job Hash (
hangfire:job:{id})hangfire:job:{id}這個 hash 裡面,會保存這個 Job 要執行什麼方法、類型是什麼、參數型別有哪些,以及實際傳進去的參數內容。幾個比較值得注意的欄位如下:
Method/Type:表示這個 Job 要呼叫的類別與方法。這些欄位與ParameterTypes合起來,描述了要呼叫的目標方法與參數型別。ParameterTypes:以陣列形式保存每個參數的完整型別名稱,通常包含 namespace、assembly 與版本資訊。這些字串本身就會佔用空間;若參數型別很多或型別名稱很長,佔用會更高。Arguments:實際參數序列化後的字串,通常是 JSON。這個欄位的大小直接取決於傳入的參數內容,從幾十 bytes 到數 KB 都有可能。
以我們看到的狀況來說,簡單 Job 只帶一個或兩個 ID / 整數時,通常會比傳入完整 DTO 小很多;如果帶入複雜 DTO,包含多個欄位、長字串或集合,就很容易讓
Arguments明顯放大。實際大小應以 RedisMEMORY USAGE或欄位長度檢查為準。 -
Current State
在我們觀察到的資料中,Hangfire 會在
hangfire:job:{id}:state相關資料中保存目前狀態欄位,例如State、Checked、FailedAt、ExceptionType、ExceptionMessage與ExceptionDetails。實際欄位會依狀態與版本有所不同。這裡比較麻煩的是失敗狀態。
當 Job 失敗時,Hangfire 會保存失敗狀態需要的錯誤資訊。
ExceptionMessage與ExceptionDetails本身就可能變得很大。如果同一類錯誤大量發生,Redis 裡累積的就不只是 Job 狀態,還會包含大量失敗資訊。 -
History
hangfire:job:{id}:history的 list 會保存狀態事件的紀錄。不同狀態會有不同內容,失敗相關紀錄可能包含ExceptionType、ExceptionMessage或ExceptionDetails這類資訊。如果 Job 有多次 retry 或狀態變更,history list 的元素就會累積。正常情況下可能還好,但如果 Job 不斷失敗、重試、再失敗,這些歷史資料就會變成額外的記憶體壓力。
-
Batch
以我們環境看到的 Batch 資料來說,Batch 自己也會有 metadata、state 與狀態歷史,例如
hangfire:batch:{id}、hangfire:batch:{id}:state、hangfire:batch:{id}:states。Batch 本身的 state 資料不一定是最大宗,但如果 Batch 數量多、底下 Job 多,或狀態變更頻繁,相關的 state、states 與 sorted set index 也會跟著累積。
另外,Batch 相關資料的保存時間也要和 Job expiration 一起看。否則很容易只調整了 Job 的保留策略,卻忽略 Batch metadata 或相關索引是否仍然累積。