2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42日でバックエンドエンジニアの基礎を完全に理解する #11 - Goでトランザクションとエラーハンドリング

2
Last updated at Posted at 2026-08-20

この記事で学ぶこと

#9 でトランザクションの概念を学びました。銀行振込のように「全部成功するか、全部なかったことにするか」の二択にする仕組みです。今回はそれをGoのコードで実装します。

概念を知っていることと、正しく実装できることの間には壁があります。特にGoのトランザクションには defer tx.Rollback() というイディオムがあり、なぜその形なのかを理解せずに書くと、エラー時にロールバックされないコードができあがります。

この記事を終えると以下ができるようになります。

  • db.Begin() / tx.Commit() / defer tx.Rollback() のイディオムで送金処理を安全に実装できる
  • tx.Exec と db.Exec の取り違えという典型事故を理解し、防げる
  • BeginTx と context でトランザクションにタイムアウトを設定できる
  • 「トランザクション内で外部APIを呼ばない」という実務の鉄則を説明できる

この回はGo編です。Pythonで進めている方は「#11 - Pythonでトランザクションとエラーハンドリング」をご覧ください。

見出しの 🔴🟡⚪ は学習の優先度です。🔴は確実に覚える、🟡は理屈を理解する、⚪は今は流してOK。迷ったら🔴だけ確実に押さえてください。


準備

PostgreSQLコンテナを起動して、psqlに入ります。

docker start pg-practice
docker exec -it pg-practice psql -U postgres -d testdb

今回は銀行口座のテーブルを使います。

DROP TABLE IF EXISTS accounts;

CREATE TABLE accounts (
    id      SERIAL PRIMARY KEY,
    name    TEXT NOT NULL,
    balance INTEGER NOT NULL CHECK (balance >= 0)
);

INSERT INTO accounts (name, balance) VALUES
    ('田中', 10000),
    ('鈴木', 5000);

CHECK (balance >= 0) は制約です。「残高は0以上でなければならない」というルールをDB自身に守らせます。残高がマイナスになるような更新は、DBがエラーで拒否します。後ほどこの制約を使って「途中で失敗するトランザクション」を再現します。

SELECT * FROM accounts;
 id | name | balance
----+------+---------
  1 | 田中 |   10000
  2 | 鈴木 |    5000

\q でpsqlを抜けます。


🟡 トランザクション無しだと何が起きるか

まず「やってはいけない実装」から見ます。田中さんから鈴木さんへ3000円送金する処理を、トランザクション無しで書くとこうなります。

// 田中から3000円引く
db.Exec("UPDATE accounts SET balance = balance - $1 WHERE name = $2", 3000, "田中")

// ← ここでプログラムがクラッシュしたら?

// 鈴木に3000円足す
db.Exec("UPDATE accounts SET balance = balance + $1 WHERE name = $2", 3000, "鈴木")

1つ目のUPDATEの直後にプログラムが落ちた状況を、実際に再現しました。

=== トランザクション無しの送金(途中でクラッシュ) ===
送金前:
   田中: 10000円
   鈴木: 5000円

!!! ここでプログラムがクラッシュ !!!

クラッシュ後:
   田中: 7000円
   鈴木: 5000円

田中さんの3000円が引かれたのに、鈴木さんには入っていません。3000円が消えました。 #9 で見たとおり、これがトランザクションで防ぎたい事故です。

クラッシュは大げさな例ではありません。サーバーの再起動、デプロイのタイミング、OOMキル、ネットワーク断。本番環境では「処理の途中で止まる」ことが日常的に起こります。


🔴 Goのトランザクション3点セット

Goの database/sql では、3つのメソッドでトランザクションを扱います。SQLの BEGIN / COMMIT / ROLLBACK にそのまま対応します。

メソッド 対応するSQL 意味
db.Begin() BEGIN トランザクションを開始し、*sql.Tx を返す
tx.Commit() COMMIT ここまでの変更を確定する
tx.Rollback() ROLLBACK ここまでの変更を全部なかったことにする

重要なのは、Begin() が返す *sql.Tx です。トランザクション中のSQLは、すべてこの tx を通して実行します。tx.Exec、tx.Query、tx.QueryRow と、#10 までに使ってきたメソッドと同じ名前のものが tx にも生えています。

db ではなく tx を使う。 これがトランザクション実装の大原則です。理由は後ほど、破るとどうなるかの実例つきで説明します。


【手を動かす①】送金処理を実装する

main.go を作ります。

package main

