😰

「トランザクション張っておけば大丈夫」と思ってませんか? バグの温床になる、よくある実装パターン

2020/12/19に公開
2

この記事は DeNA 20 新卒 Advent Calendar 2020 19日目の記事です。

はじめに

MySQLやPostgreSQLに代表されるRDBMSではトランザクションと呼ばれる仕組みが提供されています。多くのWebアプリケーションエンジニアはこのトランザクションを駆使してDBとやりとりをするロジックを組み立てることになります。

しかし不整合を起こしたくない処理があるからといって闇雲にトランザクションを張ったり、トランザクションが張られているからと安心してアプリケーション側で闇雲にロジックを組み立ててしまうと思わぬバグを生むことになってしまいます。

このエントリでは、「トランザクションを張っておけば大丈夫」という考え方は危険な場合もあるということを、ありがちな実装例を交えて紹介していきます。

並列に処理されるトランザクション

そもそも、トランザクションは全て直列に処理されるわけではありません。同時にリクエストが飛んでくれば並列に処理される場合もあります。複数のトランザクションが並列に処理されるということは、上手く処理を工夫してあげないと競合状態が発生する可能性があるのです。

そこで多くのDBでは、並列処理されたにもかかわらず直列に処理した場合と同じような結果が得られるように実装を工夫することで、アプリケーション開発者がこの競合状態を意識しなくても済むような概念を提供しています。それが「トランザクションの分離性」です。

トランザクションの分離性とその限界

残念ながら、トランザクションの分離性は万能なものではありません。分離性を上げれば上げるほどパフォーマンス上の負担となっていきます。つまり、トランザクションの分離性はパフォーマンスとトレードオフの関係にあるのです。

そこで多くのRDBMSでは、並列処理による競合状態の「全ての問題」ではなく「一部の問題」のみを解決するアプローチを提供することで現実的な解決をしています。「一部の問題」のみをRDBMSが解決するということは「残りの問題」に関してはRDMBSを扱う我々アプリケーションエンジニアが実装の工夫で担保する必要があります。それが「トランザクションを張っておけば大丈夫」ではない理由になります。

詳細は省きますが、例えばMySQLのストレージエンジンであるInnoDBでは以下のようなトランザクションの分離性を提供しています[1]。下に行くにつれて分離レベルが上がっていきます。

  • READ UNCOMMITTED
  • READ COMMITTED
  • REPEATABLE READ
  • SERIALIZABLE

InnoDBではデフォルトの分離レベルとしてREPEATABLE READが設定されています[1:1]。つまり、何も気にせずMySQLを使っていればこのREPEATABLE READの分離レベルで並列トランザクションに立ち向かうことになります。

というわけでここから先は、MySQL(InnoDB)で分離レベルがREPEATABLE READの場合を前提に話を進めていきます。また、このエントリではアプリケーションの実装にGoを使用します

1on1チャットサービスを例に考えてみる

1on1チャットサービスを例に考えてみます。このサービスには「ユーザー」と「部屋」の概念があります。ユーザーはいくつかある部屋の中から空いている部屋を選択して入室し、同じ部屋にいるユーザーとチャットすることができます。部屋の入室人数上限は2人です(1on1なので)。

では、このチャットサービスの機能を実装していきましょう。

テーブル設計

説明のために極限までシンプルな設計です。usersテーブルのroom_idによって部屋の入室情報を管理しています。room_idnullの時、そのユーザーはどの部屋にも入室していないことを表現するとします。

schema.sql
CREATE TABLE rooms (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(255)
);

CREATE TABLE users (
  id INT PRIMARY KEY AUTO_INCREMENT,
  room_id INT,
  name VARCHAR(255)
);

入室機能: 部屋に人数制限をかける!

何も考えずに、主要な機能である入室機能を実装してみます。

func enterRoom(userID, roomID int64) error {
	// トランザクションを張る
	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	
	// ① 部屋人数取得 ====
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}
	// =======

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	// ② 入室処理 ====
	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	// =======
	
	// Commit
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

これはまさに、「トランザクション張っておけば大丈夫」と思いながら書かれたコードです。トランザクションさえ張っておけば、①で取得した結果が②を実行する段階でも保証されていると思って書かれています。

お察しの通り、残念ながらこの実装ではトランザクションを張っていても①の結果は②の時点で保証されていません。 この問題は、残り入室可能人数が1人の部屋に対してAさんとBさんがほぼ同時に入室リクエストを投げるような場合に発生します。図示すると以下のようになります。

実際にこのパターンを擬似的に再現してみると、roomの上限を突破してしまうことがわかります。

