這次我用 AI 檢查幾個使用 Kafka 的服務。它找出的問題,有些發生在通知送出之前,有些發生在資料更新之後,也有些藏在「成功」的回報裡。

例如,通知還沒送出去,就被去重紀錄擋住;背景工作還沒完成,Kafka 的消費進度就可能往前走;裝置狀態更新了,原本應該產生的告警卻可能在重試時消失。

這些缺陷有對應的程式路徑與觸發條件,但不能直接說正式環境已經因此漏掉多少通知。AI 協助找問題,也不代表原本的程式都是 AI 寫的。這篇整理的是檢查時發現的設計問題與決策,並不表示所有修正都已部署。

有了 AI,人要怎麼避免把錯誤的決定一起寫進程式?叫它「補重試」「加去重」「改成平行處理」,它都能提出做法。但這些指令還缺少一件事:我們到底允許什麼結果發生?

一、什麼時候才能說成功?

我們找到的管理後台問題,是呼叫 Kafka 發送後,沒有檢查那一筆訊息的非同步結果。即使 broker 拒絕了訊息,上層仍可能沿著成功路徑回報。

另一個 Windows Go 後端的問題更直接:Kafka callback 記錄失敗後沒有返回,接著又寫了成功日誌。同一次發送,留下互相矛盾的紀錄。

修正這兩段程式,有既有行為與發送結果可以對照,不需要每一行都回頭問人。但它們提醒我,「成功」必須講清楚是哪一層。

服務接受請求
    ↓
Kafka 接受訊息
    ↓
消費者完成處理
    ↓
外部推送平台接受通知
    ↓
裝置收到通知

每一步都可能成功,卻還沒到使用者以為的那一步。如果畫面要顯示「已提交」,確認 Kafka 接受可能就符合這個承諾。如果畫面要顯示「裝置已收到」,就還需要裝置端的證據。

人要決定產品承諾到哪裡。AI 要查清楚程式目前證明到哪裡。這會影響 API 回傳、畫面文字、等待時間,以及後續需要保存的狀態。我不希望 AI 自己選一個最容易實作的完成點,再把它叫作成功。

二、中途失敗後,系統還記得欠了什麼工作嗎?

有一條管理 API 路徑,先把裝置異動存進資料庫,再發 Kafka 訊息。如果資料庫提交後,程序突然中斷,資料已經改了,通知其他服務的訊息卻可能沒送出去。沒有一起保存的待送工作,重啟後也就缺少恢復依據。

把發送移到資料庫提交之後,解決了「其他服務可能讀到未提交資料」的問題。但提交之後的中斷空隙仍然存在。

狀態與告警服務則有另一種情況。這個服務會根據裝置狀態的變化,判斷是否需要產生告警。原本的流程是:

第一次處理
狀態 A → 更新成 B → 告警處理失敗

重播同一事件
讀到狀態 B → 更新後仍是 B → 沒有變化,不產生告警

訊息重新處理了,原本的告警卻沒有恢復。因為資料庫只留下最新狀態 B,第一次的 A → B 沒有被可靠地保存下來。

這時候,只叫 AI「失敗就重試」並不夠。要恢復的是那個尚未完成的工作,而重新跑一次程式,不一定還能算出同一件事。

人要決定哪些工作不能漏、需要保留多久,以及超過恢復期限後怎麼處理。AI 再根據這些要求,判斷要保存待送事件、前後狀態,還是已經算出的告警工作,並確保保存方式不會留下新的中斷空隙。

像 outbox 這類做法,是把資料異動與待送工作一起存進資料庫的技術選項。人不必替 AI 指定每個欄位,但必須先說清楚:這次資料異動,是否允許沒有後續通知?

三、前一筆失敗,後一筆可以繼續嗎?

我們找到的狀態與告警服務,會把同一個 Kafka 分區的訊息交給多個 worker 平行處理。每一筆成功後,各自標記消費進度。

第 10 筆:失敗,還沒完成
第 11 筆:成功,推進進度

Kafka 保存的是分區中的消費位置。提交較後的位置,不會另外替應用程式保存「第 10 筆還沒做完」的清單。因此,重新啟動後,第 10 筆就可能被跨過。

這裡容易出現一種修法:把平行改成依序處理。但只要第 10 筆失敗後繼續處理第 11 筆,再提交後者,同樣可能跨過失敗的工作。問題還包括失敗時怎麼停、怎麼恢復,以及何時可以繼續。

這也是人需要回答的地方:

  • 同一台裝置的事件,是否必須依序產生效果?
  • 前一筆失敗,可以讓後面的工作等多久?
  • 如果不允許一直等,未完成工作要交到哪裡,由誰恢復?