import (
	"database/sql"
	"fmt"
	"log"

	_ "github.com/lib/pq"
)

func main() {
	db, err := sql.Open("postgres",
		"host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable")
	if err != nil {
		log.Fatal("接続設定に失敗:", err)
	}
	defer db.Close()

	if err := db.Ping(); err != nil {
		log.Fatal("DBに到達できません:", err)
	}

	if err := transfer(db, "田中", "鈴木", 3000); err != nil {
		log.Fatal("送金失敗:", err)
	}
	fmt.Println("送金成功")
}

// transfer は from から to へ amount 円を送金する
func transfer(db *sql.DB, from, to string, amount int) error {
	// ① トランザクションを開始する
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("トランザクションの開始に失敗しました: %w", err)
	}
	// ② 関数を抜けるとき、コミットされていなければロールバックする
	defer tx.Rollback()

	// ③ 出金(db.Exec ではなく tx.Exec)
	if _, err := tx.Exec(
		"UPDATE accounts SET balance = balance - $1 WHERE name = $2",
		amount, from,
	); err != nil {
		return fmt.Errorf("出金に失敗しました: %w", err)
	}

	// ④ 入金
	if _, err := tx.Exec(
		"UPDATE accounts SET balance = balance + $1 WHERE name = $2",
		amount, to,
	); err != nil {
		return fmt.Errorf("入金に失敗しました: %w", err)
	}

	// ⑤ 両方成功したので確定する
	if err := tx.Commit(); err != nil {
		return fmt.Errorf("コミットに失敗しました: %w", err)
	}
	return nil
}

実行します。

go mod init tx-practice
go get github.com/lib/pq
go run main.go
送金成功

実行前後の残高はこうなりました。

送金前:                送金後:
   田中: 10000円          田中: 7000円
   鈴木: 5000円           鈴木: 8000円

出金と入金の両方が反映されています。

🔴 なぜ defer tx.Rollback() なのか

②の行が今回の最重要ポイントです。「エラーのときだけRollbackを呼ぶ」のではなく、Beginの直後に無条件でdeferするのがGoのイディオムです。

tx, err := db.Begin()
if err != nil {
    return err
}
defer tx.Rollback()   // ← Begin が成功したら、必ずこれを書く

疑問が2つ湧くはずです。

疑問1:成功したときもRollbackが呼ばれてしまうのでは?

呼ばれます。しかしCommit済みのトランザクションへのRollbackは何もしません。実際に確かめました。

=== Commit 済みトランザクションへの Rollback ===
Rollback の戻り値: sql: transaction has already been committed or rolled back
sql.ErrTxDone と一致するか: true

sql.ErrTxDone(決着済み)というエラーが返るだけで、DBには何も起きません。つまり defer tx.Rollback() は「コミットまで辿り着けなかった場合の保険」として安全に機能します。この戻り値は無視して構いません。

疑問2:エラーのときだけ明示的にRollbackすればいいのでは?

書けなくはないですが、こうなります。

// deferを使わない場合:全部のエラー分岐にRollbackが要る
if _, err := tx.Exec(...); err != nil {
    tx.Rollback()   // ← 書き忘れたら?
    return err
}
if _, err := tx.Exec(...); err != nil {
    tx.Rollback()   // ← ここにも
    return err
}

エラー分岐が増えるたびにRollbackを書く必要があり、1箇所でも忘れるとそのトランザクションは開きっぱなしになります。開きっぱなしのトランザクションはコネクションを占有し続け、他の処理がロック待ちで詰まる原因になります。

defer なら、途中のどこでreturnしても、たとえpanicが起きても、関数を抜ける瞬間に必ずRollbackが走ります。「異常系の後始末を1行で網羅する」のがこのイディオムの価値です。

defer tx.Rollback() のイディオムの図。db.Begin() の直後に defer tx.Rollback() を登録してから処理を分岐する。(A) tx.Exec が成功して tx.Commit() まで到達した経路では、関数を抜けるときに発動する Rollback はコミット済みなので何もしない(no-op)。(B) 途中でエラーになり return した経路では、同じ defer の Rollback が実際に働いて変更を巻き戻す。だから Begin の直後に defer で1行書いておけば、どの経路で抜けても戻し忘れが起きない


【手を動かす②】失敗時にロールバックされることを確認する

今度はわざと失敗させます。田中さんの残高は10000円ですが、20000円送金してみます。

err := transfer(db, "田中", "鈴木", 20000)

