iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
AI 自動化

情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線系列 第 26

Day 26|發到一半失敗,系統怎麼知道哪一步已經成功?

  • 分享至 

  • xImage
  •  

一篇主文加兩則回覆,主文發布成功,程式正要送出第二則回覆,連線斷了。畫面停在那裡,沒有錯誤訊息,也沒有成功訊息。

這時候只有一個問題值得問:要不要按第二次?

不按,如果剛剛其實已經發布成功,這則回覆就永遠卡在半路,鏈斷在那裡。按,如果剛剛真的失敗了,這樣做沒問題;但如果剛剛其實已經成功,只是回應沒送到,重按一次可能會讓平台上多出一篇一模一樣的回覆,沒有人要求要有第二份。

系統手上只有兩個事實:主文的識別碼確定存在,第二則回覆的狀態不確定。這不是「發文失敗」,也不是「發文成功」,是第三種局面,而且它不會自己消失。

Day 25 留下的六格,卡住的是哪一格

Day 25 把「發」拆成六格:預覽、批准、建立、等待、發布、回覆,也留下一句話:published: true 太粗,沒說清楚哪一步真的完成。

今天要接的問題很具體:如果中間某一格卡住了,系統要怎麼知道自己走到第幾格,而不是連猜。上面那個斷線的例子,卡住的正是「等待」跟「發布」之間。容器可能已經變成已發布,也可能還在半路,兩種情況從呼叫端看起來一模一樣:都是沒收到回應。

我先去翻自己那支發文腳本

在猜之前,我去看正式在用的 publish_thread.py 現在怎麼處理這件事。

鏈式更新只認已發布的新識別碼。reply_to 一開始指向主文發布後拿到的識別碼(第 264 行),迴圈裡每發布成功一則留言,才把 reply_to 換成那則留言剛拿到的新識別碼(第 294 行)。下一則要接在哪裡,不是查表決定,是等上一步真正完成才知道。這已經是一種最小對帳,只是它只往前看,不往回查。

失敗時它不會回頭。如果某則留言的容器一直沒有轉成就緒狀態,腳本會直接中止,訊息裡把前面已經發了幾則講清楚(第 291-292 行,原文是「comment{i} 未就緒,中止(前 {i-1} 則已發)」)。它不會刪掉已經發布成功的主文和前幾則留言,外部世界已經看到的內容,程式不假裝可以收回。這只是這支腳本目前寫死的行為,不是唯一正確做法,我也沒有正式環境的執行紀錄能證明這個分支真的被觸發過幾次。但它至少示範了「不回頭」這個決定,可以先用程式碼寫死。

wait_ready() 分不出「這則貼文注定失敗」和「這則貼文可能還在處理,只是等得不夠久」。它最多輪詢 20 次、每次等 5 秒,平台回報「錯誤」和「20 次後仍未完成」,兩種情況目前都回傳同一個 False(第 61、64 行)。呼叫端看到的只有一個布林值,看不出兩種原因的差別。

它也沒有冪等鍵。呼叫平台 API 用的是一般 urllib POST,沒有帶任何冪等鍵或請求識別碼(第 41-45 行)。這是目前程式碼的現狀,不是已經在正式環境測出來的重複事故。沒有冪等鍵不代表平台端一定會真的建出兩篇一樣的內容,但如果網路在送出後、拿到回應前斷掉,人工重跑同一支腳本,沒有任何機制能讓平台認出「這是同一次操作」,這條路沒有任何東西擋著。

專案文件裡還記著一條相鄰但不同的教訓:回覆的 reply_to_id 要填「該則留言的識別碼」,不是主文的識別碼,否則新留言會跟自己的留言變成平行關係,不是掛在它下面(CLAUDE.md:53,2026-07-01 踩過)。這跟今天要拆的問題不是同一件事,那是「填錯值」,這篇談的是「值還沒等到就要往下走」。但兩者指向同一個弱點:每一步的識別碼都不能靠猜,要靠上一步的真實結果。

對帳要問的問題很單純

對帳(reconciliation)要回答的問題只有一句:「本地記錄的意圖」和「外部系統真正發生的事」,這兩份帳對不對得起來?

本地記錄可能只寫著「已送出建立請求」。外部可能是三種之一:已發布、還在處理、已經失敗。對帳先把外部真相查清楚,再決定下一步該接續、補償,還是交給人工。它解決的是「知道發生了什麼」,不解決「怎麼修」,怎麼修是下一步的決定。

Kubernetes 把控制器定義為持續比較「期望狀態」與「目前狀態」、再採取行動縮小差距的迴圈。套到一次發文:本地記錄的意圖是期望狀態,查回來的平台回應是目前狀態,兩者對不上時才需要決定下一步該做什麼。這是通用的抽象,不是為發文設計的,套用時要自己做類比,但方向是對的:不確定的時候,去查,不要猜。