提高處理量、維持順序、縮短等待時間,可能需要取捨。AI 可以列出方案與影響。如果已有明確契約,就依契約實作;如果沒有,就應該把衝突提出來,不能只因為平行處理比較快,就替產品決定順序不重要。

而且 Kafka 分區裡的順序,不會自動延伸到外部 API 或裝置上的執行順序。要求保序到哪裡,就需要設計到哪裡。

四、同一件事再來一次,應該做還是跳過?

推送服務的去重問題,是這次最容易說明的例子。收到訊息後,它先在 Redis 寫入一筆保留 24 小時的去重紀錄,再查詢推送需要的 token,最後才發送。

第一次處理
收到訊息 → 寫入去重紀錄 → 查詢 token 失敗
                              ↓
                         通知尚未送出

同一筆訊息重播
收到訊息 → 發現去重紀錄 → 跳過
                              ↓
                         通知仍未送出

這條失敗路徑還沒進入既有的推送失敗重試佇列,不能只用「服務已經有重試機制」來解釋。去重原本是為了避免同一件事做兩次,這裡卻把尚未做成的事也擋住了。

所以必須區分「正在處理」和「已經完成」。如果要防止兩個 worker 同時處理,也不能直接把取得處理資格的紀錄當成完成證明。

但把去重移到完成後,也還有一個問題:怎麼知道第一次完成了沒有?例如,外部服務已經接受命令,只是回應在途中遺失。這時收到 timeout,只能知道沒有等到回應,不能證明對方沒做。

我們檢查的 Google 管理介接路徑,丟棄了 API 回傳的 Operation 結果,也缺少完整的命令結果回報。這讓後續查證更加困難。

通知重複一次與裝置被重複下達清除命令,後果不同。不能因為兩者都經過 Kafka,就套同一套重試規則。這裡我做的決定是:

先查證結果;無法判定就記錄待人工處理,不自動重送。

這個決定接受了部分工作可能需要等待人工處理,也限制了 AI 不能用自動重送來解決所有 timeout。人要選擇可以承受的後果,AI 才能設計對應的恢復行為。

動工前,先讓 AI grill 人類

我說的 grill,是要 AI 把會影響設計、卻還沒說清楚的決定問出來。問題不能只丟一句「要不要重試?」。它應該先查現有程式,再帶著具體情境來問:

這個命令可能已經被外部服務接受,只是回應遺失。自動重送可能重複執行;停止重送則可能留下未完成工作。能否查詢結果?查不到時,應該由誰接手?

這樣人才能知道自己正在選什麼。已經有答案的事情,不需要反覆問。既有契約能決定的行為,也不該全部丟回來讓人判斷。

我會把這套要求寫進工作用的 Markdown:

# Kafka 修改前的設計約定

## 先查,再問

沿著實際呼叫路徑,列出:
- 入口、資料異動、Kafka 發送與消費、最終副作用。
- 每一層的成功條件。
- 消費進度何時提交。
- 去重紀錄的意義與有效期間。
- 中斷後,未完成工作從哪裡恢復。

引用目前版本與既有契約。
檢查相關呼叫端、保護措施與恢復機制,
不要只根據局部程式判定缺陷。

## 只 grill 會影響設計的未決事項

- 哪些工作不能遺失?允許延遲多久?
- 哪些效果可以重複?哪些不能?
- 順序必須維持到哪一層?
- 外部結果未知時,如何查證?
- 無法判定或自動恢復失敗時,由誰接手?
- 恢復與去重資訊需要保留多久?

每個問題都提供具體失敗情境、選項與後果。
沿用已確認的決定,不要反覆詢問。
例行實作選擇依既有契約處理,不必逐項請人決定。

## 把人的決定變成可檢查的約束

- 「成功」必須對應明確的完成條件。
- 未完成、也未可靠交接的工作,不得被提交進度跨過。
- 尚未完成的必要工作,必須有中斷後可用的恢復依據。
- 確定尚未產生副作用的失敗,不得被完成去重紀錄擋住。
- 已完成的重複事件,依約定的範圍與期間處理。
- 結果未知的不可逆命令,先查證;無法判定則交人工。

若新證據與既有決定衝突,指出衝突與影響,再處理。
不要默默改變產品保證。

Markdown 能保存決定,但不會自動保證程式符合決定。修改後仍然需要證據;關於怎麼避免把「測試通過」看得太大,我在上一篇 Kafka 文章已經談過。

AI 可以幫我找到問題、整理路徑、提出方案,也可以把我漏想的失敗情況拿出來追問。我要做的是把那些選項的後果看清楚,決定哪些可以接受,再讓它成為實作必須遵守的約束。

下一次叫 AI「補個重試」之前,我會先讓它說清楚:第一次確定沒做成,還是我們不知道它做成了沒有?