goでN+1問題を検出する静的解析ツールを作った

3 min read読了の目安(約2800字

皆さんはSQL呼び出しのパフォーマンスについて考えたことがありますか?
多くの人はあるんじゃないでしょうか。
SQL呼び出しのパフォーマンスが悪いと新たな機能を実装したは良いものの、パフォーマンスの劣化が激しく、チューニングが必要になったり、酷い場合には高負荷になりサービスの安定稼働が難しくなることもあります。そんなSQL呼び出しの中でも有名な問題として、N+1問題があります。

N+1問題とは

JOINなどを用いて1つのクエリで取得できるにも関わらず、1度目のクエリで複数件取得し、その1つずつについてクエリを発行するような問題です。以下の例では雰囲気だけ掴んでもらえればOKです。(こんな風に代入はできません)

//複数件取得
persons, _ := cnn.Query("SELECT name, job_id FROM persons")
for _, person := persons{
  job := cnn.Query("SELECT id, name FROM jobs WHERE id = {person.job_id}")
}

これは以下のようなクエリを発行することでまとめることができます。

persons, job := cnn.Query("SELECT p.name, p.job_id, j.id, j.name FROM persons AS p JOIN jobs as j ON p.job_id = j.id")

今回のように直接SQLを書いていればわかりやすいと思います。しかし、N+1問題はデータ数の少ない開発環境では問題になりにくいことやORMを使用しると発行されるORMが明確ではないことから、人力で完璧に防ぐのは難しいです。
そこで、今回はGo言語におけるN+1問題を検出する静的解析ツールを作成しました。

今回作成したツール

今回 goone という静的解析ツールを作成しました。
ISUCON9の予選問題に使用した結果が以下になります。

>go vet -vettool=(which goone) ./...
# github.com/isucon/isucon9-qualify/webapp/go
./main.go:562:18: this query is called in a loop
./main.go:567:20: this query is called in a loop
./main.go:714:10: this query is called in a loop
./main.go:732:36: this query is called in a loop
./main.go:737:36: this query is called in a loop
./main.go:847:36: this query is called in a loop

*ループの中でクエリを読んでいる箇所を検出するので厳密にはN+1以外のものも検出する可能性はあります。

判定方法

DBに通信を行う型がforループの中にあるかで判定しています。(そのためdb.Ping()なども検知されます)
デフォルトでは以下の型を判定に使用しています。

  • *database/sql.DB
  • *gorm.io/gorm.DB
  • *gopkg.in/gorp.v1.DbMap
  • *gopkg.in/gorp.v2.DbMap
  • *github.com/go-gorp/gorp/v3.DbMap
  • *github.com/jmoiron/sqlx.DB

しかし他のライブラリの型や自作のwrapperでこれらの型を包んでいる場合も後述する設定ファイルを書くことで対応できます。

インストール

> go get github.com/masibw/goone/cmd/goone

実行

bash

> go vet -vettool=`which goone` ./...

fish

> go vet -vettool=(which goone) ./...

設定ファイルの書き方

goone.yml
package:
  - pkgName: 'パッケージ名'
    typeNames:
      - typeName: '型名'

例えば、以下のように記述することでこのprojectのtodoHandlerを判定に使用することができます。

goone.yml
package:
  - pkgName: 'github.com/masibw/go_todo/cmd/go_todo/infrastructure/api/handler'
    typeNames:
      - typeName: '*todoHandler'

設定ファイルを用いる場合は実行する際に
-goone.configPath="$PWD/goone.yml"とコマンドライン引数に絶対パスで設定ファイルを渡す必要があります。

> go vet -vettool=`which goone` -goone.configPath="$PWD/goone.yml" ./...

*private repositoryの型は読み込むことができず、判定に使用できない可能性が高いです

Github Actionsでの設定方法

goone.yml
- name: install goone
    run: go get -u github.com/masibw/goone/cmd/goone
- name: run goone
    run: go vet -vettool=`which goone` ./...

終わりに

gooneを使って少しでも皆さんの開発が楽になれば幸いです。
一通りの機能は有しているgooneですが、まだまだ使い勝手をよくするためにcontribute歓迎しております!是非issueを建てるだけでもお願いします!

https://github.com/masibw/goone