ソフトウェアをリリースしました

かいふく という訪問介護の副業に特化した求人サイトを公開しています。

お近くの介護資格保有者にご紹介頂けると嬉しいです 👏

https://kai-fuku.com/

このサイトにはプロモーションが含まれます

こんにちは!バックエンドエンジニアとして日々データベースと向き合っている皆さん、お疲れ様です。

先日、同僚との雑談の中でこんな一言が飛び出し、思わずコーヒーカップを持つ手が止まりました。

「単一テーブルの更新なら、わざわざトランザクション貼らなくても良くない?」

なるほど、確かに。「ユーザーの名前を変更する」みたいなSQLが1行だけなら、データベースの自動コミット(Auto Commit)が働くし、失敗してもエラーが返るだけ。
わざわざ BEGIN~COMMIT で囲う必要なんてない、という意見も一理あるように聞こえます。

でも、「本当にそれで大丈夫?」 と問いかけると、実は冷や汗をかくような落とし穴が潜んでいるんです。

今日は、私がなぜ「単一テーブルでも(場合によっては)トランザクション推奨派」なのか、技術的な挙動と「防御的プログラミング」の観点から整理してみたいと思います。


単一テーブル更新時(単純なUPDATE/INSERT):実は同僚が正しかった?

まず、同僚が言っていた「単一テーブルへの単純な更新」についてです。

UPDATE users SET name = 'Tanaka' WHERE id = 1;

正直に言います。
データの物理的な整合性(ACID特性)という点では、同僚の言う通り「トランザクションは必須ではありません」

現代のRDBMS(MySQLやPostgreSQLなど)は非常に優秀です。
この1行のSQLが実行されるとき、DB内部では自動的にトランザクションのような処理が走り、以下を保証してくれます。

  • データ行の書き換え
  • インデックス(B-Treeなど)の更新
  • 一意制約などのチェック

これらは「成功するか、失敗するか」のどちらかであり、「データは書き換わったけどインデックスが壊れた」なんてことは起きません。

それでも私が「トランザクション枠」を作る理由

では、なぜ私はそれでもトランザクションを貼ることが多いのか。それは「未来のバグを防ぐため(防御的プログラミング)」です。

今は「名前の変更だけ」かもしれません。
でも半年後、仕様変更で「名前変更の履歴もログテーブルに残して」と言われたら?

もしトランザクションの枠組み(DB::transaction など)がないコードだと、改修担当者がうっかり履歴保存処理を単に追加するだけで、トランザクション漏れを起こすリスクがあります。

最初から「更新処理の塊」として定義しておけば、処理が増えても安全です。
「転ばぬ先の杖」として、設計の意図をコードに残す意味でも価値があると思っています。

やんやん

プログラマーとしてLEMP環境に主に生息しており、DevOps 的な立ち回りをしながらご飯を食べている当ブログの管理人のやんやんと申します。
最近はTmux使うのを辞めました。

単一テーブル更新時(SELECTが絡むとき):ここが最大の落とし穴

「単一テーブルだから大丈夫」と油断して一番痛い目を見るのが、Read-Modify-Write(読んで、加工して、書く)のパターンです。

例えば、銀行口座や在庫の管理など、「今の値を取得して、計算して、更新する」処理です。

  1. SELECT: 現在の残高を取得(例:1000円)
  2. App: アプリ側で500円引く計算(1000 - 500 = 500)
  3. UPDATE: 残高を「500円」で更新

これを安全に行うには、読み取る時点で排他ロック SELECT ... FOR UPDATEが必要です。

トランザクションがないとロックは機能しない!

ここで重要なのが、「ロックの寿命はトランザクションの終わりまで」というルールです。

もし明示的にトランザクションを貼らずに FOR UPDATE を投げたとしましょう。

-- トランザクションなしの場合

-- ① ロック付きで読む... つもりだが
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- ★ ここでAuto Commitが働き、即座にトランザクション終了&ロック解除!

-- (この計算処理中に、他の人が割り込めてしまう!)

-- ② 更新
UPDATE accounts SET balance = 500 WHERE id = 1;

このように、トランザクションの枠がないと、せっかくのロックが一瞬で外れてしまい、競合(Race Condition)によるデータ不整合を防げません。

単一テーブルであっても、「ロジックを含んだ更新」をするならトランザクションは必須なのです。

SELECT * FROM users WHERE id = 1 FOR UPDATE;
use Illuminate\Support\Facades\DB;
use App\Models\User;

DB::transaction(function () use ($userId) {
// ▼ ここでロック
$user = User::lockForUpdate()->find($userId);

/**
* この時点でレコードはロックされています。
* 他のトランザクションは、この行を lockForUpdate しようとすると
* ここで待機状態になります。
*/

$user->balance = $user->balance - 500;
$user->save();
}, 3); // デッドロック対策。リトライ処理

複数テーブル更新時:言わずもがなの大本命

最後に、これはもう「必須」と言って反対する人はいないでしょう。複数テーブルにまたがる更新です。

  • users テーブルにユーザーを作成
  • profiles テーブルに初期プロフィールを作成

もしトランザクションがなかったら、「ユーザーは作れたけど、プロフィール作成でエラーが出た」という状態で処理が終わり、「プロフィールを持たない不完全なユーザー」がDBに残存してしまいます。

これを防ぐのがトランザクションの 「All or Nothing(全部やるか、全部やらないか)」 という鉄の掟です。ここは議論の余地なしですね。


トランザクションは「お守り」ではなく「シートベルト」

「単一テーブルならトランザクションはいらない」という意見は、単純な値のセット(Setter的な動作)においては技術的に正解です。

しかし、バックエンドエンジニアとしてのスタンスは以下の通りが良いのではないでしょうか。

  1. 単純な更新(UPDATE一発)なら: 必須ではないが、将来の拡張性を考えて貼っておくと安心。
  2. 読んでから書く(SELECT + UPDATE)なら: ロックを維持するために絶対必須。単一テーブルでも関係ない。
  3. 複数テーブルなら: 絶対必須

DBはアプリケーションの心臓部です。「たかが単一テーブル」と侮らず、愛と敬意(そして適切なロック)を持ってトランザクションを貼っていきましょう!

おすすめの記事