はじめに
Laravel には,データベーストランザクションの管理方法が大きく分けて 2 種類あります.
-
DB::transaction()
を使う方法 -
DB::beginTransaction()
,DB::commit()
,DB::rollBack()
を自分で組み合わせる方法
僕は圧倒的に DB::transaction()
派なんですが,レビューで指摘すると「なぜそこまで強く推すのか」と聞かれることがあるのでここに僕の考えをまとめておこうと思います.
今後聞かれたら「この記事読んで!」ができるぞ〜〜(怠惰)
簡単なサンプル
以下に, DB::transaction()
を使ったパターンと(以下トランザクションの自動管理,自動管理) DB::beginTransaction()
, DB::commit()
, DB::rollBack()
を使ったパターン(以下トランザクションの手動管理,手動管理)を簡単に書いてみます.
自動管理
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::transaction(function () {
DB::update('update users set votes = 1');
DB::delete('delete from posts');
});
手動管理
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::beginTransaction();
try {
DB::update('update users set votes = 1');
DB::delete('delete from posts');
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
まぁぱっと見で,明らかに自動管理のほうが楽じゃーんと言う感じではあるんですが...
手動管理のデメリット
まず,手動トランザクションのデメリットとして, commit, rollback を書き忘れる可能性があります.
本人は気をつけているつもりでも,特にネストしていたり分岐が複雑になっている場合に抜け漏れが発生することは十分に考えられます.
commit のし忘れはもちろんデータが保存されないので致命的なバグになりますし, rollBack のし忘れも,変にデータが残ってしまう可能性があって良くないですね.
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::beginTransaction();
try {
DB::update('update users set votes = 1');
DB::delete('delete from posts');
} catch (\Throwable $e) {
DB::rollBack();
throw $e;
}
// コミットされないのでデータは残りません
実際, rollBack が呼ばれなかったときは最終的には勝手にrollbackされますが,ネストしている場合は別です.知らないうちに commit されてしまう可能性は十分に考えられます.
開発者が意図的に commit や rollback のタイミングを細かく制御したい場合は別ですが.そもそもそんな要件はあんまりないんじゃないかなと思います.
あとは,例えば
try {
DB::beginTransaction();
// do something
DB::commit();
} catch (\Throwable) {
DB::rollback();
throw $e;
}
このコードには,実は問題点があります.
これは,もし DB::beginTransaction()
の呼び出しに失敗してエラーが発生したとき,トランザクションを開始していないのに DB::rollBack() が呼ばれます.
トランザクションがネストしていない場合は致命的な問題はないかもしれませんが,ネストしていたときに問題が発生します
以下は, DB::rollBack()
の実装です.
public function rollBack($toLevel = null)
{
$toLevel = is_null($toLevel)
? $this->transactions - 1
: $toLevel;
if ($toLevel < 0 || $toLevel >= $this->transactions) {
return;
}
try {
$this->performRollBack($toLevel);
} catch (Throwable $e) {
$this->handleRollBackException($e);
}
$this->transactions = $toLevel;
$this->transactionsManager?->rollback(
$this->getName(), $this->transactions
);
$this->fireConnectionEvent('rollingBack');
}
Laravel では,トランザクションのレベルを管理することでネストしたトランザクションを実現しています.
トランザクションがネストしていない場合は何もせず return
されますが, ネストしている場合はトランザクションレベルが 1 下がるので,全体で辻褄が合わなくなり整合性が取れなくなります.
まとめ
もちろん,複雑な分割やネストなどの手動トランザクション管理が必要な場合には,DB::beginTransaction()
, DB::commit()
, DB::rollBack()
を使った実装は大変有益です.
しかし,多くの場合そのような複雑なトランザクション管理は必要でないと思いますし,むしろもっと簡単に,DB::transaction()
を使った実装にできるのであれば,ミスや保守性,可読性の観点からそのように修正すべきです.
コードレビューでチェックすればいい,ちゃんとテストを書けば大丈夫,と言われるかもしれませんが,自分を含めて人間は完璧でないので,使うメソッドでミスが減らせるなら減らしたほうが絶対に良いと思います.
DB::transaction()
はいいぞ!