Laravel交易裡捕捉例外仍寫入?確認rollback的邊界

表單一次要新增訂單和明細,明細寫入失敗,訂單卻留在資料庫。程式已經包了 DB::transaction(),看起來應該一起取消,為什麼仍有半張訂單?先找交易閉包裡的 catch。如果例外在裡面被捕捉,接著正常走到閉包結尾,Laravel看到的是一次成功完成的工作,就可能提交前面的寫入。

交易不會閱讀你回傳的錯誤字串,也不知道畫面上顯示「儲存失敗」。它依靠資料庫操作與例外控制流程決定怎麼收尾。要讓同一交易中的前一步取消,失敗必須離開交易閉包,或由你正確管理手動交易;本文先用自動交易的方法把這件事分清楚。

先認清是哪一個catch接住失敗

下面是容易出問題的寫法。notes只是示範表,故意在第一次寫入後拋出例外,代表下一步失敗。這個例子用來看流程,不包含真實付款或訂單處理。

DB::transaction(function () {
    try {
        DB::table('notes')->insert(['title' => 'first step']);
        throw new RuntimeException('second step failed');
    } catch (Throwable $e) {
        // 只記錄,沒有重新拋出。
        report($e);
    }
});

內層 catch 執行完後,閉包正常返回。對交易方法而言,沒有例外穿過它的邊界。程式雖然記錄了錯誤,第一筆資料仍可能被提交。把 catch 改成回傳 false、一個錯誤陣列或錯誤訊息,並不會自動把「回傳失敗」變成 rollback;這些都是正常的回傳值。

常見的誤判是看到日誌有一筆例外,就認為交易也收到例外。日誌只說明某處呼叫了記錄方法。要核對的是例外最後有沒有再被拋出,以及交易閉包結束時走的是正常回傳還是例外路徑。搜尋 try、catch、return,通常比先調整資料庫隔離層級更快找到問題。

把需要對使用者回應的catch放在外面

讓 Laravel先處理回滾,再把失敗轉成控制器的回應。例外可以保留原始類型與訊息,使用者畫面則不必把資料庫細節顯示出來。

try {
    DB::transaction(function () {
        DB::table('notes')->insert(['title' => 'first step']);
        throw new RuntimeException('second step failed');
    });
} catch (Throwable $e) {
    report($e);
    // 控制器在這裡回傳合適的失敗回應。
}

第二步的例外離開閉包,交易方法會處理 rollback,然後把例外再交到外面的 catch。外層可以記錄、通知維護者,或依應用程式規則回傳錯誤。這樣不用把 HTTP 回應和交易成功判定混在同一層。

如果業務上需要在閉包裡補上記錄或資源清理,也可以捕捉後 throw $e;,使原例外繼續向外傳。別在這裡為了避免報錯而吞掉所有 Throwable。能恢復的業務情況與必須取消交易的故障,應有各自的處理方式;不應靠「有沒有寫日誌」區分。

用一張測試表看清三種結果

在自有測試環境準備只有 id、title 的 notes 表。每個情況開始前清空表,依序跑「閉包內吞例外」、「例外交到閉包外」、「兩步正常完成」,最後查列數。清空資料只在這張測試表做,不要對正式業務表照抄。

可在自有測試站的 Artisan Tinker 載入 DB 與 Schema facade,再建立專用測試表。先確認沒有同名表;如果已存在,換一個測試表名並同步修改範例,避免覆蓋資料。不要在正式站測試這段建立與清空表的流程。

本文的隔離案例使用 PHP 8.4.2、Laravel 13.34.0 與記憶體 SQLite。吞掉例外的情況留下 1 列;外層捕捉留下 0 列;正常寫入留下 1 列。完成後交易層級為 0。這三個結果比較的是 Laravel交易控制流程,不代表你的 MySQL引擎、索引、併發或完整訂單系統已經驗證。

$count = DB::table('notes')->count();
$level = DB::transactionLevel();

列數回答「這次寫入是否留下」,交易層級則協助確認流程是否收束。只查其中一個不夠:層級為零仍可能是已提交,畫面顯示失敗也仍可能留下資料。若使用自動化測試,斷言應檢查業務資料是否存在,並涵蓋成功情況,避免把所有提交都當成錯誤。

這個案例故意自己拋出例外,所以不需要用壞密碼或破壞資料庫來製造失敗。等流程清楚後,再依測試站的表結構補上真實 constraint 或業務驗證失敗情況。測試時要記住例外來源:應用程式主動拒絕、SQL 操作失敗、外部服務回覆失敗,後續處理可能不同。

回滾只處理它能管理的資料

交易內的操作必須使用同一個受管理的資料庫連線。若前一段用預設連線,後一段另開 DB::connection('other'),不能期待預設連線的 rollback 一次取消另一個資料庫的寫入。需要多個系統一起完成的流程,要另行設計補償、重試與狀態,而不是多包一層閉包就宣稱全部原子完成。

同樣地,寄出去的郵件、已上傳的檔案和已送出的外部 API 呼叫,不是資料庫列。資料庫 rollback 不能把對方已經收到的操作收回。若工作只應在交易提交後執行,可依佇列設定使用 after_commit 或適合的提交後派送方式;Laravel官方佇列文件也說明,回滾時,設定為等待提交的交易內派送工作會被丟棄。

這不等於所有外部動作都自動變安全。郵件是否透過佇列、工作採用哪個連線、何時派送,仍要看你實際的設定。發送付款要求等會造成外部效果的操作,還需要處理重送,避免第一次已成功但回應中斷後又送一次。

某些資料庫語句也可能造成隱式提交。Laravel資料庫文件特別提醒,不要把會讓引擎自行提交的操作塞進一般交易,就假定框架層級仍能完整回滾。本文的資料列案例沒有執行這類 DDL;正式修改表結構另走 migration 與備份流程。

決定失敗是否真的要取消整筆工作

不是每個被捕捉的例外都需要 rollback。例如選填的附加說明無法取得,而你明確允許只儲存主資料,程式正常完成可能正是預期。但這個規則要寫成具體的業務判定,讓讀者或維護者知道哪些資料可缺、哪些絕不能拆開。

若需求是「訂單與明細一起成功」,明細失敗就應讓交易失敗,不能回傳成功頁,也不能留下只有主單的狀態。先把這個條件落在測試的資料斷言,再安排對使用者的回應。將 catch 移到外層通常就能修正吞例外的問題;如果仍有半套資料,下一步查連線範圍、資料表是否支援交易,以及是否有交易之外的寫入。

維護既有網站時,別急著對已留下的資料批量刪除。先列出受影響紀錄、確認是否已有付款或其他關聯,再按業務規則處理。修正程式防止新問題,與清理歷史資料是兩項工作;前者成功不能代替後者的核對。

參考資料

原文鏈接:https://wntheme.com/laravel-transaction-exception-rollback/,轉載請註明出處。
0

評論0

顯示驗證碼
沒有帳號?註冊  忘記密碼?