這不是新概念,只是換了場景。Stripe 的撥款對帳報表文件也在做同一件事:核對每一筆撥款跟結算成該筆撥款的交易批次是否對得上;文件也承認即時撥款連 Stripe 自己都無法辨識裡面包含哪些交易。這裡的教訓比較窄,但仍成立:一筆外部動作背後可能有一整批本地記錄要對應,對帳要先確定「這一筆對應的是哪一批」,不能只看狀態表面一致。今天的例子只有一條三則的鏈,範圍小很多,但「先確定對應關係,再判斷是否一致」的順序是一樣的。

兩條路徑,前提不一樣

查完之後,能做的事分兩條路,但前提不一樣,混在一起用會出事。

可以重試,代表這一步還沒確定發生,而且重送的是查詢,不是建立。比如容器還沒就緒就等到逾時,查一次外部真實狀態,確認真的沒有發布紀錄,才重新送一次建立請求。這裡有一個查完到送出之間的時間差:如果原本那個請求其實還在處理、只是還沒回應,重送就會變成沒有冪等鍵保護下的真實重複建立,正是上面那支腳本已經暴露的弱點。

要補償,代表這一步已經確定發生,不能假裝沒發生過。比如某則留言真的發布成功,後面接的留言失敗了。這時候不會嘗試刪除已經發布的留言,只會標記它是一條斷掉的鏈,等待人工接手。publish_thread.py 中止時保留前面已發的內容,做的正是這件事,只是它連「中止並保留」都還沒有標成一個正式的補償狀態,只是印一行訊息就結束。

Stripe 的冪等文件說明,帶著同一個 Idempotency-Key 重送請求,伺服器會回傳第一次請求儲存下來的原始結果,不會重新執行一次。一份 IETF 草案(第 07 版已過期存檔,只能當設計參考,不是正式標準)把行為分成兩種:原請求已完成時的重試直接回原結果,原請求還在處理中時收到的並發重試回衝突錯誤。這正是今天要拆的模糊地帶:「還在處理」和「已經失敗」需要被系統分開處理,不能都當同一種情況。這支發文腳本兩種都沒有,所以查詢跟決定只能自己補。

跟前兩天處理的問題長得像,其實不一樣

Day 14 處理的是「建立前」的冪等:同一個批准重送,不能建立兩個一樣的待辦。今天處理的是「建立後」的對帳與重送:容器已經建立、甚至已經發布,只是呼叫端不確定結果。問題不是「要不要建立」,是「已經發生的事,要怎麼查出來、接上」。查完之後如果要重送,一樣要面對沒有冪等鍵的風險,只是風險出現的時間點不一樣。

Day 17 的恢復是從一個已知的檢查點接著跑,中斷發生在本地流程裡,外部世界沒有被動到。今天的中斷發生在跟外部系統互動的當下,外部可能已經留下副作用,本地卻不知道。今天要在「接續」之外,正式擺出「補償」與「交給人工」兩個選項。

手動注入四種故障,看對帳邏輯怎麼判

光講原則不夠,我在 Day 25 的假發布客戶端上手動注入四種故障,每種都對一組「主文加兩則回覆」的合成鏈動手腳,看對帳邏輯查到什麼、判定接續哪一種、跟我事先寫好的答案是否吻合。

假客戶端純記憶體、不連網、不讀 token,跟 Day 25 一樣。四個故障都發生在 reply-1 身上,main 一律正常發布成功,這樣才能單獨看清楚「一則卡住的留言」會怎麼被處理,不被其他變數干擾。

每個情境要回答三個問題:外部真實狀態查到了什麼、系統判定接續、補償還是人工介入的哪一種、這個判定跟事先定義的正確答案是否一致。

案例 故障情境 對帳查到的外部真相 系統判定 是否吻合事先答案
container-error-confirmed-retry 建立容器後狀態查詢直接回錯誤 ERROR(確定失敗,不是還在處理) RETRY:重新開一次建立,安全 吻合
poll-exhausted-still-pending 輪詢用盡次數上限仍未就緒 IN_PROGRESS(仍未到任何終態) RETRY_QUERY:只再查,不動手 吻合
publish-response-lost-then-found 發布其實成功,回應卻在路上遺失 PUBLISHED(查到真實識別碼) CONTINUE:採用查到的識別碼接續 吻合
resend-without-reconcile-creates-duplicate 沒對帳就直接重送發布 同一意圖對應兩個已發布項目 HUMAN_REQUIRED:標記重複,不擅自刪除 吻合

四組的決定、理由與終態,4/4 對上我設計案例時就先寫好的答案。假客戶端呼叫次數(12、7、10、8,合計 37 次)是這次實際跑出來的紀錄,留著當回歸基準用,不是手算預測的數字。這點要分清楚,別把兩種性質不同的「對得上」混在一起講。