❯ go run main.go
2は、room1に入室しました
3は、room1に入室しました
room1の人数...3
再現方法
  • まずはユーザーを3人と部屋を1つ用意します
INSERT users(id, name, room_id) VALUES(1, '1', NULL),(2, '2', NULL), (3, '3', NULL);
INSERT rooms(id, name) VALUES(1, '1');
  • 1人だけ、room1に参加させます
UPDATE users SET room_id = 1 WHERE id = 1;
  • 以下のコードを実行します(ここでgoroutineとchannelを用いて擬似的に上図を再現しています)
package main

import (
	"fmt"
	"log"
	"time"

	"golang.org/x/sync/errgroup"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

var db *sqlx.DB

var (
	ch1 = make(chan struct{})
	ch2 = make(chan struct{})
	ch3 = make(chan struct{})
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	var err error
	db, err = sqlx.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test?parseTime=true")
	if err != nil {
		return err
	}
	if err := db.Ping(); err != nil {
		return err
	}

	eg := &errgroup.Group{}

	eg.Go(func() error {
		time.Sleep(1 * time.Second)
		close(ch1)
		time.Sleep(1 * time.Second)
		close(ch2)
		time.Sleep(1 * time.Second)
		close(ch3)
		return nil
	})
	eg.Go(func() error {
		return enterRoom(2, 1)
	})
	eg.Go(func() error {
		return enterRoom2(3, 1)
	})

	if err := eg.Wait(); err != nil {
		return err
	}
	return printResult(1)
}

type User struct {
	ID        int64     `db:"id"`
	RoomID    int64     `db:"room_id"`
	CreatedAt time.Time `db:"created_at"`
	UpdatedAt time.Time `db:"updated_at"`
}

func enterRoom(userID, roomID int64) error {
	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	<-ch2

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func enterRoom2(userID, roomID int64) error {
	<-ch1

	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	<-ch3

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func printResult(roomID int64) error {
	var count int64
	if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		return err
	}
	fmt.Println(fmt.Sprintf("room%dの人数...%d", roomID, count))
	return nil
}

結果

❯ go run main.go
2は、room1に入室しました
3は、room1に入室しました
room1の人数...3

解決方法

解決方法はいくつか考えられますが、シンプルな解決策の1つとして以下のように①のSELECT句でFOR UPDATEすることが挙げられます。

①のSELECT句を以下のように変更する
SELECT COUNT(*) FROM users WHERE room_id = ?

SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE

このFOR UPDATEにより、usersテーブル全体に排他ロック(テーブルロック)が掛かります(room_idにインデックスがないため)。そのため並列に処理されているBさんのトランザクションでは、Aさんのトランザクションが終了するまで①を実行することができず待たされます。これにより安全に入室上限機能を実装することができます。

排他ロックについて

排他ロックが掛かっている行に対しては、他のトランザクションは排他ロック・共有ロックを掛けることができません。共有ロックが掛かっている行に対しては共有ロックは掛けられます(排他ロックは掛けられない)。

トランザクションが、ある行に対して共有ロックを持っている場合、その行を読み取ることができます。
トランザクションが、ある行に対して排他ロックを持っている場合、その行に書き込みができます。

どのような場合にどのロックが掛かるかはトランザクション分離レベルによって異なります[2]

では、とりあえず怪しいSELECT句にはFOR UPDATEを付けておけば全て解決するのでしょうか?残念ながらそうではありません。次の例を見てみましょう。

room_idにインデックスを張る 〜ネクストキーロックとデッドロックと〜

usersテーブルのroom_idの使われ方を考えると、インデックスを張ったほうがパフォーマンス的に良さそうです。そこでroom_idにインデックスを張ることにしました。

room_idにインデックスを張るSQL
CREATE INDEX room_id_index ON users(room_id);

これで読み取りにかかるコストを抑えることができそうです。では、この状態で上記のSELECT ... FOR UPDATEする実装を動かすとどうなるか試してみましょう。

入室可能人数が残り1人の部屋に対し同時に2人が参加リクエストを送る状況を再現(SELECT ... FOR UPDATE)
  • まずはユーザーを3人と部屋を1つ用意します
INSERT users(id, name, room_id) VALUES(1, '1', NULL),(2, '2', NULL), (3, '3', NULL);
INSERT rooms(id, name) VALUES(1, '1');
  • 1人だけ、room1に参加させます
UPDATE users SET room_id = 1 WHERE id = 1;
  • 以下のコードを実行します(ここでgoroutineとchannelを用いて擬似的に上図を再現しています)
package main

import (
	"fmt"
	"log"
	"time"

	"golang.org/x/sync/errgroup"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

var db *sqlx.DB

var (
	ch1 = make(chan struct{})
	ch2 = make(chan struct{})
	ch3 = make(chan struct{})
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	var err error
	db, err = sqlx.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test?parseTime=true")
	if err != nil {
		return err
	}
	if err := db.Ping(); err != nil {
		return err
	}

	eg := &errgroup.Group{}

	eg.Go(func() error {
		time.Sleep(1 * time.Second)
		close(ch1)
		time.Sleep(1 * time.Second)
		close(ch2)
		time.Sleep(1 * time.Second)
		close(ch3)
		return nil
	})
	eg.Go(func() error {
		return enterRoom(2, 1)
	})
	eg.Go(func() error {
		return enterRoom2(3, 1)
	})

	if err := eg.Wait(); err != nil {
		return err
	}
	return printResult(1)
}

type User struct {
	ID        int64     `db:"id"`
	RoomID    int64     `db:"room_id"`
	CreatedAt time.Time `db:"created_at"`
	UpdatedAt time.Time `db:"updated_at"`
}

func enterRoom(userID, roomID int64) error {
	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	<-ch2

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func enterRoom2(userID, roomID int64) error {
	<-ch1

	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	<-ch3

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func printResult(roomID int64) error {
	var count int64
	if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		return err
	}
	fmt.Println(fmt.Sprintf("room%dの人数...%d", roomID, count))
	return nil
}

結果

❯ go run main.go
人数上限です
2は、room1に入室しました
人数上限です
room1の人数...2

想定通りに動きました。では、入室者数が0人の部屋に対して同時に2人が入室リクエストを送る状況を再現してみましょう。

再現方法
  • まずはユーザーを2人と部屋を1つ用意します
INSERT users(id, name, room_id) VALUES(1, '1', NULL),(2, '2', NULL);
INSERT rooms(id, name) VALUES(1, '1');
  • 以下のコードを実行します(ここでgoroutineとchannelを用いて擬似的に上図を再現しています)
package main

import (
	"fmt"
	"log"
	"time"

	"golang.org/x/sync/errgroup"

	_ "github.com/go-sql-driver/mysql"
	"github.com/jmoiron/sqlx"
)

var db *sqlx.DB

var (
	ch1 = make(chan struct{})
	ch2 = make(chan struct{})
	ch3 = make(chan struct{})
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() error {
	var err error
	db, err = sqlx.Open("mysql", "root:root@tcp(127.0.0.1:3306)/test?parseTime=true")
	if err != nil {
		return err
	}
	if err := db.Ping(); err != nil {
		return err
	}

	eg := &errgroup.Group{}

	eg.Go(func() error {
		time.Sleep(1 * time.Second)
		close(ch1)
		time.Sleep(1 * time.Second)
		close(ch2)
		time.Sleep(1 * time.Second)
		close(ch3)
		return nil
	})
	eg.Go(func() error {
		return enterRoom(2, 1)
	})
	eg.Go(func() error {
		return enterRoom2(3, 1)
	})

	if err := eg.Wait(); err != nil {
		return err
	}
	return printResult(1)
}

type User struct {
	ID        int64     `db:"id"`
	RoomID    int64     `db:"room_id"`
	CreatedAt time.Time `db:"created_at"`
	UpdatedAt time.Time `db:"updated_at"`
}

func enterRoom(userID, roomID int64) error {
	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	<-ch2

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func enterRoom2(userID, roomID int64) error {
	<-ch1

	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var count int64
	if err := tx.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE`, roomID).Scan(&count); err != nil {
		tx.Rollback()
		return err
	}

	if count >= 2 {
		fmt.Println("人数上限です")
		tx.Rollback()
		return nil
	}

	<-ch3

	if _, err := tx.Exec(`UPDATE users SET room_id = ? WHERE id = ?`, roomID, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		tx.Rollback()
		return err
	}
	fmt.Println(fmt.Sprintf("%dは、room%dに入室しました", userID, roomID))

	return nil
}

func printResult(roomID int64) error {
	var count int64
	if err := db.QueryRow(`SELECT COUNT(*) FROM users WHERE room_id = ?`, roomID).Scan(&count); err != nil {
		return err
	}
	fmt.Println(fmt.Sprintf("room%dの人数...%d", roomID, count))
	return nil
}

結果

❯ go run main.go
1は、room1に入室しました
2020/12/16 01:39:49 Error 1213: Deadlock found when trying to get lock; try restarting transaction
exit status 1

何ということでしょう。user2の入室はデッドロックが発生しロールバックされてしまいました (user1の入室は成功しています)。

デッドロックの原因を探る

それでは、なぜデッドロックが発生したのかを説明していきます。

まず、以下のクエリ(入室人数取得クエリと呼びます)にマッチする行が「0行」だったことが1つの要因です。

SELECT COUNT(*) FROM users WHERE room_id = ? FOR UPDATE

既に1人入室している状態での上記クエリではマッチする行が1行以上存在していたため、room_idにインデックスがある場合マッチした行に対して排他ロックが掛かっていました(レコードロック)。しかし結果が0行の場合、どの行に対しても排他ロックが掛かりません。

「排他ロックが掛からないならば、UPDATE users SET room_id = ?(入室クエリと呼びます)でデットロックは発生しないのでは?」

と思うかもしれません。しかしここにはネクストキーロック(ギャップロック)が絡んできます。

ネクストキーロック(ギャップロック)とは?

ファントムを回避するためのロックの仕組みです。

例えば1つのトランザクション中で

SELECT COUNT(*) from users WHERE id > 100 FOR UPDATE

というクエリ(カウントクエリ)が2回実行されるとします。

もし1回目と2回目のカウントクエリの間に、並列に処理された別のトランザクションで INSERT INTO users(id) VALUES(101) というクエリ(INSERTクエリ)が実行されるとどうなるでしょうか?

何かしらのロックの仕組みがないと、同じトランザクション中であるにも関わらず1回目と2回目のカウントクエリで結果が異なってしまいます。
これがファントム問題です。

REPEATABLE READ分離レベルでは、上記カウントクエリの場合「100~無限大」までをロックすることによりファントム問題を防ぎます。
これがネクストキーロックです。

今回room_idにはインデックスが張られているため、ネクストキーロックとしてroom_idの「-無限大 〜 +無限大(ギャップ上)」へ排他ロックが掛かってしまいます。このネクストキーロックが原因でデッドロックが発生してしまったのです。

「入室人数取得クエリでギャップ上に排他ロックが掛かるならば、user2のトランザクションの入室人数取得クエリは今まで通り待機状態になるのでは?」

と思うかもしれません。しかしInnoDBでは以下のような仕様があります。

InnoDB のギャップロックは、「単に抑制的」です。つまり、ほかのトランザクションによるギャップへの挿入が停止されるだけです。したがって、ギャップ X ロックの効果はギャップ S ロックと同じです。
https://dev.mysql.com/doc/refman/5.6/ja/innodb-record-level-locks.html

つまり、ギャップ上のロックは全て見かけ上共有ロックとして扱われるのです。先程説明したとおり、共有ロックは重複して掛けることができます。そのためuser2の入室人数取得クエリも待機状態にならないのです。

以上より、user1とuser2両方のトランザクションで待機状態にもならず無限大のギャップに対し共有ロックが取得されることになります。その結果、user1の入室クエリではuser2の共有ロックが開放されるのを待機することになり、user2の入室クエリでもまたuser1の共有ロックが開放されるのを待機することになります。そしてデッドロックという結末を迎えるのです。

解決方法

では、このデッドロックにどう立ち向かえば良いのでしょうか。今回は主要な4つの解決方法を紹介します。

再試行する

デッドロックが発生した場合、トランザクションを再試行するようにアプリケーション側で実装するのが1つの手です。基本的にデッドロックが発生しても片方のトランザクションはコミットされるため、デッドロックされた側がすぐ再試行すれば問題は解決されます。

分離レベルを下げる

分離レベルをREAD COMMITTEDまで下げるのも1つの手です。READ COMMITTED分離レベルの場合ギャップロックが掛からない[3]ため、今回のようなデッドロックは発生しません。
しかしギャップロックが掛からないとファントムが発生することになるため、それによる弊害を考慮する必要があります。

テーブルロックする

今回の入室人数取得クエリの前にusersテーブルに対してテーブルロック(排他ロック)を掛け、コミットの直前にテーブルロックを解除することでも解決できます。しかしテーブルロックを頻繁に用いてしまうとパフォーマンスの悪化に繋がるため、乱用は避けるべきです。

LOCK TABLES users WRITE;

-- 処理

UNLOCK TABLES;

諦める(デッドロックを許容する)

実際今回のようなケースはその処理へリクエストが集中するような場合でないと発生しません。また、1つ目の例のように入室人数がオーバーするわけではないため、サービスに深刻な影響を与えるわけでもありません。これらと上記他の対処にかかる工数を天秤にかけた時、デッドロックを許容するというのも1つの手です。

その他の例と、パターンの一般化

ここでは、同じ問題が発生し得る1on1チャットサービス以外の具体的な例を考えてみます。そして、どのような仕様が今回取り上げた問題に当てはまるのかを一般化して考えてみます。

その他の例

銀行口座サービス

シンプルな銀行口座サービスを考えてみます。1,000円残高があった口座からAさんは100円を引き出すと、最新の残高は900円となります。

共有口座からの出金機能を作る!

このサービスに共有口座機能を実装することを考えます。共有口座機能とは、1つの口座を複数のユーザーが入出金できるというものです。

口座からお金を引き出すためには、出金額が残高以下であることを確認する必要があります。そのため以下のような実装が考えられます。

func withdraw(userID, amount int64) error {
	tx, err := db.Beginx()
	if err != nil {
		return err
	}
	var b Balance
	if err := tx.QueryRow(`SELECT * FROM balances WHERE user_id = ?`, userID).Scan(&b); err != nil {
		tx.Rollback()
		return err
	}

	if b.Amount < amount {
		fmt.Println("残高不足です")
		tx.Rollback()
		return nil
	}

	newBalance := b.Amount - amount

	if _, err := tx.Exec(`UPDATE balances SET balance = ? WHERE user_id = ?`, newBalance, userID); err != nil {
		tx.Rollback()
		return err
	}
	if err := tx.Commit(); err != nil {
		return err
	}

	return nil
}

もう、上記実装の何が問題となり得るかは説明不要だと思います。

パターンの一般化

1on1チャットサービス・共有口座機能の例から、今回取り上げた問題が発生するパターンを一般化すると次のようになります。

  1. 行を読み取り
  2. 1の結果を元に後続の処理をするかしないか判断
  3. 1の結果が変化し得る書き込み処理

このような実装パターンとなる仕様があった場合は、並列にトランザクションが実行されるとどうなるか?を意識して実装することをオススメします。

さいごに

記事まとめ

このエントリでは、はじめに並列トランザクションとトランザクションの分離性について述べ、なぜトランザクションを張るだけでは問題が発生し得るのかを説明しました。その後、どういう仕様がバグの温床となり得るのか具体的な例を挙げて説明し、その解決策を示しました。そして最後にパターンを一般化し、今後他の例にも転用できるように紹介しました。

感想とか

使用するDBがどのトランザクション分離レベルで稼働しているかはアプリケーション側のコードには現れにくいのにも関わらず、正確にそれらを把握しておかないとサービスに重要な影響を与える深刻なバグに繋がる可能性があります。また、これらのバグをテストコードで見つけるのも非常に難しいです。実際にこのようなバグが発生した際、知識がなければ気がつくことも困難だと思います。

トランザクションやロックの話は難しく敬遠されがちですが、ソフトウェアエンジニアにとって必須の知識だなと感じました。

ちなみにこの記事は「データ指向アプリケーションデザイン」の第7章に出てくる話と似ています。実は弊社同期内でこの本の輪読会を開催しており、そこに参加する中でトランザクション周りの重要性を知り今回の記事を書いたという経緯がありました。
この本では他にも分散システムの話を中心に、ソフトウェアエンジニアとしての知識をワンステップアップさせるような内容が沢山詰め込まれています!ぜひじっくり読んでみることをオススメします!

DeNA 公式 Twitter アカウント @DeNAxTechでは、この Advent Calendar や DeNA の技術記事、イベントでの登壇資料などを発信しています。 もしよろしければフォローしてみてください!

脚注
  1. https://dev.mysql.com/doc/refman/5.6/ja/set-transaction.html ↩︎ ↩︎

  2. https://dev.mysql.com/doc/refman/5.6/ja/set-transaction.html ↩︎

  3. https://dev.mysql.com/doc/refman/5.6/ja/set-transaction.html ↩︎

Discussion

shuntagamishuntagami

大変わかりやすい記事をありがとうございます!
今回の場合select id from rooms where id = ? for updateというようなクエリでroomに対するロックを事前に取得することでトランザクション分離レベルを下げなくてもデッドロックを回避できるのでその内容を記事にしてみました! https://zenn.dev/shuntagami/articles/ea44a20911b817

tockn | Cloudbasetockn | Cloudbase

おおおっ!ありがとうございます!!めちゃくちゃわかりやすく勉強になります!!!
実際僕も業務でロック用のテーブルを使用したり、Redisを使った排他ロックを実装することでデッドロック等を回避しています!
続きはshuntagamiさんの記事のDiscussionに記載します!