Ruby
SQL
rubygems

リクエストごとにSQLの実行ログを書き出すgemを作った

More than 1 year has passed since last update.

ピクシブ株式会社 Advent Calendar 2017の4日目です。Pawooの開発を担当している17年度新卒のabcangです。

今回はタイトルにもあるように、リクエストごとにSQLの実行ログを書き出すgemを作った話を書こうと思います。

作った動機

Railsのデバッグログではリクエストごとに実行されたSQLを見ることができ、このログを見るとすぐにN+1問題に気づけるのでとても便利です。このロギング機能はActiveRecordが提供しているので、ActiveRecordを使わずに直接mysql2やpgなどのライブラリを使用している場合はこのようなログは出力されません。

ところで、ISUCONでは毎回Rubyで実装されたアプリケーションが提供されており、そのアプリケーションにはN+1問題がある事が多いです。しかし、Sinatraとmysql2を使用して実装されているため、Railsのデバッグログのようにリクエストごとに実行されたSQLを見ることはできません。つまり何が言いたいかというと、ISUCONの問題を解くときに、リクエストごとに実行されたSQLを見たい!!ということです。

そのようなライブラリがないかと調べてみたところ、mysql2-client-general_logというgemがありました。ただ、ファイルに書き出す機能をアプリケーション側で書く必要があったり、prepared statementに対応していなかったりしたので、forkしてカスタマイズしました。そして、もっと手軽に設定できるようにRack Middlewareにしてみました。

また、ISUCONではPostgreSQLが使われたこともあったのでPostgreSQL版も作ってみました。mysql2版とpg版の使い方はほぼ同じなので、この記事ではmysql2版の紹介をしようと思います。

使い方

今回はSinatraを使った場合の使い方を紹介します。

Gemfileではfork先のgithubのリポジトリを指定します。

Gemfile
gem 'mysql2-client-general_log', github: 'abcang/mysql2-client-general_log', branch: 'rack_middleware'

config.ruは以下のようになります。

config.ru
require_relative './app'

require 'mysql2/client/general_log'

use Mysql2::Client::GeneralLog::Middleware, enabled: true, backtrace: true, path: '/tmp/general_log'
run Sinatra::Application

ミドルウェアに渡すことのできるオプションは「enabled」「backtrace」「path」の3つです。

  • enabled: ロギングの有効/無効
  • backtrace: バックトレース(直前1件のみ)を出力するかどうか
  • path: ログを保存するディレクトリ

この場合ログファイルは/tmp/general_logディレクトリに保存され、バックトレースも出力されることになります。

アプリケーションには特に手を加える必要はありません。

app.rb
require 'sinatra'
require 'mysql2'

helpers do
  def db
    Thread.current[:db] ||= Mysql2::Client.new(config)
  end
end

get '/' do
  db.query("SELECT * FROM users LIMIT 1")
  stmt = db.prepare('SELECT * FROM users WHERE name = ?')
  stmt.execute('test')
  stmt.execute('hoge')
end

この状態でページにアクセスをすると、その日付のファイル名でログファイルが作成されます。

