データベースへの接続を伴うGoの並列テスト

こんにちは、MF KESSAIでバックエンドのエンジニアのgarsueです。
2019年もあっという間すぎて戦慄しています。

今回はMF KESSAIでのGoのテストについて、ちょっと工夫してる点を書いてみようと思います。

並列テストによる問題

Go標準のテスト実行コマンドgo testはデフォルトでパッケージごとにテストを並列実行します。

通常は何もしなくてもテストが並列実行されてうれしいのですが、たまに困るケースがあります。

ファイルやDBなどの外部のリソースを触るテストが並列実行されて競合してしまうケースです。

MF KESSAIでは、テスト用のMySQLに接続して実際に読み書きを行うテストを多数書いています。 それらが並列実行されることで、各テストのテストデータ同士が競合してしまうという問題がありました。

開発初期はとりあえずgo test -p 1として並列実行を諦め、問題を回避していました。しかしテストの数が増えるにつれテストの実行時間も伸びていき、開発効率の低下が問題になりました。

いわゆる「スローテスト問題」です。

方針、そして次の問題

並列実行しつつテストデータの競合を起こさないようにするには、それぞれのテストが別のDBを見ていればいい、と考えました。

まず並列度と同じ数のテスト用DBを用意しました。
これはMySQLを複数起動するのではなく、単に複数CREATE DATABASEしてスキーマを分けるだけです。

あとは各テストが他で使っていないDBの接続を使えれば良いのですが、これが少々厄介です。

最初は「要は各DBの接続に対し排他制御できればいいので、channelをセマフォとして使い、未使用のDBがなくなったら待たせればいいか」と雑に作りました。

しかしこれは全く機能しません。Goのテストはパッケージごとにビルドされ、別のプロセスで実行されるためです。

並列度を取り戻す

プロセスを跨いでリソースをロックするメジャーな方法として、ロックファイル1を使った手法があります。

DBに対応するファイルを作り、そのファイルのロックしているプロセスだけがDBを操作できれば良さそうです。

GoでのファイルのロックはGoならわかるシステムプログラミングが参考になります。ぜひ読みましょう。

ロックの関数は以下のような実装です。

// tryLock DBに対応する番号を指定し、そのロックを試みる。戻り値としてロック開放関数を返す。
func tryLock(dbNum int) (func() error, error) {
	filename := filepath.Join(lockFileDir, fmt.Sprintf("test-%d.lock", dbNum))
	fd, err := syscall.Open(filename, syscall.O_CREAT|syscall.O_RDONLY, 0750)
	if err != nil {
		return nil, err
	}
	if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
		if err := syscall.Close(fd); err != nil {
			return nil, err
		}
		return nil, err
	}
	return func() error {
		if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil {
			panic(err)
		}
		return syscall.Close(fd)
	}, nil
}

呼び出し側でロックに失敗したらDB番号を変えてすぐリトライしたいので、syscall.LOCK_NBフラグも指定しています。 これを指定することでロックが競合した場合でもロック解除待ちにならずすぐにエラーとして返ってくるため、すぐにリトライできます。

呼び出し箇所のイメージは以下のような感じです。 あくまでイメージで実用に耐えうるものではないことに注意してください。

func GetDB() (*sql.DB, func() error) {
	for {
		for i := 1; i <= 8; i++ {
			unlock, err := tryLock(i)
			if err != nil {
				continue
			}
			db, err := sql.Open("mysql", インデックスiに対応するDB)
			if err != nil {
				panic(err)
			}
			return db, unlock
		}
	}
}

ロックできたファイルに対応するDBに対する接続を行っています。

呼び出し側ではDB接続と一緒にunlock関数が返ってきます。テストが終わったときにunlock関数を呼び出すことで、他のテストにDBを明け渡すことができます。

リトライ周りもロック取得を試みるファイルが偏らないようにするとかリトライ回数の制限するとかwaitを入れるとか、まだまだ工夫の余地がありますが、大した話ではないので省略させてください🙇。

これで無事にテストを並列実行できるようになり、テスト実行時間も6分から3.5分に改善しました。

まとめ

この記事の内容は今年の春に行った合宿の成果の一部です。

MF KESSAIでは開発効率の改善に力を入れています。特に日々の開発の中ではやり辛い内容の改善も定期的にまとまった時間を取って取り組んでいます。

長く開発を続けていけばその分開発も重くなっていきがちなので今後も定期的な改善を続けていきたいですね。

MF KESSAIでの開発効率向上の取り組みをもっと知りたい方はお気軽に遊びに来てください! (こちらWantedlyなどからお気軽にお問い合わせください)


  1. 最近はロックファイルというとpackage-lock.jsonなどのバージョンロックのためのファイルをイメージするかもしれませんが、ここでは伝統的な意味で使っています。 [return]