02.5 トランザクションとインデックス — 途中失敗と検索の速さ¶
このレッスンでは、データベースの2つの「縁の下の力持ち」を扱います。
- トランザクション: 複数の操作を「全部成功するか、全部無かったことにするか」の どちらかにまとめる仕組み(途中で失敗したらロールバックして元に戻す)
- インデックス: 「本の索引」のように、特定の列で検索するときに全件走査を避ける仕組み
このレッスンのゴール:
- トランザクションで途中失敗をロールバックし、結果が「全部反映」か「全部無反映」のどちらかになることを確認できる
EXPLAIN QUERY PLANを読み、インデックスの有無でスキャン方式が変わることを確認できる
1. トランザクションが必要な理由¶
「口座Aから100円引いて、口座Bに100円足す」という送金操作を考えます。 1つ目の UPDATE が成功して 2つ目の UPDATE が失敗すると、お金が消えてしまいます (Aからは引かれたのに、Bには足されない)。
トランザクション(Begin() 〜 Commit()/Rollback())は、この「途中半端な状態」を防ぎます。
Commit() するまでは変更が確定せず、失敗したら Rollback() で全部無かったことにできます。
2. 非自明な点: SQLite の外部キー制約は既定で無効¶
「途中失敗」を再現するには、何かの制約違反を起こす必要があります。
直感的には外部キー(FK)制約違反を使いたくなりますが、SQLite は PRAGMA foreign_keys が
既定で OFF です(database/sql 経由の接続でも同じ)。そのため FK 違反を狙ってもエラーに
なりません。このレッスンでは、代わりに UNIQUE 制約違反を失敗トリガーに使います
(NOT NULL・CHECK 制約でも同様に使えます)。
import (
"bytes"
"database/sql"
"errors"
"fmt"
"image/color"
"strings"
"time"
"github.com/janpfeifer/gonb/gonbui"
"gonum.org/v1/plot"
"gonum.org/v1/plot/plotter"
"gonum.org/v1/plot/vg"
_ "modernc.org/sqlite"
)
// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")
// openCourseDB は、このコース共通の SQLite 接続を開く。
// GoNB はセル単位で実行されるため、DB は「1セル完結」(open→defer Close→操作)で使う。
func openCourseDB(path string) *sql.DB {
db, err := sql.Open("sqlite", path)
if err != nil {
panic(err)
}
db.SetMaxOpenConns(1)
return db
}
const dbPath = "file:_02_5_transactions_indexes.db"
%%
// accounts テーブルをシードする(code に UNIQUE 制約)。DROP→CREATE→INSERT を1セルにまとめ、
// 再実行しても同じ初期状態に戻す。
db := openCourseDB(dbPath)
defer db.Close()
db.Exec(`DROP TABLE IF EXISTS accounts`)
db.Exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY, code TEXT UNIQUE, balance INTEGER)`)
db.Exec(`INSERT INTO accounts (code, balance) VALUES ('A', 1000), ('B', 500)`)
var count int
db.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&count)
fmt.Println("✅ accounts をシードしました。行数:", count)
✅ accounts をシードしました。行数: 2
3. 成功するトランザクション¶
正しい2つの UPDATE(Aから100引く、Bに100足す)を1トランザクションで実行し、Commit() します。
%%
db := openCourseDB(dbPath)
defer db.Close()
tx, err := db.Begin()
if err != nil {
panic(err)
}
defer tx.Rollback() // Commit 後は no-op なので安全網として常に書く
if _, err := tx.Exec(`UPDATE accounts SET balance = balance - 100 WHERE code = 'A'`); err != nil {
panic(err)
}
if _, err := tx.Exec(`UPDATE accounts SET balance = balance + 100 WHERE code = 'B'`); err != nil {
panic(err)
}
if err := tx.Commit(); err != nil {
panic(err)
}
var balA, balB int
db.QueryRow(`SELECT balance FROM accounts WHERE code = 'A'`).Scan(&balA)
db.QueryRow(`SELECT balance FROM accounts WHERE code = 'B'`).Scan(&balB)
fmt.Printf("Commit 後: A=%d, B=%d(合計は変わらず1500)\n", balA, balB)
Commit 後: A=900, B=600(合計は変わらず1500)
4. 失敗してロールバックするトランザクション¶
同じ送金をもう一度行いますが、途中で UNIQUE 制約違反(既存の code='A' を重複挿入しようとする)
を起こし、Rollback() します。1つ目の UPDATE は成功しているのに、ロールバックで元に戻る
ことを確認します。
%%
db := openCourseDB(dbPath)
defer db.Close()
var balABefore int
db.QueryRow(`SELECT balance FROM accounts WHERE code = 'A'`).Scan(&balABefore)
tx, err := db.Begin()
if err != nil {
panic(err)
}
defer tx.Rollback()
// 1つ目のUPDATEは成功する。
if _, err := tx.Exec(`UPDATE accounts SET balance = balance - 100 WHERE code = 'A'`); err != nil {
panic(err)
}
var midBalA int
tx.QueryRow(`SELECT balance FROM accounts WHERE code = 'A'`).Scan(&midBalA)
fmt.Println("トランザクション内(Commit前)のA残高:", midBalA, "← すでに減っている")
// 2つ目でUNIQUE制約違反を起こす('A' は既に存在する)。
_, insErr := tx.Exec(`INSERT INTO accounts (code, balance) VALUES ('A', 0)`)
fmt.Println("INSERT の結果(UNIQUE違反を期待):", insErr)
if insErr != nil {
tx.Rollback()
fmt.Println("→ Rollback しました")
} else {
tx.Commit()
}
var balAAfter int
db.QueryRow(`SELECT balance FROM accounts WHERE code = 'A'`).Scan(&balAAfter)
fmt.Printf("Rollback後のA残高: %d(Before=%d と同じはず)\n", balAAfter, balABefore)
トランザクション内(Commit前)のA残高: 800 ← すでに減っている INSERT の結果(UNIQUE違反を期待): constraint failed: UNIQUE constraint failed: accounts.code (2067)
→ Rollback しました
Rollback後のA残高: 900(Before=900 と同じはず)
5. 前後を並べて比較する¶
func renderTxComparison(rows [][3]string) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
b.WriteString(`<tr><th>シナリオ</th><th>Aの残高</th><th>結果</th></tr>`)
for _, r := range rows {
b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td></tr>`, r[0], r[1], r[2]))
}
b.WriteString(`</table>`)
return b.String()
}
%%
gonbui.DisplayHTML(renderTxComparison([][3]string{
{"Commit(3節)", "900", "反映される"},
{"Rollback(4節、UNIQUE違反)", "900のまま", "トランザクション内の変更は破棄される"},
}))
gonbui.Sync()
| シナリオ | Aの残高 | 結果 |
|---|---|---|
| Commit(3節) | 900 | 反映される |
| Rollback(4節、UNIQUE違反) | 900のまま | トランザクション内の変更は破棄される |
表の通り、「トランザクション内で一時的に減った残高」も、Rollback() すれば無かったことになります。
これがトランザクションの「全部成功 or 全部無反映」という性質です。
6. インデックス: EXPLAIN QUERY PLAN で検索方式を見る¶
大量データに対する等価検索(WHERE email = ...)で、インデックスの有無がどう影響するかを見ます。
EXPLAIN QUERY PLAN は「SQLite がどうやってこのクエリを実行するつもりか」を教えてくれます。
func renderPlan(rows *sql.Rows) string {
var b strings.Builder
b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
b.WriteString(`<tr><th>id</th><th>parent</th><th>notused</th><th>detail</th></tr>`)
for rows.Next() {
var id, parent, notused int
var detail string
if err := rows.Scan(&id, &parent, ¬used, &detail); err != nil {
panic(err)
}
b.WriteString(fmt.Sprintf(`<tr><td>%d</td><td>%d</td><td>%d</td><td>%s</td></tr>`, id, parent, notused, detail))
}
b.WriteString(`</table>`)
return b.String()
}
%%
// 2万行の users テーブルをシードする(決定的データセット、等価述語で索引効果を確認するため十分な行数)。
db := openCourseDB(dbPath)
defer db.Close()
db.Exec(`DROP TABLE IF EXISTS users`)
db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)`)
tx, err := db.Begin()
if err != nil {
panic(err)
}
for i := 0; i < 20000; i++ {
if _, err := tx.Exec(`INSERT INTO users (email) VALUES (?)`, fmt.Sprintf("user%d@example.com", i)); err != nil {
panic(err)
}
}
if err := tx.Commit(); err != nil {
panic(err)
}
var userCount int
db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
fmt.Println("✅ users をシードしました。行数:", userCount)
✅ users をシードしました。行数: 20000
%%
db := openCourseDB(dbPath)
defer db.Close()
rows, err := db.Query(`EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'user9999@example.com'`)
if err != nil {
panic(err)
}
defer rows.Close()
gonbui.DisplayHTML("<b>索引を作る前の EXPLAIN QUERY PLAN:</b>" + renderPlan(rows))
gonbui.Sync()
| id | parent | notused | detail |
|---|---|---|---|
| 2 | 0 | 216 | SCAN users |
SCAN users は「全行を1件ずつ調べる」という意味です。2万行あれば2万回の比較が必要です。
%%
db := openCourseDB(dbPath)
defer db.Close()
if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`); err != nil {
panic(err)
}
rows, err := db.Query(`EXPLAIN QUERY PLAN SELECT * FROM users WHERE email = 'user9999@example.com'`)
if err != nil {
panic(err)
}
defer rows.Close()
gonbui.DisplayHTML("<b>索引を作った後の EXPLAIN QUERY PLAN:</b>" + renderPlan(rows))
gonbui.Sync()
| id | parent | notused | detail |
|---|---|---|---|
| 2 | 0 | 56 | SEARCH users USING COVERING INDEX idx_users_email (email=?) |
SEARCH users USING INDEX ... に変わりました。これは「索引を使って一気に絞り込む」という意味です。
比較の回数が全件走査より大幅に減ります。
7. 実行時間を測って可視化する¶
EXPLAIN QUERY PLAN はあくまで「実行計画」の説明で、実際にどれだけ速くなったかは
実行時間を測って確認します(時間の絶対値はマシン依存なので参考値。閾値でテストはしません)。
索引を一時的に削除して測り、また作り直して測ります。
下のグラフは 左(赤)が索引なし、右(青)が索引ありの実行時間(ミリ秒)です (PNG内のラベルは英字表記です。gonum/plot の既定フォントは日本語を描けないため)。
%%
db := openCourseDB(dbPath)
defer db.Close()
const query = `SELECT * FROM users WHERE email = 'user9999@example.com'`
const repeat = 200
measure := func() time.Duration {
start := time.Now()
for i := 0; i < repeat; i++ {
rows, err := db.Query(query)
if err != nil {
panic(err)
}
for rows.Next() {
}
rows.Close()
}
return time.Since(start)
}
db.Exec(`DROP INDEX IF EXISTS idx_users_email`)
noIndexDur := measure()
fmt.Printf("索引なし: %v(%d回実行)\n", noIndexDur, repeat)
db.Exec(`CREATE INDEX idx_users_email ON users(email)`)
withIndexDur := measure()
fmt.Printf("索引あり: %v(%d回実行)\n", withIndexDur, repeat)
索引なし: 310.210708ms(200回実行)
索引あり: 2.637ms(200回実行)
%%
// 上のセルの実測値を棒グラフに描く。
db := openCourseDB(dbPath)
defer db.Close()
const query = `SELECT * FROM users WHERE email = 'user9999@example.com'`
const repeat = 200
measure := func() time.Duration {
start := time.Now()
for i := 0; i < repeat; i++ {
rows, err := db.Query(query)
if err != nil {
panic(err)
}
for rows.Next() {
}
rows.Close()
}
return time.Since(start)
}
db.Exec(`DROP INDEX IF EXISTS idx_users_email`)
noIndexMs := float64(measure().Microseconds()) / 1000.0
db.Exec(`CREATE INDEX idx_users_email ON users(email)`)
withIndexMs := float64(measure().Microseconds()) / 1000.0
// PNG内は短い英字ラベルにする(gonum/plot の既定フォントは日本語グリフを描けず、
// 全角文字を混ぜると文字化けして消えるため。日本語の説明は上の Markdown 側で行う)。
p := plot.New()
p.Title.Text = fmt.Sprintf("Query time: no index vs index (total of %d runs)", repeat)
p.Y.Label.Text = "milliseconds"
w := vg.Points(40)
barsNoIndex, err := plotter.NewBarChart(plotter.Values{noIndexMs}, w)
if err != nil {
panic(err)
}
barsNoIndex.Offset = -w / 2
barsNoIndex.Color = color.RGBA{R: 220, G: 80, B: 80, A: 255}
barsWithIndex, err := plotter.NewBarChart(plotter.Values{withIndexMs}, w)
if err != nil {
panic(err)
}
barsWithIndex.Offset = w / 2
barsWithIndex.Color = color.RGBA{R: 60, G: 140, B: 220, A: 255}
p.Add(barsNoIndex, barsWithIndex)
p.NominalX("no index / with index")
p.Legend.Add("no index", barsNoIndex)
p.Legend.Add("with index", barsWithIndex)
wr, err := p.WriterTo(9*vg.Centimeter, 6*vg.Centimeter, "png")
if err != nil {
panic(err)
}
var pngBuf bytes.Buffer
wr.WriteTo(&pngBuf)
gonbui.DisplayPNG(pngBuf.Bytes())
gonbui.Sync()
fmt.Printf("索引なし=%.3fms 索引あり=%.3fms\n", noIndexMs, withIndexMs)
索引なし=281.977ms 索引あり=2.183ms
8. 直感・類推: 索引は本の目次、トランザクションは荷造り¶
インデックスは、辞書や本の索引と同じです。索引が無ければ最初のページから1枚ずつめくって 探す(全件走査)しかありませんが、索引があれば「あ行はP.10」と一気に絞り込めます。
トランザクションは、引っ越しの荷造りです。「本棚を空にする」「新しい家に運ぶ」の両方が
終わって初めて「引っ越し完了」であり、途中でトラックが故障したら元の家に全部戻す
(一部だけ運ばれた中途半端な状態を許さない)——これが Commit/Rollback の考え方です。
練習問題 2.5: ドメイン別のユーザー数を数える関数を実装しよう¶
countUsersByEmailDomain を実装してください。特定のドメイン(例: example.com)を持つ
ユーザー数を数える、非破壊(SELECT のみ)の関数です。
仕様:
func countUsersByEmailDomain(db *sql.DB, domain string) (int, error)
emailが...@<domain>の形式である行数を返す- 該当が無ければ
0, nilを返す - SQL エラーが起きたらそのエラーを返す
ヒント: WHERE email LIKE ? に "%@" + domain を渡します。
注意(索引の限界): このクエリは idx_users_email を使いません。LIKE パターンが % から
始まる(先頭ワイルドカード)と、B-tree索引は使えず全件走査(SCAN)になります。索引が効くのは
「等価一致(=)」または「前方一致(LIKE 'prefix%')」の場合だけです。答え合わせのあと、
EXPLAIN QUERY PLAN でこのクエリのプランを見て SCAN users になっていることを確認してみましょう。
// YOUR CODE HERE
// func countUsersByEmailDomain(db *sql.DB, domain string) (int, error) を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func countUsersByEmailDomain(db *sql.DB, domain string) (int, error) {
return -1, ErrUnanswered
}
チェックのためのヘルパー¶
答え合わせに使う小さなヘルパー mustEqual を定義します。
(GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)
import "reflect"
func mustEqual(got, want any, name string) {
if reflect.DeepEqual(got, want) {
fmt.Printf("✅ Passed: %s\n", name)
return
}
panic(fmt.Sprintf("❌ %s\n got = %v (%T)\n want = %v (%T)", name, got, got, want, want))
}
%%
// このチェックは SELECT のみで DB を変更しないため、何度実行しても同じ結果になる。
db := openCourseDB(dbPath)
defer db.Close()
n1, err1 := countUsersByEmailDomain(db, "example.com")
if errors.Is(err1, ErrUnanswered) {
fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
mustEqual(err1, nil, "example.com はエラーなし")
mustEqual(n1, 20000, "全ユーザーが example.com ドメイン")
n2, err2 := countUsersByEmailDomain(db, "not-exist.example")
mustEqual(err2, nil, "存在しないドメインはエラーではない")
mustEqual(n2, 0, "存在しないドメインは0件")
fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください
まとめ¶
- トランザクションは
Begin()→ 複数操作 →Commit()(確定)/Rollback()(破棄)の単位 - SQLite は FK 制約が既定 OFF。ロールバック実演には UNIQUE/NOT NULL/CHECK を使う
EXPLAIN QUERY PLANでスキャン方式(SCAN=全件走査 /SEARCH ... USING INDEX=索引利用)が分かる- インデックスは検索を速くするが、実行時間の絶対値はマシン依存の参考値として扱う
Module 2(データベース)はこれで終わりです。次は Module 3 で「ネットワークとサーバー」を扱います。