出金のUPDATEで残高が -10000 になろうとしますが、テーブルには CHECK (balance >= 0) があるため、DBがこの更新を拒否します。実行結果です。

=== トランザクションで送金(途中で失敗) ===
送金前:
   田中: 10000円
   鈴木: 5000円

送金失敗: 出金に失敗しました: pq: new row for relation "accounts" violates check constraint "accounts_balance_check"

失敗後:
   田中: 10000円
   鈴木: 5000円

注目してほしいことが2つあります。ひとつは、エラーメッセージに文脈が積み重なっていることです。「出金に失敗しました」という自分で付けた文脈に、CHECK制約違反というDBからの原因が %w のラップで繋がり、どの工程でなぜ失敗したかが一目で分かります。

もうひとつは、残高が両者とも送金前のままなことです。transfer がエラーでreturnした瞬間、defer tx.Rollback() が働いて出金が取り消されました。

エラーハンドリングとトランザクションが噛み合うと、**「失敗しても中途半端な状態が残らない」**を実現できます。

🟡 %w でラップする理由

fmt.Errorf の %w は #10 のQ5でも使いましたが、トランザクション処理では特に重要です。

return fmt.Errorf("出金に失敗しました: %w", err)

%w で包んでおくと、呼び出し元が errors.Is / errors.As で元のエラーの種類を判別できます。たとえば「CHECK制約違反なら400 Bad Request、接続断なら500」のように、原因によってAPIのレスポンスを変えられます。%v で文字列化してしまうと、この判別ができなくなります。


🔴 tx.Exec と書くべき所で db.Exec と書く事故

冒頭で「トランザクション中は必ず tx を使う」と書きました。破るとどうなるか、実際に見てみます。

tx, err := db.Begin()
if err != nil {
    log.Fatal(err)
}

// 出金は tx.Exec(トランザクションの中)
tx.Exec("UPDATE accounts SET balance = balance - 3000 WHERE name = '田中'")

// 入金は db.Exec(← 事故。トランザクションの外で実行される)
db.Exec("UPDATE accounts SET balance = balance + 3000 WHERE name = '鈴木'")

// 問題が起きたことにしてロールバック
tx.Rollback()

ロールバックしたのだから、出金も入金も取り消されてほしいところです。実行結果を見てください。

=== トランザクション内で db.Exec を使ってしまう事故 ===
実行前:
   田中: 10000円
   鈴木: 5000円

ロールバック後:
   田中: 10000円
   鈴木: 8000円

出金は取り消されたのに、入金だけが残りました。 鈴木さんの残高が3000円増えたままです。

なぜこうなるのか

*sql.DB は実は1本の接続ではなく、コネクションプール(接続の束)です。トランザクションは「1本の接続の上で BEGIN してから COMMIT / ROLLBACK するまで」という単位で成立します。

  • tx.Exec … トランザクションを開始したその接続でSQLを実行する
  • db.Exec … プールから別の接続を取り出してSQLを実行する

つまり db.Exec で実行したSQLは、トランザクションの外側で即座に確定します。ロールバックの影響を受けません。

コンパイルは通り、実行時エラーも出ません。気づけるのはデータがおかしくなった後です。 トランザクションを書くときは、中のSQLがすべて tx. になっているかを必ず確認してください。