/tmp/general_log/2017-12-04.txt
REQUEST GET /   3
SQL (0000.53ms) SELECT * FROM users LIMIT 1 []  /path/to/app.rb:11:in `block in <main>'
SQL (0000.66ms) SELECT * FROM users WHERE name = ?  ["test"]    /path/to/app.rb:12:in `block in <main>'
SQL (0000.65ms) SELECT * FROM users WHERE name = ?  ["hoge"]    /path/to/app.rb:13:in `block in <main>'

「REQUEST」から始まっている行はリクエストの情報で、HTTPメソッド、リクエストされたパス、実行されたSQLの数がタブ文字区切りで書かれています。
「SQL」から始まっている行は実行されたSQLの情報で、実行時間、実行されたクエリ、prepared statementの引数、バックトレースがタブ文字区切りで書かれています。

実装

このライブラリでは、実行したSQLや実行にかかった時間を取得するためにmysql2のメソッドにモンキーパッチをしています。
例えば、Mysql2::Client#queryは次のようになっています。

module ClientExt
  def query(sql, options = {})
    ret = nil
    time = Benchmark.realtime do
      ret = super
    end
    Mysql2::Client::GeneralLog.general_log.push(sql, [], caller_locations, time)
    ret
  end
end

Benchmark.realtimeを使用して実行時間を計測し、実行したSQLや計測した時間などをログに追加しています。

prepared statementの場合はクエリの実行がStatement#executeで行われるので、Client#prepareの返り値のStatementのインスタンスにsqlを代入して、Statement#executeで代入されたsqlをログに追加するようにしています。

module ClientExt
  def prepare(sql)
    super.tap do |ret|
      ret.sql = sql
    end
  end
end

module StatementExt
  attr_accessor :sql

  def execute(*args)
    ret = nil
    time = Benchmark.realtime do
      ret = super
    end
    Mysql2::Client::GeneralLog.general_log.push(sql, args, caller_locations, time)
    ret
  end
end

実際に使ってみる

ISUCON7の予選で使ってみたときの様子を紹介したいと思います。

出力されたログはそのままでは見づらいので、必要な情報を抽出してソートするのがいいです。まずはSQLの実行回数が多いリクエストを調べてみます。「REQUEST」から始まる行を抽出して、クエリの実行回数の多い順に並べて見てみます。

$ cat /tmp/general_log/2017-12-04.txt | sed '/^REQUEST/!d' | sort -rnk4 | less
REQUEST GET     /fetch  202
REQUEST GET     /fetch  202
REQUEST GET     /fetch  202
REQUEST GET     /fetch  202

/fetchでSQLの実行回数が非常に多いことがわかりますね。実際にファイルを開いて該当箇所を見てみるとSELECT * FROM haveread WHERE user_id = ? AND channel_id = ?SELECT COUNT(*) as cnt FROM message WHERE channel_id = ?が一回のリクエストで何度も呼ばれており、N+1問題が発生していることがすぐにわかりました。

REQUEST GET     /fetch  202
SQL     (0000.39ms)     SET SESSION sql_mode='TRADITIONAL,NO_AUTO_VALUE_ON_ZERO,ONLY_FULL_GROUP_BY'     []      /home/isucon/isubata/webapp/ruby/app.rb:364:in `db'
SQL     (0000.65ms)     SELECT id FROM channel  []      /home/isucon/isubata/webapp/ruby/app.rb:164:in `block in <class:App>'
SQL     (0000.36ms)     SELECT * FROM haveread WHERE user_id = ? AND channel_id = ?     [316, 1]        /home/isucon/isubata/webapp/ruby/app.rb:170:in `block (2 levels) in <class:App>'
SQL     (0014.32ms)     SELECT COUNT(*) as cnt FROM message WHERE channel_id = ?        [1]     /home/isucon/isubata/webapp/ruby/app.rb:176:in `block (2 levels) in <class:App>'
SQL     (0000.24ms)     SELECT * FROM haveread WHERE user_id = ? AND channel_id = ?     [316, 2]        /home/isucon/isubata/webapp/ruby/app.rb:170:in `block (2 levels) in <class:App>'
SQL     (0005.80ms)     SELECT COUNT(*) as cnt FROM message WHERE channel_id = ?        [2]     /home/isucon/isubata/webapp/ruby/app.rb:176:in `block (2 levels) in <class:App>'
SQL     (0003.10ms)     SELECT * FROM haveread WHERE user_id = ? AND channel_id = ?     [316, 3]        /home/isucon/isubata/webapp/ruby/app.rb:170:in `block (2 levels) in <class:App>'
SQL     (0005.39ms)     SELECT COUNT(*) as cnt FROM message WHERE channel_id = ?        [3]     /home/isucon/isubata/webapp/ruby/app.rb:176:in `block (2 levels) in <class:App>'
SQL     (0000.55ms)     SELECT * FROM haveread WHERE user_id = ? AND channel_id = ?     [316, 4]        /home/isucon/isubata/webapp/ruby/app.rb:170:in `block (2 levels) in <class:App>'

また、このログファイルを使って、単純に実行に時間がかかっているクエリを調べることもできます。今度は「SQL」から始まる行を抽出して、実行時間の長い順に並べて見てみます。

$ cat /tmp/general_log/2017-12-04.txt | sed '/^SQL/!d' | sort -rnk2 | less
SQL     (1542.71ms)     SELECT * FROM image WHERE name = ?      ["f0a6a8334e2b9b3f49c39ce35a19a2d20e648aab.png"]        /home/isucon/isubata/webapp/ruby/app.rb:334:in `block in <class:App>'
SQL     (1313.45ms)     SELECT * FROM image WHERE name = ?      ["0614f0cea1b70fb71b4c3b88545a42327157a41e.jpg"]        /home/isucon/isubata/webapp/ruby/app.rb:334:in `block in <class:App>'
SQL     (1308.25ms)     SELECT * FROM image WHERE name = ?      ["b4f9effc4d9e86b58b271f5652b51dd997036d43.png"]        /home/isucon/isubata/webapp/ruby/app.rb:334:in `block in <class:App>'
SQL     (1307.29ms)     SELECT * FROM image WHERE name = ?      ["bde120814720fb3bee14f415b4001305868df456.png"]        /home/isucon/isubata/webapp/ruby/app.rb:334:in `block in <class:App>'
SQL     (1302.51ms)     SELECT * FROM image WHERE name = ?      ["c2d161d74b4e6b6541974a473bf0a9dfb01579dd.png"]        /home/isucon/isubata/webapp/ruby/app.rb:334:in `block in <class:App>'

データベースのスロークエリログを有効にすれば遅いクエリを見れますが、このライブラリを使う場合はスタックトレースの情報も出力できるので、どの行で遅いクエリが実行されているかがすぐにわかります。便利ですね。

おわりに

このライブラリではタブ文字区切りのログとして出力していますが、もっと解析しやすいようなフォーマットで出力してみるのもいいかもしれないですね。

ISUCONで使う時に限らず、リクエストごとにSQLの実行ログをSinatraでも見たいと思ったときはぜひ使ってみてください。

引き続きピクシブ株式会社 Advent Calendar 2017をお楽しみください。