第一、二組看起來很像,都是容器沒有正常變成已完成,但對帳查到的終態不一樣:一個是確定的 ERROR,一個是還沒有答案的 IN_PROGRESS。這正是 wait_ready() 那個同一個 False 分不出來的兩種情況,對帳把它們重新分開了:確定失敗才能安全重試,還沒確定就只能再等。

第三、四組是同一個起點,都是回應遺失,走了兩條不同的路。第三組先查證再行動,查到已經發布,就用查到的結果往下走,什麼都沒有重複建立。第四組故意示範沒查證直接重送的下場:平台沒有冪等鍵可以擋,真的建出第二份內容。對帳這時候查到的不是「該不該重試」,是「已經重複了」,能做的只剩標記出來、交給人接手,不會自己選一個刪掉。

這四組實驗全部是純記憶體合成,零真實模型、零網路、零正式發布呼叫,跟 Day 25 一樣不能拿來當平台成功率或效能基準,只能驗對帳判斷邏輯本身。

查詢本身也可能不可靠,這篇沒有解決

老實說,對帳邏輯聽起來像是「查一次外部真實狀態就能解決」,但查詢這個動作本身也是一次外部呼叫,一樣可能逾時或失敗。這次的假客戶端刻意讓查詢動作穩定回應,用來單獨驗證對帳判斷邏輯,這代表今天的實驗結果不能直接當成「正式環境查詢一定可靠」的證據。查詢會不會也需要重試、也需要對帳,是另一個問題,今天先放著。

Chris Richardson 在 microservices.io 對 Saga 模式的說明把補償交易定義為一連串本地交易,任何一步失敗就執行補償交易撤銷前面的影響,並直接寫明沒有自動回滾機制,補償交易要工程師自己設計。今天鏈式發文失敗後選擇標記斷鏈、交給人工,是最小可行的補償設計,不是自動化的解法。AWS 官方文件在說明 Saga 協調模式時也提到,每個參與步驟都要冪等,因為意外當機時同一步可能被重複執行。對照這條建議,這支發文腳本沒有冪等鍵,代表這條鏈式呼叫本來就不適合自動重試,只能先靠對帳查清楚,再決定下一步。

重複送達也不是這篇獨有的問題。AWS SQS 的官方文件講標準佇列因為多副本容錯,可能重複送達同一則訊息;Google Cloud Pub/Sub 的文件也說訊息可能在已經確認收到之後又被重送一次。這支發文腳本沒有訊息佇列,重複的來源不同,是人工重跑,不是佇列重送,但後果一樣:沒有冪等保護的一方,要自己承擔重複的風險。

另外兩個方向今天沒有處理。Google 的《SRE Book》提醒天真的重試邏輯會讓已經失敗的請求疊加成更高流量,建議一律用隨機化的指數退避;AWS 架構部落格的實測顯示,沒有加入隨機延遲的退避會讓所有客戶端同步在同一時間重試,加入隨機延遲後總呼叫次數能減少超過一半。wait_ready() 是固定間隔輪詢 20 次,沒有退避機制。這不影響今天的對帳判斷,但代表逾時之外,等待策略還有可以改進的地方。

另一個專案,同一個取捨

另一個專案的每日排程有類似的取捨。內容備份到私有倉庫,跟網站部署是兩個獨立步驟,SOP.md 明寫「部署失敗照樣備份」;從備份失敗時的處理方式(內容留在本機、事後手動補推)可以推知反過來也成立:備份沒推上去,不影響已經完成的部署。

這跟今天的對帳邏輯呼應同一個取捨:一條鏈裡,已經發生的事不該因為後面的事沒發生就被一起判定失敗。發文腳本裡「主文成功、留言中止」保留前面戰果,跟這裡「部署成功、備份失敗」保留部署戰果,是同一個原則的兩個現場。

今天留下的東西,跟明天要接的問題

今天解決的是「同一個環境裡,發到一半失敗要怎麼判斷已經成功的部分」:查外部真相,分清楚確定失敗跟還沒確定,分清楚已經成功跟真的重複,交給人的時候老實交給人,不擅自刪東西。

明天要處理的是另一個問題:同一段邏輯,本機能跑,換一個環境卻可能走上完全不同的路。設定鍵、秘密怎麼進來、程式版本、部署產物,任何一個不對,本機測過也沒有用。那是環境契約,不是對帳。

參考資料


上一篇
Day 25|為什麼不能讓 AI 寫完就直接發文?
下一篇
Day 27|本機能跑,上線後為什麼變成另一條流程?
系列文
情報進來,內容發出去:30 天拆一條每天真的在跑的 AI 內容產線30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言