もっと悪いケースもあります。tx 側が更新した行と同じ行を db.Exec で更新しようとすると、tx が持つロックと衝突して処理が止まります(#9 で学んだロック待ちが、自分のプログラム内で起きます)。「トランザクションを使ったら処理が固まった」という相談の定番原因です。


⚪ BeginTx と context でタイムアウトを付ける

db.Begin() の兄弟に db.BeginTx(ctx, opts) があります。context を渡すことで、トランザクション全体に制限時間を設定できます。

context自体は #17 で詳しく扱うので、今回は「処理に制限時間を付ける仕組み」とだけ理解してください。

// 2秒でタイムアウトするcontextを作る
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

// わざと5秒かかるSQLを実行してみる(タイムアウトは2秒)
_, err = tx.ExecContext(ctx, "SELECT pg_sleep(5)")

実行結果です。

=== BeginTx + context によるタイムアウト ===
経過時間: 2.0秒
エラー: pq: canceling statement due to user request
Commit の戻り値: sql: transaction has already been committed or rolled back

3つのことが起きています。

  1. 5秒かかるはずのSQLが2.0秒で打ち切られた。contextの期限が来た時点で、ドライバがPostgreSQLにキャンセルを要求しています
  2. エラー canceling statement due to user request が返った。「ユーザー(=ドライバ)の要求でSQLを中断した」というPostgreSQL側のメッセージです
  3. その後の Commit は ErrTxDone になった。contextが切れた時点でトランザクション自体がロールバック済みになるため、もう確定できません

なぜタイムアウトが必要かというと、遅いクエリがトランザクションを長引かせると、その間ずっとロックとコネクションを握り続けるからです。1つの遅い処理が、他の全処理を待たせる連鎖を起こします。制限時間を付けておけば、被害を一定範囲で打ち切れます。

BeginTx の第2引数 opts では分離レベル(#9 で学んだ READ COMMITTED など)も指定できますが、まずは nil(DBのデフォルト)で問題ありません。


🔴 実務での判断基準

外部APIをトランザクションの中で呼ばない

実務で最も重要な鉄則です。

tx, _ := db.Begin()
defer tx.Rollback()

tx.Exec("INSERT INTO orders ...")

sendEmail(order)   // ← 絶対にダメ。外部APIの呼び出し

tx.Commit()

理由は2つあります。

  1. 外部APIは遅い。メール送信に3秒かかれば、トランザクションが3秒開きっぱなしになります。その間、関連する行のロックとコネクション1本を握り続け、他の処理を待たせます
  2. 外部APIはロールバックできない。INSERTが後で失敗してロールバックしても、送ってしまったメールは取り消せません。「注文完了メールが届いたのに注文が存在しない」という不整合が起きます

正しくは「コミットが成功してからメールを送る」です。DBの変更はDBの中で完結させ、外部への作用はトランザクションの外に出します。

トランザクションは短く

上の話の一般化です。トランザクションの範囲は「一緒に成功・失敗すべきSQLの集まり」だけに絞ります。ファイルの読み書き、重い計算、ログ出力などは、可能な限りBeginの前かCommitの後に追い出します。開いている時間が短いほど、ロックの衝突は減ります。

Commit のエラーも必ず確認する

見落としがちですが、コミット自体が失敗することがあります(コミット直前の接続断、制約の遅延チェックなど)。

if err := tx.Commit(); err != nil {
    return fmt.Errorf("コミットに失敗しました: %w", err)
}

Commitのエラーを無視すると、「実際には保存されていないのに成功として扱う」ことになります。#10 の RowsAffected と同じ構図で、「エラーが返る場所を全部拾う」のがDB処理の基本です。


確認テスト

知識問題

Q1. defer tx.Rollback() を db.Begin() の直後に書くのがイディオムとされています。トランザクションが正常にCommitされた場合、このRollbackは何をしますか。

解答を見る

何もしません。 sql.ErrTxDone というエラーが返るだけで、DBへの影響はありません。

トランザクションはCommitかRollbackのどちらかで「決着」します。決着済みのトランザクションにRollbackを呼んでも、「もう決着しています」と伝えられるだけです。

だからこそ defer tx.Rollback() は安全な保険として機能します。Commitまで辿り着けば何もせず、辿り着けなかった(=途中のreturnやpanicで抜けた)場合だけ、実際のロールバックが走ります。

Q2. 次のコードの問題点を指摘してください。コンパイルは通ります。

tx, err := db.Begin()
if err != nil {
    return err
}
defer tx.Rollback()

if _, err := tx.Exec("UPDATE accounts SET balance = balance - $1 WHERE id = $2", amount, fromID); err != nil {
    return err
}
if _, err := db.Exec("UPDATE accounts SET balance = balance + $1 WHERE id = $2", amount, toID); err != nil {
    return err
}
return tx.Commit()
解答を見る

入金だけ db.Exec になっています(2つ目のUPDATE)。

db.Exec はコネクションプールから別の接続を取って実行するため、トランザクションの外で即座に確定します。このコードでは、

  • 出金(tx.Exec)が成功し、入金(db.Exec)が失敗すると → 出金はロールバックされるが、エラーになるまでの間に入金だけ確定している可能性がある
  • 逆に入金成功後、Commitが失敗すると → 出金だけ取り消され、入金は残る。お金が増えます

修正は db.Exec を tx.Exec に変えるだけです。トランザクションを書いたら、中のSQLがすべて tx. を使っているか確認する癖をつけてください。

Q3. トランザクションの中で外部API(メール送信など)を呼んではいけない理由を2つ答えてください。

解答を見る
  1. 外部APIは遅く、その間トランザクションが開きっぱなしになるから。 ロックとコネクションを握ったまま数秒待つことになり、他の処理が詰まります
  2. 外部APIはロールバックできないから。 DBの変更は取り消せても、送信済みのメールや実行済みの決済は取り消せません。ロールバック時に「メールは届いたのに注文が無い」という不整合が生まれます

正しい順序は「Commitの成功を確認してから外部APIを呼ぶ」です。

Q4. fmt.Errorf でエラーを返すとき、%v ではなく %w を使う利点は何ですか。

解答を見る

呼び出し元が errors.Is / errors.As で元のエラーを判別できることです。

%w はエラーを「包む」ので、中身を保ったまま文脈を足せます。呼び出し元は包みを剥がして「原因はCHECK制約違反か?接続断か?」を判定し、HTTPステータスの出し分けなどに使えます。

%v は文字列化なので、包まれた瞬間に「ただの文字列を持つ新しいエラー」になり、元のエラーの型や値と照合できなくなります。

実技問題

Q5. accounts テーブルで送金処理を行う関数 Transfer を書いてください。以下を満たすこと。

  • db.Begin() / defer tx.Rollback() / tx.Commit() を正しく使う
  • 出金・入金それぞれの失敗を、呼び出し元が区別できるエラーとして返す
  • 出金時に RowsAffected を確認し、送金元が存在しない場合をエラーにする(#10 の復習)
解答を見る
func Transfer(db *sql.DB, fromID, toID, amount int) error {
	tx, err := db.Begin()
	if err != nil {
		return fmt.Errorf("トランザクションの開始に失敗しました: %w", err)
	}
	defer tx.Rollback()

	// 出金
	result, err := tx.Exec(
		"UPDATE accounts SET balance = balance - $1 WHERE id = $2",
		amount, fromID,
	)
	if err != nil {
		return fmt.Errorf("出金に失敗しました: %w", err)
	}

	// 送金元が存在するかを確認する(#10 の復習)
	affected, err := result.RowsAffected()
	if err != nil {
		return fmt.Errorf("出金件数の取得に失敗しました: %w", err)
	}
	if affected == 0 {
		return fmt.Errorf("送金元 id=%d の口座が見つかりません", fromID)
	}

	// 入金
	result, err = tx.Exec(
		"UPDATE accounts SET balance = balance + $1 WHERE id = $2",
		amount, toID,
	)
	if err != nil {
		return fmt.Errorf("入金に失敗しました: %w", err)
	}
	affected, err = result.RowsAffected()
	if err != nil {
		return fmt.Errorf("入金件数の取得に失敗しました: %w", err)
	}
	if affected == 0 {
		return fmt.Errorf("送金先 id=%d の口座が見つかりません", toID)
	}

	if err := tx.Commit(); err != nil {
		return fmt.Errorf("コミットに失敗しました: %w", err)
	}
	return nil
}

ポイント

  • defer tx.Rollback() により、途中のどのreturnで抜けてもロールバックされる
  • すべてのSQLを tx.Exec で実行している(db.Exec は1つもない)
  • 「出金に失敗」「送金元が見つからない」など、どの工程で失敗したかが呼び出し元に伝わる形でエラーを返している
  • RowsAffected == 0(対象がいない)を独立したエラーにしている。存在しない口座への送金は、SQLエラーにならず0件更新で「成功」してしまうため
  • Commit のエラーも確認している

なお、送金先が見つからずreturnする場合も、直前の出金は defer tx.Rollback() が取り消してくれます。エラー分岐を増やしても後始末を書き足す必要がないのが、このイディオムの強さです。

## まとめ
  • Goのトランザクションは db.Begin() → tx.Exec → tx.Commit()。SQLの BEGIN / COMMIT / ROLLBACK にそのまま対応する
  • defer tx.Rollback() をBeginの直後に書く。Commit済みなら何もしない(sql.ErrTxDone)ので、失敗時だけ働く保険になる
  • トランザクション中のSQLは必ず tx. で実行する。db.Exec を混ぜると、そのSQLだけロールバックが効かない
  • BeginTx + context で制限時間を付けられる。外部APIは遅い上にロールバックできないので、トランザクションの中で呼ばない
  • エラーは %w でラップし、err チェック・RowsAffected・Commit のエラーが返る場所を全部拾う

次回は #12 N+1問題とコネクションプール です。今回ちらっと登場した「*sql.DB は実はコネクションプール」の話を深掘りし、ループ内クエリが引き起こす性能問題とその見つけ方・直し方を扱います。

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?