最近在處理一個付款流程問題時,遇到一支 API 發生 SQL timeout。
第一時間我先往 SQL query 本身去想,但後來把整個 request flow 跟時間線整理出來後,才發現問題跟前一支付款 API 的 transaction scope 有關。
這次排查剛好把幾個平常分開看的概念串在一起:
- Transaction
- Database Lock
- Third-party API
- Retry
- Compensation
- Idempotency
- Race Condition
這篇就記錄一下整個思考過程。
問題現象
流程大概是這樣:
付款 API
↓
呼叫第三方系統
↓
後續查詢 API
↓
成功頁面
實際發生問題時,付款 API 呼叫第三方系統後等待了很長一段時間。
同時間,後續另一支 API 需要查詢資料,但最後發生 SQL timeout,導致後面的流程沒有繼續。
一開始看到 SQL timeout,很自然會先懷疑:
Query 是不是太慢?
但把兩支 API 的時間線放在一起看後,我注意到前一支付款 API 有使用 transaction,而且兩個流程都會碰到同一筆資料。
開始懷疑 Blocking
我的思路是:付款 API 有 transaction,後面的 API 又在讀相同資料,而且前面的 transaction 還沒有結束,那就有可能是 lock 還沒被釋放。
不過這時候還只能算懷疑,所以還是要去確認 database session、blocking 狀況和實際執行時間。
最後確認到後面的 query 其實大部分時間都在等待前一個 transaction。
也就是說,看到 SQL timeout 時,不能直接等同於「SQL 執行很慢」。有時候 query 本身沒有問題,它只是一直被 blocking。
Transaction Scope 太大
再往前查後,原本流程可以簡化成:
BEGIN TRANSACTION
更新資料庫
呼叫第三方 API
更新資料庫
COMMIT
問題就在第三方 API call 被放進 transaction 裡。
第三方 API 的回應時間是不可控的。可能很快,也可能因為對方系統、網路或其他狀況等很久。
如果它在 transaction 裡面,第三方等多久,我這邊的 transaction 就可能跟著維持多久。這段期間如果 lock 還被持有,其他 request 就可能一起被擋住。
整個狀況大概會變成:
付款 API
↓
Transaction 開始
↓
Update DB
↓
Call 第三方 API
↓
等待很久
↓
Transaction 尚未結束
另一支 API:
查詢相同資料
↓
被 Blocking
↓
等待
↓
SQL Timeout
這次真正要處理的點,就是 transaction 維持時間太長。
第三方 API 為什麼不適合放在 Transaction 裡
我一開始想到的理由很簡單:
第三方 API 不可控。
但往下拆後,實際風險是 transaction 的生命週期會被外部服務一起拉長。
DB operation 本身可能只需要幾百毫秒,但第三方 API 如果等了 10 秒,整個 transaction 可能就跟著卡 10 秒以上。
所以我現在會比較偏向這個原則:
Transaction scope 越小越好,只包真正需要一起成功或失敗的 DB operation。
像 HTTP request、第三方 API、檔案 I/O 這類外部操作,都要特別小心要不要放進 transaction。
移出去後又遇到一致性問題
如果改成:
Call 第三方 API
↓
第三方成功
↓
Update DB
接著就會遇到另一個問題。
假設第三方成功,但 DB update 失敗:
第三方:成功
DB:失敗
這時候兩邊狀態就不一致,而 DB transaction 也沒辦法幫第三方 rollback。
第一個想到的解法:Retry 或 Compensation
我當時第一個想到兩種方向。
第一種是把 DB update 失敗記錄下來,之後再 retry。
第二種是如果第三方有提供 cancel、refund 或 rollback API,就呼叫一個補償操作。
例如:
第三方付款成功
↓
DB update 失敗
↓
Call Refund / Cancel API
這也就是 compensation 的概念。
如果第三方沒有提供 rollback 機制,就比較可能要靠 retry,把自己的 DB 狀態補回來。
Retry 一定要考慮 Idempotency
只要開始 retry,就要想下一個問題:
同一個操作被重跑很多次怎麼辦?
例如 retry job 跑三次,如果每次都新增資料,就可能出現重複紀錄。
所以這時候就要考慮 idempotency。
我的理解是:
同一個操作不管執行一次還是很多次,最後系統狀態都應該一致。
例如可以用第三方交易編號當 unique key:
TransactionId = ABC123
同一個 ABC123 不管 retry 幾次,都只能代表同一筆交易。
為什麼先查再 Insert 還是不夠
很直覺的寫法可能是:
if (!exists)
{
Insert();
}
但這樣還是可能有 race condition。
例如兩個 request 同時進來:
Request A:查不到
Request B:也查不到
Request A:Insert
Request B:Insert
兩邊查詢當下都沒資料,所以最後還是有可能重複。
因此 application layer 可以先檢查,但資料庫還是要有最後一道保護。
例如:
UNIQUE CONSTRAINT
或:
UNIQUE INDEX
讓 DB 直接保證同一個 transaction id 只能存在一筆。
Unique Constraint 撞到後怎麼處理
如果 retry 時撞到 unique constraint,我原本會直覺想:
那就當這次 retry 成功。
但這裡還是要再確認一次既有資料。
例如確認:
TransactionId 一樣
Amount 一樣
User 一樣
Status 是預期狀態
如果資料一致,就可以合理視為之前其實已經成功過。
如果 transaction id 一樣,但內容卻不一致,那就是另一個資料一致性問題,不能直接忽略。
這次最大的收穫
這次一開始只是看到:
SQL Timeout
後來一路往前追變成:
SQL Timeout
↓
Blocking
↓
Long Transaction
↓
Third-party API 等太久
接著為了解 transaction scope,又一路延伸到:
Third-party API 移出 Transaction
↓
跨系統狀態不一致
↓
Retry / Compensation
↓
Idempotency
↓
Race Condition
↓
Unique Constraint
這些東西平常分開看都不算陌生,但真的放進同一個 production issue 裡,就比較能理解它們之間的關係。
總結
這次我整理下來幾個重點:
- SQL timeout 不一定代表 query 本身慢,也可能是在等 lock
- Transaction scope 要盡量小
- Third-party API 這類不可控操作要小心放進 transaction
- 跨系統一致性通常要靠 retry 或 compensation
- 只要有 retry,就要考慮 idempotency
- Application layer 的 exists check 不能完全避免 race condition,DB constraint 還是很重要
這次之後,我在看 production issue 時會更習慣先把整個 request flow 跟時間線拉出來。
最後爆出 error 的地方,很多時候只是結果,真正的 root cause 可能在更前面的流程。