重試通常會被加在「看起來會壞的地方」:外部氣象 API、第三方資料源、模型服務。這些地方確實常常抖動,加上重試也很直覺。
但失敗代價最高的位置,往往不是最容易失敗的位置。
Twinco 的每日推播要先併發抓天氣、行事曆、RSS 與星座,再交給模型產生摘要,最後送進 LINE。上游每一段都有各自的重試,少一個資料源也只是內容少一塊。唯獨最後一步——真正把訊息交給 LINE 的那一次呼叫——完全沒有重試。
於是某天清晨,一位使用者的推播在 TLS 握手中途被切斷。上游全部正常:天氣快取是新的、星座取到備援、摘要也產好了。只有最後一哩掛掉,而那位使用者當天的推播就這樣消失了。
這篇文章整理我們補上重試時的判準,以及幾個比「加上重試」本身更重要的細節。
先問排程語意,再決定重試策略
補上重試之前,要先回答一個和網路無關的問題:這件事如果現在沒做成,下一次它會在什麼時候被重新選中?
我們的排程每分鐘執行,篩選條件是推播設定的執行時間精確等於當下的時與分,再加上「同一則推播同一個日曆日只發一次」的去重。這兩個條件合起來,決定了整個失敗語意:
- 失敗當下沒送出去,那一整天就沒了。 失敗時送達時間不會更新,但下一分鐘的時與分已經不同、不再匹配,所以不會自動補送。
- 隔天會自動恢復。 去重比對的是日曆日而不是滾動 24 小時,昨天的失敗不影響今天的判斷。
所以在這套語意下,「之後再補送」這個選項根本不存在。重試必須在原本那一次呼叫裡就完成。
這個推論可以一般化。一個任務的重試視窗,等於它下一次會被重新選中的間隔:
| 排程形狀 | 重試視窗 | 合適的重試策略 |
|---|---|---|
| 每分鐘輪詢待辦佇列 | 一分鐘 | 當下試一次就好,剩下的交給下一輪 |
| 失敗進死信佇列 | 由消費者決定 | 當下可以放棄得很快 |
| 一天只命中一分鐘的排程 | 二十四小時 | 必須在當下重試到底 |
很多團隊會反過來做:先決定重試次數與退避曲線,再假設「反正之後還會再跑一次」。但那個假設是排程語意給的,不是重試邏輯自己能保證的。
值得一提的是另一條路。我們也可以把排程改成「執行時間已過且今天還沒送」的區間比對,讓失敗自然在下一分鐘被重新選中。那確實更耐錯,但會連帶改變去重語意與「使用者指定時間」的意義——使用者設定早上七點,卻可能在七點半收到。一個看似單純的可靠度需求,代價會落在產品語意上,這是選擇當下重試的真正理由,不只是實作比較簡單。
哪些錯誤值得重送
決定了「在當下重試」之後,下一個問題是重試哪些錯誤。判準只有一句:這個請求到底有沒有送到對方手上?
| 錯誤類型 | 送到了嗎 | 重送 |
|---|---|---|
| 連線類例外(TLS 握手中斷、DNS 失敗、對方斷線)與逾時 | 沒有 | 絕對安全 |
| HTTP 429、500、502、503、504 | 可能送到了 | 需要冪等鍵兜底 |
| 其餘 4xx(格式錯誤、被封鎖、憑證失效) | 送到了,而且被拒絕 | 沒有意義 |
第三列常被忽略。對沒救的錯誤重試不只是白費力氣,它有實際成本:推播是併發送出的,重試期間仍然佔著併發額度,所以對一個永遠不會成功的請求試三次,等於讓後面排隊的人多等。不重試也是一種保護。
退避曲線同樣要跟排程語意扣在一起。我們用的是三次嘗試、間隔 0.5 秒與 1.0 秒——刻意的短。瞬斷型錯誤重送一次幾乎必定成功,而拉長到分鐘級的指數退避對我們毫無幫助:整個重試視窗雖然名義上有二十四小時,實際上卻被併發額度與整批推播的完成時間壓在幾秒之內。能用的時間比看起來少很多。
冪等鍵讓「可能已送達」變成可以重送
上表第二列之所以能重送,唯一的理由是 LINE 提供了 X-Line-Retry-Key:帶著同一把 key 的重複請求會被去重,使用者不會收到兩則。
實作上只有一個關鍵細節,但它很容易寫錯:這把 key 的生命週期綁在「這一次邏輯上的送出」,不是「這一次 HTTP 請求」。
retry_key = str(uuid.uuid4()) # 迴圈外面,整次呼叫共用
for attempt in range(PUSH_MAX_ATTEMPTS):
try:
await line_bot_api.push_message(request, x_line_retry_key=retry_key)
return
except Exception as exc:
...
把產生 key 的那一行移進迴圈裡,程式仍然會跑、測試多半也會過,但每次嘗試都變成一筆全新的請求——冪等性完全消失,而且只有在「請求其實已送達、只是回應沒收到」時才會顯現,也就是最難重現的那種情況。
沒有冪等鍵的系統不是不能重試,而是只能重試第一類錯誤。這決定了重試能覆蓋多少故障,值得在接一個新的外部 API 時就先查清楚。
驗證冪等 header 不需要打擾任何人
補上 header 之後,怎麼確認對方真的在讀它?
直覺的做法是送一則真的訊息給自己,然後想辦法讓它失敗。但這既難重現,也會真的送出訊息。更好的方法是讓收件人無效,只觀察對方對 header 的反應。我們送了三組對照,全程沒有送出任何訊息:
| 送出內容 | 對方回應 |
|---|---|
| 合法 UUID 格式的 key | 只抱怨收件人無效,key 被接受 |
| 不帶 key | 同上(基準線) |
| 格式錯誤的 key | 明確指出這個 header 的值無效 |
第三列證明對方真的會解析並驗證這個 header,第一列證明我們送的格式通過了驗證。兩者合起來就足夠了。
一般化的說法是:**要驗證一個 header 有沒有被接受,用「壞掉的收件人」比用「真的收件人」更能分辨,也更安全。**失敗回應裡抱怨的是什麼,往往比成功回應提供更多資訊。這條檢查可以隨時重跑,不需要挑時間,也不會打擾任何人。
失敗要分成三種,不是兩種
推播的每一次嘗試都會留下一筆事件,狀態有三種而不是兩種:
| 狀態 | 意義 |
|---|---|
| 成功 | 已送達,同時更新送達時間 |
| 失敗 | 摘要產生失敗,或推播重試後仍然失敗 |
| 略過 | 沒有可送的對象、所有資料源皆空 |
第三種很重要。實測一個月的分布是 998 成功、182 略過、1 失敗——如果把略過算成失敗,失敗率會從 0.1% 變成 15%,而那個告警很快就不會有人再看。
「今天沒有任何資料源有東西可講」不是故障,是系統正確地選擇不打擾使用者。把它和真正的失敗混在一起,等於用雜訊淹掉唯一值得看的那一列。
還有一種「使用者沒收到,但不會留下失敗事件」的路徑:長期未使用的免費會員,推播會被自動降頻或凍結,那條路徑根本不會走到送出。追查「我怎麼沒收到推播」的客訴時,這通常要先排除,否則會在網路層找一個不存在的問題。可靠度告警的價值取決於它的訊噪比,而不是它涵蓋多少狀態。
診斷:先看例外型別,再看訊息
連線類錯誤的字串長得都很像,所以我們把例外型別一起寫進錯誤紀錄。先看型別再看訊息,能省下大量時間。
以 Cannot connect to host api.line.me:443 ssl:default [None] 為例,方括號裡是底層作業系統例外的錯誤描述:
| 方括號內容 | 通常代表 |
|---|---|
| 名稱解析失敗 | DNS |
| 連線被拒或被重置 | TCP 層 |
None |
底層例外根本沒有錯誤描述 |
最後一列最容易誤判。None 不代表「沒有錯誤」,也不代表 SSL 有問題——它最常見的來源是 TLS 握手中途收到 EOF:asyncio 在握手狀態收到 EOF 時,會用一個不帶任何參數的連線重置例外去完成握手,錯誤碼與錯誤描述都是空的,再被 HTTP 客戶端包了一層。
同一段訊息裡還有一個會誤導自己的欄位。ssl: 印的是連線鍵裡的設定值,真實請求會印 default;但如果你為了重現而自己構造一個例外並傳入空值,它就會印 ssl:None——**那是你自己造成的,不是 SSL 有問題。**重現故障時做出錯誤結論,比沒有重現更糟。
最後是一個和網路無關、但一樣會讓人查錯方向的細節:我們的事件時間存的是台北時間,而資料庫與容器記錄跑的是世界標準時。對照兩邊時要記得差八小時。可觀測性資料的時區不一致,比缺少資料更容易讓人得到錯誤結論,因為它看起來完全合理。
修好之後,掉的那一則不會回來
補上重試與冪等鍵之後,那一則已經消失的推播並沒有回來,它只是在隔天的排程自然恢復。
這件事值得明講。可靠度改動的價值全部在未來,沒有任何一次修復會追溯地補上已經發生的損失。它會影響兩個決定:要不要另外做補償,以及怎麼跟受影響的使用者說明。把修復講成「問題已解決」而不區分這一點,等於默認了一個不存在的補償。
可以帶走的原則
- 重試要加在失敗代價最高的地方,不是最容易失敗的地方;最後一哩經常兩者兼具,卻最常被漏掉。
- 重試視窗由排程語意決定,不是由重試邏輯決定。先確認「下一次會在什麼時候被重新選中」。
- 用「請求有沒有送達」分類錯誤:沒送到的重送絕對安全,可能送到的需要冪等鍵,被拒絕的重試沒有意義。
- 對沒救的錯誤重試會佔用併發額度,不重試也是一種保護。
- 冪等鍵綁在「一次邏輯送出」,不是「一次 HTTP 請求」。寫錯不會有任何症狀,直到最難重現的那一刻。
- 驗證 header 用壞掉的收件人,不要用真的收件人。
- 失敗、略過、成功是三種狀態;把略過混進失敗會讓告警失去意義。
- 診斷先看例外型別,再看訊息;並確認可觀測性資料的時區是一致的。
- 可靠度修復不追溯。已經掉的那一則不會回來,溝通時要說清楚。